Compare commits
118 Commits
v0.0.1-alp
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bf20b1762 | ||
|
|
76598b38cb | ||
|
|
5d6f65b699 | ||
|
|
f78db52e0a | ||
|
|
611cbc8c69 | ||
|
|
cab3935a92 | ||
|
|
f8e57827f2 | ||
|
|
55494caf4f | ||
|
|
b7d16a174b | ||
|
|
3c98bf92eb | ||
|
|
ebd46970d9 | ||
|
|
cc0dfc2528 | ||
|
|
74ddc58f83 | ||
|
|
95a008d8af | ||
|
|
db1c9d8af9 | ||
|
|
c28c1a851d | ||
|
|
d2fb4a4aea | ||
|
|
284d23d30b | ||
|
|
464747d0c4 | ||
|
|
419ce99d08 | ||
|
|
4e6a758d83 | ||
|
|
4ecf978505 | ||
|
|
5d96a67336 | ||
|
|
6a4b5618aa | ||
|
|
75e8ece60a | ||
|
|
4460bdf7f9 | ||
|
|
9b41a0e62f | ||
|
|
93c9ba7498 | ||
|
|
9668ba9c7c | ||
|
|
6440c1ac7b | ||
|
|
6c0a277397 | ||
|
|
080a664d4f | ||
|
|
ae6a99ce6d | ||
|
|
b14f546525 | ||
|
|
2b8e78a076 | ||
|
|
4a345699a8 | ||
|
|
eaef9be7db | ||
|
|
8049a25cce | ||
|
|
7403ab986b | ||
|
|
d8e9f8f4c6 | ||
|
|
fa05935017 | ||
|
|
2de5cc8e6c | ||
|
|
24d484ca25 | ||
|
|
a7b24cf4eb | ||
|
|
eaa1402173 | ||
|
|
1edeb00631 | ||
|
|
d422c1ff1e | ||
|
|
28b330ad4c | ||
|
|
a867513212 | ||
|
|
14a6341fae | ||
|
|
645f1307f3 | ||
|
|
3f16bd6465 | ||
|
|
5c15d730b3 | ||
|
|
603bf10154 | ||
|
|
57f9e0e08e | ||
|
|
597a348eab | ||
|
|
8860581450 | ||
|
|
a92ff581ae | ||
|
|
cd2e333caa | ||
|
|
4ba83b8efc | ||
|
|
e4daccfe5e | ||
|
|
76c45623a0 | ||
|
|
e4b93ec8c8 | ||
|
|
4635266273 | ||
|
|
8dc287e56a | ||
|
|
6cfa8f7624 | ||
|
|
6cf5b8167b | ||
|
|
dc76ea27d3 | ||
|
|
7adc96ba12 | ||
|
|
42eb7a169b | ||
|
|
3eab7793f5 | ||
|
|
66eb4bd61d | ||
|
|
3312e99911 | ||
|
|
3ae967e137 | ||
|
|
d18daf027e | ||
|
|
0ae7e3ba0c | ||
|
|
e918f089e2 | ||
|
|
491f019fb3 | ||
|
|
0af730dabe | ||
|
|
fadfabc868 | ||
|
|
de73fe6358 | ||
|
|
6f3ed9c988 | ||
|
|
ff484cebed | ||
|
|
1e14ed3271 | ||
|
|
954fe38531 | ||
|
|
83cf0e4d56 | ||
|
|
3b98eb4bf1 | ||
|
|
49bb4dfaab | ||
|
|
9f9e61017e | ||
|
|
65c7db9911 | ||
|
|
481a411e4c | ||
|
|
8eeddebad7 | ||
|
|
974c73e70a | ||
|
|
0988e10a09 | ||
|
|
9ea0753912 | ||
|
|
6cf421f24f | ||
|
|
0df9d4a621 | ||
|
|
6a1d75c27c | ||
|
|
c88158cf16 | ||
|
|
3026fdf4da | ||
|
|
12b53eca4a | ||
|
|
3ff971a773 | ||
|
|
8fac21f4d8 | ||
|
|
6e85013b5a | ||
|
|
edce0329cf | ||
|
|
be40d8a404 | ||
|
|
9138946ba9 | ||
|
|
ea11a7d317 | ||
|
|
91344300c8 | ||
|
|
52146a6e12 | ||
|
|
0712e6b8ca | ||
|
|
2155320f6b | ||
|
|
4ff1e173ba | ||
|
|
c1f6a2984a | ||
|
|
8ba3080734 | ||
|
|
fe22661036 | ||
|
|
3762d9166b | ||
|
|
48dd8c23aa |
3
.editorconfig
Normal file
@@ -0,0 +1,3 @@
|
||||
# Windows files
|
||||
[*.bat]
|
||||
end_of_line = crlf
|
||||
15
.eslintrc.js
@@ -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
@@ -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
|
||||
|
||||
11
.github/workflows/fastlane.yml
vendored
@@ -27,6 +27,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set outputs
|
||||
id: vars
|
||||
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
|
||||
- name: Set up Ruby 2.6
|
||||
uses: actions/setup-ruby@v1
|
||||
with:
|
||||
@@ -38,9 +41,15 @@ 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
|
||||
with:
|
||||
name: jellyfin-audio-player-android.apk
|
||||
name: jellyfin-audio-player-android-${{ steps.vars.outputs.sha_short }}.apk
|
||||
path: android/app/build/outputs/**/*.apk
|
||||
13
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: Lint
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Install Node dependencies
|
||||
run: npm install
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
7
.gitignore
vendored
@@ -63,4 +63,9 @@ buck-out/
|
||||
/ios/Pods/
|
||||
|
||||
build/
|
||||
fastlane/report.xml
|
||||
fastlane/report.xml
|
||||
fastlane/Appfile
|
||||
certificates/
|
||||
|
||||
.env
|
||||
sentry.properties
|
||||
3
Gemfile
@@ -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)
|
||||
|
||||
157
Gemfile.lock
@@ -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.345.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.75.0)
|
||||
aws-sdk-core (~> 3, >= 3.104.1)
|
||||
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.1)
|
||||
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.75.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.153.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.26.2)
|
||||
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.0)
|
||||
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)
|
||||
os (1.1.0)
|
||||
plist (3.5.0)
|
||||
public_suffix (4.0.5)
|
||||
rake (13.0.1)
|
||||
representable (3.0.4)
|
||||
naturally (2.2.1)
|
||||
os (1.1.1)
|
||||
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.17.1)
|
||||
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
|
||||
|
||||
66
README.md
@@ -1,25 +1,69 @@
|
||||
# Jellyfin Audio Player
|
||||
This is a React Native-based audio streaming app for Jellyfin.
|
||||

|
||||

