Compare commits

...

77 Commits

Author SHA1 Message Date
Lei Nelissen
6440c1ac7b Bump version 2021-10-25 23:50:33 +02:00
Lei Nelissen
6c0a277397 Update dependencies 2021-10-25 23:36:14 +02:00
Lei Nelissen
080a664d4f Merge pull request #58 from Sjord/nl-locale-fixes
Dutch translation fixes
2021-10-25 22:25:22 +02:00
Sjoerd Langkemper
ae6a99ce6d Dutch translation fixes
achtergrond = background
instellen, stel ... in = to configure
2021-10-12 21:26:02 +02:00
Lei Nelissen
b14f546525 Add Japanese as a locale 2021-05-12 22:18:23 +02:00
Lei Nelissen
2b8e78a076 Merge pull request #46 from leinelissen/translations_src-localisation-lang-en-locale-json--master_ja
Translate '/src/localisation/lang/en/locale.json' in 'ja'
2021-05-12 22:14:56 +02:00
Lei Nelissen
4a345699a8 Fix search box (fix #47) 2021-05-12 22:13:44 +02:00
Lei Nelissen
eaef9be7db Update dependencies 2021-05-12 22:03:29 +02:00
transifex-integration[bot]
8049a25cce Apply translations in ja
translation completed for the source file '/src/localisation/lang/en/locale.json'
on the 'ja' language.
2021-05-12 14:42:54 +00:00
Lei Nelissen
7403ab986b Release build 16 2021-04-25 23:45:20 +02:00
Lei Nelissen
d8e9f8f4c6 Allow arbitrary loads (ie HTTP) on iOS
Fixes #42
2021-04-25 23:16:27 +02:00
Lei Nelissen
fa05935017 Release build 15 2021-04-24 23:18:01 +02:00
Lei Nelissen
2de5cc8e6c Polish search engine UX 2021-04-24 15:30:07 +02:00
Lei Nelissen
24d484ca25 Add track results to search queries 2021-04-24 14:50:43 +02:00
Lei Nelissen
a7b24cf4eb Fix XMLDom issue 2021-04-24 12:21:02 +02:00
Lei Nelissen
eaa1402173 Update packages 2021-04-24 12:18:56 +02:00
Lei Nelissen
1edeb00631 Fix active button color on Android 2021-04-03 15:19:38 +02:00
Lei Nelissen
d422c1ff1e Pull current track from Redux store
Fixes #40
2021-04-03 14:49:49 +02:00
Lei Nelissen
28b330ad4c Update all dependencies 2021-04-03 14:49:01 +02:00
Lei Nelissen
a867513212 Update Fastlane Sentry plugin 2021-03-22 09:49:33 +01:00
Lei Nelissen
14a6341fae Release build 13 2021-03-22 09:24:36 +01:00
Lei Nelissen
645f1307f3 Add OGG Support
Fixes #12
2021-03-21 23:01:07 +01:00
Lei Nelissen
3f16bd6465 Show Album art instead of Track art if an AlbumId is available
Fixes #13
2021-03-21 22:55:35 +01:00
Lei Nelissen
5c15d730b3 Add AirPlay casting button 2021-03-21 22:42:04 +01:00
Lei Nelissen
603bf10154 Switch back to regular react-native-track-player version as patch was merged 2021-03-21 22:12:36 +01:00
Lei Nelissen
57f9e0e08e Upgrade Android build for React Native 0.64 2021-03-21 21:50:35 +01:00
Lei Nelissen
597a348eab Release build 12 2021-03-21 21:50:15 +01:00
Lei Nelissen
8860581450 Upgrade to React Native 0.64 2021-03-21 20:16:26 +01:00
Lei Nelissen
a92ff581ae Merge pull request #38 from yisiliang/master
add Chinese language support
2021-03-21 18:51:04 +01:00
YiSiliang
cd2e333caa add Chinese language support 2021-03-14 21:07:50 +08:00
Lei Nelissen
4ba83b8efc Release build 11 2021-03-09 22:42:34 +01:00
Lei Nelissen
e4daccfe5e Update packages 2021-03-09 22:25:43 +01:00
Lei Nelissen
76c45623a0 Fix launch crash on devices without fallback languages 2021-03-09 22:21:41 +01:00
Lei Nelissen
e4b93ec8c8 Release build 10 2021-02-13 22:16:04 +01:00
Lei Nelissen
4635266273 Add screens for opting into Sentry error tracking 2021-02-13 15:34:43 +01:00
Lei Nelissen
8dc287e56a Make theming somewhat more performant 2021-02-13 12:14:57 +01:00
Lei Nelissen
6cfa8f7624 Attempt fix for JELLYFIN-AUDIO-PLAYER-M 2021-02-11 23:55:49 +01:00
Lei Nelissen
6cf5b8167b Release build 9 2021-02-11 23:47:42 +01:00
Lei Nelissen
dc76ea27d3 Double-check whether an album has tracks 2021-02-11 23:46:03 +01:00
Lei Nelissen
7adc96ba12 Fancy new buttons and more consistent colors 2021-02-11 23:43:21 +01:00
Lei Nelissen
42eb7a169b Release build number 8 2021-02-09 23:33:34 +01:00
Lei Nelissen
3eab7793f5 Allow for HTTP connections in Android Webviews 2021-02-07 23:26:45 +01:00
Lei Nelissen
66eb4bd61d Add environment variables for sentry 2021-02-07 23:19:49 +01:00
Lei Nelissen
3312e99911 Minor package versions upgrade 2021-02-07 23:06:34 +01:00
Lei Nelissen
3ae967e137 Base setup for Sentry 2021-01-29 00:47:17 +01:00
Lei Nelissen
d18daf027e Update packages 2021-01-28 17:40:11 +01:00
Lei Nelissen
0ae7e3ba0c Release new build 2020-12-02 20:51:03 +01:00
Lei Nelissen
e918f089e2 Add Spanish locale as getter 2020-12-02 20:35:46 +01:00
Lei Nelissen
491f019fb3 Merge pull request #30 from raikoon/add-es-locale
Add Spanish locale
2020-12-02 20:24:43 +01:00
unknown
0af730dabe Add Spanish locale 2020-11-25 14:16:26 +01:00
Lei Nelissen
fadfabc868 Merge pull request #29 from leinelissen/translations_src-localisation-lang-en-locale-json--master_fr
Translate '/src/localisation/lang/en/locale.json' in 'fr'
2020-11-11 12:49:54 +01:00
transifex-integration[bot]
de73fe6358 Apply translations in fr
translation completed updated for the source file '/src/localisation/lang/en/locale.json'
on the 'fr' language.
2020-11-11 11:46:49 +00:00
Lei Nelissen
6f3ed9c988 Release build number 6 2020-11-04 17:05:06 +01:00
Lei Nelissen
ff484cebed Add nl locale as option 2020-11-03 23:10:21 +01:00
Lei Nelissen
1e14ed3271 Merge pull request #23 from leinelissen/translations_src-localisation-lang-en-locale-json--master_nl
Translate '/src/localisation/lang/en/locale.json' in 'nl'
2020-11-03 23:09:40 +01:00
Lei Nelissen
954fe38531 Merge pull request #24 from leinelissen/translations_src-localisation-lang-en-locale-json--master_fr
Translate '/src/localisation/lang/en/locale.json' in 'fr'
2020-11-03 23:09:13 +01:00
transifex-integration[bot]
83cf0e4d56 Apply translations in fr
translation completed for the source file '/src/localisation/lang/en/locale.json'
on the 'fr' language.
2020-11-03 18:55:06 +00:00
transifex-integration[bot]
3b98eb4bf1 Apply translations in nl
translation completed updated for the source file '/src/localisation/lang/en/locale.json'
on the 'nl' language.
2020-11-03 16:55:52 +00:00
Lei Nelissen
49bb4dfaab Merge branch 'master' of https://github.com/leinelissen/jellyfin-audio-player 2020-11-03 10:21:32 +01:00
Lei Nelissen
9f9e61017e Update project version for release 2020-11-03 10:21:17 +01:00
Lei Nelissen
65c7db9911 Merge pull request #21 from leinelissen/translations_src-localisation-lang-en-locale-json--master_fr
Translate '/src/localisation/lang/en/locale.json' in 'fr' [manual sync]
2020-11-02 23:35:15 +01:00
Lei Nelissen
481a411e4c Delete locale.json 2020-11-02 23:34:19 +01:00
transifex-integration[bot]
8eeddebad7 Apply translations in fr
at least 50% translated updated for the source file '/src/localisation/lang/en/locale.json'
on the 'fr' language.

 Manual sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format
2020-11-02 22:33:08 +00:00
transifex-integration[bot]
974c73e70a Apply translations in fr
at least 50% translated updated for the source file '/src/localisation/lang/en/locale.json'
on the 'fr' language.

 Manual sync of partially translated files: untranslated content is included with an empty translation or source language content depending on file format
2020-11-02 22:28:20 +00:00
Lei Nelissen
0988e10a09 Add example locale? 2020-11-02 23:20:26 +01:00
Lei Nelissen
9ea0753912 Give locale file static name so Transifex is better at recognizing it 2020-11-02 23:06:24 +01:00
Lei Nelissen
6cf421f24f Implement locales in codebase 2020-11-02 22:50:00 +01:00
Lei Nelissen
0df9d4a621 Add localisation files 2020-11-02 22:43:11 +01:00
Lei Nelissen
6a1d75c27c Update packages for iOS 14 2020-11-02 22:42:57 +01:00
Lei Nelissen
c88158cf16 (1) Play whole album when selecting a single track
(2) Create popup window on track long-press in which the track can be added to the end or front of the queue
(3) Add Redux counter for added tracks so that the queue is properly updated
2020-08-28 16:28:49 +02:00
Lei Nelissen
3026fdf4da Add repeat mode 2020-08-28 14:17:54 +02:00
Lei Nelissen
12b53eca4a Allow for clearing the Redux store from the settings page 2020-08-28 14:17:37 +02:00
Lei Nelissen
3ff971a773 Support FLAC 2020-08-28 12:45:21 +02:00
Lei Nelissen
8fac21f4d8 Add button for clearing current queue 2020-08-28 12:45:00 +02:00
Lei Nelissen
6e85013b5a (1) Fix missing image on first track
(2) Dial back the updates in useCurrentTrack
2020-08-25 23:34:35 +02:00
Lei Nelissen
edce0329cf Setup scheme to work correctly with shortened product name 2020-08-25 23:33:56 +02:00
Lei Nelissen
be40d8a404 Add BMD Logo to README 2020-08-25 11:01:31 +02:00
103 changed files with 8961 additions and 7731 deletions

3
.editorconfig Normal file
View File

@@ -0,0 +1,3 @@
# Windows files
[*.bat]
end_of_line = crlf

View File

@@ -8,7 +8,7 @@ module.exports = {
'plugin:react/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:react-hooks/recommended',
// "plugin:@typescript-eslint/recommended"
// 'plugin:@typescript-eslint/recommended'
],
globals: {
Atomics: 'readonly',
@@ -28,14 +28,15 @@ module.exports = {
'react-hooks'
],
rules: {
indent: [
indent: 'off',
'@typescript-eslint/indent': [
'error',
4,
{
SwitchCase: 1,
}
],
"linebreak-style": [
'linebreak-style': [
'error',
'unix'
],
@@ -47,10 +48,10 @@ module.exports = {
'error',
'always'
],
"no-unused-vars": "off",
"react/prop-types": "off",
"@typescript-eslint/no-unused-vars": [
"error"
'no-unused-vars': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-unused-vars': [
'error'
]
}
};

4
.gitattributes vendored
View File

@@ -1 +1,3 @@
*.pbxproj -text
# Windows files should use crlf line endings
# https://help.github.com/articles/dealing-with-line-endings/
*.bat text eol=crlf

View File

@@ -41,6 +41,12 @@ jobs:
- name: Install Node dependencies
run: npm install
- name: Generate APK
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
run: fastlane android beta
- name: Upload artifact
uses: actions/upload-artifact@v2

5
.gitignore vendored
View File

@@ -65,4 +65,7 @@ buck-out/
build/
fastlane/report.xml
fastlane/Appfile
certificates/
certificates/
.env
sentry.properties

View File

@@ -7,3 +7,6 @@ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
# gem "rails"
gem "fastlane", "~> 2.153"
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

View File

@@ -1,57 +1,75 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.2)
addressable (2.7.0)
CFPropertyList (3.0.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.1.0)
aws-partitions (1.355.0)
aws-sdk-core (3.104.3)
aws-eventstream (1.1.1)
aws-partitions (1.488.0)
aws-sdk-core (3.119.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.36.0)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sdk-kms (1.46.0)
aws-sdk-core (~> 3, >= 3.119.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.78.0)
aws-sdk-core (~> 3, >= 3.104.3)
aws-sdk-s3 (1.99.0)
aws-sdk-core (~> 3, >= 3.119.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.2)
aws-sigv4 (1.2.4)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.3)
babosa (1.0.4)
claide (1.0.3)
colored (1.2)
colored2 (3.1.2)
commander-fastlane (4.4.6)
highline (~> 1.7.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
declarative-option (0.1.0)
digest-crc (0.6.1)
rake (~> 13.0)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.7.6)
emoji_regex (3.0.0)
excon (0.76.0)
faraday (1.0.1)
emoji_regex (3.2.2)
excon (0.85.0)
faraday (1.7.0)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0.1)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.1)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
multipart-post (>= 1.2, < 3)
faraday-cookie_jar (0.0.6)
faraday (>= 0.7.4)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday_middleware (1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday_middleware (1.1.0)
faraday (~> 1.0)
fastimage (2.2.0)
fastlane (2.156.1)
fastimage (2.2.5)
fastlane (2.191.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.3, < 3.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander-fastlane (>= 4.4.6, < 5.0.0)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@@ -60,18 +78,19 @@ GEM
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-api-client (>= 0.37.0, < 0.39.0)
google-cloud-storage (>= 1.15.0, < 2.0.0)
highline (>= 1.7.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0)
naturally (~> 2.2)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
slack-notifier (>= 2.0.0, < 3.0.0)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
tty-screen (>= 0.6.3, < 1.0.0)
@@ -80,62 +99,75 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
fastlane-plugin-sentry (1.8.1)
gh_inspector (1.1.3)
google-api-client (0.38.0)
google-apis-androidpublisher_v3 (0.10.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-core (0.4.1)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 0.9)
httpclient (>= 2.8.1, < 3.0)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.0)
signet (~> 0.12)
google-cloud-core (1.5.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.6.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-playcustomapp_v1 (0.5.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-storage_v1 (0.6.0)
google-apis-core (>= 0.4, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.3.3)
google-cloud-env (1.5.0)
faraday (>= 0.17.3, < 2.0)
google-cloud-errors (1.0.1)
google-cloud-storage (1.27.0)
google-cloud-errors (1.1.0)
google-cloud-storage (1.34.1)
addressable (~> 2.5)
digest-crc (~> 0.4)
google-api-client (~> 0.33)
google-cloud-core (~> 1.2)
googleauth (~> 0.9)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.1)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (0.13.1)
googleauth (0.17.0)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (~> 0.14)
highline (1.7.10)
http-cookie (1.0.3)
highline (2.0.3)
http-cookie (1.0.4)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.4.0)
json (2.3.1)
jwt (2.2.1)
json (2.5.1)
jwt (2.2.3)
memoist (0.16.2)
mini_magick (4.10.1)
mini_mime (1.0.2)
mini_magick (4.11.0)
mini_mime (1.1.0)
multi_json (1.15.0)
multipart-post (2.0.0)
nanaimo (0.3.0)
naturally (2.2.0)
naturally (2.2.1)
os (1.1.1)
plist (3.5.0)
public_suffix (4.0.5)
rake (13.0.1)
representable (3.0.4)
plist (3.6.0)
public_suffix (4.0.6)
rake (13.0.6)
representable (3.1.1)
declarative (< 0.1.0)
declarative-option (< 0.2.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.5)
rouge (2.0.7)
rubyzip (2.3.0)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.14.0)
signet (0.15.0)
addressable (~> 2.3)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.5, < 3.0)
@@ -143,10 +175,10 @@ GEM
simctl (1.6.8)
CFPropertyList
naturally
slack-notifier (2.3.2)
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
trailblazer-option (0.1.1)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-spinner (0.9.3)
@@ -156,16 +188,18 @@ GEM
unf_ext
unf_ext (0.0.7.7)
unicode-display_width (1.7.0)
webrick (1.7.0)
word_wrap (1.0.0)
xcodeproj (1.18.0)
xcodeproj (1.21.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
@@ -173,6 +207,7 @@ PLATFORMS
DEPENDENCIES
fastlane (~> 2.153)
fastlane-plugin-sentry
BUNDLED WITH
1.17.2

View File

@@ -60,4 +60,10 @@ fastlane android beta
```
## Licensing and Credits
This work is licensed under the MIT license and was built by Lei Nelissen.
This work is licensed under the MIT license and was built by Lei Nelissen.
<a href="https://bmd.studio">
<img src="./docs/images/bmd-logo-icon.png" alt="BMD Studio" width="150" height="150" />
</a>
An Apple Developer license, as well as various forms of support are graciously provided for by [BMD Studio](https://bmd.studio), experts in finding creative solutions for difficult problems.

View File

@@ -83,6 +83,7 @@ project.ext.react = [
]
apply from: "../../node_modules/react-native/react.gradle"
apply from: "../../node_modules/@sentry/react-native/sentry.gradle"
/**
* Set this to true to create two separate APKs instead of one:
@@ -121,13 +122,15 @@ def jscFlavor = 'org.webkit:android-jsc:+'
*/
def enableHermes = project.ext.react.get("enableHermes", false);
android {
compileSdkVersion rootProject.ext.compileSdkVersion
/**
* Architectures to build native code for in debug.
*/
def nativeArchitectures = project.getProperties().get("reactNativeDebugArchitectures")
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
android {
ndkVersion rootProject.ext.ndkVersion
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.jellyfinaudioplayer"
@@ -156,6 +159,11 @@ android {
buildTypes {
debug {
signingConfig signingConfigs.debug
if (nativeArchitectures) {
ndk {
abiFilters nativeArchitectures.split(',')
}
}
}
release {
// Caution! In production, you need to generate your own keystore file.
@@ -171,11 +179,12 @@ android {
variant.outputs.each { output ->
// For each separate APK per architecture, set a unique version code as described here:
// https://developer.android.com/studio/build/configure-apk-splits.html
// Example: versionCode 1 will generate 1001 for armeabi-v7a, 1002 for x86, etc.
def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4]
def abi = output.getFilter(OutputFile.ABI)
if (abi != null) { // null for the universal-debug, universal-release variants
output.versionCodeOverride =
versionCodes.get(abi) * 1048576 + defaultConfig.versionCode
defaultConfig.versionCode * 1000 + versionCodes.get(abi)
}
}
@@ -213,7 +222,7 @@ dependencies {
// Run this once to be able to run the application with BUCK
// puts all compile dependencies into folder libs for BUCK to use
task copyDownloadableDepsToLibs(type: Copy) {
from configurations.compile
from configurations.implementation
into 'libs'
}

View File

@@ -4,5 +4,10 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" />
<application
android:usesCleartextTraffic="true"
tools:targetApi="28"
tools:ignore="GoogleAppIndexingWarning">
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>
</manifest>

View File

@@ -9,6 +9,7 @@
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
@@ -21,7 +22,5 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>
</manifest>

View File

@@ -1,6 +1,7 @@
package com.jellyfinaudioplayer;
import com.facebook.react.ReactActivity;
import android.os.Bundle;
public class MainActivity extends ReactActivity {
@@ -12,4 +13,9 @@ public class MainActivity extends ReactActivity {
protected String getMainComponentName() {
return "JellyfinAudioPlayer";
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(null);
}
}

View File

@@ -2,17 +2,18 @@
buildscript {
ext {
buildToolsVersion = "28.0.3"
minSdkVersion = 16
compileSdkVersion = 28
targetSdkVersion = 28
buildToolsVersion = "30.0.2"
minSdkVersion = 21
compileSdkVersion = 30
targetSdkVersion = 30
ndkVersion = "21.4.7075529"
}
repositories {
google()
jcenter()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:3.5.2")
classpath("com.android.tools.build:gradle:4.2.2")
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
@@ -21,6 +22,7 @@ buildscript {
allprojects {
repositories {
mavenCentral()
mavenLocal()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm

View File

@@ -25,4 +25,4 @@ android.useAndroidX=true
android.enableJetifier=true
# Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.33.1
FLIPPER_VERSION=0.99.0

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.9-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

2
android/gradlew vendored
View File

@@ -82,6 +82,7 @@ esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
@@ -129,6 +130,7 @@ fi
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath

View File

@@ -1,4 +1,4 @@
{
"name": "JellyfinAudioPlayer",
"displayName": "Audio Player"
"displayName": "Jellyfin Player"
}

View File

@@ -1,19 +1,36 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
presets: [
'module:metro-react-native-babel-preset',
],
plugins: [
[
'module-resolver',
{
// root: ['./src'],
root: ['.'],
extensions: [
'.ios.ts',
'.android.ts',
'.ts',
'.ios.tsx',
'.android.tsx',
'.tsx',
'.jsx',
'.js',
'.json',
],
alias: {
store: './src/store',
components: './src/components',
utility: './src/utility',
screens: './src/screens',
assets: './src/assets',
'@localisation': './src/localisation',
CONSTANTS: './src/CONSTANTS',
}
}
],
[
'module:react-native-dotenv'
]
]
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -7,7 +7,7 @@ platform :ios do
output_path: 'certificates/'
)
build_app(
scheme: "JellyfinAudioPlayer",
scheme: "Jellyfin Player",
export_method: "development",
output_directory: "build",
workspace: "ios/JellyfinAudioPlayer.xcworkspace"
@@ -30,12 +30,48 @@ platform :ios do
xcodeproj: "ios/JellyfinAudioPlayer.xcodeproj"
)
build_app(
scheme: "JellyfinAudioPlayer",
scheme: "Jellyfin Player",
output_directory: "build",
workspace: "ios/JellyfinAudioPlayer.xcworkspace",
export_method: "app-store",
)
upload_to_testflight
build_number = get_build_number(
xcodeproj: "ios/JellyfinAudioPlayer.xcodeproj"
)
Dir.chdir("..") do
sh(
"npx", "react-native", "bundle",
"--dev", "false",
"--platform", "ios",
"--entry-file", "index.js",
"--bundle-output", "build/index.ios.bundle",
"--sourcemap-output", "build/index.ios.bundle.map"
)
end
sentry_create_release(
version: "1.0+#{build_number}",
app_identifier: 'nl.moeilijkedingen.jellyfinaudioplayer',
finalize: true
)
sentry_upload_dsym(
dsym_path: 'build/Jellyfin Player.app.dSYM.zip',
info_plist: 'ios/JellyfinAudioPlayer/Info.plist',
)
sentry_upload_file(
version: "1.0+#{build_number}",
app_identifier: 'nl.moeilijkedingen.jellyfinaudioplayer',
dist: build_number,
file: 'build/index.ios.bundle',
)
sentry_upload_sourcemap(
version: "1.0+#{build_number}",
app_identifier: 'nl.moeilijkedingen.jellyfinaudioplayer',
dist: build_number,
sourcemap: 'build/index.ios.bundle.map',
rewrite: true
)
end
end

5
fastlane/Pluginfile Normal file
View File

@@ -0,0 +1,5 @@
# Autogenerated by fastlane
#
# Ensure this file is checked in to source control!
gem 'fastlane-plugin-sentry'

View File

@@ -38,6 +38,6 @@ Generate beta build
----
This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run.
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about fastlane can be found on [fastlane.tools](https://fastlane.tools).
The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools).

View File

@@ -1,18 +1,13 @@
// import React from 'react';
// // if (process.env.NODE_ENV === 'development') {
// // const whyDidYouRender = require('@welldone-software/why-did-you-render');
// // whyDidYouRender(React, {
// // trackAllPureComponents: true,
// // });
// // }
import 'react-native-gesture-handler';
import { AppRegistry } from 'react-native';
import TrackPlayer from 'react-native-track-player';
import App from './src/components/App';
import { name as appName } from './app.json';
import PlaybackService from './src/utility/PlaybackService';
import { setupSentry } from 'utility/Sentry';
import { enableScreens } from 'react-native-screens';
setupSentry();
enableScreens();
AppRegistry.registerComponent(appName, () => App);
TrackPlayer.registerPlaybackService(() => PlaybackService);

8
ios/File.swift Normal file
View File

@@ -0,0 +1,8 @@
//
// File.swift
// JellyfinAudioPlayer
//
// Created by Lei Nelissen on 02/11/2020.
//
import Foundation

View File

@@ -0,0 +1,4 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

View File

@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View File

@@ -13,6 +13,7 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
463612208457EBB4B723000A /* libPods-JellyfinAudioPlayer-JellyfinAudioPlayerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 842AB597D56E84A4ACDC4735 /* libPods-JellyfinAudioPlayer-JellyfinAudioPlayerTests.a */; };
4FA1B23D2550A94C007A035E /* File.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA1B23C2550A94C007A035E /* File.swift */; };
A807E2BB233D6F9347D8A95C /* libPods-JellyfinAudioPlayer.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 71370E61E2CC6BD9372ADCF3 /* libPods-JellyfinAudioPlayer.a */; };
/* End PBXBuildFile section */
@@ -39,6 +40,8 @@
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = JellyfinAudioPlayer/main.m; sourceTree = "<group>"; };
2710519FCC41B05FDE6738DF /* Pods-JellyfinAudioPlayer.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinAudioPlayer.release.xcconfig"; path = "Target Support Files/Pods-JellyfinAudioPlayer/Pods-JellyfinAudioPlayer.release.xcconfig"; sourceTree = "<group>"; };
39572B38534BBDBB596C8C95 /* Pods-JellyfinAudioPlayer-JellyfinAudioPlayerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinAudioPlayer-JellyfinAudioPlayerTests.release.xcconfig"; path = "Target Support Files/Pods-JellyfinAudioPlayer-JellyfinAudioPlayerTests/Pods-JellyfinAudioPlayer-JellyfinAudioPlayerTests.release.xcconfig"; sourceTree = "<group>"; };
4FA1B23B2550A94B007A035E /* JellyfinAudioPlayer-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "JellyfinAudioPlayer-Bridging-Header.h"; sourceTree = "<group>"; };
4FA1B23C2550A94C007A035E /* File.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = File.swift; sourceTree = "<group>"; };
590BEA7DE65819C5B5FDAD06 /* Pods-JellyfinAudioPlayer-tvOSTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinAudioPlayer-tvOSTests.release.xcconfig"; path = "Target Support Files/Pods-JellyfinAudioPlayer-tvOSTests/Pods-JellyfinAudioPlayer-tvOSTests.release.xcconfig"; sourceTree = "<group>"; };
71370E61E2CC6BD9372ADCF3 /* libPods-JellyfinAudioPlayer.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-JellyfinAudioPlayer.a"; sourceTree = BUILT_PRODUCTS_DIR; };
7D43C7610851B9666193E3F6 /* libPods-JellyfinAudioPlayer-tvOSTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-JellyfinAudioPlayer-tvOSTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -99,6 +102,8 @@
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB11A68108700A75B9A /* LaunchScreen.xib */,
13B07FB71A68108700A75B9A /* main.m */,
4FA1B23C2550A94C007A035E /* File.swift */,
4FA1B23B2550A94B007A035E /* JellyfinAudioPlayer-Bridging-Header.h */,
);
name = JellyfinAudioPlayer;
sourceTree = "<group>";
@@ -174,6 +179,7 @@
00E356EB1AD99517003FC87E /* Frameworks */,
00E356EC1AD99517003FC87E /* Resources */,
BDE784ECF29EF861DBFF49D7 /* [CP] Copy Pods Resources */,
476A07969B11CA4E09819AC0 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -196,6 +202,8 @@
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
B9FB8FC65CEFF9AFAC71127E /* [CP] Copy Pods Resources */,
1DC46C84C90B4D84A18AC142 /* Upload Debug Symbols to Sentry */,
E8FF56077B675D4D92D5CC25 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -222,7 +230,7 @@
};
13B07F861A680F5B00A75B9A = {
DevelopmentTeam = 238P3C58WC;
LastSwiftMigration = 1120;
LastSwiftMigration = 1210;
ProvisioningStyle = Automatic;
};
};
@@ -281,7 +289,41 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "export NODE_BINARY=$(which node)\n../node_modules/react-native/scripts/react-native-xcode.sh\n";
shellScript = "export SENTRY_PROPERTIES=sentry.properties\nexport EXTRA_PACKAGER_ARGS=\"--sourcemap-output $DERIVED_FILE_DIR/main.jsbundle.map\"\nexport NODE_BINARY=$(which node)\n../node_modules/@sentry/cli/bin/sentry-cli react-native xcode ../node_modules/react-native/scripts/react-native-xcode.sh\n";
};
1DC46C84C90B4D84A18AC142 /* Upload Debug Symbols to Sentry */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Upload Debug Symbols to Sentry";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "export SENTRY_PROPERTIES=sentry.properties\n../node_modules/@sentry/cli/bin/sentry-cli upload-dsym";
};
476A07969B11CA4E09819AC0 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-JellyfinAudioPlayer-JellyfinAudioPlayerTests/Pods-JellyfinAudioPlayer-JellyfinAudioPlayerTests-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flipper-DoubleConversion/double-conversion.framework/double-conversion",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/double-conversion.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinAudioPlayer-JellyfinAudioPlayerTests/Pods-JellyfinAudioPlayer-JellyfinAudioPlayerTests-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
B9FB8FC65CEFF9AFAC71127E /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
@@ -363,6 +405,26 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
E8FF56077B675D4D92D5CC25 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-JellyfinAudioPlayer/Pods-JellyfinAudioPlayer-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flipper-DoubleConversion/double-conversion.framework/double-conversion",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/double-conversion.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinAudioPlayer/Pods-JellyfinAudioPlayer-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
FD10A7F022414F080027D42C /* Start Packager */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -398,6 +460,7 @@
buildActionMask = 2147483647;
files = (
13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */,
4FA1B23D2550A94C007A035E /* File.swift in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -429,6 +492,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 8DAD3DCD6450C4255A20940E /* Pods-JellyfinAudioPlayer-JellyfinAudioPlayerTests.debug.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 238P3C58WC;
@@ -454,6 +518,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 39572B38534BBDBB596C8C95 /* Pods-JellyfinAudioPlayer-JellyfinAudioPlayerTests.release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
@@ -481,7 +546,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = 238P3C58WC;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -498,6 +563,7 @@
PRODUCT_BUNDLE_IDENTIFIER = nl.moeilijkedingen.jellyfinaudioplayer;
PRODUCT_NAME = "Jellyfin Player";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "JellyfinAudioPlayer-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -511,7 +577,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = 238P3C58WC;
INFOPLIST_FILE = JellyfinAudioPlayer/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -524,6 +590,7 @@
PRODUCT_NAME = "Jellyfin Player";
PROVISIONING_PROFILE = "915c5213-22f6-4f9d-8065-2a06300f9bfb";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "JellyfinAudioPlayer-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
@@ -562,6 +629,7 @@
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -577,7 +645,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = (
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
@@ -586,6 +654,7 @@
);
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "Jellyfin Player";
SDKROOT = iphoneos;
};
name = Debug;
@@ -623,6 +692,7 @@
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -631,7 +701,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = (
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
@@ -639,6 +709,7 @@
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = NO;
PRODUCT_NAME = "Jellyfin Player";
SDKROOT = iphoneos;
VALIDATE_PRODUCT = YES;
};

View File

@@ -1,88 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2D02E47A1E0B4A5D006451C7"
BuildableName = "JellyfinAudioPlayer-tvOS.app"
BlueprintName = "JellyfinAudioPlayer-tvOS"
ReferencedContainer = "container:JellyfinAudioPlayer.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2D02E48F1E0B4A5D006451C7"
BuildableName = "JellyfinAudioPlayer-tvOSTests.xctest"
BlueprintName = "JellyfinAudioPlayer-tvOSTests"
ReferencedContainer = "container:JellyfinAudioPlayer.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2D02E47A1E0B4A5D006451C7"
BuildableName = "JellyfinAudioPlayer-tvOS.app"
BlueprintName = "JellyfinAudioPlayer-tvOS"
ReferencedContainer = "container:JellyfinAudioPlayer.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2D02E47A1E0B4A5D006451C7"
BuildableName = "JellyfinAudioPlayer-tvOS.app"
BlueprintName = "JellyfinAudioPlayer-tvOS"
ReferencedContainer = "container:JellyfinAudioPlayer.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -21,21 +21,13 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>3</string>
<string>19</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>

View File

@@ -19,6 +19,6 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>3</string>
<string>19</string>
</dict>
</plist>

View File

@@ -1,4 +1,4 @@
platform :ios, '10.0'
platform :ios, '11.0'
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
require_relative '../node_modules/react-native/scripts/react_native_pods'
@@ -17,5 +17,8 @@ target 'JellyfinAudioPlayer' do
use_flipper!
post_install do |installer|
flipper_post_install(installer)
installer.pods_project.build_configurations.each do |config|
config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64"
end
end
end

File diff suppressed because it is too large Load Diff

12732
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +1,75 @@
{
"name": "JellyfinAudioPlayer",
"version": "0.0.1",
"version": "0.1.5",
"main": "src/index.js",
"private": true,
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"ios": "react-native run-ios --scheme \"Jellyfin Player\"",
"start": "react-native start",
"test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"build:ios": "react-native bundle --entry-file='index.ts' --bundle-output='./ios/main.jsbundle' --dev=false --platform='ios'"
},
"dependencies": {
"@react-native-community/async-storage": "^1.11.0",
"@react-native-community/masked-view": "^0.1.10",
"@react-native-community/picker": "^1.6.5",
"@react-native-community/async-storage": "^1.12.1",
"@react-native-community/masked-view": "^0.1.11",
"@react-native-community/picker": "^1.8.1",
"@react-native-community/slider": "^3.0.3",
"@react-navigation/bottom-tabs": "^5.7.2",
"@react-navigation/native": "^5.7.1",
"@react-navigation/stack": "^5.7.1",
"@reduxjs/toolkit": "^1.4.0",
"@types/lodash": "^4.14.158",
"@types/redux-logger": "^3.0.8",
"@types/styled-components": "^5.1.1",
"date-fns": "^2.15.0",
"fuse.js": "^6.4.0",
"lodash": "^4.17.19",
"react": "^16.13.1",
"react-native": "^0.63.2",
"react-native-appearance": "^0.3.4",
"react-native-fast-image": "^8.3.2",
"react-native-gesture-handler": "^1.7.0",
"react-native-reanimated": "^1.10.1",
"react-native-safe-area-context": "^3.1.1",
"react-native-screens": "^2.9.0",
"react-native-svg": "^12.1.0",
"@react-navigation/bottom-tabs": "^5.11.15",
"@react-navigation/native": "^5.9.8",
"@react-navigation/stack": "^5.14.9",
"@reduxjs/toolkit": "^1.6.2",
"@sentry/react-native": "^2.6.2",
"@types/lodash": "^4.14.176",
"date-fns": "^2.25.0",
"fuse.js": "^6.4.6",
"i18n-js": "^3.8.0",
"lodash": "^4.17.21",
"react": "^17.0.2",
"react-native": "^0.66.1",
"react-native-airplay-button": "^1.1.0",
"react-native-collapsible": "^1.6.0",
"react-native-dotenv": "^2.6.2",
"react-native-fast-image": "^8.5.11",
"react-native-gesture-handler": "^1.10.3",
"react-native-localize": "^2.1.5",
"react-native-reanimated": "^2.2.3",
"react-native-safe-area-context": "^3.3.2",
"react-native-screens": "^3.8.0",
"react-native-svg": "^12.2.0",
"react-native-svg-transformer": "^0.14.3",
"react-native-track-player": "github:leinelissen/react-native-track-player",
"react-native-webview": "^10.3.2",
"react-redux": "^7.2.1",
"redux": "^4.0.5",
"react-native-track-player": "^1.2.7",
"react-native-webview": "^11.14.1",
"react-redux": "^7.2.6",
"redux": "^4.1.1",
"redux-logger": "^3.0.6",
"redux-persist": "^6.0.0",
"styled-components": "^5.1.1"
"styled-components": "^5.3.3"
},
"devDependencies": {
"@babel/core": "^7.10.5",
"@babel/runtime": "^7.10.5",
"@babel/core": "^7.15.8",
"@babel/runtime": "^7.15.4",
"@react-native-community/eslint-config": "^2.0.0",
"@types/jest": "^26.0.7",
"@types/react-native": "^0.63.2",
"@types/react-redux": "^7.1.9",
"@types/react-test-renderer": "16.9.2",
"@typescript-eslint/eslint-plugin": "^3.7.0",
"@typescript-eslint/parser": "^3.7.0",
"babel-jest": "^26.1.0",
"babel-plugin-module-resolver": "^4.0.0",
"eslint": "^7.5.0",
"eslint-plugin-react-hooks": "^4.0.8",
"jest": "^26.1.0",
"metro-react-native-babel-preset": "^0.61.0",
"react-test-renderer": "^16.13.1",
"typescript": "^3.9.7"
"@sentry/cli": "^1.70.1",
"@types/i18n-js": "^3.8.2",
"@types/jest": "^26.0.24",
"@types/react-native": "^0.66.1",
"@types/react-redux": "^7.1.20",
"@types/react-test-renderer": "^17.0.1",
"@types/redux-logger": "^3.0.9",
"@types/styled-components": "^5.1.15",
"@types/styled-components-react-native": "^5.1.2",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"babel-jest": "^26.6.3",
"babel-plugin-module-resolver": "^4.1.0",
"eslint": "^7.32.0",
"eslint-plugin-react-hooks": "^4.2.0",
"jest": "^26.6.3",
"metro-react-native-babel-preset": "^0.66.2",
"react-test-renderer": "^17.0.2",
"typescript": "^4.4.4"
},
"jest": {
"preset": "react-native",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98.39 84.08"><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Regular-M"><path d="M3.66,0A3.62,3.62,0,0,0,0,3.66,3.67,3.67,0,0,0,3.66,7.37h91a3.75,3.75,0,0,0,3.76-3.71A3.71,3.71,0,0,0,94.63,0Zm0,25.54A3.67,3.67,0,0,0,0,29.25a3.62,3.62,0,0,0,3.66,3.66h91a3.71,3.71,0,0,0,3.76-3.66,3.75,3.75,0,0,0-3.76-3.71ZM.05,50.68v11c0,6.93,4.88,11.18,11.77,11.18h14.4V78c0,4,3.76,5.47,6.84,3l13.23-10.6a3.71,3.71,0,0,0,0-6L33.06,53.86c-3-2.35-6.84-.88-6.84,3v4.88H12.89A1.51,1.51,0,0,1,11.23,60V50.68c0-4-2.05-6.44-5.61-6.44S.05,46.73.05,50.68Zm61.18.44a3.69,3.69,0,0,0,0,7.38h33.4a3.69,3.69,0,1,0,0-7.38Zm0,25.59a3.69,3.69,0,0,0,0,7.37h33.4a3.69,3.69,0,1,0,0-7.37Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 758 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98.39 84.08"><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Regular-M"><path d="M61.23,7.37h33.4a3.72,3.72,0,0,0,3.76-3.71A3.71,3.71,0,0,0,94.63,0H61.23a3.62,3.62,0,0,0-3.66,3.66A3.64,3.64,0,0,0,61.23,7.37ZM.05,33.4c0,4,2,6.44,5.57,6.44s5.61-2.49,5.61-6.44V24.07a1.51,1.51,0,0,1,1.66-1.71H26.22v4.89c0,3.85,3.86,5.32,6.84,3L46.29,19.68a3.71,3.71,0,0,0,0-6L33.06,3.12c-3.08-2.49-6.84-1-6.84,3v5.08H11.82C4.93,11.18.05,15.43.05,22.36ZM61.23,33h33.4a3.69,3.69,0,1,0,0-7.37H61.23a3.69,3.69,0,0,0,0,7.37ZM3.66,58.54h91a3.75,3.75,0,0,0,3.76-3.71,3.71,3.71,0,0,0-3.76-3.66h-91A3.62,3.62,0,0,0,0,54.83,3.67,3.67,0,0,0,3.66,58.54Zm0,25.54h91a3.71,3.71,0,0,0,3.76-3.66,3.75,3.75,0,0,0-3.76-3.71h-91A3.67,3.67,0,0,0,0,80.42,3.62,3.62,0,0,0,3.66,84.08Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 848 B

1
src/assets/queue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98.39 74.76"><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Regular-M"><path d="M4,15.17,12.65,10a2.45,2.45,0,0,0,0-4.11L4,.62C2-.6,0,0,0,2.33V13.46C0,15.71,2.05,16.29,4,15.17ZM31.3,11.75H94.43a3.93,3.93,0,1,0,0-7.86H31.3a3.88,3.88,0,0,0-4,4A3.87,3.87,0,0,0,31.3,11.75ZM4,44.61l8.69-5.17a2.44,2.44,0,0,0,0-4.1L4,30.06c-2-1.17-4-.58-4,1.71V42.91C0,45.15,2.05,45.74,4,44.61ZM31.3,41.29H94.43a3.93,3.93,0,1,0,0-7.86H31.3a3.93,3.93,0,1,0,0,7.86ZM4,74.2,12.65,69a2.44,2.44,0,0,0,0-4.1L4,59.65C2,58.48,0,59,0,61.36V72.5C0,74.74,2.05,75.33,4,74.2ZM31.3,70.79H94.43a3.93,3.93,0,1,0,0-7.86H31.3a3.93,3.93,0,1,0,0,7.86Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 717 B

1
src/assets/repeat.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 106.69 89.45"><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Regular-M"><path d="M0,41.16a5.37,5.37,0,0,0,10.74,0V37.94c0-7.76,5.37-12.89,13.38-12.89H60.94v10.2a4.05,4.05,0,0,0,4.39,4.3,5.27,5.27,0,0,0,3.32-1.22L87.11,23.1a4.1,4.1,0,0,0,0-6.55L68.65,1.22A5.57,5.57,0,0,0,65.33,0a4.09,4.09,0,0,0-4.39,4.35v10.1H25.15C9.77,14.45,0,23.29,0,37.16ZM45.75,54.25A4,4,0,0,0,41.36,50a5.25,5.25,0,0,0-3.27,1.17L19.63,66.36a4,4,0,0,0,0,6.54L38.09,88.23a5,5,0,0,0,3.27,1.22,4,4,0,0,0,4.39-4.29V75H81.54c15.38,0,25.15-8.89,25.15-22.7v-4a5.37,5.37,0,1,0-10.74,0v3.22c0,7.72-5.37,12.9-13.38,12.9H45.75Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 695 B

1
src/assets/shuffle.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 114.69 90.97"><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Symbols"><g id="Regular-M"><path d="M0,70.51c0,3.12,2.29,5.27,5.76,5.27H16.85c8.44,0,13.47-2.44,19.58-9.52L47.36,53.47l11,12.79c6,7.08,11.08,9.57,19.58,9.57h9V86.67A4,4,0,0,0,91.26,91a5.27,5.27,0,0,0,3.32-1.22L113,74.51A4.08,4.08,0,0,0,113,68L94.58,52.64a5.27,5.27,0,0,0-3.32-1.22,4,4,0,0,0-4.35,4.29v9.48H78.22c-5.08,0-8.2-1.62-12.16-6.25L54.39,45.31,66.06,31.74c4-4.69,7.08-6.3,12.16-6.3h8.69v9.81a4,4,0,0,0,4.35,4.3,5.27,5.27,0,0,0,3.32-1.22L113,23.1a4.09,4.09,0,0,0,0-6.55L94.58,1.22A5.27,5.27,0,0,0,91.26,0a4,4,0,0,0-4.35,4.3V14.84h-9c-8.5,0-13.57,2.49-19.58,9.57l-11,12.8L36.43,24.41c-6.11-7.08-11.14-9.57-19.58-9.57H5.76C2.29,14.84,0,17,0,20.17s2.34,5.32,5.76,5.32H17.09c4.69,0,7.67,1.61,11.67,6.25L40.43,45.31,28.76,58.94c-4.05,4.63-7,6.25-11.67,6.25H5.76C2.34,65.19,0,67.38,0,70.51Z"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -9,10 +9,10 @@ import {
TapGestureHandlerGestureEvent
} from 'react-native-gesture-handler';
interface LetterContainerProps {
onPress: (letter: string) => void;
letter: string;
}
// interface LetterContainerProps {
// onPress: (letter: string) => void;
// letter: string;
// }
const Container = styled.View`
position: absolute;

View File

@@ -1,8 +1,7 @@
import React, { Component } from 'react';
import React, { useEffect } from 'react';
import { Provider } from 'react-redux';
import TrackPlayer from 'react-native-track-player';
import { PersistGate } from 'redux-persist/integration/react';
import { AppearanceProvider, Appearance, AppearanceListener } from 'react-native-appearance';
import Routes from '../screens';
import store, { persistedStore } from 'store';
import {
@@ -10,60 +9,43 @@ import {
DefaultTheme,
DarkTheme,
} from '@react-navigation/native';
import { useColorScheme } from 'react-native';
import { ColorSchemeContext, themes } from './Colors';
// import ErrorReportingAlert from 'utility/ErrorReportingAlert';
import PlayerStateUpdater from './PlayerStateUpdater';
interface State {
isReady: boolean;
colorScheme?: string;
}
export default function App(): JSX.Element {
const colorScheme = useColorScheme();
// const colorScheme = 'dark';
const theme = themes[colorScheme || 'light'];
export default class App extends Component<{}, State> {
state: State = {
isReady: false,
};
subscription = null;
async componentDidMount() {
await TrackPlayer.setupPlayer();
await TrackPlayer.updateOptions({
capabilities: [
TrackPlayer.CAPABILITY_PLAY,
TrackPlayer.CAPABILITY_PAUSE,
TrackPlayer.CAPABILITY_SKIP_TO_NEXT,
TrackPlayer.CAPABILITY_SKIP_TO_PREVIOUS,
TrackPlayer.CAPABILITY_STOP,
TrackPlayer.CAPABILITY_SEEK_TO,
]
});
this.subscription = Appearance.addChangeListener(this.setScheme);
this.setState({ isReady: true, colorScheme: Appearance.getColorScheme() });
}
componentWillUnmount() {
this.subscription?.remove();
}
setScheme: AppearanceListener = ({ colorScheme }) => {
this.setState({ colorScheme });
}
render() {
const { isReady, colorScheme } = this.state;
if (!isReady) {
return null;
useEffect(() => {
async function setupTrackPlayer() {
await TrackPlayer.setupPlayer();
await TrackPlayer.updateOptions({
capabilities: [
TrackPlayer.CAPABILITY_PLAY,
TrackPlayer.CAPABILITY_PAUSE,
TrackPlayer.CAPABILITY_SKIP_TO_NEXT,
TrackPlayer.CAPABILITY_SKIP_TO_PREVIOUS,
TrackPlayer.CAPABILITY_STOP,
TrackPlayer.CAPABILITY_SEEK_TO,
]
});
}
setupTrackPlayer();
}, []);
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistedStore}>
<AppearanceProvider>
<NavigationContainer theme={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Routes />
</NavigationContainer>
</AppearanceProvider>
</PersistGate>
</Provider>
);
}
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistedStore}>
<ColorSchemeContext.Provider value={theme}>
<NavigationContainer theme={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Routes />
<PlayerStateUpdater />
</NavigationContainer>
</ColorSchemeContext.Provider>
</PersistGate>
</Provider>
);
}

65
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,65 @@
import React, { useCallback, useState } from 'react';
import { SvgProps } from 'react-native-svg';
import {
PressableProps, ViewProps,
} from 'react-native';
import { THEME_COLOR } from 'CONSTANTS';
import styled, { css } from 'styled-components/native';
import useDefaultStyles from './Colors';
interface ButtonProps extends PressableProps {
icon?: React.FC<SvgProps>;
title: string;
style?: ViewProps['style'];
}
const BaseButton = styled.Pressable`
padding: 16px;
border-radius: 8px;
flex-direction: row;
align-items: center;
justify-content: center;
flex-grow: 1;
`;
const ButtonText = styled.Text<{ active?: boolean }>`
color: ${THEME_COLOR};
font-weight: 600;
${props => props.active && css`
color: white;
`}
`;
export default function Button(props: ButtonProps) {
const { icon: Icon, title, ...rest } = props;
const defaultStyles = useDefaultStyles();
const [isPressed, setPressed] = useState(false);
const handlePressIn = useCallback(() => setPressed(true), []);
const handlePressOut = useCallback(() => setPressed(false), []);
return (
<BaseButton
{...rest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[
props.style,
{ backgroundColor: isPressed ? THEME_COLOR : defaultStyles.button.backgroundColor }
]}
>
{Icon &&
<Icon
width={12}
height={12}
fill={isPressed ? '#fff' : THEME_COLOR}
style={{
marginRight: 8,
}}
/>
}
<ButtonText active={isPressed}>{title}</ButtonText>
</BaseButton>
);
}

View File

@@ -1,26 +0,0 @@
import { StyleSheet, PlatformColor } from 'react-native';
import { THEME_COLOR } from 'CONSTANTS';
export const colors = StyleSheet.create({
text: {
color: PlatformColor('?attr/colorOnBackground'),
},
view: {
backgroundColor: PlatformColor('?android:colorBackground'),
},
border: {
borderColor: '#88888844'
},
activeBackground: {
backgroundColor: `${THEME_COLOR}44`,
},
imageBackground: {
backgroundColor: PlatformColor('?attr/colorBackgroundFloating'),
},
modal: {
backgroundColor: PlatformColor('?attr/colorBackgroundFloating'),
},
input: {
backgroundColor: PlatformColor('?attr/colorBackgroundFloating'),
}
});

View File

@@ -1,27 +0,0 @@
import { StyleSheet, PlatformColor, DynamicColorIOS } from 'react-native';
import { THEME_COLOR } from 'CONSTANTS';
export const colors = StyleSheet.create({
text: {
color: PlatformColor('label'),
},
view: {
backgroundColor: PlatformColor('systemBackground'),
},
border: {
borderColor: PlatformColor('systemGray5Color'),
},
activeBackground: {
backgroundColor: DynamicColorIOS({ light: `${THEME_COLOR}16`, dark: `${THEME_COLOR}66` })
},
imageBackground: {
backgroundColor: PlatformColor('systemGray5Color')
},
modal: {
backgroundColor: DynamicColorIOS({ light: '#eeeeeeee', dark: '#222222ee' })
},
input: {
backgroundColor: PlatformColor('systemGray5Color'),
color: PlatformColor('label'),
}
});

80
src/components/Colors.ts Normal file
View File

@@ -0,0 +1,80 @@
import { THEME_COLOR } from 'CONSTANTS';
import React from 'react';
import { useContext } from 'react';
import { ColorSchemeName, StyleSheet } from 'react-native';
/**
* Function for generating both the dark and light stylesheets, so that they
* don't have to be generate on every individual component render
*/
function generateStyles(scheme: ColorSchemeName) {
return StyleSheet.create({
text: {
color: scheme === 'dark' ? '#fff' : '#000',
},
textHalfOpacity: {
color: scheme === 'dark' ? '#ffffff88' : '#00000088',
},
view: {
backgroundColor: scheme === 'dark' ? '#111' : '#f6f6f6',
},
border: {
borderColor: scheme === 'dark' ? '#262626' : '#ddd',
},
activeBackground: {
backgroundColor: `${THEME_COLOR}${scheme === 'dark' ? '66' : '16'}`,
},
imageBackground: {
backgroundColor: scheme === 'dark' ? '#333' : '#ddd',
},
modal: {
backgroundColor: scheme === 'dark' ? '#222222ee' : '#eeeeeeee',
},
modalInner: {
backgroundColor: scheme === 'dark' ? '#000' : '#fff',
},
button: {
backgroundColor: scheme === 'dark' ? '#161616' : '#e6e6e6',
},
input: {
backgroundColor: scheme === 'dark' ? '#161616' : '#e6e6e6',
color: scheme === 'dark' ? '#fff' : '#000',
},
sectionHeading: {
backgroundColor: scheme === 'dark' ? '#111' : '#eee',
borderColor: scheme === 'dark' ? '#333' : '#ddd',
},
stackHeader: {
color: scheme === 'dark' ? 'white' : 'black'
}
});
}
// Prerender both stylesheets
export const themes: Record<'dark' | 'light', ReturnType<typeof generateStyles>> = {
'dark': generateStyles('dark'),
'light': generateStyles('light'),
};
// Create context for supplying the theming information
export const ColorSchemeContext = React.createContext(themes.dark);
/**
* Retrieves the default styles object in hook form
*/
export default function useDefaultStyles() {
return useContext(ColorSchemeContext);
}
interface DefaultStylesProviderProps {
children: (defaultStyles: ReturnType<typeof useDefaultStyles>) => JSX.Element;
}
/**
* A render props component to supply the defaultStyles object.
*/
export function DefaultStylesProvider(props: DefaultStylesProviderProps) {
const defaultStyles = useDefaultStyles();
return props.children(defaultStyles);
}

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components/native';
const Input = styled.TextInput`
margin: 10px 0;
border-radius: 5px;
border-radius: 8px;
padding: 15px;
`;

View File

@@ -1,24 +1,48 @@
import React from 'react';
import { TouchableOpacityProps, Text } from 'react-native';
import React, { useCallback, useState } from 'react';
import { TouchableOpacityProps } from 'react-native';
import ChevronRight from 'assets/chevron-right.svg';
import styled from 'styled-components/native';
import styled, { css } from 'styled-components/native';
import { THEME_COLOR } from 'CONSTANTS';
import { colors } from './Colors';
import useDefaultStyles from './Colors';
const BUTTON_SIZE = 14;
const Container = styled.TouchableOpacity`
padding: 18px 0;
const Container = styled.Pressable<{ active?: boolean }>`
padding: 18px 20px;
border-bottom-width: 1px;
flex-direction: row;
justify-content: space-between;
${props => props.active && css`
background-color: ${THEME_COLOR};
`}
`;
const Label = styled.Text<{ active?: boolean }>`
color: ${THEME_COLOR};
font-size: 16px;
${props => props.active && css`
color: white;
`}
`;
const ListButton: React.FC<TouchableOpacityProps> = ({ children, ...props }) => {
const defaultStyles = useDefaultStyles();
const [isPressed, setPressed] = useState(false);
const handlePressIn = useCallback(() => setPressed(true), []);
const handlePressOut = useCallback(() => setPressed(false), []);
return (
<Container {...props} style={colors.border}>
<Text style={{ color: THEME_COLOR, fontSize: 16 }}>{children}</Text>
<ChevronRight width={BUTTON_SIZE} height={BUTTON_SIZE} fill={THEME_COLOR} />
<Container
{...props}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={defaultStyles.border}
active={isPressed}
>
<Label active={isPressed}>{children}</Label>
<ChevronRight width={BUTTON_SIZE} height={BUTTON_SIZE} fill={isPressed ? '#fff' : THEME_COLOR} />
</Container>
);
};

View File

@@ -1,23 +1,39 @@
import React from 'react';
import styled from 'styled-components/native';
import { SafeAreaView } from 'react-native';
import { colors } from './Colors';
import React, { useCallback } from 'react';
import styled, { css } from 'styled-components/native';
import { SafeAreaView, Pressable } from 'react-native';
import { useNavigation, StackActions } from '@react-navigation/native';
import useDefaultStyles from './Colors';
const Background = styled.View`
interface Props {
fullSize?: boolean;
}
const Background = styled(Pressable)`
padding: 100px 25px;
flex: 1;
justify-content: center;
`;
const Container = styled.View`
const Container = styled(Pressable)<Pick<Props, 'fullSize'>>`
border-radius: 20px;
flex: 1;
margin: auto 0;
${props => props.fullSize && css`
flex: 1;
`}
`;
const Modal: React.FC = ({ children }) => {
const Modal: React.FC<Props> = ({ children, fullSize = true }) => {
const defaultStyles = useDefaultStyles();
const navigation = useNavigation();
const closeModal = useCallback(() => {
navigation.dispatch(StackActions.popToTop());
}, [navigation]);
return (
<Background style={colors.modal}>
<Background style={defaultStyles.modal} onPress={closeModal}>
<SafeAreaView style={{ flex: 1 }}>
<Container style={colors.view}>
<Container style={defaultStyles.modalInner} fullSize={fullSize}>
{children}
</Container>
</SafeAreaView>

View File

@@ -0,0 +1,54 @@
import { useCallback, useEffect } from 'react';
import TrackPlayer, { TrackPlayerEvents } from 'react-native-track-player';
import { shallowEqual, useDispatch } from 'react-redux';
import { useTypedSelector } from 'store';
import player from 'store/player';
function PlayerStateUpdater() {
const dispatch = useDispatch();
const trackId = useTypedSelector(state => state.player.currentTrack?.id, shallowEqual);
const handleUpdate = useCallback(async () => {
const currentTrackId = await TrackPlayer.getCurrentTrack();
// GUARD: Only retrieve new track if it is different from the one we
// have currently in state.
if (currentTrackId === trackId){
return;
}
// GUARD: Only fetch current track if there is a current track
if (!currentTrackId) {
dispatch(player.actions.setCurrentTrack(undefined));
}
// If it is different, retrieve the track and save it
try {
const currentTrack = await TrackPlayer.getTrack(currentTrackId);
dispatch(player.actions.setCurrentTrack(currentTrack));
} catch {
// Due to the async nature, a track might be removed at the
// point when we try to retrieve it. If this happens, we'll just
// smother the error and wait for a new track update to
// finish.
}
}, [trackId, dispatch]);
useEffect(() => {
function handler() {
handleUpdate();
}
handler();
const subscription = TrackPlayer.addEventListener(TrackPlayerEvents.PLAYBACK_TRACK_CHANGED, handler);
return () => {
subscription.remove();
};
}, []);
return null;
}
export default PlayerStateUpdater;

11
src/components/Text.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React, { PropsWithChildren } from 'react';
import { Text as BaseText, TextProps } from 'react-native';
import useDefaultStyles from './Colors';
export default function Text(props: PropsWithChildren<TextProps>) {
const defaultStyles = useDefaultStyles();
return (
<BaseText {...props} style={[defaultStyles.text, props.style]} />
);
}

View File

@@ -1,24 +1,41 @@
import React, { useCallback } from 'react';
import { TouchableOpacity } from 'react-native';
import { Pressable, ViewStyle } from 'react-native';
interface TouchableHandlerProps {
id: string;
onPress: (id: string) => void;
onLongPress?: (id: string) => void;
}
function TouchableStyles({ pressed }: { pressed: boolean }): ViewStyle {
if (pressed) {
return { opacity: 0.5 };
} else {
return { opacity: 1 };
}
}
/**
* This is a generic handler that accepts id as a prop, and return it when it is
* pressed. This comes in handy with lists in which albums / tracks need to be selected.
*/
const TouchableHandler: React.FC<TouchableHandlerProps> = ({ id, onPress, children }) => {
const TouchableHandler: React.FC<TouchableHandlerProps> = ({ id, onPress, onLongPress, children }) => {
const handlePress = useCallback(() => {
return onPress(id);
}, [id, onPress]);
const handleLongPress = useCallback(() => {
return onLongPress ? onLongPress(id) : undefined;
}, [id, onLongPress]);
return (
<TouchableOpacity onPress={handlePress}>
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
style={TouchableStyles}
>
{children}
</TouchableOpacity>
</Pressable>
);
};

View File

@@ -1,7 +1,13 @@
import styled from 'styled-components/native';
import Text from './Text';
export const Header = styled.Text`
export const Header = styled(Text)`
margin: 24px 0 12px 0;
font-size: 36px;
font-weight: bold;
`;
export const SubHeader = styled(Text)`
font-size: 24px;
margin: 12px 0;
`;

4
src/custom.d.ts vendored
View File

@@ -2,4 +2,8 @@ declare module '*.svg' {
import { SvgProps } from 'react-native-svg';
const content: React.FC<SvgProps>;
export default content;
}
declare module '@env' {
export const SENTRY_DSN: string;
}

52
src/localisation/index.ts Normal file
View File

@@ -0,0 +1,52 @@
import i18n from 'i18n-js';
import { findBestAvailableLanguage } from 'react-native-localize';
import { LocaleKeys } from './types';
// Lazy loaders for locale
const localeGetters: Record<string, () => object> = {
en: () => require('./lang/en/locale.json'),
fr: () => require('./lang/fr/locale.json'),
nl: () => require('./lang/nl/locale.json'),
es: () => require('./lang/es/locale.json'),
zh: () => require('./lang/zh/locale.json'),
ja: () => require('./lang/ja/locale.json'),
};
// Have RNLocalize pick the best locale from the languages on offer
let locale = findBestAvailableLanguage(Object.keys(localeGetters));
// Check if the locale is correctly picked
if (!locale || !locale.languageTag) {
// Some users might not list English as a fallback language, and hence might not
// be assigned a locale. In this case, we'll just default to English.
locale = {
languageTag: 'en',
isRTL: false,
};
}
// Set the key-value pairs for the different languages you want to support.
i18n.translations = {
en: localeGetters.en(),
};
// If the locale is not english, we add it to the translations key s well
if (locale.languageTag !== 'en') {
i18n.translations[locale.languageTag] = localeGetters[locale.languageTag as string]();
}
// Set the locale once at the beginning of your app.
i18n.locale = locale.languageTag;
// Fallback to the default language for missing translation strings
i18n.fallbacks = true;
/**
* An i18n Typescript helper with autocomplete for the key argument
* @param key string
*/
export function t(key: LocaleKeys) {
return i18n.t(key);
}
export default i18n;

View File

@@ -0,0 +1,43 @@
{
"play-next": "Play Next",
"play-album": "Play Album",
"queue": "Queue",
"add-to-queue": "Add to Queue",
"clear-queue": "Clear Queue",
"no-results": "No results...",
"album": "Album",
"albums": "Albums",
"all-albums": "All Albums",
"search": "Search",
"music": "Music",
"now-playing": "Now Playing",
"onboarding-welcome": "Welcome!",
"onboarding-intro": "Jellyfin Audio Player will allow you to stream your music library from anywhere, with full support for background audio and casting.",
"onboarding-cta": "In order to get started, you need a Jellyfin server. Click the button below to enter your Jellyfin server address and login to it.",
"set-jellyfin-server": "Set Jellyfin Server",
"set-jellyfin-server-instruction": "Please enter your Jellyfin server URL. Make sure to include the protocol and port",
"settings": "Settings",
"jellyfin-library": "Jellyfin Library",
"jellyfin-server-url": "Jellyfin Server URL",
"jellyfin-access-token": "Jellyfin Access Token",
"jellyfin-user-id": "Jellyfin User ID",
"setting-cache": "Cache",
"setting-cache-description": "If you have updated your Jellyfin library, but the app is holding on to cached assets, you can forcefully clear the cache using this button. This will force the app to fetch the library from scratch.",
"reset-cache": "Reset Cache",
"recent-albums": "Recent Albums",
"error-reporting": "Error Reporting",
"error-reporting-description": "During use of this app, you may encounter errors. Reporting these errors helps in creating a more secure and stable app experience.",
"error-reporting-rationale": "When you enable error reporting, every time an error occurs, a report is automatically created and sent to a server, along with helpful debugging information such as devices, versions and the specific error.",
"why-use-tracking": "Why use tracking?",
"why-use-tracking-description": "Tracking helps speed up development for this app by reporting weird edge cases and oversights. This helps make the app more stable and robust, thus increasing the app experience for everyone.",
"what-data-is-gathered": "What data is gathered?",
"what-data-is-gathered-description": "We log the error, device type, OS version, app version and device id. No application state is sent in any error reporting. The device id is a unique hash that can be reset in your device settings, and we cannot deduce any personal information from this identifier.",
"where-is-data-stored": "Where is data stored?",
"where-is-data-stored-description": "The Sentry backend is self-hosted on our own infrastructure. No-one but us has access to the servers, databases, application and data logs, least of all any Sentry staff. The infrastructure is hosted in the European Union.",
"enable-error-reporting": "Do you want to enable error reporting?",
"enable-error-reporting-description": "This helps improve the app experience by sending crash and error reports to us.",
"enable": "Enable",
"disable": "Disable",
"more-info": "More Info",
"track": "Track"
}

View File

@@ -0,0 +1,28 @@
{
"play-next": "Reproducir siguente",
"play-album": "Reproducir Álbum",
"queue": "Cola",
"add-to-queue": "Añadir a la cola",
"clear-queue": "Limpiar cola",
"no-results": "No hay resultados...",
"album": "Álbum",
"albums": "Álbumes",
"all-albums": "Todos los Álbumes",
"search": "Buscar",
"music": "Música",
"now-playing": "Reproduciendo ahora",
"onboarding-welcome": "Bienvenido!",
"onboarding-intro": "Jellyfin Audio Player te permitirá reproducir tu biblioteca musical desde cualquier sitio, con suporte completo para audio en segundo plano y casteo en otros dispositivos.",
"onboarding-cta": "Para empezar necesitas un servidor de Jellyfin. Pulsa el botón de abajo para introducir la dirección del servidor y autentifícate con tus credenciales.",
"set-jellyfin-server": "Introduce servidor de Jellyfin",
"set-jellyfin-server-instruction": "Por favor introduce la URL de tu servidor de Jellyfin. Acuérdate de incluir protocolo y puerto",
"settings": "Configuración",
"jellyfin-library": "Biblioteca Jellyfin",
"jellyfin-server-url": "Url del servidor Jellyfin",
"jellyfin-access-token": "Token de acceso Jellyfin",
"jellyfin-user-id": "ID de usuario Jellyfin",
"setting-cache": "Caché",
"setting-cache-description": "Si has actualizado la biblioteca de Jellyfin pero tu aplicación muestra resultados anteriores, puedes forzar la actualización de la biblioteca pulsando este botón y se volverá a cargar toda información",
"reset-cache": "Resetear Caché",
"recent-albums": "Álbumes recientes"
}

View File

@@ -0,0 +1,28 @@
{
"play-next": "Lire ensuite",
"play-album": "Jouer l'album",
"queue": "File d'attente",
"add-to-queue": "Ajouter à la file d'attente",
"clear-queue": "Vider la file d'attente",
"no-results": "Pas de résultats...",
"album": "Album",
"albums": "Albums",
"all-albums": "Tous les Albums",
"search": "Rechercher",
"music": "Musique",
"now-playing": "En cours",
"onboarding-welcome": "Bienvenue !",
"onboarding-intro": "Jellyfin Audio Player vous permettra de diffuser votre bibliothèque musicale de n'importe où, avec un support de la lecture en arrière plan et la diffusion à distance.",
"onboarding-cta": "Pour utiliser Jellyfin Audio Player, vous avez besoin d'un serveur Jellyfin. Cliquez sur le bouton ci-dessous pour entrer l'adresse de votre serveur Jellyfin et vous y connecter.",
"set-jellyfin-server": "Configurer le serveur Jellyfin",
"set-jellyfin-server-instruction": "Veuillez entrer l'URL de votre serveur Jellyfin. Assurez-vous d'inclure le protocole et le port",
"settings": "Réglages",
"jellyfin-library": "Bibliothèque Jellyfin",
"jellyfin-server-url": "URL du serveur Jellyfin",
"jellyfin-access-token": "Jeton d'accès à la Jellyfin",
"jellyfin-user-id": "ID utilisateur Jellyfin",
"setting-cache": "Cache",
"setting-cache-description": "Si vous avez mis à jour votre bibliothèque Jellyfin mais que l'application conserve toujours des ressources en cache, vous pouvez vider le cache en utilisant ce bouton. Cela forcera l'application à récupérer lintégralité de bibliothèque.",
"reset-cache": "Réinitialiser le cache",
"recent-albums": "Albums récents"
}

View File

@@ -0,0 +1,43 @@
{
"play-next": "再生する",
"play-album": "アルバムを再生する",
"queue": "再生リスト",
"add-to-queue": "再生リストに追加",
"clear-queue": "再生リストをクリア",
"no-results": "結果なし",
"album": "アルバム",
"albums": "アルバム",
"all-albums": "すべてのアルバム",
"search": "検索",
"music": "音楽",
"now-playing": "再生中",
"onboarding-welcome": "ようこそ。",
"onboarding-intro": "Jellyfin Audio Player は、どこからでも音楽ライブラリをストリーミングすることができます。バックグラウンド・オーディオやキャスティングを完全にサポートします。",
"onboarding-cta": "始めるためには、Jellyfin サーバーが必要です。下のボタンをクリックして、Jellyfin サーバーのアドレスを入力し、ログインしてください。",
"set-jellyfin-server": "Jellyfin サーバ を設定",
"set-jellyfin-server-instruction": "Jellyfin サーバの URL を入力してください。プロトコルとポートを必ず含めてください。",
"settings": "設定",
"jellyfin-library": "Jellyfin ライブライ",
"jellyfin-server-url": "Jellyfin サーバの URL",
"jellyfin-access-token": "Jellyfin アクセストークン",
"jellyfin-user-id": "Jellyfin ユーザ ID",
"setting-cache": "キャッシュ",
"setting-cache-description": "Jellyfinライブラリをアップデートしたにもかかわらず、アプリがキャッシュされたアセットを保持している場合、このボタンを使って強制的にキャッシュをクリアすることができます。これにより、アプリはライブラリを最初から取得するようになります。",
"reset-cache": "キャッシュをリセット",
"recent-albums": "最近のアルバム",
"error-reporting": "エラー報告",
"error-reporting-description": "このアプリを使用中に、エラーの可能性があります。これらのエラーを報告することで、よりセキュアで安定したアプリ体験を実現することができます。",
"error-reporting-rationale": "エラーレポートを有効にすると、エラーが発生するたびにレポートが自動的に作成され、デバイス、バージョン、特定のエラーなど、デバッグに役立つ情報とともにサーバーに送信されます。",
"why-use-tracking": "なぜトラッキングを使うか?",
"why-use-tracking-description": "トラッキングは、奇妙なエッジケースや見落としを報告することで、このアプリの開発を促進します。これにより、アプリの安定性と堅牢性が向上し、すべての人のアプリ体験が向上します。",
"what-data-is-gathered": "どのデータが集まりますか?",
"what-data-is-gathered-description": "エラー、デバイスタイプ、OSバージョン、アプリバージョン、デバイスIDが記録されます。いかなるエラー報告においても、アプリケーションの状態は送信されません。デバイスIDは、デバイスの設定でリセット可能な一意のハッシュであり、この識別子から個人情報を推測することはできません。",
"where-is-data-stored": "データはどこに保存されていますか?",
"where-is-data-stored-description": "Sentryサービスは、私たち自身のインフラでセルフホストされています。当社以外の誰も、サーバー、データベース、アプリケーション、データログにアクセスすることはできません。このインフラはEUでホストされています。",
"enable-error-reporting": "エラー報告を有効にしますか?",
"enable-error-reporting-description": "これは、クラッシュやエラーのレポートを送信することで、アプリを改善に役立ちます。",
"enable": "有効する",
"disable": "無効する",
"more-info": "詳しくは",
"track": "追跡"
}

View File

@@ -0,0 +1,42 @@
{
"play-next": "Speel volgende",
"play-album": "Speel album",
"queue": "Wachtrij",
"add-to-queue": "Voeg toe aan wachtrij",
"clear-queue": "Wis wachtrij",
"no-results": "Geen resultaten...",
"album": "Album",
"albums": "Albums",
"all-albums": "Alle Albums",
"search": "Zoeken",
"music": "Muziek",
"now-playing": "Nu spelend",
"onboarding-welcome": "Welkom!",
"onboarding-intro": "Jellyfin Audio Player maakt het mogelijk om van waar dan ook je muziek te streamen, met volledige support voor achtergroundaudio en casting.",
"onboarding-cta": "Om te starten moet je een Jellyfin server hebben. Klik de onderstaande knop om het adres van je Jellyfin server in te vullen en er in te loggen.",
"set-jellyfin-server": "Stel Jellyfin Server in",
"set-jellyfin-server-instruction": "Vul alsjeblieft je Jellyfin server URL in. Voeg ook het protocol en de poort toe",
"settings": "Instellingen",
"jellyfin-library": "Jellyfin Bibliotheek",
"jellyfin-server-url": "Jellyfin Server URL",
"jellyfin-access-token": "Jellyfin Toegangstoken",
"jellyfin-user-id": "Jellyfin Gebruiker ID",
"setting-cache": "Cache",
"setting-cache-description": "Als je Jellyfin bibliotheek geüpdatet hebt, maar de app nog aan elementen uit de cache vasthoudt, kun je de cache geforceerd leegmaken met deze knop. Dit forceert de app om de bibliotheek weer vanaf het begin op te bouwen.",
"reset-cache": "Leeg Cache",
"recent-albums": "Recente Albums",
"error-reporting": "Foutmeldingen Rapporteren",
"error-reporting-description": "Gedurende het gebruik van deze app kun je tegen fouten aanlopen. Het rapporteren van deze foutmeldingen helpt om een veiligere en stabielere app-ervaring te maken.",
"error-reporting-rationale": "Wanneer je foutmeldingen aanzet, wordt er elke keer als je een foutmelding krijgt een automatisch rapport gegenereerd en naar onze server gestuurd. Deze bevat behulpzame informatie, zoals apparaat, versies en de specifieke foutmelding.",
"why-use-tracking": "Waarom tracking gebruiken?",
"why-use-tracking-description": "Tracking helpt het versnellen van het ontwikkeltempo doordat we beter inzicht hebben op rare gevallen en dingen die we gemist hebben. Dit helpt met het stabieler en veiliger maken van de voor iedereen.",
"what-data-is-gathered": "Welke data wordt er verzameld?",
"what-data-is-gathered-description": "We loggen de foutmeldingen, je apparaattype, versienummers en een device id. Geen applicatiestaat wordt gedeeld in de rapportage. Het device id is een unieke hash die gereset kan worden in de instellingen van je apparaat. Wij kunnen geen verdere informatie afleiden van deze identifier.",
"where-is-data-stored": "Waar wordt de data opgeslagen?",
"where-is-data-stored-description": "De Sentry backend wordt door onszelf gehost op onze eigen infrastructuur. Niemand behalve wij heeft toegang tot de servers, databases, applicaties en data logs, en zeker geen Sentry-werknemers. De infrastructuur wordt gehost in de Europese Unie.",
"enable-error-reporting": "Wil je foutmeldingen rapporteren?",
"enable-error-reporting-description": "Dit helpt de appervaring te verbeteren door ons rapportages te sturen van crashes en andere foutmeldingen.",
"enable": "Zet aan",
"disable": "Zet uit",
"more-info": "Meer informatie"
}

View File

@@ -0,0 +1,42 @@
{
"play-next": "播放下一首",
"play-album": "播放专辑",
"queue": "歌曲队列",
"add-to-queue": "加入队列",
"clear-queue": "清除队列",
"no-results": "无匹配记录",
"album": "专辑",
"albums": "专辑",
"all-albums": "所有专辑",
"search": "搜索",
"music": "歌曲",
"now-playing": "正在播放",
"onboarding-welcome": "欢迎",
"onboarding-intro": "Jellyfin Audio Player will allow you to stream your music library from anywhere, with full support for background audio and casting.",
"onboarding-cta": "In order to get started, you need a Jellyfin server. Click the button below to enter your Jellyfin server address and login to it.",
"set-jellyfin-server": "设置Jellyfin服务器",
"set-jellyfin-server-instruction": "设置Jellyfin服务器的完整网址包括HTTP/HTTS和端口。",
"settings": "设置",
"jellyfin-library": "Jellyfin库",
"jellyfin-server-url": "Jellyfin服务器网址",
"jellyfin-access-token": "Jellyfin Access Token",
"jellyfin-user-id": "Jellyfin用户ID",
"setting-cache": "缓存",
"setting-cache-description": "If you have updated your Jellyfin library, but the app is holding on to cached assets, you can forcefully clear the cache using this button. This will force the app to fetch the library from scratch.",
"reset-cache": "清除缓存",
"recent-albums": "最近专辑",
"error-reporting": "Error Reporting",
"error-reporting-description": "During use of this app, you may encounter errors. Reporting these errors helps in creating a more secure and stable app experience.",
"error-reporting-rationale": "When you enable error reporting, every time an error occurs, a report is automatically created and sent to a server, along with helpful debugging information such as devices, versions and the specific error.",
"why-use-tracking": "Why use tracking?",
"why-use-tracking-description": "Tracking helps speed up development for this app by reporting weird edge cases and oversights. This helps make the app more stable and robust, thus increasing the app experience for everyone.",
"what-data-is-gathered": "What data is gathered?",
"what-data-is-gathered-description": "We log the error, device type, OS version, app version and device id. No application state is sent in any error reporting. The device id is a unique hash that can be reset in your device settings, and we cannot deduce any personal information from this identifier.",
"where-is-data-stored": "Where is data stored?",
"where-is-data-stored-description": "The Sentry backend is self-hosted on our own infrastructure. No-one but us has access to the servers, databases, application and data logs, least of all any Sentry staff. The infrastructure is hosted in the European Union.",
"enable-error-reporting": "Do you want to enable error reporting?",
"enable-error-reporting-description": "This helps improve the app experience by sending crash and error reports to us.",
"enable": "启用",
"disable": "禁用",
"more-info": "更多信息"
}

41
src/localisation/types.ts Normal file
View File

@@ -0,0 +1,41 @@
export type LocaleKeys = 'play-next'
| 'play-album'
| 'queue'
| 'add-to-queue'
| 'clear-queue'
| 'no-results'
| 'album'
| 'albums'
| 'all-albums'
| 'search'
| 'music'
| 'now-playing'
| 'onboarding-welcome'
| 'onboarding-intro'
| 'onboarding-cta'
| 'set-jellyfin-server'
| 'set-jellyfin-server-instruction'
| 'settings'
| 'jellyfin-library'
| 'jellyfin-server-url'
| 'jellyfin-access-token'
| 'jellyfin-user-id'
| 'setting-cache'
| 'setting-cache-description'
| 'reset-cache'
| 'recent-albums'
| 'error-reporting'
| 'error-reporting-description'
| 'error-reporting-rationale'
| 'why-use-tracking'
| 'why-use-tracking-description'
| 'what-data-is-gathered'
| 'what-data-is-gathered-description'
| 'where-is-data-stored'
| 'where-is-data-stored-description'
| 'enable-error-reporting'
| 'enable-error-reporting-description'
| 'enable'
| 'disable'
| 'more-info'
| 'track'

View File

@@ -6,21 +6,23 @@ import Album from './stacks/Album';
import RecentAlbums from './stacks/RecentAlbums';
import Search from './stacks/Search';
import { THEME_COLOR } from 'CONSTANTS';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
const Stack = createStackNavigator<StackParams>();
const navigationOptions = {
headerTintColor: THEME_COLOR,
headerTitleStyle: { color: 'black' }
};
function MusicStack() {
const defaultStyles = useDefaultStyles();
return (
<Stack.Navigator initialRouteName="RecentAlbums" screenOptions={navigationOptions}>
<Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: 'Recent Albums' }} />
<Stack.Screen name="Albums" component={Albums} />
<Stack.Screen name="Album" component={Album} />
<Stack.Screen name="Search" component={Search} />
<Stack.Navigator initialRouteName="RecentAlbums" screenOptions={{
headerTintColor: THEME_COLOR,
headerTitleStyle: defaultStyles.stackHeader
}}>
<Stack.Screen name="RecentAlbums" component={RecentAlbums} options={{ headerTitle: t('recent-albums') }} />
<Stack.Screen name="Albums" component={Albums} options={{ headerTitle: t('albums') }} />
<Stack.Screen name="Album" component={Album} options={{ headerTitle: t('album') }} />
<Stack.Screen name="Search" component={Search} options={{ headerTitle: t('search') }} />
</Stack.Navigator>
);
}

