Compare commits

...

41 Commits

Author SHA1 Message Date
Lei Nelissen
287b64c356 chore: release v2.1.0 2023-04-23 23:51:53 +02:00
Lei Nelissen
e116e95236 fix: reign in padding on album view a bit 2023-04-23 23:37:48 +02:00
Lei Nelissen
c8283fc580 feat: finish offsets on new navigation views 2023-04-23 23:31:35 +02:00
Lei Nelissen
81b9ba683a fix: make similar albums translateable 2023-04-23 22:06:39 +02:00
Lei Nelissen
913d185b46 fix: padding in similar scrollwheel 2023-04-23 01:29:59 +02:00
Lei Nelissen
1d97830f83 fix: contentInset doesn't behave on Android 2023-04-23 01:25:43 +02:00
Lei Nelissen
6ccfd19dea fix: linter issues 2023-04-23 01:15:07 +02:00
Lei Nelissen
2e816f4a71 fix: correctly calculate amount of minutes when an hour is present 2023-04-23 01:14:56 +02:00
Lei Nelissen
4ff071d0c8 fix: only show similar albums if there are any 2023-04-23 01:07:56 +02:00
Lei Nelissen
dba87247d8 feat: add extra metadata to the album view 2023-04-23 01:04:30 +02:00
Lei Nelissen
c3c32ae565 feat: show artist in playlist view 2023-04-22 23:41:41 +02:00
Lei Nelissen
1d7db11328 fix: also add navigator padding when playing the first track in a queue 2023-04-22 23:41:25 +02:00
Lei Nelissen
1a5e4aee12 feat: add blurview to headers as well 2023-04-22 23:31:37 +02:00
Lei Nelissen
e2c1c0300f fix: keep album views in search tab when navigating from search results 2023-04-22 22:31:54 +02:00
Lei Nelissen
7601408d49 feat: update tab bars with blurview 2023-04-22 21:58:27 +02:00
Lei Nelissen
4509ef1ec6 fix: remove padding from Modal 2023-04-22 20:52:30 +02:00
Lei Nelissen
dcd3f595ed chore: update changelog 2023-04-12 11:50:35 +02:00
Lei Nelissen
c704a27c09 chore: release v2.0.5 2023-04-12 11:47:05 +02:00
Lei Nelissen
67499b1103 fix: crash when fast-image fails to load an image
There is some weird behaviour in react-native-fast-image
which causes it to crash whenever the onError or
onLoadEnd prop is set. The issue is documented in
https://github.com/DylanVann/react-native-fast-image/issues/504.
2023-04-12 11:31:33 +02:00
Lei Nelissen
5bbc3b8588 chore: update badges 2023-04-11 22:43:01 +02:00
Lei Nelissen
f71c46eacc chore: add links to README badges 2023-04-11 22:33:32 +02:00
Lei Nelissen
e4b75ce0a2 chore: fix README whitespace 2023-04-11 22:29:39 +02:00
Lei Nelissen
d45195dc6b chore: update README 2023-04-11 22:29:01 +02:00
Lei Nelissen
4419b387e9 chore: iOS build for v2.0.4 2023-04-11 21:12:41 +02:00
Lei Nelissen
63a755a231 chore: fix changelog 2023-04-11 18:46:21 +02:00
Lei Nelissen
1d7b3d7e6c chore: generate changelog 2023-04-11 18:45:36 +02:00
Lei Nelissen
531c6f708d chore: release v2.0.4 2023-04-11 18:39:37 +02:00
Lei Nelissen
56647cd7ab chore: add Android screenshots 2023-04-11 18:34:12 +02:00
Lei Nelissen
1648389ccc fix: disable BlurView on Android as it crashes the app 2023-04-11 18:27:55 +02:00
Lei Nelissen
a532154ce0 fix: use debug signing config when not having a keystore 2023-04-11 10:48:24 +02:00
Lei Nelissen
74d82eb77a fix: only set signingConfig to release when a keystore is available 2023-04-10 17:42:51 +02:00
Lei Nelissen
a8c0003fc1 fix: linter issue 2023-04-10 17:15:32 +02:00
Lei Nelissen
ba805e061e feat: Add base Android content for F-Droid and Play Store 2023-04-10 17:10:53 +02:00
Lei Nelissen
cc14373575 feat: setup Fastlane for Google Play Store 2023-04-10 17:10:12 +02:00
Lei Nelissen
943815e4a6 chore: update fastlane 2023-04-10 17:09:24 +02:00
Lei Nelissen
2f45f868c8 fix: linting issue 2023-03-08 10:09:59 +01:00
Lei Nelissen
0a0c78f3d5 feat: add fallback images when album cover isn't available 2023-03-07 23:03:09 +01:00
Lei Nelissen
40ecfb08fb chore: release v2.0.3 2023-02-28 10:25:22 +01:00
Lei Nelissen
099bbebe38 fix: improve album list scrolling performance 2023-02-28 10:08:24 +01:00
Lei Nelissen
a34b6c5114 fix: prevent track indexes from overflowing 2023-02-10 17:08:12 +01:00
Lei Nelissen
7353b04dd1 chore: add CHANGELOG 2023-01-10 23:25:38 +01:00
68 changed files with 2406 additions and 1567 deletions

View File

