Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
287b64c356 | ||
|
|
e116e95236 | ||
|
|
c8283fc580 | ||
|
|
81b9ba683a | ||
|
|
913d185b46 | ||
|
|
1d97830f83 | ||
|
|
6ccfd19dea | ||
|
|
2e816f4a71 | ||
|
|
4ff071d0c8 | ||
|
|
dba87247d8 | ||
|
|
c3c32ae565 | ||
|
|
1d7db11328 | ||
|
|
1a5e4aee12 | ||
|
|
e2c1c0300f | ||
|
|
7601408d49 | ||
|
|
4509ef1ec6 | ||
|
|
dcd3f595ed | ||
|
|
c704a27c09 | ||
|
|
67499b1103 | ||
|
|
5bbc3b8588 | ||
|
|
f71c46eacc | ||
|
|
e4b75ce0a2 | ||
|
|
d45195dc6b | ||
|
|
4419b387e9 | ||
|
|
63a755a231 | ||
|
|
1d7b3d7e6c | ||
|
|
531c6f708d | ||
|
|
56647cd7ab | ||
|
|
1648389ccc | ||
|
|
a532154ce0 | ||
|
|
74d82eb77a | ||
|
|
a8c0003fc1 | ||
|
|
ba805e061e | ||
|
|
cc14373575 | ||
|
|
943815e4a6 | ||
|
|
2f45f868c8 | ||
|
|
0a0c78f3d5 | ||
|
|
40ecfb08fb | ||
|
|
099bbebe38 | ||
|
|
a34b6c5114 | ||
|
|
7353b04dd1 | ||
|
|
a2c1a82ebb | ||
|
|
ccfa68c530 | ||
|
|
6885ae6216 |
@@ -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
@@ -72,4 +72,5 @@ certificates/
|
||||
sentry.properties
|
||||
|
||||
screenshots
|
||||
fastlane/Preview.html
|
||||
fastlane/Preview.html
|
||||
fastlane/play-store-credentials.json
|
||||
189
CHANGELOG.md
Normal 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)
|
||||
|
||||
|
||||
|
||||
50
Gemfile.lock
@@ -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)
|
||||
|
||||
41
README.md
@@ -1,34 +1,33 @@
|
||||
# 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).
|
||||

|
||||
|
||||
## ❗️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.
|
||||
[](https://apple.co/3MFYIJH)
|
||||
|
||||
[](https://github.com/leinelissen/jellyfin-audio-player/releases/latest)
|
||||
[](https://apple.co/3MFYIJH)
|
||||

|
||||
[](./LICENSE.md)
|
||||
[](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>
|
||||
|
||||
|||
|
||||
|-|-|-|
|
||||
|||
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -138,8 +138,8 @@ android {
|
||||
applicationId "nl.moeilijkedingen.jellyfinaudioplayer"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 13
|
||||
versionName "2.0.1"
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/react_native_config"
|
||||
tools:targetApi="28"
|
||||
tools:ignore="GoogleAppIndexingWarning">
|
||||
<activity
|
||||
|
||||
9
android/app/src/debug/res/xml/react_native_config.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="user"/>
|
||||
<certificates src="system"/>
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -9,7 +9,7 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:allowBackup="false"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/react_native_config"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
9
android/app/src/main/res/xml/react_native_config.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="user"/>
|
||||
<certificates src="system"/>
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
46
docs/images/app-store.svg
Executable 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="<Group>">
|
||||
<g id="_Group_2" data-name="<Group>">
|
||||
<g id="_Group_3" data-name="<Group>">
|
||||
<path id="_Path_" data-name="<Path>" 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="<Path>" 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="<Group>">
|
||||
<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 |
BIN
docs/images/fintunes-banner.png
Normal file
|
After Width: | Height: | Size: 755 KiB |
@@ -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")
|
||||
@@ -10,7 +10,7 @@ platform :ios do
|
||||
get_provisioning_profile(
|
||||
output_path: 'certificates/',
|
||||
filename: "provisioning.mobileprovision",
|
||||
fail_on_name_taken: true,
|
||||
fail_on_name_taken: false,
|
||||
)
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: true,
|
||||
@@ -42,7 +42,16 @@ platform :ios do
|
||||
workspace: "ios/Fintunes.xcworkspace",
|
||||
export_method: "app-store",
|
||||
)
|
||||
upload_to_testflight
|
||||
upload_to_testflight()
|
||||
end
|
||||
|
||||
lane :build do
|
||||
build_app(
|
||||
scheme: "Fintunes",
|
||||
output_directory: "build",
|
||||
workspace: "ios/Fintunes.xcworkspace",
|
||||
export_method: "app-store",
|
||||
)
|
||||
end
|
||||
|
||||
after_all do
|
||||
@@ -101,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
1
fastlane/metadata/android/en-GB/full_description.txt
Normal 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.
|
||||
1
fastlane/metadata/android/en-GB/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Streaming audio player for Jellyfin
|
||||
1
fastlane/metadata/android/en-GB/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Fintunes
|
||||
0
fastlane/metadata/android/en-GB/video.txt
Normal file
|
After Width: | Height: | Size: 746 KiB |
|
After Width: | Height: | Size: 730 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 366 KiB |
@@ -606,7 +606,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
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 = 49;
|
||||
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 = 49;
|
||||
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 = 49;
|
||||
CURRENT_PROJECT_VERSION = 64;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.1</string>
|
||||
<string>2.1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>49</string>
|
||||
<string>64</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
466
ios/Podfile.lock
@@ -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
15
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fintunes",
|
||||
"version": "2.0.1",
|
||||
"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",
|
||||
|
||||
22
patches/react-native-fast-image+8.6.3.patch
Normal 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(@{});
|
||||
}
|
||||
}
|
||||
BIN
src/assets/images/empty-album-dark.png
Normal file
|
After Width: | Height: | Size: 1005 KiB |
BIN
src/assets/images/empty-album-light.png
Normal file
|
After Width: | Height: | Size: 926 KiB |
@@ -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',
|
||||
} ]} />
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,14 +40,24 @@ 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} style={{ margin: 0, padding: 0 }} ref={inputRef} testID={`${testID}-textinput`} />
|
||||
<TextInput
|
||||
{...rest}
|
||||
style={[defaultStyles.text, { margin: 0, padding: 0 }]}
|
||||
ref={inputRef}
|
||||
testID={`${testID}-textinput`}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
100
src/components/SafeNavigatorView.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -57,4 +57,6 @@ export type LocaleKeys = 'play-next'
|
||||
| 'you-are-offline-message'
|
||||
| 'playing-on'
|
||||
| 'local-playback'
|
||||
| 'streaming'
|
||||
| 'streaming'
|
||||
| 'total-duration'
|
||||
| 'similar-albums'
|
||||
@@ -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>
|
||||
|
||||
@@ -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') }} />
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
39
src/screens/Music/stacks/components/AlbumImage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
335
src/screens/Search/stacks/Search/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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') }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||