View File

@@ -1,9 +1,9 @@
import React, { useCallback, useEffect } from 'react';
import { StackParams } from '../types';
import { Text, ScrollView, Dimensions, Button, RefreshControl, StyleSheet } from 'react-native';
import { Text, ScrollView, Dimensions, RefreshControl, StyleSheet, View } from 'react-native';
import { useGetImage } from 'utility/JellyfinApi';
import styled, { css } from 'styled-components/native';
import { useRoute, RouteProp } from '@react-navigation/native';
import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
import FastImage from 'react-native-fast-image';
import { useDispatch } from 'react-redux';
import { differenceInDays } from 'date-fns';
@@ -11,10 +11,13 @@ import { useTypedSelector } from 'store';
import { fetchTracksByAlbum } from 'store/music/actions';
import { ALBUM_CACHE_AMOUNT_OF_DAYS, THEME_COLOR } from 'CONSTANTS';
import usePlayAlbum from 'utility/usePlayAlbum';
import usePlayTrack from 'utility/usePlayTrack';
import TouchableHandler from 'components/TouchableHandler';
import useCurrentTrack from 'utility/useCurrentTrack';
import { colors } from 'components/Colors';
import TrackPlayer from 'react-native-track-player';
import { t } from '@localisation';
import Button from 'components/Button';
import Play from 'assets/play.svg';
import useDefaultStyles from 'components/Colors';
type Route = RouteProp<StackParams, 'Album'>;
@@ -22,18 +25,15 @@ const Screen = Dimensions.get('screen');
const styles = StyleSheet.create({
name: {
...colors.text,
fontSize: 36,
fontWeight: 'bold'
},
artist: {
...colors.text,
fontSize: 24,
opacity: 0.5,
marginBottom: 24
},
index: {
...colors.text,
width: 20,
opacity: 0.5,
marginRight: 5
@@ -60,6 +60,8 @@ const TrackContainer = styled.View<{isPlaying: boolean}>`
`;
const Album: React.FC = () => {
const defaultStyles = useDefaultStyles();
// Retrieve state
const { params: { id } } = useRoute<Route>();
const tracks = useTypedSelector((state) => state.music.tracks.entities);
@@ -71,11 +73,26 @@ const Album: React.FC = () => {
const getImage = useGetImage();
const playAlbum = usePlayAlbum();
const currentTrack = useCurrentTrack();
const navigation = useNavigation();
// Setup callbacks
const selectAlbum = useCallback(() => { playAlbum(id); }, [playAlbum, id]);
const selectTrack = usePlayTrack();
const refresh = useCallback(() => { dispatch(fetchTracksByAlbum(id)); }, [id, dispatch]);
const selectTrack = useCallback(async (trackId) => {
const tracks = await playAlbum(id, false);
if (tracks) {
const track = tracks.find((t) => t.id.startsWith(trackId));
if (track) {
await TrackPlayer.skip(track.id);
await TrackPlayer.play();
}
}
}, [playAlbum, id]);
const longPressTrack = useCallback((trackId: string) => {
navigation.navigate('TrackPopupMenu', { trackId });
}, [navigation]);
// Retrieve album tracks on load
useEffect(() => {
@@ -96,20 +113,27 @@ const Album: React.FC = () => {
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
}
>
<AlbumImage source={{ uri: getImage(album?.Id) }} style={colors.imageBackground} />
<Text style={styles.name} >{album?.Name}</Text>
<Text style={styles.artist}>{album?.AlbumArtist}</Text>
<Button title="Play Album" onPress={selectAlbum} color={THEME_COLOR} />
{album?.Tracks?.length ? album.Tracks.map((trackId) =>
<TouchableHandler key={trackId} id={trackId} onPress={selectTrack}>
<TrackContainer isPlaying={currentTrack?.id.startsWith(trackId) || false} style={colors.border}>
<Text style={styles.index}>
{tracks[trackId]?.IndexNumber}
</Text>
<Text style={colors.text}>{tracks[trackId]?.Name}</Text>
</TrackContainer>
</TouchableHandler>
) : undefined}
<AlbumImage source={{ uri: getImage(album?.Id) }} style={defaultStyles.imageBackground} />
<Text style={[ defaultStyles.text, styles.name ]} >{album?.Name}</Text>
<Text style={[ defaultStyles.text, styles.artist ]}>{album?.AlbumArtist}</Text>
<Button title={t('play-album')} icon={Play} onPress={selectAlbum} />
<View style={{ marginTop: 15 }}>
{album?.Tracks?.length ? album.Tracks.map((trackId) =>
<TouchableHandler
key={trackId}
id={trackId}
onPress={selectTrack}
onLongPress={longPressTrack}
>
<TrackContainer isPlaying={currentTrack?.id.startsWith(trackId) || false} style={defaultStyles.border}>
<Text style={[ defaultStyles.text, styles.index ]}>
{tracks[trackId]?.IndexNumber}
</Text>
<Text style={defaultStyles.text}>{tracks[trackId]?.Name}</Text>
</TrackContainer>
</TouchableHandler>
) : undefined}
</View>
</ScrollView>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, PureComponent, ReactText } from 'react';
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
import { useGetImage } from 'utility/JellyfinApi';
import { Album, NavigationProp } from '../types';
import { Text, SafeAreaView, SectionList, View } from 'react-native';
@@ -9,13 +9,12 @@ import { useTypedSelector } from 'store';
import { fetchAllAlbums } from 'store/music/actions';
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
import TouchableHandler from 'components/TouchableHandler';
import ListContainer from './components/ListContainer';
import AlbumImage, { AlbumItem } from './components/AlbumImage';
import { selectAlbumsByAlphabet, SectionedId } from 'store/music/selectors';
import AlphabetScroller from 'components/AlphabetScroller';
import { EntityId } from '@reduxjs/toolkit';
import styled from 'styled-components/native';
import { colors } from 'components/Colors';
import useDefaultStyles from 'components/Colors';
interface VirtualizedItemInfo {
section: SectionedId,
@@ -51,19 +50,16 @@ const SectionText = styled.Text`
font-weight: bold;
`;
const sectionStyles = { ...colors.view, ...colors.border };
const SectionHeading = React.memo(function SectionHeading(props: { label: string }) {
const defaultStyles = useDefaultStyles();
const { label } = props;
class SectionHeading extends PureComponent<{ label: string }> {
render() {
const { label } = this.props;
return (
<SectionContainer style={sectionStyles}>
<SectionText style={colors.text}>{label}</SectionText>
</SectionContainer>
);
}
}
return (
<SectionContainer style={defaultStyles.sectionHeading}>
<SectionText style={defaultStyles.text}>{label}</SectionText>
</SectionContainer>
);
});
interface GeneratedAlbumItemProps {
id: ReactText;
@@ -77,21 +73,20 @@ const HalfOpacity = styled.Text`
opacity: 0.5;
`;
class GeneratedAlbumItem extends PureComponent<GeneratedAlbumItemProps> {
render() {
const { id, imageUrl, name, artist, onPress } = this.props;
const GeneratedAlbumItem = React.memo(function GeneratedAlbumItem(props: GeneratedAlbumItemProps) {
const defaultStyles = useDefaultStyles();
const { id, imageUrl, name, artist, onPress } = props;
return (
<TouchableHandler id={id as string} onPress={onPress}>
<AlbumItem>
<AlbumImage source={{ uri: imageUrl }} style={colors.imageBackground} />
<Text numberOfLines={1} style={colors.text}>{name}</Text>
<HalfOpacity style={colors.text} numberOfLines={1}>{artist}</HalfOpacity>
</AlbumItem>
</TouchableHandler>
);
}
}
return (
<TouchableHandler id={id as string} onPress={onPress}>
<AlbumItem>
<AlbumImage source={{ uri: imageUrl }} style={defaultStyles.imageBackground} />
<Text numberOfLines={1} style={defaultStyles.text}>{name}</Text>
<HalfOpacity style={defaultStyles.text} numberOfLines={1}>{artist}</HalfOpacity>
</AlbumItem>
</TouchableHandler>
);
});
const Albums: React.FC = () => {
// Retrieve data from store

View File

@@ -12,30 +12,36 @@ import AlbumImage, { AlbumItem } from './components/AlbumImage';
import { useRecentAlbums } from 'store/music/selectors';
import { Header } from 'components/Typography';
import ListButton from 'components/ListButton';
import { colors } from 'components/Colors';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
const styles = StyleSheet.create({
artist: {
...colors.text,
opacity: 0.5,
columnWrapper: {
paddingLeft: 10,
paddingRight: 10
}
});
const NavigationHeader: React.FC = () => {
const navigation = useNavigation();
const defaultStyles = useDefaultStyles();
const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]);
const handleSearchClick = useCallback(() => { navigation.navigate('Search'); }, [navigation]);
return (
<ListContainer>
<ListButton onPress={handleAllAlbumsClick}>All Albums</ListButton>
<ListButton onPress={handleSearchClick}>Search</ListButton>
<Header style={colors.text}>Recent Albums</Header>
</ListContainer>
<>
<ListButton onPress={handleAllAlbumsClick}>{t('all-albums')}</ListButton>
<ListButton onPress={handleSearchClick}>{t('search')}</ListButton>
<ListContainer>
<Header style={defaultStyles.text}>{t('recent-albums')}</Header>
</ListContainer>
</>
);
};
const RecentAlbums: React.FC = () => {
const defaultStyles = useDefaultStyles();
// Retrieve data from store
const { entities: albums } = useTypedSelector((state) => state.music.albums);
const recentAlbums = useRecentAlbums(24);
@@ -55,25 +61,24 @@ const RecentAlbums: React.FC = () => {
return (
<SafeAreaView>
<ListContainer>
<FlatList
data={recentAlbums as string[]}
refreshing={isLoading}
onRefresh={retrieveData}
numColumns={2}
keyExtractor={d => d}
ListHeaderComponent={NavigationHeader}
renderItem={({ item }) => (
<TouchableHandler id={item} onPress={selectAlbum}>
<AlbumItem>
<AlbumImage source={{ uri: getImage(item) }} style={colors.imageBackground} />
<Text style={colors.text} numberOfLines={1}>{albums[item]?.Name}</Text>
<Text style={styles.artist} numberOfLines={1}>{albums[item]?.AlbumArtist}</Text>
</AlbumItem>
</TouchableHandler>
)}
/>
</ListContainer>
<FlatList
data={recentAlbums as string[]}
refreshing={isLoading}
onRefresh={retrieveData}
numColumns={2}
keyExtractor={d => d}
columnWrapperStyle={styles.columnWrapper}
ListHeaderComponent={NavigationHeader}
renderItem={({ item }) => (
<TouchableHandler id={item} onPress={selectAlbum}>
<AlbumItem>
<AlbumImage source={{ uri: getImage(item) }} style={defaultStyles.imageBackground} />
<Text style={defaultStyles.text} numberOfLines={1}>{albums[item]?.Name}</Text>
<Text style={defaultStyles.textHalfOpacity} numberOfLines={1}>{albums[item]?.AlbumArtist}</Text>
</AlbumItem>
</TouchableHandler>
)}
/>
</SafeAreaView>
);
};

View File

@@ -1,20 +1,33 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import Input from 'components/Input';
import { Text, View } from 'react-native';
import { ActivityIndicator, Text, View } from 'react-native';
import styled from 'styled-components/native';
import { useTypedSelector } from 'store';
import { useAppDispatch, useTypedSelector } from 'store';
import Fuse from 'fuse.js';
import { Album } from 'store/music/types';
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 { NavigationProp } from '../types';
import FastImage from 'react-native-fast-image';
import { colors } from 'components/Colors';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
import { searchAndFetchAlbums } from 'store/music/actions';
import { debounce } from 'lodash';
const Container = styled.View`
padding: 0 20px;
position: relative;
`;
const Loading = styled.View`
position: absolute;
right: 32px;
top: 0;
height: 100%;
flex: 1;
justify-content: center;
`;
const AlbumImage = styled(FastImage)`
@@ -45,16 +58,39 @@ const fuseOptions = {
includeScore: true,
};
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();
// Prepare state
const [fuseIsReady, setFuseReady] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const albums = useTypedSelector(state => state.music.albums.entities);
const [results, setResults] = useState<Fuse.FuseResult<Album>[]>([]);
const fuse = useRef<Fuse<Album, typeof fuseOptions>>();
const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
const [isLoading, setLoading] = useState(false);
const fuse = useRef<Fuse<Album>>();
// Prepare helpers
const navigation = useNavigation<NavigationProp>();
const getImage = useGetImage();
const dispatch = useAppDispatch();
/**
* Since it is impractical to have a global fuse variable, we need to
@@ -65,21 +101,89 @@ export default function Search() {
*/
useEffect(() => {
fuse.current = new Fuse(Object.values(albums) as Album[], fuseOptions);
}, [albums]);
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(() => {
// 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) {
if (!searchTerm) {
return;
}
setResults(fuse.current.search(searchTerm));
}, [searchTerm, setResults, fuse]);
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) =>
@@ -88,36 +192,60 @@ export default function Search() {
const HeaderComponent = React.useMemo(() => (
<Container>
<Input value={searchTerm} onChangeText={setSearchTerm} style={colors.input} placeholder="Search..." />
{(searchTerm.length && !results.length) ? <Text>No results...</Text> : null}
<Input value={searchTerm} onChangeText={setSearchTerm} style={defaultStyles.input} placeholder={t('search') + '...'} />
{isLoading && <Loading><ActivityIndicator /></Loading>}
</Container>
), [searchTerm, results, setSearchTerm]);
), [searchTerm, setSearchTerm, defaultStyles, isLoading]);
const FooterComponent = React.useMemo(() => (
<Container>
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading)
? <Text style={{ textAlign: 'center' }}>{t('no-results')}</Text>
: null}
</Container>
), [searchTerm, jellyfinResults, fuseResults, isLoading]);
// 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 (!fuse.current) {
if (!fuseIsReady) {
return null;
}
return (
<FlatList
data={results}
renderItem={({ item: { item: album } }) =>(
<TouchableHandler id={album.Id} onPress={selectAlbum}>
<SearchResult style={colors.border}>
<AlbumImage source={{ uri: getImage(album.Id) }} />
<View>
<Text numberOfLines={1} ellipsizeMode="tail" style={colors.text}>
{album.Name} - {album.AlbumArtist}
</Text>
<HalfOpacity style={colors.text}>Album</HalfOpacity>
</View>
</SearchResult>
</TouchableHandler>
)}
keyExtractor={(item) => item.refIndex.toString()}
ListHeaderComponent={HeaderComponent}
extraData={searchTerm}
/>
<>
<FlatList
style={{ flex: 1 }}
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 id={album.Id} onPress={selectAlbum}>
<SearchResult style={defaultStyles.border}>
<AlbumImage source={{ uri: getImage(album.Id) }} />
<View>
<Text numberOfLines={1} ellipsizeMode="tail" style={defaultStyles.text}>
{trackName || album.Name} - {album.AlbumArtist}
</Text>
<HalfOpacity style={defaultStyles.text}>
{type === 'AlbumArtist' ? t('album'): t('track')}
</HalfOpacity>
</View>
</SearchResult>
</TouchableHandler>
);
}}
keyExtractor={(item) => item.id}
ListHeaderComponent={HeaderComponent}
ListFooterComponent={FooterComponent}
extraData={[searchTerm, albums]}
/>
</>
);
}

View File

@@ -1,75 +1,11 @@
import { StackNavigationProp } from '@react-navigation/stack';
export interface UserData {
PlaybackPositionTicks: number;
PlayCount: number;
IsFavorite: boolean;
Played: boolean;
Key: string;
}
export interface ArtistItem {
Name: string;
Id: string;
}
export interface AlbumArtist {
Name: string;
Id: string;
}
export interface ImageTags {
Primary: string;
}
export interface Album {
Name: string;
ServerId: string;
Id: string;
SortName: string;
RunTimeTicks: number;
ProductionYear: number;
IsFolder: boolean;
Type: string;
UserData: UserData;
PrimaryImageAspectRatio: number;
Artists: string[];
ArtistItems: ArtistItem[];
AlbumArtist: string;
AlbumArtists: AlbumArtist[];
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
DateCreated: string;
}
export interface AlbumTrack {
Name: string;
ServerId: string;
Id: string;
RunTimeTicks: number;
ProductionYear: number;
IndexNumber: number;
IsFolder: boolean;
Type: string;
UserData: UserData;
Artists: string[];
ArtistItems: ArtistItem[];
Album: string;
AlbumId: string;
AlbumPrimaryImageTag: string;
AlbumArtist: string;
AlbumArtists: AlbumArtist[];
ImageTags: ImageTags;
BackdropImageTags: any[];
LocationType: string;
MediaType: string;
}
import { Album } from 'store/music/types';
export type StackParams = {
Albums: undefined;
Album: { id: string, album: Album };
RecentAlbums: undefined;
Search: undefined;
};
export type NavigationProp = StackNavigationProp<StackParams>;

View File

@@ -7,6 +7,7 @@ import { NavigationProp } from 'screens';
import { useTypedSelector } from 'store';
import { useDispatch } from 'react-redux';
import { setOnboardingStatus } from 'store/settings/actions';
import { t } from '@localisation';
const Container = styled.SafeAreaView`
background-color: ${THEME_COLOR};
@@ -57,16 +58,16 @@ function Onboarding() {
<TextContainer contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}>
<Logo source={require('../../assets/app-icon-white.png')} />
<Text >
Welcome!
{t('onboarding-welcome')}
</Text>
<Text>
Jellyfin Audio Player will allow you to stream your music library from anywhere, with full support for background audio and casting.
{t('onboarding-intro')}
</Text>
<Text>
In order to get started, you need a Jellyfin server. Click the button below to enter your Jellyfin server address and login to it.
{t('onboarding-cta')}
</Text>
<ButtonContainer>
<Button title="Set Jellyfin Server" color="#ffffff" onPress={handleClick} />
<Button title={t('set-jellyfin-server')} color="#ffffff" onPress={handleClick} />
</ButtonContainer>
</TextContainer>
</Container>

View File

@@ -0,0 +1,14 @@
import React from 'react';
import styled from 'styled-components/native';
const Button = styled.View`
margin: 20px 40px;
`;
function Casting() {
return (
<Button />
);
}
export default Casting;

View File

@@ -0,0 +1,9 @@
import React from 'react';
export interface CastingProps {
fill?: string;
}
declare const CastingComponent: React.FC<CastingProps>;
export default CastingComponent;

View File

@@ -0,0 +1,25 @@
import { THEME_COLOR } from 'CONSTANTS';
import React from 'react';
import AirPlayButton from 'react-native-airplay-button';
import styled from 'styled-components/native';
import { CastingProps } from './Casting';
const Button = styled.View`
margin: 20px 40px;
`;
function Casting({ fill }: CastingProps) {
return (
<>
<Button>
<AirPlayButton
activeTintColor={THEME_COLOR}
tintColor={fill}
style={{ width: 40, height: 40 }}
/>
</Button>
</>
);
}
export default Casting;

View File

@@ -1,15 +1,19 @@
import React from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import TrackPlayer, { usePlaybackState, STATE_PLAYING, STATE_PAUSED } from 'react-native-track-player';
import { TouchableOpacity } from 'react-native';
import { TouchableOpacity, useColorScheme } from 'react-native';
import styled from 'styled-components/native';
import { useHasQueue } from 'utility/useQueue';
import ForwardIcon from 'assets/forwards.svg';
import BackwardIcon from 'assets/backwards.svg';
import PlayIcon from 'assets/play.svg';
import PauseIcon from 'assets/pause.svg';
import { useColorScheme } from 'react-native-appearance';
import RepeatIcon from 'assets/repeat.svg';
// import ShuffleIcon from 'assets/shuffle.svg';
import { THEME_COLOR } from 'CONSTANTS';
import Casting from './Casting';
const BUTTON_SIZE = 40;
const BUTTON_SIZE_SMALL = 25;
const pause = () => TrackPlayer.pause();
const play = () => TrackPlayer.play();
@@ -47,6 +51,12 @@ export default function MediaControls() {
<NextButton fill={fill} />
</Button>
</Buttons>
<Buttons>
<Button>
<RepeatButton fill={fill} />
</Button>
<Casting fill={fill} />
</Buttons>
</Container>
);
}
@@ -71,6 +81,67 @@ export function NextButton({ fill }: { fill: string }) {
);
}
export function RepeatButton({ fill }: { fill: string}) {
const [isRepeating, setRepeating] = useState(false);
const handlePress = useCallback(() => setRepeating(!isRepeating), [isRepeating, setRepeating]);
const listener = useRef<TrackPlayer.EmitterSubscription | null>(null);
// The callback that should determine whether we need to repeeat or not
const handleEndEvent = useCallback(async () => {
if (isRepeating) {
// Retrieve all current tracks
const tracks = await TrackPlayer.getQueue();
// Then skip to the first track
await TrackPlayer.skip(tracks[0].id);
// Cautiously reset the seek time, as there might only be a single
// item in queue.
await TrackPlayer.seekTo(0);
// Then play the item
await TrackPlayer.play();
}
}, [isRepeating]);
// Subscribe to ended event handler so that we can restart the queue from
// the start if looping is enabled
useEffect(() => {
// Set the event listener
listener.current = TrackPlayer.addEventListener('playback-queue-ended', handleEndEvent);
// Then clean up after
return function cleanup() {
listener?.current?.remove();
};
}, [handleEndEvent]);
return (
<TouchableOpacity onPress={handlePress} style={{ opacity: isRepeating ? 1 : 0.5 }}>
<RepeatIcon
width={BUTTON_SIZE_SMALL}
height={BUTTON_SIZE_SMALL}
fill={isRepeating ? THEME_COLOR : fill}
/>
</TouchableOpacity>
);
}
// export function ShuffleButton({ fill }: { fill: string}) {
// const [isShuffling, setShuffling] = useState(false);
// const handlePress = useCallback(() => setShuffling(!isShuffling), [isShuffling, setShuffling]);
// return (
// <TouchableOpacity onPress={handlePress} style={{ opacity: isShuffling ? 1 : 0.5 }}>
// <ShuffleIcon
// width={BUTTON_SIZE_SMALL}
// height={BUTTON_SIZE_SMALL}
// fill={isShuffling ? THEME_COLOR : fill}
// />
// </TouchableOpacity>
// );
// }
export function MainButton({ fill }: { fill: string }) {
const state = usePlaybackState();

View File

@@ -1,9 +1,10 @@
import React from 'react';
import { Text, Dimensions, View, StyleSheet } from 'react-native';
import { Dimensions, View, StyleSheet } from 'react-native';
import useCurrentTrack from 'utility/useCurrentTrack';
import styled from 'styled-components/native';
import FastImage from 'react-native-fast-image';
import { colors } from 'components/Colors';
import useDefaultStyles from 'components/Colors';
import Text from 'components/Text';
const Screen = Dimensions.get('screen');
@@ -12,19 +13,15 @@ const Artwork = styled(FastImage)`
width: ${Screen.width * 0.8}px;
height: ${Screen.width * 0.8}px;
margin: 25px auto;
display: flex;
flex: 1;
`;
const styles = StyleSheet.create({
artist: {
...colors.text,
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
title: {
...colors.text,
fontSize: 18,
marginBottom: 12,
textAlign: 'center',
@@ -36,11 +33,18 @@ const styles = StyleSheet.create({
export default function NowPlaying() {
const track = useCurrentTrack();
const defaultStyles = useDefaultStyles();
return (
<View style={{ alignItems: 'center' }}>
<Artwork style={colors.imageBackground} source={{ uri: track?.artwork }} />
<Text style={styles.artist} >{track?.artist}</Text>
<Artwork
style={defaultStyles.imageBackground}
source={{
uri: track?.artwork,
priority: FastImage.priority.high,
}}
/>
<Text style={styles.artist}>{track?.artist}</Text>
<Text style={styles.title}>{track?.title}</Text>
</View>
);

View File

@@ -4,7 +4,7 @@ import styled from 'styled-components/native';
import { Text, Platform } from 'react-native';
import Slider from '@react-native-community/slider';
import { THEME_COLOR } from 'CONSTANTS';
import { colors } from 'components/Colors';
import { DefaultStylesProvider } from 'components/Colors';
const NumberBar = styled.View`
flex-direction: row;
@@ -65,24 +65,29 @@ export default class ProgressBar extends Component<{}, State> {
render() {
const { position, duration, gesture } = this.state;
return (
<>
<Slider
value={gesture || position}
minimumValue={0}
maximumValue={duration || 0}
onValueChange={this.handleGesture}
onSlidingComplete={this.handleEndOfGesture}
minimumTrackTintColor={THEME_COLOR}
thumbTintColor={Platform.OS === 'android' ? THEME_COLOR : undefined}
disabled={!duration}
/>
<NumberBar>
<Text style={colors.text}>{getMinutes(gesture || position)}:{getSeconds(gesture || position)}</Text>
<Text style={colors.text}>{getMinutes(duration)}:{getSeconds(duration)}</Text>
</NumberBar>
</>
<DefaultStylesProvider>
{defaultStyle => (
<>
<Slider
value={gesture || position}
minimumValue={0}
maximumValue={duration || 0}
onValueChange={this.handleGesture}
onSlidingComplete={this.handleEndOfGesture}
minimumTrackTintColor={THEME_COLOR}
thumbTintColor={Platform.OS === 'android' ? THEME_COLOR : undefined}
disabled={!duration}
/>
<NumberBar>
<Text style={defaultStyle.text}>{getMinutes(gesture || position)}:{getSeconds(gesture || position)}</Text>
<Text style={defaultStyle.text}>{getMinutes(duration)}:{getSeconds(duration)}</Text>
</NumberBar>
</>
)
}
</DefaultStylesProvider>
);
}
}

View File

@@ -1,12 +1,14 @@
import React, { useCallback } from 'react';
import useQueue from 'utility/useQueue';
import { View, Text, StyleSheet } from 'react-native';
import { View, StyleSheet } from 'react-native';
import styled, { css } from 'styled-components/native';
import useCurrentTrack from 'utility/useCurrentTrack';
import TouchableHandler from 'components/TouchableHandler';
import TrackPlayer from 'react-native-track-player';
import { THEME_COLOR } from 'CONSTANTS';
import { colors } from 'components/Colors';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
import Text from 'components/Text';
import Button from 'components/Button';
const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean, isDark?: boolean }>`
padding: 10px;
@@ -23,18 +25,18 @@ const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean, isDar
`}
`;
const ClearQueue = styled.View`
margin: 20px 0;
`;
const styles = StyleSheet.create({
title: {
...colors.text,
marginBottom: 2,
},
artist: {
...colors.text,
opacity: 0.5,
trackTitle: {
marginBottom: 2
}
});
export default function Queue() {
const defaultStyles = useDefaultStyles();
const queue = useQueue();
const currentTrack = useCurrentTrack();
const currentIndex = queue.findIndex(d => d.id === currentTrack?.id);
@@ -42,26 +44,32 @@ export default function Queue() {
await TrackPlayer.skip(trackId);
await TrackPlayer.play();
}, []);
const clearQueue = useCallback(async () => {
await TrackPlayer.reset();
}, []);
return (
<View>
<Text style={{ marginTop: 20, marginBottom: 20 }}>Queue</Text>
<Text style={{ marginTop: 20, marginBottom: 20 }}>{t('queue')}</Text>
{queue.map((track, i) => (
<TouchableHandler id={track.id} onPress={playTrack} key={i}>
<QueueItem
active={currentTrack?.id === track.id}
key={i}
alreadyPlayed={i < currentIndex}
style={{
...colors.border,
...currentTrack?.id === track.id ? colors.activeBackground : {},
}}
style={[
defaultStyles.border,
currentTrack?.id === track.id ? defaultStyles.activeBackground : {},
]}
>
<Text style={styles.title}>{track.title}</Text>
<Text style={styles.artist}>{track.artist}</Text>
<Text style={styles.trackTitle}>{track.title}</Text>
<Text style={defaultStyles.textHalfOpacity}>{track.artist}</Text>
</QueueItem>
</TouchableHandler>
))}
<ClearQueue>
<Button title={t('clear-queue')} onPress={clearQueue} />
</ClearQueue>
</View>
);
}

View File

@@ -4,18 +4,19 @@ import MediaControls from './components/MediaControls';
import ProgressBar from './components/ProgressBar';
import NowPlaying from './components/NowPlaying';
import Queue from './components/Queue';
import { colors } from 'components/Colors';
import useDefaultStyles from 'components/Colors';
const styles = StyleSheet.create({
outer: colors.view,
inner: {
padding: 25,
}
});
export default function Player() {
const defaultStyles = useDefaultStyles();
return (
<ScrollView contentContainerStyle={styles.inner} style={styles.outer}>
<ScrollView contentContainerStyle={styles.inner} style={defaultStyles.view}>
<NowPlaying />
<MediaControls />
<ProgressBar />

View File

@@ -0,0 +1,34 @@
import React, { useCallback } from 'react';
import TrackPlayer from 'react-native-track-player';
import { useDispatch } from 'react-redux';
import music from 'store/music';
import { t } from '@localisation';
import Button from 'components/Button';
import styled from 'styled-components/native';
import Text from 'components/Text';
const ClearCache = styled(Button)`
margin-top: 16px;
`;
const Container = styled.ScrollView`
padding: 24px;
`;
export default function CacheSettings() {
const dispatch = useDispatch();
const handleClearCache = useCallback(() => {
// Dispatch an action to reset the music subreducer state
dispatch(music.actions.reset());
// Also clear the TrackPlayer queue
TrackPlayer.reset();
}, [dispatch]);
return (
<Container>
<Text>{t('setting-cache-description')}</Text>
<ClearCache title={t('reset-cache')} onPress={handleClearCache} />
</Container>
);
}

View File

@@ -0,0 +1,48 @@
import styled from 'styled-components/native';
import { useNavigation } from '@react-navigation/native';
import React, { useCallback } from 'react';
import useDefaultStyles from 'components/Colors';
import { NavigationProp } from '../..';
import { useTypedSelector } from 'store';
import { t } from '@localisation';
import Button from 'components/Button';
import Text from 'components/Text';
const InputContainer = styled.View`
margin: 10px 0;
`;
const Input = styled.TextInput`
padding: 15px;
margin-top: 5px;
border-radius: 5px;
`;
const Container = styled.ScrollView`
padding: 24px;
`;
export default function LibrarySettings() {
const defaultStyles = useDefaultStyles();
const { jellyfin } = useTypedSelector(state => state.settings);
const navigation = useNavigation<NavigationProp>();
const handleSetLibrary = useCallback(() => navigation.navigate('SetJellyfinServer'), [navigation]);
return (
<Container>
<InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-server-url')}</Text>
<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>
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.access_token} editable={false} style={defaultStyles.input} />
</InputContainer>
<InputContainer>
<Text style={defaultStyles.text}>{t('jellyfin-user-id')}</Text>
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.user_id} editable={false} style={defaultStyles.input} />
</InputContainer>
<Button title={t('set-jellyfin-server')} onPress={handleSetLibrary} />
</Container>
);
}

View File

@@ -0,0 +1,124 @@
import Text from 'components/Text';
import React, { useEffect, useState } from 'react';
import { Switch } from 'react-native-gesture-handler';
import styled, { css } from 'styled-components/native';
import { isSentryEnabled, setSentryStatus } from 'utility/Sentry';
import Accordion from 'react-native-collapsible/Accordion';
import ChevronIcon from 'assets/chevron-right.svg';
import { THEME_COLOR } from 'CONSTANTS';
import useDefaultStyles, { DefaultStylesProvider } from 'components/Colors';
import { t } from '@localisation';
const Container = styled.ScrollView`
padding: 24px;
`;
const SwitchContainer = styled.View`
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 16px 0;
`;
const HeaderContainer = styled.View<{ isActive?: boolean }>`
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 16px 0 4px 0;
${props => props.isActive && css`
background-color: ${THEME_COLOR};
`}
`;
const HeaderText = styled(Text)`
font-size: 16px;
`;
const ContentContainer = styled.View`
margin-top: 8px;
`;
const Label = styled(Text)`
font-size: 16px;
`;
const Chevron = styled(ChevronIcon)<{ isActive?: boolean }>`
width: 14px;
height: 14px;
transform: rotate(-90deg);
${props => props.isActive && css`
transform: rotate(90deg);
`}
`;
type Question = { title: string, content: string };
const questions: Question[] = [
{
title: t('why-use-tracking'),
content: t('why-use-tracking-description')
},
{
title: t('what-data-is-gathered'),
content: t('what-data-is-gathered-description')
},
{
title: t('where-is-data-stored'),
content: t('where-is-data-stored-description')
}
];
function renderHeader(question: Question, index: number, isActive: boolean) {
return (
<HeaderContainer>
<HeaderText>{question.title}</HeaderText>
<DefaultStylesProvider>
{styles =>
<Chevron fill={styles.text.color} isActive={isActive} />
}
</DefaultStylesProvider>
</HeaderContainer>
);
}
function renderContent(question: Question) {
return (
<ContentContainer>
<Text>{question.content}</Text>
</ContentContainer>
);
}
export default function Sentry() {
const defaultStyles = useDefaultStyles();
const [isReportingEnabled, setReporting] = useState(isSentryEnabled);
const [activeSections, setActiveSections] = useState<number[]>([]);
const toggleSwitch = () => setReporting(previousState => !previousState);
useEffect(() => {
setSentryStatus(isReportingEnabled);
});
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>
<Accordion
sections={questions}
renderHeader={renderHeader}
renderContent={renderContent}
activeSections={activeSections}
onChange={setActiveSections}
underlayColor={defaultStyles.activeBackground.backgroundColor}
/>
</Container>
);
}

View File

@@ -1,57 +1,46 @@
import React, { useCallback } from 'react';
import { View, Text, SafeAreaView, Button, StyleSheet } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import styled from 'styled-components/native';
import { useSelector } from 'react-redux';
import { AppState } from 'store';
import { SafeAreaView, ScrollView } from 'react-native';
import Library from './components/Library';
import Cache from './components/Cache';
import useDefaultStyles from 'components/Colors';
import { t } from '@localisation';
import { createStackNavigator } from '@react-navigation/stack';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '..';
import ListButton from 'components/ListButton';
import { THEME_COLOR } from 'CONSTANTS';
import { Header } from 'components/Typography';
import { colors } from 'components/Colors';
import Sentry from './components/Sentry';
const InputContainer = styled.View`
margin: 10px 0;
`;
const Input = styled.TextInput`
padding: 15px;
margin-top: 5px;
border-radius: 5px;
`;
export default function Settings() {
const { jellyfin, bitrate } = useSelector((state: AppState) => state.settings);
const navigation = useNavigation<NavigationProp>();
const handleClick = useCallback(() => navigation.navigate('SetJellyfinServer'), [navigation]);
export function SettingsList() {
const navigation = useNavigation();
const handleLibraryClick = useCallback(() => { navigation.navigate('Library'); }, [navigation]);
const handleCacheClick = useCallback(() => { navigation.navigate('Cache'); }, [navigation]);
const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]);
return (
<ScrollView>
<SafeAreaView>
<View style={{ padding: 20 }}>
<Header style={colors.text}>Settings</Header>
<InputContainer>
<Text style={colors.text}>Jellyfin Server URL</Text>
<Input placeholder="https://jellyfin.yourserver.com/" value={jellyfin?.uri} editable={false} style={colors.input} />
</InputContainer>
<InputContainer>
<Text style={colors.text}>Jellyfin Access Token</Text>
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.access_token} editable={false} style={colors.input} />
</InputContainer>
<InputContainer>
<Text style={colors.text}>Jellyfin User ID</Text>
<Input placeholder="deadbeefdeadbeefdeadbeef" value={jellyfin?.user_id} editable={false} style={colors.input} />
</InputContainer>
<Button title="Set Jellyfin server" onPress={handleClick} color={THEME_COLOR} />
{/* The bitrate setting is hidden for now, since Jellyfin does not appear to support custom bitrates */}
{/* <InputContainer>
<Text style={colors.text}>Bitrate</Text>
<Picker selectedValue={bitrate}>
<Picker.Item label="320kbps" value={140000000} />
</Picker>
</InputContainer> */}
</View>
<ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton>
<ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton>
<ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton>
</SafeAreaView>
</ScrollView>
);
}
const Stack = createStackNavigator();
export default function Settings() {
const defaultStyles = useDefaultStyles();
return (
<Stack.Navigator initialRouteName="SettingList" screenOptions={{
headerTintColor: THEME_COLOR,
headerTitleStyle: defaultStyles.stackHeader
}}>
<Stack.Screen name="SettingList" component={SettingsList} options={{ headerTitle: t('settings') }} />
<Stack.Screen name="Library" component={Library} options={{ headerTitle: t('jellyfin-library') }} />
<Stack.Screen name="Cache" component={Cache} options={{ headerTitle: t('setting-cache') }} />
<Stack.Screen name="Sentry" component={Sentry} options={{ headerTitle: t('error-reporting') }} />
</Stack.Navigator>
);
}

View File

@@ -12,8 +12,13 @@ import GearIcon from 'assets/gear.svg';
import { THEME_COLOR } from 'CONSTANTS';
import { useTypedSelector } from 'store';
import Onboarding from './Onboarding';
import TrackPopupMenu from './modals/TrackPopupMenu';
import { ModalStackParams } from './types';
import { t } from '@localisation';
import ErrorReportingAlert from 'utility/ErrorReportingAlert';
import ErrorReportingPopup from './modals/ErrorReportingPopup';
const Stack = createStackNavigator();
const Stack = createStackNavigator<ModalStackParams>();
const Tab = createBottomTabNavigator();
type Screens = {
@@ -45,27 +50,30 @@ function Screens() {
}
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: function TabBarIcon({ color, size }) {
const Icon = getIcon(route.name);
<>
<ErrorReportingAlert />
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: function TabBarIcon({ color, size }) {
const Icon = getIcon(route.name);
if (!Icon) {
return null;
if (!Icon) {
return null;
}
return <Icon fill={color} width={size} height={size} />;
}
return <Icon fill={color} width={size} height={size} />;
}
})}
tabBarOptions={{
activeTintColor: THEME_COLOR,
inactiveTintColor: 'gray',
}}
>
<Tab.Screen name="NowPlaying" component={Player} options={{ tabBarLabel: 'Now Playing' }} />
<Tab.Screen name="Music" component={Music} />
<Tab.Screen name="Settings" component={Settings} />
</Tab.Navigator>
})}
tabBarOptions={{
activeTintColor: THEME_COLOR,
inactiveTintColor: 'gray',
}}
>
<Tab.Screen name="NowPlaying" component={Player} options={{ tabBarLabel: t('now-playing') }} />
<Tab.Screen name="Music" component={Music} options={{ tabBarLabel: t('music') }} />
<Tab.Screen name="Settings" component={Settings} options={{ tabBarLabel: t('settings') }} />
</Tab.Navigator>
</>
);
}
@@ -84,11 +92,13 @@ export default function Routes() {
}}>
<Stack.Screen name="Screens" component={Screens} />
<Stack.Screen name="SetJellyfinServer" component={SetJellyfinServer} />
<Stack.Screen name="TrackPopupMenu" component={TrackPopupMenu} />
<Stack.Screen name="ErrorReporting" component={ErrorReportingPopup} />
</Stack.Navigator>
);
}
export type NavigationProp = CompositeNavigationProp<
StackNavigationProp<Routes>,
BottomTabNavigationProp<Screens>
StackNavigationProp<Routes>,
BottomTabNavigationProp<Screens>
>;

View File

@@ -0,0 +1,11 @@
import React from 'react';
import Modal from 'components/Modal';
import Sentry from 'screens/Settings/components/Sentry';
export default function ErrorReportingPopup() {
return (
<Modal fullSize={false}>
<Sentry />
</Modal>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react';
import { Text, Button, View } from 'react-native';
import { Button, View } from 'react-native';
import Modal from 'components/Modal';
import Input from 'components/Input';
import { setJellyfinCredentials } from 'store/settings/actions';
@@ -7,9 +7,12 @@ import { useDispatch } from 'react-redux';
import { useNavigation, StackActions } from '@react-navigation/native';
import CredentialGenerator from './components/CredentialGenerator';
import { THEME_COLOR } from 'CONSTANTS';
import { colors } from 'components/Colors';
import { t } from '@localisation';
import useDefaultStyles from 'components/Colors';
import Text from 'components/Text';
export default function SetJellyfinServer() {
const defaultStyles = useDefaultStyles();
// State for first screen
const [serverUrl, setServerUrl] = useState<string>();
const [isLogginIn, setIsLogginIn] = useState<boolean>(false);
@@ -33,8 +36,8 @@ export default function SetJellyfinServer() {
/>
) : (
<View style={{ padding: 20, flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={colors.text}>
Please enter your Jellyfin server URL. Make sure to include the protocol and port
<Text>
{t('set-jellyfin-server-instruction')}
</Text>
<Input
placeholder="https://jellyfin.yourserver.io/"
@@ -43,10 +46,10 @@ export default function SetJellyfinServer() {
keyboardType="url"
autoCapitalize="none"
autoCorrect={false}
style={{ ...colors.input, width: '100%' }}
style={[ defaultStyles.input, { width: '100%' } ]}
/>
<Button
title="Set server"
title={t('set-jellyfin-server')}
onPress={() => setIsLogginIn(true)}
disabled={!serverUrl?.length}
color={THEME_COLOR}

View File

@@ -0,0 +1,67 @@
import React, { useCallback } from 'react';
import Modal from 'components/Modal';
import { useNavigation, StackActions, useRoute, RouteProp } from '@react-navigation/native';
import { ModalStackParams } from 'screens/types';
import { useTypedSelector } from 'store';
import { SubHeader } from 'components/Typography';
import styled from 'styled-components/native';
import usePlayTrack from 'utility/usePlayTrack';
import { t } from '@localisation';
import Button from 'components/Button';
import PlayIcon from 'assets/play.svg';
import QueueAppendIcon from 'assets/queue-append.svg';
import Text from 'components/Text';
type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>;
const Container = styled.View`
padding: 20px;
`;
const Buttons = styled.View`
margin-top: 20px;
flex-direction: row;
/* justify-content: space-around; */
`;
const ButtonSpacing = styled.View`
width: 8px;
`;
function TrackPopupMenu() {
// Retrieve helpers
const { params: { trackId } } = useRoute<Route>();
const navigation = useNavigation();
const track = useTypedSelector((state) => state.music.tracks.entities[trackId]);
const playTrack = usePlayTrack();
// Set callback to close the modal
const closeModal = useCallback(() => {
navigation.dispatch(StackActions.popToTop());
}, [navigation]);
const handlePlayNext = useCallback(() => {
playTrack(trackId, false, false);
closeModal();
}, [playTrack, closeModal, trackId]);
const handleAddToQueue = useCallback(() => {
playTrack(trackId, false, true);
closeModal();
}, [playTrack, closeModal, trackId]);
return (
<Modal fullSize={false}>
<Container>
<SubHeader>{track?.Name}</SubHeader>
<Text>{track?.Album} - {track?.AlbumArtist}</Text>
<Buttons>
<Button title={t('play-next')} icon={PlayIcon} onPress={handlePlayNext} />
<ButtonSpacing />
<Button title={t('add-to-queue')} icon={QueueAppendIcon} onPress={handleAddToQueue} />
</Buttons>
</Container>
</Modal>
);
}
export default TrackPopupMenu;

View File

@@ -0,0 +1,4 @@
export interface ModalStackParams {
SetJellyfinServer: undefined;
TrackPopupMenu: { trackId: string };
}

View File

@@ -1,5 +1,5 @@
import { configureStore, getDefaultMiddleware, combineReducers } from '@reduxjs/toolkit';
import { useSelector, TypedUseSelectorHook } from 'react-redux';
import { useSelector, TypedUseSelectorHook, useDispatch } from 'react-redux';
import AsyncStorage from '@react-native-community/async-storage';
import { persistStore, persistReducer, PersistConfig } from 'redux-persist';
import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2';
@@ -13,9 +13,11 @@ const persistConfig: PersistConfig<AppState> = {
import settings from './settings';
import music from './music';
import player from './player';
const reducers = combineReducers({
settings,
player: player.reducer,
music: music.reducer,
});
@@ -32,6 +34,7 @@ export type AppState = ReturnType<typeof reducers>;
export type AppDispatch = typeof store.dispatch;
export type AsyncThunkAPI = { state: AppState, dispatch: AppDispatch };
export const useTypedSelector: TypedUseSelectorHook<AppState> = useSelector;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const persistedStore = persistStore(store);

View File

@@ -1,7 +1,7 @@
import { createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
import { Album, AlbumTrack } from './types';
import { AsyncThunkAPI } from '..';
import { retrieveAlbums, retrieveAlbumTracks, retrieveRecentAlbums } from 'utility/JellyfinApi';
import { retrieveAllAlbums, retrieveAlbumTracks, retrieveRecentAlbums, searchItem, retrieveAlbum } from 'utility/JellyfinApi';
export const albumAdapter = createEntityAdapter<Album>({
selectId: album => album.Id,
@@ -15,7 +15,7 @@ export const fetchAllAlbums = createAsyncThunk<Album[], undefined, AsyncThunkAPI
'/albums/all',
async (empty, thunkAPI) => {
const credentials = thunkAPI.getState().settings.jellyfin;
return retrieveAlbums(credentials) as Promise<Album[]>;
return retrieveAllAlbums(credentials) as Promise<Album[]>;
}
);
@@ -44,4 +44,36 @@ export const fetchTracksByAlbum = createAsyncThunk<AlbumTrack[], string, AsyncTh
const credentials = thunkAPI.getState().settings.jellyfin;
return retrieveAlbumTracks(ItemId, credentials) as Promise<AlbumTrack[]>;
}
);
type SearchAndFetchResults = {
albums: Album[];
results: (Album | AlbumTrack)[];
};
export const searchAndFetchAlbums = createAsyncThunk<
SearchAndFetchResults,
{ term: string, limit?: number },
AsyncThunkAPI
>(
'/search',
async ({ term, limit = 24 }, thunkAPI) => {
const state = thunkAPI.getState();
const results = await searchItem(state.settings.jellyfin, term, limit);
const albums = await Promise.all(results.filter((item) => (
!state.music.albums.ids.includes(item.Type === 'MusicAlbum' ? item.Id : item.AlbumId)
)).map(async (item) => {
if (item.Type === 'MusicAlbum') {
return item;
}
return retrieveAlbum(state.settings.jellyfin, item.AlbumId);
}));
return {
albums,
results
};
}
);

View File

@@ -1,4 +1,4 @@
import { fetchAllAlbums, albumAdapter, fetchTracksByAlbum, trackAdapter, fetchRecentAlbums } from './actions';
import { fetchAllAlbums, albumAdapter, fetchTracksByAlbum, trackAdapter, fetchRecentAlbums, searchAndFetchAlbums } from './actions';
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
import { Album, AlbumTrack } from './types';
import { setJellyfinCredentials } from 'store/settings/actions';
@@ -31,7 +31,9 @@ const initialState: State = {
const music = createSlice({
name: 'music',
initialState,
reducers: {},
reducers: {
reset: () => initialState,
},
extraReducers: builder => {
/**
* Fetch All albums
@@ -58,6 +60,10 @@ const music = createSlice({
* Fetch tracks by album
*/
builder.addCase(fetchTracksByAlbum.fulfilled, (state, { payload }) => {
if (!payload.length) {
return;
}
trackAdapter.upsertMany(state.tracks, payload);
// Also store all the track ids in the album
@@ -71,6 +77,12 @@ const music = createSlice({
builder.addCase(fetchTracksByAlbum.pending, (state) => { state.tracks.isLoading = true; });
builder.addCase(fetchTracksByAlbum.rejected, (state) => { state.tracks.isLoading = false; });
builder.addCase(searchAndFetchAlbums.pending, (state) => { state.albums.isLoading = true; });
builder.addCase(searchAndFetchAlbums.fulfilled, (state, { payload }) => {
albumAdapter.upsertMany(state.albums, payload.albums);
state.albums.isLoading = false;
});
// Reset any caches we have when a new server is set
builder.addCase(setJellyfinCredentials, () => initialState);
}

View File

@@ -30,7 +30,7 @@ export interface Album {
RunTimeTicks: number;
ProductionYear: number;
IsFolder: boolean;
Type: string;
Type: 'MusicAlbum';
UserData: UserData;
PrimaryImageAspectRatio: number;
Artists: string[];
@@ -53,7 +53,7 @@ export interface AlbumTrack {
ProductionYear: number;
IndexNumber: number;
IsFolder: boolean;
Type: string;
Type: 'Audio';
UserData: UserData;
Artists: string[];
ArtistItems: ArtistItem[];

27
src/store/player/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Track } from 'react-native-track-player';
interface State {
addedTrackCount: number,
currentTrack: Track | undefined,
}
const initialState: State = {
addedTrackCount: 0,
currentTrack: undefined,
};
const player = createSlice({
name: 'player',
initialState,
reducers: {
addNewTrackToPlayer: (state) => {
state.addedTrackCount += 1;
},
setCurrentTrack: (state, action: PayloadAction<Track | undefined>) => {
state.currentTrack = action.payload;
},
}
});
export default player;

View File

@@ -2,4 +2,5 @@ import { createAction } from '@reduxjs/toolkit';
export const setJellyfinCredentials = createAction<{ access_token: string, user_id: string, uri: string, deviced_id: string; }>('SET_JELLYFIN_CREDENTIALS');
export const setBitrate = createAction<number>('SET_BITRATE');
export const setOnboardingStatus = createAction<boolean>('SET_ONBOARDING_STATUS');
export const setOnboardingStatus = createAction<boolean>('SET_ONBOARDING_STATUS');
export const setReceivedErrorReportingAlert = createAction<void>('SET_RECEIVED_ERROR_REPORTING_ALERT');

View File

@@ -1,5 +1,5 @@
import { createReducer } from '@reduxjs/toolkit';
import { setBitrate, setJellyfinCredentials, setOnboardingStatus } from './actions';
import { setReceivedErrorReportingAlert, setBitrate, setJellyfinCredentials, setOnboardingStatus } from './actions';
interface State {
jellyfin?: {
@@ -10,11 +10,13 @@ interface State {
}
bitrate: number;
isOnboardingComplete: boolean;
hasReceivedErrorReportingAlert: boolean;
}
const initialState: State = {
bitrate: 140000000,
isOnboardingComplete: false,
hasReceivedErrorReportingAlert: false,
};
const settings = createReducer(initialState, {
@@ -29,6 +31,10 @@ const settings = createReducer(initialState, {
[setOnboardingStatus.type]: (state, action) => ({
...state,
isOnboardingComplete: action.payload,
}),
[setReceivedErrorReportingAlert.type]: (state) => ({
...state,
hasReceivedErrorReportingAlert: true,
})
});

View File

@@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { Alert } from 'react-native';
import { useTypedSelector } from 'store';
import { t } from '@localisation';
import { setReceivedErrorReportingAlert } from 'store/settings/actions';
import { setSentryStatus } from './Sentry';
import { useNavigation } from '@react-navigation/native';
import { useDispatch } from 'react-redux';
/**
* This will send out an alert message asking the user if they want to enable
* error reporting.
*/
export default function ErrorReportingAlert() {
const { hasReceivedErrorReportingAlert } = useTypedSelector(state => state.settings);
const navigation = useNavigation();
const dispatch = useDispatch();
useEffect(() => {
// Only send out alert if we haven't done so ever
if (!hasReceivedErrorReportingAlert) {
// Generate the alert
Alert.alert(
t('enable-error-reporting'),
t('enable-error-reporting-description'),
[
{
text: t('enable'),
style: 'default',
onPress: () => {
setSentryStatus(true);
}
},
{
text: t('disable'),
style: 'destructive',
onPress: () => {
setSentryStatus(false);
}
},
{
text: t('more-info'),
style: 'cancel',
onPress: () => {
navigation.navigate('ErrorReporting');
}
}
]
);
// Store the flag that we have sent out the alert, so that we don't
// have to do so anymore in the future.
dispatch(setReceivedErrorReportingAlert());
}
}, []);
return null;
}

View File

@@ -1,6 +1,6 @@
import { Track } from 'react-native-track-player';
import { AppState, useTypedSelector } from 'store';
import { AlbumTrack } from 'store/music/types';
import { Album, AlbumTrack } from 'store/music/types';
type Credentials = AppState['settings']['jellyfin'];
@@ -22,11 +22,7 @@ const baseTrackOptions: Record<string, string> = {
// This must be set to support client seeking
TranscodingProtocol: 'hls',
TranscodingContainer: 'ts',
// NOTE: We cannot send a comma-delimited list yet due to an issue with
// react-native-track-player. This is set to be merged and released very
// soon: https://github.com/react-native-kit/react-native-track-player/pull/950
// Container: 'mp3',
Container: 'mp3,aac,m4a,m4b|aac,alac,m4a,m4b|alac',
Container: 'mp3,aac,m4a,m4b|aac,alac,m4a,m4b|alac,flac|ogg',
AudioCodec: 'aac',
static: 'true',
};
@@ -45,7 +41,7 @@ export function generateTrack(track: AlbumTrack, credentials: Credentials): Trac
DeviceId: credentials?.device_id || '',
};
const trackParams = new URLSearchParams(trackOptions).toString();
const url = encodeURI(`${credentials?.uri}/Audio/${track.Id}/universal.mp3?${trackParams}`);
const url = encodeURI(`${credentials?.uri}/Audio/${track.Id}/universal?${trackParams}`);
return {
id: track.Id,
@@ -53,7 +49,9 @@ export function generateTrack(track: AlbumTrack, credentials: Credentials): Trac
title: track.Name,
artist: track.Artists.join(', '),
album: track.Album,
artwork: getImage(track.Id, credentials),
artwork: track.AlbumId
? getImage(track.AlbumId, credentials)
: getImage(track.Id, credentials),
};
}
@@ -72,7 +70,7 @@ const albumParams = new URLSearchParams(albumOptions).toString();
/**
* Retrieve all albums that are available on the Jellyfin server
*/
export async function retrieveAlbums(credentials: Credentials) {
export async function retrieveAllAlbums(credentials: Credentials) {
const config = generateConfig(credentials);
const albums = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${albumParams}`, config)
.then(response => response.json());
@@ -80,6 +78,15 @@ export async function retrieveAlbums(credentials: Credentials) {
return albums.Items;
}
/**
* Retrieve a single album
*/
export async function retrieveAlbum(credentials: Credentials, id: string): Promise<Album> {
const config = generateConfig(credentials);
return fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items/${id}`, config)
.then(response => response.json());
}
const latestAlbumsOptions = {
IncludeItemTypes: 'MusicAlbum',
Fields: 'DateCreated',
@@ -124,11 +131,66 @@ export async function retrieveAlbumTracks(ItemId: string, credentials: Credentia
return album.Items;
}
/**
* Retrieve an image URL for a given ItemId
*/
export function getImage(ItemId: string, credentials: Credentials): string {
return encodeURI(`${credentials?.uri}/Items/${ItemId}/Images/Primary?format=jpeg`);
}
/**
* Create a hook that can convert ItemIds to image URLs
*/
export function useGetImage() {
const credentials = useTypedSelector((state) => state.settings.jellyfin);
return (ItemId: string) => getImage(ItemId, credentials);
}
}
const trackParams = {
SortBy: 'AlbumArtist,SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Audio',
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,SortName,BasicSyncInfo,DateCreated',
};
/**
* Retrieve all possible tracks that can be found in Jellyfin
*/
export async function retrieveAllTracks(credentials: Credentials) {
const config = generateConfig(credentials);
const tracks = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${trackParams}`, config)
.then(response => response.json());
return tracks.Items;
}
const searchParams = {
IncludeItemTypes: 'Audio,MusicAlbum',
SortBy: 'Album,SortName',
SortOrder: 'Ascending',
Recursive: 'true',
};
/**
* Remotely search the Jellyfin library for a particular search term
*/
export async function searchItem(
credentials: Credentials,
term: string, limit = 24
): Promise<(Album | AlbumTrack)[]> {
const config = generateConfig(credentials);
const params = new URLSearchParams({
...searchParams,
SearchTerm: term,
Limit: limit.toString(),
}).toString();
const results = await fetch(`${credentials?.uri}/Users/${credentials?.user_id}/Items?${params}`, config)
.then(response => response.json());
return results.Items;
}

50
src/utility/Sentry.ts Normal file
View File

@@ -0,0 +1,50 @@
import { SENTRY_DSN } from '@env';
import AsyncStorage from '@react-native-community/async-storage';
import * as Sentry from '@sentry/react-native';
const SENTRY_ASYNC__ITEM_STRING = 'sentry_enabled';
export let isSentryEnabled = false;
/**
* Setup Sentry based on what value is stored in AsyncStorage.
*/
export async function setupSentry(): Promise<void> {
// First, we'll retrieve the user settings. This delays Sentry being active
// slightly, at the bonus of acutally being able to send off reports for
// start-up stuff.
isSentryEnabled = (await AsyncStorage.getItem(SENTRY_ASYNC__ITEM_STRING)) === 'true';
// Make sure the DSN is actually set, in order to prevent weird erros.
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
// Before we send any event, check whether the Sentry SDK should be
// enabled based on user settings.
beforeSend(event) {
// If so, pass off the event to the back-end
if (isSentryEnabled) {
return event;
}
// If not, don't sent a thing
return null;
}
});
}
}
/**
* Helper function to enable or disable the Sentry SDK for this app.
*/
export async function setSentryStatus(isEnabled: boolean): Promise<void> {
// GUARD: If nothing's changed, change nothing
if (isEnabled === isSentryEnabled) {
return;
}
// First, store the value in Async Storage
await AsyncStorage.setItem(SENTRY_ASYNC__ITEM_STRING, isEnabled.toString());
// Then, assign it to the variable
isSentryEnabled = isEnabled;
}

View File

@@ -1,28 +1,15 @@
import { useEffect, useState } from 'react';
import TrackPlayer, { usePlaybackState, Track } from 'react-native-track-player';
import { Track } from 'react-native-track-player';
import { useTypedSelector } from 'store';
const idEqual = (left: Track | undefined, right: Track | undefined) => {
return left?.id === right?.id;
};
/**
* This hook retrieves the current playing track from TrackPlayer
*/
export default function useCurrentTrack(): Track | undefined {
const state = usePlaybackState();
const [track, setTrack] = useState<Track>();
useEffect(() => {
const fetchTrack = async () => {
const currentTrackId = await TrackPlayer.getCurrentTrack();
// GUARD: Only fetch current track if there is a current track
if (!currentTrackId) {
return;
}
const currentTrack = await TrackPlayer.getTrack(currentTrackId);
setTrack(currentTrack);
};
fetchTrack();
}, [state]);
const track = useTypedSelector(state => state.player.currentTrack, idEqual);
return track;
}

View File

@@ -2,17 +2,22 @@ import { useTypedSelector } from 'store';
import { useCallback } from 'react';
import TrackPlayer, { Track } from 'react-native-track-player';
import { generateTrack } from './JellyfinApi';
import useQueue from './useQueue';
import player from 'store/player';
import { useDispatch } from 'react-redux';
/**
* Generate a callback function that starts playing a full album given its
* supplied id.
*/
export default function usePlayAlbum() {
const dispatch = useDispatch();
const credentials = useTypedSelector(state => state.settings.jellyfin);
const albums = useTypedSelector(state => state.music.albums.entities);
const tracks = useTypedSelector(state => state.music.tracks.entities);
const queue = useQueue();
return useCallback(async function playAlbum(albumId: string) {
return useCallback(async function playAlbum(albumId: string, play = true): Promise<TrackPlayer.Track[] | undefined> {
const album = albums[albumId];
const trackIds = album?.Tracks;
@@ -21,6 +26,29 @@ export default function usePlayAlbum() {
return;
}
// Check if the queue already contains the consecutive track listing
// that is described as part of the album
const queuedAlbum = queue.reduce<TrackPlayer.Track[]>((sum, track) => {
if (track.id.startsWith(trackIds[sum.length])) {
sum.push(track);
} else {
sum = [];
}
return sum;
}, []);
// If the entire album is already in the queue, we can just return those
// tracks, rather than adding it to the queue again.
if (queuedAlbum.length === trackIds.length) {
if (play) {
await TrackPlayer.skip(trackIds[0]);
await TrackPlayer.play();
}
return queuedAlbum;
}
// Convert all trackIds to the relevant format for react-native-track-player
const newTracks = trackIds.map((trackId) => {
const track = tracks[trackId];
@@ -34,7 +62,15 @@ export default function usePlayAlbum() {
// Clear the queue and add all tracks
await TrackPlayer.removeUpcomingTracks();
await TrackPlayer.add(newTracks);
await TrackPlayer.skip(trackIds[0]);
TrackPlayer.play();
}, [credentials, albums, tracks]);
// Then, we'll dispatch the added track event
dispatch(player.actions.addNewTrackToPlayer());
if (play) {
await TrackPlayer.skip(trackIds[0]);
await TrackPlayer.play();
}
return newTracks;
}, [credentials, albums, tracks, queue, dispatch]);
}

Some files were not shown because too many files have changed in this diff Show More