|
||||
|
||||
This is a [React Native](https://reactnative.dev/)-based audio streaming app for [Jellyfin](https://jellyfin.org/). Jellyfin is a community-based piece of software that allows you to stream your media library over the internet. By means of React Native, Jellyfin Audio Player allows you to stream your Jellyfin Music library, with full support for background audio and casting (ie. Airplay and Chromecast).
|
||||
|
||||
## ❗️Now open for beta testing on iOS
|
||||
Please follow this link to enroll for the TestFlight beta release of Jellyfin Audio Player: https://testflight.apple.com/join/cf2AMDpx.
|
||||
|
||||
|||||
|
||||
|-|-|-|-|
|
||||
|
||||
## Features
|
||||
* Sorting by recent albums
|
||||
* Browsing through all available albums
|
||||
* Searching based on album and artist names
|
||||
* Queuing tracks and albums
|
||||
* AirPlay and Chromecast support
|
||||
* Background audio
|
||||
* Native Dark Mode
|
||||
|
||||
### Features being considered
|
||||
* Downloading music for offline playback
|
||||
* Searching based on track names
|
||||
* Looping and shuffling queue
|
||||
|
||||
## Getting Started
|
||||
What you want get started on depends on your intentions. As there is no build for general availability yet, you will need to build this project yourself.
|
||||
This piece of software is in alpha. I am working on getting this app in ~~TestFlight and~~ Google Play Developer Console, but this is contingent on keys being available. In the meantime, IPAs and APK are intermittenly released on the [Releases page](https://github.com/leinelissen/jellyfin-audio-player/releases). Alternatively, you can build this app from source using the build instructions.
|
||||
|
||||
### Using the app
|
||||
You will need to setup your Jellyfin account for the application to be able to pull in all your audio. To do this, go over to the "Settings" tab and click the "Set Jellyfin server"-button. A modal will pop up in which you will enter your Jellyfin server url, after which you enter your credentials in the provided browser view. When the app detects your credentials, they will automatically be remembered by the app.
|
||||
You will need to setup your Jellyfin account for the application to be able to pull in all your audio. To do this, go over to the "Settings" tab and click the "Set Jellyfin server"-button. A modal will pop up in which you will enter your Jellyfin server url, after which you enter your credentials in the provided browser view. When the app detects your credentials, they will automatically be remembered for the future.
|
||||
|
||||
## Building from source
|
||||
### Prerequisites
|
||||
This project is built on React Native, and first of all requires [NodeJS](https://nodejs.org/en/) to be installed. After installing it and cloning this repository, don't forget ton run `npm install` on your command line, so that all Node dependencies are installed.
|
||||
|
||||
#### iOS Prerequisites
|
||||
[XCode](https://developer.apple.com/download/) is required to build the iOS application. It also comes bundles with iOS simulators which make development exceedingly easy. This does mean that iOS development is limited to macs.
|
||||
|
||||
#### Android prerequisites
|
||||
[Android Studio](https://developer.android.com/studio/install) is recommended for development as it includes the Android SDK as well as Android Simulators for devleopment. At the very least, installing the Android SDK is neccessary for building any version of the app.
|
||||
|
||||
### Development Build
|
||||
This app has been mainly developed for iOS, but should mostly function on Android as well. To get started, do the following:
|
||||
1. Clone this repository
|
||||
2. Install [NodeJS](https://nodejs.org/en/) and [XCode](https://developer.apple.com/download/)
|
||||
3. `npm install`
|
||||
4. `npm run ios`
|
||||
As soon as all prerequisites are covered, you can start development in either iOS or Android simulators by running the following
|
||||
```
|
||||
npm run ios
|
||||
npm run android
|
||||
```
|
||||
|
||||
### Production Build
|
||||
Follow step 1-3 from the development build, then do the following:
|
||||
This project is configured using [Fastlane](https://docs.fastlane.tools/), which allows for easy IPA and APK generation. To get started with this, make sure you install Fastlane first either using bundler (see below), or alternatively via e.g. Homebrew ([see supported methods](https://docs.fastlane.tools/getting-started/ios/setup/)).
|
||||
```
|
||||
npm run build:ios
|
||||
gem install bundler
|
||||
bundle install -j 6
|
||||
```
|
||||
Then open `ios/JellyfinAudioPlayer.xcworkspace` and build the project in XCode.
|
||||
When fastlane is setup, you can run either commands for generating IPA (iOS) or APK (Android) bundles.
|
||||
```
|
||||
fastlane ios beta
|
||||
fastlane android beta
|
||||
```
|
||||
|
||||
## Licensing and Credits
|
||||
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.
|
||||
@@ -79,10 +79,11 @@ import com.android.build.OutputFile
|
||||
|
||||
project.ext.react = [
|
||||
enableHermes: false, // clean and rebuild if changing
|
||||
entryFile: 'index.ts'
|
||||
entryFile: 'index.js'
|
||||
]
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
200
android/gradlew.bat
vendored
@@ -1,100 +1,100 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
||||
2
app.json
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "JellyfinAudioPlayer",
|
||||
"displayName": "Audio Player"
|
||||
"displayName": "Jellyfin Player"
|
||||
}
|
||||
@@ -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'
|
||||
]
|
||||
]
|
||||
};
|
||||
|
||||
BIN
docs/images/bmd-logo-icon.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
@@ -1,2 +1,4 @@
|
||||
package_name("org.leinelissen.jellyfinaudioplayer")
|
||||
app_identifier("org.leinelissen.JellyfinAudioPlayer")
|
||||
package_name("nl.moeilijkedingen.jellyfinaudioplayer")
|
||||
app_identifier("nl.moeilijkedingen.jellyfinaudioplayer")
|
||||
apple_id("lei@moeilijkedingen.nl")
|
||||
team_id("238P3C58WC")
|
||||
@@ -1,23 +1,86 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
lane :beta do
|
||||
enable_automatic_code_signing
|
||||
lane :alpha do
|
||||
get_certificates(
|
||||
development: true,
|
||||
output_path: 'certificates/'
|
||||
)
|
||||
build_app(
|
||||
scheme: "JellyfinAudioPlayer",
|
||||
scheme: "Jellyfin Player",
|
||||
export_method: "development",
|
||||
output_directory: "build",
|
||||
workspace: "ios/JellyfinAudioPlayer.xcworkspace"
|
||||
)
|
||||
end
|
||||
lane :beta do
|
||||
get_certificates(
|
||||
output_path: 'certificates/'
|
||||
)
|
||||
get_provisioning_profile(
|
||||
output_path: 'certificates/',
|
||||
filename: "provisioning.mobileprovision",
|
||||
fail_on_name_taken: true,
|
||||
)
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: true,
|
||||
path: "ios/JellyfinAudioPlayer.xcodeproj"
|
||||
)
|
||||
increment_build_number(
|
||||
xcodeproj: "ios/JellyfinAudioPlayer.xcodeproj"
|
||||
)
|
||||
build_app(
|
||||
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
|
||||
|
||||
platform :android do
|
||||
desc "Generate beta build"
|
||||
lane :beta do
|
||||
gradle(
|
||||
task: "clean assembleRelease",
|
||||
project_dir: "android"
|
||||
)
|
||||
end
|
||||
desc "Generate beta build"
|
||||
lane :beta do
|
||||
gradle(
|
||||
task: "clean assembleRelease",
|
||||
project_dir: "android"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
5
fastlane/Pluginfile
Normal file
@@ -0,0 +1,5 @@
|
||||
# Autogenerated by fastlane
|
||||
#
|
||||
# Ensure this file is checked in to source control!
|
||||
|
||||
gem 'fastlane-plugin-sentry'
|
||||
43
fastlane/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
fastlane documentation
|
||||
================
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
Install _fastlane_ using
|
||||
```
|
||||
[sudo] gem install fastlane -NV
|
||||
```
|
||||
or alternatively using `brew install fastlane`
|
||||
|
||||
# Available Actions
|
||||
## iOS
|
||||
### ios alpha
|
||||
```
|
||||
fastlane ios alpha
|
||||
```
|
||||
|
||||
### ios beta
|
||||
```
|
||||
fastlane ios beta
|
||||
```
|
||||
|
||||
|
||||
----
|
||||
|
||||
## Android
|
||||
### android beta
|
||||
```
|
||||
fastlane android beta
|
||||
```
|
||||
Generate beta build
|
||||
|
||||
----
|
||||
|
||||
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).
|
||||
@@ -1,18 +1,15 @@
|
||||
// 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';
|
||||
import { patchTrackPlayer } from 'utility/AddedTrackEvents';
|
||||
|
||||
setupSentry();
|
||||
enableScreens();
|
||||
patchTrackPlayer();
|
||||
AppRegistry.registerComponent(appName, () => App);
|
||||
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
||||
8
ios/File.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
//
|
||||
// File.swift
|
||||
// JellyfinAudioPlayer
|
||||
//
|
||||
// Created by Lei Nelissen on 02/11/2020.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
4
ios/JellyfinAudioPlayer-Bridging-Header.h
Normal file
@@ -0,0 +1,4 @@
|
||||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -13,7 +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 */; };
|
||||
4F46F441249A56FF00308470 /* main.jsbundle in Resources */ = {isa = PBXBuildFile; fileRef = 008F07F21AC5B25A0029DE68 /* main.jsbundle */; };
|
||||
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 */
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = "<group>"; };
|
||||
00E356EE1AD99517003FC87E /* JellyfinAudioPlayerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JellyfinAudioPlayerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
00E356F21AD99517003FC87E /* JellyfinAudioPlayerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = JellyfinAudioPlayerTests.m; sourceTree = "<group>"; };
|
||||
@@ -41,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; };
|
||||
@@ -95,13 +96,14 @@
|
||||
13B07FAE1A68108700A75B9A /* JellyfinAudioPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
008F07F21AC5B25A0029DE68 /* main.jsbundle */,
|
||||
13B07FAF1A68108700A75B9A /* AppDelegate.h */,
|
||||
13B07FB01A68108700A75B9A /* AppDelegate.m */,
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
13B07FB11A68108700A75B9A /* LaunchScreen.xib */,
|
||||
13B07FB71A68108700A75B9A /* main.m */,
|
||||
4FA1B23C2550A94C007A035E /* File.swift */,
|
||||
4FA1B23B2550A94B007A035E /* JellyfinAudioPlayer-Bridging-Header.h */,
|
||||
);
|
||||
name = JellyfinAudioPlayer;
|
||||
sourceTree = "<group>";
|
||||
@@ -177,6 +179,7 @@
|
||||
00E356EB1AD99517003FC87E /* Frameworks */,
|
||||
00E356EC1AD99517003FC87E /* Resources */,
|
||||
BDE784ECF29EF861DBFF49D7 /* [CP] Copy Pods Resources */,
|
||||
A02366876E56A727F566EC3A /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -199,6 +202,8 @@
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
B9FB8FC65CEFF9AFAC71127E /* [CP] Copy Pods Resources */,
|
||||
1DC46C84C90B4D84A18AC142 /* Upload Debug Symbols to Sentry */,
|
||||
2917566AA57EE087FC9FCCE9 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -219,13 +224,13 @@
|
||||
TargetAttributes = {
|
||||
00E356ED1AD99517003FC87E = {
|
||||
CreatedOnToolsVersion = 6.2;
|
||||
DevelopmentTeam = HD2D35G9Y4;
|
||||
DevelopmentTeam = 238P3C58WC;
|
||||
ProvisioningStyle = Automatic;
|
||||
TestTargetID = 13B07F861A680F5B00A75B9A;
|
||||
};
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
DevelopmentTeam = HD2D35G9Y4;
|
||||
LastSwiftMigration = 1120;
|
||||
DevelopmentTeam = 238P3C58WC;
|
||||
LastSwiftMigration = 1210;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
@@ -261,7 +266,6 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4F46F441249A56FF00308470 /* main.jsbundle in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */,
|
||||
);
|
||||
@@ -275,14 +279,71 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Bundle React Native code and images";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/main.jsbundle",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "export NODE_BINARY=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";
|
||||
};
|
||||
2917566AA57EE087FC9FCCE9 /* [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;
|
||||
};
|
||||
A02366876E56A727F566EC3A /* [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;
|
||||
@@ -399,6 +460,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */,
|
||||
4FA1B23D2550A94C007A035E /* File.swift in Sources */,
|
||||
13B07FC11A68108700A75B9A /* main.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -430,9 +492,10 @@
|
||||
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 = HD2D35G9Y4;
|
||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
@@ -440,6 +503,10 @@
|
||||
INFOPLIST_FILE = JellyfinAudioPlayerTests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(SDKROOT)/usr/lib/swift",
|
||||
"$(inherited)",
|
||||
);
|
||||
OTHER_LDFLAGS = (
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
@@ -455,13 +522,18 @@
|
||||
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;
|
||||
DEVELOPMENT_TEAM = HD2D35G9Y4;
|
||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||
INFOPLIST_FILE = JellyfinAudioPlayerTests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(SDKROOT)/usr/lib/swift",
|
||||
"$(inherited)",
|
||||
);
|
||||
OTHER_LDFLAGS = (
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
@@ -469,6 +541,8 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE = "915c5213-22f6-4f9d-8065-2a06300f9bfb";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/JellyfinAudioPlayer.app/JellyfinAudioPlayer";
|
||||
};
|
||||
name = Release;
|
||||
@@ -480,8 +554,8 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = HD2D35G9Y4;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
@@ -489,13 +563,19 @@
|
||||
);
|
||||
INFOPLIST_FILE = JellyfinAudioPlayer/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(SDKROOT)/usr/lib/swift",
|
||||
"$(inherited)",
|
||||
);
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.leinelissen.JellyfinAudioPlayer;
|
||||
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";
|
||||
@@ -508,18 +588,26 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = HD2D35G9Y4;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 238P3C58WC;
|
||||
INFOPLIST_FILE = JellyfinAudioPlayer/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(SDKROOT)/usr/lib/swift",
|
||||
"$(inherited)",
|
||||
);
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.leinelissen.JellyfinAudioPlayer;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = nl.moeilijkedingen.jellyfinaudioplayer;
|
||||
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";
|
||||
};
|
||||
@@ -553,10 +641,12 @@
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
@@ -572,15 +662,17 @@
|
||||
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 = (
|
||||
"$(SDKROOT)/usr/lib/swift",
|
||||
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
|
||||
"\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"",
|
||||
"\"$(inherited)\"",
|
||||
);
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "Jellyfin Player";
|
||||
SDKROOT = iphoneos;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -613,10 +705,12 @@
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: Bureau Moeilijke Dingen BV (238P3C58WC)";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution: Bureau Moeilijke Dingen BV (238P3C58WC)";
|
||||
COPY_PHASE_STRIP = YES;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
@@ -625,14 +719,16 @@
|
||||
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 = (
|
||||
"$(SDKROOT)/usr/lib/swift",
|
||||
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
|
||||
"\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"",
|
||||
"\"$(inherited)\"",
|
||||
);
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
PRODUCT_NAME = "Jellyfin Player";
|
||||
SDKROOT = iphoneos;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -11,6 +11,7 @@
|
||||
#import <FlipperKitNetworkPlugin/FlipperKitNetworkPlugin.h>
|
||||
#import <SKIOSNetworkPlugin/SKIOSNetworkAdapter.h>
|
||||
#import <FlipperKitReactPlugin/FlipperKitReactPlugin.h>
|
||||
#import <RNFSManager.h>
|
||||
|
||||
static void InitializeFlipper(UIApplication *application) {
|
||||
FlipperClient *client = [FlipperClient sharedClient];
|
||||
|
||||
@@ -21,28 +21,20 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>27</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>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>processing</string>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>27</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
29
ios/Podfile
@@ -1,21 +1,38 @@
|
||||
platform :ios, '10.0'
|
||||
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
|
||||
require_relative '../node_modules/react-native/scripts/react_native_pods'
|
||||
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
|
||||
|
||||
platform :ios, '12.0'
|
||||
|
||||
target 'JellyfinAudioPlayer' do
|
||||
config = use_native_modules!
|
||||
use_react_native!(:path => config["reactNativePath"])
|
||||
|
||||
use_react_native!(
|
||||
:path => config[:reactNativePath],
|
||||
# to enable hermes on iOS, change `false` to `true` and then install pods
|
||||
:hermes_enabled => false
|
||||
)
|
||||
|
||||
target 'JellyfinAudioPlayerTests' do
|
||||
inherit! :complete
|
||||
# Pods for testing
|
||||
end
|
||||
|
||||
# Enables Flipper.
|
||||
#
|
||||
# Note that if you have use_frameworks! enabled, Flipper will not work and
|
||||
# you should disable these next few lines.
|
||||
use_flipper!
|
||||
# you should disable the next line.
|
||||
use_flipper!()
|
||||
|
||||
post_install do |installer|
|
||||
flipper_post_install(installer)
|
||||
react_native_post_install(installer)
|
||||
__apply_Xcode_12_5_M1_post_install_workaround(installer)
|
||||
installer.aggregate_targets.each do |aggregate_target|
|
||||
aggregate_target.user_project.native_targets.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['LIBRARY_SEARCH_PATHS'] = ['$(SDKROOT)/usr/lib/swift', '$(inherited)']
|
||||
end
|
||||
end
|
||||
aggregate_target.user_project.save
|
||||
end
|
||||
end
|
||||
end
|
||||
874
ios/Podfile.lock
30432
package-lock.json
generated
115
package.json
@@ -1,67 +1,79 @@
|
||||
{
|
||||
"name": "JellyfinAudioPlayer",
|
||||
"version": "0.0.1",
|
||||
"main": "src/index.ts",
|
||||
"version": "0.2.1",
|
||||
"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",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx && tsc --noEmit",
|
||||
"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/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-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-community/async-storage": "^1.12.1",
|
||||
"@react-native-community/masked-view": "^0.1.11",
|
||||
"@react-native-community/netinfo": "^7.1.7",
|
||||
"@react-native-community/picker": "^1.8.1",
|
||||
"@react-native-community/slider": "^4.1.12",
|
||||
"@react-navigation/bottom-tabs": "^6.0.9",
|
||||
"@react-navigation/native": "^6.0.6",
|
||||
"@react-navigation/stack": "^6.0.11",
|
||||
"@reduxjs/toolkit": "^1.7.1",
|
||||
"@sentry/react-native": "^3.2.10",
|
||||
"@types/lodash": "^4.14.178",
|
||||
"date-fns": "^2.28.0",
|
||||
"events": "^3.3.0",
|
||||
"fuse.js": "^6.5.3",
|
||||
"i18n-js": "^3.8.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^17.0.2",
|
||||
"react-native": "^0.66.4",
|
||||
"react-native-airplay-button": "^1.1.0",
|
||||
"react-native-collapsible": "^1.6.0",
|
||||
"react-native-dotenv": "^3.3.1",
|
||||
"react-native-fast-image": "^8.5.11",
|
||||
"react-native-flipper": "^0.127.0",
|
||||
"react-native-fs": "^2.18.0",
|
||||
"react-native-gesture-handler": "^2.1.0",
|
||||
"react-native-localize": "^2.1.7",
|
||||
"react-native-safe-area-context": "^3.3.2",
|
||||
"react-native-screens": "^3.10.1",
|
||||
"react-native-svg": "^12.2.0",
|
||||
"react-native-svg-transformer": "^1.0.0",
|
||||
"react-native-track-player": "^2.1.2",
|
||||
"react-native-webview": "^11.15.0",
|
||||
"react-redux": "^7.2.6",
|
||||
"redux": "^4.1.2",
|
||||
"redux-flipper": "^2.0.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",
|
||||
"@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"
|
||||
"@babel/core": "^7.16.7",
|
||||
"@babel/runtime": "^7.16.7",
|
||||
"@react-native-community/eslint-config": "^3.0.1",
|
||||
"@sentry/cli": "^1.71.0",
|
||||
"@types/i18n-js": "^3.8.2",
|
||||
"@types/jest": "^27.4.0",
|
||||
"@types/react-native": "^0.66.10",
|
||||
"@types/react-redux": "^7.1.21",
|
||||
"@types/react-test-renderer": "^17.0.1",
|
||||
"@types/redux-logger": "^3.0.9",
|
||||
"@types/styled-components": "^5.1.19",
|
||||
"@types/styled-components-react-native": "^5.1.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.8.1",
|
||||
"@typescript-eslint/parser": "^5.8.1",
|
||||
"babel-jest": "^27.4.5",
|
||||
"babel-plugin-module-resolver": "^4.1.0",
|
||||
"eslint": "^8.5.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"jest": "^27.4.5",
|
||||
"metro-react-native-babel-preset": "^0.66.2",
|
||||
"react-test-renderer": "^17.0.2",
|
||||
"typescript": "^4.5.4"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "react-native",
|
||||
@@ -73,5 +85,8 @@
|
||||
"json",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react-native": "^0.66.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const ALBUM_CACHE_AMOUNT_OF_DAYS = 7;
|
||||
export const PLAYLIST_CACHE_AMOUNT_OF_DAYS = 7;
|
||||
export const ALPHABET_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ ';
|
||||
export const THEME_COLOR = '#FF3C00';
|
||||
BIN
src/assets/app-icon-white.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
3
src/assets/arrow-clockwise.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.74512 9.09863C8 9.09863 8.20215 9.01953 8.36035 8.85254L11.9639 5.22266C12.1572 5.0293 12.2451 4.81836 12.2451 4.57227C12.2451 4.33496 12.1484 4.10645 11.9639 3.93066L8.36035 0.265625C8.20215 0.0898438 8 0.00195312 7.74512 0.00195312C7.27051 0.00195312 6.89258 0.397461 6.89258 0.880859C6.89258 1.11816 6.98047 1.31152 7.12988 1.47852L9.23047 3.53516C8.81738 3.47363 8.39551 3.43848 7.97363 3.43848C3.62305 3.43848 0.142578 6.91895 0.142578 11.2783C0.142578 15.6377 3.64941 19.1445 8 19.1445C12.3594 19.1445 15.8574 15.6377 15.8574 11.2783C15.8574 10.751 15.4883 10.373 14.9609 10.373C14.4512 10.373 14.1084 10.751 14.1084 11.2783C14.1084 14.6709 11.3926 17.3955 8 17.3955C4.61621 17.3955 1.8916 14.6709 1.8916 11.2783C1.8916 7.85938 4.58984 5.15234 7.97363 5.15234C8.54492 5.15234 9.07227 5.19629 9.53809 5.27539L7.13867 7.64844C6.98047 7.80664 6.89258 8 6.89258 8.2373C6.89258 8.7207 7.27051 9.09863 7.74512 9.09863Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
3
src/assets/arrow-down-to-line.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 18.7334C14.9658 18.7334 19.0791 14.6289 19.0791 9.6543C19.0791 4.68848 14.9658 0.575195 9.99121 0.575195C5.02539 0.575195 0.920898 4.68848 0.920898 9.6543C0.920898 14.6289 5.03418 18.7334 10 18.7334ZM10 16.9492C5.95703 16.9492 2.71387 13.6973 2.71387 9.6543C2.71387 5.61133 5.94824 2.36816 9.99121 2.36816C14.0342 2.36816 17.2861 5.61133 17.2949 9.6543C17.2949 13.6973 14.043 16.9492 10 16.9492ZM9.98242 12.335C10.1758 12.335 10.3428 12.2559 10.4834 12.124L13.3838 9.20605C13.542 9.05664 13.5947 8.88965 13.5947 8.71387C13.5947 8.35352 13.3398 8.07227 12.9707 8.07227C12.7861 8.07227 12.6191 8.14258 12.4873 8.27441L11.9336 8.82812L10.5449 10.4014L10.6416 8.90723V5.24219C10.6416 4.85547 10.3691 4.57422 9.98242 4.57422C9.58691 4.57422 9.32324 4.85547 9.32324 5.24219V8.90723L9.42871 10.4102L8.01367 8.81934L7.50391 8.27441C7.38086 8.13379 7.22266 8.07227 7.02051 8.07227C6.65137 8.07227 6.3877 8.33594 6.3877 8.72266C6.3877 8.87207 6.4668 9.07422 6.58984 9.19727L9.49023 12.124C9.63086 12.2646 9.79785 12.335 9.98242 12.335ZM6.7832 14.2598H13.208C13.5859 14.2598 13.8584 13.9785 13.8584 13.6006C13.8584 13.2314 13.5859 12.959 13.208 12.959H6.7832C6.40527 12.959 6.13281 13.2314 6.13281 13.6006C6.13281 13.9785 6.40527 14.2598 6.7832 14.2598Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
src/assets/cloud-down-arrow.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="21" viewBox="0 0 24 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.63672 15.2002H10.9453V9.82129C10.9453 9.25 11.4287 8.78418 12 8.78418C12.5713 8.78418 13.0459 9.25 13.0459 9.82129V15.2002H18.8643C21.4658 15.2002 23.2324 13.6006 23.2324 11.4033C23.2324 9.58398 22.1689 7.98438 20.4199 7.27246C20.4287 3.2998 17.5635 0.43457 13.8809 0.43457C11.543 0.43457 9.78516 1.63867 8.68652 3.22949C6.58594 2.71094 4.10742 4.30176 4.01953 6.78027C2.00684 7.13184 0.767578 8.90723 0.767578 10.9902C0.767578 13.3193 2.70996 15.2002 5.63672 15.2002ZM12 20.5791C12.2109 20.5791 12.4131 20.5088 12.624 20.2979L15.5771 17.4502C15.7266 17.3008 15.8057 17.1426 15.8057 16.9316C15.8057 16.5186 15.4717 16.2197 15.0674 16.2197C14.8652 16.2197 14.6631 16.3076 14.5225 16.4658L13.3008 17.7666L12.7295 18.417L12.8174 17.1162V15.0859C12.8174 14.6465 12.4482 14.2773 12 14.2773C11.543 14.2773 11.1738 14.6465 11.1738 15.0859V17.1162L11.2617 18.417L10.6992 17.7666L9.46875 16.4658C9.32812 16.3076 9.11719 16.2197 8.91504 16.2197C8.51074 16.2197 8.18555 16.5186 8.18555 16.9316C8.18555 17.1426 8.27344 17.3008 8.42285 17.4502L11.376 20.2979C11.5869 20.5088 11.7803 20.5791 12 20.5791Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
src/assets/cloud-exclamation-mark.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="16" viewBox="0 0 24 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.3281 15.6592C21.0703 15.6592 23.2324 13.6465 23.2324 11.1328C23.2324 9.26074 22.1689 7.59961 20.4199 6.87012C20.4287 2.89746 17.5635 0.0322266 13.8809 0.0322266C11.543 0.0322266 9.78516 1.23633 8.68652 2.83594C6.4541 2.23828 4.10742 3.89941 4.01953 6.37793C2.00684 6.73828 0.767578 8.54004 0.767578 10.7461C0.767578 13.418 3.10547 15.6504 6.18164 15.6504L18.3281 15.6592ZM18.3281 13.9014H6.19043C4.09863 13.9014 2.54297 12.4424 2.54297 10.7461C2.54297 8.98828 3.62402 7.6875 5.41699 7.6875C5.54883 7.6875 5.60156 7.61719 5.59277 7.49414C5.54004 4.88379 7.41211 3.9873 9.30176 4.58496C9.41602 4.62012 9.48633 4.59375 9.53906 4.49707C10.4092 2.96777 11.6924 1.78125 13.8721 1.78125C16.6318 1.78125 18.6006 3.96973 18.7324 6.52734C18.7588 7.00195 18.7236 7.51172 18.6885 7.93359C18.6709 8.05664 18.7236 8.12695 18.8379 8.14453C20.4287 8.45215 21.457 9.57715 21.457 11.1328C21.457 12.6709 20.0947 13.9014 18.3281 13.9014ZM12.0088 9.4541C12.4658 9.4541 12.7207 9.19922 12.7471 8.70703L12.8701 5.75391C12.8965 5.24414 12.5098 4.88379 12 4.88379C11.4814 4.88379 11.1123 5.23535 11.1387 5.75391L11.2529 8.71582C11.2793 9.19043 11.5342 9.4541 12.0088 9.4541ZM12 12.2402C12.5537 12.2402 12.9932 11.8447 12.9932 11.3174C12.9932 10.7725 12.5625 10.3857 12 10.3857C11.4375 10.3857 11.0068 10.7812 11.0068 11.3174C11.0068 11.8447 11.4463 12.2402 12 12.2402Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
3
src/assets/cloud-slash.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.5674 20.4658C23.7207 19.9736 25.2324 18.2334 25.2324 16.1328C25.2324 14.2607 24.1689 12.5996 22.4199 11.8701C22.4287 7.89746 19.5635 5.03223 15.8809 5.03223C13.543 5.03223 11.7852 6.23633 10.6865 7.83594C10.124 7.67773 9.50879 7.67773 8.9375 7.83594L21.5674 20.4658ZM20.3369 22.7773C20.6182 23.0498 21.0576 23.0498 21.3301 22.7773C21.5938 22.5049 21.6025 22.0566 21.3301 21.7842L6.52051 6.9834C6.23926 6.70215 5.78223 6.71973 5.51855 6.9834C5.25488 7.24707 5.26367 7.71289 5.51855 7.96777L20.3369 22.7773ZM8.18164 20.6592H16.6631L6.24805 10.2705C6.11621 10.6045 6.03711 10.9736 6.01953 11.3779C4.00684 11.7383 2.76758 13.54 2.76758 15.7461C2.76758 18.418 5.10547 20.6592 8.18164 20.6592Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 808 B |
3
src/assets/cloud.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.3281 15.6592C21.0703 15.6592 23.2324 13.6465 23.2324 11.1328C23.2324 9.26074 22.1689 7.59961 20.4199 6.87012C20.4287 2.89746 17.5635 0.0322266 13.8809 0.0322266C11.543 0.0322266 9.78516 1.23633 8.68652 2.83594C6.4541 2.23828 4.10742 3.89941 4.01953 6.37793C2.00684 6.73828 0.767578 8.54004 0.767578 10.7461C0.767578 13.418 3.10547 15.6504 6.18164 15.6504L18.3281 15.6592ZM18.3281 13.9014H6.19043C4.09863 13.9014 2.54297 12.4424 2.54297 10.7461C2.54297 8.98828 3.62402 7.6875 5.41699 7.6875C5.54883 7.6875 5.60156 7.61719 5.59277 7.49414C5.54004 4.88379 7.41211 3.9873 9.30176 4.58496C9.41602 4.62012 9.48633 4.59375 9.53906 4.49707C10.4092 2.96777 11.6924 1.78125 13.8721 1.78125C16.6318 1.78125 18.6006 3.96973 18.7324 6.52734C18.7588 7.00195 18.7236 7.51172 18.6885 7.93359C18.6709 8.05664 18.7236 8.12695 18.8379 8.14453C20.4287 8.45215 21.457 9.57715 21.457 11.1328C21.457 12.6709 20.0947 13.9014 18.3281 13.9014Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1003 B |
3
src/assets/internal-drive.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 22 17" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.400391 12.2139C0.400391 14.5781 2.17578 16.3535 4.71582 16.3535H17.2842C19.8242 16.3535 21.5996 14.5781 21.5996 12.2139C21.5996 11.502 21.3975 10.8604 21.1514 10.2803L18.1279 3.19629C17.5127 1.74609 16.291 0.981445 14.6562 0.981445H7.35254C5.70898 0.981445 4.4873 1.74609 3.88086 3.19629L0.875 10.2451C0.620117 10.834 0.400391 11.4844 0.400391 12.2139ZM3.57324 8.26758L5.48926 3.5918C5.78809 2.83594 6.46484 2.44043 7.37012 2.44043H14.6299C15.5439 2.44043 16.2207 2.83594 16.5195 3.5918L18.4355 8.26758C18.084 8.15332 17.6973 8.08301 17.2842 8.08301H4.71582C4.30273 8.08301 3.9248 8.15332 3.57324 8.26758ZM2.08789 12.2139C2.08789 10.8164 3.13379 9.77051 4.71582 9.77051H17.2842C18.8662 9.77051 19.9121 10.8164 19.9121 12.2139C19.9121 13.7607 18.8662 14.6572 17.2842 14.6572H4.71582C3.13379 14.6572 2.08789 13.6201 2.08789 12.2139ZM8.45117 13.084C8.45117 13.3828 8.68848 13.6113 8.9873 13.6113C9.27734 13.6113 9.50586 13.3828 9.50586 13.084V11.3525C9.50586 11.0625 9.27734 10.8252 8.9873 10.8252C8.68848 10.8252 8.45117 11.0625 8.45117 11.3525V13.084ZM10.4727 13.084C10.4727 13.3828 10.7012 13.6113 11 13.6113C11.29 13.6113 11.5273 13.3828 11.5273 13.084V11.3525C11.5273 11.0625 11.29 10.8252 11 10.8252C10.7012 10.8252 10.4727 11.0625 10.4727 11.3525V13.084ZM12.4854 13.084C12.4854 13.3828 12.7227 13.6113 13.0215 13.6113C13.3115 13.6113 13.5488 13.3828 13.5488 13.084V11.3525C13.5488 11.0625 13.3115 10.8252 13.0215 10.8252C12.7227 10.8252 12.4854 11.0625 12.4854 11.3525V13.084ZM14.5068 13.084C14.5068 13.3828 14.7441 13.6113 15.043 13.6113C15.333 13.6113 15.5703 13.3828 15.5703 13.084V11.3525C15.5703 11.0625 15.333 10.8252 15.043 10.8252C14.7441 10.8252 14.5068 11.0625 14.5068 11.3525V13.084ZM16.5283 13.084C16.5283 13.3828 16.7656 13.6113 17.0645 13.6113C17.3545 13.6113 17.583 13.3828 17.583 13.084V11.3525C17.583 11.0625 17.3545 10.8252 17.0645 10.8252C16.7656 10.8252 16.5283 11.0625 16.5283 11.3525V13.084Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
1
src/assets/queue-append.svg
Normal 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 |
1
src/assets/queue-prepend.svg
Normal 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
@@ -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
@@ -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
@@ -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 |
3
src/assets/trash.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.84277 22.4785H18.166C19.3701 22.4785 20.0732 21.8369 20.126 20.6416L20.6797 7.94141H21.8926C22.3408 7.94141 22.6836 7.58984 22.6836 7.15039C22.6836 6.71094 22.332 6.37695 21.8926 6.37695H18.0781V5.05859C18.0781 3.65234 17.1729 2.81738 15.6611 2.81738H12.3213C10.8096 2.81738 9.9043 3.65234 9.9043 5.05859V6.37695H6.10742C5.66797 6.37695 5.31641 6.71973 5.31641 7.15039C5.31641 7.59863 5.66797 7.94141 6.10742 7.94141H7.3291L7.8916 20.6416C7.93555 21.8369 8.63867 22.4785 9.84277 22.4785ZM11.7324 5.1377C11.7324 4.74219 12.0049 4.4873 12.4443 4.4873H15.5469C15.9863 4.4873 16.2588 4.74219 16.2588 5.1377V6.37695H11.7324V5.1377ZM11.1787 19.7803C10.8271 19.7803 10.5811 19.5518 10.5723 19.2002L10.3086 9.86621C10.2998 9.51465 10.5459 9.27734 10.915 9.27734C11.2666 9.27734 11.5127 9.50586 11.5215 9.85742L11.7852 19.1914C11.8027 19.543 11.5566 19.7803 11.1787 19.7803ZM14 19.7803C13.6309 19.7803 13.3848 19.5518 13.3848 19.2002V9.85742C13.3848 9.51465 13.6309 9.27734 14 9.27734C14.3691 9.27734 14.624 9.51465 14.624 9.85742V19.2002C14.624 19.5518 14.3691 19.7803 14 19.7803ZM16.8213 19.7891C16.4434 19.7891 16.1973 19.543 16.2148 19.2002L16.4785 9.85742C16.4873 9.50586 16.7334 9.27734 17.085 9.27734C17.4541 9.27734 17.7002 9.51465 17.6914 9.86621L17.4277 19.2002C17.4189 19.5518 17.1729 19.7891 16.8213 19.7891Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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;
|
||||
|
||||
@@ -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 TrackPlayer, { Capability } 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,41 @@ import {
|
||||
DefaultTheme,
|
||||
DarkTheme,
|
||||
} from '@react-navigation/native';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { ColorSchemeContext, themes } from './Colors';
|
||||
// import ErrorReportingAlert from 'utility/ErrorReportingAlert';
|
||||
|
||||
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: [
|
||||
Capability.Play,
|
||||
Capability.Pause,
|
||||
Capability.SkipToNext,
|
||||
Capability.SkipToPrevious,
|
||||
Capability.Stop,
|
||||
Capability.SeekTo,
|
||||
]
|
||||
});
|
||||
}
|
||||
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 />
|
||||
</NavigationContainer>
|
||||
</ColorSchemeContext.Provider>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
73
src/components/Button.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { SvgProps } from 'react-native-svg';
|
||||
import {
|
||||
PressableProps, ViewProps, View,
|
||||
} 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;
|
||||
|
||||
${(props) => props.disabled && css`
|
||||
opacity: 0.25;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ButtonText = styled.Text<{ active?: boolean }>`
|
||||
color: ${THEME_COLOR};
|
||||
font-weight: 600;
|
||||
|
||||
${props => props.active && css`
|
||||
color: white;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
|
||||
const { icon: Icon, title, disabled, ...rest } = props;
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const [isPressed, setPressed] = useState(false);
|
||||
const handlePressIn = useCallback(() => setPressed(true), []);
|
||||
const handlePressOut = useCallback(() => setPressed(false), []);
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
// @ts-expect-error styled-components has outdated react-native typings
|
||||
ref={ref}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={[
|
||||
props.style,
|
||||
{ backgroundColor: isPressed ? THEME_COLOR : defaultStyles.button.backgroundColor }
|
||||
]}
|
||||
>
|
||||
{Icon &&
|
||||
<Icon
|
||||
width={14}
|
||||
height={14}
|
||||
fill={isPressed ? '#fff' : THEME_COLOR}
|
||||
style={{
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<ButtonText active={isPressed}>{title}</ButtonText>
|
||||
</BaseButton>
|
||||
);
|
||||
});
|
||||
|
||||
export default Button;
|
||||
@@ -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'),
|
||||
}
|
||||
});
|
||||
@@ -1,26 +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'),
|
||||
}
|
||||
});
|
||||
80
src/components/Colors.ts
Normal 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' ? '26' : '16'}`,
|
||||
},
|
||||
imageBackground: {
|
||||
backgroundColor: scheme === 'dark' ? '#333' : '#ddd',
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: scheme === 'dark' ? '#22222200' : '#eeeeee00',
|
||||
},
|
||||
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);
|
||||
}
|
||||
98
src/components/DownloadIcon.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTypedSelector } from 'store';
|
||||
import CloudIcon from 'assets/cloud.svg';
|
||||
import CloudExclamationMarkIcon from 'assets/cloud-exclamation-mark.svg';
|
||||
import InternalDriveIcon from 'assets/internal-drive.svg';
|
||||
import useDefaultStyles from './Colors';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import Svg, { Circle, CircleProps } from 'react-native-svg';
|
||||
import { Animated, Easing } from 'react-native';
|
||||
|
||||
interface DownloadIconProps {
|
||||
trackId: EntityId;
|
||||
size?: number;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
|
||||
// determine styles
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const iconFill = fill || defaultStyles.textHalfOpacity.color;
|
||||
|
||||
// Get download icon from state
|
||||
const entity = useTypedSelector((state) => state.downloads.entities[trackId]);
|
||||
|
||||
// Memoize calculations for radius and circumference of the circle
|
||||
const radius = useMemo(() => size / 2, [size]);
|
||||
const circumference = useMemo(() => radius * 2 * Math.PI, [radius]);
|
||||
|
||||
// Initialize refs for the circle and the animated value
|
||||
const circleRef = useRef<Circle>(null);
|
||||
const offsetAnimation = useRef(new Animated.Value(entity?.progress || 0)).current;
|
||||
|
||||
// Whenever the progress changes, trigger the animation
|
||||
useEffect(() => {
|
||||
Animated.timing(offsetAnimation, {
|
||||
toValue: (circumference * (1 - (entity?.progress || 0))),
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
easing: Easing.ease,
|
||||
}).start();
|
||||
}, [entity?.progress, offsetAnimation, circumference]);
|
||||
|
||||
// On mount, subscribe to changes in the animation value and then
|
||||
// apply them to the circle using native props
|
||||
useEffect(() => {
|
||||
const subscription = offsetAnimation.addListener((offset) => {
|
||||
// @ts-expect-error undocumented functionality
|
||||
const setNativeProps = circleRef.current?.setNativeProps as (props: CircleProps) => void | undefined;
|
||||
setNativeProps?.({ strokeDashoffset: offset.value });
|
||||
});
|
||||
|
||||
return () => offsetAnimation.removeListener(subscription);
|
||||
}, [offsetAnimation]);
|
||||
|
||||
if (!entity) {
|
||||
return (
|
||||
<CloudIcon width={size} height={size} fill={iconFill} />
|
||||
);
|
||||
}
|
||||
|
||||
const { isComplete, isFailed } = entity;
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<InternalDriveIcon width={size} height={size} fill={iconFill} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isFailed) {
|
||||
return (
|
||||
<CloudExclamationMarkIcon width={size} height={size} fill={iconFill} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!isComplete && !isFailed) {
|
||||
return (
|
||||
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
|
||||
<Circle
|
||||
cx={radius}
|
||||
cy={radius}
|
||||
r={radius - 1}
|
||||
stroke={iconFill}
|
||||
// @ts-expect-error react-native-svg has outdated react-native typings
|
||||
ref={circleRef}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray={[ circumference, circumference ]}
|
||||
strokeDashoffset={circumference}
|
||||
strokeLinecap='round'
|
||||
fill='transparent'
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default DownloadIcon;
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,24 +1,49 @@
|
||||
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} />
|
||||
// @ts-expect-error styled-components has outdated react-native typings
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
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 { Pressable } from 'react-native';
|
||||
import { useNavigation, StackActions } from '@react-navigation/native';
|
||||
import useDefaultStyles from './Colors';
|
||||
|
||||
const Background = styled.View`
|
||||
padding: 100px 25px;
|
||||
interface Props {
|
||||
fullSize?: boolean;
|
||||
}
|
||||
|
||||
const Background = styled(Pressable)`
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const Container = styled.View`
|
||||
border-radius: 20px;
|
||||
flex: 1;
|
||||
const Container = styled(Pressable)<Pick<Props, 'fullSize'>>`
|
||||
margin: auto 20px;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
flex: 0 0 auto;
|
||||
background: salmon;
|
||||
|
||||
${props => props.fullSize && css`
|
||||
flex: 1;
|
||||
margin: auto 0;
|
||||
border-radius: 0px;
|
||||
`}
|
||||
`;
|
||||
|
||||
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}>
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<Container style={colors.view}>
|
||||
{children}
|
||||
</Container>
|
||||
</SafeAreaView>
|
||||
<Background style={defaultStyles.modal} onPress={closeModal}>
|
||||
<Container style={defaultStyles.modalInner} fullSize={fullSize}>
|
||||
{children}
|
||||
</Container>
|
||||
</Background>
|
||||
);
|
||||
};
|
||||
|
||||
11
src/components/Text.tsx
Normal 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]} />
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,47 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { TouchableOpacity } from 'react-native';
|
||||
import React, { PropsWithChildren, useCallback } from 'react';
|
||||
import { Pressable, ViewStyle } from 'react-native';
|
||||
|
||||
interface TouchableHandlerProps {
|
||||
id: string;
|
||||
onPress: (id: string) => void;
|
||||
interface TouchableHandlerProps<T = number> {
|
||||
id: T;
|
||||
onPress: (id: T) => void;
|
||||
onLongPress?: (id: T) => 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 }) => {
|
||||
function TouchableHandler<T>({
|
||||
id,
|
||||
onPress,
|
||||
onLongPress,
|
||||
children
|
||||
}: PropsWithChildren<TouchableHandlerProps<T>>): JSX.Element {
|
||||
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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default TouchableHandler;
|
||||
@@ -1,7 +1,14 @@
|
||||
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;
|
||||
font-weight: 500;
|
||||
`;
|
||||
13
src/components/WrappableButtonRow.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import styled from 'styled-components/native';
|
||||
import Button from './Button';
|
||||
|
||||
export const WrappableButtonRow = styled.View`
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin: 6px -2px;
|
||||
`;
|
||||
|
||||
export const WrappableButton = styled(Button)`
|
||||
margin: 2px;
|
||||
`;
|
||||
52
src/localisation/index.ts
Normal 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;
|
||||
60
src/localisation/lang/en/locale.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"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",
|
||||
"playlists": "Playlists",
|
||||
"playlist": "Playlist",
|
||||
"play-playlist": "Play Playlist",
|
||||
"shuffle-album": "Shuffle Album",
|
||||
"shuffle-playlist": "Shuffle Playlist",
|
||||
"downloads": "Downloads",
|
||||
"download-track": "Download Track",
|
||||
"download-album": "Download Album",
|
||||
"download-playlist": "Download Playlist",
|
||||
"no-downloads": "You have not yet downloaded any tracks",
|
||||
"delete-track": "Delete Track",
|
||||
"delete-all-tracks": "Delete All Tracks",
|
||||
"delete-album": "Delete Album",
|
||||
"delete-playlist": "Delete Playlist",
|
||||
"total-download-size": "Total Download Size",
|
||||
"retry-failed-downloads": "Retry Failed Downloads",
|
||||
"you-are-offline-message": "You are currently offline. You can only play previously downloaded music."
|
||||
}
|
||||
28
src/localisation/lang/es/locale.json
Normal 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"
|
||||
}
|
||||
28
src/localisation/lang/fr/locale.json
Normal 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 l’intégralité de bibliothèque.",
|
||||
"reset-cache": "Réinitialiser le cache",
|
||||
"recent-albums": "Albums récents"
|
||||
}
|
||||
43
src/localisation/lang/ja/locale.json
Normal 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": "追跡"
|
||||
}
|
||||
61
src/localisation/lang/nl/locale.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"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",
|
||||
"track": "Track",
|
||||
"playlists": "Playlists",
|
||||
"playlist": "Playlist",
|
||||
"play-playlist": "Speel Playlist",
|
||||
"shuffle-album": "Shuffle Album",
|
||||
"shuffle-playlist": "Shuffle Playlist",
|
||||
"downloads": "Downloads",
|
||||
"download-track": "Download Track",
|
||||
"download-album": "Download Album",
|
||||
"download-playlist": "Download Playlist",
|
||||
"no-downloads": "Je hebt nog geen nummers gedownload",
|
||||
"delete-track": "Verwijder Track",
|
||||
"delete-all-tracks": "Verwijder alle nummbers",
|
||||
"delete-album": "Verwijder Album",
|
||||
"delete-playlist": "Verwijder Playlist",
|
||||
"total-download-size": "Totale grootte downloads",
|
||||
"retry-failed-downloads": "Probeer Mislukte Downloads Opnieuw",
|
||||
"you-are-offline-message": "Je bent op dit moment offline. Je kunt alleen eerder gedownloade nummers afspelen."
|
||||
|
||||
}
|
||||
42
src/localisation/lang/zh/locale.json
Normal 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": "更多信息"
|
||||
}
|
||||
57
src/localisation/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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'
|
||||
| 'playlists'
|
||||
| 'playlist'
|
||||
| 'play-playlist'
|
||||
| 'shuffle-album'
|
||||
| 'shuffle-playlist'
|
||||
| 'downloads'
|
||||
| 'download-track'
|
||||
| 'download-album'
|
||||
| 'download-playlist'
|
||||
| 'delete-album'
|
||||
| 'delete-playlist'
|
||||
| 'delete-track'
|
||||
| 'total-download-size'
|
||||
| 'no-downloads'
|
||||
| 'retry-failed-downloads'
|
||||
| 'you-are-offline-message'
|
||||
144
src/screens/Downloads/index.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { FlatListProps, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useTypedSelector } from 'store';
|
||||
import formatBytes from 'utility/formatBytes';
|
||||
import TrashIcon from 'assets/trash.svg';
|
||||
import ArrowClockwise from 'assets/arrow-clockwise.svg';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions';
|
||||
import Button from 'components/Button';
|
||||
import { t } from 'i18n-js';
|
||||
import DownloadIcon from 'components/DownloadIcon';
|
||||
import styled from 'styled-components/native';
|
||||
|
||||
const DownloadedTrack = styled.View`
|
||||
flex: 1 0 auto;
|
||||
flex-direction: row;
|
||||
padding: 8px 0;
|
||||
align-items: center;
|
||||
margin: 0 20px;
|
||||
border-bottom-width: 1px;
|
||||
`;
|
||||
|
||||
function Downloads() {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { entities, ids } = useTypedSelector((state) => state.downloads);
|
||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||
|
||||
// Calculate the total download size
|
||||
const totalDownloadSize = useMemo(() => (
|
||||
ids?.reduce<number>((sum, id) => sum + (entities[id]?.size || 0), 0)
|
||||
), [ids, entities]);
|
||||
|
||||
/**
|
||||
* Handlers for actions in this components
|
||||
*/
|
||||
|
||||
// Delete a single downloaded track
|
||||
const handleDelete = useCallback((id: EntityId) => {
|
||||
dispatch(removeDownloadedTrack(id));
|
||||
}, [dispatch]);
|
||||
|
||||
// Delete all downloaded tracks
|
||||
const handleDeleteAllTracks = useCallback(() => ids.forEach(handleDelete), [handleDelete, ids]);
|
||||
|
||||
// Retry a single failed track
|
||||
const retryTrack = useCallback((id: EntityId) => {
|
||||
dispatch(downloadTrack(id));
|
||||
}, [dispatch]);
|
||||
|
||||
// Retry all failed tracks
|
||||
const failedIds = useMemo(() => ids.filter((id) => !entities[id]?.isComplete), [ids, entities]);
|
||||
const handleRetryFailed = useCallback(() => (
|
||||
failedIds.forEach(retryTrack)
|
||||
), [failedIds, retryTrack]);
|
||||
|
||||
/**
|
||||
* Render section
|
||||
*/
|
||||
|
||||
const ListHeaderComponent = useMemo(() => (
|
||||
<View style={{ marginHorizontal: 20, marginBottom: 12 }}>
|
||||
<Text style={[{ textAlign: 'center', marginVertical: 6 }, defaultStyles.textHalfOpacity]}>
|
||||
{t('total-download-size')}: {formatBytes(totalDownloadSize)}
|
||||
</Text>
|
||||
<Button
|
||||
icon={TrashIcon}
|
||||
title={t('delete-all-tracks')}
|
||||
onPress={handleDeleteAllTracks}
|
||||
disabled={!ids.length}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
<Button
|
||||
icon={ArrowClockwise}
|
||||
title={t('retry-failed-downloads')}
|
||||
onPress={handleRetryFailed}
|
||||
disabled={failedIds.length === 0}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</View>
|
||||
), [totalDownloadSize, defaultStyles, failedIds.length, handleRetryFailed, handleDeleteAllTracks, ids.length]);
|
||||
|
||||
const renderItem = useCallback<NonNullable<FlatListProps<EntityId>['renderItem']>>(({ item }) => (
|
||||
<DownloadedTrack style={defaultStyles.border}>
|
||||
<View style={{ marginRight: 12 }}>
|
||||
<DownloadIcon trackId={item} />
|
||||
</View>
|
||||
<View style={{ flexShrink: 1, marginRight: 8 }}>
|
||||
<Text style={[{ fontSize: 16, marginBottom: 4 }, defaultStyles.text]} numberOfLines={1}>
|
||||
{tracks[item]?.Name}
|
||||
</Text>
|
||||
<Text style={[{ flexShrink: 1, fontSize: 11 }, defaultStyles.textHalfOpacity]} numberOfLines={1}>
|
||||
{tracks[item]?.AlbumArtist} ({tracks[item]?.Album})
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ marginLeft: 'auto', flexDirection: 'row', alignItems: 'center' }}>
|
||||
{entities[item]?.isComplete && entities[item]?.size ? (
|
||||
<Text style={[defaultStyles.textHalfOpacity, { marginRight: 6, fontSize: 12 }]}>
|
||||
{formatBytes(entities[item]?.size || 0)}
|
||||
</Text>
|
||||
) : null}
|
||||
<TouchableOpacity onPress={() => handleDelete(item)}>
|
||||
<TrashIcon height={24} width={24} fill={THEME_COLOR} />
|
||||
</TouchableOpacity>
|
||||
{!entities[item]?.isComplete && (
|
||||
<TouchableOpacity onPress={() => retryTrack(item)}>
|
||||
<ArrowClockwise height={18} width={18} fill={THEME_COLOR} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</DownloadedTrack>
|
||||
), [entities, retryTrack, handleDelete, defaultStyles, tracks]);
|
||||
|
||||
// If no tracks have been downloaded, show a short message describing this
|
||||
if (!ids.length) {
|
||||
return (
|
||||
<View style={{ margin: 24, flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={[{ textAlign: 'center'}, defaultStyles.textHalfOpacity]}>
|
||||
{t('no-downloads')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<FlatList
|
||||
data={ids}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default Downloads;
|
||||
@@ -1,26 +1,32 @@
|
||||
import React from 'react';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import { StackParams } from './types';
|
||||
import { MusicStackParams } from './types';
|
||||
import Albums from './stacks/Albums';
|
||||
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';
|
||||
import Playlists from './stacks/Playlists';
|
||||
import Playlist from './stacks/Playlist';
|
||||
|
||||
const Stack = createStackNavigator<StackParams>();
|
||||
|
||||
const navigationOptions = {
|
||||
headerTintColor: THEME_COLOR,
|
||||
headerTitleStyle: { color: 'black' }
|
||||
};
|
||||
const Stack = createStackNavigator<MusicStackParams>();
|
||||
|
||||
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="Playlists" component={Playlists} options={{ headerTitle: t('playlists') }} />
|
||||
<Stack.Screen name="Playlist" component={Playlist} options={{ headerTitle: t('playlist') }} />
|
||||
<Stack.Screen name="Search" component={Search} options={{ headerTitle: t('search') }} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,116 +1,46 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { StackParams } from '../types';
|
||||
import { Text, ScrollView, Dimensions, Button, RefreshControl, StyleSheet } from 'react-native';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import styled, { css } from 'styled-components/native';
|
||||
import { MusicStackParams } from '../types';
|
||||
import { useRoute, RouteProp } from '@react-navigation/native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { useTypedSelector } from 'store';
|
||||
import TrackListView from './components/TrackListView';
|
||||
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 { differenceInDays } from 'date-fns';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||
import { t } from '@localisation';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
type Route = RouteProp<StackParams, 'Album'>;
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
const AlbumImage = styled(FastImage)`
|
||||
border-radius: 10px;
|
||||
width: ${Screen.width * 0.6}px;
|
||||
height: ${Screen.width * 0.6}px;
|
||||
margin: 10px auto;
|
||||
`;
|
||||
|
||||
const TrackContainer = styled.View<{isPlaying: boolean}>`
|
||||
padding: 15px;
|
||||
border-bottom-width: 1px;
|
||||
flex-direction: row;
|
||||
|
||||
${props => props.isPlaying && css`
|
||||
background-color: ${THEME_COLOR}16;
|
||||
margin: 0 -20px;
|
||||
padding: 15px 35px;
|
||||
`}
|
||||
`;
|
||||
type Route = RouteProp<MusicStackParams, 'Album'>;
|
||||
|
||||
const Album: React.FC = () => {
|
||||
// Retrieve state
|
||||
const { params: { id } } = useRoute<Route>();
|
||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||
const album = useTypedSelector((state) => state.music.albums.entities[id]);
|
||||
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
|
||||
|
||||
// Retrieve helpers
|
||||
const dispatch = useDispatch();
|
||||
const getImage = useGetImage();
|
||||
const playAlbum = usePlayAlbum();
|
||||
const currentTrack = useCurrentTrack();
|
||||
|
||||
// Setup callbacks
|
||||
const selectAlbum = useCallback(() => { playAlbum(id); }, [playAlbum, id]);
|
||||
const selectTrack = usePlayTrack();
|
||||
// Retrieve the album data from the store
|
||||
const album = useTypedSelector((state) => state.music.albums.entities[id]);
|
||||
const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
|
||||
|
||||
// Define a function for refreshing this entity
|
||||
const refresh = useCallback(() => { dispatch(fetchTracksByAlbum(id)); }, [id, dispatch]);
|
||||
|
||||
// Retrieve album tracks on load
|
||||
// Auto-fetch the track data periodically
|
||||
useEffect(() => {
|
||||
if (!album?.lastRefreshed || differenceInDays(album?.lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) {
|
||||
refresh();
|
||||
}
|
||||
}, [album?.lastRefreshed, refresh]);
|
||||
|
||||
// GUARD: If there is no album, we cannot render a thing
|
||||
if (!album) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 50 }}
|
||||
refreshControl={
|
||||
<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}
|
||||
</ScrollView>
|
||||
<TrackListView
|
||||
trackIds={albumTracks || []}
|
||||
title={album?.Name}
|
||||
artist={album?.AlbumArtist}
|
||||
entityId={id}
|
||||
refresh={refresh}
|
||||
playButtonText={t('play-album')}
|
||||
shuffleButtonText={t('shuffle-album')}
|
||||
downloadButtonText={t('download-album')}
|
||||
deleteButtonText={t('delete-album')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { MusicNavigationProp } from '../types';
|
||||
import { Text, SafeAreaView, SectionList, View } from 'react-native';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -9,13 +9,13 @@ 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';
|
||||
import { Album } from 'store/music/types';
|
||||
|
||||
interface VirtualizedItemInfo {
|
||||
section: SectionedId,
|
||||
@@ -51,19 +51,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,32 +74,31 @@ 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
|
||||
const { entities: albums } = useTypedSelector((state) => state.music.albums);
|
||||
const isLoading = useTypedSelector((state) => state.music.albums.isLoading);
|
||||
const lastRefreshed = useTypedSelector((state) => state.music.lastRefreshed);
|
||||
const lastRefreshed = useTypedSelector((state) => state.music.albums.lastRefreshed);
|
||||
const sections = useTypedSelector(selectAlbumsByAlphabet);
|
||||
|
||||
// Initialise helpers
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const getImage = useGetImage();
|
||||
const listRef = useRef<SectionList<EntityId>>(null);
|
||||
|
||||
|
||||
47
src/screens/Music/stacks/Playlist.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { MusicStackParams } from '../types';
|
||||
import { useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { useTypedSelector } from 'store';
|
||||
import TrackListView from './components/TrackListView';
|
||||
import { fetchTracksByPlaylist } from 'store/music/actions';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||
import { t } from '@localisation';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
type Route = RouteProp<MusicStackParams, 'Album'>;
|
||||
|
||||
const Playlist: React.FC = () => {
|
||||
const { params: { id } } = useRoute<Route>();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Retrieve the album data from the store
|
||||
const playlist = useTypedSelector((state) => state.music.playlists.entities[id]);
|
||||
const playlistTracks = useTypedSelector((state) => state.music.tracks.byPlaylist[id]);
|
||||
|
||||
// Define a function for refreshing this entity
|
||||
const refresh = useCallback(() => dispatch(fetchTracksByPlaylist(id)), [dispatch, id]);
|
||||
|
||||
// Auto-fetch the track data periodically
|
||||
useEffect(() => {
|
||||
if (!playlist?.lastRefreshed || differenceInDays(playlist?.lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) {
|
||||
refresh();
|
||||
}
|
||||
}, [playlist?.lastRefreshed, refresh]);
|
||||
|
||||
return (
|
||||
<TrackListView
|
||||
trackIds={playlistTracks || []}
|
||||
title={playlist?.Name}
|
||||
entityId={id}
|
||||
refresh={refresh}
|
||||
listNumberingStyle='index'
|
||||
playButtonText={t('play-playlist')}
|
||||
shuffleButtonText={t('shuffle-playlist')}
|
||||
downloadButtonText={t('download-playlist')}
|
||||
deleteButtonText={t('delete-playlist')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playlist;
|
||||
110
src/screens/Music/stacks/Playlists.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import { MusicNavigationProp } from '../types';
|
||||
import { Text, View, FlatList, ListRenderItem } from 'react-native';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { fetchAllPlaylists } from 'store/music/actions';
|
||||
import { PLAYLIST_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||
import TouchableHandler from 'components/TouchableHandler';
|
||||
import AlbumImage, { AlbumItem } from './components/AlbumImage';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
|
||||
interface GeneratedAlbumItemProps {
|
||||
id: ReactText;
|
||||
imageUrl: string;
|
||||
name: string;
|
||||
onPress: (id: string) => void;
|
||||
}
|
||||
|
||||
const GeneratedPlaylistItem = React.memo(function GeneratedPlaylistItem(props: GeneratedAlbumItemProps) {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const { id, imageUrl, name, onPress } = props;
|
||||
|
||||
return (
|
||||
<TouchableHandler id={id as string} onPress={onPress}>
|
||||
<AlbumItem>
|
||||
<AlbumImage source={{ uri: imageUrl }} style={defaultStyles.imageBackground} />
|
||||
<Text numberOfLines={1} style={defaultStyles.text}>{name}</Text>
|
||||
</AlbumItem>
|
||||
</TouchableHandler>
|
||||
);
|
||||
});
|
||||
|
||||
const Playlists: React.FC = () => {
|
||||
// Retrieve data from store
|
||||
const { entities, ids } = useTypedSelector((state) => state.music.playlists);
|
||||
const isLoading = useTypedSelector((state) => state.music.playlists.isLoading);
|
||||
const lastRefreshed = useTypedSelector((state) => state.music.playlists.lastRefreshed);
|
||||
|
||||
// Initialise helpers
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const getImage = useGetImage();
|
||||
const listRef = useRef<FlatList<EntityId>>(null);
|
||||
|
||||
const getItemLayout = useCallback((data: EntityId[] | null | undefined, index: number): { offset: number, length: number, index: number } => {
|
||||
const length = 220;
|
||||
const offset = length * index;
|
||||
return { index, length, offset };
|
||||
}, []);
|
||||
|
||||
// Set callbacks
|
||||
const retrieveData = useCallback(() => dispatch(fetchAllPlaylists()), [dispatch]);
|
||||
const selectAlbum = useCallback((id: string) => {
|
||||
navigation.navigate('Playlist', { id });
|
||||
}, [navigation]);
|
||||
const generateItem: ListRenderItem<EntityId> = useCallback(({ item, index }) => {
|
||||
if (index % 2 === 1) {
|
||||
return <View key={item} />;
|
||||
}
|
||||
|
||||
const nextItemId = ids[index + 1];
|
||||
const nextItem = entities[nextItemId];
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', marginLeft: 10, marginRight: 10 }} key={item}>
|
||||
<GeneratedPlaylistItem
|
||||
id={item}
|
||||
imageUrl={getImage(item as string)}
|
||||
name={entities[item]?.Name || ''}
|
||||
onPress={selectAlbum}
|
||||
/>
|
||||
{nextItem &&
|
||||
<GeneratedPlaylistItem
|
||||
id={nextItemId}
|
||||
imageUrl={getImage(nextItemId as string)}
|
||||
name={nextItem.Name || ''}
|
||||
onPress={selectAlbum}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}, [entities, getImage, selectAlbum, ids]);
|
||||
|
||||
// Retrieve data on mount
|
||||
useEffect(() => {
|
||||
// GUARD: Only refresh this API call every set amounts of days
|
||||
if (!lastRefreshed || differenceInDays(lastRefreshed, new Date()) > PLAYLIST_CACHE_AMOUNT_OF_DAYS) {
|
||||
retrieveData();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={ids}
|
||||
refreshing={isLoading}
|
||||
onRefresh={retrieveData}
|
||||
getItemLayout={getItemLayout}
|
||||
ref={listRef}
|
||||
keyExtractor={(item, index) => `${item}_${index}`}
|
||||
renderItem={generateItem}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default Playlists;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import { Album, NavigationProp } from '../types';
|
||||
import { MusicNavigationProp } from '../types';
|
||||
import { Text, SafeAreaView, FlatList, StyleSheet } from 'react-native';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -12,30 +12,39 @@ 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';
|
||||
import { Album } from 'store/music/types';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
artist: {
|
||||
...colors.text,
|
||||
opacity: 0.5,
|
||||
columnWrapper: {
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10
|
||||
}
|
||||
});
|
||||
|
||||
const NavigationHeader: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const handleAllAlbumsClick = useCallback(() => { navigation.navigate('Albums'); }, [navigation]);
|
||||
const handlePlaylistsClick = useCallback(() => { navigation.navigate('Playlists'); }, [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={handlePlaylistsClick}>{t('playlists')}</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);
|
||||
@@ -43,7 +52,7 @@ const RecentAlbums: React.FC = () => {
|
||||
|
||||
// Initialise helpers
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const getImage = useGetImage();
|
||||
|
||||
// Set callbacks
|
||||
@@ -55,25 +64,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Input from 'components/Input';
|
||||
import { Text, View } from 'react-native';
|
||||
import { ActivityIndicator, Text, TextInput, View } from 'react-native';
|
||||
import styled from 'styled-components/native';
|
||||
import { 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 { MusicNavigationProp } 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';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
const Container = styled.View`
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const FullSizeContainer = styled(Container)`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const Loading = styled.View`
|
||||
position: absolute;
|
||||
right: 32px;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const AlbumImage = styled(FastImage)`
|
||||
@@ -45,16 +63,41 @@ 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 [isLoading, setLoading] = useState(false);
|
||||
const [fuseResults, setFuseResults] = useState<CombinedResults>([]);
|
||||
const [jellyfinResults, setJellyfinResults] = useState<CombinedResults>([]);
|
||||
|
||||
const albums = useTypedSelector(state => state.music.albums.entities);
|
||||
const [results, setResults] = useState<Fuse.FuseResult<Album>[]>([]);
|
||||
const fuse = useRef<Fuse<Album, typeof fuseOptions>>();
|
||||
|
||||
const fuse = useRef<Fuse<Album>>();
|
||||
const searchElement = useRef<TextInput>(null);
|
||||
|
||||
// Prepare helpers
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const getImage = useGetImage();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
/**
|
||||
* Since it is impractical to have a global fuse variable, we need to
|
||||
@@ -65,21 +108,97 @@ 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
|
||||
// @ts-expect-error need to fix this with AppDispatch
|
||||
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]);
|
||||
|
||||
// Automatically focus on the text input on mount
|
||||
useEffect(() => {
|
||||
// Give the timeout a slight delay so the component has a chance to actually
|
||||
// render the text input field.
|
||||
setTimeout(() => searchElement.current?.focus(), 10);
|
||||
}, []);
|
||||
|
||||
// Handlers
|
||||
const selectAlbum = useCallback((id: string) =>
|
||||
@@ -88,36 +207,72 @@ 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
|
||||
// @ts-expect-error styled-components has outdated react-native typings
|
||||
ref={searchElement}
|
||||
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(() => (
|
||||
// <FullSizeContainer>
|
||||
// {(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading)
|
||||
// ? <Text style={{ textAlign: 'center', opacity: 0.5 }}>{t('no-results')}</Text>
|
||||
// : null}
|
||||
// </FullSizeContainer>
|
||||
// ), [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<string> 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]}
|
||||
/>
|
||||
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
|
||||
<FullSizeContainer>
|
||||
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
|
||||
</FullSizeContainer>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
193
src/screens/Music/stacks/components/TrackListView.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Text, ScrollView, Dimensions, RefreshControl, StyleSheet, View } from 'react-native';
|
||||
import { useGetImage } from 'utility/JellyfinApi';
|
||||
import styled, { css } from 'styled-components/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import TouchableHandler from 'components/TouchableHandler';
|
||||
import useCurrentTrack from 'utility/useCurrentTrack';
|
||||
import TrackPlayer from 'react-native-track-player';
|
||||
import Play from 'assets/play.svg';
|
||||
import Shuffle from 'assets/shuffle.svg';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import usePlayTracks from 'utility/usePlayTracks';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow';
|
||||
import { MusicNavigationProp } from 'screens/Music/types';
|
||||
import DownloadIcon from 'components/DownloadIcon';
|
||||
import CloudDownArrow from 'assets/cloud-down-arrow.svg';
|
||||
import Trash from 'assets/trash.svg';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions';
|
||||
import { selectDownloadedTracks } from 'store/downloads/selectors';
|
||||
|
||||
const Screen = Dimensions.get('screen');
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
name: {
|
||||
fontSize: 36,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
artist: {
|
||||
fontSize: 24,
|
||||
opacity: 0.5,
|
||||
marginBottom: 12
|
||||
},
|
||||
index: {
|
||||
width: 20,
|
||||
opacity: 0.5,
|
||||
marginRight: 5
|
||||
}
|
||||
});
|
||||
|
||||
const AlbumImage = styled(FastImage)`
|
||||
border-radius: 10px;
|
||||
width: ${Screen.width * 0.6}px;
|
||||
height: ${Screen.width * 0.6}px;
|
||||
margin: 10px auto;
|
||||
`;
|
||||
|
||||
const TrackContainer = styled.View<{isPlaying: boolean}>`
|
||||
padding: 15px 4px;
|
||||
border-bottom-width: 1px;
|
||||
flex-direction: row;
|
||||
|
||||
${props => props.isPlaying && css`
|
||||
margin: 0 -20px;
|
||||
padding: 15px 24px;
|
||||
`}
|
||||
`;
|
||||
|
||||
interface TrackListViewProps {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
trackIds: EntityId[];
|
||||
entityId: string;
|
||||
refresh: () => void;
|
||||
playButtonText: string;
|
||||
shuffleButtonText: string;
|
||||
downloadButtonText: string;
|
||||
deleteButtonText: string;
|
||||
listNumberingStyle?: 'album' | 'index';
|
||||
}
|
||||
|
||||
const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
trackIds,
|
||||
entityId,
|
||||
title,
|
||||
artist,
|
||||
refresh,
|
||||
playButtonText,
|
||||
shuffleButtonText,
|
||||
downloadButtonText,
|
||||
deleteButtonText,
|
||||
listNumberingStyle = 'album',
|
||||
}) => {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
|
||||
// Retrieve state
|
||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
|
||||
const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds));
|
||||
|
||||
// Retrieve helpers
|
||||
const getImage = useGetImage();
|
||||
const playTracks = usePlayTracks();
|
||||
const { track: currentTrack } = useCurrentTrack();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Setup callbacks
|
||||
const playEntity = useCallback(() => { playTracks(trackIds); }, [playTracks, trackIds]);
|
||||
const shuffleEntity = useCallback(() => { playTracks(trackIds, { shuffle: true }); }, [playTracks, trackIds]);
|
||||
const selectTrack = useCallback(async (index: number) => {
|
||||
await playTracks(trackIds, { play: false });
|
||||
await TrackPlayer.skip(index);
|
||||
await TrackPlayer.play();
|
||||
}, [playTracks, trackIds]);
|
||||
const longPressTrack = useCallback((index: number) => {
|
||||
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] });
|
||||
}, [navigation, trackIds]);
|
||||
const downloadAllTracks = useCallback(() => {
|
||||
trackIds.forEach((trackId) => dispatch(downloadTrack(trackId)));
|
||||
}, [dispatch, trackIds]);
|
||||
const deleteAllTracks = useCallback(() => {
|
||||
downloadedTracks.forEach((trackId) => dispatch(removeDownloadedTrack(trackId)));
|
||||
}, [dispatch, downloadedTracks]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 50 }}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
|
||||
}
|
||||
>
|
||||
<AlbumImage source={{ uri: getImage(entityId) }} style={defaultStyles.imageBackground} />
|
||||
<Text style={[ defaultStyles.text, styles.name ]} >{title}</Text>
|
||||
<Text style={[ defaultStyles.text, styles.artist ]}>{artist}</Text>
|
||||
<WrappableButtonRow>
|
||||
<WrappableButton title={playButtonText} icon={Play} onPress={playEntity} />
|
||||
<WrappableButton title={shuffleButtonText} icon={Shuffle} onPress={shuffleEntity} />
|
||||
</WrappableButtonRow>
|
||||
<View style={{ marginTop: 8 }}>
|
||||
{trackIds.map((trackId, i) =>
|
||||
<TouchableHandler
|
||||
key={trackId}
|
||||
id={i}
|
||||
onPress={selectTrack}
|
||||
onLongPress={longPressTrack}
|
||||
>
|
||||
<TrackContainer
|
||||
isPlaying={currentTrack?.backendId === trackId || false}
|
||||
style={[defaultStyles.border, currentTrack?.backendId === trackId || false ? defaultStyles.activeBackground : null ]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
defaultStyles.text,
|
||||
styles.index,
|
||||
currentTrack?.backendId === trackId && {
|
||||
color: THEME_COLOR,
|
||||
opacity: 1
|
||||
}
|
||||
]}
|
||||
>
|
||||
{listNumberingStyle === 'index'
|
||||
? i + 1
|
||||
: tracks[trackId]?.IndexNumber}
|
||||
</Text>
|
||||
<Text
|
||||
style={currentTrack?.backendId === trackId
|
||||
? { color: THEME_COLOR, fontWeight: '700' }
|
||||
: defaultStyles.text
|
||||
}
|
||||
>
|
||||
{tracks[trackId]?.Name}
|
||||
</Text>
|
||||
<View style={{ marginLeft: 'auto' }}>
|
||||
<DownloadIcon trackId={trackId} />
|
||||
</View>
|
||||
</TrackContainer>
|
||||
</TouchableHandler>
|
||||
)}
|
||||
<WrappableButtonRow style={{ marginTop: 24 }}>
|
||||
<WrappableButton
|
||||
icon={CloudDownArrow}
|
||||
title={downloadButtonText}
|
||||
onPress={downloadAllTracks}
|
||||
disabled={downloadedTracks.length === trackIds.length}
|
||||
/>
|
||||
<WrappableButton
|
||||
icon={Trash}
|
||||
title={deleteButtonText}
|
||||
onPress={deleteAllTracks}
|
||||
disabled={downloadedTracks.length === 0}
|
||||
/>
|
||||
</WrappableButtonRow>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackListView;
|
||||
@@ -1,75 +1,14 @@
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { Album } from 'store/music/types';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type StackParams = {
|
||||
export type MusicStackParams = {
|
||||
[key: string]: Record<string, unknown> | undefined;
|
||||
Albums: undefined;
|
||||
Album: { id: string, album: Album };
|
||||
Playlists: undefined;
|
||||
Playlist: { id: string };
|
||||
RecentAlbums: undefined;
|
||||
Search: undefined;
|
||||
};
|
||||
|
||||
export type NavigationProp = StackNavigationProp<StackParams>;
|
||||
export type MusicNavigationProp = StackNavigationProp<MusicStackParams>;
|
||||
|
||||
79
src/screens/Onboarding/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import styled from 'styled-components/native';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from 'screens';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setOnboardingStatus } from 'store/settings/actions';
|
||||
import { t } from '@localisation';
|
||||
import Button from 'components/Button';
|
||||
|
||||
const Container = styled.SafeAreaView`
|
||||
background-color: ${THEME_COLOR};
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const TextContainer = styled.ScrollView`
|
||||
padding: 25px;
|
||||
`;
|
||||
|
||||
const Text = styled.Text`
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const ButtonContainer = styled.View`
|
||||
margin-top: 50px;
|
||||
`;
|
||||
|
||||
const Logo = styled.Image`
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
margin: 0 auto 50px auto;
|
||||
`;
|
||||
|
||||
function Onboarding() {
|
||||
// Get account from Redux and dispatcher
|
||||
const account = useTypedSelector(state => state.settings.jellyfin);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Also retrieve the navigation handler so that we can open the modal in
|
||||
// which the Jellyfin server is set
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const handleClick = useCallback(() => navigation.navigate('SetJellyfinServer'), [navigation]);
|
||||
|
||||
// We'll also respond to any change in the account, setting the onboarding
|
||||
// status to true, so that the app becomes available.
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
dispatch(setOnboardingStatus(true));
|
||||
}
|
||||
}, [account, dispatch]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TextContainer contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}>
|
||||
<Logo source={require('../../assets/app-icon-white.png')} />
|
||||
<Text >
|
||||
{t('onboarding-welcome')}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('onboarding-intro')}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('onboarding-cta')}
|
||||
</Text>
|
||||
<ButtonContainer>
|
||||
<Button
|
||||
title={t('set-jellyfin-server')}
|
||||
onPress={handleClick}/>
|
||||
</ButtonContainer>
|
||||
</TextContainer>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Onboarding;
|
||||
14
src/screens/Player/components/Casting.android.tsx
Normal 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;
|
||||
9
src/screens/Player/components/Casting.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface CastingProps {
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
declare const CastingComponent: React.FC<CastingProps>;
|
||||
|
||||
export default CastingComponent;
|
||||
25
src/screens/Player/components/Casting.ios.tsx
Normal 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;
|
||||
37
src/screens/Player/components/ConnectionNotice.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { useNetInfo } from '@react-native-community/netinfo';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import styled from 'styled-components/native';
|
||||
import CloudSlash from 'assets/cloud-slash.svg';
|
||||
import { Text } from 'react-native';
|
||||
import { t } from '@localisation';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
|
||||
const Well = styled.View`
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
`;
|
||||
|
||||
function ConnectionNotice() {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const { isInternetReachable } = useNetInfo();
|
||||
|
||||
if (!isInternetReachable) {
|
||||
return (
|
||||
<Well style={defaultStyles.activeBackground}>
|
||||
<CloudSlash width={24} height={24} fill={THEME_COLOR} />
|
||||
<Text style={{ color: THEME_COLOR, marginLeft: 12 }}>
|
||||
{t('you-are-offline-message')}
|
||||
</Text>
|
||||
</Well>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default ConnectionNotice;
|
||||
@@ -1,20 +1,24 @@
|
||||
import React from 'react';
|
||||
import TrackPlayer, { usePlaybackState, STATE_PLAYING, STATE_PAUSED } from 'react-native-track-player';
|
||||
import { TouchableOpacity } from 'react-native';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import TrackPlayer, { Event, State, usePlaybackState, useTrackPlayerEvents } from 'react-native-track-player';
|
||||
import { TouchableOpacity, useColorScheme } from 'react-native';
|
||||
import styled from 'styled-components/native';
|
||||
import { useHasQueue } from 'utility/useQueue';
|
||||
import { useHasNextQueue, useHasPreviousQueue } 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();
|
||||
const next = () => TrackPlayer.skipToNext();
|
||||
const previous = () => TrackPlayer.skipToPrevious();
|
||||
const pause = TrackPlayer.pause;
|
||||
const play = TrackPlayer.play;
|
||||
const next = TrackPlayer.skipToNext;
|
||||
const previous = TrackPlayer.skipToPrevious;
|
||||
|
||||
const Container = styled.View`
|
||||
align-items: center;
|
||||
@@ -47,12 +51,18 @@ export default function MediaControls() {
|
||||
<NextButton fill={fill} />
|
||||
</Button>
|
||||
</Buttons>
|
||||
<Buttons>
|
||||
<Button>
|
||||
<RepeatButton fill={fill} />
|
||||
</Button>
|
||||
<Casting fill={fill} />
|
||||
</Buttons>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviousButton({ fill }: { fill: string }) {
|
||||
const hasQueue = useHasQueue();
|
||||
const hasQueue = useHasPreviousQueue();
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={previous} disabled={!hasQueue} style={{ opacity: hasQueue ? 1 : 0.5 }}>
|
||||
@@ -62,7 +72,7 @@ export function PreviousButton({ fill }: { fill: string }) {
|
||||
}
|
||||
|
||||
export function NextButton({ fill }: { fill: string }) {
|
||||
const hasQueue = useHasQueue();
|
||||
const hasQueue = useHasNextQueue();
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={next} disabled={!hasQueue} style={{ opacity: hasQueue ? 1 : 0.5 }}>
|
||||
@@ -71,17 +81,62 @@ export function NextButton({ fill }: { fill: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function RepeatButton({ fill }: { fill: string}) {
|
||||
const [isRepeating, setRepeating] = useState(false);
|
||||
const handlePress = useCallback(() => setRepeating(!isRepeating), [isRepeating, setRepeating]);
|
||||
|
||||
// The callback that should determine whether we need to repeeat or not
|
||||
useTrackPlayerEvents([Event.PlaybackQueueEnded], async () => {
|
||||
if (isRepeating) {
|
||||
// Skip to the first track
|
||||
await TrackPlayer.skip(0);
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
switch (state) {
|
||||
case STATE_PLAYING:
|
||||
case State.Playing:
|
||||
return (
|
||||
<TouchableOpacity onPress={pause}>
|
||||
<PauseIcon width={BUTTON_SIZE} height={BUTTON_SIZE} fill={fill} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
case STATE_PAUSED:
|
||||
case State.Paused:
|
||||
return (
|
||||
<TouchableOpacity onPress={play}>
|
||||
<PlayIcon width={BUTTON_SIZE} height={BUTTON_SIZE} fill={fill} />
|
||||
|
||||
@@ -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',
|
||||
@@ -35,12 +32,19 @@ const styles = StyleSheet.create({
|
||||
|
||||
|
||||
export default function NowPlaying() {
|
||||
const track = useCurrentTrack();
|
||||
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 as string | undefined,
|
||||
priority: FastImage.priority.high,
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.artist}>{track?.artist}</Text>
|
||||
<Text style={styles.title}>{track?.title}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -31,16 +31,18 @@ export default class ProgressBar extends Component<{}, State> {
|
||||
state: State = {
|
||||
position: 0,
|
||||
duration: 0,
|
||||
}
|
||||
};
|
||||
|
||||
timer: number = 0;
|
||||
timer: number | null = null;
|
||||
|
||||
componentDidMount() {
|
||||
this.timer = setInterval(this.updateProgress, 500);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.timer);
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
}
|
||||
|
||||
updateProgress = async () => {
|
||||
@@ -50,39 +52,44 @@ export default class ProgressBar extends Component<{}, State> {
|
||||
]);
|
||||
|
||||
this.setState({ position, duration });
|
||||
}
|
||||
};
|
||||
|
||||
handleGesture = async (gesture: number) => {
|
||||
// Set relative translation in state
|
||||
this.setState({ gesture });
|
||||
}
|
||||
};
|
||||
|
||||
handleEndOfGesture = (position: number) => {
|
||||
// Calculate and set the new position
|
||||
TrackPlayer.seekTo(position);
|
||||
this.setState({ gesture: undefined, position });
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
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 { t } from '@localisation';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import Text from 'components/Text';
|
||||
import Button from 'components/Button';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import { colors } from 'components/Colors';
|
||||
import DownloadIcon from 'components/DownloadIcon';
|
||||
|
||||
const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean, isDark?: boolean }>`
|
||||
padding: 10px;
|
||||
border-bottom-width: 1px;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
${props => props.active && css`
|
||||
font-weight: 900;
|
||||
@@ -23,45 +30,55 @@ 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);
|
||||
const playTrack = useCallback(async (trackId: string) => {
|
||||
await TrackPlayer.skip(trackId);
|
||||
const { index: currentIndex } = useCurrentTrack();
|
||||
const playTrack = useCallback(async (index: number) => {
|
||||
await TrackPlayer.skip(index);
|
||||
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}>
|
||||
<TouchableHandler id={i} onPress={playTrack} key={i}>
|
||||
<QueueItem
|
||||
active={currentTrack?.id === track.id}
|
||||
active={currentIndex === i}
|
||||
key={i}
|
||||
alreadyPlayed={i < currentIndex}
|
||||
style={{
|
||||
...colors.border,
|
||||
...currentTrack?.id === track.id ? colors.activeBackground : {},
|
||||
}}
|
||||
alreadyPlayed={currentIndex ? i < currentIndex : false}
|
||||
style={[
|
||||
defaultStyles.border,
|
||||
currentIndex === i ? defaultStyles.activeBackground : {},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.title}>{track.title}</Text>
|
||||
<Text style={styles.artist}>{track.artist}</Text>
|
||||
<View>
|
||||
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '700' } : styles.trackTitle}>{track.title}</Text>
|
||||
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '400' } : defaultStyles.textHalfOpacity}>{track.artist}</Text>
|
||||
</View>
|
||||
<View style={{ marginLeft: 'auto' }}>
|
||||
<DownloadIcon trackId={track.backendId} />
|
||||
</View>
|
||||
</QueueItem>
|
||||
</TouchableHandler>
|
||||
))}
|
||||
<ClearQueue>
|
||||
<Button title={t('clear-queue')} onPress={clearQueue} />
|
||||
</ClearQueue>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -4,21 +4,22 @@ 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';
|
||||
import ConnectionNotice from './components/ConnectionNotice';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: colors.view,
|
||||
inner: {
|
||||
padding: 25,
|
||||
}
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(styles));
|
||||
|
||||
export default function Player() {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.inner} style={styles.outer}>
|
||||
<ScrollView contentContainerStyle={styles.inner} style={defaultStyles.view}>
|
||||
<NowPlaying />
|
||||
<ConnectionNotice />
|
||||
<MediaControls />
|
||||
<ProgressBar />
|
||||
<Queue />
|
||||
|
||||
34
src/screens/Settings/components/Cache.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/screens/Settings/components/Library.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||