@@ -52,6 +52,12 @@ module.exports = {
'react/prop-types': 'off',
'@typescript-eslint/no-unused-vars': [
'error'
],
'react/jsx-no-literals': [
'error',
{
ignoreProps: true
}
]
},
settings: {

3
.gitignore vendored
View File

@@ -72,4 +72,5 @@ certificates/
sentry.properties
screenshots
fastlane/Preview.html
fastlane/Preview.html
fastlane/play-store-credentials.json

189
CHANGELOG.md Normal file
View File

@@ -0,0 +1,189 @@
## [2.0.5](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.4...v2.0.5) (2023-04-12)
### Bug Fixes
* crash when fast-image fails to load an image ([67499b1](https://github.com/leinelissen/jellyfin-audio-player/commit/67499b11037779bf33bb557fff69114cd519c78e))
## [2.0.4](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.3...v2.0.4) (2023-04-11)
### Bug Fixes
* disable BlurView on Android as it crashes the app ([1648389](https://github.com/leinelissen/jellyfin-audio-player/commit/1648389ccce088e6836bcad31bd5c3b7cb996a78))
* linter issue ([a8c0003](https://github.com/leinelissen/jellyfin-audio-player/commit/a8c0003fc13cb7d4778f65e8702b1c3c5fd1cc59))
* linting issue ([2f45f86](https://github.com/leinelissen/jellyfin-audio-player/commit/2f45f868c8cc8a7f4308282b672d1d487f480c0a))
* only set signingConfig to release when a keystore is available ([74d82eb](https://github.com/leinelissen/jellyfin-audio-player/commit/74d82eb77a412ba84d0820abbad84ac304c62611))
* use debug signing config when not having a keystore ([a532154](https://github.com/leinelissen/jellyfin-audio-player/commit/a532154ce023ba2eecbbc3c8d7bbe08bcca0cd57))
### Features
* Add base Android content for F-Droid and Play Store ([ba805e0](https://github.com/leinelissen/jellyfin-audio-player/commit/ba805e061e56d719b18cfd8a6bafccf9174110b8))
* add fallback images when album cover isn't available ([0a0c78f](https://github.com/leinelissen/jellyfin-audio-player/commit/0a0c78f3d592e0d92a6bb3fd605810e0af1441bb))
* setup Fastlane for Google Play Store ([cc14373](https://github.com/leinelissen/jellyfin-audio-player/commit/cc14373575a844458737ac6f0a6e8d8ea783ce75))
## [2.0.3](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.2...v2.0.3) (2023-02-28)
### Bug Fixes
* improve album list scrolling performance ([099bbeb](https://github.com/leinelissen/jellyfin-audio-player/commit/099bbebe38942f2c72782e6c34ad3cea0876b291))
* prevent track indexes from overflowing ([a34b6c5](https://github.com/leinelissen/jellyfin-audio-player/commit/a34b6c51141cb3cd6058733ccb3323d75f40bbd5))
## [2.0.2](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.1...v2.0.2) (2023-01-10)
### Bug Fixes
* allow user-supplied CA certificates on Android ([ccfa68c](https://github.com/leinelissen/jellyfin-audio-player/commit/ccfa68c53045dfc1a7071d282da477a3ec6c9f60)), closes [#110](https://github.com/leinelissen/jellyfin-audio-player/issues/110)
* font colour for dark mode on input ([6885ae6](https://github.com/leinelissen/jellyfin-audio-player/commit/6885ae6216119155e86146c39ca502fa8a18183f))
## [2.0.1](https://github.com/leinelissen/jellyfin-audio-player/compare/v2.0.0...v2.0.1) (2022-11-28)
### Bug Fixes
* android and ios builds ([845b379](https://github.com/leinelissen/jellyfin-audio-player/commit/845b379e0983f012a2eda65350748307d4b74dca))
* Blur obscuring buttons on Android ([e0493c4](https://github.com/leinelissen/jellyfin-audio-player/commit/e0493c4a55157abff8fbb1eddeab331ac856feff))
* BlurView on Android ([b2bd211](https://github.com/leinelissen/jellyfin-audio-player/commit/b2bd211758f13a789294b98b5a129b07519ec3f8))
* Depcreated createReducer calls ([d072292](https://github.com/leinelissen/jellyfin-audio-player/commit/d072292008929aa53738bf69e91eb6925686687a))
* Ensure proper spacing in downloads screen ([cd10ddd](https://github.com/leinelissen/jellyfin-audio-player/commit/cd10ddd260c0a8d2b967248fe6dc0aeb09983e32))
* Input icon alignment on Android ([0ffc5b6](https://github.com/leinelissen/jellyfin-audio-player/commit/0ffc5b64894099d761451483fa7cd35e76446054))
* jumpy progress animations ([9807b0e](https://github.com/leinelissen/jellyfin-audio-player/commit/9807b0e920379ea646f6940d814cd2ed239a2054))
* margin on connection notice ([68de2ca](https://github.com/leinelissen/jellyfin-audio-player/commit/68de2ca80e3ba55489a34d9464af4f891093ffe6))
* Only show single line for tracks without artists or albums ([7ed389e](https://github.com/leinelissen/jellyfin-audio-player/commit/7ed389ead647c299be229b15fab47a8cc97be8c7))
* Remove any restrictions on bitrate and samplerate ([b41031e](https://github.com/leinelissen/jellyfin-audio-player/commit/b41031eeac9b5a9976b10a93d620bfd108c8d97c))
* Rename Jellyfin Audio Player to Fintunes in translation files ([0a7f6ab](https://github.com/leinelissen/jellyfin-audio-player/commit/0a7f6abf3e6af6f5684b63b0005868f250e687a2))
* screenshotting logic ([d4570b6](https://github.com/leinelissen/jellyfin-audio-player/commit/d4570b60aecdeae4ce8dedb63c511f359e9760cb))
* switch album id to demo instance ([9a1defb](https://github.com/leinelissen/jellyfin-audio-player/commit/9a1defbeef61a79addec4f71e0363e0b0271a111))
* use entire input box as touch area for focus ([87f992d](https://github.com/leinelissen/jellyfin-audio-player/commit/87f992d912f0846773a85d67b6f67a90fe1ac293))
### Features
* Save App metadata in the repo ([9c8e474](https://github.com/leinelissen/jellyfin-audio-player/commit/9c8e474d51402f5e6fa24ab683cc86aa3e131552))
## [1.2.7](https://github.com/leinelissen/jellyfin-audio-player/compare/v1.2.6...v1.2.7) (2022-08-13)
### Features
* Allow FLAC playback ([5b54760](https://github.com/leinelissen/jellyfin-audio-player/commit/5b54760e4ee6620062ce0cc4c79daf81753f00ae))
## [1.2.6](https://github.com/leinelissen/jellyfin-audio-player/compare/v1.2.6-beta.1...v1.2.6) (2022-08-09)
### Bug Fixes
* Peer dependency chain ([63bbbf2](https://github.com/leinelissen/jellyfin-audio-player/commit/63bbbf2719aa5d296a6ec99774f9bf1a1aa068d0))
* Remove unused imports ([c7f0d46](https://github.com/leinelissen/jellyfin-audio-player/commit/c7f0d46b410825765ab5d074469ec23d32ffd45d))
## [1.2.6-beta.1](https://github.com/leinelissen/jellyfin-audio-player/compare/v1.2.5...v1.2.6-beta.1) (2022-06-09)
## [1.2.5](https://github.com/leinelissen/jellyfin-audio-player/compare/v1.2.4...v1.2.5) (2022-05-18)
### Bug Fixes
* Only pull Exoplayer from jcenter ([89d2984](https://github.com/leinelissen/jellyfin-audio-player/commit/89d29844b9821e1a42b3b60c43dc4c3078231d56))
### Features
* Apply default text styles to ReText ([37ead0e](https://github.com/leinelissen/jellyfin-audio-player/commit/37ead0ec989a8b714fde1bcb6dd36e568c6e7e8c))
* Create new progress slider from scratch ([6efc8e7](https://github.com/leinelissen/jellyfin-audio-player/commit/6efc8e757c10c66019914f7561d075c3ecaf2f69))
* Implement colored blur backgrounds ([f48d248](https://github.com/leinelissen/jellyfin-audio-player/commit/f48d2481443850888a0bd1a1cf2604420e633b26))
* Tweak progress bar gestures ([b0961d3](https://github.com/leinelissen/jellyfin-audio-player/commit/b0961d3263d5f4ef3978fde748a6a277059cb0cb))
## [1.2.4](https://github.com/leinelissen/jellyfin-audio-player/compare/v1.2.3...v1.2.4) (2022-05-04)
### Bug Fixes
* No interaction on Android webview ([#59](https://github.com/leinelissen/jellyfin-audio-player/issues/59)) ([91eaa1d](https://github.com/leinelissen/jellyfin-audio-player/commit/91eaa1d864f66e1a6597809bd46c17907acc99ee))
## [1.2.3](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.2.3...v1.2.3) (2022-01-16)
## [0.2.3](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.2.2...v0.2.3) (2022-01-15)
## [0.2.2](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.2.1...v0.2.2) (2022-01-03)
## [0.2.1](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.2.0...v0.2.1) (2022-01-02)
# [0.2.0](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.1.7...v0.2.0) (2022-01-02)
## [0.1.7](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.1.6...v0.1.7) (2021-10-25)
## [0.1.6](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.1.5...v0.1.6) (2021-04-25)
## [0.1.5](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.1.4...v0.1.5) (2021-04-24)
## [0.1.4](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.1.3...v0.1.4) (2021-04-03)
## [0.1.3](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.1.2...v0.1.3) (2021-03-21)
## [0.1.2](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.1.1...v0.1.2) (2021-03-09)
## [0.1.1](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.1.0...v0.1.1) (2021-02-13)
# [0.1.0](https://github.com/leinelissen/jellyfin-audio-player/compare/v1.0.0-beta3...v0.1.0) (2021-02-07)
# [1.0.0-beta3](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.0.1-alpha1...v1.0.0-beta3) (2020-08-25)
## [0.0.1-alpha1](https://github.com/leinelissen/jellyfin-audio-player/compare/v0.0.1-alpha0...v0.0.1-alpha1) (2020-07-26)
## 0.0.1-alpha0 (2020-07-10)

View File

@@ -1,7 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.5)
CFPropertyList (3.0.6)
rexml
activesupport (6.1.6)
concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -9,7 +9,7 @@ GEM
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.1)
addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
@@ -17,16 +17,16 @@ GEM
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.660.0)
aws-sdk-core (3.167.0)
aws-partitions (1.743.0)
aws-sdk-core (3.171.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.59.0)
aws-sdk-kms (1.63.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.117.1)
aws-sdk-s3 (1.120.1)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
@@ -86,8 +86,8 @@ GEM
escape (0.0.4)
ethon (0.15.0)
ffi (>= 1.15.0)
excon (0.94.0)
faraday (1.10.2)
excon (0.99.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@@ -116,7 +116,7 @@ GEM
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.6)
fastlane (2.211.0)
fastlane (2.212.1)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@@ -163,9 +163,9 @@ GEM
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.31.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-core (0.9.1)
google-apis-androidpublisher_v3 (0.38.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@@ -174,10 +174,10 @@ GEM
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.16.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-playcustomapp_v1 (0.12.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
@@ -185,7 +185,7 @@ GEM
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
@@ -194,7 +194,7 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.3.0)
googleauth (1.5.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
@@ -207,11 +207,11 @@ GEM
httpclient (2.8.3)
i18n (1.10.0)
concurrent-ruby (~> 1.0)
jmespath (1.6.1)
json (2.6.2)
jwt (2.5.0)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.0)
memoist (0.16.2)
mini_magick (4.11.0)
mini_magick (4.12.0)
mini_mime (1.1.2)
minitest (5.15.0)
molinillo (0.8.0)
@@ -223,7 +223,7 @@ GEM
netrc (0.11.0)
optparse (0.1.1)
os (1.1.4)
plist (3.6.0)
plist (3.7.0)
public_suffix (4.0.7)
rake (13.0.6)
representable (3.2.0)
@@ -242,7 +242,7 @@ GEM
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.8)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
@@ -262,7 +262,7 @@ GEM
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.7.0)
webrick (1.8.1)
word_wrap (1.0.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)

View File

@@ -1,34 +1,33 @@
# Jellyfin Audio Player
![Fastlane](https://github.com/leinelissen/jellyfin-audio-player/workflows/Fastlane/badge.svg)
![MIT License](https://img.shields.io/github/license/leinelissen/jellyfin-audio-player)
<div align="center">
This is a [React Native](https://reactnative.dev/)-based audio streaming app for [Jellyfin](https://jellyfin.org/). Jellyfin is a community-based piece of software that allows you to stream your media library over the internet. By means of React Native, Jellyfin Audio Player allows you to stream your Jellyfin Music library, with full support for background audio and casting (ie. Airplay and Chromecast).
![Fintunes](./docs/images/fintunes-banner.png)
## ❗Now open for beta testing on iOS
Please follow this link to enroll for the TestFlight beta release of Jellyfin Audio Player: https://testflight.apple.com/join/cf2AMDpx.
[![Get Fintunes on the App Store](./docs/images/app-store.svg)](https://apple.co/3MFYIJH)
[![Latest GitHub release](https://img.shields.io/github/v/release/leinelissen/jellyfin-audio-player?label=latest%20release)](https://github.com/leinelissen/jellyfin-audio-player/releases/latest)
[![Latest App Store release](https://img.shields.io/itunes/v/1527732194?label=app%20store)](https://apple.co/3MFYIJH)
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/leinelissen/jellyfin-audio-player/fastlane.yml)
[![MIT License](https://img.shields.io/github/license/leinelissen/jellyfin-audio-player)](./LICENSE.md)
[![Discord](https://img.shields.io/discord/1080781083882307594)](https://discord.gg/xyd97GpC4Q)
<br />
With Fintunes, you can stream your Jellyfin audio library in full quality. List or search through your favourite tracks, albums and playlists. Stream to speakers and TVs wirelessly via either **AirPlay** or **Chromecast**. **Download** your favourite tracks and play them back, even when you are offline. Available in both a **dark and a light mode**, based on your operating system settings.
<br />
<br />
</div>
|![](./docs/images/now-playing.png)|![](./docs/images/recent-albums.png)|![](./docs/images/album-list.png)
|-|-|-|
|![](./docs/images/album.png)|![](./docs/images/downloads.png)|![](./docs/images/search.png)
## Features
* Sorting by recent albums
* Browsing through all available albums
* Searching based on album and artist names
* Queuing tracks and albums
* AirPlay and Chromecast support
* Background audio
* Native Dark Mode
* Downloading music for offline playback
* Searching based on track names
* Looping and shuffling queue
## Getting Started
This piece of software is in beta. I am working on getting this app in ~~TestFlight and~~ Google Play Developer Console, but this is contingent on keys being available. In the meantime, IPAs and APK are intermittenly released on the [Releases page](https://github.com/leinelissen/jellyfin-audio-player/releases). Alternatively, you can build this app from source using the build instructions.
Fintunes is available on the [App Store](https://apple.co/3MFYIJH). It is in the process of being released on both Google Play and F-Droid. You can also grab either an APK or IPA from the [release page](https://github.com/leinelissen/jellyfin-audio-player/releases/latest). If you are feeling frisky, you can compile Fintunes from source using the settings below.
### Using the app
You will need to setup your Jellyfin account for the application to be able to pull in all your audio. To do this, go over to the "Settings" tab and click the "Set Jellyfin server"-button. A modal will pop up in which you will enter your Jellyfin server url, after which you enter your credentials in the provided browser view. When the app detects your credentials, they will automatically be remembered for the future.
You will need to setup your Jellyfin account for the application to be able to pull in all your audio. To do this, go over to the "Settings" tab and click the "Set Jellyfin server"-button. A modal will pop up in which you will enter your Jellyfin server URL, after which you enter your credentials in the provided browser view. When the app detects your credentials, they will automatically be remembered for the future.
## Building from source
### Prerequisites

View File

@@ -138,8 +138,8 @@ android {
applicationId "nl.moeilijkedingen.jellyfinaudioplayer"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 14
versionName "2.0.2"
versionCode 18
versionName "2.1.0"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
if (isNewArchitectureEnabled()) {
@@ -217,6 +217,14 @@ android {
keyAlias 'androiddebugkey'
keyPassword 'android'
}
release {
if (project.hasProperty('FINTUNES_UPLOAD_STORE_FILE')) {
storeFile file(FINTUNES_UPLOAD_STORE_FILE)
storePassword FINTUNES_UPLOAD_STORE_PASSWORD
keyAlias FINTUNES_UPLOAD_KEY_ALIAS
keyPassword FINTUNES_UPLOAD_KEY_PASSWORD
}
}
}
buildTypes {
debug {
@@ -225,7 +233,11 @@ android {
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
if (project.hasProperty('FINTUNES_UPLOAD_STORE_FILE')) {
signingConfig signingConfigs.release
} else {
signingConfig signingConfigs.debug
}
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}

46
docs/images/app-store.svg Executable file
View File

@@ -0,0 +1,46 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

View File

@@ -1,4 +1,5 @@
package_name("nl.moeilijkedingen.jellyfinaudioplayer")
app_identifier("nl.moeilijkedingen.jellyfinaudioplayer")
apple_id("lei@moeilijkedingen.nl")
team_id("238P3C58WC")
team_id("238P3C58WC")
json_key_file("./fastlane/play-store-credentials.json")

View File

@@ -42,9 +42,15 @@ platform :ios do
workspace: "ios/Fintunes.xcworkspace",
export_method: "app-store",
)
upload_to_testflight(
submit_for_review: true,
automatic_release: true,
upload_to_testflight()
end
lane :build do
build_app(
scheme: "Fintunes",
output_directory: "build",
workspace: "ios/Fintunes.xcworkspace",
export_method: "app-store",
)
end
@@ -104,8 +110,18 @@ platform :android do
gradle_file: "android/app/build.gradle"
)
gradle(
task: "assembleRelease",
task: "assemble",
build_type: "Release",
project_dir: "android"
)
end
lane :release do
gradle(
task: "bundle",
build_type: 'Release',
project_dir: "android"
)
upload_to_play_store
end
end

View File

@@ -31,6 +31,14 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
### ios build
```sh
[bundle exec] fastlane ios build
```
### ios screenshots
```sh
@@ -52,6 +60,22 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
Generate beta build
### android release
```sh
[bundle exec] fastlane android release
```
### android build
```sh
[bundle exec] fastlane android build
```
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.

View File

@@ -0,0 +1 @@
Fintunes is a streaming audio player for the Jellyfin media system. It features a gorgeous interface that allows you to play your favourite music with ease. You can search your entire library for any track, or just take it easy with a playlist that you've created earlier in Jellyfin. All tracks are streamed directly at the highest quality from your Jellyfin library. Streaming not always an option? Any track in your Jellyfin library can be downloaded and played offline.

View File

@@ -0,0 +1 @@
Streaming audio player for Jellyfin

View File

@@ -0,0 +1 @@
Fintunes

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View File

@@ -606,7 +606,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51;
CURRENT_PROJECT_VERSION = 64;
DEVELOPMENT_TEAM = 238P3C58WC;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -643,7 +643,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51;
CURRENT_PROJECT_VERSION = 64;
DEVELOPMENT_TEAM = 238P3C58WC;
INFOPLIST_FILE = Fintunes/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -799,7 +799,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 51;
CURRENT_PROJECT_VERSION = 64;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 238P3C58WC;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -832,7 +832,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 51;
CURRENT_PROJECT_VERSION = 64;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 238P3C58WC;
GCC_C_LANGUAGE_STANDARD = gnu11;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.0.2</string>
<string>2.1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>51</string>
<string>64</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>

View File

@@ -2,14 +2,14 @@ PODS:
- boost (1.76.0)
- CocoaAsyncSocket (7.6.5)
- DoubleConversion (1.1.6)
- FBLazyVector (0.70.5)
- FBReactNativeSpec (0.70.5):
- FBLazyVector (0.70.8)
- FBReactNativeSpec (0.70.8):
- RCT-Folly (= 2021.07.22.00)
- RCTRequired (= 0.70.5)
- RCTTypeSafety (= 0.70.5)
- React-Core (= 0.70.5)
- React-jsi (= 0.70.5)
- ReactCommon/turbomodule/core (= 0.70.5)
- RCTRequired (= 0.70.8)
- RCTTypeSafety (= 0.70.8)
- React-Core (= 0.70.8)
- React-jsi (= 0.70.8)
- ReactCommon/turbomodule/core (= 0.70.8)
- Flipper (0.164.0):
- Flipper-Folly (~> 2.6)
- Flipper-Boost-iOSX (1.76.0.1.11)
@@ -72,7 +72,7 @@ PODS:
- FlipperKit/FlipperKitNetworkPlugin
- fmt (6.2.1)
- glog (0.3.5)
- hermes-engine (0.70.5)
- hermes-engine (0.70.8)
- libevent (2.1.12)
- libwebp (1.2.4):
- libwebp/demux (= 1.2.4)
@@ -101,216 +101,216 @@ PODS:
- fmt (~> 6.2.1)
- glog
- libevent
- RCTRequired (0.70.5)
- RCTTypeSafety (0.70.5):
- FBLazyVector (= 0.70.5)
- RCTRequired (= 0.70.5)
- React-Core (= 0.70.5)
- React (0.70.5):
- React-Core (= 0.70.5)
- React-Core/DevSupport (= 0.70.5)
- React-Core/RCTWebSocket (= 0.70.5)
- React-RCTActionSheet (= 0.70.5)
- React-RCTAnimation (= 0.70.5)
- React-RCTBlob (= 0.70.5)
- React-RCTImage (= 0.70.5)
- React-RCTLinking (= 0.70.5)
- React-RCTNetwork (= 0.70.5)
- React-RCTSettings (= 0.70.5)
- React-RCTText (= 0.70.5)
- React-RCTVibration (= 0.70.5)
- RCTRequired (0.70.8)
- RCTTypeSafety (0.70.8):
- FBLazyVector (= 0.70.8)
- RCTRequired (= 0.70.8)
- React-Core (= 0.70.8)
- React (0.70.8):
- React-Core (= 0.70.8)
- React-Core/DevSupport (= 0.70.8)
- React-Core/RCTWebSocket (= 0.70.8)
- React-RCTActionSheet (= 0.70.8)
- React-RCTAnimation (= 0.70.8)
- React-RCTBlob (= 0.70.8)
- React-RCTImage (= 0.70.8)
- React-RCTLinking (= 0.70.8)
- React-RCTNetwork (= 0.70.8)
- React-RCTSettings (= 0.70.8)
- React-RCTText (= 0.70.8)
- React-RCTVibration (= 0.70.8)
- react-airplay (1.2.0):
- React-Core
- React-bridging (0.70.5):
- React-bridging (0.70.8):
- RCT-Folly (= 2021.07.22.00)
- React-jsi (= 0.70.5)
- React-callinvoker (0.70.5)
- React-Codegen (0.70.5):
- FBReactNativeSpec (= 0.70.5)
- React-jsi (= 0.70.8)
- React-callinvoker (0.70.8)
- React-Codegen (0.70.8):
- FBReactNativeSpec (= 0.70.8)
- RCT-Folly (= 2021.07.22.00)
- RCTRequired (= 0.70.5)
- RCTTypeSafety (= 0.70.5)
- React-Core (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- ReactCommon/turbomodule/core (= 0.70.5)
- React-Core (0.70.5):
- RCTRequired (= 0.70.8)
- RCTTypeSafety (= 0.70.8)
- React-Core (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- ReactCommon/turbomodule/core (= 0.70.8)
- React-Core (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default (= 0.70.5)
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-Core/Default (= 0.70.8)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/CoreModulesHeaders (0.70.5):
- React-Core/CoreModulesHeaders (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/Default (0.70.5):
- React-Core/Default (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/DevSupport (0.70.5):
- React-Core/DevSupport (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default (= 0.70.5)
- React-Core/RCTWebSocket (= 0.70.5)
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-jsinspector (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-Core/Default (= 0.70.8)
- React-Core/RCTWebSocket (= 0.70.8)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-jsinspector (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/RCTActionSheetHeaders (0.70.5):
- React-Core/RCTActionSheetHeaders (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/RCTAnimationHeaders (0.70.5):
- React-Core/RCTAnimationHeaders (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/RCTBlobHeaders (0.70.5):
- React-Core/RCTBlobHeaders (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/RCTImageHeaders (0.70.5):
- React-Core/RCTImageHeaders (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/RCTLinkingHeaders (0.70.5):
- React-Core/RCTLinkingHeaders (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/RCTNetworkHeaders (0.70.5):
- React-Core/RCTNetworkHeaders (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/RCTSettingsHeaders (0.70.5):
- React-Core/RCTSettingsHeaders (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/RCTTextHeaders (0.70.5):
- React-Core/RCTTextHeaders (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/RCTVibrationHeaders (0.70.5):
- React-Core/RCTVibrationHeaders (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-Core/RCTWebSocket (0.70.5):
- React-Core/RCTWebSocket (0.70.8):
- glog
- RCT-Folly (= 2021.07.22.00)
- React-Core/Default (= 0.70.5)
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-Core/Default (= 0.70.8)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-perflogger (= 0.70.8)
- Yoga
- React-CoreModules (0.70.5):
- React-CoreModules (0.70.8):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.70.5)
- React-Codegen (= 0.70.5)
- React-Core/CoreModulesHeaders (= 0.70.5)
- React-jsi (= 0.70.5)
- React-RCTImage (= 0.70.5)
- ReactCommon/turbomodule/core (= 0.70.5)
- React-cxxreact (0.70.5):
- RCTTypeSafety (= 0.70.8)
- React-Codegen (= 0.70.8)
- React-Core/CoreModulesHeaders (= 0.70.8)
- React-jsi (= 0.70.8)
- React-RCTImage (= 0.70.8)
- ReactCommon/turbomodule/core (= 0.70.8)
- React-cxxreact (0.70.8):
- boost (= 1.76.0)
- DoubleConversion
- glog
- RCT-Folly (= 2021.07.22.00)
- React-callinvoker (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsinspector (= 0.70.5)
- React-logger (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-runtimeexecutor (= 0.70.5)
- React-hermes (0.70.5):
- React-callinvoker (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsinspector (= 0.70.8)
- React-logger (= 0.70.8)
- React-perflogger (= 0.70.8)
- React-runtimeexecutor (= 0.70.8)
- React-hermes (0.70.8):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2021.07.22.00)
- RCT-Folly/Futures (= 2021.07.22.00)
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-jsiexecutor (= 0.70.5)
- React-jsinspector (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-jsi (0.70.5):
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-jsiexecutor (= 0.70.8)
- React-jsinspector (= 0.70.8)
- React-perflogger (= 0.70.8)
- React-jsi (0.70.8):
- boost (= 1.76.0)
- DoubleConversion
- glog
- RCT-Folly (= 2021.07.22.00)
- React-jsi/Default (= 0.70.5)
- React-jsi/Default (0.70.5):
- React-jsi/Default (= 0.70.8)
- React-jsi/Default (0.70.8):
- boost (= 1.76.0)
- DoubleConversion
- glog
- RCT-Folly (= 2021.07.22.00)
- React-jsiexecutor (0.70.5):
- React-jsiexecutor (0.70.8):
- DoubleConversion
- glog
- RCT-Folly (= 2021.07.22.00)
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-jsinspector (0.70.5)
- React-logger (0.70.5):
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-perflogger (= 0.70.8)
- React-jsinspector (0.70.8)
- React-logger (0.70.8):
- glog
- react-native-blur (4.3.0):
- React-Core
@@ -358,72 +358,72 @@ PODS:
- SwiftAudioEx (= 0.15.3)
- react-native-webview (11.23.1):
- React-Core
- React-perflogger (0.70.5)
- React-RCTActionSheet (0.70.5):
- React-Core/RCTActionSheetHeaders (= 0.70.5)
- React-RCTAnimation (0.70.5):
- React-perflogger (0.70.8)
- React-RCTActionSheet (0.70.8):
- React-Core/RCTActionSheetHeaders (= 0.70.8)
- React-RCTAnimation (0.70.8):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.70.5)
- React-Codegen (= 0.70.5)
- React-Core/RCTAnimationHeaders (= 0.70.5)
- React-jsi (= 0.70.5)
- ReactCommon/turbomodule/core (= 0.70.5)
- React-RCTBlob (0.70.5):
- RCTTypeSafety (= 0.70.8)
- React-Codegen (= 0.70.8)
- React-Core/RCTAnimationHeaders (= 0.70.8)
- React-jsi (= 0.70.8)
- ReactCommon/turbomodule/core (= 0.70.8)
- React-RCTBlob (0.70.8):
- RCT-Folly (= 2021.07.22.00)
- React-Codegen (= 0.70.5)
- React-Core/RCTBlobHeaders (= 0.70.5)
- React-Core/RCTWebSocket (= 0.70.5)
- React-jsi (= 0.70.5)
- React-RCTNetwork (= 0.70.5)
- ReactCommon/turbomodule/core (= 0.70.5)
- React-RCTImage (0.70.5):
- React-Codegen (= 0.70.8)
- React-Core/RCTBlobHeaders (= 0.70.8)
- React-Core/RCTWebSocket (= 0.70.8)
- React-jsi (= 0.70.8)
- React-RCTNetwork (= 0.70.8)
- ReactCommon/turbomodule/core (= 0.70.8)
- React-RCTImage (0.70.8):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.70.5)
- React-Codegen (= 0.70.5)
- React-Core/RCTImageHeaders (= 0.70.5)
- React-jsi (= 0.70.5)
- React-RCTNetwork (= 0.70.5)
- ReactCommon/turbomodule/core (= 0.70.5)
- React-RCTLinking (0.70.5):
- React-Codegen (= 0.70.5)
- React-Core/RCTLinkingHeaders (= 0.70.5)
- React-jsi (= 0.70.5)
- ReactCommon/turbomodule/core (= 0.70.5)
- React-RCTNetwork (0.70.5):
- RCTTypeSafety (= 0.70.8)
- React-Codegen (= 0.70.8)
- React-Core/RCTImageHeaders (= 0.70.8)
- React-jsi (= 0.70.8)
- React-RCTNetwork (= 0.70.8)
- ReactCommon/turbomodule/core (= 0.70.8)
- React-RCTLinking (0.70.8):
- React-Codegen (= 0.70.8)
- React-Core/RCTLinkingHeaders (= 0.70.8)
- React-jsi (= 0.70.8)
- ReactCommon/turbomodule/core (= 0.70.8)
- React-RCTNetwork (0.70.8):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.70.5)
- React-Codegen (= 0.70.5)
- React-Core/RCTNetworkHeaders (= 0.70.5)
- React-jsi (= 0.70.5)
- ReactCommon/turbomodule/core (= 0.70.5)
- React-RCTSettings (0.70.5):
- RCTTypeSafety (= 0.70.8)
- React-Codegen (= 0.70.8)
- React-Core/RCTNetworkHeaders (= 0.70.8)
- React-jsi (= 0.70.8)
- ReactCommon/turbomodule/core (= 0.70.8)
- React-RCTSettings (0.70.8):
- RCT-Folly (= 2021.07.22.00)
- RCTTypeSafety (= 0.70.5)
- React-Codegen (= 0.70.5)
- React-Core/RCTSettingsHeaders (= 0.70.5)
- React-jsi (= 0.70.5)
- ReactCommon/turbomodule/core (= 0.70.5)
- React-RCTText (0.70.5):
- React-Core/RCTTextHeaders (= 0.70.5)
- React-RCTVibration (0.70.5):
- RCTTypeSafety (= 0.70.8)
- React-Codegen (= 0.70.8)
- React-Core/RCTSettingsHeaders (= 0.70.8)
- React-jsi (= 0.70.8)
- ReactCommon/turbomodule/core (= 0.70.8)
- React-RCTText (0.70.8):
- React-Core/RCTTextHeaders (= 0.70.8)
- React-RCTVibration (0.70.8):
- RCT-Folly (= 2021.07.22.00)
- React-Codegen (= 0.70.5)
- React-Core/RCTVibrationHeaders (= 0.70.5)
- React-jsi (= 0.70.5)
- ReactCommon/turbomodule/core (= 0.70.5)
- React-runtimeexecutor (0.70.5):
- React-jsi (= 0.70.5)
- ReactCommon/turbomodule/core (0.70.5):
- React-Codegen (= 0.70.8)
- React-Core/RCTVibrationHeaders (= 0.70.8)
- React-jsi (= 0.70.8)
- ReactCommon/turbomodule/core (= 0.70.8)
- React-runtimeexecutor (0.70.8):
- React-jsi (= 0.70.8)
- ReactCommon/turbomodule/core (0.70.8):
- DoubleConversion
- glog
- RCT-Folly (= 2021.07.22.00)
- React-bridging (= 0.70.5)
- React-callinvoker (= 0.70.5)
- React-Core (= 0.70.5)
- React-cxxreact (= 0.70.5)
- React-jsi (= 0.70.5)
- React-logger (= 0.70.5)
- React-perflogger (= 0.70.5)
- React-bridging (= 0.70.8)
- React-callinvoker (= 0.70.8)
- React-Core (= 0.70.8)
- React-cxxreact (= 0.70.8)
- React-jsi (= 0.70.8)
- React-logger (= 0.70.8)
- React-perflogger (= 0.70.8)
- RNCAsyncStorage (1.17.11):
- React-Core
- RNFastImage (8.6.3):
@@ -432,11 +432,11 @@ PODS:
- SDWebImageWebPCoder (~> 0.8.4)
- RNFS (2.20.0):
- React-Core
- RNGestureHandler (2.8.0):
- RNGestureHandler (2.9.0):
- React-Core
- RNLocalize (2.2.4):
- React-Core
- RNReanimated (2.12.0):
- RNReanimated (2.14.4):
- DoubleConversion
- FBLazyVector
- FBReactNativeSpec
@@ -695,8 +695,8 @@ SPEC CHECKSUMS:
boost: a7c83b31436843459a1961bfd74b96033dc77234
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
FBLazyVector: affa4ba1bfdaac110a789192f4d452b053a86624
FBReactNativeSpec: fe8b5f1429cfe83a8d72dc8ed61dc7704cac8745
FBLazyVector: ce6c993e675c5e9684e3b83aa0c346eb116c3ec6
FBReactNativeSpec: d8772db98ada3c2daf8f65e2105ada77bf209c02
Flipper: d08578a2cc23c60c27086b07930efaeb39101342
Flipper-Boost-iOSX: fd1e2b8cbef7e662a122412d7ac5f5bea715403c
Flipper-DoubleConversion: 2dc99b02f658daf147069aad9dbd29d8feb06d30
@@ -708,26 +708,26 @@ SPEC CHECKSUMS:
FlipperKit: ddf459d2625ca33f115492de5ba6d970e2576311
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
hermes-engine: 7fe5fc6ef707b7fdcb161b63898ec500e285653d
hermes-engine: 0b19f33a9c2ec1dbdede3232606eeb1101db4cec
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c
RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda
RCTRequired: 21229f84411088e5d8538f21212de49e46cc83e2
RCTTypeSafety: 62eed57a32924b09edaaf170a548d1fc96223086
React: f0254ccddeeef1defe66c6b1bb9133a4f040792b
RCTRequired: 35a7977a5a3cb2d3830c3fef7352b7b116115829
RCTTypeSafety: 259790fb8b16c94e57e0d3d1e2479e69a2b93f50
React: 89f0551b8f7a555e38ce016a81c50bc68f1972a8
react-airplay: 8197767f12cae11a7623b1507d29a89482a720ad
React-bridging: e46911666b7ec19538a620a221d6396cd293d687
React-callinvoker: 66b62e2c34546546b2f21ab0b7670346410a2b53
React-Codegen: b6999435966df3bdf82afa3f319ba0d6f9a8532a
React-Core: dabbc9d1fe0a11d884e6ee1599789cf8eb1058a5
React-CoreModules: 5b6b7668f156f73a56420df9ec68ca2ec8f2e818
React-cxxreact: c7ca2baee46db22a30fce9e639277add3c3f6ad1
React-hermes: c93e1d759ad5560dfea54d233013d7d2c725c286
React-jsi: a565dcb49130ed20877a9bb1105ffeecbb93d02d
React-jsiexecutor: 31564fa6912459921568e8b0e49024285a4d584b
React-jsinspector: badd81696361249893a80477983e697aab3c1a34
React-logger: fdda34dd285bdb0232e059b19d9606fa0ec3bb9c
React-bridging: 2e425b6bc8536206918fa55bf9dd37016f99bb33
React-callinvoker: 1c733126b1e4d95d0d412d95c51cedf06b3b979d
React-Codegen: 41d2ddcd966eac2a5f2698d5cd21e3d741e999bd
React-Core: 3021f04b6b1a2064952e166470a58db671ed65b1
React-CoreModules: f569f295874d0864bfd7a686dad3f828f4e8813a
React-cxxreact: a6c952ae24061777510f7e60b808b673e624009e
React-hermes: be32d1db90d052cc025a38ec2ea4e1a493d33c6a
React-jsi: 3d7bafe69dddd780fb3527b7f939dfcbfd6790b5
React-jsiexecutor: bc8556d76f83a1a9075cdee207aad7c0b7b30a33
React-jsinspector: 5e5497c844f2381e8648ec3a7d0ad25b3f27f23e
React-logger: b277ad8f4473f2506fb30b762b6348534a3de10e
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
react-native-flipper: c33a4995958ef12a2b2f8290d63bed7adeed7634
react-native-netinfo: f80db8cac2151405633324cb645c60af098ee461
@@ -735,24 +735,24 @@ SPEC CHECKSUMS:
react-native-skia: 7f9a3bd36c4247005e87005d912dcf6db76a6289
react-native-track-player: 0c26d981b5097910486cbbeb6d8f5352d41be069
react-native-webview: d33e2db8925d090871ffeb232dfa50cb3a727581
React-perflogger: e68d3795cf5d247a0379735cbac7309adf2fb931
React-RCTActionSheet: 05452c3b281edb27850253db13ecd4c5a65bc247
React-RCTAnimation: 578eebac706428e68466118e84aeacf3a282b4da
React-RCTBlob: f47a0aa61e7d1fb1a0e13da832b0da934939d71a
React-RCTImage: 60f54b66eed65d86b6dffaf4733d09161d44929d
React-RCTLinking: 91073205aeec4b29450ca79b709277319368ac9e
React-RCTNetwork: ca91f2c9465a7e335c8a5fae731fd7f10572213b
React-RCTSettings: 1a9a5d01337d55c18168c1abe0f4a589167d134a
React-RCTText: c591e8bd9347a294d8416357ca12d779afec01d5
React-RCTVibration: 8e5c8c5d17af641f306d7380d8d0fe9b3c142c48
React-runtimeexecutor: 7401c4a40f8728fd89df4a56104541b760876117
ReactCommon: c9246996e73bf75a2c6c3ff15f1e16707cdc2da9
React-perflogger: e9249a18e055cae96fdf685bf6145cbea62506c8
React-RCTActionSheet: a6d2a544a4605a111ce80fa9319cc870ca3ea778
React-RCTAnimation: 21b776b15aa5451a0b5bcb342fd2f346817c1101
React-RCTBlob: 95f54d45305b4103b29d8b2c1e705b5c3183239a
React-RCTImage: 1b76ab9e3b60313edd85bc3fd3e07c29cec6ab68
React-RCTLinking: 7176da2a80f3056152a51587812d6d0c451b1f7b
React-RCTNetwork: d36f896304e6ef2998f58cd4199a0239bd312318
React-RCTSettings: 004b9a1afb5870f4bcd06521c088e738c1558940
React-RCTText: a2606a79fdb52dd2bde0d7fde7726160fa16b70c
React-RCTVibration: 19d21a3ed620352180800447771f68a101f196e9
React-runtimeexecutor: f795fd426264709901c09432c6ce072f8400147e
ReactCommon: c440e7f15075e81eb29802521c58a1f38b1aa903
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3
RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
RNLocalize: 0df7970cfc60389f00eb62fd7c097dc75af3fb4f
RNReanimated: 2a91e85fcd343f8af3c58d3425b99fdd285590a5
RNReanimated: 6668b0587bebd4b15dd849b99e5a9c70fc12ed95
RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
RNSentry: db7fd7b66efda28885e4e904a8b5e7349aec61c1
RNSVG: 38ca962c970dbce1ca38991a5aebf26d163f9efb
@@ -761,9 +761,9 @@ SPEC CHECKSUMS:
Sentry: 4272663eb0eda312024d795ca3f5a562a8ce5e18
SocketRocket: fccef3f9c5cedea1353a9ef6ada904fde10d6608
SwiftAudioEx: 83eabba2940924fc1c0d5cb0896049921365229c
Yoga: eca980a5771bf114c41a754098cd85e6e0d90ed7
Yoga: d6133108734e69e8c0becc6ba587294b94829687
YogaKit: f782866e155069a2cca2517aafea43200b01fd5a
PODFILE CHECKSUM: 94434618afff1be257dd0576e9a75bcaa7b48664
COCOAPODS: 1.11.3
COCOAPODS: 1.12.0

1460
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "fintunes",
"version": "2.0.2",
"version": "2.1.0",
"main": "src/index.js",
"private": true,
"scripts": {
@@ -8,14 +8,16 @@
"ios": "react-native run-ios --scheme \"Fintunes\"",
"start": "react-native start",
"test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
"build:ios": "react-native bundle --entry-file='index.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'"
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
"build:ios": "react-native bundle --entry-file='index.js' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'",
"postinstall": "patch-package"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^1.17.11",
"@react-native-community/blur": "^4.3.0",
"@react-native-community/netinfo": "^9.3.6",
"@react-navigation/bottom-tabs": "^6.4.0",
"@react-navigation/elements": "^1.3.17",
"@react-navigation/native": "^6.0.13",
"@react-navigation/native-stack": "^6.9.1",
"@react-navigation/stack": "^6.3.4",
@@ -29,17 +31,18 @@
"hermes-engine": "^0.11.0",
"i18n-js": "^3.9.2",
"lodash": "^4.17.21",
"patch-package": "^6.5.1",
"react": "18.2.0",
"react-airplay": "^1.2.0",
"react-native": "0.70.5",
"react-native": "^0.70.8",
"react-native-collapsible": "^1.6.0",
"react-native-dotenv": "^3.4.2",
"react-native-fast-image": "^8.6.3",
"react-native-flipper": "^0.164.0",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.8.0",
"react-native-gesture-handler": "^2.9.0",
"react-native-localize": "^2.2.4",
"react-native-reanimated": "^2.12.0",
"react-native-reanimated": "^2.14.4",
"react-native-safe-area-context": "^4.4.1",
"react-native-screens": "^3.18.2",
"react-native-shadow-2": "^7.0.6",

View File

@@ -0,0 +1,22 @@
diff --git a/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m b/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m
index f710081..87c4532 100644
--- a/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m
+++ b/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m
@@ -32,7 +32,7 @@
- (void) setOnFastImageLoadEnd: (RCTDirectEventBlock)onFastImageLoadEnd {
_onFastImageLoadEnd = onFastImageLoadEnd;
- if (self.hasCompleted) {
+ if (self.hasCompleted && _onFastImageLoadEnd != NULL) {
_onFastImageLoadEnd(@{});
}
}
@@ -46,7 +46,7 @@
- (void) setOnFastImageError: (RCTDirectEventBlock)onFastImageError {
_onFastImageError = onFastImageError;
- if (self.hasErrored) {
+ if (self.hasErrored && _onFastImageError != NULL) {
_onFastImageError(@{});
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

View File

@@ -2,7 +2,7 @@ import { BlurView, BlurViewProps } from '@react-native-community/blur';
import { THEME_COLOR } from 'CONSTANTS';
import React, { PropsWithChildren } from 'react';
import { useContext } from 'react';
import { ColorSchemeName, Platform, StyleSheet, useColorScheme } from 'react-native';
import { ColorSchemeName, Platform, StyleSheet, View, useColorScheme } from 'react-native';
const majorPlatformVersion = typeof Platform.Version === 'string' ? parseInt(Platform.Version, 10) : Platform.Version;
@@ -108,14 +108,8 @@ export function ColoredBlurView(props: PropsWithChildren<BlurViewProps>) {
: scheme === 'dark' ? 'extraDark' : 'xlight'
} />
) : (
<BlurView
{...props}
blurType={scheme === 'dark' ? 'dark' : 'light'}
blurAmount={10}
style={[ props.style, {
backgroundColor: scheme === 'light' ? '#f6f6f6bb' : '#333333bb',
borderRadius: 8
} ]}
/>
<View {...props} style={[ props.style, {
backgroundColor: scheme === 'light' ? '#f6f6f6fb' : '#333333fb',
} ]} />
);
}

View File

@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { Dimensions, ViewProps } from 'react-native';
import { Dimensions, useColorScheme, ViewProps } from 'react-native';
import { Canvas, Blur, Image as SkiaImage, useImage, Offset, Mask, RoundedRect, Shadow } from '@shopify/react-native-skia';
import useDefaultStyles from './Colors';
import styled from 'styled-components/native';
@@ -45,14 +45,18 @@ function CoverImage({
src,
}: Props) {
const defaultStyles = useDefaultStyles();
const colorScheme = useColorScheme();
const image = useImage(src || '');
const image = useImage(src || null);
const fallback = useImage(colorScheme === 'light' ? require('assets/images/empty-album-light.png') : require('assets/images/empty-album-dark.png'));
const { canvasSize, imageSize } = useMemo(() => {
const imageSize = Screen.width - margin;
const canvasSize = imageSize + blurRadius * 2;
return { imageSize, canvasSize };
}, [blurRadius, margin]);
const skiaImage = useMemo(() => (image || fallback), [image, fallback]);
return (
<Container size={imageSize} style={style}>
<BlurContainer size={canvasSize} offset={blurRadius}>
@@ -63,18 +67,16 @@ function CoverImage({
<Shadow dx={0} dy={8} blur={16} color="#0000000d" />
<Shadow dx={0} dy={16} blur={32} color="#0000000d" />
</RoundedRect>
{image ? (
{skiaImage ? (
<>
<SkiaImage image={image} width={imageSize} height={imageSize} opacity={opacity}>
<SkiaImage image={skiaImage} width={imageSize} height={imageSize} opacity={opacity}>
<Offset x={blurRadius} y={blurRadius} />
<Blur blur={blurRadius / 2} />
</SkiaImage>
<Mask mask={<RoundedRect width={imageSize} height={imageSize} x={blurRadius} y={blurRadius} r={radius} />}>
{image ? (
<SkiaImage image={image} width={imageSize} height={imageSize}>
<Offset x={blurRadius} y={blurRadius} />
</SkiaImage>
) : null}
<SkiaImage image={skiaImage} width={imageSize} height={imageSize}>
<Offset x={blurRadius} y={blurRadius} />
</SkiaImage>
</Mask>
</>
) : null}

View File

@@ -2,23 +2,35 @@ import React, { useCallback, useRef } from 'react';
import { Platform, TextInput, TextInputProps } from 'react-native';
import styled, { css } from 'styled-components/native';
import useDefaultStyles from './Colors';
import { Gap } from './Utility';
export interface InputProps extends TextInputProps {
icon?: React.ReactNode;
}
const Container = styled.Pressable`
const Container = styled.Pressable<{ hasIcon?: boolean }>`
position: relative;
margin: 6px 0;
border-radius: 8px;
border-radius: 12px;
display: flex;
flex-direction: row;
align-items: center;
${Platform.select({
ios: css`padding: 12px;`,
android: css`padding: 4px 12px;`,
})}
${({ hasIcon }) => hasIcon && css`
padding-left: 36px;
`}
`;
const IconWrapper = styled.View`
position: absolute;
left: 0;
top: 0;
bottom: 0;
display: flex;
justify-content: center;
padding-left: 12px;
`;
function Input({ icon = null, style, testID, ...rest }: InputProps) {
@@ -28,12 +40,17 @@ function Input({ icon = null, style, testID, ...rest }: InputProps) {
const handlePress = useCallback(() => inputRef.current?.focus(), []);
return (
<Container style={[defaultStyles.input, style]} onPress={handlePress} testID={`${testID}-container`} accessible={false}>
<Container
style={[defaultStyles.input, style]}
onPress={handlePress}
testID={`${testID}-container`}
accessible={false}
hasIcon={!!icon}
>
{icon && (
<>
<IconWrapper>
{icon}
<Gap size={8} />
</>
</IconWrapper>
)}
<TextInput
{...rest}

View File

@@ -14,7 +14,6 @@ const Background = styled.View`
const Container = styled.View<Pick<Props, 'fullSize'>>`
margin: auto 20px;
padding: 4px;
border-radius: 12px;
flex: 0 0 auto;
background: salmon;

View File

@@ -0,0 +1,100 @@
import React, { ForwardedRef, Ref, forwardRef } from 'react';
import { useHeaderHeight } from '@react-navigation/elements';
import { FlatList, FlatListProps, ScrollView, ScrollViewProps, SectionList, SectionListProps } from 'react-native';
import useCurrentTrack from '../utility/useCurrentTrack';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
declare module 'react' {
function forwardRef<T, P = {}>(
render: (props: P, ref: React.Ref<T>) => React.ReactElement | null
): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
}
/**
* A wrapper for ScrollView that takes any paddings, margins and insets into
* account that result from the bottom tabs, potential NowPlaying overlay and header.
*/
export function SafeScrollView({
contentContainerStyle,
...props
}: ScrollViewProps) {
const { top, bottom } = useNavigationOffsets();
return (
<ScrollView
contentContainerStyle={[
contentContainerStyle,
{ paddingTop: top, paddingBottom: bottom },
]}
scrollIndicatorInsets={{ top: top / 2, bottom: bottom / 2 + 5 }}
{...props}
/>
);
}
/**
* A wrapper for ScrollView that takes any paddings, margins and insets into
* account that result from the bottom tabs, potential NowPlaying overlay and header.
*/
function BareSafeSectionList<I, S>({
contentContainerStyle,
...props
}: SectionListProps<I, S>, ref: ForwardedRef<SectionList<I, S>>) {
const { top, bottom } = useNavigationOffsets();
return (
<SectionList
contentContainerStyle={[
{ paddingTop: top, paddingBottom: bottom },
contentContainerStyle,
]}
scrollIndicatorInsets={{ top: top / 2, bottom: bottom / 2 + 5 }}
ref={ref}
{...props}
/>
);
}
export const SafeSectionList = forwardRef(BareSafeSectionList);
/**
* A wrapper for ScrollView that takes any paddings, margins and insets into
* account that result from the bottom tabs, potential NowPlaying overlay and header.
*/
function BareSafeFlatList<I>({
contentContainerStyle,
...props
}: FlatListProps<I>, ref: ForwardedRef<FlatList<I>>) {
const { top, bottom } = useNavigationOffsets();
return (
<FlatList
contentContainerStyle={[
{ paddingTop: top, paddingBottom: bottom },
contentContainerStyle,
]}
scrollIndicatorInsets={{ top, bottom }}
ref={ref}
{...props}
/>
);
}
export const SafeFlatList = forwardRef(BareSafeFlatList);
/**
* A hook that returns the correct offset that should be applied to any Views
* that are wrapped in a NavigationView, in order to account for overlays,
* headers and bottom tabs.
*/
export function useNavigationOffsets({ includeOverlay = true } = {} as { includeOverlay?: boolean }) {
const headerHeight = useHeaderHeight();
const bottomBarHeight = useBottomTabBarHeight();
const { track } = useCurrentTrack();
return {
top: headerHeight,
bottom: (track && includeOverlay ? 68 : 0) + bottomBarHeight || 0,
};
}

View File

@@ -15,7 +15,8 @@ export function Text(props: PropsWithChildren<TextProps>) {
export const Header = styled(Text)`
margin: 0 0 6px 0;
font-size: 28px;
font-weight: 400;
font-weight: 500;
letter-spacing: -0.3px;
`;
export const SubHeader = styled(Text)`
@@ -24,3 +25,9 @@ export const SubHeader = styled(Text)`
font-weight: 400;
opacity: 0.5;
`;
export const Paragraph = styled(Text)`
opacity: 0.5;
font-size: 12px;
line-height: 20px;
`;

View File

@@ -59,5 +59,7 @@
"you-are-offline-message": "You are currently offline. You can only play previously downloaded music.",
"playing-on": "Playing on",
"local-playback": "Local playback",
"streaming": "Streaming"
"streaming": "Streaming",
"total-duration": "Total duration",
"similar-albums": "Similar albums"
}

View File

@@ -59,5 +59,7 @@
"you-are-offline-message": "Je bent op dit moment offline. Je kunt alleen eerder gedownloade nummers afspelen.",
"playing-on": "Speelt af op",
"local-playback": "Lokaal afspelen",
"streaming": "Streamen"
"streaming": "Streamen",
"total-duration": "Totale duur",
"similar-albums": "Vergelijkbare albums"
}

View File

@@ -57,4 +57,6 @@ export type LocaleKeys = 'play-next'
| 'you-are-offline-message'
| 'playing-on'
| 'local-playback'
| 'streaming'
| 'streaming'
| 'total-duration'
| 'similar-albums'

View File

@@ -1,7 +1,6 @@
import useDefaultStyles from 'components/Colors';
import React, { useCallback, useMemo } from 'react';
import { FlatListProps, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppDispatch, useTypedSelector } from 'store';
import formatBytes from 'utility/formatBytes';
@@ -17,6 +16,7 @@ import { Text } from 'components/Typography';
import FastImage from 'react-native-fast-image';
import { useGetImage } from 'utility/JellyfinApi';
import { ShadowWrapper } from 'components/Shadow';
import { SafeFlatList } from 'components/SafeNavigatorView';
const DownloadedTrack = styled.View`
flex: 1 0 auto;
@@ -82,7 +82,7 @@ function Downloads() {
]}
numberOfLines={1}
>
{t('total-download-size')}: {formatBytes(totalDownloadSize)}
{t('total-download-size')}{': '}{formatBytes(totalDownloadSize)}
</Text>
<Button
icon={TrashIcon}
@@ -151,10 +151,10 @@ function Downloads() {
return (
<SafeAreaView style={{ flex: 1 }}>
{ListHeaderComponent}
<FlatList
<SafeFlatList
data={ids}
style={{ flex: 1, paddingTop: 12 }}
contentContainerStyle={{ flexGrow: 1, paddingBottom: 24 }}
contentContainerStyle={{ flexGrow: 1 }}
renderItem={renderItem}
/>
</SafeAreaView>

View File

@@ -1,18 +1,20 @@
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { MusicStackParams } from './types';
import Albums from './stacks/Albums';
import Album from './stacks/Album';
import RecentAlbums from './stacks/RecentAlbums';
import { THEME_COLOR } from 'CONSTANTS';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
import Playlists from './stacks/Playlists';
import Playlist from './stacks/Playlist';
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { StackParams } from 'screens/types';
import NowPlaying from './overlays/NowPlaying';
const Stack = createStackNavigator<MusicStackParams>();
import RecentAlbums from './stacks/RecentAlbums';
import Albums from './stacks/Albums';
import Album from './stacks/Album';
import Playlists from './stacks/Playlists';
import Playlist from './stacks/Playlist';
import { StyleSheet } from 'react-native';
const Stack = createStackNavigator<StackParams>();
function MusicStack() {
const defaultStyles = useDefaultStyles();
@@ -23,8 +25,10 @@ function MusicStack() {
headerTintColor: THEME_COLOR,
headerTitleStyle: defaultStyles.stackHeader,
cardStyle: defaultStyles.view,
headerTransparent: true,
headerBackground: () => <ColoredBlurView style={StyleSheet.absoluteFill} />,
}}>
<Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums') }} />
<Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums'), headerShown: false }} />
<Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} />
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
<Stack.Screen name="Playlists" component={Playlists} options={{ headerTitle: t('playlists') }} />

View File

@@ -15,15 +15,15 @@ import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { useNavigation } from '@react-navigation/native';
import { calculateProgressTranslation } from 'components/Progresstrack';
import { THEME_COLOR } from 'CONSTANTS';
import { MusicNavigationProp } from 'screens/Music/types';
import { NavigationProp } from 'screens/types';
import { ShadowWrapper } from 'components/Shadow';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
const NOW_PLAYING_POPOVER_MARGIN = 6;
const NOW_PLAYING_POPOVER_WIDTH = Dimensions.get('screen').width - 2 * NOW_PLAYING_POPOVER_MARGIN;
const PopoverPosition = css`
position: absolute;
bottom: ${NOW_PLAYING_POPOVER_MARGIN}px;
left: ${NOW_PLAYING_POPOVER_MARGIN}px;
right: ${NOW_PLAYING_POPOVER_MARGIN}px;
border-radius: 8px;
@@ -111,10 +111,11 @@ function NowPlaying() {
const { index, track } = useCurrentTrack();
const { buffered, position } = useProgress();
const defaultStyles = useDefaultStyles();
const tabBarHeight = useBottomTabBarHeight();
const previousBuffered = usePrevious(buffered);
const previousPosition = usePrevious(position);
const navigation = useNavigation<MusicNavigationProp>();
const navigation = useNavigation<NavigationProp>();
const bufferAnimation = useRef(new Animated.Value(0));
const progressAnimation = useRef(new Animated.Value(0));
@@ -164,7 +165,7 @@ function NowPlaying() {
}
return (
<Container>
<Container style={{ bottom: tabBarHeight + NOW_PLAYING_POPOVER_MARGIN }}>
{/** TODO: Fix shadow overflow on Android */}
{Platform.OS === 'ios' ? (
<ShadowOverlay pointerEvents='none'>

View File

@@ -1,14 +1,54 @@
import React, { useCallback, useEffect } from 'react';
import { MusicStackParams } from '../types';
import { useRoute, RouteProp } from '@react-navigation/native';
import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
import { useAppDispatch, useTypedSelector } from 'store';
import TrackListView from './components/TrackListView';
import { fetchTracksByAlbum } from 'store/music/actions';
import { fetchAlbum, fetchTracksByAlbum } from 'store/music/actions';
import { differenceInDays } from 'date-fns';
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import { t } from '@localisation';
import { NavigationProp, StackParams } from 'screens/types';
import { SubHeader, Text } from 'components/Typography';
import { ScrollView } from 'react-native-gesture-handler';
import { useGetImage } from 'utility/JellyfinApi';
import styled from 'styled-components';
import { Dimensions, Pressable } from 'react-native';
import AlbumImage from './components/AlbumImage';
type Route = RouteProp<MusicStackParams, 'Album'>;
type Route = RouteProp<StackParams, 'Album'>;
const Screen = Dimensions.get('screen');
const Cover = styled(AlbumImage)`
height: ${Screen.width / 2.8};
width: ${Screen.width / 2.8};
border-radius: 12px;
margin-bottom: 8px;
`;
function SimilarAlbum({ id }: { id: string }) {
const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage();
const album = useTypedSelector((state) => state.music.albums.entities[id]);
const handlePress = useCallback(() => {
album && navigation.push('Album', { id, album });
}, [id, album, navigation]);
return (
<Pressable
style={({ pressed }) => ({
opacity: pressed ? 0.5 : 1.0,
width: Screen.width / 2.8,
marginRight: 12
})}
onPress={handlePress}
>
<Cover key={id} source={{ uri: getImage(id) }} />
<Text numberOfLines={1} style={{ fontSize: 13, marginBottom: 2 }}>{album?.Name}</Text>
<Text numberOfLines={1} style={{ opacity: 0.5, fontSize: 13 }}>{album?.Artists.join(', ')}</Text>
</Pressable>
);
}
const Album: React.FC = () => {
const { params: { id } } = useRoute<Route>();
@@ -19,7 +59,10 @@ const Album: React.FC = () => {
const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
// Define a function for refreshing this entity
const refresh = useCallback(() => { dispatch(fetchTracksByAlbum(id)); }, [id, dispatch]);
const refresh = useCallback(() => {
dispatch(fetchTracksByAlbum(id));
dispatch(fetchAlbum(id));
}, [id, dispatch]);
// Auto-fetch the track data periodically
useEffect(() => {
@@ -39,7 +82,21 @@ const Album: React.FC = () => {
shuffleButtonText={t('shuffle-album')}
downloadButtonText={t('download-album')}
deleteButtonText={t('delete-album')}
/>
>
{album?.Overview ? (
<Text style={{ opacity: 0.5, lineHeight: 20, fontSize: 12, paddingBottom: 24 }}>{album?.Overview}</Text>
) : null}
{album?.Similar?.length ? (
<>
<SubHeader>{t('similar-albums')}</SubHeader>
<ScrollView horizontal style={{ marginLeft: -24, marginRight: -24, marginTop: 8 }} contentContainerStyle={{ paddingHorizontal: 24 }} showsHorizontalScrollIndicator={false}>
{album.Similar.map((id) => (
<SimilarAlbum id={id} key={id} />
))}
</ScrollView>
</>
) : null}
</TrackListView>
);
};

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
import React, { useCallback, useEffect, useRef, ReactText, useMemo } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { MusicNavigationProp } from '../types';
import { SafeAreaView, SectionList, View } from 'react-native';
import { SectionList, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { differenceInDays } from 'date-fns';
import { useAppDispatch, useTypedSelector } from 'store';
@@ -17,25 +16,11 @@ import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { Album } from 'store/music/types';
import { Text } from 'components/Typography';
import { ShadowWrapper } from 'components/Shadow';
import { NavigationProp } from 'screens/types';
import { SafeSectionList } from 'components/SafeNavigatorView';
const HeadingHeight = 50;
interface VirtualizedItemInfo {
section: SectionedId,
// Key of the section or combined key for section + item
key: string,
// Relative index within the section
index: number,
// True if this is the section header
header?: boolean,
leadingItem?: EntityId,
leadingSection?: SectionedId,
trailingItem?: EntityId,
trailingSection?: SectionedId,
}
type VirtualizedSectionList = { _subExtractor: (index: number) => VirtualizedItemInfo };
function generateSection({ section }: { section: SectionedId }) {
return (
<SectionHeading label={section.label} key={section.label} />
@@ -103,44 +88,54 @@ const Albums: React.FC = () => {
// Initialise helpers
const dispatch = useAppDispatch();
const navigation = useNavigation<MusicNavigationProp>();
const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage();
const listRef = useRef<SectionList<EntityId>>(null);
const listRef = useRef<SectionList<EntityId[]>>(null);
const getItemLayout = useCallback((data: SectionedId[] | null, index: number): { offset: number, length: number, index: number } => {
// We must wait for the ref to become available before we can use the
// native item retriever in VirtualizedSectionList
if (!listRef.current) {
return { offset: 0, length: 0, index };
}
// Create an array that computes all the height data for the entire list in
// advance. We can then use this pre-computed data to respond to
// `getItemLayout` calls, without having to compute things in place (and
// fail horribly).
// This approach was inspired by https://gist.github.com/RaphBlanchet/472ed013e05398c083caae6216b598b5
const itemLayouts = useMemo(() => {
// Create an array in which we will store all possible outputs for
// `getItemLayout`. We will loop through each potential album and add
// items that will be in the list
const layouts: Array<{ length: number; offset: number; index: number }> = [];
// Keep track of both the index of items and the offset (in pixels) from
// the top
let index = 0;
let offset = 0;
// Retrieve the right item info
// @ts-ignore
const wrapperListRef = (listRef.current?._wrapperListRef) as VirtualizedSectionList;
const info: VirtualizedItemInfo = wrapperListRef._subExtractor(index);
const { index: itemIndex, header, key } = info;
const sectionIndex = parseInt(key.split(':')[0]);
// Loop through each individual section (i.e. alphabet letter) and add
// all items in that particular section.
sections.forEach((section) => {
// Each section starts with a header, so we'll need to add the item,
// as well as the offset.
layouts[index] = ({ length: HeadingHeight, offset, index });
index++;
offset += HeadingHeight;
// We can then determine the "length" (=height) of this item. Header items
// end up with an itemIndex of -1, thus are easy to identify.
const length = header ? 50 : (itemIndex % 2 === 0 ? AlbumHeight : 0);
// We'll also need to account for any unevenly-ended lists up until the
// current item.
const previousRows = data?.filter((row, i) => i < sectionIndex)
.reduce((sum, row) => sum + Math.ceil(row.data.length / 2), 0) || 0;
// Then, loop through all the rows (sets of two albums) and add
// items for those as well.
section.data.forEach(() => {
layouts[index] = ({ length: AlbumHeight, offset, index });
index++;
offset += AlbumHeight;
});
// We must also calcuate the offset, total distance from the top of the
// screen. First off, we'll account for each sectionIndex that is shown up
// until now. This only includes the heading for the current section if the
// item is not the section header
const headingOffset = HeadingHeight * (header ? sectionIndex : sectionIndex + 1);
const currentRows = itemIndex > 1 ? Math.ceil((itemIndex + 1) / 2) : 0;
const itemOffset = AlbumHeight * (previousRows + currentRows);
const offset = headingOffset + itemOffset;
return { index, length, offset };
}, [listRef]);
// The way SectionList works is that you get an item for a
// SectionHeader and a SectionFooter, no matter if you've specified
// whether you want them or not. Thus, we will need to add an empty
// footer as an item, so that we don't mismatch our indexes
layouts[index] = { length: 0, offset, index };
index++;
});
// Then, store and memoize the output
return layouts;
}, [sections]);
// Set callbacks
const retrieveData = useCallback(() => dispatch(fetchAllAlbums()), [dispatch]);
@@ -148,30 +143,19 @@ const Albums: React.FC = () => {
const selectLetter = useCallback((sectionIndex: number) => {
listRef.current?.scrollToLocation({ sectionIndex, itemIndex: 0, animated: false, });
}, [listRef]);
const generateItem = useCallback(({ item, index, section }: { item: EntityId, index: number, section: SectionedId }) => {
if (index % 2 === 1) {
return <View key={item} />;
}
const nextItem = section.data[index + 1];
const generateItem = useCallback(({ item }: { item: EntityId[] }) => {
return (
<View style={{ flexDirection: 'row', marginLeft: 10, marginRight: 10 }} key={item}>
<GeneratedAlbumItem
id={item}
imageUrl={getImage(item as string)}
name={albums[item]?.Name || ''}
artist={albums[item]?.AlbumArtist || ''}
onPress={selectAlbum}
/>
{albums[nextItem] &&
<View style={{ flexDirection: 'row', marginLeft: 10, marginRight: 10 }} key={item.join('-')}>
{item.map((id) => (
<GeneratedAlbumItem
id={nextItem}
imageUrl={getImage(nextItem as string)}
name={albums[nextItem]?.Name || ''}
artist={albums[nextItem]?.AlbumArtist || ''}
key={id}
id={id}
imageUrl={getImage(id as string)}
name={albums[id]?.Name || ''}
artist={albums[id]?.AlbumArtist || ''}
onPress={selectAlbum}
/>
}
))}
</View>
);
}, [albums, getImage, selectAlbum]);
@@ -185,19 +169,19 @@ const Albums: React.FC = () => {
});
return (
<SafeAreaView>
<>
<AlphabetScroller onSelect={selectLetter} />
<SectionList
<SafeSectionList
sections={sections}
refreshing={isLoading}
onRefresh={retrieveData}
getItemLayout={getItemLayout}
getItemLayout={(_, i) => itemLayouts[i] ?? { length: 0, offset: 0, index: i }}
ref={listRef}
keyExtractor={(item) => item as string}
keyExtractor={(item) => item.join('-')}
renderSectionHeader={generateSection}
renderItem={generateItem}
/>
</SafeAreaView>
</>
);
};

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import { MusicStackParams } from '../types';
import { useRoute, RouteProp } from '@react-navigation/native';
import { useAppDispatch, useTypedSelector } from 'store';
import TrackListView from './components/TrackListView';
@@ -7,8 +6,9 @@ import { fetchTracksByPlaylist } from 'store/music/actions';
import { differenceInDays } from 'date-fns';
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import { t } from '@localisation';
import { StackParams } from 'screens/types';
type Route = RouteProp<MusicStackParams, 'Album'>;
type Route = RouteProp<StackParams, 'Album'>;
const Playlist: React.FC = () => {
const { params: { id } } = useRoute<Route>();
@@ -39,6 +39,7 @@ const Playlist: React.FC = () => {
shuffleButtonText={t('shuffle-playlist')}
downloadButtonText={t('download-playlist')}
deleteButtonText={t('delete-playlist')}
itemDisplayStyle='playlist'
/>
);
};

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { MusicNavigationProp } from '../types';
import { Text, View, FlatList, ListRenderItem } from 'react-native';
import { Text, View, FlatList, ListRenderItem, RefreshControl } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { differenceInDays } from 'date-fns';
import { useAppDispatch, useTypedSelector } from 'store';
@@ -11,6 +10,8 @@ import TouchableHandler from 'components/TouchableHandler';
import AlbumImage, { AlbumItem } from './components/AlbumImage';
import { EntityId } from '@reduxjs/toolkit';
import useDefaultStyles from 'components/Colors';
import { NavigationProp } from 'screens/types';
import { SafeFlatList, useNavigationOffsets } from 'components/SafeNavigatorView';
interface GeneratedAlbumItemProps {
id: ReactText;
@@ -34,6 +35,8 @@ const GeneratedPlaylistItem = React.memo(function GeneratedPlaylistItem(props: G
});
const Playlists: React.FC = () => {
const offsets = useNavigationOffsets();
// Retrieve data from store
const { entities, ids } = useTypedSelector((state) => state.music.playlists);
const isLoading = useTypedSelector((state) => state.music.playlists.isLoading);
@@ -41,7 +44,7 @@ const Playlists: React.FC = () => {
// Initialise helpers
const dispatch = useAppDispatch();
const navigation = useNavigation<MusicNavigationProp>();
const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage();
const listRef = useRef<FlatList<EntityId>>(null);
@@ -93,10 +96,11 @@ const Playlists: React.FC = () => {
});
return (
<FlatList
<SafeFlatList
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={retrieveData} progressViewOffset={offsets.top} />
}
data={ids}
refreshing={isLoading}
onRefresh={retrieveData}
getItemLayout={getItemLayout}
ref={listRef}
keyExtractor={(item, index) => `${item}_${index}`}

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { MusicNavigationProp } from '../types';
import { Text, SafeAreaView, FlatList, StyleSheet } from 'react-native';
import { Text, SafeAreaView, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useAppDispatch, useTypedSelector } from 'store';
import { fetchRecentAlbums } from 'store/music/actions';
@@ -17,6 +16,8 @@ import { Album } from 'store/music/types';
import Divider from 'components/Divider';
import styled from 'styled-components/native';
import { ShadowWrapper } from 'components/Shadow';
import { NavigationProp } from 'screens/types';
import { SafeFlatList } from 'components/SafeNavigatorView';
const styles = StyleSheet.create({
columnWrapper: {
@@ -31,7 +32,7 @@ const HeaderContainer = styled.View`
`;
const NavigationHeader: React.FC = () => {
const navigation = useNavigation<MusicNavigationProp>();
const navigation = useNavigation<NavigationProp>();
const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]);
const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [navigation]);
@@ -59,7 +60,7 @@ const RecentAlbums: React.FC = () => {
// Initialise helpers
const dispatch = useAppDispatch();
const navigation = useNavigation<MusicNavigationProp>();
const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage();
// Set callbacks
@@ -71,7 +72,7 @@ const RecentAlbums: React.FC = () => {
return (
<SafeAreaView>
<FlatList
<SafeFlatList
data={recentAlbums as string[]}
refreshing={isLoading}
onRefresh={retrieveData}

View File

@@ -1,23 +0,0 @@
import styled from 'styled-components/native';
import FastImage from 'react-native-fast-image';
import { Dimensions } from 'react-native';
const Screen = Dimensions.get('screen');
export const AlbumWidth = Screen.width / 2 - 24;
export const AlbumHeight = AlbumWidth + 40;
export const CoverSize = AlbumWidth - 16;
export const AlbumItem = styled.View`
width: ${AlbumWidth}px;
height: ${AlbumHeight}px;
padding: 8px;
`;
const AlbumImage = styled(FastImage)`
border-radius: 10px;
width: ${CoverSize}px;
height: ${CoverSize}px;
margin-bottom: 5px;
`;
export default AlbumImage;

View File

@@ -0,0 +1,39 @@
import React, { useState } from 'react';
import styled from 'styled-components/native';
import FastImage, { FastImageProps } from 'react-native-fast-image';
import { Dimensions, useColorScheme } from 'react-native';
const Screen = Dimensions.get('screen');
export const AlbumWidth = Screen.width / 2 - 24;
export const AlbumHeight = AlbumWidth + 40;
export const CoverSize = AlbumWidth - 16;
export const AlbumItem = styled.View`
width: ${AlbumWidth}px;
height: ${AlbumHeight}px;
padding: 8px;
`;
const Container = styled(FastImage)`
border-radius: 10px;
width: ${CoverSize}px;
height: ${CoverSize}px;
margin-bottom: 5px;
`;
function AlbumImage(props: FastImageProps) {
const [hasError, setError] = useState(false);
const colorScheme = useColorScheme();
if (!props.source || hasError) {
return (
<Container {...props} source={colorScheme === 'light' ? require('assets/images/empty-album-light.png') : require('assets/images/empty-album-dark.png')} />
);
}
return (
<Container {...props} onError={() => setError(true)} />
);
}
export default AlbumImage;

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import { ScrollView, RefreshControl, StyleSheet, View } from 'react-native';
import React, { PropsWithChildren, useCallback, useMemo } from 'react';
import { Platform, RefreshControl, StyleSheet, View } from 'react-native';
import { useGetImage } from 'utility/JellyfinApi';
import styled, { css } from 'styled-components/native';
import { useNavigation } from '@react-navigation/native';
@@ -14,7 +14,7 @@ import useDefaultStyles from 'components/Colors';
import usePlayTracks from 'utility/usePlayTracks';
import { EntityId } from '@reduxjs/toolkit';
import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow';
import { MusicNavigationProp } from 'screens/Music/types';
import { NavigationProp } from 'screens/types';
import DownloadIcon from 'components/DownloadIcon';
import CloudDownArrow from 'assets/icons/cloud-down-arrow.svg';
import Trash from 'assets/icons/trash.svg';
@@ -25,11 +25,12 @@ import { Text } from 'components/Typography';
import CoverImage from 'components/CoverImage';
import ticksToDuration from 'utility/ticksToDuration';
import { t } from '@localisation';
import { SafeScrollView, useNavigationOffsets } from 'components/SafeNavigatorView';
const styles = StyleSheet.create({
index: {
width: 16,
marginRight: 8
marginRight: 12
},
activeText: {
color: THEME_COLOR,
@@ -43,18 +44,23 @@ const AlbumImageContainer = styled.View`
align-items: center;
`;
const TrackContainer = styled.View<{ isPlaying: boolean }>`
const TrackContainer = styled.View<{ isPlaying: boolean, small?: boolean }>`
padding: 12px 4px;
flex-direction: row;
border-radius: 6px;
align-items: flex-start;
${props => props.isPlaying && css`
margin: 0 -12px;
padding: 12px 16px;
`}
${props => props.small && css`
padding: ${Platform.select({ ios: '8px 4px', android: '4px'})};
`}
`;
interface TrackListViewProps {
export interface TrackListViewProps extends PropsWithChildren<{}> {
title?: string;
artist?: string;
trackIds: EntityId[];
@@ -65,6 +71,7 @@ interface TrackListViewProps {
downloadButtonText: string;
deleteButtonText: string;
listNumberingStyle?: 'album' | 'index';
itemDisplayStyle?: 'album' | 'playlist';
}
const TrackListView: React.FC<TrackListViewProps> = ({
@@ -78,19 +85,27 @@ const TrackListView: React.FC<TrackListViewProps> = ({
downloadButtonText,
deleteButtonText,
listNumberingStyle = 'album',
itemDisplayStyle = 'album',
children
}) => {
const defaultStyles = useDefaultStyles();
const offsets = useNavigationOffsets();
// Retrieve state
const tracks = useTypedSelector((state) => state.music.tracks.entities);
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds));
const totalDuration = useMemo(() => (
trackIds.reduce<number>((sum, trackId) => (
sum + (tracks[trackId]?.RunTimeTicks || 0)
), 0)
), [trackIds, tracks]);
// Retrieve helpers
const getImage = useGetImage();
const playTracks = usePlayTracks();
const { track: currentTrack } = useCurrentTrack();
const navigation = useNavigation<MusicNavigationProp>();
const navigation = useNavigation<NavigationProp>();
const dispatch = useAppDispatch();
// Setup callbacks
@@ -102,7 +117,7 @@ const TrackListView: React.FC<TrackListViewProps> = ({
await TrackPlayer.play();
}, [playTracks, trackIds]);
const longPressTrack = useCallback((index: number) => {
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] });
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index].toString() });
}, [navigation, trackIds]);
const downloadAllTracks = useCallback(() => {
trackIds.forEach((trackId) => dispatch(queueTrackForDownload(trackId)));
@@ -112,90 +127,109 @@ const TrackListView: React.FC<TrackListViewProps> = ({
}, [dispatch, downloadedTracks]);
return (
<ScrollView
<SafeScrollView
style={defaultStyles.view}
contentContainerStyle={{ padding: 24, paddingTop: 32, paddingBottom: 64 }}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
<RefreshControl refreshing={isLoading} onRefresh={refresh} progressViewOffset={offsets.top} />
}
>
<AlbumImageContainer>
<CoverImage src={getImage(entityId)} />
</AlbumImageContainer>
<Header>{title}</Header>
<SubHeader>{artist}</SubHeader>
<WrappableButtonRow>
<WrappableButton title={playButtonText} icon={Play} onPress={playEntity} testID="play-album" />
<WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} testID="shuffle-album" />
</WrappableButtonRow>
<View style={{ marginTop: 8 }}>
{trackIds.map((trackId, i) =>
<TouchableHandler
key={trackId}
id={i}
onPress={selectTrack}
onLongPress={longPressTrack}
testID={`play-track-${trackId}`}
>
<TrackContainer
isPlaying={currentTrack?.backendId === trackId || false}
style={[defaultStyles.border, currentTrack?.backendId === trackId || false ? defaultStyles.activeBackground : null ]}
<View style={{ padding: 24, paddingTop: 32, paddingBottom: 32 }}>
<AlbumImageContainer>
<CoverImage src={getImage(entityId)} />
</AlbumImageContainer>
<Header>{title}</Header>
<SubHeader>{artist}</SubHeader>
<WrappableButtonRow>
<WrappableButton title={playButtonText} icon={Play} onPress={playEntity} testID="play-album" />
<WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} testID="shuffle-album" />
</WrappableButtonRow>
<View style={{ marginTop: 8 }}>
{trackIds.map((trackId, i) =>
<TouchableHandler
key={trackId}
id={i}
onPress={selectTrack}
onLongPress={longPressTrack}
testID={`play-track-${trackId}`}
>
<Text
style={[
styles.index,
{ opacity: 0.25 },
currentTrack?.backendId === trackId && styles.activeText
]}
numberOfLines={1}
<TrackContainer
isPlaying={currentTrack?.backendId === trackId || false}
style={[defaultStyles.border, currentTrack?.backendId === trackId || false ? defaultStyles.activeBackground : null ]}
small={itemDisplayStyle === 'playlist'}
>
{listNumberingStyle === 'index'
? i + 1
: tracks[trackId]?.IndexNumber}
</Text>
<Text
style={{
...currentTrack?.backendId === trackId && styles.activeText,
flexShrink: 1,
marginRight: 4,
}}
numberOfLines={1}
>
{tracks[trackId]?.Name}
</Text>
<View style={{ marginLeft: 'auto', flexDirection: 'row' }}>
<Text
style={[
{ marginRight: 12, opacity: 0.25 },
styles.index,
{ opacity: 0.25 },
currentTrack?.backendId === trackId && styles.activeText
]}
numberOfLines={1}
>
{ticksToDuration(tracks[trackId]?.RunTimeTicks || 0)}
{listNumberingStyle === 'index'
? i + 1
: tracks[trackId]?.IndexNumber}
</Text>
<DownloadIcon trackId={trackId} fill={currentTrack?.backendId === trackId ? `${THEME_COLOR}44` : undefined} />
</View>
</TrackContainer>
</TouchableHandler>
)}
<WrappableButtonRow style={{ marginTop: 24 }}>
<WrappableButton
icon={CloudDownArrow}
title={downloadButtonText}
onPress={downloadAllTracks}
disabled={downloadedTracks.length === trackIds.length}
testID="download-album"
/>
<WrappableButton
icon={Trash}
title={deleteButtonText}
onPress={deleteAllTracks}
disabled={downloadedTracks.length === 0}
testID="delete-album"
/>
</WrappableButtonRow>
<View style={{ flexShrink: 1 }}>
<Text
style={{
...currentTrack?.backendId === trackId && styles.activeText,
flexShrink: 1,
marginRight: 4,
}}
numberOfLines={1}
>
{tracks[trackId]?.Name}
</Text>
{itemDisplayStyle === 'playlist' && (
<Text
style={{
...currentTrack?.backendId === trackId && styles.activeText,
flexShrink: 1,
marginRight: 4,
opacity:currentTrack?.backendId === trackId ? 0.5 : 0.25,
}}
numberOfLines={1}
>
{tracks[trackId]?.Artists.join(', ')}
</Text>
)}
</View>
<View style={{ marginLeft: 'auto', flexDirection: 'row' }}>
<Text
style={[
{ marginRight: 12, opacity: 0.25 },
currentTrack?.backendId === trackId && styles.activeText
]}
numberOfLines={1}
>
{ticksToDuration(tracks[trackId]?.RunTimeTicks || 0)}
</Text>
<DownloadIcon trackId={trackId} fill={currentTrack?.backendId === trackId ? `${THEME_COLOR}44` : undefined} />
</View>
</TrackContainer>
</TouchableHandler>
)}
<Text style={{ paddingTop: 24, paddingBottom: 12, textAlign: 'center', opacity: 0.5 }}>{t('total-duration')}{': '}{ticksToDuration(totalDuration)}</Text>
<WrappableButtonRow style={{ marginTop: 24 }}>
<WrappableButton
icon={CloudDownArrow}
title={downloadButtonText}
onPress={downloadAllTracks}
disabled={downloadedTracks.length === trackIds.length}
testID="download-album"
/>
<WrappableButton
icon={Trash}
title={deleteButtonText}
onPress={deleteAllTracks}
disabled={downloadedTracks.length === 0}
testID="delete-album"
/>
</WrappableButtonRow>
</View>
{children}
</View>
</ScrollView>
</SafeScrollView>
);
};

View File

@@ -1,328 +1,31 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import Input from 'components/Input';
import { ActivityIndicator, Animated, SafeAreaView, View } from 'react-native';
import styled from 'styled-components/native';
import { useAppDispatch, useTypedSelector } from 'store';
import Fuse from 'fuse.js';
import { Album, AlbumTrack } from 'store/music/types';
import { FlatList } from 'react-native-gesture-handler';
import TouchableHandler from 'components/TouchableHandler';
import { useNavigation } from '@react-navigation/native';
import { useGetImage } from 'utility/JellyfinApi';
import FastImage from 'react-native-fast-image';
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { THEME_COLOR } from 'CONSTANTS';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
import { searchAndFetchAlbums } from 'store/music/actions';
import { debounce } from 'lodash';
import { Text } from 'components/Typography';
import { MusicNavigationProp } from 'screens/Music/types';
import DownloadIcon from 'components/DownloadIcon';
import ChevronRight from 'assets/icons/chevron-right.svg';
import SearchIcon from 'assets/icons/magnifying-glass.svg';
import { ShadowWrapper } from 'components/Shadow';
import { useKeyboardHeight } from 'utility/useKeyboardHeight';
// import MicrophoneIcon from 'assets/icons/microphone.svg';
// import AlbumIcon from 'assets/icons/collection.svg';
// import TrackIcon from 'assets/icons/note.svg';
// import PlaylistIcon from 'assets/icons/note-list.svg';
// import StreamIcon from 'assets/icons/cloud.svg';
// import LocalIcon from 'assets/icons/internal-drive.svg';
// import SelectableFilter from './components/SelectableFilter';
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { StackParams } from 'screens/types';
import Search from './stacks/Search';
import Album from 'screens/Music/stacks/Album';
import { StyleSheet } from 'react-native';
const Container = styled(Animated.View)`
padding: 4px 32px 0 32px;
margin-bottom: 0px;
padding-bottom: 0px;
border-top-width: 0.5px;
`;
const Stack = createStackNavigator<StackParams>();
const FullSizeContainer = styled.View`
flex: 1;
`;
const Loading = styled.View`
position: absolute;
right: 12px;
top: 0;
height: 100%;
flex: 1;
justify-content: center;
`;
const AlbumImage = styled(FastImage)`
border-radius: 4px;
width: 32px;
height: 32px;
margin-right: 10px;
`;
const HalfOpacity = styled.Text`
opacity: 0.5;
margin-top: 2px;
font-size: 12px;
flex: 1 1 auto;
`;
const SearchResult = styled.View`
flex-direction: row;
align-items: center;
padding: 8px 32px;
height: 54px;
`;
const fuseOptions: Fuse.IFuseOptions<Album> = {
keys: ['Name', 'AlbumArtist', 'AlbumArtists', 'Artists'],
threshold: 0.1,
includeScore: true,
fieldNormWeight: 1,
};
type AudioResult = {
type: 'Audio',
id: string;
album: string;
name: string;
};
type AlbumResult = {
type: 'AlbumArtist',
id: string;
album: undefined;
name: undefined;
}
type CombinedResults = (AudioResult | AlbumResult)[];
export default function Search() {
function SearchStack() {
const defaultStyles = useDefaultStyles();
// Prepare state for fuse and albums
const [fuseIsReady, setFuseReady] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setLoading] = useState(false);
const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
const albums = useTypedSelector(state => state.music.albums.entities);
const fuse = useRef<Fuse<Album>>();
// Prepare helpers
const navigation = useNavigation<MusicNavigationProp>();
const keyboardHeight = useKeyboardHeight();
const getImage = useGetImage();
const dispatch = useAppDispatch();
/**
* Since it is impractical to have a global fuse variable, we need to
* instantiate it for thsi function. With this effect, we generate a new
* Fuse instance every time the albums change. This can of course be done
* more intelligently by removing and adding the changed albums, but this is
* an open todo.
*/
useEffect(() => {
fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions);
setFuseReady(true);
}, [albums, setFuseReady]);
/**
* This function retrieves search results from Jellyfin. It is a seperate
* callback, so that we can make sure it is properly debounced and doesn't
* cause execessive jank in the interface.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string, currentResults: CombinedResults) => {
// First, query the Jellyfin API
const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm }));
// Convert the current results to album ids
const albumIds = currentResults.map(item => item.id);
// Parse the result in correct typescript form
const results = (payload as { results: (Album | AlbumTrack)[] }).results;
// Filter any results that are already displayed
const items = results.filter(item => (
!(item.Type === 'MusicAlbum' && albumIds.includes(item.Id))
// Then convert the results to proper result form
)).map((item) => ({
type: item.Type,
id: item.Id,
album: item.Type === 'Audio'
? item.AlbumId
: undefined,
name: item.Type === 'Audio'
? item.Name
: undefined,
}));
// Lastly, we'll merge the two and assign them to the state
setJellyfinResults([...items] as CombinedResults);
// Loading is now complete
setLoading(false);
}, 50), [dispatch, setJellyfinResults]);
/**
* Whenever the search term changes, we gather results from Fuse and assign
* them to state
*/
useEffect(() => {
if (!searchTerm) {
return;
}
const retrieveResults = async () => {
// GUARD: In some extraordinary cases, Fuse might not be presented since
// it is assigned via refs. In this case, we can't handle any searching.
if (!fuse.current) {
return;
}
// First set the immediate results from fuse
const fuseResults = fuse.current.search(searchTerm);
const albums: AlbumResult[] = fuseResults
.map(({ item }) => ({
id: item.Id,
type: 'AlbumArtist',
album: undefined,
name: undefined,
}));
// Assign the preliminary results
setFuseResults(albums);
setLoading(true);
try {
// Wrap the call in a try/catch block so that we catch any
// network issues in search and just use local search if the
// network is unavailable
fetchJellyfinResults(searchTerm, albums);
} catch {
// Reset the loading indicator if the network fails
setLoading(false);
}
};
retrieveResults();
}, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]);
// Handlers
const selectAlbum = useCallback((id: string) =>
navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]
);
const HeaderComponent = React.useMemo(() => (
<View>
<Container style={[
defaultStyles.border,
defaultStyles.view,
{ transform: [{ translateY: keyboardHeight }]},
]}>
<View>
<Input
value={searchTerm}
onChangeText={setSearchTerm}
style={[defaultStyles.input, { marginBottom: 12 }]}
placeholder={t('search') + '...'}
icon={<SearchIcon width={14} height={14} fill={defaultStyles.textHalfOpacity.color} />}
testID="search-input"
/>
{isLoading && <Loading><ActivityIndicator /></Loading>}
</View>
</Container>
{/* <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={{ paddingHorizontal: 32, paddingBottom: 12, flex: 1, flexDirection: 'row' }}>
<SelectableFilter
text="Artists"
icon={MicrophoneIcon}
active
/>
<SelectableFilter
text="Albums"
icon={AlbumIcon}
active={false}
/>
<SelectableFilter
text="Tracks"
icon={TrackIcon}
active={false}
/>
<SelectableFilter
text="Playlist"
icon={PlaylistIcon}
active={false}
/>
<SelectableFilter
text="Streaming"
icon={StreamIcon}
active={false}
/>
<SelectableFilter
text="Local Playback"
icon={LocalIcon}
active={false}
/>
</View>
</ScrollView> */}
</View>
), [searchTerm, setSearchTerm, defaultStyles, isLoading, keyboardHeight]);
// GUARD: We cannot search for stuff unless Fuse is loaded with results.
// Therefore we delay rendering to when we are certain it's there.
if (!fuseIsReady) {
return null;
}
return (
<SafeAreaView style={{ flex: 1 }}>
<FlatList
style={{ flex: 2 }}
data={[...jellyfinResults, ...fuseResults]}
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => {
const album = albums[trackAlbum || id];
// GUARD: If the album cannot be found in the store, we
// cannot display it.
if (!album) {
return null;
}
return (
<TouchableHandler<string> id={album.Id} onPress={selectAlbum} testID={`search-result-${album.Id}`}>
<SearchResult>
<ShadowWrapper>
<AlbumImage source={{ uri: getImage(album.Id) }} style={defaultStyles.imageBackground} />
</ShadowWrapper>
<View style={{ flex: 1 }}>
<Text numberOfLines={1}>
{trackName || album.Name}
</Text>
{(album.AlbumArtist || album.Name) && (
<HalfOpacity style={defaultStyles.text} numberOfLines={1}>
{type === 'AlbumArtist'
? `${t('album')}${album.AlbumArtist}`
: `${t('track')}${album.AlbumArtist}${album.Name}`
}
</HalfOpacity>
)}
</View>
<View style={{ marginLeft: 16 }}>
<DownloadIcon trackId={id} />
</View>
<View style={{ marginLeft: 16 }}>
<ChevronRight width={14} height={14} fill={defaultStyles.textQuarterOpacity.color} />
</View>
</SearchResult>
</TouchableHandler>
);
}}
keyExtractor={(item) => item.id}
extraData={[searchTerm, albums]}
/>
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
<FullSizeContainer>
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
</FullSizeContainer>
) : null}
{HeaderComponent}
</SafeAreaView>
<Stack.Navigator initialRouteName="Search" screenOptions={{
headerTintColor: THEME_COLOR,
headerTitleStyle: defaultStyles.stackHeader,
cardStyle: defaultStyles.view,
headerTransparent: true,
headerBackground: () => <ColoredBlurView style={StyleSheet.absoluteFill} />,
}}>
<Stack.Screen name="Search" component={Search} options={{ headerTitle: t('search'), headerShown: false }} />
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
</Stack.Navigator>
);
}
}
export default SearchStack;

View File

@@ -0,0 +1,335 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import Input from 'components/Input';
import { ActivityIndicator, Animated, SafeAreaView, View } from 'react-native';
import styled from 'styled-components/native';
import { useAppDispatch, useTypedSelector } from 'store';
import Fuse from 'fuse.js';
import { Album, AlbumTrack } from 'store/music/types';
import { FlatList } from 'react-native-gesture-handler';
import TouchableHandler from 'components/TouchableHandler';
import { useNavigation } from '@react-navigation/native';
import { useGetImage } from 'utility/JellyfinApi';
import FastImage from 'react-native-fast-image';
import { t } from '@localisation';
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { searchAndFetchAlbums } from 'store/music/actions';
import { debounce } from 'lodash';
import { Text } from 'components/Typography';
import DownloadIcon from 'components/DownloadIcon';
import ChevronRight from 'assets/icons/chevron-right.svg';
import SearchIcon from 'assets/icons/magnifying-glass.svg';
import { ShadowWrapper } from 'components/Shadow';
import { useKeyboardHeight } from 'utility/useKeyboardHeight';
import { NavigationProp } from 'screens/types';
import { useNavigationOffsets } from 'components/SafeNavigatorView';
// import MicrophoneIcon from 'assets/icons/microphone.svg';
// import AlbumIcon from 'assets/icons/collection.svg';
// import TrackIcon from 'assets/icons/note.svg';
// import PlaylistIcon from 'assets/icons/note-list.svg';
// import StreamIcon from 'assets/icons/cloud.svg';
// import LocalIcon from 'assets/icons/internal-drive.svg';
// import SelectableFilter from './components/SelectableFilter';
const SEARCH_INPUT_HEIGHT = 62;
const Container = styled(View)`
padding: 4px 24px 0 24px;
margin-bottom: 0px;
padding-bottom: 0px;
border-top-width: 0.5px;
`;
const FullSizeContainer = styled.View`
flex: 1;
`;
const Loading = styled.View`
position: absolute;
right: 12px;
top: 0;
height: 100%;
flex: 1;
justify-content: center;
`;
const AlbumImage = styled(FastImage)`
border-radius: 4px;
width: 32px;
height: 32px;
margin-right: 10px;
`;
const HalfOpacity = styled.Text`
opacity: 0.5;
margin-top: 2px;
font-size: 12px;
flex: 1 1 auto;
`;
const SearchResult = styled.View`
flex-direction: row;
align-items: center;
padding: 8px 32px;
height: 54px;
`;
const fuseOptions: Fuse.IFuseOptions<Album> = {
keys: ['Name', 'AlbumArtist', 'AlbumArtists', 'Artists'],
threshold: 0.1,
includeScore: true,
fieldNormWeight: 1,
};
type AudioResult = {
type: 'Audio',
id: string;
album: string;
name: string;
};
type AlbumResult = {
type: 'AlbumArtist',
id: string;
album: undefined;
name: undefined;
}
type CombinedResults = (AudioResult | AlbumResult)[];
export default function Search() {
const defaultStyles = useDefaultStyles();
const offsets = useNavigationOffsets({ includeOverlay: false });
// Prepare state for fuse and albums
const [fuseIsReady, setFuseReady] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setLoading] = useState(false);
const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
const albums = useTypedSelector(state => state.music.albums.entities);
const fuse = useRef<Fuse<Album>>();
// Prepare helpers
const navigation = useNavigation<NavigationProp>();
const keyboardHeight = useKeyboardHeight();
const getImage = useGetImage();
const dispatch = useAppDispatch();
/**
* Since it is impractical to have a global fuse variable, we need to
* instantiate it for thsi function. With this effect, we generate a new
* Fuse instance every time the albums change. This can of course be done
* more intelligently by removing and adding the changed albums, but this is
* an open todo.
*/
useEffect(() => {
fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions);
setFuseReady(true);
}, [albums, setFuseReady]);
/**
* This function retrieves search results from Jellyfin. It is a seperate
* callback, so that we can make sure it is properly debounced and doesn't
* cause execessive jank in the interface.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string, currentResults: CombinedResults) => {
// First, query the Jellyfin API
const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm }));
// Convert the current results to album ids
const albumIds = currentResults.map(item => item.id);
// Parse the result in correct typescript form
const results = (payload as { results: (Album | AlbumTrack)[] }).results;
// Filter any results that are already displayed
const items = results.filter(item => (
!(item.Type === 'MusicAlbum' && albumIds.includes(item.Id))
// Then convert the results to proper result form
)).map((item) => ({
type: item.Type,
id: item.Id,
album: item.Type === 'Audio'
? item.AlbumId
: undefined,
name: item.Type === 'Audio'
? item.Name
: undefined,
}));
// Lastly, we'll merge the two and assign them to the state
setJellyfinResults([...items] as CombinedResults);
// Loading is now complete
setLoading(false);
}, 50), [dispatch, setJellyfinResults]);
/**
* Whenever the search term changes, we gather results from Fuse and assign
* them to state
*/
useEffect(() => {
if (!searchTerm) {
return;
}
const retrieveResults = async () => {
// GUARD: In some extraordinary cases, Fuse might not be presented since
// it is assigned via refs. In this case, we can't handle any searching.
if (!fuse.current) {
return;
}
// First set the immediate results from fuse
const fuseResults = fuse.current.search(searchTerm);
const albums: AlbumResult[] = fuseResults
.map(({ item }) => ({
id: item.Id,
type: 'AlbumArtist',
album: undefined,
name: undefined,
}));
// Assign the preliminary results
setFuseResults(albums);
setLoading(true);
try {
// Wrap the call in a try/catch block so that we catch any
// network issues in search and just use local search if the
// network is unavailable
fetchJellyfinResults(searchTerm, albums);
} catch {
// Reset the loading indicator if the network fails
setLoading(false);
}
};
retrieveResults();
}, [searchTerm, setFuseResults, setLoading, fuse, fetchJellyfinResults]);
// Handlers
const selectAlbum = useCallback((id: string) => {
navigation.navigate('Album', { id, album: albums[id] as Album });
}, [navigation, albums]);
const SearchInput = React.useMemo(() => (
<Animated.View style={[
{ position: 'absolute', bottom: offsets.bottom, right: 0, left: 0 },
{ transform: [{ translateY: keyboardHeight }] },
]}>
<ColoredBlurView>
<Container style={[ defaultStyles.border ]}>
<View>
<Input
value={searchTerm}
onChangeText={setSearchTerm}
style={[defaultStyles.view, { marginBottom: 12 }]}
placeholder={t('search') + '...'}
icon={<SearchIcon width={14} height={14} fill={defaultStyles.textHalfOpacity.color} />}
testID="search-input"
/>
{isLoading && <Loading style={{ marginTop: -4 }}><ActivityIndicator /></Loading>}
</View>
</Container>
{/* <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={{ paddingHorizontal: 32, paddingBottom: 12, flex: 1, flexDirection: 'row' }}>
<SelectableFilter
text="Artists"
icon={MicrophoneIcon}
active
/>
<SelectableFilter
text="Albums"
icon={AlbumIcon}
active={false}
/>
<SelectableFilter
text="Tracks"
icon={TrackIcon}
active={false}
/>
<SelectableFilter
text="Playlist"
icon={PlaylistIcon}
active={false}
/>
<SelectableFilter
text="Streaming"
icon={StreamIcon}
active={false}
/>
<SelectableFilter
text="Local Playback"
icon={LocalIcon}
active={false}
/>
</View>
</ScrollView> */}
</ColoredBlurView>
</Animated.View>
), [searchTerm, setSearchTerm, defaultStyles, isLoading, keyboardHeight, offsets]);
// GUARD: We cannot search for stuff unless Fuse is loaded with results.
// Therefore we delay rendering to when we are certain it's there.
if (!fuseIsReady) {
return null;
}
return (
<SafeAreaView style={{ flex: 1 }}>
<FlatList
style={{ flex: 2, }}
contentContainerStyle={{ paddingTop: offsets.top, paddingBottom: offsets.bottom + SEARCH_INPUT_HEIGHT }}
scrollIndicatorInsets={{ top: offsets.top / 2, bottom: offsets.bottom / 2 + 10 + SEARCH_INPUT_HEIGHT }}
data={[...jellyfinResults, ...fuseResults]}
renderItem={({ item: { id, type, album: trackAlbum, name: trackName } }: { item: AlbumResult | AudioResult }) => {
const album = albums[trackAlbum || id];
// GUARD: If the album cannot be found in the store, we
// cannot display it.
if (!album) {
return null;
}
return (
<TouchableHandler<string> id={album.Id} onPress={selectAlbum} testID={`search-result-${album.Id}`}>
<SearchResult>
<ShadowWrapper>
<AlbumImage source={{ uri: getImage(album.Id) }} style={defaultStyles.imageBackground} />
</ShadowWrapper>
<View style={{ flex: 1 }}>
<Text numberOfLines={1}>
{trackName || album.Name}
</Text>
{(album.AlbumArtist || album.Name) && (
<HalfOpacity style={defaultStyles.text} numberOfLines={1}>
{type === 'AlbumArtist'
? `${t('album')}${album.AlbumArtist}`
: `${t('track')}${album.AlbumArtist}${album.Name}`
}
</HalfOpacity>
)}
</View>
<View style={{ marginLeft: 16 }}>
<DownloadIcon trackId={id} />
</View>
<View style={{ marginLeft: 16 }}>
<ChevronRight width={14} height={14} fill={defaultStyles.textQuarterOpacity.color} />
</View>
</SearchResult>
</TouchableHandler>
);
}}
keyExtractor={(item) => item.id}
extraData={[searchTerm, albums]}
/>
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
<FullSizeContainer>
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
</FullSizeContainer>
) : null}
{SearchInput}
</SafeAreaView>
);
}

View File

@@ -4,15 +4,15 @@ import music from 'store/music';
import { t } from '@localisation';
import Button from 'components/Button';
import styled from 'styled-components/native';
import { Text } from 'components/Typography';
import { Paragraph } from 'components/Typography';
import { useAppDispatch } from 'store';
import { SafeScrollView } from 'components/SafeNavigatorView';
const ClearCache = styled(Button)`
margin-top: 16px;
`;
const Container = styled.ScrollView`
const Container = styled(SafeScrollView)`
padding: 24px;
`;
@@ -28,7 +28,7 @@ export default function CacheSettings() {
return (
<Container>
<Text>{t('setting-cache-description')}</Text>
<Paragraph>{t('setting-cache-description')}</Paragraph>
<ClearCache title={t('reset-cache')} onPress={handleClearCache} />
</Container>
);

View File

@@ -6,8 +6,8 @@ import { NavigationProp } from '../..';
import { useTypedSelector } from 'store';
import { t } from '@localisation';
import Button from 'components/Button';
import { Text } from 'components/Typography';
import { Paragraph } from 'components/Typography';
import { SafeScrollView } from 'components/SafeNavigatorView';
const InputContainer = styled.View`
margin: 10px 0;
@@ -19,7 +19,7 @@ const Input = styled.TextInput`
border-radius: 5px;
`;
const Container = styled.ScrollView`
const Container = styled(SafeScrollView)`
padding: 24px;
`;
@@ -32,15 +32,15 @@ export default function LibrarySettings() {
return (
<Container>
<InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-server-url')}</Text>
<Paragraph style={defaultStyles.text}>{t('jellyfin-server-url')}</Paragraph>
<Input placeholder="https://jellyfin.yourserver.com/" value={jellyfin?.uri} editable={false} style={defaultStyles.input} />
</InputContainer>
<InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-access-token')}</Text>
<Paragraph style={defaultStyles.text}>{t('jellyfin-access-token')}</Paragraph>
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.access_token} editable={false} style={defaultStyles.input} />
</InputContainer>
<InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-user-id')}</Text>
<Paragraph style={defaultStyles.text}>{t('jellyfin-user-id')}</Paragraph>
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.user_id} editable={false} style={defaultStyles.input} />
</InputContainer>
<Button title={t('set-jellyfin-server')} onPress={handleSetLibrary} />

View File

@@ -1,4 +1,4 @@
import { Text } from 'components/Typography';
import { Paragraph, Text } from 'components/Typography';
import React, { useEffect, useState } from 'react';
import { Switch } from 'react-native-gesture-handler';
@@ -9,8 +9,9 @@ import ChevronIcon from 'assets/icons/chevron-right.svg';
import { THEME_COLOR } from 'CONSTANTS';
import useDefaultStyles, { DefaultStylesProvider } from 'components/Colors';
import { t } from '@localisation';
import { SafeScrollView } from 'components/SafeNavigatorView';
const Container = styled.ScrollView`
const Container = styled.View`
padding: 24px;
`;
@@ -25,7 +26,9 @@ const HeaderContainer = styled.View<{ isActive?: boolean }>`
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 16px 0 4px 0;
padding: 16px 24px;
border-radius: 8px;
overflow: hidden;
${props => props.isActive && css`
background-color: ${THEME_COLOR};
@@ -37,7 +40,8 @@ const HeaderText = styled(Text)`
`;
const ContentContainer = styled.View`
margin-top: 8px;
margin-bottom: 8px;
padding: 8px 24px;
`;
const Label = styled(Text)`
@@ -87,7 +91,7 @@ function renderHeader(question: Question, index: number, isActive: boolean) {
function renderContent(question: Question) {
return (
<ContentContainer>
<Text>{question.content}</Text>
<Paragraph>{question.content}</Paragraph>
</ContentContainer>
);
}
@@ -104,14 +108,17 @@ export default function Sentry() {
});
return (
<Container>
<Text>{t('error-reporting-description')}</Text>
<Text />
<Text>{t('error-reporting-rationale')}</Text>
<SwitchContainer>
<Label>{t('error-reporting')}</Label>
<Switch value={isReportingEnabled} onValueChange={toggleSwitch} />
</SwitchContainer>
<SafeScrollView>
<Container>
<Paragraph>{t('error-reporting-description')}</Paragraph>
<Paragraph />
<Paragraph>{t('error-reporting-rationale')}</Paragraph>
<SwitchContainer>
<Label>{t('error-reporting')}</Label>
<Switch value={isReportingEnabled} onValueChange={toggleSwitch} />
</SwitchContainer>
</Container>
<Accordion
sections={questions}
renderHeader={renderHeader}
@@ -120,6 +127,6 @@ export default function Sentry() {
onChange={setActiveSections}
underlayColor={defaultStyles.activeBackground.backgroundColor}
/>
</Container>
</SafeScrollView>
);
}

View File

@@ -1,8 +1,8 @@
import React, { useCallback } from 'react';
import { SafeAreaView, ScrollView } from 'react-native';
import { StyleSheet } from 'react-native';
import Library from './components/Library';
import Cache from './components/Cache';
import useDefaultStyles from 'components/Colors';
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
import { t } from '@localisation';
import { createStackNavigator } from '@react-navigation/stack';
import { useNavigation } from '@react-navigation/native';
@@ -10,6 +10,7 @@ import ListButton from 'components/ListButton';
import { THEME_COLOR } from 'CONSTANTS';
import Sentry from './components/Sentry';
import { SettingsNavigationProp } from './types';
import { SafeScrollView } from 'components/SafeNavigatorView';
export function SettingsList() {
const navigation = useNavigation<SettingsNavigationProp>();
@@ -18,13 +19,11 @@ export function SettingsList() {
const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]);
return (
<ScrollView>
<SafeAreaView>
<ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton>
<ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton>
<ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton>
</SafeAreaView>
</ScrollView>
<SafeScrollView>
<ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton>
<ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton>
<ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton>
</SafeScrollView>
);
}
@@ -34,9 +33,11 @@ export default function Settings() {
const defaultStyles = useDefaultStyles();
return (
<Stack.Navigator initialRouteName="SettingList" screenOptions={{
<Stack.Navigator initialRouteName="SettingList" screenOptions={{
headerTintColor: THEME_COLOR,
headerTitleStyle: defaultStyles.stackHeader
headerTitleStyle: defaultStyles.stackHeader,
headerTransparent: true,
headerBackground: () => <ColoredBlurView style={StyleSheet.absoluteFill} />,
}}>
<Stack.Screen name="SettingList" component={SettingsList} options={{ headerTitle: t('settings') }} />
<Stack.Screen name="Library" component={Library} options={{ headerTitle: t('jellyfin-library') }} />

View File

@@ -5,7 +5,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { CompositeNavigationProp } from '@react-navigation/native';
import { THEME_COLOR } from 'CONSTANTS';
import Search from './Search';
import SearchStack from './Search';
import Music from './Music';
import Settings from './Settings';
import Downloads from './Downloads';
@@ -18,13 +18,15 @@ import NotesIcon from 'assets/icons/notes.svg';
import GearIcon from 'assets/icons/gear.svg';
import DownloadsIcon from 'assets/icons/arrow-down-to-line.svg';
import { useTypedSelector } from 'store';
import { ModalStackParams } from './types';
import { t } from '@localisation';
import ErrorReportingAlert from 'utility/ErrorReportingAlert';
import ErrorReportingPopup from './modals/ErrorReportingPopup';
import Player from './modals/Player';
import { StyleSheet } from 'react-native';
import { ColoredBlurView } from 'components/Colors';
import { StackParams } from './types';
const Stack = createNativeStackNavigator<ModalStackParams>();
const Stack = createNativeStackNavigator<StackParams>();
const Tab = createBottomTabNavigator();
type Screens = {
@@ -48,9 +50,9 @@ function Screens() {
screenOptions={({ route }) => ({
tabBarIcon: function TabBarIcon({ color, size }) {
switch (route.name) {
case 'Search':
case 'SearchTab':
return <SearchIcon fill={color} height={size - 4} width={size - 4} />;
case 'Music':
case 'MusicTab':
return <NotesIcon fill={color} height={size} width={size} />;
case 'Settings':
return <GearIcon fill={color} height={size - 1} width={size - 1} />;
@@ -63,10 +65,15 @@ function Screens() {
tabBarActiveTintColor: THEME_COLOR,
tabBarInactiveTintColor: 'gray',
headerShown: false,
tabBarShowLabel: false,
tabBarStyle: { position: 'absolute' },
tabBarBackground: () => (
<ColoredBlurView style={StyleSheet.absoluteFill} />
)
})}
>
<Tab.Screen name="Music" component={Music} options={{ tabBarLabel: t('music'), tabBarTestID: 'music-tab' }} />
<Tab.Screen name="Search" component={Search} options={{ tabBarLabel: t('search'), tabBarTestID: 'search-tab' }} />
<Tab.Screen name="MusicTab" component={Music} options={{ tabBarLabel: t('music'), tabBarTestID: 'music-tab' }} />
<Tab.Screen name="SearchTab" component={SearchStack} options={{ tabBarLabel: t('search'), tabBarTestID: 'search-tab' }} />
<Tab.Screen name="Downloads" component={Downloads} options={{ tabBarLabel: t('downloads'), tabBarTestID: 'downloads-tab'}} />
<Tab.Screen name="Settings" component={Settings} options={{ tabBarLabel: t('settings'), tabBarTestID: 'settings-tab' }} />
</Tab.Navigator>

View File

@@ -60,7 +60,6 @@ class CredentialGenerator extends Component<Props> {
return (
<WebView
source={{ uri: serverUrl as string }}
style={{ borderRadius: 20 }}
onNavigationStateChange={this.handleStateChange}
onMessage={this.handleMessage}
ref={this.ref}

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import { useNavigation, StackActions, useRoute, RouteProp } from '@react-navigation/native';
import { ModalStackParams } from 'screens/types';
import { StackParams } from 'screens/types';
import { useAppDispatch, useTypedSelector } from 'store';
import { Header, SubHeader } from 'components/Typography';
import styled from 'styled-components/native';
@@ -17,7 +17,7 @@ import usePlayTracks from 'utility/usePlayTracks';
import { selectIsDownloaded } from 'store/downloads/selectors';
import { useGetImage } from 'utility/JellyfinApi';
type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>;
type Route = RouteProp<StackParams, 'TrackPopupMenu'>;
const Container = styled.View`
padding: 40px;

View File

@@ -1,9 +1,16 @@
import { StackNavigationProp } from '@react-navigation/stack';
import { Album } from 'store/music/types';
export interface ModalStackParams {
export type StackParams = {
[key: string]: Record<string, unknown> | undefined;
Albums: undefined;
Album: { id: string, album: Album };
Playlists: undefined;
Playlist: { id: string };
RecentAlbums: undefined;
Search: undefined;
SetJellyfinServer: undefined;
TrackPopupMenu: { trackId: string };
}
};
export type ModalNavigationProp = StackNavigationProp<ModalStackParams>;
export type NavigationProp = StackNavigationProp<StackParams>;

View File

@@ -46,6 +46,14 @@ export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncTh
}
);
export const fetchAlbum = createAsyncThunk<Album, string, AsyncThunkAPI>(
'/albums/single',
async (ItemId, thunkAPI) => {
const credentials = thunkAPI.getState().settings.jellyfin;
return retrieveAlbum(credentials, ItemId) as Promise<Album>;
}
);
type SearchAndFetchResults = {
albums: Album[];
results: (Album | AlbumTrack)[];

View File

@@ -7,7 +7,8 @@ import {
searchAndFetchAlbums,
playlistAdapter,
fetchAllPlaylists,
fetchTracksByPlaylist
fetchTracksByPlaylist,
fetchAlbum
} from './actions';
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
import { Album, AlbumTrack, Playlist } from './types';
@@ -69,6 +70,15 @@ const music = createSlice({
});
builder.addCase(fetchAllAlbums.pending, (state) => { state.albums.isLoading = true; });
builder.addCase(fetchAllAlbums.rejected, (state) => { state.albums.isLoading = false; });
/**
* Fetch single album
*/
builder.addCase(fetchAlbum.fulfilled, (state, { payload }) => {
albumAdapter.upsertOne(state.albums, payload);
});
builder.addCase(fetchAlbum.pending, (state) => { state.albums.isLoading = true; });
builder.addCase(fetchAlbum.rejected, (state) => { state.albums.isLoading = false; });
/**
* Fetch most recent albums

View File

@@ -50,7 +50,7 @@ export const selectAlbumsByArtist = createSelector(
albumsByArtist,
);
export type SectionedId = SectionListData<EntityId>;
export type SectionedId = SectionListData<EntityId[]>;
/**
* Splits a set of albums into a list that is split by alphabet letters
@@ -58,13 +58,26 @@ export type SectionedId = SectionListData<EntityId>;
function splitAlbumsByAlphabet(state: AppState['music']['albums']): SectionedId[] {
const { entities: albums } = state;
const albumIds = albumsByArtist(state);
const sections: SectionedId[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [] }));
const sections: SectionedId[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [[]] }));
albumIds.forEach((id) => {
// Retrieve the album letter and corresponding letter index
const album = albums[id];
const letter = album?.AlbumArtist?.toUpperCase().charAt(0);
const index = letter ? ALPHABET_LETTERS.indexOf(letter) : 26;
(sections[index >= 0 ? index : 26].data as Array<EntityId>).push(id);
// Then find the current row in this section (note that albums are
// grouped in pairs so we can render them more easily).
const section = sections[index >= 0 ? index : 26];
const row = section.data.length - 1;
// Add the album to the row
section.data[row].push(id);
// GUARD: Check if the row is overflowing. If so, add a new row.
if (section.data[row].length >= 2) {
(section.data as EntityId[][]).push([]);
}
});
return sections;

View File

@@ -43,6 +43,8 @@ export interface Album {
Tracks?: string[];
lastRefreshed?: number;
DateCreated: string;
Overview?: string;
Similar?: string[];
}
export interface AlbumTrack {
@@ -94,4 +96,8 @@ export interface Playlist {
MediaType: string;
Tracks?: string[];
lastRefreshed?: number;
}
export interface SimilarAlbum {
Id: string;
}

View File

@@ -5,7 +5,7 @@ import { t } from '@localisation';
import { setReceivedErrorReportingAlert } from 'store/settings/actions';
import { setSentryStatus } from './Sentry';
import { useNavigation } from '@react-navigation/native';
import { ModalNavigationProp } from 'screens/types';
import { NavigationProp } from 'screens/types';
/**
* This will send out an alert message asking the user if they want to enable
@@ -13,7 +13,7 @@ import { ModalNavigationProp } from 'screens/types';
*/
export default function ErrorReportingAlert() {
const { hasReceivedErrorReportingAlert } = useTypedSelector(state => state.settings);
const navigation = useNavigation<ModalNavigationProp>();
const navigation = useNavigation<NavigationProp>();
const dispatch = useAppDispatch();
useEffect(() => {

View File

@@ -1,6 +1,6 @@
import { Track } from 'react-native-track-player';
import { AppState, useTypedSelector } from 'store';
import { Album, AlbumTrack } from 'store/music/types';
import { Album, AlbumTrack, SimilarAlbum } from 'store/music/types';
type Credentials = AppState['settings']['jellyfin'];
@@ -86,8 +86,14 @@ export async function retrieveAllAlbums(credentials: Credentials) {
*/
export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> {
const config = generateConfig(credentials);
const Similar = await fetch(`${credentials?.uri}/Items/${id}/Similar?userId=${credentials?.user_id}&limit=12`, config)
.then(response => response.json() as Promise<{ Items: SimilarAlbum[] }>)
.then((albums) => albums.Items.map((a) => a.Id));
return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config)
.then(response => response.json());
.then(response => response.json() as Promise<Album>)
.then(album => ({ ...album, Similar }));
}
const latestAlbumsOptions = {
@@ -96,7 +102,6 @@ const latestAlbumsOptions = {
SortOrder: 'Ascending',
};
/**
* Retrieve the most recently added albums on the Jellyfin server
*/

View File

@@ -1,9 +1,9 @@
function ticksToDuration(ticks: number) {
const seconds = Math.round(ticks / 10000000);
const minutes = Math.round(seconds / 60);
const hours = Math.round(minutes / 60);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
return `${hours > 0 ? hours + ':' : ''}${minutes}:${(seconds % 60).toString().padStart(2, '0')}`;
return `${hours > 0 ? hours + ':' : ''}${(minutes % 60).toString().padStart(hours > 0 ? 2 : 0, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
}
export default ticksToDuration;

View File

@@ -1,6 +1,6 @@
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useRef, useEffect } from 'react';
import { Animated, Keyboard, KeyboardEvent } from 'react-native';
import { Animated, Easing, Keyboard, KeyboardEvent } from 'react-native';
/**
* This returns an animated height that the keyboard is poking up from the
@@ -15,9 +15,10 @@ export const useKeyboardHeight = () => {
useEffect(() => {
const keyboardWillShow = (e: KeyboardEvent) => {
Animated.timing(keyboardHeight, {
duration: e.duration,
duration: e.duration - 20,
toValue: tabBarHeight - e.endCoordinates.height,
useNativeDriver: true,
easing: Easing.ease,
}).start();
};