33 Commits
Author SHA1 Message Date
austinried 7da1afa643 bump version 1.0.0 for release 2021-08-27 11:17:58 +09:00
austinried 77e89fc9e2 add version number to about setion 2021-08-27 09:58:49 +09:00
austinried 36e97cb621 update metadata per google play standards 2021-08-26 16:38:30 +09:00
austinried c539376cc4 add metadata for stores 2021-08-26 16:10:49 +09:00
austinried 0465147a7c bump sdk version for google play 2021-08-26 15:19:03 +09:00
austinried 2e96c61b82 let's do some pre-release builds 2021-08-26 14:38:22 +09:00
austinried 08470aaf00 Merge branch 'master' of github.com:austinried/subtracks 2021-08-26 13:41:38 +09:00
aux4 8ee98327fe update readme 2021-08-26 13:41:07 +09:00
austinried a91f455534 add scripts for bumping versions 2021-08-26 13:33:15 +09:00
austinried ac175d091f ignore html for languages 2021-08-26 12:58:35 +09:00
austinried 872dde4e1c remove bad button style 2021-08-26 12:49:26 +09:00
austinried 23807a3506 update header for better dark mode 2021-08-26 12:48:06 +09:00
austinried a3fda31743 added licenses and webview for them
added link to github page
2021-08-26 12:47:56 +09:00
austinried b8f04e5426 update readme 2021-08-26 08:18:29 +09:00
austinried 46eb293f98 fix net state not being right on startup 2021-08-26 08:17:39 +09:00
austinried 5af187ba48 lint builds 2021-08-26 08:07:32 +09:00
austinried 9847cdb894 fix for search clearing text/not scrolling 2021-08-25 16:32:04 +09:00
austinried fac2b62ec2 improved large album/playlist performance
switched to flatlist for all of those
2021-08-25 16:13:35 +09:00
austinried 81b5bfb56b readme update 2021-08-25 11:26:03 +09:00
austinried 7abf5c0e88 settings wording 2021-08-25 10:43:36 +09:00
austinried ed4a32cba3 remove UI option to disable estimateContentLength 2021-08-25 10:29:59 +09:00
austinried 0778c49b18 Merge branch 'master' of github.com:austinried/subtracks 2021-08-25 10:25:03 +09:00
austinried 3db1d795dc fix player crashing at the end of transcoded track 2021-08-25 10:24:42 +09:00
aux4 07fc618fd2 specify android app in readme 2021-08-24 16:00:21 +09:00
austinried 75da5d16ac Merge branch 'master' of github.com:austinried/subtracks 2021-08-24 15:58:01 +09:00
austinried 1d40ac5b35 stop building on non-app file changes 2021-08-24 15:57:46 +09:00
aux4 6bdd052472 added download badge to readme 2021-08-24 15:52:34 +09:00
austinried 74632eff10 added "get it on" badges" 2021-08-24 15:49:41 +09:00
austinried 3055726fb3 better name/group artifacts 2021-08-24 15:31:22 +09:00
aux4 d10a29b931 fix readme link, add server examples 2021-08-24 14:46:07 +09:00
aux4 75a264ca83 fix readme image formatting 2021-08-24 14:27:27 +09:00
austinried dcd764bd68 add images/features/coming soon to readme 2021-08-24 14:23:00 +09:00
aux4 1a596077fa Merge pull request #1 from austinried/github-actions-test
Use actions to build and sign PRs/releases
2021-08-24 11:27:12 +09:00
107 changed files with 4138 additions and 22563 deletions
-2
View File
@@ -1,2 +0,0 @@
BUNDLE_PATH: "vendor/bundle"
BUNDLE_FORCE_RUBY_PLATFORM: 1
-1
View File
@@ -1,6 +1,5 @@
module.exports = { module.exports = {
root: true, root: true,
ignorePatterns: ['submodules/**', 'ndk/**'],
extends: '@react-native-community', extends: '@react-native-community',
rules: { rules: {
'@typescript-eslint/no-unused-vars': ['warn'], '@typescript-eslint/no-unused-vars': ['warn'],
+1 -1
View File
@@ -3,4 +3,4 @@
*.bat text eol=crlf *.bat text eol=crlf
# ignore these paths for GitHub language percentages # ignore these paths for GitHub language percentages
android/app/src/main/assets/** linguist-vendored=true android/app/src/main/assets/licenses/* linguist-vendored=true
-1
View File
@@ -1 +0,0 @@
ko_fi: austinried
+4 -29
View File
@@ -4,57 +4,35 @@ on:
push: push:
paths-ignore: paths-ignore:
- assets/** - assets/**
- .vscode/**
- .eslintrc.js
- .prettierrc.js
- BUILDING.md
- README.md - README.md
- LICENSE - LICENSE
branches: branches:
- main - master
- release/* - release/*
pull_request: pull_request:
paths-ignore: paths-ignore:
- assets/** - assets/**
- .vscode/**
- .eslintrc.js
- .prettierrc.js
- BUILDING.md
- README.md - README.md
- LICENSE - LICENSE
branches: branches:
- main - master
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with:
submodules: recursive
- run: |
rm submodules/ExoPlayer/gradle/wrapper/gradle-wrapper.jar
- uses: gradle/wrapper-validation-action@v1
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: '14' node-version: '14'
cache: 'yarn' cache: 'yarn'
- run: | - run: |
yarn install --network-concurrency 1 yarn install
- run: | - run: |
yarn lint --max-warnings 0 yarn lint --max-warnings 0
- run: | - run: |
yarn license yarn license
- uses: gradle/wrapper-validation-action@v1
- run: |
yarn prepare-build
- uses: gradle/gradle-build-action@v1 - uses: gradle/gradle-build-action@v1
with: with:
distributions-cache-enabled: true distributions-cache-enabled: true
@@ -62,14 +40,11 @@ jobs:
configuration-cache-enabled: true configuration-cache-enabled: true
build-root-directory: android build-root-directory: android
arguments: assembleRelease -PsignReleaseWithDebug=true arguments: assembleRelease -PsignReleaseWithDebug=true
- run: | - run: |
mkdir bin mkdir bin
- run: > - run: >
mv android/app/build/outputs/apk/release/app-release.apk mv android/app/build/outputs/apk/release/app-release.apk
bin/subtracks-${GITHUB_SHA}.apk bin/subtracks-${GITHUB_SHA}.apk
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v2
with: with:
name: subtracks-testing name: subtracks-testing
+2 -22
View File
@@ -10,38 +10,23 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with:
submodules: recursive
- run: |
rm submodules/ExoPlayer/gradle/wrapper/gradle-wrapper.jar
- uses: gradle/wrapper-validation-action@v1
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: '14' node-version: '14'
cache: 'yarn' cache: 'yarn'
- name: Get subtracks version - name: Get subtracks version
run: | run: |
echo "SUBTRACKS_VERSION=$(node -e "console.log(require('./package.json').version);")" >> $GITHUB_ENV echo "SUBTRACKS_VERSION=$(node -e "console.log(require('./package.json').version);")" >> $GITHUB_ENV
- run: | - run: |
yarn install --network-concurrency 1 yarn install
- run: | - run: |
yarn license yarn license
- uses: gradle/wrapper-validation-action@v1
- run: |
yarn prepare-build
- name: Retrieve keystore file - name: Retrieve keystore file
env: env:
SUBTRACKS_KEYSTORE_BASE64: ${{ secrets.SUBTRACKS_KEYSTORE_BASE64 }} SUBTRACKS_KEYSTORE_BASE64: ${{ secrets.SUBTRACKS_KEYSTORE_BASE64 }}
run: | run: |
echo "$SUBTRACKS_KEYSTORE_BASE64" | base64 --decode > android/app/subtracks.keystore echo "$SUBTRACKS_KEYSTORE_BASE64" | base64 --decode > android/app/subtracks.keystore
- name: Build release APK - name: Build release APK
env: env:
SUBTRACKS_UPLOAD_KEY_PASSWORD: ${{ secrets.SUBTRACKS_UPLOAD_KEY_PASSWORD }} SUBTRACKS_UPLOAD_KEY_PASSWORD: ${{ secrets.SUBTRACKS_UPLOAD_KEY_PASSWORD }}
@@ -53,14 +38,11 @@ jobs:
-PSUBTRACKS_UPLOAD_KEY_ALIAS=subtracks -PSUBTRACKS_UPLOAD_KEY_ALIAS=subtracks
-PSUBTRACKS_UPLOAD_KEY_PASSWORD="$SUBTRACKS_UPLOAD_KEY_PASSWORD" -PSUBTRACKS_UPLOAD_KEY_PASSWORD="$SUBTRACKS_UPLOAD_KEY_PASSWORD"
-PSUBTRACKS_UPLOAD_STORE_PASSWORD="$SUBTRACKS_UPLOAD_STORE_PASSWORD" -PSUBTRACKS_UPLOAD_STORE_PASSWORD="$SUBTRACKS_UPLOAD_STORE_PASSWORD"
- run: | - run: |
mkdir bin mkdir bin
- run: > - run: >
mv android/app/build/outputs/apk/release/app-release.apk mv android/app/build/outputs/apk/release/app-release.apk
bin/subtracks-${SUBTRACKS_VERSION}.apk bin/subtracks-${SUBTRACKS_VERSION}.apk
- name: Build release AAB - name: Build release AAB
env: env:
SUBTRACKS_UPLOAD_KEY_PASSWORD: ${{ secrets.SUBTRACKS_UPLOAD_KEY_PASSWORD }} SUBTRACKS_UPLOAD_KEY_PASSWORD: ${{ secrets.SUBTRACKS_UPLOAD_KEY_PASSWORD }}
@@ -72,11 +54,9 @@ jobs:
-PSUBTRACKS_UPLOAD_KEY_ALIAS=subtracks -PSUBTRACKS_UPLOAD_KEY_ALIAS=subtracks
-PSUBTRACKS_UPLOAD_KEY_PASSWORD="$SUBTRACKS_UPLOAD_KEY_PASSWORD" -PSUBTRACKS_UPLOAD_KEY_PASSWORD="$SUBTRACKS_UPLOAD_KEY_PASSWORD"
-PSUBTRACKS_UPLOAD_STORE_PASSWORD="$SUBTRACKS_UPLOAD_STORE_PASSWORD" -PSUBTRACKS_UPLOAD_STORE_PASSWORD="$SUBTRACKS_UPLOAD_STORE_PASSWORD"
- run: > - run: >
mv android/app/build/outputs/bundle/release/app-release.aab mv android/app/build/outputs/bundle/release/app-release.aab
bin/subtracks-${SUBTRACKS_VERSION}.aab bin/subtracks-${SUBTRACKS_VERSION}.aab
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v2
with: with:
name: subtracks-release name: subtracks-release
-4
View File
@@ -28,7 +28,6 @@ build/
.gradle .gradle
local.properties local.properties
*.iml *.iml
*.hprof
# node.js # node.js
# #
@@ -58,6 +57,3 @@ buck-out/
# CocoaPods # CocoaPods
/ios/Pods/ /ios/Pods/
# ffmpeg build
/ndk
-3
View File
@@ -1,3 +0,0 @@
[submodule "submodules/ExoPlayer"]
path = submodules/ExoPlayer
url = https://github.com/google/ExoPlayer.git
-1
View File
@@ -1 +0,0 @@
14
+1 -1
View File
@@ -1,7 +1,7 @@
module.exports = { module.exports = {
bracketSpacing: true, bracketSpacing: true,
singleQuote: true,
jsxBracketSameLine: true, jsxBracketSameLine: true,
singleQuote: true,
trailingComma: 'all', trailingComma: 'all',
arrowParens: 'avoid', arrowParens: 'avoid',
printWidth: 120, printWidth: 120,
-3
View File
@@ -1,3 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
+1 -3
View File
@@ -1,3 +1 @@
{ {}
"ignore_dirs": ["ndk"]
}
-59
View File
@@ -1,59 +0,0 @@
# Building from source
Instructions are provided here for Linux, specifically Ubuntu 20.04, but other operating systems should be fine as long as the requirements are met.
## Requirements
### Base
- git
- wget
- unzip
- node.js (14.x)
- yarn (1.x)
### React Native
Subtracks is a React Native application, which means you need to make sure you are set up for that. Follow the guide here to set up your development environment (React Native CLI, not Expo):
https://reactnative.dev/docs/environment-setup
When setting up your environment, make sure to install the following dependency versions:
- Node 14
- Java JDK 11 (tested with openjdk-11-jdk)
### ExoPlayer
We also need the requirements to build ExoPlayer's ffmpeg extension for Android, which should be covered by:
- build-essential (make, etc.)
More information on building ffmpeg can be found here (this is what the `prepare-build` script does):
https://github.com/google/ExoPlayer/tree/r2.11.4/extensions/ffmpeg
## Building
Clone the repository, making sure to recurse submodules, and change into the directory:
```sh
git clone --recursive https://github.com/austinried/subtracks.git
cd subtracks
```
Install the yarn packages and prepare the ExoPlayer ffmpeg extension by running the yarn script:
```sh
yarn install
yarn prepare-build
```
### Debug
In a separate terminal start Metro, which will be an ongoing process that serves javascript bundles to the build:
```sh
yarn start
```
Run the android debug build script, which will start the build and also start your default emulator and push the build to it (or push the build to a running emulator/already connected device):
```sh
yarn android
```
### Release
Similar to the debug build, except we don't need to run Metro first:
```sh
yarn android:release
```
The generated APK will be written to `android/app/build/outputs/apk/release/app-release.apk`
+1 -9
View File
@@ -13,13 +13,9 @@ Subtracks is an Android open source music streaming app for [Subsonic-API-compat
# Download # Download
<p float="left"> <p float="left">
<a href="https://play.google.com/store/apps/details?id=com.subtracks"><img src="assets/google-play-badge.png" width="250"/></a> <a href="https://github.com/austinried/subtracks/releases/latest" disabled><img src="assets/github-badge.png" width="250"/></a>
<a href="https://f-droid.org/en/packages/com.subtracks/"><img src="assets/f-droid-badge.png" width="250"></a>
<a href="https://github.com/austinried/subtracks/releases/latest"><img src="assets/github-badge.png" width="250"/></a>
</p> </p>
> :warning: Note: each download source above is signed with a different key, so you cannot switch between them without first uninstalling and then re-installing the app.
# Features # Features
- Album and artist art display by default (full-res in detail/now playing views) - Album and artist art display by default (full-res in detail/now playing views)
- Gapless playback - Gapless playback
@@ -41,7 +37,3 @@ Subtracks is an Android open source music streaming app for [Subsonic-API-compat
- Queue management (add/remove/sort) - Queue management (add/remove/sort)
- Multi-select actions - Multi-select actions
- Performance & animation improvements - Performance & animation improvements
- More shuffle play modes
# Building
See [Building from source](BUILDING.md).
+2 -11
View File
@@ -134,8 +134,8 @@ android {
applicationId "com.subtracks" applicationId "com.subtracks"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 8 versionCode 2
versionName '1.2.0' versionName '1.0.0'
} }
splits { splits {
abi { abi {
@@ -220,15 +220,6 @@ dependencies {
} else { } else {
implementation jscFlavor implementation jscFlavor
} }
implementation(project(":react-native-track-player")) {
exclude group:'com.google.android.exoplayer'
}
implementation project(':exoplayer-library-core')
implementation project(':exoplayer-extension-ffmpeg')
compileOnly project(':exoplayer-library-dash')
compileOnly project(':exoplayer-library-hls')
compileOnly project(':exoplayer-library-smoothstreaming')
} }
// Run this once to be able to run the application with BUCK // Run this once to be able to run the application with BUCK
-4
View File
@@ -10,7 +10,3 @@
# Add any project specific keep options here: # Add any project specific keep options here:
-keep class com.facebook.react.turbomodule.** { *; } -keep class com.facebook.react.turbomodule.** { *; }
# react-native-reanimated
# https://github.com/software-mansion/react-native-reanimated/pull/2725
-keep class com.swmansion.** {*;}
+2 -4
View File
@@ -1,8 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.subtracks" xmlns:tools="http://schemas.android.com/tools"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.subtracks">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission tools:node="remove" android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:usesCleartextTraffic="true">
<uses-permission tools:node="remove" android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:usesCleartextTraffic="true" android:networkSecurityConfig="@xml/network_security_config">
<activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustPan"> <activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustPan">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
File diff suppressed because one or more lines are too long
-504
View File
@@ -1,504 +0,0 @@
This software uses code of <a href="http://ffmpeg.org">FFmpeg</a> licensed under the <a href="http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html">LGPLv2.1</a> and its source can be downloaded <a href="http://ffmpeg.org/download.html#get-sources">here</a>.
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
not price. Our General Public Licenses are designed to make sure that
you have the freedom to distribute copies of free software (and charge
for this service if you wish); that you receive source code or can get
it if you want it; that you can change the software and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!
File diff suppressed because one or more lines are too long
@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material">
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>
+1 -1
View File
@@ -3,7 +3,7 @@
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. --> <!-- Customize your theme here. -->
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item> <item name="android:textColor">#000000</item>
</style> </style>
</resources> </resources>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+7 -23
View File
@@ -2,25 +2,24 @@
buildscript { buildscript {
ext { ext {
buildToolsVersion = "30.0.2" buildToolsVersion = "30.0.3"
minSdkVersion = 21 minSdkVersion = 21
compileSdkVersion = 30 compileSdkVersion = 30
targetSdkVersion = 30 targetSdkVersion = 30
ndkVersion = "21.4.7075529" ndkVersion = "20.1.5948944"
// react-native-async-storage next // react-native-async-storage next
kotlinVersion = '1.4.21' kotlinVersion = '1.4.21'
} }
repositories { repositories {
google() google()
mavenCentral() jcenter()
// jcenter()
maven { maven {
url 'https://plugins.gradle.org/m2/' url 'https://plugins.gradle.org/m2/'
} }
} }
dependencies { dependencies {
classpath("com.android.tools.build:gradle:4.2.2") classpath("com.android.tools.build:gradle:4.1.0")
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
@@ -32,7 +31,7 @@ buildscript {
apply plugin: "com.cookpad.android.plugin.license-tools" apply plugin: "com.cookpad.android.plugin.license-tools"
licenseTools { licenseTools {
outputHtml = "../../../app/src/main/assets/licenses.html" outputHtml = "../../../app/src/main/assets/licenses/android_licenses.html"
} }
allprojects { allprojects {
@@ -46,24 +45,9 @@ allprojects {
// Android JSC is installed from npm // Android JSC is installed from npm
url("$rootDir/../node_modules/jsc-android/dist") url("$rootDir/../node_modules/jsc-android/dist")
} }
mavenCentral {
// We don't want to fetch react-native from Maven Central as there are
// older versions over there.
content {
excludeGroup "com.facebook.react"
}
}
google() google()
jcenter()
maven { url 'https://www.jitpack.io' } maven { url 'https://www.jitpack.io' }
// remove jcenter once the following workarounds are no longer needed
jcenter() {
content {
// used by react-native-track-player, can remove once they use a newer exoplayer
includeGroup("com.linkedin.dexmaker")
// used by exoplayer
includeGroup("com.linkedin.dexmaker-mockito")
includeGroup("com.google.android.exoplayer")
}
}
} }
} }
-64
View File
@@ -1,64 +0,0 @@
// Copyright (C) 2017 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
def rootDir = gradle.ext.exoplayerRoot
def modulePrefix = ':'
if (gradle.ext.has('exoplayerModulePrefix')) {
modulePrefix += gradle.ext.exoplayerModulePrefix
}
// include modulePrefix + 'library'
include modulePrefix + 'library-core'
include modulePrefix + 'library-dash'
include modulePrefix + 'library-hls'
include modulePrefix + 'library-smoothstreaming'
// include modulePrefix + 'library-ui'
include modulePrefix + 'testutils'
// include modulePrefix + 'extension-av1'
include modulePrefix + 'extension-ffmpeg'
// include modulePrefix + 'extension-flac'
// include modulePrefix + 'extension-gvr'
// include modulePrefix + 'extension-ima'
// include modulePrefix + 'extension-cast'
// include modulePrefix + 'extension-cronet'
// include modulePrefix + 'extension-mediasession'
// include modulePrefix + 'extension-okhttp'
// include modulePrefix + 'extension-opus'
// include modulePrefix + 'extension-vp9'
// include modulePrefix + 'extension-rtmp'
// include modulePrefix + 'extension-leanback'
// include modulePrefix + 'extension-jobdispatcher'
// include modulePrefix + 'extension-workmanager'
// project(modulePrefix + 'library').projectDir = new File(rootDir, 'library/all')
project(modulePrefix + 'library-core').projectDir = new File(rootDir, 'library/core')
project(modulePrefix + 'library-dash').projectDir = new File(rootDir, 'library/dash')
project(modulePrefix + 'library-hls').projectDir = new File(rootDir, 'library/hls')
project(modulePrefix + 'library-smoothstreaming').projectDir = new File(rootDir, 'library/smoothstreaming')
// project(modulePrefix + 'library-ui').projectDir = new File(rootDir, 'library/ui')
project(modulePrefix + 'testutils').projectDir = new File(rootDir, 'testutils')
// project(modulePrefix + 'extension-av1').projectDir = new File(rootDir, 'extensions/av1')
project(modulePrefix + 'extension-ffmpeg').projectDir = new File(rootDir, 'extensions/ffmpeg')
// project(modulePrefix + 'extension-flac').projectDir = new File(rootDir, 'extensions/flac')
// project(modulePrefix + 'extension-gvr').projectDir = new File(rootDir, 'extensions/gvr')
// project(modulePrefix + 'extension-ima').projectDir = new File(rootDir, 'extensions/ima')
// project(modulePrefix + 'extension-cast').projectDir = new File(rootDir, 'extensions/cast')
// project(modulePrefix + 'extension-cronet').projectDir = new File(rootDir, 'extensions/cronet')
// project(modulePrefix + 'extension-mediasession').projectDir = new File(rootDir, 'extensions/mediasession')
// project(modulePrefix + 'extension-okhttp').projectDir = new File(rootDir, 'extensions/okhttp')
// project(modulePrefix + 'extension-opus').projectDir = new File(rootDir, 'extensions/opus')
// project(modulePrefix + 'extension-vp9').projectDir = new File(rootDir, 'extensions/vp9')
// project(modulePrefix + 'extension-rtmp').projectDir = new File(rootDir, 'extensions/rtmp')
// project(modulePrefix + 'extension-leanback').projectDir = new File(rootDir, 'extensions/leanback')
// project(modulePrefix + 'extension-jobdispatcher').projectDir = new File(rootDir, 'extensions/jobdispatcher')
// project(modulePrefix + 'extension-workmanager').projectDir = new File(rootDir, 'extensions/workmanager')
+3 -7
View File
@@ -9,8 +9,8 @@
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx1024m -XX:MaxPermSize=256m # Default value: -Xmx10248m -XX:MaxPermSize=256m
org.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
@@ -25,12 +25,8 @@ android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# Version of flipper SDK to use with React Native # Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.99.0 FLIPPER_VERSION=0.75.1
# react-native-async-storage next # react-native-async-storage next
AsyncStorage_useNextStorage=true AsyncStorage_useNextStorage=true
AsyncStorage_kotlinVersion=1.4.21 AsyncStorage_kotlinVersion=1.4.21
# required to override exoplayer used by react-native-track-player, resolves "abstract method" error
# see: https://github.com/google/ExoPlayer/issues/6801#issuecomment-574089568
android.enableDexingArtifactTransform=false
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+23 -232
View File
@@ -4,18 +4,12 @@
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx url: https://developer.android.com/jetpack/androidx
- artifact: androidx.annotation:annotation-experimental:+
name: annotation-experimental
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx/releases/annotation#1.1.0
- artifact: androidx.annotation:annotation:+ - artifact: androidx.annotation:annotation:+
name: annotation name: annotation
copyrightHolder: Original Author copyrightHolder: Original Author
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx/releases/annotation#1.2.0 url: http://developer.android.com/tools/extras/support-library.html
- artifact: androidx.appcompat:appcompat-resources:+ - artifact: androidx.appcompat:appcompat-resources:+
name: appcompat-resources name: appcompat-resources
copyrightHolder: Original Author copyrightHolder: Original Author
@@ -46,12 +40,6 @@
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://developer.android.com/tools/extras/support-library.html url: http://developer.android.com/tools/extras/support-library.html
- artifact: androidx.autofill:autofill:+
name: autofill
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx/releases/autofill#1.1.0
- artifact: androidx.cardview:cardview:+ - artifact: androidx.cardview:cardview:+
name: cardview name: cardview
copyrightHolder: Original Author copyrightHolder: Original Author
@@ -70,12 +58,6 @@
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx url: https://developer.android.com/jetpack/androidx
- artifact: androidx.core:core-ktx:+
name: core-ktx
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx/releases/core#1.6.0
- artifact: androidx.core:core:+ - artifact: androidx.core:core:+
name: core name: core
copyrightHolder: Original Author copyrightHolder: Original Author
@@ -207,19 +189,19 @@
copyrightHolder: Original Author copyrightHolder: Original Author
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx/releases/room#2.3.0 url: https://developer.android.com/topic/libraries/architecture/index.html
- artifact: androidx.room:room-ktx:+ - artifact: androidx.room:room-ktx:+
name: room-ktx name: room-ktx
copyrightHolder: Original Author copyrightHolder: Original Author
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx/releases/room#2.3.0 url: https://developer.android.com/topic/libraries/architecture/index.html
- artifact: androidx.room:room-runtime:+ - artifact: androidx.room:room-runtime:+
name: room-runtime name: room-runtime
copyrightHolder: Original Author copyrightHolder: Original Author
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx/releases/room#2.3.0 url: https://developer.android.com/topic/libraries/architecture/index.html
- artifact: androidx.savedstate:savedstate:+ - artifact: androidx.savedstate:savedstate:+
name: savedstate name: savedstate
copyrightHolder: Original Author copyrightHolder: Original Author
@@ -250,24 +232,6 @@
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://developer.android.com/tools/extras/support-library.html url: http://developer.android.com/tools/extras/support-library.html
- artifact: androidx.test.ext:junit:+
name: junit
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/testing
- artifact: androidx.test:core:+
name: core
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/testing
- artifact: androidx.test:monitor:+
name: monitor
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/testing
- artifact: androidx.transition:transition:+ - artifact: androidx.transition:transition:+
name: transition name: transition
copyrightHolder: Original Author copyrightHolder: Original Author
@@ -319,104 +283,62 @@
- artifact: com.facebook.fresco:drawee:+ - artifact: com.facebook.fresco:drawee:+
name: drawee name: drawee
copyrightHolder: Original Author copyrightHolder: Original Author
license: MIT license: MIT License
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:fbcore:+ - artifact: com.facebook.fresco:fbcore:+
name: fbcore name: fbcore
copyrightHolder: Original Author copyrightHolder: Original Author
license: MIT license: MIT License
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:fresco:+ - artifact: com.facebook.fresco:fresco:+
name: fresco name: fresco
copyrightHolder: Original Author copyrightHolder: Original Author
license: MIT license: MIT License
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:imagepipeline-base:+ - artifact: com.facebook.fresco:imagepipeline-base:+
name: imagepipeline-base name: imagepipeline-base
copyrightHolder: Original Author copyrightHolder: Original Author
license: MIT license: MIT License
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:imagepipeline-native:+
name: imagepipeline-native
copyrightHolder: Original Author
license: MIT
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:imagepipeline-okhttp3:+ - artifact: com.facebook.fresco:imagepipeline-okhttp3:+
name: imagepipeline-okhttp3 name: imagepipeline-okhttp3
copyrightHolder: Original Author copyrightHolder: Original Author
license: MIT license: MIT License
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:imagepipeline:+ - artifact: com.facebook.fresco:imagepipeline:+
name: imagepipeline name: imagepipeline
copyrightHolder: Original Author copyrightHolder: Original Author
license: MIT license: MIT License
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:memory-type-ashmem:+
name: memory-type-ashmem
copyrightHolder: Original Author
license: MIT
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:memory-type-java:+
name: memory-type-java
copyrightHolder: Original Author
license: MIT
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:memory-type-native:+
name: memory-type-native
copyrightHolder: Original Author
license: MIT
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:middleware:+
name: middleware
copyrightHolder: Original Author
license: MIT
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:nativeimagefilters:+ - artifact: com.facebook.fresco:nativeimagefilters:+
name: nativeimagefilters name: nativeimagefilters
copyrightHolder: Original Author copyrightHolder: Original Author
license: MIT license: MIT License
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:nativeimagetranscoder:+ - artifact: com.facebook.fresco:nativeimagetranscoder:+
name: nativeimagetranscoder name: nativeimagetranscoder
copyrightHolder: Original Author copyrightHolder: Original Author
license: MIT license: MIT License
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:soloader:+
name: soloader
copyrightHolder: Original Author
license: MIT
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco
- artifact: com.facebook.fresco:ui-common:+
name: ui-common
copyrightHolder: Original Author
license: MIT
licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE licenseUrl: https://github.com/facebook/fresco/blob/master/LICENSE
url: https://github.com/facebook/fresco url: https://github.com/facebook/fresco
- artifact: com.facebook.infer.annotation:infer-annotation:+ - artifact: com.facebook.infer.annotation:infer-annotation:+
name: infer-annotation name: infer-annotation
copyrightHolder: Original Author copyrightHolder: Original Author
license: MIT license license: BSD license
licenseUrl: https://github.com/facebook/infer/blob/master/LICENSE licenseUrl: https://github.com/facebook/infer/blob/master/LICENSE
url: http://fbinfer.com/ url: http://fbinfer.com/
- artifact: com.facebook.react:react-native:+ - artifact: com.facebook.react:react-native:+
name: react-native name: react-native
copyrightHolder: Original Author copyrightHolder: Original Author
license: MIT License license: MIT License
licenseUrl: https://github.com/facebook/react-native/blob/HEAD/LICENSE licenseUrl: https://github.com/facebook/react-native/blob/master/LICENSE
url: https://github.com/facebook/react-native url: https://github.com/facebook/react-native
- artifact: com.facebook.soloader:annotation:+ - artifact: com.facebook.soloader:annotation:+
name: annotation name: annotation
@@ -492,59 +414,12 @@
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://developer.android.com/tools/extras/support-library.html url: http://developer.android.com/tools/extras/support-library.html
- artifact: com.google.auto.value:auto-value-annotations:+
name: auto-value-annotations
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/google/auto
- artifact: com.google.code.findbugs:jsr305:+ - artifact: com.google.code.findbugs:jsr305:+
name: jsr305 name: jsr305
copyrightHolder: Original Author copyrightHolder: Original Author
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://findbugs.sourceforge.net/ url: http://findbugs.sourceforge.net/
- artifact: com.google.errorprone:error_prone_annotations:+
name: error_prone_annotations
copyrightHolder: Original Author
license: Apache 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
- artifact: com.google.guava:failureaccess:+
name: failureaccess
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/google/guava
- artifact: com.google.guava:guava:+
name: guava
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/google/guava
- artifact: com.google.guava:listenablefuture:+
name: listenablefuture
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/google/guava
- artifact: com.google.j2objc:j2objc-annotations:+
name: j2objc-annotations
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/google/j2objc/
- artifact: com.google.truth:truth:+
name: truth
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/google/truth
- artifact: com.googlecode.java-diff-utils:diffutils:+
name: diffutils
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://code.google.com/p/java-diff-utils/
- artifact: com.parse.bolts:bolts-tasks:+ - artifact: com.parse.bolts:bolts-tasks:+
name: bolts-tasks name: bolts-tasks
copyrightHolder: Original Author copyrightHolder: Original Author
@@ -554,105 +429,33 @@
- artifact: com.squareup.okhttp3:okhttp-urlconnection:+ - artifact: com.squareup.okhttp3:okhttp-urlconnection:+
name: okhttp-urlconnection name: okhttp-urlconnection
copyrightHolder: Original Author copyrightHolder: Original Author
license: The Apache Software License, Version 2.0 license: Apache-2.0 License
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: https://github.com/square/okhttp/blob/master/LICENSE.txt
url: https://square.github.io/okhttp/ url: https://github.com/square/okhttp
- artifact: com.squareup.okhttp3:okhttp:+ - artifact: com.squareup.okhttp3:okhttp:+
name: okhttp name: okhttp
copyrightHolder: Original Author copyrightHolder: Original Author
license: The Apache Software License, Version 2.0 license: Apache-2.0 License
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: https://github.com/square/okhttp/blob/master/LICENSE.txt
url: https://square.github.io/okhttp/ url: https://github.com/square/okhttp
- artifact: com.squareup.okio:okio:+ - artifact: com.squareup.okio:okio:+
name: okio name: okio
copyrightHolder: Original Author copyrightHolder: Original Author
license: The Apache Software License, Version 2.0 license: Apache-2.0 License
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: https://github.com/square/okio/blob/master/LICENSE.txt
url: https://github.com/square/okio/ url: https://github.com/square/okio
- artifact: javax.inject:javax.inject:+ - artifact: javax.inject:javax.inject:+
name: javax.inject name: javax.inject
copyrightHolder: Original Author copyrightHolder: Original Author
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://code.google.com/p/atinject/ url: http://code.google.com/p/atinject/
- artifact: junit:junit:+
name: junit
copyrightHolder: Original Author
license: Eclipse Public License 1.0
licenseUrl: http://www.eclipse.org/legal/epl-v10.html
url: http://junit.org
- artifact: net.bytebuddy:byte-buddy-agent:+
name: byte-buddy-agent
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/raphw/byte-buddy
- artifact: net.bytebuddy:byte-buddy:+
name: byte-buddy
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://github.com/raphw/byte-buddy
- artifact: org.apache.commons:commons-lang3:+
name: commons-lang3
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://commons.apache.org/lang/
- artifact: org.checkerframework:checker-compat-qual:+
name: checker-compat-qual
copyrightHolder: Original Author
license: GNU General Public License, version 2 (GPL2), with the classpath exception
licenseUrl: http://www.gnu.org/software/classpath/license.html
url: https://checkerframework.org
- artifact: org.checkerframework:checker-qual:+
name: checker-qual
copyrightHolder: Original Author
license: The MIT License
licenseUrl: http://opensource.org/licenses/MIT
url: https://checkerframework.org
- artifact: org.codehaus.mojo:animal-sniffer-annotations:+
name: animal-sniffer-annotations
copyrightHolder: Original Author
license: The MIT License
licenseUrl: http://opensource.org/licenses/MIT
url: https://github.com/mojohaus/animal-sniffer
- artifact: org.hamcrest:hamcrest-core:+
name: hamcrest-core
copyrightHolder: Original Author
license: BSD License
licenseUrl: https://raw.githubusercontent.com/hamcrest/JavaHamcrest/master/LICENSE.txt
url: https://github.com/hamcrest/JavaHamcrest
- artifact: org.jetbrains.kotlin:kotlin-annotations-jvm:+
name: kotlin-annotations-jvm
copyrightHolder: Original Author
license: The Apache License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://kotlinlang.org/
- artifact: org.jetbrains.kotlin:kotlin-reflect:+
name: kotlin-reflect
copyrightHolder: Original Author
license: The Apache License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://kotlinlang.org/
- artifact: org.jetbrains.kotlin:kotlin-stdlib-common:+ - artifact: org.jetbrains.kotlin:kotlin-stdlib-common:+
name: kotlin-stdlib-common name: kotlin-stdlib-common
copyrightHolder: Original Author copyrightHolder: Original Author
license: The Apache License, Version 2.0 license: The Apache License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://kotlinlang.org/ url: https://kotlinlang.org/
- artifact: org.jetbrains.kotlin:kotlin-stdlib-jdk7:+
name: kotlin-stdlib-jdk7
copyrightHolder: Original Author
license: The Apache License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://kotlinlang.org/
- artifact: org.jetbrains.kotlin:kotlin-stdlib-jdk8:+
name: kotlin-stdlib-jdk8
copyrightHolder: Original Author
license: The Apache License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://kotlinlang.org/
- artifact: org.jetbrains.kotlin:kotlin-stdlib:+ - artifact: org.jetbrains.kotlin:kotlin-stdlib:+
name: kotlin-stdlib name: kotlin-stdlib
copyrightHolder: Original Author copyrightHolder: Original Author
@@ -677,15 +480,3 @@
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://www.jetbrains.org url: http://www.jetbrains.org
- artifact: org.mockito:mockito-core:+
name: mockito-core
copyrightHolder: Original Author
license: The MIT License
licenseUrl: https://github.com/mockito/mockito/blob/master/LICENSE
url: https://github.com/mockito/mockito
- artifact: org.objenesis:objenesis:+
name: objenesis
copyrightHolder: Original Author
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://objenesis.org
-3
View File
@@ -7,6 +7,3 @@ include ':react-native-fs'
project(':react-native-fs').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fs/android') project(':react-native-fs').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fs/android')
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
include ':app' include ':app'
gradle.ext.exoplayerRoot = '../submodules/ExoPlayer'
gradle.ext.exoplayerModulePrefix = 'exoplayer-'
apply from: file("exoplayer_settings.gradle")
+5 -15
View File
@@ -5,27 +5,19 @@ import React from 'react'
import { StatusBar, View, StyleSheet } from 'react-native' import { StatusBar, View, StyleSheet } from 'react-native'
import ProgressHook from './components/ProgressHook' import ProgressHook from './components/ProgressHook'
import { useStore } from './state/store' import { useStore } from './state/store'
import { selectTrackPlayer } from './state/trackplayer'
import { MenuProvider } from 'react-native-popup-menu' import { MenuProvider } from 'react-native-popup-menu'
import { QueryClientProvider } from 'react-query'
import queryClient from './queryClient'
const Debug = () => { const Debug = () => {
const currentTrackTitle = useStore(store => store.currentTrack?.title) const currentTrack = useStore(selectTrackPlayer.currentTrack)
console.log('currentTrackTitle', currentTrackTitle) console.log(currentTrack?.title)
return <></> return <></>
} }
const App = () => { const App = () => (
return (
<QueryClientProvider client={queryClient}>
<MenuProvider backHandler={true}> <MenuProvider backHandler={true}>
<View style={styles.appContainer}> <View style={styles.appContainer}>
<StatusBar <StatusBar animated={true} backgroundColor={'rgba(0, 0, 0, 0.3)'} barStyle={'light-content'} translucent={true} />
animated={true}
backgroundColor={'rgba(0, 0, 0, 0.3)'}
barStyle={'light-content'}
translucent={true}
/>
<SplashPage> <SplashPage>
<ProgressHook /> <ProgressHook />
<Debug /> <Debug />
@@ -33,9 +25,7 @@ const App = () => {
</SplashPage> </SplashPage>
</View> </View>
</MenuProvider> </MenuProvider>
</QueryClientProvider>
) )
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
appContainer: { appContainer: {
+1 -1
View File
@@ -37,7 +37,7 @@ function BackgroundHeaderFlatList<ItemT>(props: BackgroundHeaderFlatListProp<Ite
</props.BackgroundComponent> </props.BackgroundComponent>
} }
ListHeaderComponentStyle={[headerStyle]} ListHeaderComponentStyle={[headerStyle]}
ListEmptyComponent={props.ListEmptyComponent || <NothingHere style={styles.nothing} />} ListEmptyComponent={<NothingHere style={styles.nothing} />}
/> />
) )
} }
+15 -29
View File
@@ -1,6 +1,8 @@
import PressableOpacity from '@app/components/PressableOpacity' import PressableOpacity from '@app/components/PressableOpacity'
import { useStar } from '@app/hooks/query' import { useStarred } from '@app/hooks/music'
import { StarrableItemType, Song, Artist, Album } from '@app/models/library' import { AlbumListItem, Artist, Song, StarrableItemType } from '@app/models/music'
import { selectMusic } from '@app/state/music'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { NavigationProp, useNavigation } from '@react-navigation/native' import { NavigationProp, useNavigation } from '@react-navigation/native'
@@ -10,8 +12,9 @@ import { ScrollView, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-
import { Menu, MenuOption, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu' import { Menu, MenuOption, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu'
import IconFA from 'react-native-vector-icons/FontAwesome' import IconFA from 'react-native-vector-icons/FontAwesome'
import IconFA5 from 'react-native-vector-icons/FontAwesome5' import IconFA5 from 'react-native-vector-icons/FontAwesome5'
// import IconMat from 'react-native-vector-icons/MaterialIcons'
import CoverArt from './CoverArt' import CoverArt from './CoverArt'
import { Star } from './Star' import Star from './Star'
const { SlideInMenu } = renderers const { SlideInMenu } = renderers
@@ -22,7 +25,6 @@ type ContextMenuProps = {
triggerTouchableStyle?: StyleProp<ViewStyle> triggerTouchableStyle?: StyleProp<ViewStyle>
onPress?: () => any onPress?: () => any
triggerOnLongPress?: boolean triggerOnLongPress?: boolean
disabled?: boolean
} }
type InternalContextMenuProps = ContextMenuProps & { type InternalContextMenuProps = ContextMenuProps & {
@@ -40,7 +42,6 @@ const ContextMenu: React.FC<InternalContextMenuProps> = ({
menuOptions, menuOptions,
children, children,
triggerOnLongPress, triggerOnLongPress,
disabled,
}) => { }) => {
menuStyle = menuStyle || { flex: 1 } menuStyle = menuStyle || { flex: 1 }
triggerWrapperStyle = triggerWrapperStyle || { flex: 1 } triggerWrapperStyle = triggerWrapperStyle || { flex: 1 }
@@ -49,12 +50,11 @@ const ContextMenu: React.FC<InternalContextMenuProps> = ({
return ( return (
<Menu renderer={SlideInMenu} style={menuStyle}> <Menu renderer={SlideInMenu} style={menuStyle}>
<MenuTrigger <MenuTrigger
disabled={disabled}
triggerOnLongPress={triggerOnLongPress === undefined ? true : triggerOnLongPress} triggerOnLongPress={triggerOnLongPress === undefined ? true : triggerOnLongPress}
customStyles={{ customStyles={{
triggerOuterWrapper: triggerOuterWrapperStyle, triggerOuterWrapper: triggerOuterWrapperStyle,
triggerWrapper: triggerWrapperStyle, triggerWrapper: triggerWrapperStyle,
triggerTouchable: { style: triggerTouchableStyle, disabled }, triggerTouchable: { style: triggerTouchableStyle },
TriggerTouchableComponent: PressableOpacity, TriggerTouchableComponent: PressableOpacity,
}} }}
onAlternativeAction={onPress}> onAlternativeAction={onPress}>
@@ -120,24 +120,9 @@ const MenuHeader = React.memo<{
}>(({ coverArt, artistId, title, subtitle }) => ( }>(({ coverArt, artistId, title, subtitle }) => (
<View style={styles.menuHeader}> <View style={styles.menuHeader}>
{artistId ? ( {artistId ? (
<CoverArt <CoverArt type="artist" artistId={artistId} style={styles.coverArt} resizeMode={'cover'} round={true} />
type="artist"
artistId={artistId}
style={styles.coverArt}
resizeMode="cover"
round={true}
size="thumbnail"
fadeDuration={0}
/>
) : ( ) : (
<CoverArt <CoverArt type="cover" coverArt={coverArt} style={styles.coverArt} resizeMode={'cover'} />
type="cover"
coverArt={coverArt}
style={styles.coverArt}
resizeMode="cover"
size="thumbnail"
fadeDuration={0}
/>
)} )}
<View style={styles.menuHeaderText}> <View style={styles.menuHeaderText}>
<Text numberOfLines={1} style={styles.menuTitle}> <Text numberOfLines={1} style={styles.menuTitle}>
@@ -159,13 +144,14 @@ const OptionStar = React.memo<{
type: StarrableItemType type: StarrableItemType
additionalText?: string additionalText?: string
}>(({ id, type, additionalText: text }) => { }>(({ id, type, additionalText: text }) => {
const { query, toggle } = useStar(id, type) const starred = useStarred(id, type)
const setStarred = useStore(selectMusic.starItem)
return ( return (
<ContextMenuIconTextOption <ContextMenuIconTextOption
IconComponentRaw={<Star starred={!!query.data} size={26} />} IconComponentRaw={<Star starred={starred} size={26} />}
text={(query.data ? 'Unstar' : 'Star') + (text ? ` ${text}` : '')} text={(starred ? 'Unstar' : 'Star') + (text ? ` ${text}` : '')}
onSelect={() => toggle.mutate()} onSelect={() => setStarred(id, type, starred)}
/> />
) )
}) })
@@ -217,7 +203,7 @@ const OptionViewAlbum = React.memo<{
// )) // ))
export type AlbumContextPressableProps = ContextMenuProps & { export type AlbumContextPressableProps = ContextMenuProps & {
album: Album album: AlbumListItem
} }
export const AlbumContextPressable: React.FC<AlbumContextPressableProps> = props => { export const AlbumContextPressable: React.FC<AlbumContextPressableProps> = props => {
+18 -26
View File
@@ -1,5 +1,5 @@
import { useQueryArtistArtPath, useQueryCoverArtPath } from '@app/hooks/query' import { useArtistArtFile, useCoverArtFile } from '@app/hooks/cache'
import { CacheImageSize } from '@app/models/cache' import { CacheFile, CacheImageSize, CacheRequest } from '@app/models/cache'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import React, { useState } from 'react' import React, { useState } from 'react'
import { import {
@@ -18,8 +18,7 @@ type BaseProps = {
imageStyle?: ImageStyle imageStyle?: ImageStyle
resizeMode?: ImageResizeMode resizeMode?: ImageResizeMode
round?: boolean round?: boolean
size: CacheImageSize size?: CacheImageSize
fadeDuration?: number
} }
type ArtistCoverArtProps = BaseProps & { type ArtistCoverArtProps = BaseProps & {
@@ -32,54 +31,47 @@ type CoverArtProps = BaseProps & {
coverArt?: string coverArt?: string
} }
type ImageSourceProps = BaseProps & { const ImageSource = React.memo<{ cache?: { file?: CacheFile; request?: CacheRequest } } & BaseProps>(
data?: string ({ cache, style, imageStyle, resizeMode }) => {
isFetching: boolean
isExistingFetching: boolean
}
const ImageSource = React.memo<ImageSourceProps>(
({ style, imageStyle, resizeMode, data, isFetching, isExistingFetching, fadeDuration }) => {
const [error, setError] = useState(false) const [error, setError] = useState(false)
let source: ImageSourcePropType let source: ImageSourcePropType
if (!error && data) { if (!error && cache?.file && !cache?.request?.promise) {
source = { uri: `file://${data}` } source = { uri: `file://${cache.file.path}`, cache: 'reload' }
} else { } else {
source = require('@res/fallback.png') source = require('@res/fallback.png')
} }
return ( return (
<> <>
{isExistingFetching ? (
<View style={{ height: style?.height, width: style?.width }} />
) : (
<Image <Image
source={source} source={source}
fadeDuration={fadeDuration === undefined ? 250 : fadeDuration} fadeDuration={150}
resizeMode={resizeMode || 'contain'} resizeMode={resizeMode || 'contain'}
style={[{ height: style?.height, width: style?.width }, imageStyle]} style={[{ height: style?.height, width: style?.width }, imageStyle]}
onError={() => setError(true)} onError={() => setError(true)}
/> />
)} <ActivityIndicator
{isFetching && ( animating={!!cache?.request?.promise}
<ActivityIndicator animating={true} size="large" color={colors.accent} style={styles.indicator} /> size="large"
)} color={colors.accent}
style={styles.indicator}
/>
</> </>
) )
}, },
) )
const ArtistImage = React.memo<ArtistCoverArtProps>(props => { const ArtistImage = React.memo<ArtistCoverArtProps>(props => {
const { data, isFetching, isExistingFetching } = useQueryArtistArtPath(props.artistId, props.size) const cache = useArtistArtFile(props.artistId, props.size)
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} /> return <ImageSource cache={cache} {...props} imageStyle={{ ...styles.artistImage, ...props.imageStyle }} />
}) })
const CoverArtImage = React.memo<CoverArtProps>(props => { const CoverArtImage = React.memo<CoverArtProps>(props => {
const { data, isFetching, isExistingFetching } = useQueryCoverArtPath(props.coverArt, props.size) const cache = useCoverArtFile(props.coverArt, props.size)
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} /> return <ImageSource cache={cache} {...props} />
}) })
const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps>(props => { const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps>(props => {
+1 -3
View File
@@ -4,7 +4,6 @@ import dimensions from '@app/styles/dimensions'
import React, { ForwardedRef, PropsWithChildren } from 'react' import React, { ForwardedRef, PropsWithChildren } from 'react'
import { ScrollView, ScrollViewProps, useWindowDimensions } from 'react-native' import { ScrollView, ScrollViewProps, useWindowDimensions } from 'react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
export type GradientScrollViewProps = PropsWithChildren< export type GradientScrollViewProps = PropsWithChildren<
Animated.AnimateProps<ScrollViewProps> & { Animated.AnimateProps<ScrollViewProps> & {
@@ -14,9 +13,8 @@ export type GradientScrollViewProps = PropsWithChildren<
const GradientScrollView = React.forwardRef<ScrollView, GradientScrollViewProps>((props, ref) => { const GradientScrollView = React.forwardRef<ScrollView, GradientScrollViewProps>((props, ref) => {
const layout = useWindowDimensions() const layout = useWindowDimensions()
const paddingTop = useSafeAreaInsets().top
const minHeight = layout.height - (dimensions.header + paddingTop + dimensions.bottom()) const minHeight = layout.height - (dimensions.top() + dimensions.bottom())
return ( return (
<Animated.ScrollView <Animated.ScrollView
+10 -10
View File
@@ -1,18 +1,17 @@
import { Album, Song } from '@app/models/library'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions' import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font' import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/core' import { useNavigation } from '@react-navigation/core'
import { ReactComponentLike } from 'prop-types'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { StyleSheet, Text, View, ViewStyle } from 'react-native' import { View, StatusBar, Text, StyleSheet, ViewStyle } from 'react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import { AlbumContextPressable, NowPlayingContextPressable } from './ContextMenu'
import PressableOpacity from './PressableOpacity' import PressableOpacity from './PressableOpacity'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import { ReactComponentLike } from 'prop-types'
import { AlbumListItem, Song } from '@app/models/music'
import { AlbumContextPressable, NowPlayingContextPressable } from './ContextMenu'
export type HeaderContextItem = Song | Album export type HeaderContextItem = Song | AlbumListItem
const More = React.memo<{ contextItem?: HeaderContextItem }>(({ contextItem }) => { const More = React.memo<{ contextItem?: HeaderContextItem }>(({ contextItem }) => {
const moreIcon = <IconMat name="more-vert" color="white" size={25} /> const moreIcon = <IconMat name="more-vert" color="white" size={25} />
@@ -49,12 +48,11 @@ const More = React.memo<{ contextItem?: HeaderContextItem }>(({ contextItem }) =
const HeaderBar = React.memo<{ const HeaderBar = React.memo<{
title?: string title?: string
headerStyle?: Animated.AnimateStyle<ViewStyle> | Animated.AnimateStyle<ViewStyle>[] headerStyle?: Animated.AnimatedStyleProp<ViewStyle> | Animated.AnimatedStyleProp<ViewStyle>[]
HeaderCenter?: ReactComponentLike HeaderCenter?: ReactComponentLike
contextItem?: HeaderContextItem contextItem?: HeaderContextItem
}>(({ title, headerStyle, HeaderCenter, contextItem }) => { }>(({ title, headerStyle, HeaderCenter, contextItem }) => {
const navigation = useNavigation() const navigation = useNavigation()
const top = useSafeAreaInsets().top
const back = useCallback(() => { const back = useCallback(() => {
navigation.goBack() navigation.goBack()
@@ -63,7 +61,7 @@ const HeaderBar = React.memo<{
const _headerStyle = Array.isArray(headerStyle) ? headerStyle : [headerStyle] const _headerStyle = Array.isArray(headerStyle) ? headerStyle : [headerStyle]
return ( return (
<Animated.View style={[styles.container, { paddingTop: top, height: dimensions.header + top }, ..._headerStyle]}> <Animated.View style={[styles.container, ..._headerStyle]}>
<PressableOpacity onPress={back} style={styles.icons}> <PressableOpacity onPress={back} style={styles.icons}>
<IconMat name="arrow-back" color="white" size={25} /> <IconMat name="arrow-back" color="white" size={25} />
</PressableOpacity> </PressableOpacity>
@@ -83,6 +81,8 @@ const HeaderBar = React.memo<{
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
height: dimensions.top(),
paddingTop: StatusBar.currentHeight,
backgroundColor: colors.gradient.high, backgroundColor: colors.gradient.high,
width: '100%', width: '100%',
flexDirection: 'row', flexDirection: 'row',
+1 -3
View File
@@ -3,13 +3,11 @@ import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions' import dimensions from '@app/styles/dimensions'
import React from 'react' import React from 'react'
import { ScrollView, ScrollViewProps, useWindowDimensions } from 'react-native' import { ScrollView, ScrollViewProps, useWindowDimensions } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const ImageGradientScrollView: React.FC<ScrollViewProps & ImageGradientBackgroundProps> = props => { const ImageGradientScrollView: React.FC<ScrollViewProps & ImageGradientBackgroundProps> = props => {
const layout = useWindowDimensions() const layout = useWindowDimensions()
const paddingTop = useSafeAreaInsets().top
const minHeight = layout.height - (dimensions.header + paddingTop + dimensions.bottom()) const minHeight = layout.height - (dimensions.top() + dimensions.bottom())
return ( return (
<ScrollView <ScrollView
+31 -39
View File
@@ -1,5 +1,8 @@
import { useStarred } from '@app/hooks/music'
import { useIsPlaying } from '@app/hooks/trackplayer' import { useIsPlaying } from '@app/hooks/trackplayer'
import { Album, Artist, ListableItem, Song } from '@app/models/library' import { AlbumListItem, Artist, ListableItem, Song } from '@app/models/music'
import { selectMusic } from '@app/state/music'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
@@ -10,8 +13,7 @@ import IconMat from 'react-native-vector-icons/MaterialIcons'
import { AlbumContextPressable, ArtistContextPressable, SongContextPressable } from './ContextMenu' import { AlbumContextPressable, ArtistContextPressable, SongContextPressable } from './ContextMenu'
import CoverArt from './CoverArt' import CoverArt from './CoverArt'
import PressableOpacity from './PressableOpacity' import PressableOpacity from './PressableOpacity'
import { PressableStar } from './Star' import Star from './Star'
import equal from 'fast-deep-equal/es6/react'
const TitleTextSong = React.memo<{ const TitleTextSong = React.memo<{
contextId?: string contextId?: string
@@ -54,9 +56,9 @@ const ListItem: React.FC<{
listStyle?: 'big' | 'small' listStyle?: 'big' | 'small'
subtitle?: string subtitle?: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
disabled?: boolean }> = ({ item, contextId, queueId, onPress, showArt, showStar, subtitle, listStyle, style }) => {
}> = ({ item, contextId, queueId, onPress, showArt, showStar, subtitle, listStyle, style, disabled }) => {
const navigation = useNavigation() const navigation = useNavigation()
const starred = useStarred(item.id, item.itemType)
showStar = showStar === undefined ? true : showStar showStar = showStar === undefined ? true : showStar
listStyle = listStyle || 'small' listStyle = listStyle || 'small'
@@ -66,13 +68,13 @@ const ListItem: React.FC<{
if (!onPress) { if (!onPress) {
switch (item.itemType) { switch (item.itemType) {
case 'album': case 'album':
onPress = () => navigation.navigate('album', { id: item.id, title: item.name, album: item }) onPress = () => navigation.navigate('album', { id: item.id, title: item.name })
break break
case 'artist': case 'artist':
onPress = () => navigation.navigate('artist', { id: item.id, title: item.name }) onPress = () => navigation.navigate('artist', { id: item.id, title: item.name })
break break
case 'playlist': case 'playlist':
onPress = () => navigation.navigate('playlist', { id: item.id, title: item.name, playlist: item }) onPress = () => navigation.navigate('playlist', { id: item.id, title: item.name })
break break
} }
} }
@@ -91,43 +93,35 @@ const ListItem: React.FC<{
const itemPressable = useCallback( const itemPressable = useCallback(
({ children }) => ( ({ children }) => (
<PressableOpacity onPress={onPress} style={styles.item} disabled={disabled}> <PressableOpacity onPress={onPress} style={styles.item}>
{children} {children}
</PressableOpacity> </PressableOpacity>
), ),
[disabled, onPress], [onPress],
) )
const albumPressable = useCallback( const albumPressable = useCallback(
({ children }) => ( ({ children }) => (
<AlbumContextPressable <AlbumContextPressable album={item as AlbumListItem} onPress={onPress} triggerWrapperStyle={styles.item}>
album={item as Album}
onPress={onPress}
triggerWrapperStyle={styles.item}
disabled={disabled}>
{children} {children}
</AlbumContextPressable> </AlbumContextPressable>
), ),
[disabled, item, onPress], [item, onPress],
) )
const songPressable = useCallback( const songPressable = useCallback(
({ children }) => ( ({ children }) => (
<SongContextPressable song={item as Song} onPress={onPress} triggerWrapperStyle={styles.item} disabled={disabled}> <SongContextPressable song={item as Song} onPress={onPress} triggerWrapperStyle={styles.item}>
{children} {children}
</SongContextPressable> </SongContextPressable>
), ),
[disabled, item, onPress], [item, onPress],
) )
const artistPressable = useCallback( const artistPressable = useCallback(
({ children }) => ( ({ children }) => (
<ArtistContextPressable <ArtistContextPressable artist={item as Artist} onPress={onPress} triggerWrapperStyle={styles.item}>
artist={item as Artist}
onPress={onPress}
triggerWrapperStyle={styles.item}
disabled={disabled}>
{children} {children}
</ArtistContextPressable> </ArtistContextPressable>
), ),
[disabled, item, onPress], [item, onPress],
) )
let PressableComponent = itemPressable let PressableComponent = itemPressable
@@ -139,6 +133,13 @@ const ListItem: React.FC<{
PressableComponent = artistPressable PressableComponent = artistPressable
} }
const starItem = useStore(selectMusic.starItem)
const toggleStarred = useCallback(() => {
if (item.itemType !== 'playlist') {
starItem(item.id, item.itemType, starred)
}
}, [item.id, item.itemType, starItem, starred])
let title = <></> let title = <></>
if (item.itemType === 'song' && queueId !== undefined) { if (item.itemType === 'song' && queueId !== undefined) {
title = <TitleTextSong contextId={contextId} queueId={queueId} title={item.title} /> title = <TitleTextSong contextId={contextId} queueId={queueId} title={item.title} />
@@ -150,20 +151,9 @@ const ListItem: React.FC<{
const resizeMode = 'cover' const resizeMode = 'cover'
let coverArt = <></> let coverArt = <></>
if (item.itemType === 'artist') { if (item.itemType === 'artist') {
coverArt = ( coverArt = <CoverArt type="artist" artistId={item.id} round={true} style={artStyle} resizeMode={resizeMode} />
<CoverArt
type="artist"
artistId={item.id}
round={true}
style={artStyle}
resizeMode={resizeMode}
size="thumbnail"
/>
)
} else { } else {
coverArt = ( coverArt = <CoverArt type="cover" coverArt={item.coverArt} style={artStyle} resizeMode={resizeMode} />
<CoverArt type="cover" coverArt={item.coverArt} style={artStyle} resizeMode={resizeMode} size="thumbnail" />
)
} }
return ( return (
@@ -188,8 +178,10 @@ const ListItem: React.FC<{
</View> </View>
</PressableComponent> </PressableComponent>
<View style={styles.controls}> <View style={styles.controls}>
{showStar && item.itemType !== 'playlist' && ( {showStar && (
<PressableStar id={item.id} type={item.itemType} size={26} style={styles.controlItem} disabled={disabled} /> <PressableOpacity onPress={toggleStarred} style={styles.controlItem}>
<Star size={26} starred={starred} />
</PressableOpacity>
)} )}
</View> </View>
</View> </View>
@@ -267,4 +259,4 @@ const bigStyles = StyleSheet.create({
}, },
}) })
export default React.memo(ListItem, equal) export default React.memo(ListItem)
+13 -7
View File
@@ -1,5 +1,7 @@
import Button from '@app/components/Button' import Button from '@app/components/Button'
import { Song } from '@app/models/library' import { Song } from '@app/models/music'
import { useStore } from '@app/state/store'
import { QueueContextType, selectTrackPlayer } from '@app/state/trackplayer'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import React, { useState } from 'react' import React, { useState } from 'react'
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native' import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
@@ -9,12 +11,13 @@ import IconMat from 'react-native-vector-icons/MaterialIcons'
const ListPlayerControls = React.memo<{ const ListPlayerControls = React.memo<{
songs: Song[] songs: Song[]
typeName: string typeName: string
queueName: string
queueContextType: QueueContextType
queueContextId: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
play: () => void }>(({ songs, typeName, queueName, queueContextType, queueContextId, style }) => {
shuffle: () => void
disabled?: boolean
}>(({ typeName, style, play, shuffle, disabled }) => {
const [downloaded, setDownloaded] = useState(false) const [downloaded, setDownloaded] = useState(false)
const setQueue = useStore(selectTrackPlayer.setQueue)
return ( return (
<View style={[styles.controls, style]}> <View style={[styles.controls, style]}>
@@ -31,10 +34,13 @@ const ListPlayerControls = React.memo<{
</Button> </Button>
</View> </View>
<View style={styles.controlsCenter}> <View style={styles.controlsCenter}>
<Button title={`Play ${typeName}`} disabled={disabled} onPress={play} /> <Button
title={`Play ${typeName}`}
onPress={() => setQueue(songs, queueName, queueContextType, queueContextId, undefined, false)}
/>
</View> </View>
<View style={styles.controlsSide}> <View style={styles.controlsSide}>
<Button disabled={disabled} onPress={shuffle}> <Button onPress={() => setQueue(songs, queueName, queueContextType, queueContextId, undefined, true)}>
<Icon name="shuffle" size={26} color="white" /> <Icon name="shuffle" size={26} color="white" />
</Button> </Button>
</View> </View>
+8 -13
View File
@@ -2,6 +2,7 @@ import CoverArt from '@app/components/CoverArt'
import PressableOpacity from '@app/components/PressableOpacity' import PressableOpacity from '@app/components/PressableOpacity'
import { usePause, usePlay } from '@app/hooks/trackplayer' import { usePause, usePlay } from '@app/hooks/trackplayer'
import { useStore } from '@app/state/store' import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/trackplayer'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
@@ -11,8 +12,7 @@ import { State } from 'react-native-track-player'
import IconFA5 from 'react-native-vector-icons/FontAwesome5' import IconFA5 from 'react-native-vector-icons/FontAwesome5'
const ProgressBar = React.memo(() => { const ProgressBar = React.memo(() => {
const position = useStore(store => store.progress.position) const { position, duration } = useStore(selectTrackPlayer.progress)
const duration = useStore(store => store.progress.duration)
let progress = 0 let progress = 0
if (duration > 0) { if (duration > 0) {
@@ -41,7 +41,7 @@ const progressStyles = StyleSheet.create({
}) })
const Controls = React.memo(() => { const Controls = React.memo(() => {
const state = useStore(store => store.playerState) const state = useStore(selectTrackPlayer.playerState)
const play = usePlay() const play = usePlay()
const pause = usePause() const pause = usePause()
@@ -78,12 +78,9 @@ const Controls = React.memo(() => {
const NowPlayingBar = React.memo(() => { const NowPlayingBar = React.memo(() => {
const navigation = useNavigation() const navigation = useNavigation()
const currentTrackExists = useStore(store => !!store.currentTrack) const track = useStore(selectTrackPlayer.currentTrack)
const coverArt = useStore(store => store.currentTrack?.coverArt)
const title = useStore(store => store.currentTrack?.title)
const artist = useStore(store => store.currentTrack?.artist)
const displayStyle: ViewStyle = { display: currentTrackExists ? 'flex' : 'none' } const displayStyle: ViewStyle = { display: track ? 'flex' : 'none' }
return ( return (
<Pressable onPress={() => navigation.navigate('now-playing')} style={[styles.container, displayStyle]}> <Pressable onPress={() => navigation.navigate('now-playing')} style={[styles.container, displayStyle]}>
@@ -92,16 +89,14 @@ const NowPlayingBar = React.memo(() => {
<CoverArt <CoverArt
type="cover" type="cover"
style={{ height: styles.subContainer.height, width: styles.subContainer.height }} style={{ height: styles.subContainer.height, width: styles.subContainer.height }}
coverArt={coverArt} coverArt={track?.coverArt}
size="thumbnail"
fadeDuration={0}
/> />
<View style={styles.detailsContainer}> <View style={styles.detailsContainer}>
<Text numberOfLines={1} style={styles.detailsTitle}> <Text numberOfLines={1} style={styles.detailsTitle}>
{title} {track?.title}
</Text> </Text>
<Text numberOfLines={1} style={styles.detailsAlbum}> <Text numberOfLines={1} style={styles.detailsAlbum}>
{artist} {track?.artist}
</Text> </Text>
</View> </View>
<Controls /> <Controls />
+8 -4
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { GestureResponderEvent, LayoutRectangle, Pressable, PressableProps, StyleSheet } from 'react-native' import { GestureResponderEvent, LayoutRectangle, Pressable, PressableProps, ViewStyle, StyleSheet } from 'react-native'
type PressableOpacityProps = PressableProps & { type PressableOpacityProps = PressableProps & {
ripple?: boolean ripple?: boolean
@@ -9,8 +9,13 @@ type PressableOpacityProps = PressableProps & {
const PressableOpacity: React.FC<PressableOpacityProps> = props => { const PressableOpacity: React.FC<PressableOpacityProps> = props => {
const [opacity, setOpacity] = useState(1) const [opacity, setOpacity] = useState(1)
const [disabledStyle, setDisabledStyle] = useState<ViewStyle>({})
const [dimensions, setDimensions] = useState<LayoutRectangle | undefined>(undefined) const [dimensions, setDimensions] = useState<LayoutRectangle | undefined>(undefined)
useEffect(() => {
props.disabled === true ? setDisabledStyle({ opacity: 0.3 }) : setDisabledStyle({})
}, [props.disabled])
props = { props = {
...props, ...props,
unstable_pressDelay: props.unstable_pressDelay === undefined ? 60 : props.unstable_pressDelay, unstable_pressDelay: props.unstable_pressDelay === undefined ? 60 : props.unstable_pressDelay,
@@ -50,8 +55,7 @@ const PressableOpacity: React.FC<PressableOpacityProps> = props => {
return ( return (
<Pressable <Pressable
{...props} {...props}
// eslint-disable-next-line react-native/no-inline-styles style={[styles.pressable, props.style as any, { opacity }, disabledStyle]}
style={[styles.pressable, props.style as any, { opacity }, props.disabled ? { opacity: 0.3 } : {}]}
android_ripple={ android_ripple={
props.ripple props.ripple
? { ? {
+3 -2
View File
@@ -1,10 +1,11 @@
import { useStore } from '@app/state/store' import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/trackplayer'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { State, useProgress } from 'react-native-track-player' import { State, useProgress } from 'react-native-track-player'
const ProgressHook = () => { const ProgressHook = () => {
const playerState = useStore(store => store.playerState) const playerState = useStore(selectTrackPlayer.playerState)
const setProgress = useStore(store => store.setProgress) const setProgress = useStore(selectTrackPlayer.setProgress)
const progress = useProgress(250) const progress = useProgress(250)
useEffect(() => { useEffect(() => {
+2 -19
View File
@@ -1,11 +1,8 @@
import { useStar } from '@app/hooks/query'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import React from 'react' import React from 'react'
import { PressableStateCallbackType, StyleProp, ViewStyle } from 'react-native'
import IconFA from 'react-native-vector-icons/FontAwesome' import IconFA from 'react-native-vector-icons/FontAwesome'
import PressableOpacity from './PressableOpacity'
export const Star = React.memo<{ const Star = React.memo<{
starred: boolean starred: boolean
size: number size: number
}>(({ starred, size }) => { }>(({ starred, size }) => {
@@ -14,18 +11,4 @@ export const Star = React.memo<{
) )
}) })
export const PressableStar = React.memo<{ export default Star
id: string
type: 'album' | 'artist' | 'song'
size: number
style?: StyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>) | undefined
disabled?: boolean
}>(({ id, type, size, style, disabled }) => {
const { query, toggle } = useStar(id, type)
return (
<PressableOpacity onPress={() => toggle.mutate()} style={style} disabled={disabled}>
<Star size={size} starred={!!query.data} />
</PressableOpacity>
)
})
+88
View File
@@ -0,0 +1,88 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { ArtistInfo } from '@app/models/music'
import { selectCache } from '@app/state/cache'
import { selectMusic } from '@app/state/music'
import { selectSettings } from '@app/state/settings'
import { useStore, Store } from '@app/state/store'
import { useCallback, useEffect } from 'react'
const useFileRequest = (key: CacheItemTypeKey, id: string) => {
const file = useStore(
useCallback(
(store: Store) => {
const activeServerId = store.settings.activeServer
if (!activeServerId) {
return
}
return store.cacheFiles[activeServerId][key][id]
},
[key, id],
),
)
const request = useStore(
useCallback(
(store: Store) => {
const activeServerId = store.settings.activeServer
if (!activeServerId) {
return
}
return store.cacheRequests[activeServerId][key][id]
},
[key, id],
),
)
return { file, request }
}
export const useCoverArtFile = (coverArt = '-1', size: CacheImageSize = 'thumbnail') => {
const type: CacheItemTypeKey = size === 'original' ? 'coverArt' : 'coverArtThumb'
const { file, request } = useFileRequest(type, coverArt)
const client = useStore(selectSettings.client)
const cacheItem = useStore(selectCache.cacheItem)
useEffect(() => {
if (!file && client) {
cacheItem(type, coverArt, () =>
client.getCoverArtUri({
id: coverArt,
size: type === 'coverArtThumb' ? '256' : undefined,
}),
)
}
// intentionally leaving file out so it doesn't re-render if the request fails
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheItem, client, coverArt, type])
return { file, request }
}
export const useArtistArtFile = (artistId: string, size: CacheImageSize = 'thumbnail') => {
const type: CacheItemTypeKey = size === 'original' ? 'artistArt' : 'artistArtThumb'
const fetchArtistInfo = useStore(selectMusic.fetchArtistInfo)
const { file, request } = useFileRequest(type, artistId)
const cacheItem = useStore(selectCache.cacheItem)
useEffect(() => {
if (!file) {
cacheItem(type, artistId, async () => {
let artistInfo: ArtistInfo | undefined
const cachedArtistInfo = useStore.getState().artistInfo[artistId]
if (cachedArtistInfo) {
artistInfo = cachedArtistInfo
} else {
artistInfo = await fetchArtistInfo(artistId)
}
return type === 'artistArtThumb' ? artistInfo?.smallImageUrl : artistInfo?.largeImageUrl
})
}
// intentionally leaving file out so it doesn't re-render if the request fails
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artistId, cacheItem, fetchArtistInfo, type])
return { file, request }
}
-305
View File
@@ -1,305 +0,0 @@
import { CacheItemTypeKey } from '@app/models/cache'
import { Album, AlbumCoverArt, Playlist, Song } from '@app/models/library'
import { mapAlbum, mapArtist, mapArtistInfo, mapPlaylist, mapSong } from '@app/models/map'
import queryClient from '@app/queryClient'
import { useStore } from '@app/state/store'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import { cacheDir } from '@app/util/fs'
import { mapCollectionById } from '@app/util/state'
import userAgent from '@app/util/userAgent'
import cd from 'content-disposition'
import mime from 'mime-types'
import path from 'path'
import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util'
import RNFS from 'react-native-fs'
import qk from './queryKeys'
export const useClient = () => {
const client = useStore(store => store.client)
return () => {
if (!client) {
throw new Error('no client!')
}
return client
}
}
function cacheStarredData<T extends { id: string; starred?: undefined | any }>(item: T) {
queryClient.setQueryData<boolean>(qk.starredItems(item.id), !!item.starred)
}
function cacheAlbumCoverArtData<T extends { id: string; coverArt?: string }>(item: T) {
queryClient.setQueryData<AlbumCoverArt>(qk.albumCoverArt(item.id), { albumId: item.id, coverArt: item.coverArt })
}
export const useFetchArtists = () => {
const client = useClient()
return async () => {
const res = await client().getArtists()
res.data.artists.forEach(cacheStarredData)
return mapCollectionById(res.data.artists, mapArtist)
}
}
export const useFetchArtist = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getArtist({ id })
cacheStarredData(res.data.artist)
res.data.albums.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return {
artist: mapArtist(res.data.artist),
albums: res.data.albums.map(mapAlbum),
}
}
}
export const useFetchArtistInfo = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getArtistInfo2({ id })
return mapArtistInfo(id, res.data.artistInfo)
}
}
export const useFetchArtistTopSongs = () => {
const client = useClient()
return async (artistName: string) => {
const res = await client().getTopSongs({ artist: artistName })
res.data.songs.forEach(cacheStarredData)
return res.data.songs.map(mapSong)
}
}
export const useFetchPlaylists = () => {
const client = useClient()
return async () => {
const res = await client().getPlaylists()
return mapCollectionById(res.data.playlists, mapPlaylist)
}
}
export const useFetchPlaylist = () => {
const client = useClient()
return async (id: string): Promise<{ playlist: Playlist; songs?: Song[] }> => {
const res = await client().getPlaylist({ id })
res.data.playlist.songs.forEach(cacheStarredData)
return {
playlist: mapPlaylist(res.data.playlist),
songs: res.data.playlist.songs.map(mapSong),
}
}
}
export const useFetchAlbum = () => {
const client = useClient()
return async (id: string): Promise<{ album: Album; songs?: Song[] }> => {
const res = await client().getAlbum({ id })
cacheStarredData(res.data.album)
res.data.songs.forEach(cacheStarredData)
cacheAlbumCoverArtData(res.data.album)
return {
album: mapAlbum(res.data.album),
songs: res.data.songs.map(mapSong),
}
}
}
export const useFetchAlbumList = () => {
const client = useClient()
return async (size: number, offset: number, type: GetAlbumList2TypeBase) => {
const res = await client().getAlbumList2({ size, offset, type })
res.data.albums.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return res.data.albums.map(mapAlbum)
}
}
export const useFetchSong = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getSong({ id })
cacheStarredData(res.data.song)
return mapSong(res.data.song)
}
}
export const useFetchSearchResults = () => {
const client = useClient()
return async (params: Search3Params) => {
const res = await client().search3(params)
res.data.artists.forEach(cacheStarredData)
res.data.albums.forEach(cacheStarredData)
res.data.songs.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return {
artists: res.data.artists.map(mapArtist),
albums: res.data.albums.map(mapAlbum),
songs: res.data.songs.map(mapSong),
}
}
}
export const useFetchStar = () => {
const client = useClient()
return async (params: StarParams) => {
await client().star(params)
return
}
}
export const useFetchUnstar = () => {
const client = useClient()
return async (params: StarParams) => {
await client().unstar(params)
return
}
}
export type FetchExisingFileOptions = {
itemType: CacheItemTypeKey
itemId: string
}
export const useFetchExistingFile: () => (options: FetchExisingFileOptions) => Promise<string | undefined> = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async ({ itemType, itemId }) => {
const fileDir = cacheDir(serverId, itemType, itemId)
try {
const dir = await RNFS.readDir(fileDir)
console.log('existing file:', dir[0].path)
return dir[0].path
} catch {}
}
}
function assertMimeType(expected?: string, actual?: string) {
expected = expected?.toLowerCase()
actual = actual?.toLowerCase()
if (!expected || expected === actual) {
return
}
if (!expected.includes(';')) {
actual = actual?.split(';')[0]
}
if (!expected.includes('/')) {
actual = actual?.split('/')[0]
}
if (expected !== actual) {
throw new Error(`Request does not satisfy expected content type. Expected: ${expected} Actual: ${actual}`)
}
}
export type FetchFileOptions = FetchExisingFileOptions & {
fromUrl: string
useCacheBuster?: boolean
expectedContentType?: string
progress?: (received: number, total: number) => void
}
export const useFetchFile: () => (options: FetchFileOptions) => Promise<string> = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async ({ itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress }) => {
useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster
const fileDir = cacheDir(serverId, itemType, itemId)
const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType)
try {
await RNFS.unlink(fileDir)
} catch {}
const headers = { 'User-Agent': userAgent }
// we send a HEAD first for two reasons:
// 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects)
// 2. to obtain the mime-type up front so we can use it for the file extension/validation
const headRes = await fetch(fromUrl, { method: 'HEAD', headers })
if (headRes.status > 399) {
throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`)
}
const contentType = headRes.headers.get('content-type') || undefined
assertMimeType(expectedContentType, contentType)
const contentDisposition = headRes.headers.get('content-disposition') || undefined
const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined
let extension: string | undefined
if (filename) {
extension = path.extname(filename) || undefined
if (extension) {
extension = extension.substring(1)
}
} else if (contentType) {
extension = mime.extension(contentType) || undefined
}
const config = ReactNativeBlobUtil.config({
addAndroidDownloads: {
useDownloadManager: true,
notification: false,
mime: contentType,
description: 'subtracks',
path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt,
},
})
const fetchParams: Parameters<typeof config['fetch']> = ['GET', headRes.url, headers]
let res: FetchBlobResponse
if (progress) {
res = await config.fetch(...fetchParams).progress(progress)
} else {
res = await config.fetch(...fetchParams)
}
const downloadPath = res.path()
queryClient.setQueryData<string>(qk.existingFiles(itemType, itemId), downloadPath)
console.log('downloaded file:', downloadPath)
return downloadPath
}
}
+77
View File
@@ -0,0 +1,77 @@
import { useState, useCallback } from 'react'
import { useActiveServerRefresh } from './server'
export const useFetchList = <T>(fetchList: () => Promise<T[]>) => {
const [list, setList] = useState<T[]>([])
const [refreshing, setRefreshing] = useState(false)
const refresh = useCallback(() => {
setRefreshing(true)
fetchList().then(items => {
setList(items)
setRefreshing(false)
})
}, [fetchList])
const reset = useCallback(() => {
setList([])
refresh()
}, [refresh])
useActiveServerRefresh(
useCallback(() => {
reset()
}, [reset]),
)
return { list, refreshing, refresh, reset }
}
export const useFetchPaginatedList = <T>(
fetchList: (size?: number, offset?: number) => Promise<T[]>,
pageSize: number,
) => {
const [list, setList] = useState<T[]>([])
const [refreshing, setRefreshing] = useState(false)
const [offset, setOffset] = useState(0)
const refresh = useCallback(() => {
setOffset(0)
setRefreshing(true)
fetchList(pageSize, 0).then(firstPage => {
setList(firstPage)
setRefreshing(false)
})
}, [fetchList, pageSize])
const reset = useCallback(() => {
setList([])
refresh()
}, [refresh])
useActiveServerRefresh(
useCallback(() => {
reset()
}, [reset]),
)
const fetchNextPage = useCallback(() => {
const newOffset = offset + pageSize
setRefreshing(true)
fetchList(pageSize, newOffset).then(nextPage => {
setRefreshing(false)
if (nextPage.length === 0) {
return
}
setList([...list, ...nextPage])
setOffset(newOffset)
})
}, [offset, pageSize, fetchList, list])
return { list, refreshing, refresh, reset, fetchNextPage }
}
+62
View File
@@ -0,0 +1,62 @@
import { selectMusic } from '@app/state/music'
import { Store, useStore } from '@app/state/store'
import { useCallback, useEffect } from 'react'
export const useArtistInfo = (id: string) => {
const artistInfo = useStore(useCallback((state: Store) => state.artistInfo[id], [id]))
const fetchArtistInfo = useStore(selectMusic.fetchArtistInfo)
useEffect(() => {
if (!artistInfo) {
fetchArtistInfo(id)
}
}, [artistInfo, fetchArtistInfo, id])
return artistInfo
}
export const useAlbumWithSongs = (id: string) => {
const album = useStore(useCallback((state: Store) => state.albumsWithSongs[id], [id]))
const fetchAlbum = useStore(selectMusic.fetchAlbumWithSongs)
useEffect(() => {
if (!album) {
fetchAlbum(id)
}
}, [album, fetchAlbum, id])
return album
}
export const usePlaylistWithSongs = (id: string) => {
const playlist = useStore(useCallback((state: Store) => state.playlistsWithSongs[id], [id]))
const fetchPlaylist = useStore(selectMusic.fetchPlaylistWithSongs)
useEffect(() => {
if (!playlist) {
fetchPlaylist(id)
}
}, [fetchPlaylist, id, playlist])
return playlist
}
export const useStarred = (id: string, type: string) => {
return useStore(
useCallback(
(state: Store) => {
switch (type) {
case 'song':
return state.starredSongs[id]
case 'album':
return state.starredAlbums[id]
case 'artist':
return state.starredArtists[id]
default:
return false
}
},
[type, id],
),
)
}
-406
View File
@@ -1,406 +0,0 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { Album, AlbumCoverArt, Artist, Playlist, Song, StarrableItemType } from '@app/models/library'
import { CollectionById } from '@app/models/state'
import queryClient from '@app/queryClient'
import { useStore } from '@app/state/store'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import _ from 'lodash'
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
useQueries,
useQuery,
UseQueryResult,
} from 'react-query'
import {
useFetchAlbum,
useFetchAlbumList,
useFetchArtist,
useFetchArtistInfo,
useFetchArtists,
useFetchArtistTopSongs,
useFetchExistingFile,
useFetchFile,
useFetchPlaylist,
useFetchPlaylists,
useFetchSearchResults,
useFetchSong,
useFetchStar,
useFetchUnstar,
} from './fetch'
import qk from './queryKeys'
export const useQueryArtists = () => useQuery(qk.artists, useFetchArtists())
export const useQueryArtist = (id: string) => {
const fetchArtist = useFetchArtist()
return useQuery(qk.artist(id), () => fetchArtist(id), {
placeholderData: () => {
const artist = queryClient.getQueryData<CollectionById<Artist>>(qk.artists)?.byId[id]
if (artist) {
return { artist, albums: [] }
}
},
})
}
export const useQueryArtistInfo = (id: string) => {
const fetchArtistInfo = useFetchArtistInfo()
return useQuery(qk.artistInfo(id), () => fetchArtistInfo(id))
}
export const useQueryArtistTopSongs = (artistName?: string) => {
const fetchArtistTopSongs = useFetchArtistTopSongs()
const query = useQuery(qk.artistTopSongs(artistName || ''), () => fetchArtistTopSongs(artistName as string), {
enabled: !!artistName,
retry: false,
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isError', 'isFetched', 'isSuccess', 'isFetching'],
})
const querySuccess = query.isFetched && query.isSuccess && query.data && query.data.length > 0
const fetchSearchResults = useFetchSearchResults()
const [artistCount, albumCount, songCount] = [0, 0, 300]
const backupQuery = useQuery(
qk.search(artistName || '', artistCount, albumCount, songCount),
() => fetchSearchResults({ query: artistName as string, artistCount, albumCount, songCount }),
{
select: data => {
const artistNameLower = artistName?.toLowerCase()
const songs = data.songs.filter(s => s.artist?.toLowerCase() === artistNameLower)
// sortBy is a stable sort, so that this doesn't change order arbitrarily and re-render
return _.sortBy(songs, [
s => -(s.playCount || 0),
s => -(s.averageRating || 0),
s => -(s.userRating || 0),
]).slice(0, 50)
},
enabled: !!artistName && !query.isFetching && !querySuccess,
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isError'],
},
)
return useFixCoverArt(querySuccess ? query : backupQuery)
}
export const useQueryPlaylists = () => useQuery(qk.playlists, useFetchPlaylists())
export const useQueryPlaylist = (id: string, placeholderPlaylist?: Playlist) => {
const fetchPlaylist = useFetchPlaylist()
const query = useQuery(qk.playlist(id), () => fetchPlaylist(id), {
placeholderData: () => {
if (placeholderPlaylist) {
return { playlist: placeholderPlaylist }
}
const playlist = queryClient.getQueryData<CollectionById<Playlist>>(qk.playlists)?.byId[id]
if (playlist) {
return { playlist, songs: [] }
}
},
})
return useFixCoverArt(query)
}
export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
const fetchAlbum = useFetchAlbum()
const query = useQuery(qk.album(id), () => fetchAlbum(id), {
placeholderData: (): { album: Album; songs?: Song[] } | undefined =>
placeholderAlbum ? { album: placeholderAlbum } : undefined,
})
return useFixCoverArt(query)
}
export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => {
const fetchAlbumList = useFetchAlbumList()
return useInfiniteQuery(
qk.albumList(type, size),
async context => {
return await fetchAlbumList(size, context.pageParam || 0, type)
},
{
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length === 0) {
return
}
return allPages.length * size
},
cacheTime: 0,
},
)
}
export const useQuerySearchResults = (params: Search3Params) => {
const fetchSearchResults = useFetchSearchResults()
const query = useInfiniteQuery(
qk.search(params.query, params.artistCount, params.albumCount, params.songCount),
async context => {
return await fetchSearchResults({
...params,
artistOffset: context.pageParam?.artistOffset || 0,
albumOffset: context.pageParam?.albumOffset || 0,
songOffset: context.pageParam?.songOffset || 0,
})
},
{
getNextPageParam: (lastPage, allPages) => {
if (lastPage.albums.length + lastPage.artists.length + lastPage.songs.length === 0) {
return
}
return {
artistOffset: allPages.reduce((acc, val) => (acc += val.artists.length), 0),
albumOffset: allPages.reduce((acc, val) => (acc += val.albums.length), 0),
songOffset: allPages.reduce((acc, val) => (acc += val.songs.length), 0),
}
},
cacheTime: 1000 * 60,
enabled: !!params.query && params.query.length > 1,
},
)
return useFixCoverArt(query)
}
export const useQueryHomeLists = (types: GetAlbumList2TypeBase[], size: number) => {
const fetchAlbumList = useFetchAlbumList()
const listQueries = useQueries(
types.map(type => {
return {
queryKey: qk.albumList(type, size),
queryFn: async () => {
const albums = await fetchAlbumList(size, 0, type as GetAlbumList2TypeBase)
return { type, albums }
},
}
}),
)
return listQueries
}
export const useStar = (id: string, type: StarrableItemType) => {
const fetchStar = useFetchStar()
const fetchUnstar = useFetchUnstar()
const fetchSong = useFetchSong()
const fetchAlbum = useFetchAlbum()
const fetchArtist = useFetchArtist()
const query = useQuery(
qk.starredItems(id),
async () => {
switch (type) {
case 'album':
console.log('fetch album starred', id)
return !!(await fetchAlbum(id)).album.starred
case 'artist':
console.log('fetch artist starred', id)
return !!(await fetchArtist(id)).artist.starred
default:
console.log('fetch song starred', id)
return !!(await fetchSong(id)).starred
}
},
{
cacheTime: Infinity,
staleTime: Infinity,
},
)
const toggle = useMutation(
() => {
const params: StarParams = {
id: type === 'song' ? id : undefined,
albumId: type === 'album' ? id : undefined,
artistId: type === 'artist' ? id : undefined,
}
return !query.data ? fetchStar(params) : fetchUnstar(params)
},
{
onMutate: () => {
queryClient.setQueryData<boolean>(qk.starredItems(id), !query.data)
},
onSuccess: () => {
if (type === 'album') {
queryClient.invalidateQueries(qk.albumList('starred'))
}
},
},
)
return { query, toggle }
}
export const useQueryExistingFile = (itemType: CacheItemTypeKey, itemId: string) => {
const fetchExistingFile = useFetchExistingFile()
return useQuery(qk.existingFiles(itemType, itemId), () => fetchExistingFile({ itemType, itemId }), {
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isFetched'],
})
}
export const useQueryCoverArtPath = (coverArt = '-1', size: CacheImageSize = 'thumbnail') => {
const fetchFile = useFetchFile()
const client = useStore(store => store.client)
const itemType: CacheItemTypeKey = size === 'original' ? 'coverArt' : 'coverArtThumb'
const existing = useQueryExistingFile(itemType, coverArt)
const query = useQuery(
qk.coverArt(coverArt, size),
async () => {
if (!client) {
return
}
const fromUrl = client.getCoverArtUri({ id: coverArt, size: itemType === 'coverArtThumb' ? '256' : undefined })
return await fetchFile({ itemType, itemId: coverArt, fromUrl, expectedContentType: 'image' })
},
{
enabled: existing.isFetched && !existing.data && !!client,
staleTime: Infinity,
cacheTime: Infinity,
},
)
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
}
export const useQueryArtistArtPath = (artistId: string, size: CacheImageSize = 'thumbnail') => {
const fetchFile = useFetchFile()
const client = useStore(store => store.client)
const { data: artistInfo } = useQueryArtistInfo(artistId)
const itemType: CacheItemTypeKey = size === 'original' ? 'artistArt' : 'artistArtThumb'
const existing = useQueryExistingFile(itemType, artistId)
const query = useQuery(
qk.artistArt(artistId, size),
async () => {
if (!client || !artistInfo?.smallImageUrl || !artistInfo?.largeImageUrl) {
return
}
const fromUrl = itemType === 'artistArtThumb' ? artistInfo.smallImageUrl : artistInfo.largeImageUrl
return await fetchFile({ itemType, itemId: artistId, fromUrl, expectedContentType: 'image' })
},
{
enabled:
existing.isFetched &&
!existing.data &&
!!client &&
(!!artistInfo?.smallImageUrl || !!artistInfo?.largeImageUrl),
staleTime: Infinity,
cacheTime: Infinity,
},
)
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
}
type WithSongs = Song[] | { songs?: Song[] }
type InfiniteWithSongs = { songs: Song[] }
type AnyDataWithSongs = WithSongs | InfiniteData<InfiniteWithSongs>
type AnyQueryWithSongs = UseQueryResult<WithSongs> | UseInfiniteQueryResult<{ songs: Song[] }>
function getSongs<T extends AnyDataWithSongs>(data: T | undefined): Song[] {
if (!data) {
return []
}
if (Array.isArray(data)) {
return data
}
if ('pages' in data) {
return data.pages.flatMap(p => p.songs)
}
return data.songs || []
}
function setSongCoverArt<T extends AnyQueryWithSongs>(query: T, coverArts: UseQueryResult<AlbumCoverArt>[]): T {
if (!query.data) {
return query
}
const mapSongCoverArt = (song: Song) => ({
...song,
coverArt: coverArts.find(c => c.data?.albumId === song.albumId)?.data?.coverArt,
})
if (Array.isArray(query.data)) {
return {
...query,
data: query.data.map(mapSongCoverArt),
}
}
if ('pages' in query.data) {
return {
...query,
data: {
pages: query.data.pages.map(p => ({
...p,
songs: p.songs.map(mapSongCoverArt),
})),
},
}
}
if (query.data.songs) {
return {
...query,
data: {
...query.data,
songs: query.data.songs.map(mapSongCoverArt),
},
}
}
return query
}
// song cover art comes back from the api as a unique id per song even if it all points to the same
// album art, which prevents us from caching it once, so we need to use the album's cover art
const useFixCoverArt = <T extends AnyQueryWithSongs>(query: T) => {
const fetchAlbum = useFetchAlbum()
const songs = getSongs(query.data)
const albumIds = _.uniq((songs || []).map(s => s.albumId).filter((id): id is string => id !== undefined))
const coverArts = useQueries(
albumIds.map(id => ({
queryKey: qk.albumCoverArt(id),
queryFn: async (): Promise<AlbumCoverArt> => {
const res = await fetchAlbum(id)
return { albumId: res.album.id, coverArt: res.album.coverArt }
},
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isFetched'] as any,
})),
)
if (coverArts.every(c => c.isFetched)) {
return setSongCoverArt(query, coverArts)
}
return query
}
-52
View File
@@ -1,52 +0,0 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
const qk = {
starredItems: (id: string) => ['starredItems', id],
albumCoverArt: (id: string) => ['albumCoverArt', id],
artists: 'artists',
artist: (id: string) => ['artist', id],
artistInfo: (id: string) => ['artistInfo', id],
artistTopSongs: (artistName: string) => ['artistTopSongs', artistName],
playlists: 'playlists',
playlist: (id: string) => ['playlist', id],
album: (id: string) => ['album', id],
albumList: (type: GetAlbumList2TypeBase, size?: number) => {
const key: (string | number)[] = ['albumList', type]
size !== undefined && key.push(size)
return key
},
search: (query: string, artistCount?: number, albumCount?: number, songCount?: number) => [
'search',
query,
artistCount,
albumCount,
songCount,
],
coverArt: (coverArt?: string, size?: CacheImageSize) => {
const key: string[] = ['coverArt']
coverArt !== undefined && key.push(coverArt)
size !== undefined && key.push(size)
return key
},
artistArt: (artistId?: string, size?: CacheImageSize) => {
const key: string[] = ['artistArt']
artistId !== undefined && key.push(artistId)
size !== undefined && key.push(size)
return key
},
existingFiles: (type?: CacheItemTypeKey, itemId?: string) => {
const key: string[] = ['existingFiles']
type !== undefined && key.push(type)
itemId !== undefined && key.push(itemId)
return key
},
}
export default qk
+29
View File
@@ -0,0 +1,29 @@
import { useReset } from '@app/hooks/trackplayer'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import { useEffect } from 'react'
export const useSwitchActiveServer = () => {
const activeServer = useStore(selectSettings.activeServer)
const setActiveServer = useStore(selectSettings.setActiveServer)
const resetPlayer = useReset()
return async (id: string) => {
if (id === activeServer?.id) {
return
}
await resetPlayer()
setActiveServer(id)
}
}
export const useActiveServerRefresh = (refresh: () => void) => {
const activeServer = useStore(selectSettings.activeServer)
useEffect(() => {
if (activeServer) {
refresh()
}
}, [activeServer, refresh])
}
-68
View File
@@ -1,68 +0,0 @@
import { useReset } from '@app/hooks/trackplayer'
import { CacheItemTypeKey } from '@app/models/cache'
import queryClient from '@app/queryClient'
import { useStore, useStoreDeep } from '@app/state/store'
import { cacheDir } from '@app/util/fs'
import RNFS from 'react-native-fs'
import qk from './queryKeys'
export const useSwitchActiveServer = () => {
const activeServerId = useStore(store => store.settings.activeServerId)
const setActiveServer = useStore(store => store.setActiveServer)
const resetPlayer = useReset()
return async (id: string) => {
if (id === activeServerId) {
return
}
await queryClient.cancelQueries(undefined, { active: true })
await resetPlayer()
queryClient.removeQueries()
setActiveServer(id)
}
}
export const useFirstRun = () => {
return useStore(store => Object.keys(store.settings.servers).length === 0)
}
export const useResetImageCache = () => {
const serverIds = useStoreDeep(store => Object.keys(store.settings.servers))
const changeCacheBuster = useStore(store => store.changeCacheBuster)
return async () => {
// disable/invalidate queries
await Promise.all([
queryClient.cancelQueries(qk.artistArt(), { active: true }),
queryClient.cancelQueries(qk.coverArt(), { active: true }),
queryClient.cancelQueries(qk.existingFiles(), { active: true }),
queryClient.invalidateQueries(qk.artistArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.coverArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.existingFiles(), { refetchActive: false }),
])
// delete all images
const itemTypes: CacheItemTypeKey[] = ['artistArt', 'artistArtThumb', 'coverArt', 'coverArtThumb']
await Promise.all(
serverIds.flatMap(id =>
itemTypes.map(async type => {
const dir = cacheDir(id, type)
try {
await RNFS.unlink(dir)
} catch {}
}),
),
)
// change cacheBuster
changeCacheBuster()
// enable queries
await Promise.all([
queryClient.refetchQueries(qk.existingFiles(), { active: true }),
queryClient.refetchQueries(qk.artistArt(), { active: true }),
queryClient.refetchQueries(qk.coverArt(), { active: true }),
])
}
}
+6 -99
View File
@@ -1,14 +1,6 @@
import { Song } from '@app/models/library' import { useStore } from '@app/state/store'
import { QueueContextType, TrackExt } from '@app/models/trackplayer' import { getQueue, selectTrackPlayer, trackPlayerCommands } from '@app/state/trackplayer'
import queryClient from '@app/queryClient'
import { useStore, useStoreDeep } from '@app/state/store'
import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer'
import userAgent from '@app/util/userAgent'
import _ from 'lodash'
import TrackPlayer from 'react-native-track-player' import TrackPlayer from 'react-native-track-player'
import { useQueries } from 'react-query'
import { useFetchExistingFile, useFetchFile } from './fetch'
import qk from './queryKeys'
export const usePlay = () => { export const usePlay = () => {
return () => trackPlayerCommands.enqueue(() => TrackPlayer.play()) return () => trackPlayerCommands.enqueue(() => TrackPlayer.play())
@@ -65,7 +57,7 @@ export const useSeekTo = () => {
} }
export const useReset = (enqueue = true) => { export const useReset = (enqueue = true) => {
const resetStore = useStore(store => store.resetTrackPlayerState) const resetStore = useStore(selectTrackPlayer.resetTrackPlayerState)
const reset = async () => { const reset = async () => {
await TrackPlayer.reset() await TrackPlayer.reset()
@@ -76,9 +68,9 @@ export const useReset = (enqueue = true) => {
} }
export const useIsPlaying = (contextId: string | undefined, track: number) => { export const useIsPlaying = (contextId: string | undefined, track: number) => {
const queueContextId = useStore(store => store.queueContextId) const queueContextId = useStore(selectTrackPlayer.queueContextId)
const currentTrackIdx = useStore(store => store.currentTrackIdx) const currentTrackIdx = useStore(selectTrackPlayer.currentTrackIdx)
const shuffleOrder = useStoreDeep(store => store.shuffleOrder) const shuffleOrder = useStore(selectTrackPlayer.shuffleOrder)
if (contextId === undefined) { if (contextId === undefined) {
return track === currentTrackIdx return track === currentTrackIdx
@@ -91,88 +83,3 @@ export const useIsPlaying = (contextId: string | undefined, track: number) => {
return contextId === queueContextId && track === currentTrackIdx return contextId === queueContextId && track === currentTrackIdx
} }
export const useSetQueue = (type: QueueContextType, songs?: Song[]) => {
const _setQueue = useStore(store => store.setQueue)
const client = useStore(store => store.client)
const buildStreamUri = useStore(store => store.buildStreamUri)
const fetchFile = useFetchFile()
const fetchExistingFile = useFetchExistingFile()
const songCoverArt = _.uniq((songs || []).map(s => s.coverArt)).filter((c): c is string => c !== undefined)
const coverArtPaths = useQueries(
songCoverArt.map(coverArt => ({
queryKey: qk.coverArt(coverArt, 'thumbnail'),
queryFn: async () => {
if (!client) {
return
}
const itemType = 'coverArtThumb'
const existingCache = queryClient.getQueryData<string | undefined>(qk.existingFiles(itemType, coverArt))
if (existingCache) {
return existingCache
}
const existingDisk = await fetchExistingFile({ itemId: coverArt, itemType })
if (existingDisk) {
return existingDisk
}
const fromUrl = client.getCoverArtUri({ id: coverArt, size: '256' })
return await fetchFile({
itemType,
itemId: coverArt,
fromUrl,
expectedContentType: 'image',
})
},
enabled: !!client && !!songs,
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isFetched'] as any,
})),
)
const songCoverArtToPath = _.zipObject(
songCoverArt,
coverArtPaths.map(c => c.data),
)
const mapSongToTrackExt = (s: Song): TrackExt => {
let artwork = require('@res/fallback.png')
if (s.coverArt) {
const filePath = songCoverArtToPath[s.coverArt]
if (filePath) {
artwork = `file://${filePath}`
}
}
return {
id: s.id,
title: s.title,
artist: s.artist || 'Unknown Artist',
album: s.album || 'Unknown Album',
url: buildStreamUri(s.id),
userAgent,
artwork,
coverArt: s.coverArt,
duration: s.duration,
artistId: s.artistId,
albumId: s.albumId,
track: s.track,
discNumber: s.discNumber,
}
}
const contextId = `${type}-${songs?.map(s => s.id).join('-')}`
const setQueue = async (options: SetQueueOptions) => {
const queue = (songs || []).map(mapSongToTrackExt)
return await _setQueue({ queue, type, contextId, ...options })
}
return { setQueue, contextId, isReady: coverArtPaths.every(c => c.isFetched) }
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { Album, Playlist, Artist, Song } from './library' import { Album, PlaylistListItem, Artist, Song } from './music'
export enum CacheItemType { export enum CacheItemType {
coverArt = 'coverArt', coverArt = 'coverArt',
coverArtThumb = 'coverArtThumb', coverArtThumb = 'coverArtThumb',
artistArt = 'artistArt', artistArt = 'artistArt',
artistArtThumb = 'artistArtThumb', artistArtThumb = 'artistArtThumb',
song = 'song',
} }
export type CacheItemTypeKey = keyof typeof CacheItemType export type CacheItemTypeKey = keyof typeof CacheItemType
@@ -26,7 +27,7 @@ export type DownloadedAlbum = Album & {
songs: string[] songs: string[]
} }
export type DownloadedPlaylist = Playlist & { export type DownloadedPlaylist = PlaylistListItem & {
songs: string[] songs: string[]
} }
-85
View File
@@ -1,85 +0,0 @@
import {
AlbumID3Element,
ArtistID3Element,
ArtistInfo2Element,
ChildElement,
PlaylistElement,
} from '@app/subsonic/elements'
import { Album, Artist, ArtistInfo, Playlist, Song } from './library'
import { TrackExt } from './trackplayer'
export function mapArtist(artist: ArtistID3Element): Artist {
return {
itemType: 'artist',
id: artist.id,
name: artist.name,
starred: artist.starred?.getTime(),
coverArt: artist.coverArt,
}
}
export function mapArtistInfo(id: string, info: ArtistInfo2Element): ArtistInfo {
return {
id,
smallImageUrl: info.smallImageUrl,
largeImageUrl: info.largeImageUrl,
}
}
export function mapAlbum(album: AlbumID3Element): Album {
return {
itemType: 'album',
id: album.id,
name: album.name,
artist: album.artist,
artistId: album.artistId,
starred: album.starred?.getTime(),
coverArt: album.coverArt,
year: album.year,
}
}
export function mapPlaylist(playlist: PlaylistElement): Playlist {
return {
itemType: 'playlist',
id: playlist.id,
name: playlist.name,
comment: playlist.comment,
coverArt: playlist.coverArt,
}
}
export function mapSong(song: ChildElement): Song {
return {
itemType: 'song',
id: song.id,
album: song.album,
albumId: song.albumId,
artist: song.artist,
artistId: song.artistId,
title: song.title,
track: song.track,
discNumber: song.discNumber,
duration: song.duration,
starred: song.starred?.getTime(),
playCount: song.playCount,
averageRating: song.averageRating,
userRating: song.userRating,
}
}
export function mapTrackExtToSong(track: TrackExt): Song {
return {
itemType: 'song',
id: track.id,
title: track.title as string,
artist: track.artist,
album: track.album,
coverArt: track.coverArt,
duration: track.duration,
artistId: track.artistId,
albumId: track.albumId,
track: track.track,
discNumber: track.discNumber,
}
}
+33 -24
View File
@@ -2,28 +2,43 @@ export interface Artist {
itemType: 'artist' itemType: 'artist'
id: string id: string
name: string name: string
starred?: number starred?: Date
coverArt?: string coverArt?: string
} }
export interface ArtistInfo { export interface ArtistInfo extends Artist {
id: string albums: Album[]
smallImageUrl?: string smallImageUrl?: string
largeImageUrl?: string largeImageUrl?: string
topSongs: Song[]
} }
export interface Album { export interface AlbumListItem {
itemType: 'album' itemType: 'album'
id: string id: string
name: string name: string
artist?: string artist?: string
artistId?: string artistId?: string
starred?: number starred?: Date
coverArt?: string
}
export interface Album extends AlbumListItem {
coverArt?: string coverArt?: string
year?: number year?: number
} }
export interface Playlist { export interface AlbumWithSongs extends Album {
songs: Song[]
}
export interface SearchResults {
artists: Artist[]
albums: AlbumListItem[]
songs: Song[]
}
export interface PlaylistListItem {
itemType: 'playlist' itemType: 'playlist'
id: string id: string
name: string name: string
@@ -31,6 +46,10 @@ export interface Playlist {
coverArt?: string coverArt?: string
} }
export interface PlaylistWithSongs extends PlaylistListItem {
songs: Song[]
}
export interface Song { export interface Song {
itemType: 'song' itemType: 'song'
id: string id: string
@@ -42,24 +61,14 @@ export interface Song {
track?: number track?: number
discNumber?: number discNumber?: number
duration?: number duration?: number
starred?: number starred?: Date
coverArt?: string
playCount?: number
userRating?: number
averageRating?: number
}
export interface SearchResults { streamUri: string
artists: Artist[]
albums: Album[]
songs: Song[]
}
export type StarrableItemType = 'album' | 'song' | 'artist'
export type ListableItem = Album | Song | Artist | Playlist
export interface AlbumCoverArt {
albumId: string
coverArt?: string coverArt?: string
} }
export type ListableItem = Song | AlbumListItem | Artist | PlaylistListItem
export type HomeLists = { [key: string]: AlbumListItem[] }
export type StarrableItemType = 'song' | 'album' | 'artist'
+21 -11
View File
@@ -1,19 +1,9 @@
import { GetAlbumList2Type } from '@app/subsonic/params' import { GetAlbumList2Type } from '@app/subsonic/params'
export type Server = (TokenPassword | PlainPassword) & { export interface Server {
id: string id: string
address: string address: string
username: string username: string
usePlainPassword: boolean
}
interface PlainPassword {
usePlainPassword: true
plainPassword: string
}
interface TokenPassword {
usePlainPassword: false
token: string token: string
salt: string salt: string
} }
@@ -30,3 +20,23 @@ export type ArtistFilterType = 'random' | 'starred' | 'alphabeticalByName'
export interface ArtistFilterSettings { export interface ArtistFilterSettings {
type: ArtistFilterType type: ArtistFilterType
} }
export interface AppSettings {
servers: Server[]
screens: {
home: {
lists: string[]
}
library: {
albums: AlbumFilterSettings
artists: ArtistFilterSettings
}
}
activeServer?: string
scrobble: boolean
estimateContentLength: boolean
maxBitrateWifi: number
maxBitrateMobile: number
minBuffer: number
maxBuffer: number
}
-19
View File
@@ -1,19 +0,0 @@
export interface ById<T> {
[id: string]: T
}
export type OneToMany = ById<string[]>
export interface OrderedById<T> {
byId: ById<T>
allIds: string[]
}
export interface PaginatedList {
[offset: number]: string[]
}
export interface CollectionById<T extends { id: string }> {
byId: ById<T>
allIds: string[]
}
-18
View File
@@ -1,18 +0,0 @@
import { Track } from 'react-native-track-player'
export type TrackExt = Track & {
id: string
coverArt?: string
artistId?: string
albumId?: string
track?: number
discNumber?: number
}
export type Progress = {
position: number
duration: number
buffered: number
}
export type QueueContextType = 'album' | 'playlist' | 'song' | 'artist'
+4 -3
View File
@@ -1,6 +1,7 @@
import NowPlayingBar from '@app/components/NowPlayingBar' import NowPlayingBar from '@app/components/NowPlayingBar'
import PressableOpacity from '@app/components/PressableOpacity' import PressableOpacity from '@app/components/PressableOpacity'
import { useFirstRun } from '@app/hooks/settings' import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions' import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font' import font from '@app/styles/font'
@@ -9,7 +10,7 @@ import { BottomTabBarProps } from '@react-navigation/bottom-tabs'
import { BottomTabNavigationEventMap } from '@react-navigation/bottom-tabs/lib/typescript/src/types' import { BottomTabNavigationEventMap } from '@react-navigation/bottom-tabs/lib/typescript/src/types'
import { NavigationHelpers, ParamListBase } from '@react-navigation/native' import { NavigationHelpers, ParamListBase } from '@react-navigation/native'
import React from 'react' import React from 'react'
import { Image, ImageStyle, StyleSheet, Text, View } from 'react-native' import { StyleSheet, Text, View, Image, ImageStyle } from 'react-native'
const BottomTabButton = React.memo<{ const BottomTabButton = React.memo<{
routeKey: string routeKey: string
@@ -19,7 +20,7 @@ const BottomTabButton = React.memo<{
icon: OutlineFillIcon icon: OutlineFillIcon
navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap> navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap>
}>(({ routeKey, label, name, isFocused, icon, navigation }) => { }>(({ routeKey, label, name, isFocused, icon, navigation }) => {
const firstRun = useFirstRun() const firstRun = useStore(selectSettings.firstRun)
const disabled = firstRun && name !== 'settings' const disabled = firstRun && name !== 'settings'
+16 -16
View File
@@ -1,5 +1,3 @@
import { useFirstRun } from '@app/hooks/settings'
import { Album, Playlist } from '@app/models/library'
import BottomTabBar from '@app/navigation/BottomTabBar' import BottomTabBar from '@app/navigation/BottomTabBar'
import LibraryTopTabNavigator from '@app/navigation/LibraryTopTabNavigator' import LibraryTopTabNavigator from '@app/navigation/LibraryTopTabNavigator'
import ArtistView from '@app/screens/ArtistView' import ArtistView from '@app/screens/ArtistView'
@@ -10,6 +8,7 @@ import ServerView from '@app/screens/ServerView'
import SettingsView from '@app/screens/Settings' import SettingsView from '@app/screens/Settings'
import SongListView from '@app/screens/SongListView' import SongListView from '@app/screens/SongListView'
import WebViewScreen from '@app/screens/WebViewScreen' import WebViewScreen from '@app/screens/WebViewScreen'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store' import { useStore } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
@@ -21,9 +20,9 @@ import { createNativeStackNavigator, NativeStackNavigationProp } from 'react-nat
type TabStackParamList = { type TabStackParamList = {
main: undefined main: undefined
album: { id: string; title: string; album?: Album } album: { id: string; title: string }
artist: { id: string; title: string } artist: { id: string; title: string }
playlist: { id: string; title: string; playlist?: Playlist } playlist: { id: string; title: string }
results: { query: string; type: 'album' | 'song' | 'artist' } results: { query: string; type: 'album' | 'song' | 'artist' }
} }
@@ -34,7 +33,9 @@ type AlbumScreenProps = {
navigation: AlbumScreenNavigationProp navigation: AlbumScreenNavigationProp
} }
const AlbumScreen: React.FC<AlbumScreenProps> = ({ route }) => <SongListView {...route.params} type="album" /> const AlbumScreen: React.FC<AlbumScreenProps> = ({ route }) => (
<SongListView id={route.params.id} title={route.params.title} type="album" />
)
type ArtistScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'artist'> type ArtistScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'artist'>
type ArtistScreenRouteProp = RouteProp<TabStackParamList, 'artist'> type ArtistScreenRouteProp = RouteProp<TabStackParamList, 'artist'>
@@ -43,7 +44,9 @@ type ArtistScreenProps = {
navigation: ArtistScreenNavigationProp navigation: ArtistScreenNavigationProp
} }
const ArtistScreen: React.FC<ArtistScreenProps> = ({ route }) => <ArtistView {...route.params} /> const ArtistScreen: React.FC<ArtistScreenProps> = ({ route }) => (
<ArtistView id={route.params.id} title={route.params.title} />
)
type PlaylistScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'playlist'> type PlaylistScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'playlist'>
type PlaylistScreenRouteProp = RouteProp<TabStackParamList, 'playlist'> type PlaylistScreenRouteProp = RouteProp<TabStackParamList, 'playlist'>
@@ -52,7 +55,9 @@ type PlaylistScreenProps = {
navigation: PlaylistScreenNavigationProp navigation: PlaylistScreenNavigationProp
} }
const PlaylistScreen: React.FC<PlaylistScreenProps> = ({ route }) => <SongListView {...route.params} type="playlist" /> const PlaylistScreen: React.FC<PlaylistScreenProps> = ({ route }) => (
<SongListView id={route.params.id} title={route.params.title} type="playlist" />
)
type ResultsScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'results'> type ResultsScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'results'>
type ResultsScreenRouteProp = RouteProp<TabStackParamList, 'results'> type ResultsScreenRouteProp = RouteProp<TabStackParamList, 'results'>
@@ -61,7 +66,9 @@ type ResultsScreenProps = {
navigation: ResultsScreenNavigationProp navigation: ResultsScreenNavigationProp
} }
const ResultsScreen: React.FC<ResultsScreenProps> = ({ route }) => <SearchResultsView {...route.params} /> const ResultsScreen: React.FC<ResultsScreenProps> = ({ route }) => (
<SearchResultsView query={route.params.query} type={route.params.type} />
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
stackheaderStyle: { stackheaderStyle: {
@@ -170,20 +177,13 @@ const SettingsTab = () => {
const Tab = createBottomTabNavigator() const Tab = createBottomTabNavigator()
const BottomTabNavigator = () => { const BottomTabNavigator = () => {
const firstRun = useFirstRun() const firstRun = useStore(selectSettings.firstRun)
const resetServer = useStore(store => store.resetServer)
return ( return (
<Tab.Navigator tabBar={BottomTabBar} initialRouteName={firstRun ? 'settings' : 'home'}> <Tab.Navigator tabBar={BottomTabBar} initialRouteName={firstRun ? 'settings' : 'home'}>
{resetServer ? (
<></>
) : (
<>
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: 'Home' }} /> <Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: 'Home' }} />
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: 'Library' }} /> <Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: 'Library' }} />
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: 'Search' }} /> <Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: 'Search' }} />
</>
)}
<Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: 'Settings' }} /> <Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: 'Settings' }} />
</Tab.Navigator> </Tab.Navigator>
) )
+4 -8
View File
@@ -6,18 +6,14 @@ import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font' import font from '@app/styles/font'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs' import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import React from 'react' import React from 'react'
import { StyleSheet } from 'react-native' import { StatusBar, StyleSheet } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const Tab = createMaterialTopTabNavigator() const Tab = createMaterialTopTabNavigator()
const LibraryTopTabNavigator = () => { const LibraryTopTabNavigator = () => (
const marginTop = useSafeAreaInsets().top
return (
<Tab.Navigator <Tab.Navigator
tabBarOptions={{ tabBarOptions={{
style: [styles.tabBar, { marginTop }], style: styles.tabBar,
labelStyle: styles.tablabelStyle, labelStyle: styles.tablabelStyle,
indicatorStyle: styles.tabindicatorStyle, indicatorStyle: styles.tabindicatorStyle,
}} }}
@@ -27,11 +23,11 @@ const LibraryTopTabNavigator = () => {
<Tab.Screen name="playlists" component={PlaylistsTab} options={{ tabBarLabel: 'Playlists' }} /> <Tab.Screen name="playlists" component={PlaylistsTab} options={{ tabBarLabel: 'Playlists' }} />
</Tab.Navigator> </Tab.Navigator>
) )
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
tabBar: { tabBar: {
height: dimensions.header, height: dimensions.header,
marginTop: StatusBar.currentHeight,
backgroundColor: colors.gradient.high, backgroundColor: colors.gradient.high,
elevation: 0, elevation: 0,
justifyContent: 'center', justifyContent: 'center',
+10 -20
View File
@@ -1,4 +1,4 @@
import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer' import { getCurrentTrack, getPlayerState, TrackExt, trackPlayerCommands } from '@app/state/trackplayer'
import TrackPlayer, { Event, State } from 'react-native-track-player' import TrackPlayer, { Event, State } from 'react-native-track-player'
import { useStore } from './state/store' import { useStore } from './state/store'
import { unstable_batchedUpdates } from 'react-native' import { unstable_batchedUpdates } from 'react-native'
@@ -28,15 +28,9 @@ const setNetState = (netState: 'mobile' | 'wifi') => {
}) })
} }
const rebuildQueue = (forcePlay?: boolean) => { const rebuildQueue = () => {
unstable_batchedUpdates(() => { unstable_batchedUpdates(() => {
useStore.getState().rebuildQueue(forcePlay) useStore.getState().rebuildQueue(useStore.getState().playerState === State.Playing)
})
}
const setDuckPaused = (duckPaused: boolean) => {
unstable_batchedUpdates(() => {
useStore.getState().setDuckPaused(duckPaused)
}) })
} }
@@ -44,12 +38,13 @@ let serviceCreated = false
const createService = async () => { const createService = async () => {
useStore.subscribe( useStore.subscribe(
state => state.currentTrack?.id, (currentTrack?: TrackExt) => {
(currentTrackId?: string) => { if (currentTrack) {
if (currentTrackId) { useStore.getState().scrobbleTrack(currentTrack.id)
useStore.getState().scrobbleTrack(currentTrackId)
} }
}, },
state => state.currentTrack,
(prev, next) => prev?.id === next?.id,
) )
NetInfo.fetch().then(state => { NetInfo.fetch().then(state => {
@@ -86,14 +81,9 @@ const createService = async () => {
} }
if (data.paused) { if (data.paused) {
let state = useStore.getState().playerState
if (state === State.Playing || state === State.Buffering || state === State.Connecting) {
trackPlayerCommands.enqueue(TrackPlayer.pause) trackPlayerCommands.enqueue(TrackPlayer.pause)
setDuckPaused(true) } else {
}
} else if (useStore.getState().duckPaused) {
trackPlayerCommands.enqueue(TrackPlayer.play) trackPlayerCommands.enqueue(TrackPlayer.play)
setDuckPaused(false)
} }
}) })
@@ -139,7 +129,7 @@ const createService = async () => {
// fix for ExoPlayer aborting playback while esimating content length // fix for ExoPlayer aborting playback while esimating content length
if (code === 'playback-source' && message.includes('416')) { if (code === 'playback-source' && message.includes('416')) {
rebuildQueue(true) rebuildQueue()
} }
}) })
} }
-5
View File
@@ -1,5 +0,0 @@
import { QueryClient } from 'react-query'
const client = new QueryClient()
export default client
+32 -56
View File
@@ -5,15 +5,15 @@ import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header' import Header from '@app/components/Header'
import HeaderBar from '@app/components/HeaderBar' import HeaderBar from '@app/components/HeaderBar'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import { useQueryArtist, useQueryArtistTopSongs } from '@app/hooks/query' import { useArtistInfo } from '@app/hooks/music'
import { useSetQueue } from '@app/hooks/trackplayer' import { Album, Song } from '@app/models/music'
import { Album, Song } from '@app/models/library' import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/trackplayer'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions' import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font' import font from '@app/styles/font'
import { useLayout } from '@react-native-community/hooks' import { useLayout } from '@react-native-community/hooks'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react' import React from 'react'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
@@ -32,63 +32,40 @@ const AlbumItem = React.memo<{
return ( return (
<AlbumContextPressable <AlbumContextPressable
album={album} album={album}
onPress={() => navigation.navigate('album', { id: album.id, title: album.name, album })} onPress={() => navigation.navigate('album', { id: album.id, title: album.name })}
menuStyle={[styles.albumItem, { width }]} menuStyle={[styles.albumItem, { width }]}
triggerOuterWrapperStyle={{ width }}> triggerOuterWrapperStyle={{ width }}>
<CoverArt type="cover" coverArt={album.coverArt} style={{ height, width }} resizeMode="cover" size="thumbnail" /> <CoverArt type="cover" coverArt={album.coverArt} style={{ height, width }} resizeMode={'cover'} />
<Text style={styles.albumTitle}>{album.name}</Text> <Text style={styles.albumTitle}>{album.name}</Text>
<Text style={styles.albumYear}> {album.year ? album.year : ''}</Text> <Text style={styles.albumYear}> {album.year ? album.year : ''}</Text>
</AlbumContextPressable> </AlbumContextPressable>
) )
}, equal) })
const TopSongs = React.memo<{ const TopSongs = React.memo<{
songs: Song[] songs: Song[]
name: string name: string
}>(({ songs, name }) => { artistId: string
const { setQueue, isReady, contextId } = useSetQueue('artist', songs) }>(({ songs, name, artistId }) => {
const setQueue = useStore(selectTrackPlayer.setQueue)
return ( return (
<> <>
<Header>Top Songs</Header> <Header>Top Songs</Header>
{songs.slice(0, 5).map((s, i) => ( {songs.map((s, i) => (
<ListItem <ListItem
key={i} key={i}
item={s} item={s}
contextId={contextId} contextId={artistId}
queueId={i} queueId={i}
showArt={true} showArt={true}
subtitle={s.album} subtitle={s.album}
onPress={() => setQueue({ title: name, playTrack: i })} onPress={() => setQueue(songs, name, 'artist', artistId, i)}
disabled={!isReady}
/> />
))} ))}
</> </>
) )
}, equal) })
const ArtistAlbums = React.memo<{
albums: Album[]
}>(({ albums }) => {
const albumsLayout = useLayout()
const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
return (
<>
<Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))}
</View>
</>
)
}, equal)
const ArtistViewFallback = React.memo(() => ( const ArtistViewFallback = React.memo(() => (
<GradientBackground style={styles.fallback}> <GradientBackground style={styles.fallback}>
@@ -97,9 +74,8 @@ const ArtistViewFallback = React.memo(() => (
)) ))
const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) => { const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) => {
const { data: artistData } = useQueryArtist(id) const artist = useArtistInfo(id)
const { data: topSongs, isError } = useQueryArtistTopSongs(artistData?.artist?.name) const albumsLayout = useLayout()
const coverLayout = useLayout() const coverLayout = useLayout()
const headerOpacity = useSharedValue(0) const headerOpacity = useSharedValue(0)
@@ -115,11 +91,15 @@ const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) =>
} }
}) })
if (!artistData) { const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
if (!artist) {
return <ArtistViewFallback /> return <ArtistViewFallback />
} }
const { artist, albums } = artistData const _albums = [...artist.albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -130,23 +110,22 @@ const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) =>
style={styles.scroll} style={styles.scroll}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
onScroll={onScroll}> onScroll={onScroll}>
<CoverArt type="artist" size="original" artistId={artist.id} style={styles.artistCover} resizeMode="cover" /> <CoverArt type="artist" size="original" artistId={artist.id} style={styles.artistCover} resizeMode={'cover'} />
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={styles.title}>{artist.name}</Text> <Text style={styles.title}>{artist.name}</Text>
</View> </View>
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
{(topSongs || isError) && artist ? ( {artist.topSongs.length > 0 ? (
topSongs && topSongs.length > 0 ? ( <TopSongs songs={artist.topSongs} name={artist.name} artistId={artist.id} />
<>
<TopSongs songs={topSongs} name={artist.name} />
<ArtistAlbums albums={albums} />
</>
) : ( ) : (
<ArtistAlbums albums={albums} /> <></>
)
) : (
<ActivityIndicator size="large" color={colors.accent} style={styles.loading} />
)} )}
<Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{_albums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))}
</View>
</View> </View>
</GradientScrollView> </GradientScrollView>
</View> </View>
@@ -221,9 +200,6 @@ const styles = StyleSheet.create({
fontFamily: font.regular, fontFamily: font.regular,
textAlign: 'center', textAlign: 'center',
}, },
loading: {
marginTop: 30,
},
}) })
export default ArtistView export default ArtistView
+42 -32
View File
@@ -3,17 +3,17 @@ import CoverArt from '@app/components/CoverArt'
import GradientScrollView from '@app/components/GradientScrollView' import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header' import Header from '@app/components/Header'
import NothingHere from '@app/components/NothingHere' import NothingHere from '@app/components/NothingHere'
import { useQueryHomeLists } from '@app/hooks/query' import { useActiveServerRefresh } from '@app/hooks/server'
import { Album } from '@app/models/library' import { AlbumListItem } from '@app/models/music'
import { useStoreDeep } from '@app/state/store' import { selectMusic } from '@app/state/music'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { GetAlbumList2TypeBase, GetAlbumListType } from '@app/subsonic/params' import { GetAlbumListType } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react' import React, { useCallback } from 'react'
import React from 'react' import { RefreshControl, ScrollView, StatusBar, StyleSheet, Text, View } from 'react-native'
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const titles: { [key in GetAlbumListType]?: string } = { const titles: { [key in GetAlbumListType]?: string } = {
recent: 'Recently Played', recent: 'Recently Played',
@@ -23,7 +23,7 @@ const titles: { [key in GetAlbumListType]?: string } = {
} }
const AlbumItem = React.memo<{ const AlbumItem = React.memo<{
album: Album album: AlbumListItem
}>(({ album }) => { }>(({ album }) => {
const navigation = useNavigation() const navigation = useNavigation()
@@ -31,13 +31,12 @@ const AlbumItem = React.memo<{
<AlbumContextPressable <AlbumContextPressable
album={album} album={album}
triggerWrapperStyle={styles.item} triggerWrapperStyle={styles.item}
onPress={() => navigation.navigate('album', { id: album.id, title: album.name, album })}> onPress={() => navigation.navigate('album', { id: album.id, title: album.name })}>
<CoverArt <CoverArt
type="cover" type="cover"
coverArt={album.coverArt} coverArt={album.coverArt}
style={{ height: styles.item.width, width: styles.item.width }} style={{ height: styles.item.width, width: styles.item.width }}
resizeMode="cover" resizeMode={'cover'}
size="thumbnail"
/> />
<Text style={styles.title} numberOfLines={1}> <Text style={styles.title} numberOfLines={1}>
{album.name} {album.name}
@@ -47,12 +46,12 @@ const AlbumItem = React.memo<{
</Text> </Text>
</AlbumContextPressable> </AlbumContextPressable>
) )
}, equal) })
const Category = React.memo<{ const Category = React.memo<{
type: string name?: string
albums: Album[] data: AlbumListItem[]
}>(({ type, albums }) => { }>(({ name, data }) => {
const Albums = () => ( const Albums = () => (
<ScrollView <ScrollView
horizontal={true} horizontal={true}
@@ -60,8 +59,8 @@ const Category = React.memo<{
overScrollMode={'never'} overScrollMode={'never'}
style={styles.artScroll} style={styles.artScroll}
contentContainerStyle={styles.artScrollContent}> contentContainerStyle={styles.artScrollContent}>
{albums.map(a => ( {data.map(album => (
<AlbumItem key={a.id} album={a} /> <AlbumItem key={album.id} album={album} />
))} ))}
</ScrollView> </ScrollView>
) )
@@ -74,34 +73,42 @@ const Category = React.memo<{
return ( return (
<View style={styles.category}> <View style={styles.category}>
<Header style={styles.header}>{titles[type as GetAlbumListType] || ''}</Header> <Header style={styles.header}>{name}</Header>
{albums.length > 0 ? <Albums /> : <Nothing />} {data.length > 0 ? <Albums /> : <Nothing />}
</View> </View>
) )
}, equal) })
const Home = () => { const Home = () => {
const types = useStoreDeep(store => store.settings.screens.home.listTypes) const types = useStore(selectSettings.homeLists)
const listQueries = useQueryHomeLists(types as GetAlbumList2TypeBase[], 20) const lists = useStore(selectMusic.homeLists)
const paddingTop = useSafeAreaInsets().top const updating = useStore(selectMusic.homeListsUpdating)
const update = useStore(selectMusic.fetchHomeLists)
const clear = useStore(selectMusic.clearHomeLists)
useActiveServerRefresh(
useCallback(() => {
clear()
update()
}, [clear, update]),
)
return ( return (
<GradientScrollView <GradientScrollView
style={styles.scroll} style={styles.scroll}
contentContainerStyle={{ paddingTop }} contentContainerStyle={styles.scrollContentContainer}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={listQueries.some(q => q.isLoading)} refreshing={updating}
onRefresh={() => listQueries.forEach(q => q.refetch())} onRefresh={update}
colors={[colors.accent, colors.accentLow]} colors={[colors.accent, colors.accentLow]}
progressViewOffset={paddingTop} progressViewOffset={StatusBar.currentHeight}
/> />
}> }>
<View style={styles.content}> <View style={styles.content}>
{types.map(type => { {types.map(type => (
const query = listQueries.find(list => list.data?.type === type) <Category key={type} name={titles[type as GetAlbumListType]} data={type in lists ? lists[type] : []} />
return <Category key={type} type={type} albums={query?.data?.albums || []} /> ))}
})}
</View> </View>
</GradientScrollView> </GradientScrollView>
) )
@@ -111,6 +118,9 @@ const styles = StyleSheet.create({
scroll: { scroll: {
flex: 1, flex: 1,
}, },
scrollContentContainer: {
paddingTop: StatusBar.currentHeight,
},
content: { content: {
paddingBottom: 20, paddingBottom: 20,
}, },
+21 -28
View File
@@ -2,41 +2,32 @@ import { AlbumContextPressable } from '@app/components/ContextMenu'
import CoverArt from '@app/components/CoverArt' import CoverArt from '@app/components/CoverArt'
import FilterButton, { OptionData } from '@app/components/FilterButton' import FilterButton, { OptionData } from '@app/components/FilterButton'
import GradientFlatList from '@app/components/GradientFlatList' import GradientFlatList from '@app/components/GradientFlatList'
import { useQueryAlbumList } from '@app/hooks/query' import { useFetchPaginatedList } from '@app/hooks/list'
import { Album } from '@app/models/library' import { Album, AlbumListItem } from '@app/models/music'
import { useStore, useStoreDeep } from '@app/state/store' import { selectMusic } from '@app/state/music'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { GetAlbumList2Type, GetAlbumList2TypeBase } from '@app/subsonic/params' import { GetAlbumList2Type } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react' import React, { useEffect } from 'react'
import React from 'react'
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native' import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'
const AlbumItem = React.memo<{ const AlbumItem = React.memo<{
album: Album album: AlbumListItem
size: number size: number
height: number height: number
}>(({ album, size, height }) => { }>(({ album, size, height }) => {
const navigation = useNavigation() const navigation = useNavigation()
if (!album) {
return <></>
}
return ( return (
<AlbumContextPressable <AlbumContextPressable
album={album} album={album}
menuStyle={[styles.itemMenu, { width: size }]} menuStyle={[styles.itemMenu, { width: size }]}
triggerWrapperStyle={[styles.itemWrapper, { height }]} triggerWrapperStyle={[styles.itemWrapper, { height }]}
onPress={() => navigation.navigate('album', { id: album.id, title: album.name, album })}> onPress={() => navigation.navigate('album', { id: album.id, title: album.name })}>
<CoverArt <CoverArt type="cover" coverArt={album.coverArt} style={{ height: size, width: size }} resizeMode={'cover'} />
type="cover"
coverArt={album.coverArt}
style={{ height: size, width: size }}
resizeMode="cover"
size="thumbnail"
/>
<View style={styles.itemDetails}> <View style={styles.itemDetails}>
<Text style={styles.title} numberOfLines={1}> <Text style={styles.title} numberOfLines={1}>
{album.name} {album.name}
@@ -47,7 +38,7 @@ const AlbumItem = React.memo<{
</View> </View>
</AlbumContextPressable> </AlbumContextPressable>
) )
}, equal) })
const AlbumListRenderItem: React.FC<{ const AlbumListRenderItem: React.FC<{
item: { album: Album; size: number; height: number } item: { album: Album; size: number; height: number }
@@ -66,28 +57,30 @@ const filterOptions: OptionData[] = [
] ]
const AlbumsList = () => { const AlbumsList = () => {
const filter = useStoreDeep(store => store.settings.screens.library.albumsFilter) const fetchAlbums = useStore(selectMusic.fetchAlbums)
const setFilter = useStore(store => store.setLibraryAlbumFilter) const { list, refreshing, refresh, reset, fetchNextPage } = useFetchPaginatedList(fetchAlbums, 300)
const filter = useStore(selectSettings.libraryAlbumFilter)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filter.type as GetAlbumList2TypeBase, 300) const setFilter = useStore(selectSettings.setLibraryAlbumFilter)
const layout = useWindowDimensions() const layout = useWindowDimensions()
const size = layout.width / 3 - styles.itemWrapper.marginHorizontal * 2 const size = layout.width / 3 - styles.itemWrapper.marginHorizontal * 2
const height = size + 36 const height = size + 36
useEffect(() => reset(), [reset, filter])
return ( return (
<View style={styles.container}> <View style={styles.container}>
<GradientFlatList <GradientFlatList
data={data ? data.pages.flatMap(albums => albums.map(album => ({ album, size, height }))) : []} data={list.map(album => ({ album, size, height }))}
renderItem={AlbumListRenderItem} renderItem={AlbumListRenderItem}
keyExtractor={item => item.album.id} keyExtractor={item => item.album.id}
numColumns={3} numColumns={3}
removeClippedSubviews={true} removeClippedSubviews={true}
refreshing={isLoading} refreshing={refreshing}
onRefresh={refetch} onRefresh={refresh}
overScrollMode="never" overScrollMode="never"
onEndReached={() => fetchNextPage()} onEndReached={fetchNextPage}
onEndReachedThreshold={6} onEndReachedThreshold={6}
windowSize={5} windowSize={5}
/> />
+12 -19
View File
@@ -1,10 +1,12 @@
import FilterButton, { OptionData } from '@app/components/FilterButton' import FilterButton, { OptionData } from '@app/components/FilterButton'
import GradientFlatList from '@app/components/GradientFlatList' import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import { useQueryArtists } from '@app/hooks/query' import { useFetchList } from '@app/hooks/list'
import { Artist } from '@app/models/library' import { Artist } from '@app/models/music'
import { ArtistFilterType } from '@app/models/settings' import { ArtistFilterType } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store' import { selectMusic } from '@app/state/music'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { StyleSheet, View } from 'react-native' import { StyleSheet, View } from 'react-native'
@@ -19,19 +21,13 @@ const filterOptions: OptionData[] = [
] ]
const ArtistsList = () => { const ArtistsList = () => {
const filter = useStoreDeep(store => store.settings.screens.library.artistsFilter) const fetchArtists = useStore(selectMusic.fetchArtists)
const setFilter = useStore(store => store.setLibraryArtistFiler) const { list, refreshing, refresh } = useFetchList(fetchArtists)
const filter = useStore(selectSettings.libraryArtistFilter)
const { isLoading, data, refetch } = useQueryArtists() const setFilter = useStore(selectSettings.setLibraryArtistFiler)
const [sortedList, setSortedList] = useState<Artist[]>([]) const [sortedList, setSortedList] = useState<Artist[]>([])
useEffect(() => { useEffect(() => {
if (!data) {
setSortedList([])
return
}
const list = Object.values(data.byId)
switch (filter.type) { switch (filter.type) {
case 'random': case 'random':
setSortedList([...list].sort(() => Math.random() - 0.5)) setSortedList([...list].sort(() => Math.random() - 0.5))
@@ -39,14 +35,11 @@ const ArtistsList = () => {
case 'starred': case 'starred':
setSortedList([...list].filter(a => a.starred)) setSortedList([...list].filter(a => a.starred))
break break
case 'alphabeticalByName':
setSortedList(data.allIds.map(id => data.byId[id]))
break
default: default:
setSortedList([...list]) setSortedList([...list])
break break
} }
}, [filter.type, data]) }, [list, filter])
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -54,8 +47,8 @@ const ArtistsList = () => {
data={sortedList} data={sortedList}
renderItem={ArtistRenderItem} renderItem={ArtistRenderItem}
keyExtractor={item => item.id} keyExtractor={item => item.id}
onRefresh={refetch} onRefresh={refresh}
refreshing={isLoading} refreshing={refreshing}
overScrollMode="never" overScrollMode="never"
windowSize={3} windowSize={3}
contentMarginTop={6} contentMarginTop={6}
+10 -8
View File
@@ -1,25 +1,27 @@
import GradientFlatList from '@app/components/GradientFlatList' import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import { useQueryPlaylists } from '@app/hooks/query' import { useFetchList } from '@app/hooks/list'
import { Playlist } from '@app/models/library' import { PlaylistListItem } from '@app/models/music'
import { mapById } from '@app/util/state' import { selectMusic } from '@app/state/music'
import { useStore } from '@app/state/store'
import React from 'react' import React from 'react'
import { StyleSheet } from 'react-native' import { StyleSheet } from 'react-native'
const PlaylistRenderItem: React.FC<{ item: Playlist }> = ({ item }) => ( const PlaylistRenderItem: React.FC<{ item: PlaylistListItem }> = ({ item }) => (
<ListItem item={item} showArt={true} showStar={false} listStyle="big" style={styles.listItem} /> <ListItem item={item} showArt={true} showStar={false} listStyle="big" style={styles.listItem} />
) )
const PlaylistsList = () => { const PlaylistsList = () => {
const { isLoading, data, refetch } = useQueryPlaylists() const fetchPlaylists = useStore(selectMusic.fetchPlaylists)
const { list, refreshing, refresh } = useFetchList(fetchPlaylists)
return ( return (
<GradientFlatList <GradientFlatList
data={data ? mapById(data?.byId, data?.allIds) : []} data={list}
renderItem={PlaylistRenderItem} renderItem={PlaylistRenderItem}
keyExtractor={item => item.id} keyExtractor={item => item.id}
onRefresh={refetch} onRefresh={refresh}
refreshing={isLoading} refreshing={refreshing}
overScrollMode="never" overScrollMode="never"
windowSize={5} windowSize={5}
contentMarginTop={6} contentMarginTop={6}
+6 -4
View File
@@ -2,9 +2,10 @@ import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import NowPlayingBar from '@app/components/NowPlayingBar' import NowPlayingBar from '@app/components/NowPlayingBar'
import { useSkipTo } from '@app/hooks/trackplayer' import { useSkipTo } from '@app/hooks/trackplayer'
import { Song } from '@app/models/library' import { Song } from '@app/models/music'
import { mapTrackExtToSong } from '@app/models/map' import { useStore } from '@app/state/store'
import { useStoreDeep } from '@app/state/store' import { selectTrackPlayer } from '@app/state/trackplayer'
import { selectTrackPlayerMap } from '@app/state/trackplayermap'
import React from 'react' import React from 'react'
import { StyleSheet, View } from 'react-native' import { StyleSheet, View } from 'react-native'
@@ -26,7 +27,8 @@ const SongRenderItem: React.FC<{
) )
const NowPlayingQueue = React.memo<{}>(() => { const NowPlayingQueue = React.memo<{}>(() => {
const queue = useStoreDeep(store => store.queue) const queue = useStore(selectTrackPlayer.queue)
const mapTrackExtToSong = useStore(selectTrackPlayerMap.mapTrackExtToSong)
const skipTo = useSkipTo() const skipTo = useSkipTo()
return ( return (
+36 -29
View File
@@ -2,11 +2,13 @@ import CoverArt from '@app/components/CoverArt'
import HeaderBar from '@app/components/HeaderBar' import HeaderBar from '@app/components/HeaderBar'
import ImageGradientBackground from '@app/components/ImageGradientBackground' import ImageGradientBackground from '@app/components/ImageGradientBackground'
import PressableOpacity from '@app/components/PressableOpacity' import PressableOpacity from '@app/components/PressableOpacity'
import { PressableStar } from '@app/components/Star' import Star from '@app/components/Star'
import { useStarred } from '@app/hooks/music'
import { useNext, usePause, usePlay, usePrevious, useSeekTo } from '@app/hooks/trackplayer' import { useNext, usePause, usePlay, usePrevious, useSeekTo } from '@app/hooks/trackplayer'
import { mapTrackExtToSong } from '@app/models/map' import { selectMusic } from '@app/state/music'
import { QueueContextType, TrackExt } from '@app/models/trackplayer' import { useStore } from '@app/state/store'
import { useStore, useStoreDeep } from '@app/state/store' import { QueueContextType, selectTrackPlayer, TrackExt } from '@app/state/trackplayer'
import { selectTrackPlayerMap } from '@app/state/trackplayermap'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import formatDuration from '@app/util/formatDuration' import formatDuration from '@app/util/formatDuration'
@@ -39,8 +41,9 @@ function getContextName(type?: QueueContextType) {
const NowPlayingHeader = React.memo<{ const NowPlayingHeader = React.memo<{
track?: TrackExt track?: TrackExt
}>(({ track }) => { }>(({ track }) => {
const queueName = useStore(store => store.queueName) const queueName = useStore(selectTrackPlayer.queueName)
const queueContextType = useStore(store => store.queueContextType) const queueContextType = useStore(selectTrackPlayer.queueContextType)
const mapTrackExtToSong = useStore(selectTrackPlayerMap.mapTrackExtToSong)
if (!track) { if (!track) {
return <></> return <></>
@@ -91,11 +94,11 @@ const headerStyles = StyleSheet.create({
}) })
const SongCoverArt = () => { const SongCoverArt = () => {
const coverArt = useStore(store => store.currentTrack?.coverArt) const track = useStore(selectTrackPlayer.currentTrack)
return ( return (
<View style={coverArtStyles.container}> <View style={coverArtStyles.container}>
<CoverArt type="cover" size="original" coverArt={coverArt} style={coverArtStyles.image} /> <CoverArt type="cover" size="original" coverArt={track?.coverArt} style={coverArtStyles.image} />
</View> </View>
) )
} }
@@ -114,22 +117,26 @@ const coverArtStyles = StyleSheet.create({
}) })
const SongInfo = () => { const SongInfo = () => {
const id = useStore(store => store.currentTrack?.id) const track = useStore(selectTrackPlayer.currentTrack)
const artist = useStore(store => store.currentTrack?.artist) const id = track?.id || '-1'
const title = useStore(store => store.currentTrack?.title) const type = 'song'
const starred = useStarred(id, type)
const setStarred = useStore(selectMusic.starItem)
return ( return (
<View style={infoStyles.container}> <View style={infoStyles.container}>
<View style={infoStyles.details}> <View style={infoStyles.details}>
<Text numberOfLines={1} style={infoStyles.title}> <Text numberOfLines={1} style={infoStyles.title}>
{title} {track?.title}
</Text> </Text>
<Text numberOfLines={1} style={infoStyles.artist}> <Text numberOfLines={1} style={infoStyles.artist}>
{artist} {track?.artist}
</Text> </Text>
</View> </View>
<View style={infoStyles.controls}> <View style={infoStyles.controls}>
<PressableStar id={id || '-1'} type={'song'} size={32} /> <PressableOpacity onPress={() => setStarred(id, type, starred)}>
<Star size={32} starred={starred} />
</PressableOpacity>
</View> </View>
</View> </View>
) )
@@ -149,13 +156,13 @@ const infoStyles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
title: { title: {
minHeight: 30, height: 28,
fontFamily: font.bold, fontFamily: font.bold,
fontSize: 22, fontSize: 22,
color: colors.text.primary, color: colors.text.primary,
}, },
artist: { artist: {
minHeight: 21, height: 20,
fontFamily: font.regular, fontFamily: font.regular,
fontSize: 16, fontSize: 16,
color: colors.text.secondary, color: colors.text.secondary,
@@ -163,8 +170,7 @@ const infoStyles = StyleSheet.create({
}) })
const SeekBar = () => { const SeekBar = () => {
const position = useStore(store => store.progress.position) const { position, duration } = useStore(selectTrackPlayer.progress)
const duration = useStore(store => store.progress.duration)
const seekTo = useSeekTo() const seekTo = useSeekTo()
const [value, setValue] = useState(0) const [value, setValue] = useState(0)
const [sliding, setSliding] = useState(false) const [sliding, setSliding] = useState(false)
@@ -256,15 +262,15 @@ const seekStyles = StyleSheet.create({
}) })
const PlayerControls = () => { const PlayerControls = () => {
const state = useStore(store => store.playerState) const state = useStore(selectTrackPlayer.playerState)
const play = usePlay() const play = usePlay()
const pause = usePause() const pause = usePause()
const next = useNext() const next = useNext()
const previous = usePrevious() const previous = usePrevious()
const shuffled = useStore(store => !!store.shuffleOrder) const shuffled = useStore(selectTrackPlayer.shuffled)
const toggleShuffle = useStore(store => store.toggleShuffle) const toggleShuffle = useStore(selectTrackPlayer.toggleShuffle)
const repeatMode = useStore(store => store.repeatMode) const repeatMode = useStore(selectTrackPlayer.repeatMode)
const toggleRepeat = useStore(store => store.toggleRepeatMode) const toggleRepeat = useStore(selectTrackPlayer.toggleRepeatMode)
const navigation = useNavigation() const navigation = useNavigation()
let playPauseIcon: string let playPauseIcon: string
@@ -330,9 +336,9 @@ const PlayerControls = () => {
</View> </View>
</View> </View>
<View style={controlsStyles.bottom}> <View style={controlsStyles.bottom}>
{/* <PressableOpacity onPress={undefined} disabled={true} hitSlop={16}> <PressableOpacity onPress={undefined} disabled={true} hitSlop={16}>
<IconMatCom name="cast-audio" size={20} color="white" /> <IconMatCom name="cast-audio" size={20} color="white" />
</PressableOpacity> */} </PressableOpacity>
<PressableOpacity onPress={() => navigation.navigate('queue')} disabled={disabled} hitSlop={16}> <PressableOpacity onPress={() => navigation.navigate('queue')} disabled={disabled} hitSlop={16}>
<IconMatCom name="playlist-play" size={24} color="white" /> <IconMatCom name="playlist-play" size={24} color="white" />
</PressableOpacity> </PressableOpacity>
@@ -355,9 +361,9 @@ const controlsStyles = StyleSheet.create({
bottom: { bottom: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-end', justifyContent: 'space-between',
paddingTop: 10, paddingTop: 10,
paddingBottom: 40, paddingBottom: 54,
}, },
play: { play: {
marginHorizontal: 30, marginHorizontal: 30,
@@ -386,7 +392,7 @@ type RootStackParamList = {
type NowPlayingProps = NativeStackScreenProps<RootStackParamList, 'main'> type NowPlayingProps = NativeStackScreenProps<RootStackParamList, 'main'>
const NowPlayingView: React.FC<NowPlayingProps> = ({ navigation }) => { const NowPlayingView: React.FC<NowPlayingProps> = ({ navigation }) => {
const track = useStoreDeep(store => store.currentTrack) const track = useStore(selectTrackPlayer.currentTrack)
useEffect(() => { useEffect(() => {
if (!track) { if (!track) {
@@ -398,7 +404,7 @@ const NowPlayingView: React.FC<NowPlayingProps> = ({ navigation }) => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ImageGradientBackground imagePath={imagePath} height={'100%'} /> <ImageGradientBackground imagePath={imagePath} />
<NowPlayingHeader track={track} /> <NowPlayingHeader track={track} />
<View style={styles.content}> <View style={styles.content}>
<SongCoverArt /> <SongCoverArt />
@@ -413,6 +419,7 @@ const NowPlayingView: React.FC<NowPlayingProps> = ({ navigation }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
// paddingTop: StatusBar.currentHeight,
}, },
content: { content: {
flex: 1, flex: 1,
+52 -47
View File
@@ -4,47 +4,46 @@ import Header from '@app/components/Header'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import NothingHere from '@app/components/NothingHere' import NothingHere from '@app/components/NothingHere'
import TextInput from '@app/components/TextInput' import TextInput from '@app/components/TextInput'
import { useQuerySearchResults } from '@app/hooks/query' import { useActiveServerRefresh } from '@app/hooks/server'
import { useSetQueue } from '@app/hooks/trackplayer' import { ListableItem, SearchResults, Song } from '@app/models/music'
import { Album, Artist, SearchResults, Song } from '@app/models/library' import { selectMusic } from '@app/state/music'
import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/trackplayer'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { useFocusEffect, useNavigation } from '@react-navigation/native' import { useFocusEffect, useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react' import debounce from 'lodash.debounce'
import _ from 'lodash'
import React, { useCallback, useMemo, useRef, useState } from 'react' import React, { useCallback, useMemo, useRef, useState } from 'react'
import { import {
ActivityIndicator, ActivityIndicator,
InteractionManager, InteractionManager,
ScrollView, ScrollView,
StatusBar,
StyleSheet, StyleSheet,
TextInput as ReactTextInput, TextInput as ReactTextInput,
View, View,
} from 'react-native' } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const SongItem = React.memo<{ item: Song }>(({ item }) => { const SongItem = React.memo<{ item: Song }>(({ item }) => {
const { setQueue, isReady, contextId } = useSetQueue('song', [item]) const setQueue = useStore(selectTrackPlayer.setQueue)
return ( return (
<ListItem <ListItem
item={item} item={item}
contextId={contextId} contextId={item.id}
queueId={0} queueId={0}
showArt={true} showArt={true}
showStar={false} showStar={false}
onPress={() => setQueue({ title: item.title, playTrack: 0 })} onPress={() => setQueue([item], item.title, 'song', item.id, 0)}
disabled={!isReady}
/> />
) )
}, equal) })
const ResultsCategory = React.memo<{ const ResultsCategory = React.memo<{
name: string name: string
query: string query: string
items: (Artist | Album | Song)[] items: ListableItem[]
type: 'artist' | 'album' | 'song' }>(({ name, query, items }) => {
}>(({ name, query, type, items }) => {
const navigation = useNavigation() const navigation = useNavigation()
if (items.length === 0) { if (items.length === 0) {
@@ -55,8 +54,8 @@ const ResultsCategory = React.memo<{
<> <>
<Header>{name}</Header> <Header>{name}</Header>
{items.map(a => {items.map(a =>
type === 'song' ? ( a.itemType === 'song' ? (
<SongItem key={a.id} item={a as Song} /> <SongItem key={a.id} item={a} />
) : ( ) : (
<ListItem key={a.id} item={a} showArt={true} showStar={false} /> <ListItem key={a.id} item={a} showArt={true} showStar={false} />
), ),
@@ -71,7 +70,7 @@ const ResultsCategory = React.memo<{
)} )}
</> </>
) )
}, equal) })
const Results = React.memo<{ const Results = React.memo<{
results: SearchResults results: SearchResults
@@ -79,62 +78,64 @@ const Results = React.memo<{
}>(({ results, query }) => { }>(({ results, query }) => {
return ( return (
<> <>
<ResultsCategory name="Artists" query={query} type={'artist'} items={results.artists} /> <ResultsCategory name="Artists" query={query} items={results.artists} />
<ResultsCategory name="Albums" query={query} type={'album'} items={results.albums} /> <ResultsCategory name="Albums" query={query} items={results.albums} />
<ResultsCategory name="Songs" query={query} type={'song'} items={results.songs} /> <ResultsCategory name="Songs" query={query} items={results.songs} />
</> </>
) )
}, equal) })
const Search = () => { const Search = () => {
const [query, setQuery] = useState('') const fetchSearchResults = useStore(selectMusic.fetchSearchResults)
const { data, isLoading } = useQuerySearchResults({ query, albumCount: 5, artistCount: 5, songCount: 5 }) const [results, setResults] = useState<SearchResults>({ artists: [], albums: [], songs: [] })
const [refreshing, setRefreshing] = useState(false)
const [text, setText] = useState('') const [text, setText] = useState('')
const searchBarRef = useRef<ReactTextInput>(null) const searchBarRef = useRef<ReactTextInput>(null)
const scrollRef = useRef<ScrollView>(null) const scrollRef = useRef<ScrollView>(null)
const paddingTop = useSafeAreaInsets().top
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
const task = InteractionManager.runAfterInteractions(() => { const task = InteractionManager.runAfterInteractions(() => {
setTimeout(() => { setTimeout(() => {
if (text) {
return
}
setText('') setText('')
setQuery('') setResults({ artists: [], albums: [], songs: [] })
searchBarRef.current?.focus() searchBarRef.current?.focus()
scrollRef.current?.scrollTo({ y: 0, animated: true }) scrollRef.current?.scrollTo({ y: 0, animated: true })
}, 50) }, 50)
}) })
return () => task.cancel() return () => task.cancel()
}, [text]), }, [searchBarRef, scrollRef]),
) )
const debouncedSetQuery = useMemo( useActiveServerRefresh(
useCallback(() => {
setText('')
setResults({ artists: [], albums: [], songs: [] })
}, []),
)
const debouncedonUpdateSearch = useMemo(
() => () =>
_.debounce((value: string) => { debounce(async (query: string) => {
setQuery(value) setRefreshing(true)
setResults(await fetchSearchResults(query))
setRefreshing(false)
}, 400), }, 400),
[], [fetchSearchResults],
) )
const onChangeText = useCallback( const onChangeText = useCallback(
(value: string) => { (value: string) => {
setText(value) setText(value)
debouncedSetQuery(value) debouncedonUpdateSearch(value)
}, },
[setText, debouncedSetQuery], [setText, debouncedonUpdateSearch],
) )
const resultsCount = const resultsCount = results.albums.length + results.artists.length + results.songs.length
(data ? data.pages.reduce((acc, val) => (acc += val.artists.length), 0) : 0) +
(data ? data.pages.reduce((acc, val) => (acc += val.albums.length), 0) : 0) +
(data ? data.pages.reduce((acc, val) => (acc += val.songs.length), 0) : 0)
return ( return (
<GradientScrollView ref={scrollRef} style={styles.scroll} contentContainerStyle={{ paddingTop }}> <GradientScrollView ref={scrollRef} style={styles.scroll} contentContainerStyle={styles.scrollContentContainer}>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.inputBar}> <View style={styles.inputBar}>
<TextInput <TextInput
@@ -144,13 +145,14 @@ const Search = () => {
value={text} value={text}
onChangeText={onChangeText} onChangeText={onChangeText}
/> />
<ActivityIndicator animating={isLoading} size="small" color={colors.text.secondary} style={styles.activity} /> <ActivityIndicator
animating={refreshing}
size="small"
color={colors.text.secondary}
style={styles.activity}
/>
</View> </View>
{data !== undefined && resultsCount > 0 ? ( {resultsCount > 0 ? <Results results={results} query={text} /> : <NothingHere style={styles.noResults} />}
<Results results={data.pages[0]} query={text} />
) : (
<NothingHere style={styles.noResults} />
)}
</View> </View>
</GradientScrollView> </GradientScrollView>
) )
@@ -160,6 +162,9 @@ const styles = StyleSheet.create({
scroll: { scroll: {
flex: 1, flex: 1,
}, },
scrollContentContainer: {
paddingTop: StatusBar.currentHeight,
},
content: { content: {
paddingHorizontal: 20, paddingHorizontal: 20,
paddingBottom: 20, paddingBottom: 20,
+36 -55
View File
@@ -1,34 +1,24 @@
import GradientFlatList from '@app/components/GradientFlatList' import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import { useQuerySearchResults } from '@app/hooks/query' import { useFetchPaginatedList } from '@app/hooks/list'
import { useSetQueue } from '@app/hooks/trackplayer' import { AlbumListItem, Artist, Song } from '@app/models/music'
import { Album, Artist, Song } from '@app/models/library' import { selectMusic } from '@app/state/music'
import { Search3Params } from '@app/subsonic/params' import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/trackplayer'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import React, { useEffect } from 'react' import React, { useCallback, useEffect } from 'react'
import { StyleSheet } from 'react-native' import { StyleSheet } from 'react-native'
type SearchListItemType = Album | Song | Artist type SearchListItemType = AlbumListItem | Song | Artist
const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => { const ResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
const { setQueue, isReady, contextId } = useSetQueue('song', [item]) const setQueue = useStore(selectTrackPlayer.setQueue)
return ( let onPress
<ListItem if (item.itemType === 'song') {
item={item} onPress = () => setQueue([item], item.title, 'song', item.id, 0)
contextId={contextId}
queueId={0}
showArt={true}
showStar={false}
listStyle="small"
onPress={() => setQueue({ title: item.title, playTrack: 0 })}
style={styles.listItem}
disabled={!isReady}
/>
)
} }
const OtherResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
return ( return (
<ListItem <ListItem
item={item} item={item}
@@ -37,19 +27,12 @@ const OtherResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item })
showArt={true} showArt={true}
showStar={false} showStar={false}
listStyle="small" listStyle="small"
onPress={onPress}
style={styles.listItem} style={styles.listItem}
/> />
) )
} }
const ResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
if (item.itemType === 'song') {
return <SongResultsListItem item={item} />
} else {
return <OtherResultsListItem item={item} />
}
}
const SearchResultsRenderItem: React.FC<{ item: SearchListItemType }> = ({ item }) => <ResultsListItem item={item} /> const SearchResultsRenderItem: React.FC<{ item: SearchListItemType }> = ({ item }) => <ResultsListItem item={item} />
const SearchResultsView: React.FC<{ const SearchResultsView: React.FC<{
@@ -57,28 +40,26 @@ const SearchResultsView: React.FC<{
type: 'album' | 'artist' | 'song' type: 'album' | 'artist' | 'song'
}> = ({ query, type }) => { }> = ({ query, type }) => {
const navigation = useNavigation() const navigation = useNavigation()
const fetchSearchResults = useStore(selectMusic.fetchSearchResults)
const size = 100 const { list, refreshing, refresh, fetchNextPage } = useFetchPaginatedList<SearchListItemType>(
const params: Search3Params = { query } useCallback(
(size, offset) =>
if (type === 'album') { fetchSearchResults(query, type, size, offset).then(results => {
params.albumCount = size switch (type) {
} else if (type === 'artist') { case 'album':
params.artistCount = size return results.albums
} else { case 'artist':
params.songCount = size return results.artists
} case 'song':
return results.songs
const { data, isLoading, refetch, fetchNextPage } = useQuerySearchResults(params) default:
return []
const items: (Artist | Album | Song)[] = []
if (type === 'album') {
data && items.push(...data.pages.flatMap(p => p.albums))
} else if (type === 'artist') {
data && items.push(...data.pages.flatMap(p => p.artists))
} else {
data && items.push(...data.pages.flatMap(p => p.songs))
} }
}),
[fetchSearchResults, query, type],
),
100,
)
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
@@ -89,13 +70,13 @@ const SearchResultsView: React.FC<{
return ( return (
<GradientFlatList <GradientFlatList
data={items} data={list}
renderItem={SearchResultsRenderItem} renderItem={SearchResultsRenderItem}
keyExtractor={(item, i) => i.toString()} keyExtractor={(item, i) => i.toString()}
onRefresh={refetch} onRefresh={refresh}
refreshing={isLoading} refreshing={refreshing}
overScrollMode="never" overScrollMode="never"
onEndReached={() => fetchNextPage} onEndReached={fetchNextPage}
removeClippedSubviews={true} removeClippedSubviews={true}
onEndReachedThreshold={2} onEndReachedThreshold={2}
contentMarginTop={6} contentMarginTop={6}
+45 -81
View File
@@ -1,7 +1,8 @@
import Button from '@app/components/Button' import Button from '@app/components/Button'
import GradientScrollView from '@app/components/GradientScrollView' import GradientScrollView from '@app/components/GradientScrollView'
import { Server } from '@app/models/settings' import { Server } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store' import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import toast from '@app/util/toast' import toast from '@app/util/toast'
@@ -9,40 +10,34 @@ import { useNavigation } from '@react-navigation/native'
import md5 from 'md5' import md5 from 'md5'
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { StyleSheet, Text, TextInput, View, ViewStyle } from 'react-native' import { StyleSheet, Text, TextInput, View, ViewStyle } from 'react-native'
import uuid from 'react-native-uuid' import { v4 as uuidv4 } from 'uuid'
import SettingsSwitch from '@app/components/SettingsSwitch'
const PASSWORD_PLACEHOLDER = 'PASSWORD_PLACEHOLDER'
const ServerView: React.FC<{ const ServerView: React.FC<{
id?: string id?: string
}> = ({ id }) => { }> = ({ id }) => {
const navigation = useNavigation() const navigation = useNavigation()
const activeServerId = useStore(store => store.settings.activeServerId) const activeServer = useStore(selectSettings.activeServer)
const servers = useStoreDeep(store => store.settings.servers) const servers = useStore(selectSettings.servers)
const addServer = useStore(store => store.addServer) const addServer = useStore(selectSettings.addServer)
const updateServer = useStore(store => store.updateServer) const updateServer = useStore(selectSettings.updateServer)
const removeServer = useStore(store => store.removeServer) const removeServer = useStore(selectSettings.removeServer)
const server = id ? servers[id] : undefined const server = id ? servers.find(s => s.id === id) : undefined
const pingServer = useStore(store => store.pingServer) const pingServer = useStore(selectSettings.pingServer)
const [address, setAddress] = useState(server?.address || '') const [address, setAddress] = useState(server?.address || '')
const [username, setUsername] = useState(server?.username || '') const [username, setUsername] = useState(server?.username || '')
const [password, setPassword] = useState(server?.token ? 'password' : '')
const [usePlainPassword, setUsePlainPassword] = useState(server?.usePlainPassword ?? false)
const [password, setPassword] = useState(
server?.usePlainPassword ? server.plainPassword || '' : server?.token ? PASSWORD_PLACEHOLDER : '',
)
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [removing, setRemoving] = useState(false)
const [saving, setSaving] = useState(false)
const validate = useCallback(() => { const validate = useCallback(() => {
return !!address && !!username && !!password return !!address && !!username && !!password
}, [address, username, password]) }, [address, username, password])
const canRemove = useCallback(() => { const canRemove = useCallback(() => {
return id && Object.keys(servers).length > 1 && activeServerId !== id return id && servers.length > 1 && activeServer?.id !== id
}, [id, servers, activeServerId]) }, [id, servers, activeServer])
const exit = useCallback(() => { const exit = useCallback(() => {
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
@@ -53,51 +48,44 @@ const ServerView: React.FC<{
}, [navigation]) }, [navigation])
const createServer = useCallback<() => Server>(() => { const createServer = useCallback<() => Server>(() => {
if (usePlainPassword) { const salt = server?.salt || uuidv4()
return {
id: server?.id || '',
usePlainPassword,
plainPassword: password,
address,
username,
}
}
let token: string let token: string
let salt: string if (password === 'password' && server?.token) {
if (server && !server.usePlainPassword && password === PASSWORD_PLACEHOLDER) {
salt = server.salt
token = server.token token = server.token
} else { } else {
salt = uuid.v4() as string
token = md5(password + salt) token = md5(password + salt)
} }
return { return {
id: server?.id || '', id: server?.id || uuidv4(),
address, address,
username, username,
usePlainPassword,
salt, salt,
token, token,
} }
}, [usePlainPassword, server, address, username, password]) }, [address, password, server?.id, server?.salt, server?.token, username])
const save = useCallback(() => { const save = useCallback(() => {
if (!validate()) { if (!validate()) {
return return
} }
setSaving(true)
const update = createServer() const update = createServer()
const waitForSave = async () => {
try {
if (id) { if (id) {
updateServer(update) updateServer(update)
} else { } else {
addServer(update) await addServer(update)
} }
exit() exit()
} catch (err) {
setSaving(false)
}
}
waitForSave()
}, [addServer, createServer, exit, id, updateServer, validate]) }, [addServer, createServer, exit, id, updateServer, validate])
const remove = useCallback(() => { const remove = useCallback(() => {
@@ -105,29 +93,18 @@ const ServerView: React.FC<{
return return
} }
removeServer(id as string) setRemoving(true)
const waitForRemove = async () => {
try {
await removeServer(id as string)
exit() exit()
} catch (err) {
setRemoving(false)
}
}
waitForRemove()
}, [canRemove, exit, id, removeServer]) }, [canRemove, exit, id, removeServer])
const togglePlainPassword = useCallback(
(value: boolean) => {
setUsePlainPassword(value)
if (value) {
if (server && server.usePlainPassword) {
setPassword(server.plainPassword)
} else if (server) {
setPassword('')
}
} else {
if (server && !server.usePlainPassword) {
setPassword(PASSWORD_PLACEHOLDER)
}
}
},
[server],
)
const test = useCallback(() => { const test = useCallback(() => {
setTesting(true) setTesting(true)
const potential = createServer() const potential = createServer()
@@ -145,8 +122,8 @@ const ServerView: React.FC<{
}, [createServer, pingServer]) }, [createServer, pingServer])
const disableControls = useCallback(() => { const disableControls = useCallback(() => {
return !validate() || testing return !validate() || testing || removing || saving
}, [validate, testing]) }, [validate, testing, removing, saving])
const formatAddress = useCallback(() => { const formatAddress = useCallback(() => {
let addressFormatted = address.trim() let addressFormatted = address.trim()
@@ -167,7 +144,7 @@ const ServerView: React.FC<{
} }
return ( return (
<GradientScrollView style={styles.scroll}> <GradientScrollView style={styles.scroll} contentContainerStyle={styles.scrollContentContainer}>
<View style={styles.content}> <View style={styles.content}>
<Text style={styles.inputTitle}>Address</Text> <Text style={styles.inputTitle}>Address</Text>
<TextInput <TextInput
@@ -176,8 +153,6 @@ const ServerView: React.FC<{
selectionColor={colors.text.secondary} selectionColor={colors.text.secondary}
textContentType="URL" textContentType="URL"
placeholder="http://demo.navidrome.org" placeholder="http://demo.navidrome.org"
autoCorrect={false}
autoCapitalize="none"
value={address} value={address}
onChangeText={setAddress} onChangeText={setAddress}
onBlur={formatAddress} onBlur={formatAddress}
@@ -188,9 +163,7 @@ const ServerView: React.FC<{
placeholderTextColor="grey" placeholderTextColor="grey"
selectionColor={colors.text.secondary} selectionColor={colors.text.secondary}
textContentType="username" textContentType="username"
autoComplete="username" autoCompleteType="username"
importantForAutofill="yes"
autoCapitalize="none"
placeholder="demo" placeholder="demo"
value={username} value={username}
onChangeText={setUsername} onChangeText={setUsername}
@@ -201,24 +174,12 @@ const ServerView: React.FC<{
placeholderTextColor="grey" placeholderTextColor="grey"
selectionColor={colors.text.secondary} selectionColor={colors.text.secondary}
textContentType="password" textContentType="password"
autoComplete="password" autoCompleteType="password"
autoCapitalize="none"
importantForAutofill="yes"
secureTextEntry={true} secureTextEntry={true}
placeholder="demo" placeholder="demo"
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
/> />
<SettingsSwitch
title="Force plain text password"
subtitle={
usePlainPassword
? 'Send password in plain text (legacy, make sure your connection is secure!)'
: 'Send password as token + salt'
}
value={usePlainPassword}
setValue={togglePlainPassword}
/>
<Button <Button
disabled={disableControls()} disabled={disableControls()}
style={styles.button} style={styles.button}
@@ -242,6 +203,9 @@ const styles = StyleSheet.create({
scroll: { scroll: {
flex: 1, flex: 1,
}, },
scrollContentContainer: {
// paddingTop: StatusBar.currentHeight,
},
content: { content: {
paddingHorizontal: 20, paddingHorizontal: 20,
}, },
+56 -27
View File
@@ -5,23 +5,24 @@ import PressableOpacity from '@app/components/PressableOpacity'
import SettingsItem from '@app/components/SettingsItem' import SettingsItem from '@app/components/SettingsItem'
import SettingsSwitch from '@app/components/SettingsSwitch' import SettingsSwitch from '@app/components/SettingsSwitch'
import TextInput from '@app/components/TextInput' import TextInput from '@app/components/TextInput'
import { useSwitchActiveServer, useResetImageCache } from '@app/hooks/settings' import { useSwitchActiveServer } from '@app/hooks/server'
import { Server } from '@app/models/settings' import { Server } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store' import { selectCache } from '@app/state/cache'
import { selectSettings } from '@app/state/settings'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/core' import { useNavigation } from '@react-navigation/core'
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { KeyboardTypeOptions, Linking, Modal, Pressable, StyleSheet, Text, View } from 'react-native' import { KeyboardTypeOptions, Linking, Modal, Pressable, StatusBar, StyleSheet, Text, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler' import { ScrollView } from 'react-native-gesture-handler'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons' import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { version } from '../../package.json' import { version } from '../../package.json'
const ServerItem = React.memo<{ const ServerItem = React.memo<{
server: Server server: Server
}>(({ server }) => { }>(({ server }) => {
const activeServerId = useStore(store => store.settings.activeServerId) const activeServer = useStore(selectSettings.activeServer)
const switchActiveServer = useSwitchActiveServer() const switchActiveServer = useSwitchActiveServer()
const navigation = useNavigation() const navigation = useNavigation()
@@ -35,7 +36,7 @@ const ServerItem = React.memo<{
subtitle={server.username} subtitle={server.username}
onPress={() => navigation.navigate('server', { id: server.id })}> onPress={() => navigation.navigate('server', { id: server.id })}>
<PressableOpacity style={styles.serverActive} onPress={setActive}> <PressableOpacity style={styles.serverActive} onPress={setActive}>
{activeServerId === server.id ? ( {activeServer && activeServer.id === server.id ? (
<Icon name="checkbox-marked-circle" size={30} color={colors.accent} /> <Icon name="checkbox-marked-circle" size={30} color={colors.accent} />
) : ( ) : (
<Icon name="checkbox-blank-circle-outline" size={30} color={colors.text.secondary} /> <Icon name="checkbox-blank-circle-outline" size={30} color={colors.text.secondary} />
@@ -192,31 +193,45 @@ function secondsUnit(seconds: string): string {
} }
const SettingsContent = React.memo(() => { const SettingsContent = React.memo(() => {
const servers = useStoreDeep(store => store.settings.servers) const servers = useStore(selectSettings.servers)
const scrobble = useStore(store => store.settings.scrobble) const scrobble = useStore(selectSettings.scrobble)
const setScrobble = useStore(store => store.setScrobble) const setScrobble = useStore(selectSettings.setScrobble)
const maxBitrateWifi = useStore(store => store.settings.maxBitrateWifi) // doesn't seem to ever be a case where we want this off
const setMaxBitrateWifi = useStore(store => store.setMaxBitrateWifi) // will remove later if there isn't a use case for disabling
// const estimateContentLength = useStore(selectSettings.estimateContentLength)
// const setEstimateContentLength = useStore(selectSettings.setEstimateContentLength)
const maxBitrateMobile = useStore(store => store.settings.maxBitrateMobile) const maxBitrateWifi = useStore(selectSettings.maxBitrateWifi)
const setMaxBitrateMobile = useStore(store => store.setMaxBitrateMobile) const setMaxBitrateWifi = useStore(selectSettings.setMaxBitrateWifi)
const minBuffer = useStore(store => store.settings.minBuffer) const maxBitrateMobile = useStore(selectSettings.maxBitrateMobile)
const setMinBuffer = useStore(store => store.setMinBuffer) const setMaxBitrateMobile = useStore(selectSettings.setMaxBitrateMobile)
const maxBuffer = useStore(store => store.settings.maxBuffer)
const setMaxBuffer = useStore(store => store.setMaxBuffer)
const minBuffer = useStore(selectSettings.minBuffer)
const setMinBuffer = useStore(selectSettings.setMinBuffer)
const maxBuffer = useStore(selectSettings.maxBuffer)
const setMaxBuffer = useStore(selectSettings.setMaxBuffer)
const clearImageCache = useStore(selectCache.clearImageCache)
const [clearing, setClearing] = useState(false) const [clearing, setClearing] = useState(false)
const resetImageCache = useResetImageCache()
const navigation = useNavigation() const navigation = useNavigation()
const clear = useCallback(async () => { const clear = useCallback(() => {
setClearing(true) setClearing(true)
await resetImageCache()
const waitForClear = async () => {
try {
await clearImageCache()
} catch (err) {
console.log(err)
} finally {
setClearing(false) setClearing(false)
}, [resetImageCache]) }
}
waitForClear()
}, [clearImageCache])
const setMinBufferText = useCallback((text: string) => setMinBuffer(parseFloat(text)), [setMinBuffer]) const setMinBufferText = useCallback((text: string) => setMinBuffer(parseFloat(text)), [setMinBuffer])
const setMaxBufferText = useCallback((text: string) => setMaxBuffer(parseFloat(text)), [setMaxBuffer]) const setMaxBufferText = useCallback((text: string) => setMaxBuffer(parseFloat(text)), [setMaxBuffer])
@@ -224,7 +239,7 @@ const SettingsContent = React.memo(() => {
return ( return (
<View style={styles.content}> <View style={styles.content}>
<Header>Servers</Header> <Header>Servers</Header>
{Object.values(servers).map(s => ( {servers.map(s => (
<ServerItem key={s.id} server={s} /> <ServerItem key={s.id} server={s} />
))} ))}
<Button <Button
@@ -236,6 +251,12 @@ const SettingsContent = React.memo(() => {
<Header style={styles.header}>Network</Header> <Header style={styles.header}>Network</Header>
<BitrateModal title="Maximum bitrate (Wi-Fi)" bitrate={maxBitrateWifi} setBitrate={setMaxBitrateWifi} /> <BitrateModal title="Maximum bitrate (Wi-Fi)" bitrate={maxBitrateWifi} setBitrate={setMaxBitrateWifi} />
<BitrateModal title="Maximum bitrate (mobile)" bitrate={maxBitrateMobile} setBitrate={setMaxBitrateMobile} /> <BitrateModal title="Maximum bitrate (mobile)" bitrate={maxBitrateMobile} setBitrate={setMaxBitrateMobile} />
{/* <SettingsSwitch
title="Estimate content length"
subtitle='Send the "estimateContentLength" flag when streaming. Helps fix issues with seeking when the server is transcoding songs.'
value={estimateContentLength}
setValue={setEstimateContentLength}
/> */}
<SettingsTextModal <SettingsTextModal
title="Minimum buffer time" title="Minimum buffer time"
value={minBuffer.toString()} value={minBuffer.toString()}
@@ -279,8 +300,15 @@ const SettingsContent = React.memo(() => {
<Button <Button
disabled={clearing} disabled={clearing}
style={styles.button} style={styles.button}
title="Licenses" title="Licenses (NPM)"
onPress={() => navigation.navigate('web', { uri: 'file:///android_asset/licenses.html' })} onPress={() => navigation.navigate('web', { uri: 'file:///android_asset/licenses/npm_licenses.txt' })}
buttonStyle="hollow"
/>
<Button
disabled={clearing}
style={styles.button}
title="Licenses (Android)"
onPress={() => navigation.navigate('web', { uri: 'file:///android_asset/licenses/android_licenses.html' })}
buttonStyle="hollow" buttonStyle="hollow"
/> />
</View> </View>
@@ -288,10 +316,8 @@ const SettingsContent = React.memo(() => {
}) })
const Settings = () => { const Settings = () => {
const paddingTop = useSafeAreaInsets().top
return ( return (
<GradientScrollView style={styles.scroll} contentContainerStyle={{ paddingTop }}> <GradientScrollView style={styles.scroll} contentContainerStyle={styles.scrollContentContainer}>
<SettingsContent /> <SettingsContent />
</GradientScrollView> </GradientScrollView>
) )
@@ -301,6 +327,9 @@ const styles = StyleSheet.create({
scroll: { scroll: {
flex: 1, flex: 1,
}, },
scrollContentContainer: {
paddingTop: StatusBar.currentHeight,
},
content: { content: {
paddingHorizontal: 20, paddingHorizontal: 20,
paddingBottom: 40, paddingBottom: 40,
+35 -74
View File
@@ -4,13 +4,13 @@ import HeaderBar from '@app/components/HeaderBar'
import ImageGradientFlatList from '@app/components/ImageGradientFlatList' import ImageGradientFlatList from '@app/components/ImageGradientFlatList'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import ListPlayerControls from '@app/components/ListPlayerControls' import ListPlayerControls from '@app/components/ListPlayerControls'
import NothingHere from '@app/components/NothingHere' import { useCoverArtFile } from '@app/hooks/cache'
import { useQueryAlbum, useQueryCoverArtPath, useQueryPlaylist } from '@app/hooks/query' import { useAlbumWithSongs, usePlaylistWithSongs } from '@app/hooks/music'
import { useSetQueue } from '@app/hooks/trackplayer' import { AlbumWithSongs, PlaylistWithSongs, Song } from '@app/models/music'
import { Album, Playlist, Song } from '@app/models/library' import { useStore } from '@app/state/store'
import { selectTrackPlayer } from '@app/state/trackplayer'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import equal from 'fast-deep-equal/es6/react'
import React, { useState } from 'react' import React, { useState } from 'react'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
@@ -30,7 +30,6 @@ const SongRenderItem: React.FC<{
subtitle?: string subtitle?: string
onPress?: () => void onPress?: () => void
showArt?: boolean showArt?: boolean
disabled?: boolean
} }
}> = ({ item }) => ( }> = ({ item }) => (
<ListItem <ListItem
@@ -41,21 +40,24 @@ const SongRenderItem: React.FC<{
onPress={item.onPress} onPress={item.onPress}
showArt={item.showArt} showArt={item.showArt}
style={styles.listItem} style={styles.listItem}
disabled={item.disabled}
/> />
) )
const SongListDetails = React.memo<{ const SongListDetails = React.memo<{
title: string title: string
type: SongListType type: SongListType
songList?: Album | Playlist songList?: AlbumWithSongs | PlaylistWithSongs
songs?: Song[]
subtitle?: string subtitle?: string
}>(({ title, songList, songs, subtitle, type }) => { }>(({ title, songList, subtitle, type }) => {
const { data: coverArtPath } = useQueryCoverArtPath(songList?.coverArt, 'thumbnail') const coverArtFile = useCoverArtFile(songList?.coverArt, 'thumbnail')
const [headerColor, setHeaderColor] = useState<string | undefined>(undefined) const [headerColor, setHeaderColor] = useState<string | undefined>(undefined)
const setQueue = useStore(selectTrackPlayer.setQueue)
const _songs = [...(songs || [])] if (!songList) {
return <SongListDetailsFallback />
}
const _songs = [...songList.songs]
let typeName = '' let typeName = ''
if (type === 'album') { if (type === 'album') {
@@ -73,16 +75,6 @@ const SongListDetails = React.memo<{
typeName = 'Playlist' typeName = 'Playlist'
} }
const { setQueue, isReady, contextId } = useSetQueue(type, _songs)
if (!songList) {
return <SongListDetailsFallback />
}
const disabled = !isReady || _songs.length === 0
const play = (track?: number, shuffle?: boolean) => () =>
setQueue({ title: songList.name, playTrack: track, shuffle })
return ( return (
<View style={styles.container}> <View style={styles.container}>
<HeaderBar <HeaderBar
@@ -93,99 +85,74 @@ const SongListDetails = React.memo<{
<ImageGradientFlatList <ImageGradientFlatList
data={_songs.map((s, i) => ({ data={_songs.map((s, i) => ({
song: s, song: s,
contextId, contextId: songList.id,
queueId: i, queueId: i,
subtitle: s.artist, subtitle: s.artist,
onPress: play(i), onPress: () => setQueue(_songs, songList.name, type, songList.id, i),
showArt: songList.itemType === 'playlist', showArt: songList.itemType === 'playlist',
disabled: disabled,
}))} }))}
renderItem={SongRenderItem} renderItem={SongRenderItem}
keyExtractor={(item, i) => i.toString()} keyExtractor={(item, i) => i.toString()}
backgroundProps={{ backgroundProps={{
imagePath: coverArtPath, imagePath: coverArtFile?.file?.path,
style: styles.container, style: styles.container,
onGetColor: setHeaderColor, onGetColor: setHeaderColor,
}} }}
overScrollMode="never" overScrollMode="never"
windowSize={7} windowSize={7}
contentMarginTop={26} contentMarginTop={26}
ListEmptyComponent={
songs ? (
<NothingHere style={styles.nothing} />
) : (
<ActivityIndicator size="large" color={colors.accent} style={styles.listLoading} />
)
}
ListHeaderComponent={ ListHeaderComponent={
<View style={styles.content}> <View style={styles.content}>
<CoverArt type="cover" size="original" coverArt={songList.coverArt} style={styles.cover} /> <CoverArt type="cover" size="original" coverArt={songList.coverArt} style={styles.cover} />
<Text style={styles.title}>{songList.name}</Text> <Text style={styles.title}>{songList.name}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : <></>} {subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : <></>}
{songList.songs.length > 0 && (
<ListPlayerControls <ListPlayerControls
style={styles.controls} style={styles.controls}
songs={_songs} songs={_songs}
typeName={typeName} typeName={typeName}
play={play(undefined, false)} queueName={songList.name}
shuffle={play(undefined, true)} queueContextId={songList.id}
disabled={disabled} queueContextType={type}
/> />
)}
</View> </View>
} }
/> />
</View> </View>
) )
}, equal) })
const PlaylistView = React.memo<{ const PlaylistView = React.memo<{
id: string id: string
title: string title: string
playlist?: Playlist }>(({ id, title }) => {
}>(({ id, title, playlist }) => { const playlist = usePlaylistWithSongs(id)
const query = useQueryPlaylist(id, playlist) return <SongListDetails title={title} songList={playlist} subtitle={playlist?.comment} type="playlist" />
})
return (
<SongListDetails
title={title}
songList={query.data?.playlist}
songs={query.data?.songs}
subtitle={query.data?.playlist?.comment}
type="playlist"
/>
)
}, equal)
const AlbumView = React.memo<{ const AlbumView = React.memo<{
id: string id: string
title: string title: string
album?: Album }>(({ id, title }) => {
}>(({ id, title, album }) => { const album = useAlbumWithSongs(id)
const query = useQueryAlbum(id, album)
return ( return (
<SongListDetails <SongListDetails
title={title} title={title}
songList={query.data?.album} songList={album}
songs={query.data?.songs} subtitle={(album?.artist || '') + (album?.year ? ' • ' + album?.year : '')}
subtitle={(query.data?.album?.artist || '') + (query.data?.album?.year ? ' • ' + query.data?.album?.year : '')}
type="album" type="album"
/> />
) )
}, equal) })
const SongListView = React.memo<{ const SongListView = React.memo<{
id: string id: string
title: string title: string
type: SongListType type: SongListType
album?: Album }>(({ id, title, type }) => {
playlist?: Playlist return type === 'album' ? <AlbumView id={id} title={title} /> : <PlaylistView id={id} title={title} />
}>(({ id, title, type, album, playlist }) => { })
return type === 'album' ? (
<AlbumView id={id} title={title} album={album} />
) : (
<PlaylistView id={id} title={title} playlist={playlist} />
)
}, equal)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
@@ -229,12 +196,6 @@ const styles = StyleSheet.create({
listItem: { listItem: {
paddingHorizontal: 20, paddingHorizontal: 20,
}, },
nothing: {
width: '100%',
},
listLoading: {
marginTop: 10,
},
}) })
export default SongListView export default SongListView
+1 -1
View File
@@ -45,7 +45,7 @@ const SplashPage: React.FC<{}> = ({ children }) => {
const splash = ( const splash = (
<Animated.View style={[styles.splashContainer, animatedStyles]} pointerEvents="none"> <Animated.View style={[styles.splashContainer, animatedStyles]} pointerEvents="none">
<GradientBackground style={styles.background} height="100%"> <GradientBackground style={styles.background}>
<View style={styles.logoContainer}> <View style={styles.logoContainer}>
<Image style={styles.image} source={require('@res/casette.png')} fadeDuration={0} /> <Image style={styles.image} source={require('@res/casette.png')} fadeDuration={0} />
<Text style={styles.text}>subtracks</Text> <Text style={styles.text}>subtracks</Text>
+293
View File
@@ -0,0 +1,293 @@
import { CacheFile, CacheImageSize, CacheItemType, CacheItemTypeKey, CacheRequest } from '@app/models/cache'
import { mkdir, rmdir } from '@app/util/fs'
import PromiseQueue from '@app/util/PromiseQueue'
import produce from 'immer'
import RNFS from 'react-native-fs'
import { GetState, SetState } from 'zustand'
import { Store } from './store'
const queues: Record<CacheItemTypeKey, PromiseQueue> = {
coverArt: new PromiseQueue(5),
coverArtThumb: new PromiseQueue(50),
artistArt: new PromiseQueue(5),
artistArtThumb: new PromiseQueue(50),
song: new PromiseQueue(1),
}
export type CacheDownload = CacheFile & CacheRequest
export type CacheDirsByServer = Record<string, Record<CacheItemTypeKey, string>>
export type CacheFilesByServer = Record<string, Record<CacheItemTypeKey, Record<string, CacheFile>>>
export type CacheRequestsByServer = Record<string, Record<CacheItemTypeKey, Record<string, CacheRequest>>>
// export type DownloadedItemsByServer = Record<
// string,
// {
// songs: { [songId: string]: DownloadedSong }
// albums: { [albumId: string]: DownloadedAlbum }
// artists: { [songId: string]: DownloadedArtist }
// playlists: { [playlistId: string]: DownloadedPlaylist }
// }
// >
export type CacheSlice = {
cacheItem: (
key: CacheItemTypeKey,
itemId: string,
url: string | (() => string | Promise<string | undefined>),
) => Promise<void>
// cache: DownloadedItemsByServer
cacheDirs: CacheDirsByServer
cacheFiles: CacheFilesByServer
cacheRequests: CacheRequestsByServer
fetchCoverArtFilePath: (coverArt: string, size?: CacheImageSize) => Promise<string | undefined>
createCache: (serverId: string) => Promise<void>
prepareCache: (serverId: string) => void
pendingRemoval: Record<string, boolean>
removeCache: (serverId: string) => Promise<void>
clearImageCache: () => Promise<void>
}
export const selectCache = {
cacheItem: (store: CacheSlice) => store.cacheItem,
fetchCoverArtFilePath: (store: CacheSlice) => store.fetchCoverArtFilePath,
clearImageCache: (store: CacheSlice) => store.clearImageCache,
}
export const createCacheSlice = (set: SetState<Store>, get: GetState<Store>): CacheSlice => ({
// cache: {},
cacheDirs: {},
cacheFiles: {},
cacheRequests: {},
cacheItem: async (key, itemId, url) => {
const client = get().client
if (!client) {
return
}
const activeServerId = get().settings.activeServer
if (!activeServerId) {
return
}
if (get().pendingRemoval[activeServerId]) {
return
}
const inProgress = get().cacheRequests[activeServerId][key][itemId]
if (inProgress && inProgress.promise !== undefined) {
return await inProgress.promise
}
const existing = get().cacheFiles[activeServerId][key][itemId]
if (existing) {
return
}
const path = `${get().cacheDirs[activeServerId][key]}/${itemId}`
const promise = queues[key].enqueue(async () => {
const urlResult = typeof url === 'string' ? url : url()
const fromUrl = typeof urlResult === 'string' ? urlResult : await urlResult
try {
if (!fromUrl) {
throw new Error('cannot resolve url for cache request')
}
await RNFS.downloadFile({
fromUrl,
toFile: path,
// progressInterval: 100,
// progress: res => {
// set(
// produce<CacheSlice>(state => {
// state.cacheRequests[activeServerId][key][itemId].progress = Math.max(
// 1,
// res.bytesWritten / (res.contentLength || 1),
// )
// }),
// )
// },
}).promise
set(
produce<CacheSlice>(state => {
state.cacheRequests[activeServerId][key][itemId].progress = 1
delete state.cacheRequests[activeServerId][key][itemId].promise
}),
)
} catch {
set(
produce<CacheSlice>(state => {
delete state.cacheFiles[activeServerId][key][itemId]
delete state.cacheRequests[activeServerId][key][itemId]
}),
)
}
})
set(
produce<Store>(state => {
state.cacheFiles[activeServerId][key][itemId] = {
path,
date: Date.now(),
permanent: false,
}
state.cacheRequests[activeServerId][key][itemId] = {
progress: 0,
promise,
}
}),
)
return await promise
},
fetchCoverArtFilePath: async (coverArt, size = 'thumbnail') => {
const client = get().client
if (!client) {
return
}
const activeServerId = get().settings.activeServer
if (!activeServerId) {
return
}
const key: CacheItemTypeKey = size === 'thumbnail' ? 'coverArtThumb' : 'coverArt'
const existing = get().cacheFiles[activeServerId][key][coverArt]
const inProgress = get().cacheRequests[activeServerId][key][coverArt]
if (existing && inProgress) {
if (inProgress.promise) {
await inProgress.promise
}
return `file://${existing.path}`
}
await get().cacheItem(key, coverArt, () =>
client.getCoverArtUri({
id: coverArt,
size: size === 'thumbnail' ? '256' : undefined,
}),
)
return `file://${get().cacheFiles[activeServerId][key][coverArt].path}`
},
createCache: async serverId => {
for (const type in CacheItemType) {
await mkdir(`${RNFS.DocumentDirectoryPath}/servers/${serverId}/${type}`)
}
set(
produce<CacheSlice>(state => {
state.cacheFiles[serverId] = {
song: {},
coverArt: {},
coverArtThumb: {},
artistArt: {},
artistArtThumb: {},
}
}),
)
get().prepareCache(serverId)
},
prepareCache: serverId => {
set(
produce<CacheSlice>(state => {
if (!state.cacheDirs[serverId]) {
state.cacheDirs[serverId] = {
song: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/song`,
coverArt: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/coverArt`,
coverArtThumb: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/coverArtThumb`,
artistArt: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/artistArt`,
artistArtThumb: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/artistArtThumb`,
}
}
if (!state.cacheRequests[serverId]) {
state.cacheRequests[serverId] = {
song: {},
coverArt: {},
coverArtThumb: {},
artistArt: {},
artistArtThumb: {},
}
}
}),
)
},
pendingRemoval: {},
removeCache: async serverId => {
set(
produce<CacheSlice>(state => {
state.pendingRemoval[serverId] = true
}),
)
const cacheRequests = get().cacheRequests[serverId]
const pendingRequests: Promise<void>[] = []
for (const type in CacheItemType) {
const requests = Object.values(cacheRequests[type as CacheItemTypeKey])
.filter(r => r.promise !== undefined)
.map(r => r.promise) as Promise<void>[]
pendingRequests.push(...requests)
}
await Promise.all(pendingRequests)
await rmdir(`${RNFS.DocumentDirectoryPath}/servers/${serverId}`)
set(
produce<CacheSlice>(state => {
delete state.pendingRemoval[serverId]
if (state.cacheDirs[serverId]) {
delete state.cacheDirs[serverId]
}
if (state.cacheFiles[serverId]) {
delete state.cacheFiles[serverId]
}
if (state.cacheRequests[serverId]) {
delete state.cacheRequests[serverId]
}
}),
)
},
clearImageCache: async () => {
const cacheRequests = get().cacheRequests
for (const serverId in cacheRequests) {
const coverArtRequests = cacheRequests[serverId].coverArt
const artstArtRequests = cacheRequests[serverId].artistArt
const requests = [...Object.values(coverArtRequests), ...Object.values(artstArtRequests)]
const pendingRequests = [
...(requests.filter(r => r.promise !== undefined).map(r => r.promise) as Promise<void>[]),
]
await Promise.all(pendingRequests)
await rmdir(get().cacheDirs[serverId].coverArt)
await mkdir(get().cacheDirs[serverId].coverArt)
await rmdir(get().cacheDirs[serverId].artistArt)
await mkdir(get().cacheDirs[serverId].artistArt)
set(
produce<CacheSlice>(state => {
state.cacheFiles[serverId].coverArt = {}
state.cacheFiles[serverId].coverArtThumb = {}
state.cacheFiles[serverId].artistArt = {}
state.cacheFiles[serverId].artistArtThumb = {}
}),
)
}
},
})
-69
View File
@@ -1,69 +0,0 @@
import { Server } from '@app/models/settings'
import { ById } from '@app/models/state'
import { newCacheBuster } from './settings'
import RNFS from 'react-native-fs'
const migrations: Array<(state: any) => Promise<any>> = [
// 1
async state => {
for (let server of state.settings.servers) {
server.usePlainPassword = false
}
return state
},
// 2
async state => {
state.settings.servers = state.settings.servers.reduce((acc: ById<Server>, server: Server) => {
acc[server.id] = server
return acc
}, {} as ById<Server>)
state.settings.activeServerId = state.settings.activeServer
delete state.settings.activeServer
state.settings.screens.home.listTypes = [...state.settings.screens.home.lists]
delete state.settings.screens.home.lists
state.settings.screens.library.albumsFilter = { ...state.settings.screens.library.albums }
delete state.settings.screens.library.albums
state.settings.screens.library.artistsFilter = { ...state.settings.screens.library.artists }
delete state.settings.screens.library.artists
delete state.settings.estimateContentLength
return state
},
// 3
async state => {
state.settings.cacheBuster = newCacheBuster()
state.settings.servers = Object.values(state.settings.servers as Record<string, Server>).reduce(
(acc, server, i) => {
const newId = i.toString()
if (server.id === state.settings.activeServerId) {
state.settings.activeServerId = newId
}
server.id = newId
acc[newId] = server
return acc
},
{} as Record<string, Server>,
)
try {
await RNFS.unlink(`${RNFS.DocumentDirectoryPath}/servers`)
} catch (err) {
console.error(err)
}
return state
},
]
export default migrations
+471
View File
@@ -0,0 +1,471 @@
import {
AlbumListItem,
AlbumWithSongs,
Artist,
ArtistInfo,
HomeLists,
PlaylistListItem,
PlaylistWithSongs,
SearchResults,
StarrableItemType,
} from '@app/models/music'
import { Store } from '@app/state/store'
import { GetAlbumList2Params, GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import produce from 'immer'
import { GetState, SetState } from 'zustand'
export type MusicSlice = {
//
// family-style state
//
artistInfo: { [id: string]: ArtistInfo }
fetchArtistInfo: (id: string) => Promise<ArtistInfo | undefined>
albumsWithSongs: { [id: string]: AlbumWithSongs }
fetchAlbumWithSongs: (id: string) => Promise<AlbumWithSongs | undefined>
playlistsWithSongs: { [id: string]: PlaylistWithSongs }
fetchPlaylistWithSongs: (id: string) => Promise<PlaylistWithSongs | undefined>
//
// lists-style state
//
fetchArtists: (size?: number, offset?: number) => Promise<Artist[]>
fetchPlaylists: () => Promise<PlaylistListItem[]>
fetchAlbums: () => Promise<AlbumListItem[]>
fetchSearchResults: (
query: string,
type?: 'album' | 'song' | 'artist',
size?: number,
offset?: number,
) => Promise<SearchResults>
homeLists: HomeLists
homeListsUpdating: boolean
fetchHomeLists: () => Promise<void>
clearHomeLists: () => void
//
// actions, etc.
//
starredSongs: { [id: string]: boolean }
starredAlbums: { [id: string]: boolean }
starredArtists: { [id: string]: boolean }
starItem: (id: string, type: StarrableItemType, unstar?: boolean) => Promise<void>
albumIdCoverArt: { [id: string]: string | undefined }
albumIdCoverArtRequests: { [id: string]: Promise<void> }
fetchAlbumCoverArt: (id: string) => Promise<void>
getAlbumCoverArt: (id: string | undefined) => Promise<string | undefined>
}
export const selectMusic = {
fetchArtistInfo: (state: Store) => state.fetchArtistInfo,
fetchAlbumWithSongs: (state: Store) => state.fetchAlbumWithSongs,
fetchPlaylistWithSongs: (state: Store) => state.fetchPlaylistWithSongs,
fetchArtists: (store: MusicSlice) => store.fetchArtists,
fetchPlaylists: (store: MusicSlice) => store.fetchPlaylists,
fetchAlbums: (store: MusicSlice) => store.fetchAlbums,
fetchSearchResults: (store: MusicSlice) => store.fetchSearchResults,
homeLists: (store: MusicSlice) => store.homeLists,
homeListsUpdating: (store: MusicSlice) => store.homeListsUpdating,
fetchHomeLists: (store: MusicSlice) => store.fetchHomeLists,
clearHomeLists: (store: MusicSlice) => store.clearHomeLists,
starItem: (store: MusicSlice) => store.starItem,
}
function reduceStarred(
starredType: { [id: string]: boolean },
items: { id: string; starred?: Date | boolean }[],
): { [id: string]: boolean } {
return {
...starredType,
...items.reduce((acc, val) => {
acc[val.id] = !!val.starred
return acc
}, {} as { [id: string]: boolean }),
}
}
export const createMusicSlice = (set: SetState<Store>, get: GetState<Store>): MusicSlice => ({
artistInfo: {},
fetchArtistInfo: async id => {
const client = get().client
if (!client) {
return undefined
}
try {
const [artistResponse, artistInfoResponse] = await Promise.all([
client.getArtist({ id }),
client.getArtistInfo2({ id }),
])
const topSongsResponse = await client.getTopSongs({ artist: artistResponse.data.artist.name, count: 50 })
const artistInfo = await get().mapArtistInfo(
artistResponse.data,
artistInfoResponse.data.artistInfo,
topSongsResponse.data.songs,
)
set(
produce<MusicSlice>(state => {
state.artistInfo[id] = artistInfo
state.starredSongs = reduceStarred(state.starredSongs, artistInfo.topSongs)
state.starredArtists = reduceStarred(state.starredArtists, [artistInfo])
state.starredAlbums = reduceStarred(state.starredAlbums, artistInfo.albums)
}),
)
return artistInfo
} catch {
return undefined
}
},
albumsWithSongs: {},
fetchAlbumWithSongs: async id => {
const client = get().client
if (!client) {
return undefined
}
try {
const response = await client.getAlbum({ id })
const album = await get().mapAlbumID3WithSongstoAlbumWithSongs(response.data.album, response.data.songs)
set(
produce<MusicSlice>(state => {
state.albumsWithSongs[id] = album
state.starredSongs = reduceStarred(state.starredSongs, album.songs)
state.starredAlbums = reduceStarred(state.starredAlbums, [album])
}),
)
return album
} catch {
return undefined
}
},
playlistsWithSongs: {},
fetchPlaylistWithSongs: async id => {
const client = get().client
if (!client) {
return undefined
}
try {
const response = await client.getPlaylist({ id })
const playlist = await get().mapPlaylistWithSongs(response.data.playlist)
set(
produce<MusicSlice>(state => {
state.playlistsWithSongs[id] = playlist
state.starredSongs = reduceStarred(state.starredSongs, playlist.songs)
}),
)
return playlist
} catch {
return undefined
}
},
fetchArtists: async () => {
const client = get().client
if (!client) {
return []
}
try {
const response = await client.getArtists()
const artists = response.data.artists.map(get().mapArtistID3toArtist)
set(
produce<MusicSlice>(state => {
state.starredArtists = reduceStarred(state.starredArtists, artists)
}),
)
return artists
} catch {
return []
}
},
fetchPlaylists: async () => {
const client = get().client
if (!client) {
return []
}
try {
const response = await client.getPlaylists()
return response.data.playlists.map(get().mapPlaylistListItem)
} catch {
return []
}
},
fetchAlbums: async (size = 500, offset = 0) => {
const client = get().client
if (!client) {
return []
}
try {
const filter = get().settings.screens.library.albums
let params: GetAlbumList2Params
switch (filter.type) {
case 'byYear':
params = {
size,
offset,
type: filter.type,
fromYear: filter.fromYear,
toYear: filter.toYear,
}
break
case 'byGenre':
params = {
size,
offset,
type: filter.type,
genre: filter.genre,
}
break
default:
params = {
size,
offset,
type: filter.type,
}
break
}
const response = await client.getAlbumList2(params)
const albums = response.data.albums.map(get().mapAlbumID3toAlbumListItem)
set(
produce<MusicSlice>(state => {
state.starredAlbums = reduceStarred(state.starredAlbums, albums)
}),
)
return albums
} catch {
return []
}
},
fetchSearchResults: async (query, type, size, offset) => {
if (query.length < 2) {
return { artists: [], albums: [], songs: [] }
}
const client = get().client
if (!client) {
return { artists: [], albums: [], songs: [] }
}
try {
const params: Search3Params = { query }
if (type === 'album') {
params.albumCount = size
params.albumOffset = offset
} else if (type === 'artist') {
params.artistCount = size
params.artistOffset = offset
} else if (type === 'song') {
params.songCount = size
params.songOffset = offset
} else {
params.albumCount = 5
params.artistCount = 5
params.songCount = 5
}
const response = await client.search3(params)
const artists = response.data.artists.map(get().mapArtistID3toArtist)
const albums = response.data.albums.map(get().mapAlbumID3toAlbumListItem)
const songs = await get().mapChildrenToSongs(response.data.songs)
set(
produce<MusicSlice>(state => {
state.starredSongs = reduceStarred(state.starredSongs, songs)
state.starredArtists = reduceStarred(state.starredArtists, artists)
state.starredAlbums = reduceStarred(state.starredAlbums, albums)
}),
)
return { artists, albums, songs }
} catch {
return { artists: [], albums: [], songs: [] }
}
},
homeLists: {},
homeListsUpdating: false,
fetchHomeLists: async () => {
const client = get().client
if (!client) {
return
}
if (get().homeListsUpdating) {
return
}
set({ homeListsUpdating: true })
const types = get().settings.screens.home.lists
try {
const promises: Promise<any>[] = []
for (const type of types) {
promises.push(
client
.getAlbumList2({ type: type as GetAlbumList2TypeBase, size: 20 })
.then(response => {
const list = response.data.albums.map(get().mapAlbumID3toAlbumListItem)
set(
produce<MusicSlice>(state => {
state.homeLists[type] = list
state.starredAlbums = reduceStarred(state.starredAlbums, state.homeLists[type])
}),
)
})
.catch(() => {}),
)
}
await Promise.all(promises)
} finally {
set({ homeListsUpdating: false })
}
},
clearHomeLists: () => {
set({ homeLists: {} })
},
starredSongs: {},
starredAlbums: {},
starredArtists: {},
starItem: async (id, type, unstar = false) => {
const client = get().client
if (!client) {
return
}
let params: StarParams
let setStarred: (starred: boolean) => void
switch (type) {
case 'song':
params = { id }
setStarred = starred => {
set(
produce<MusicSlice>(state => {
state.starredSongs = reduceStarred(state.starredSongs, [{ id, starred }])
}),
)
}
break
case 'album':
params = { albumId: id }
setStarred = starred => {
set(
produce<MusicSlice>(state => {
state.starredAlbums = reduceStarred(state.starredAlbums, [{ id, starred }])
}),
)
}
break
case 'artist':
params = { artistId: id }
setStarred = starred => {
set(
produce<MusicSlice>(state => {
state.starredArtists = reduceStarred(state.starredArtists, [{ id, starred }])
}),
)
}
break
default:
return
}
try {
setStarred(!unstar)
if (unstar) {
await client.unstar(params)
} else {
await client.star(params)
}
} catch {
setStarred(unstar)
}
},
albumIdCoverArt: {},
albumIdCoverArtRequests: {},
fetchAlbumCoverArt: async id => {
const client = get().client
if (!client) {
return
}
const inProgress = get().albumIdCoverArtRequests[id]
if (inProgress !== undefined) {
return await inProgress
}
const promise = new Promise<void>(async resolve => {
try {
const response = await client.getAlbum({ id })
set(
produce<MusicSlice>(state => {
state.albumIdCoverArt[id] = response.data.album.coverArt
}),
)
} finally {
resolve()
}
}).then(() => {
set(
produce<MusicSlice>(state => {
delete state.albumIdCoverArtRequests[id]
}),
)
})
set(
produce<MusicSlice>(state => {
state.albumIdCoverArtRequests[id] = promise
}),
)
return await promise
},
getAlbumCoverArt: async id => {
if (!id) {
return
}
const existing = get().albumIdCoverArt[id]
if (existing) {
return existing
}
await get().fetchAlbumCoverArt(id)
return get().albumIdCoverArt[id]
},
})
+142
View File
@@ -0,0 +1,142 @@
import {
AlbumListItem,
AlbumWithSongs,
Artist,
ArtistInfo,
PlaylistListItem,
PlaylistWithSongs,
Song,
} from '@app/models/music'
import {
AlbumID3Element,
ArtistID3Element,
ArtistInfo2Element,
ChildElement,
PlaylistElement,
PlaylistWithSongsElement,
} from '@app/subsonic/elements'
import { GetArtistResponse } from '@app/subsonic/responses'
import { GetState, SetState } from 'zustand'
import { Store } from './store'
export type MusicMapSlice = {
mapChildToSong: (child: ChildElement, coverArt?: string) => Promise<Song>
mapChildrenToSongs: (children: ChildElement[], coverArt?: string) => Promise<Song[]>
mapArtistID3toArtist: (artist: ArtistID3Element) => Artist
mapArtistInfo: (
artistResponse: GetArtistResponse,
info: ArtistInfo2Element,
topSongs: ChildElement[],
) => Promise<ArtistInfo>
mapAlbumID3toAlbumListItem: (album: AlbumID3Element) => AlbumListItem
mapAlbumID3toAlbum: (album: AlbumID3Element) => AlbumListItem
mapAlbumID3WithSongstoAlbumWithSongs: (album: AlbumID3Element, songs: ChildElement[]) => Promise<AlbumWithSongs>
mapPlaylistListItem: (playlist: PlaylistElement) => PlaylistListItem
mapPlaylistWithSongs: (playlist: PlaylistWithSongsElement) => Promise<PlaylistWithSongs>
}
export const createMusicMapSlice = (set: SetState<Store>, get: GetState<Store>): MusicMapSlice => ({
mapChildToSong: async (child, coverArt) => {
return {
itemType: 'song',
id: child.id,
album: child.album,
albumId: child.albumId,
artist: child.artist,
artistId: child.artistId,
title: child.title,
track: child.track,
discNumber: child.discNumber,
duration: child.duration,
starred: child.starred,
coverArt: coverArt || (await get().getAlbumCoverArt(child.albumId)),
streamUri: get().buildStreamUri(child.id),
}
},
mapChildrenToSongs: async (children, coverArt) => {
const albumIds = children.reduce((acc, val) => {
if (val.albumId && !(val.albumId in acc)) {
acc[val.albumId] = get().getAlbumCoverArt(val.albumId)
}
return acc
}, {} as Record<string, Promise<string | undefined>>)
await Promise.all(Object.values(albumIds))
const songs: Song[] = []
for (const child of children) {
songs.push(await get().mapChildToSong(child, coverArt || (await get().getAlbumCoverArt(child.albumId))))
}
return songs
},
mapArtistID3toArtist: artist => {
return {
itemType: 'artist',
id: artist.id,
name: artist.name,
starred: artist.starred,
coverArt: artist.coverArt,
}
},
mapArtistInfo: async (artistResponse, info, topSongs) => {
const { artist, albums } = artistResponse
const mappedAlbums = albums.map(get().mapAlbumID3toAlbum)
return {
...get().mapArtistID3toArtist(artist),
albums: mappedAlbums,
smallImageUrl: info.smallImageUrl,
largeImageUrl: info.largeImageUrl,
topSongs: (await get().mapChildrenToSongs(topSongs)).slice(0, 5),
}
},
mapAlbumID3toAlbumListItem: album => {
return {
itemType: 'album',
id: album.id,
name: album.name,
artist: album.artist,
artistId: album.artistId,
starred: album.starred,
coverArt: album.coverArt,
}
},
mapAlbumID3toAlbum: album => {
return {
...get().mapAlbumID3toAlbumListItem(album),
coverArt: album.coverArt,
year: album.year,
}
},
mapAlbumID3WithSongstoAlbumWithSongs: async (album, songs) => {
return {
...get().mapAlbumID3toAlbum(album),
songs: await get().mapChildrenToSongs(songs),
}
},
mapPlaylistListItem: playlist => {
return {
itemType: 'playlist',
id: playlist.id,
name: playlist.name,
comment: playlist.comment,
coverArt: playlist.coverArt,
}
},
mapPlaylistWithSongs: async playlist => {
return {
...get().mapPlaylistListItem(playlist),
songs: await get().mapChildrenToSongs(playlist.songs),
coverArt: playlist.coverArt,
}
},
})
+136 -91
View File
@@ -1,41 +1,21 @@
import { AlbumFilterSettings, ArtistFilterSettings, Server } from '@app/models/settings' import { AppSettings, ArtistFilterSettings, AlbumFilterSettings, Server } from '@app/models/settings'
import { ById } from '@app/models/state' import { Store } from '@app/state/store'
import { GetStore, SetStore } from '@app/state/store'
import { SubsonicApiClient } from '@app/subsonic/api' import { SubsonicApiClient } from '@app/subsonic/api'
import uuid from 'react-native-uuid' import produce from 'immer'
import { GetState, SetState } from 'zustand'
export type SettingsSlice = { export type SettingsSlice = {
settings: { settings: AppSettings
servers: ById<Server>
activeServerId?: string
screens: {
home: {
listTypes: string[]
}
library: {
albumsFilter: AlbumFilterSettings
artistsFilter: ArtistFilterSettings
}
}
scrobble: boolean
maxBitrateWifi: number
maxBitrateMobile: number
minBuffer: number
maxBuffer: number
cacheBuster: string
}
client?: SubsonicApiClient client?: SubsonicApiClient
resetServer: boolean
changeCacheBuster: () => void
setActiveServer: (id: string | undefined, force?: boolean) => Promise<void> setActiveServer: (id: string | undefined, force?: boolean) => Promise<void>
addServer: (server: Server) => void getActiveServer: () => Server | undefined
removeServer: (id: string) => void addServer: (server: Server) => Promise<void>
removeServer: (id: string) => Promise<void>
updateServer: (server: Server) => void updateServer: (server: Server) => void
setScrobble: (scrobble: boolean) => void setScrobble: (scrobble: boolean) => void
setEstimateContentLength: (estimateContentLength: boolean) => void
setMaxBitrateWifi: (maxBitrateWifi: number) => void setMaxBitrateWifi: (maxBitrateWifi: number) => void
setMaxBitrateMobile: (maxBitrateMobile: number) => void setMaxBitrateMobile: (maxBitrateMobile: number) => void
setMinBuffer: (minBuffer: number) => void setMinBuffer: (minBuffer: number) => void
@@ -47,53 +27,80 @@ export type SettingsSlice = {
setLibraryArtistFiler: (filter: ArtistFilterSettings) => void setLibraryArtistFiler: (filter: ArtistFilterSettings) => void
} }
export function newCacheBuster(): string { export const selectSettings = {
return (uuid.v4() as string).split('-')[0] client: (state: SettingsSlice) => state.client,
firstRun: (state: SettingsSlice) => state.settings.servers.length === 0,
activeServer: (state: SettingsSlice) => state.settings.servers.find(s => s.id === state.settings.activeServer),
setActiveServer: (state: SettingsSlice) => state.setActiveServer,
servers: (state: SettingsSlice) => state.settings.servers,
addServer: (state: SettingsSlice) => state.addServer,
removeServer: (state: SettingsSlice) => state.removeServer,
updateServer: (state: SettingsSlice) => state.updateServer,
homeLists: (state: SettingsSlice) => state.settings.screens.home.lists,
scrobble: (state: SettingsSlice) => state.settings.scrobble,
setScrobble: (state: SettingsSlice) => state.setScrobble,
estimateContentLength: (state: SettingsSlice) => state.settings.estimateContentLength,
setEstimateContentLength: (state: SettingsSlice) => state.setEstimateContentLength,
maxBitrateWifi: (state: SettingsSlice) => state.settings.maxBitrateWifi,
setMaxBitrateWifi: (state: SettingsSlice) => state.setMaxBitrateWifi,
maxBitrateMobile: (state: SettingsSlice) => state.settings.maxBitrateMobile,
setMaxBitrateMobile: (state: SettingsSlice) => state.setMaxBitrateMobile,
minBuffer: (state: SettingsSlice) => state.settings.minBuffer,
setMinBuffer: (state: SettingsSlice) => state.setMinBuffer,
maxBuffer: (state: SettingsSlice) => state.settings.maxBuffer,
setMaxBuffer: (state: SettingsSlice) => state.setMaxBuffer,
pingServer: (state: SettingsSlice) => state.pingServer,
setLibraryAlbumFilter: (state: SettingsSlice) => state.setLibraryAlbumFilter,
libraryAlbumFilter: (state: SettingsSlice) => state.settings.screens.library.albums,
setLibraryArtistFiler: (state: SettingsSlice) => state.setLibraryArtistFiler,
libraryArtistFilter: (state: SettingsSlice) => state.settings.screens.library.artists,
} }
export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice => ({ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>): SettingsSlice => ({
settings: { settings: {
servers: {}, servers: [],
screens: { screens: {
home: { home: {
listTypes: ['frequent', 'recent', 'starred', 'random'], lists: ['frequent', 'recent', 'starred', 'random'],
}, },
library: { library: {
albumsFilter: { albums: {
type: 'alphabeticalByArtist', type: 'alphabeticalByArtist',
fromYear: 1, fromYear: 1,
toYear: 9999, toYear: 9999,
genre: '', genre: '',
}, },
artistsFilter: { artists: {
type: 'alphabeticalByName', type: 'alphabeticalByName',
}, },
}, },
}, },
scrobble: false, scrobble: false,
estimateContentLength: true,
maxBitrateWifi: 0, maxBitrateWifi: 0,
maxBitrateMobile: 192, maxBitrateMobile: 192,
minBuffer: 6, minBuffer: 6,
maxBuffer: 60, maxBuffer: 60,
cacheBuster: newCacheBuster(),
},
resetServer: false,
changeCacheBuster: () => {
set(store => {
store.settings.cacheBuster = newCacheBuster()
})
}, },
setActiveServer: async (id, force) => { setActiveServer: async (id, force) => {
const servers = get().settings.servers const servers = get().settings.servers
const currentActiveServerId = get().settings.activeServerId const currentActiveServerId = get().settings.activeServer
const newActiveServer = id ? servers[id] : undefined const newActiveServer = servers.find(s => s.id === id)
if (!newActiveServer) { if (!newActiveServer) {
set(state => { set({
state.client = undefined client: undefined,
}) })
return return
} }
@@ -102,60 +109,82 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
return return
} }
set(state => { get().prepareCache(newActiveServer.id)
state.resetServer = true
})
set(state => { set(
state.settings.activeServerId = newActiveServer.id produce<Store>(state => {
state.settings.activeServer = newActiveServer.id
state.client = new SubsonicApiClient(newActiveServer) state.client = new SubsonicApiClient(newActiveServer)
}) }),
)
set(state => {
state.resetServer = false
})
}, },
addServer: server => { getActiveServer: () => get().settings.servers.find(s => s.id === get().settings.activeServer),
const serverIds = Object.keys(get().settings.servers)
server.id =
serverIds.length === 0 ? '0' : (serverIds.map(i => parseInt(i, 10)).sort((a, b) => b - a)[0] + 1).toString()
set(state => { addServer: async server => {
state.settings.servers[server.id] = server await get().createCache(server.id)
})
if (Object.keys(get().settings.servers).length === 1) { set(
produce<SettingsSlice>(state => {
state.settings.servers.push(server)
}),
)
if (get().settings.servers.length === 1) {
get().setActiveServer(server.id) get().setActiveServer(server.id)
} }
}, },
removeServer: id => { removeServer: async id => {
set(state => { await get().removeCache(id)
delete state.settings.servers[id]
}) set(
produce<SettingsSlice>(state => {
state.settings.servers = state.settings.servers.filter(s => s.id !== id)
}),
)
}, },
updateServer: server => { updateServer: server => {
set(state => { set(
state.settings.servers[server.id] = server produce<SettingsSlice>(state => {
}) state.settings.servers = replaceIndex(
state.settings.servers,
state.settings.servers.findIndex(s => s.id === server.id),
server,
)
}),
)
if (get().settings.activeServerId === server.id) { if (get().settings.activeServer === server.id) {
get().setActiveServer(server.id, true) get().setActiveServer(server.id, true)
} }
}, },
setScrobble: scrobble => { setScrobble: scrobble => {
set(state => { set(
produce<SettingsSlice>(state => {
state.settings.scrobble = scrobble state.settings.scrobble = scrobble
}) }),
)
},
setEstimateContentLength: estimateContentLength => {
set(
produce<SettingsSlice>(state => {
state.settings.estimateContentLength = estimateContentLength
}),
)
get().rebuildQueue()
}, },
setMaxBitrateWifi: maxBitrateWifi => { setMaxBitrateWifi: maxBitrateWifi => {
set(state => { set(
produce<SettingsSlice>(state => {
state.settings.maxBitrateWifi = maxBitrateWifi state.settings.maxBitrateWifi = maxBitrateWifi
}) }),
)
if (get().netState === 'wifi') { if (get().netState === 'wifi') {
get().rebuildQueue() get().rebuildQueue()
@@ -163,9 +192,11 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
}, },
setMaxBitrateMobile: maxBitrateMobile => { setMaxBitrateMobile: maxBitrateMobile => {
set(state => { set(
produce<SettingsSlice>(state => {
state.settings.maxBitrateMobile = maxBitrateMobile state.settings.maxBitrateMobile = maxBitrateMobile
}) }),
)
if (get().netState === 'mobile') { if (get().netState === 'mobile') {
get().rebuildQueue() get().rebuildQueue()
@@ -177,9 +208,11 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
return return
} }
set(state => { set(
produce<SettingsSlice>(state => {
state.settings.minBuffer = Math.max(1, Math.min(minBuffer, state.settings.maxBuffer / 2)) state.settings.minBuffer = Math.max(1, Math.min(minBuffer, state.settings.maxBuffer / 2))
}) }),
)
get().rebuildQueue() get().rebuildQueue()
}, },
@@ -189,9 +222,11 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
return return
} }
set(state => { set(
produce<SettingsSlice>(state => {
state.settings.maxBuffer = Math.min(5 * 60, Math.max(maxBuffer, state.settings.minBuffer * 2)) state.settings.maxBuffer = Math.min(5 * 60, Math.max(maxBuffer, state.settings.minBuffer * 2))
}) }),
)
get().rebuildQueue() get().rebuildQueue()
}, },
@@ -217,14 +252,24 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
}, },
setLibraryAlbumFilter: filter => { setLibraryAlbumFilter: filter => {
set(state => { set(
state.settings.screens.library.albumsFilter = filter produce<SettingsSlice>(state => {
}) state.settings.screens.library.albums = filter
}),
)
}, },
setLibraryArtistFiler: filter => { setLibraryArtistFiler: filter => {
set(state => { set(
state.settings.screens.library.artistsFilter = filter produce<SettingsSlice>(state => {
}) state.settings.screens.library.artists = filter
}),
)
}, },
}) })
function replaceIndex<T>(array: T[], index: number, replacement: T): T[] {
const start = array.slice(0, index)
const end = array.slice(index + 1)
return [...start, replacement, ...end]
}
+37 -68
View File
@@ -1,95 +1,64 @@
import { createMusicSlice, MusicSlice } from '@app/state/music'
import { createSettingsSlice, SettingsSlice } from '@app/state/settings' import { createSettingsSlice, SettingsSlice } from '@app/state/settings'
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import equal from 'fast-deep-equal' import create from 'zustand'
import create, { GetState, Mutate, SetState, State, StateCreator, StateSelector, StoreApi } from 'zustand' import { persist, StateStorage } from 'zustand/middleware'
import { persist, subscribeWithSelector } from 'zustand/middleware' import { CacheSlice, createCacheSlice } from './cache'
import migrations from './migrations' import { createMusicMapSlice, MusicMapSlice } from './musicmap'
import { createTrackPlayerSlice, TrackPlayerSlice } from './trackplayer' import { createTrackPlayerSlice, TrackPlayerSlice } from './trackplayer'
import produce, { Draft } from 'immer' import { createTrackPlayerMapSlice, TrackPlayerMapSlice } from './trackplayermap'
import { WritableDraft } from 'immer/dist/internal'
const DB_VERSION = migrations.length
export type Store = SettingsSlice & export type Store = SettingsSlice &
TrackPlayerSlice & { MusicSlice &
MusicMapSlice &
TrackPlayerSlice &
TrackPlayerMapSlice &
CacheSlice & {
hydrated: boolean hydrated: boolean
setHydrated: (hydrated: boolean) => void setHydrated: (hydrated: boolean) => void
} }
// taken from zustand test examples: const storage: StateStorage = {
// https://github.com/pmndrs/zustand/blob/v3.7.1/tests/middlewareTypes.test.tsx#L20 getItem: async name => {
const immer = try {
< return await AsyncStorage.getItem(name)
T extends State, } catch (err) {
CustomSetState extends SetState<T>, console.error(`getItem error (key: ${name})`, err)
CustomGetState extends GetState<T>, return null
CustomStoreApi extends StoreApi<T>, }
>(
config: StateCreator<
T,
(partial: ((draft: Draft<T>) => void) | T, replace?: boolean) => void,
CustomGetState,
CustomStoreApi
>,
): StateCreator<T, CustomSetState, CustomGetState, CustomStoreApi> =>
(set, get, api) =>
config(
(partial, replace) => {
const nextState = typeof partial === 'function' ? produce(partial as (state: Draft<T>) => T) : (partial as T)
return set(nextState, replace)
}, },
get, setItem: async (name, item) => {
api, try {
) await AsyncStorage.setItem(name, item)
} catch (err) {
console.error(`setItem error (key: ${name})`, err)
}
},
}
export type SetStore = (partial: Store | ((draft: WritableDraft<Store>) => void), replace?: boolean | undefined) => void export const useStore = create<Store>(
export type GetStore = GetState<Store>
// types taken from zustand test examples:
// https://github.com/pmndrs/zustand/blob/v3.7.1/tests/middlewareTypes.test.tsx#L584
export const useStore = create<
Store,
SetState<Store>,
GetState<Store>,
Mutate<StoreApi<Store>, [['zustand/subscribeWithSelector', never], ['zustand/persist', Partial<Store>]]>
>(
subscribeWithSelector(
persist( persist(
immer((set, get) => ({ (set, get) => ({
...createSettingsSlice(set, get), ...createSettingsSlice(set, get),
...createMusicSlice(set, get),
...createMusicMapSlice(set, get),
...createTrackPlayerSlice(set, get), ...createTrackPlayerSlice(set, get),
...createTrackPlayerMapSlice(set, get),
...createCacheSlice(set, get),
hydrated: false, hydrated: false,
setHydrated: hydrated => setHydrated: hydrated => set({ hydrated }),
set(state => {
state.hydrated = hydrated
}), }),
})),
{ {
name: '@appStore', name: '@appStore',
version: DB_VERSION, getStorage: () => storage,
getStorage: () => AsyncStorage, whitelist: ['settings', 'cacheFiles'],
partialize: state => ({ settings: state.settings }),
onRehydrateStorage: _preState => { onRehydrateStorage: _preState => {
return async (postState, _error) => { return async (postState, _error) => {
await postState?.setActiveServer(postState.settings.activeServerId, true) await postState?.setActiveServer(postState.settings.activeServer, true)
postState?.setHydrated(true) postState?.setHydrated(true)
} }
}, },
migrate: async (persistedState, version) => {
if (version > DB_VERSION) {
throw new Error('cannot migrate db on a downgrade, delete all data first')
}
for (let i = version; i < DB_VERSION; i++) {
persistedState = await migrations[i](persistedState)
}
return persistedState
}, },
},
),
), ),
) )
export const useStoreDeep = <U>(stateSelector: StateSelector<Store, U>) => useStore(stateSelector, equal)
+113 -95
View File
@@ -1,22 +1,28 @@
import { NoClientError } from '@app/models/error' import { NoClientError } from '@app/models/error'
import { Progress, QueueContextType, TrackExt } from '@app/models/trackplayer' import { Song } from '@app/models/music'
import PromiseQueue from '@app/util/PromiseQueue' import PromiseQueue from '@app/util/PromiseQueue'
import produce from 'immer' import produce from 'immer'
import TrackPlayer, { PlayerOptions, RepeatMode, State } from 'react-native-track-player' import TrackPlayer, { PlayerOptions, RepeatMode, State, Track } from 'react-native-track-player'
import { GetStore, SetStore } from './store' import { GetState, SetState } from 'zustand'
import { Store } from './store'
export type SetQueueOptions = { export type TrackExt = Track & {
title: string id: string
playTrack?: number coverArt?: string
shuffle?: boolean artistId?: string
albumId?: string
track?: number
discNumber?: number
} }
export type SetQueueOptionsInternal = SetQueueOptions & { export type Progress = {
queue: TrackExt[] position: number
contextId: string duration: number
type: QueueContextType buffered: number
} }
export type QueueContextType = 'album' | 'playlist' | 'song' | 'artist'
export type TrackPlayerSlice = { export type TrackPlayerSlice = {
queueName?: string queueName?: string
setQueueName: (name?: string) => void setQueueName: (name?: string) => void
@@ -36,15 +42,19 @@ export type TrackPlayerSlice = {
playerState: State playerState: State
setPlayerState: (playerState: State) => void setPlayerState: (playerState: State) => void
duckPaused: boolean
setDuckPaused: (duckPaused: boolean) => void
currentTrack?: TrackExt currentTrack?: TrackExt
currentTrackIdx?: number currentTrackIdx?: number
setCurrentTrackIdx: (idx?: number) => void setCurrentTrackIdx: (idx?: number) => void
queue: TrackExt[] queue: TrackExt[]
setQueue: (options: SetQueueOptionsInternal) => Promise<void> setQueue: (
songs: Song[],
name: string,
contextType: QueueContextType,
contextId: string,
playTrack?: number,
shuffle?: boolean,
) => Promise<void>
progress: Progress progress: Progress
setProgress: (progress: Progress) => void setProgress: (progress: Progress) => void
@@ -61,26 +71,55 @@ export type TrackPlayerSlice = {
getPlayerOptions: () => PlayerOptions getPlayerOptions: () => PlayerOptions
} }
export const selectTrackPlayer = {
queueName: (store: TrackPlayerSlice) => store.queueName,
setQueueName: (store: TrackPlayerSlice) => store.setQueueName,
queueContextType: (store: TrackPlayerSlice) => store.queueContextType,
setQueueContextType: (store: TrackPlayerSlice) => store.setQueueContextType,
queueContextId: (store: TrackPlayerSlice) => store.queueContextId,
setQueueContextId: (store: TrackPlayerSlice) => store.setQueueContextId,
shuffleOrder: (store: TrackPlayerSlice) => store.shuffleOrder,
shuffled: (store: TrackPlayerSlice) => !!store.shuffleOrder,
toggleShuffle: (store: TrackPlayerSlice) => store.toggleShuffle,
repeatMode: (store: TrackPlayerSlice) => store.repeatMode,
toggleRepeatMode: (store: TrackPlayerSlice) => store.toggleRepeatMode,
playerState: (store: TrackPlayerSlice) => store.playerState,
setPlayerState: (store: TrackPlayerSlice) => store.setPlayerState,
currentTrack: (store: TrackPlayerSlice) => store.currentTrack,
currentTrackIdx: (store: TrackPlayerSlice) => store.currentTrackIdx,
setCurrentTrackIdx: (store: TrackPlayerSlice) => store.setCurrentTrackIdx,
queue: (store: TrackPlayerSlice) => store.queue,
setQueue: (store: TrackPlayerSlice) => store.setQueue,
progress: (store: TrackPlayerSlice) => store.progress,
setProgress: (store: TrackPlayerSlice) => store.setProgress,
scrobbleTrack: (store: TrackPlayerSlice) => store.scrobbleTrack,
setNetState: (store: TrackPlayerSlice) => store.setNetState,
buildStreamUri: (store: TrackPlayerSlice) => store.buildStreamUri,
resetTrackPlayerState: (store: TrackPlayerSlice) => store.resetTrackPlayerState,
}
export const trackPlayerCommands = new PromiseQueue(1) export const trackPlayerCommands = new PromiseQueue(1)
export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlayerSlice => ({ export const createTrackPlayerSlice = (set: SetState<Store>, get: GetState<Store>): TrackPlayerSlice => ({
queueName: undefined, queueName: undefined,
setQueueName: name => setQueueName: name => set({ queueName: name }),
set(state => {
state.queueName = name
}),
queueContextType: undefined, queueContextType: undefined,
setQueueContextType: queueContextType => setQueueContextType: queueContextType => set({ queueContextType }),
set(state => {
state.queueContextType = queueContextType
}),
queueContextId: undefined, queueContextId: undefined,
setQueueContextId: queueContextId => setQueueContextId: queueContextId => set({ queueContextId }),
set(state => {
state.queueContextId = queueContextId
}),
shuffleOrder: undefined, shuffleOrder: undefined,
toggleShuffle: async () => { toggleShuffle: async () => {
@@ -98,9 +137,7 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
} }
await TrackPlayer.add(tracks) await TrackPlayer.add(tracks)
set(state => { set({ shuffleOrder })
state.shuffleOrder = shuffleOrder
})
} else { } else {
const tracks = unshuffleTracks(queue, queueShuffleOrder) const tracks = unshuffleTracks(queue, queueShuffleOrder)
@@ -115,18 +152,11 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
await TrackPlayer.add(tracks) await TrackPlayer.add(tracks)
} }
set(state => { set({ shuffleOrder: undefined })
state.shuffleOrder = undefined
})
} }
const newQueue = await getQueue() set({ queue: await getQueue() })
const newCurrentTrackIdx = await getCurrentTrack() get().setCurrentTrackIdx(await getCurrentTrack())
set(state => {
state.queue = newQueue
})
get().setCurrentTrackIdx(newCurrentTrackIdx)
}) })
}, },
@@ -149,17 +179,12 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
} }
await TrackPlayer.setRepeatMode(nextMode) await TrackPlayer.setRepeatMode(nextMode)
set(state => { set({ repeatMode: nextMode })
state.repeatMode = nextMode
})
}) })
}, },
playerState: State.None, playerState: State.None,
setPlayerState: playerState => setPlayerState: playerState => set({ playerState }),
set(state => {
state.playerState = playerState
}),
currentTrack: undefined, currentTrack: undefined,
currentTrackIdx: undefined, currentTrackIdx: undefined,
@@ -172,45 +197,45 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
) )
}, },
duckPaused: false,
setDuckPaused: duckPaused =>
set(state => {
state.duckPaused = duckPaused
}),
queue: [], queue: [],
setQueue: async ({ queue, title, type, contextId, playTrack, shuffle }) => { setQueue: async (songs, name, contextType, contextId, playTrack, shuffle) => {
return trackPlayerCommands.enqueue(async () => { return trackPlayerCommands.enqueue(async () => {
const shuffled = shuffle !== undefined ? shuffle : !!get().shuffleOrder const shuffled = shuffle !== undefined ? shuffle : !!get().shuffleOrder
await TrackPlayer.setupPlayer(get().getPlayerOptions()) await TrackPlayer.setupPlayer(get().getPlayerOptions())
await TrackPlayer.reset() await TrackPlayer.reset()
if (queue.length === 0) { if (songs.length === 0) {
return
}
let queue = await get().mapSongstoTrackExts(songs)
try {
for (const t of queue) {
t.url = get().buildStreamUri(t.id)
}
} catch {
return return
} }
if (shuffled) { if (shuffled) {
const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack) const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack)
set(state => { set({ shuffleOrder })
state.shuffleOrder = shuffleOrder
})
queue = tracks queue = tracks
playTrack = 0 playTrack = 0
} else { } else {
set(state => { set({ shuffleOrder: undefined })
state.shuffleOrder = undefined
})
} }
playTrack = playTrack || 0 playTrack = playTrack || 0
try { try {
set(state => { set({
state.queue = queue queue,
state.queueName = title queueName: name,
state.queueContextType = type queueContextType: contextType,
state.queueContextId = contextId queueContextId: contextId,
}) })
get().setCurrentTrackIdx(playTrack) get().setCurrentTrackIdx(playTrack)
@@ -233,10 +258,7 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
}, },
progress: { position: 0, duration: 0, buffered: 0 }, progress: { position: 0, duration: 0, buffered: 0 },
setProgress: progress => setProgress: progress => set({ progress }),
set(state => {
state.progress = progress
}),
scrobbleTrack: async id => { scrobbleTrack: async id => {
const client = get().client const client = get().client
@@ -258,9 +280,7 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
if (netState === get().netState) { if (netState === get().netState) {
return return
} }
set(state => { set({ netState })
state.netState = netState
})
get().rebuildQueue() get().rebuildQueue()
}, },
@@ -272,9 +292,8 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
} }
const currentTrack = await getCurrentTrack() const currentTrack = await getCurrentTrack()
const playerState = await getPlayerState() const state = await getPlayerState()
const position = (await TrackPlayer.getPosition()) || 0 const position = (await TrackPlayer.getPosition()) || 0
const repeatMode = await getRepeatMode()
const queueName = get().queueName const queueName = get().queueName
const queueContextId = get().queueContextId const queueContextId = get().queueContextId
@@ -291,11 +310,11 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
return return
} }
set(state => { set({
state.queue = queue queue,
state.queueName = queueName queueName,
state.queueContextType = queueContextType queueContextId,
state.queueContextId = queueContextId queueContextType,
}) })
get().setCurrentTrackIdx(currentTrack) get().setCurrentTrackIdx(currentTrack)
@@ -305,10 +324,9 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
await TrackPlayer.skip(currentTrack) await TrackPlayer.skip(currentTrack)
} }
await TrackPlayer.setRepeatMode(repeatMode)
await TrackPlayer.seekTo(position) await TrackPlayer.seekTo(position)
if (playerState === State.Playing || forcePlay) { if (state === State.Playing || forcePlay) {
await TrackPlayer.play() await TrackPlayer.play()
} }
}) })
@@ -322,23 +340,23 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
return client.streamUri({ return client.streamUri({
id, id,
estimateContentLength: true, estimateContentLength: get().settings.estimateContentLength,
maxBitRate: get().netState === 'mobile' ? get().settings.maxBitrateMobile : get().settings.maxBitrateWifi, maxBitRate: get().netState === 'mobile' ? get().settings.maxBitrateMobile : get().settings.maxBitrateWifi,
}) })
}, },
resetTrackPlayerState: () => { resetTrackPlayerState: () => {
set(state => { set({
state.queueName = undefined queueName: undefined,
state.queueContextType = undefined queueContextType: undefined,
state.queueContextId = undefined queueContextId: undefined,
state.shuffleOrder = undefined shuffleOrder: undefined,
state.repeatMode = RepeatMode.Off repeatMode: RepeatMode.Off,
state.playerState = State.None playerState: State.None,
state.currentTrack = undefined currentTrack: undefined,
state.currentTrackIdx = undefined currentTrackIdx: undefined,
state.queue = [] queue: [],
state.progress = { position: 0, duration: 0, buffered: 0 } progress: { position: 0, duration: 0, buffered: 0 },
}) })
}, },
+62
View File
@@ -0,0 +1,62 @@
import { Song } from '@app/models/music'
import { GetState, SetState } from 'zustand'
import { Store } from './store'
import { TrackExt } from './trackplayer'
export type TrackPlayerMapSlice = {
mapSongtoTrackExt: (song: Song) => Promise<TrackExt>
mapSongstoTrackExts: (songs: Song[]) => Promise<TrackExt[]>
mapTrackExtToSong: (song: TrackExt) => Song
}
export const selectTrackPlayerMap = {
mapTrackExtToSong: (store: TrackPlayerMapSlice) => store.mapTrackExtToSong,
}
export const createTrackPlayerMapSlice = (set: SetState<Store>, get: GetState<Store>): TrackPlayerMapSlice => ({
mapSongtoTrackExt: async song => {
let artwork = require('@res/fallback.png')
if (song.coverArt) {
const filePath = await get().fetchCoverArtFilePath(song.coverArt)
if (filePath) {
artwork = filePath
}
}
return {
id: song.id,
title: song.title,
artist: song.artist || 'Unknown Artist',
album: song.album || 'Unknown Album',
url: song.streamUri,
artwork,
coverArt: song.coverArt,
duration: song.duration,
artistId: song.artistId,
albumId: song.albumId,
track: song.track,
discNumber: song.discNumber,
}
},
mapSongstoTrackExts: async songs => {
return await Promise.all(songs.map(get().mapSongtoTrackExt))
},
mapTrackExtToSong: track => {
return {
itemType: 'song',
id: track.id,
title: track.title as string,
artist: track.artist,
album: track.album,
streamUri: track.url as string,
coverArt: track.coverArt,
duration: track.duration,
artistId: track.artistId,
albumId: track.albumId,
track: track.track,
discNumber: track.discNumber,
}
},
})
+4
View File
@@ -1,10 +1,14 @@
import { StatusBar } from 'react-native'
const header = 56 const header = 56
const tabBar = 54 const tabBar = 54
const top = () => header + (StatusBar.currentHeight || 0)
const bottom = () => tabBar const bottom = () => tabBar
export default { export default {
header, header,
tabBar, tabBar,
top,
bottom, bottom,
} }
+64 -63
View File
@@ -11,7 +11,6 @@ import {
GetMusicDirectoryParams, GetMusicDirectoryParams,
GetPlaylistParams, GetPlaylistParams,
GetPlaylistsParams, GetPlaylistsParams,
GetSongParams,
GetTopSongsParams, GetTopSongsParams,
ScrobbleParams, ScrobbleParams,
Search3Params, Search3Params,
@@ -30,14 +29,12 @@ import {
GetMusicDirectoryResponse, GetMusicDirectoryResponse,
GetPlaylistResponse, GetPlaylistResponse,
GetPlaylistsResponse, GetPlaylistsResponse,
GetSongResponse,
GetTopSongsResponse, GetTopSongsResponse,
NullResponse,
Search3Response, Search3Response,
SubsonicResponse,
} from '@app/subsonic/responses' } from '@app/subsonic/responses'
import toast from '@app/util/toast' import toast from '@app/util/toast'
import userAgent from '@app/util/userAgent' import { DOMParser } from 'xmldom'
import { DOMParser } from '@xmldom/xmldom'
export class SubsonicApiError extends Error { export class SubsonicApiError extends Error {
method: string method: string
@@ -65,39 +62,30 @@ export class SubsonicApiClient {
this.username = server.username this.username = server.username
this.params = new URLSearchParams() this.params = new URLSearchParams()
this.params.append('u', encodeURIComponent(server.username)) this.params.append('u', server.username)
this.params.append('t', server.token)
if (server.usePlainPassword) { this.params.append('s', server.salt)
this.params.append('p', encodeURIComponent(server.plainPassword)) this.params.append('v', '1.15.0')
} else {
this.params.append('t', encodeURIComponent(server.token))
this.params.append('s', encodeURIComponent(server.salt))
}
this.params.append('v', '1.13.0')
this.params.append('c', 'subtracks') this.params.append('c', 'subtracks')
} }
private buildUrl(method: string, params?: { [key: string]: any }): string { private buildUrl(method: string, params?: { [key: string]: any }): string {
let urlParams = this.params.toString() let query = this.params.toString()
if (params) { if (params) {
const methodParams = this.obj2Params(params) const urlParams = this.obj2Params(params)
if (methodParams) { if (urlParams) {
urlParams += '&' + methodParams.toString() query += '&' + urlParams.toString()
} }
} }
// *.view was present on all method names in API 1.14.0 and earlier return `${this.address}/rest/${method}?${query}`
return `${this.address}/rest/${method}.view?${urlParams}`
} }
private async apiGetXml(method: string, params?: { [key: string]: any }): Promise<Document> { private async apiGetXml(method: string, params?: { [key: string]: any }): Promise<Document> {
let text: string let text: string
try { try {
const response = await fetch(this.buildUrl(method, params), { const response = await fetch(this.buildUrl(method, params))
headers: { 'User-Agent': userAgent },
})
text = await response.text() text = await response.text()
} catch (err) { } catch (err) {
toast(`Network error: ${this.address}`) toast(`Network error: ${this.address}`)
@@ -123,7 +111,7 @@ export class SubsonicApiClient {
if (obj[key] === undefined || obj[key] === null) { if (obj[key] === undefined || obj[key] === null) {
continue continue
} }
params.append(key, encodeURIComponent(String(obj[key]))) params.append(key, String(obj[key]))
} }
return params return params
@@ -133,72 +121,81 @@ export class SubsonicApiClient {
// System // System
// //
async ping(): Promise<NullResponse> { async ping(): Promise<SubsonicResponse<null>> {
return new NullResponse(await this.apiGetXml('ping')) const xml = await this.apiGetXml('ping')
return new SubsonicResponse<null>(xml, null)
} }
// //
// Browsing // Browsing
// //
async getArtists(): Promise<GetArtistsResponse> { async getArtists(): Promise<SubsonicResponse<GetArtistsResponse>> {
return new GetArtistsResponse(await this.apiGetXml('getArtists')) const xml = await this.apiGetXml('getArtists')
return new SubsonicResponse<GetArtistsResponse>(xml, new GetArtistsResponse(xml))
} }
async getIndexes(params?: GetIndexesParams): Promise<GetIndexesResponse> { async getIndexes(params?: GetIndexesParams): Promise<SubsonicResponse<GetIndexesResponse>> {
return new GetIndexesResponse(await this.apiGetXml('getIndexes', params)) const xml = await this.apiGetXml('getIndexes', params)
return new SubsonicResponse<GetIndexesResponse>(xml, new GetIndexesResponse(xml))
} }
async getMusicDirectory(params: GetMusicDirectoryParams): Promise<GetMusicDirectoryResponse> { async getMusicDirectory(params: GetMusicDirectoryParams): Promise<SubsonicResponse<GetMusicDirectoryResponse>> {
return new GetMusicDirectoryResponse(await this.apiGetXml('getMusicDirectory', params)) const xml = await this.apiGetXml('getMusicDirectory', params)
return new SubsonicResponse<GetMusicDirectoryResponse>(xml, new GetMusicDirectoryResponse(xml))
} }
async getAlbum(params: GetAlbumParams): Promise<GetAlbumResponse> { async getAlbum(params: GetAlbumParams): Promise<SubsonicResponse<GetAlbumResponse>> {
return new GetAlbumResponse(await this.apiGetXml('getAlbum', params)) const xml = await this.apiGetXml('getAlbum', params)
return new SubsonicResponse<GetAlbumResponse>(xml, new GetAlbumResponse(xml))
} }
async getArtistInfo(params: GetArtistInfoParams): Promise<GetArtistInfoResponse> { async getArtistInfo(params: GetArtistInfoParams): Promise<SubsonicResponse<GetArtistInfoResponse>> {
return new GetArtistInfoResponse(await this.apiGetXml('getArtistInfo', params)) const xml = await this.apiGetXml('getArtistInfo', params)
return new SubsonicResponse<GetArtistInfoResponse>(xml, new GetArtistInfoResponse(xml))
} }
async getArtistInfo2(params: GetArtistInfo2Params): Promise<GetArtistInfo2Response> { async getArtistInfo2(params: GetArtistInfo2Params): Promise<SubsonicResponse<GetArtistInfo2Response>> {
return new GetArtistInfo2Response(await this.apiGetXml('getArtistInfo2', params)) const xml = await this.apiGetXml('getArtistInfo2', params)
return new SubsonicResponse<GetArtistInfo2Response>(xml, new GetArtistInfo2Response(xml))
} }
async getArtist(params: GetArtistParams): Promise<GetArtistResponse> { async getArtist(params: GetArtistParams): Promise<SubsonicResponse<GetArtistResponse>> {
return new GetArtistResponse(await this.apiGetXml('getArtist', params)) const xml = await this.apiGetXml('getArtist', params)
return new SubsonicResponse<GetArtistResponse>(xml, new GetArtistResponse(xml))
} }
async getTopSongs(params: GetTopSongsParams): Promise<GetTopSongsResponse> { async getTopSongs(params: GetTopSongsParams): Promise<SubsonicResponse<GetTopSongsResponse>> {
return new GetTopSongsResponse(await this.apiGetXml('getTopSongs', params)) const xml = await this.apiGetXml('getTopSongs', params)
} return new SubsonicResponse<GetTopSongsResponse>(xml, new GetTopSongsResponse(xml))
async getSong(params: GetSongParams): Promise<GetSongResponse> {
return new GetSongResponse(await this.apiGetXml('getSong', params))
} }
// //
// Album/song lists // Album/song lists
// //
async getAlbumList(params: GetAlbumListParams): Promise<GetAlbumListResponse> { async getAlbumList(params: GetAlbumListParams): Promise<SubsonicResponse<GetAlbumListResponse>> {
return new GetAlbumListResponse(await this.apiGetXml('getAlbumList', params)) const xml = await this.apiGetXml('getAlbumList', params)
return new SubsonicResponse<GetAlbumListResponse>(xml, new GetAlbumListResponse(xml))
} }
async getAlbumList2(params: GetAlbumList2Params): Promise<GetAlbumList2Response> { async getAlbumList2(params: GetAlbumList2Params): Promise<SubsonicResponse<GetAlbumList2Response>> {
return new GetAlbumList2Response(await this.apiGetXml('getAlbumList2', params)) const xml = await this.apiGetXml('getAlbumList2', params)
return new SubsonicResponse<GetAlbumList2Response>(xml, new GetAlbumList2Response(xml))
} }
// //
// Playlists // Playlists
// //
async getPlaylists(params?: GetPlaylistsParams): Promise<GetPlaylistsResponse> { async getPlaylists(params?: GetPlaylistsParams): Promise<SubsonicResponse<GetPlaylistsResponse>> {
return new GetPlaylistsResponse(await this.apiGetXml('getPlaylists', params)) const xml = await this.apiGetXml('getPlaylists', params)
return new SubsonicResponse<GetPlaylistsResponse>(xml, new GetPlaylistsResponse(xml))
} }
async getPlaylist(params: GetPlaylistParams): Promise<GetPlaylistResponse> { async getPlaylist(params: GetPlaylistParams): Promise<SubsonicResponse<GetPlaylistResponse>> {
return new GetPlaylistResponse(await this.apiGetXml('getPlaylist', params)) const xml = await this.apiGetXml('getPlaylist', params)
return new SubsonicResponse<GetPlaylistResponse>(xml, new GetPlaylistResponse(xml))
} }
// //
@@ -217,23 +214,27 @@ export class SubsonicApiClient {
// Media annotation // Media annotation
// //
async scrobble(params: ScrobbleParams): Promise<NullResponse> { async scrobble(params: ScrobbleParams): Promise<SubsonicResponse<undefined>> {
return new NullResponse(await this.apiGetXml('scrobble', params)) const xml = await this.apiGetXml('scrobble', params)
return new SubsonicResponse<undefined>(xml, undefined)
} }
async star(params: StarParams): Promise<NullResponse> { async star(params: StarParams): Promise<SubsonicResponse<undefined>> {
return new NullResponse(await this.apiGetXml('star', params)) const xml = await this.apiGetXml('star', params)
return new SubsonicResponse<undefined>(xml, undefined)
} }
async unstar(params: StarParams): Promise<NullResponse> { async unstar(params: StarParams): Promise<SubsonicResponse<undefined>> {
return new NullResponse(await this.apiGetXml('unstar', params)) const xml = await this.apiGetXml('unstar', params)
return new SubsonicResponse<undefined>(xml, undefined)
} }
// //
// Searching // Searching
// //
async search3(params: Search3Params): Promise<Search3Response> { async search3(params: Search3Params): Promise<SubsonicResponse<Search3Response>> {
return new Search3Response(await this.apiGetXml('search3', params)) const xml = await this.apiGetXml('search3', params)
return new SubsonicResponse<Search3Response>(xml, new Search3Response(xml))
} }
} }
+10 -3
View File
@@ -101,7 +101,10 @@ export class BaseArtistInfoElement<T> {
this.largeImageUrl = e.getElementsByTagName('largeImageUrl')[0].textContent as string this.largeImageUrl = e.getElementsByTagName('largeImageUrl')[0].textContent as string
} }
this.similarArtists = Array.from(e.getElementsByTagName('similarArtist')).map(i => new artistType(i)) const similarArtistElements = e.getElementsByTagName('similarArtist')
for (let i = 0; i < similarArtistElements.length; i++) {
this.similarArtists.push(new artistType(similarArtistElements[i]))
}
} }
} }
@@ -247,7 +250,9 @@ export class PlaylistElement {
coverArt?: string coverArt?: string
constructor(e: Element) { constructor(e: Element) {
this.allowedUser = Array.from(e.getElementsByTagName('allowedUser')).map(i => i.textContent as string) for (let i = 0; i < e.getElementsByTagName('allowedUser').length; i++) {
this.allowedUser.push(e.getElementsByTagName('allowedUser')[i].textContent as string)
}
this.id = requiredString(e, 'id') this.id = requiredString(e, 'id')
this.name = requiredString(e, 'name') this.name = requiredString(e, 'name')
@@ -268,6 +273,8 @@ export class PlaylistWithSongsElement extends PlaylistElement {
constructor(e: Element) { constructor(e: Element) {
super(e) super(e)
this.songs = Array.from(e.getElementsByTagName('entry')).map(i => new ChildElement(i)) for (let i = 0; i < e.getElementsByTagName('entry').length; i++) {
this.songs.push(new ChildElement(e.getElementsByTagName('entry')[i]))
}
} }
} }
-4
View File
@@ -27,10 +27,6 @@ export type GetArtistParams = {
id: string id: string
} }
export type GetSongParams = {
id: string
}
export type GetTopSongsParams = { export type GetTopSongsParams = {
artist: string artist: string
count?: number count?: number
+74 -122
View File
@@ -12,160 +12,119 @@ import {
export type ResponseStatus = 'ok' | 'failed' export type ResponseStatus = 'ok' | 'failed'
export class SubsonicResponse { export class SubsonicResponse<T> {
status: ResponseStatus status: ResponseStatus
version: string version: string
data: T
constructor(xml: Document) { constructor(xml: Document, data: T) {
this.data = data
this.status = xml.documentElement.getAttribute('status') as ResponseStatus this.status = xml.documentElement.getAttribute('status') as ResponseStatus
this.version = xml.documentElement.getAttribute('version') as string this.version = xml.documentElement.getAttribute('version') as string
} }
} }
export class NullResponse extends SubsonicResponse {
data = null
}
// //
// Browsing // Browsing
// //
export class GetArtistsResponse extends SubsonicResponse { export class GetArtistsResponse {
data: {
ignoredArticles: string ignoredArticles: string
artists: ArtistID3Element[] artists: ArtistID3Element[] = []
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml) this.ignoredArticles = xml.getElementsByTagName('artists')[0].getAttribute('ignoredArticles') as string
this.data = { const artistElements = xml.getElementsByTagName('artist')
ignoredArticles: xml.getElementsByTagName('artists')[0].getAttribute('ignoredArticles') || '', for (let i = 0; i < artistElements.length; i++) {
artists: Array.from(xml.getElementsByTagName('artist')).map(i => new ArtistID3Element(i)), this.artists.push(new ArtistID3Element(artistElements[i]))
} }
} }
} }
export class GetArtistResponse extends SubsonicResponse { export class GetArtistResponse {
data: {
artist: ArtistID3Element artist: ArtistID3Element
albums: AlbumID3Element[] albums: AlbumID3Element[] = []
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml) this.artist = new ArtistID3Element(xml.getElementsByTagName('artist')[0])
this.data = { const albumElements = xml.getElementsByTagName('album')
artist: new ArtistID3Element(xml.getElementsByTagName('artist')[0]), for (let i = 0; i < albumElements.length; i++) {
albums: Array.from(xml.getElementsByTagName('album')).map(i => new AlbumID3Element(i)), this.albums.push(new AlbumID3Element(albumElements[i]))
} }
} }
} }
export class GetIndexesResponse extends SubsonicResponse { export class GetIndexesResponse {
data: {
ignoredArticles: string ignoredArticles: string
lastModified: number lastModified: number
artists: ArtistElement[] artists: ArtistElement[] = []
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml)
const indexesElement = xml.getElementsByTagName('indexes')[0] const indexesElement = xml.getElementsByTagName('indexes')[0]
this.data = { this.ignoredArticles = indexesElement.getAttribute('ignoredArticles') as string
ignoredArticles: indexesElement.getAttribute('ignoredArticles') || '', this.lastModified = parseInt(indexesElement.getAttribute('lastModified') as string, 10)
lastModified: parseInt(indexesElement.getAttribute('lastModified') || '0', 10),
artists: Array.from(xml.getElementsByTagName('artist')).map(i => new ArtistElement(i)), const artistElements = xml.getElementsByTagName('artist')
for (let i = 0; i < artistElements.length; i++) {
this.artists.push(new ArtistElement(artistElements[i]))
} }
} }
} }
export class GetArtistInfoResponse extends SubsonicResponse { export class GetArtistInfoResponse {
data: {
artistInfo: ArtistInfoElement artistInfo: ArtistInfoElement
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml) this.artistInfo = new ArtistInfoElement(xml.getElementsByTagName('artistInfo')[0])
this.data = {
artistInfo: new ArtistInfoElement(xml.getElementsByTagName('artistInfo')[0]),
}
} }
} }
export class GetArtistInfo2Response extends SubsonicResponse { export class GetArtistInfo2Response {
data: {
artistInfo: ArtistInfo2Element artistInfo: ArtistInfo2Element
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml) this.artistInfo = new ArtistInfo2Element(xml.getElementsByTagName('artistInfo2')[0])
this.data = {
artistInfo: new ArtistInfo2Element(xml.getElementsByTagName('artistInfo2')[0]),
}
} }
} }
export class GetMusicDirectoryResponse extends SubsonicResponse { export class GetMusicDirectoryResponse {
data: {
directory: DirectoryElement directory: DirectoryElement
children: ChildElement[] children: ChildElement[] = []
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml) this.directory = new DirectoryElement(xml.getElementsByTagName('directory')[0])
this.data = { const childElements = xml.getElementsByTagName('child')
directory: new DirectoryElement(xml.getElementsByTagName('directory')[0]), for (let i = 0; i < childElements.length; i++) {
children: Array.from(xml.getElementsByTagName('child')).map(i => new ChildElement(i)), this.children.push(new ChildElement(childElements[i]))
} }
} }
} }
export class GetAlbumResponse extends SubsonicResponse { export class GetAlbumResponse {
data: {
album: AlbumID3Element album: AlbumID3Element
songs: ChildElement[] songs: ChildElement[] = []
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml) this.album = new AlbumID3Element(xml.getElementsByTagName('album')[0])
this.data = { const childElements = xml.getElementsByTagName('song')
album: new AlbumID3Element(xml.getElementsByTagName('album')[0]), for (let i = 0; i < childElements.length; i++) {
songs: Array.from(xml.getElementsByTagName('song')).map(i => new ChildElement(i)), this.songs.push(new ChildElement(childElements[i]))
} }
} }
} }
export class GetTopSongsResponse extends SubsonicResponse { export class GetTopSongsResponse {
data: { songs: ChildElement[] = []
songs: ChildElement[]
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml) const childElements = xml.getElementsByTagName('song')
for (let i = 0; i < childElements.length; i++) {
this.data = { this.songs.push(new ChildElement(childElements[i]))
songs: Array.from(xml.getElementsByTagName('song')).map(i => new ChildElement(i)),
}
}
}
export class GetSongResponse extends SubsonicResponse {
data: {
song: ChildElement
}
constructor(xml: Document) {
super(xml)
this.data = {
song: new ChildElement(xml.getElementsByTagName('song')[0]),
} }
} }
} }
@@ -174,16 +133,13 @@ export class GetSongResponse extends SubsonicResponse {
// Album/song lists // Album/song lists
// //
class BaseGetAlbumListResponse<T> extends SubsonicResponse { class BaseGetAlbumListResponse<T> {
data: { albums: T[] = []
albums: T[]
}
constructor(xml: Document, AlbumType: new (e: Element) => T) { constructor(xml: Document, albumType: new (e: Element) => T) {
super(xml) const albumElements = xml.getElementsByTagName('album')
for (let i = 0; i < albumElements.length; i++) {
this.data = { this.albums.push(new albumType(albumElements[i]))
albums: Array.from(xml.getElementsByTagName('album')).map(i => new AlbumType(i)),
} }
} }
} }
@@ -204,31 +160,22 @@ export class GetAlbumList2Response extends BaseGetAlbumListResponse<AlbumID3Elem
// Playlists // Playlists
// //
export class GetPlaylistsResponse extends SubsonicResponse { export class GetPlaylistsResponse {
data: { playlists: PlaylistElement[] = []
playlists: PlaylistElement[]
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml) const playlistElements = xml.getElementsByTagName('playlist')
for (let i = 0; i < playlistElements.length; i++) {
this.data = { this.playlists.push(new PlaylistElement(playlistElements[i]))
playlists: Array.from(xml.getElementsByTagName('playlist')).map(i => new PlaylistElement(i)),
} }
} }
} }
export class GetPlaylistResponse extends SubsonicResponse { export class GetPlaylistResponse {
data: {
playlist: PlaylistWithSongsElement playlist: PlaylistWithSongsElement
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml) this.playlist = new PlaylistWithSongsElement(xml.getElementsByTagName('playlist')[0])
this.data = {
playlist: new PlaylistWithSongsElement(xml.getElementsByTagName('playlist')[0]),
}
} }
} }
@@ -236,20 +183,25 @@ export class GetPlaylistResponse extends SubsonicResponse {
// Searching // Searching
// //
export class Search3Response extends SubsonicResponse { export class Search3Response {
data: { artists: ArtistID3Element[] = []
artists: ArtistID3Element[] albums: AlbumID3Element[] = []
albums: AlbumID3Element[] songs: ChildElement[] = []
songs: ChildElement[]
}
constructor(xml: Document) { constructor(xml: Document) {
super(xml) const artistElements = xml.getElementsByTagName('artist')
for (let i = 0; i < artistElements.length; i++) {
this.artists.push(new ArtistID3Element(artistElements[i]))
}
this.data = { const albumElements = xml.getElementsByTagName('album')
artists: Array.from(xml.getElementsByTagName('artist')).map(i => new ArtistID3Element(i)), for (let i = 0; i < albumElements.length; i++) {
albums: Array.from(xml.getElementsByTagName('album')).map(i => new AlbumID3Element(i)), this.albums.push(new AlbumID3Element(albumElements[i]))
songs: Array.from(xml.getElementsByTagName('song')).map(i => new ChildElement(i)), }
const songElements = xml.getElementsByTagName('song')
for (let i = 0; i < songElements.length; i++) {
this.songs.push(new ChildElement(songElements[i]))
} }
} }
} }
+14 -10
View File
@@ -1,15 +1,19 @@
import RNFS from 'react-native-fs' import RNFS from 'react-native-fs'
import path from 'path'
import { CacheItemTypeKey } from '@app/models/cache'
const serversCacheDir = path.join(RNFS.ExternalDirectoryPath, 's') export async function mkdir(path: string): Promise<void> {
const exists = await RNFS.exists(path)
if (exists) {
const isDir = (await RNFS.stat(path)).isDirectory()
if (!isDir) {
throw new Error(`path exists and is not a directory: ${path}`)
} else {
return
}
}
export function cacheDir(serverId?: string, itemType?: CacheItemTypeKey, itemId?: string): string { return await RNFS.mkdir(path)
const segments: string[] = [] }
serverId && segments.push(serverId) export async function rmdir(path: string): Promise<void> {
serverId && itemType && segments.push(itemType) return RNFS.unlink(path)
serverId && itemType && itemId && segments.push(itemId)
return path.join(serversCacheDir, ...segments)
} }
-32
View File
@@ -1,32 +0,0 @@
import { ById, CollectionById } from '@app/models/state'
import _ from 'lodash'
export function reduceById<T extends { id: string }>(collection: T[]): ById<T> {
return collection.reduce((acc, value) => {
acc[value.id] = value
return acc
}, {} as ById<T>)
}
export function mergeById<T extends { [id: string]: unknown }>(object: T, source: T): void {
_.merge(object, source)
}
export function mapById<T>(object: ById<T>, ids: string[]): T[] {
return ids.map(id => object[id]).filter(a => a !== undefined)
}
export function mapId(entities: { id: string }[]): string[] {
return entities.map(e => e.id)
}
export function mapCollectionById<T, U extends { id: string }>(
collection: T[],
map: (item: T) => U,
): CollectionById<U> {
const mapped = collection.map(map)
return {
byId: reduceById(mapped),
allIds: mapId(mapped),
}
}
-3
View File
@@ -1,3 +0,0 @@
import { Platform } from 'react-native'
export default `subtracks/${Platform.OS}`
+2 -6
View File
@@ -1,15 +1,11 @@
import 'react-native-gesture-handler' import 'react-native-gesture-handler'
import 'react-native-get-random-values'
import { enableScreens } from 'react-native-screens' import { enableScreens } from 'react-native-screens'
enableScreens() enableScreens()
import { LogBox } from 'react-native' import { LogBox } from 'react-native'
LogBox.ignoreLogs([ LogBox.ignoreLogs(["The action 'POP_TO_TOP'"])
"The action 'POP_TO_TOP'",
'`new NativeEventEmitter()` was called with a non-null argument without the required `addListener` method.',
'`new NativeEventEmitter()` was called with a non-null argument without the required `removeListeners` method.',
"[react-native-gesture-handler] Seems like you're using an old API with gesture components, check out new Gestures system!",
])
import { AppRegistry } from 'react-native' import { AppRegistry } from 'react-native'
import App from '@app/App' import App from '@app/App'
-4
View File
@@ -1,4 +0,0 @@
## Changes
- Remove unused permissions (read/write external storage)
- Fix text cutting off on Now Playing screen
-11
View File
@@ -1,11 +0,0 @@
## New
- Added ALAC file playback support
- Added plain-text password option for older servers
- Target API version 1.14.0 (instead of 1.15.0) to support more servers (Ampache, Airsonic Advanced)
## Fixed
- Removed non-working cast button until support is added
- Fixed Subtracks showing up multiple times in Navidrome clients menu (too many user-agents)
- Fixed network error for HTTPS servers using self-signed certificates that the user has trusted
-2
View File
@@ -1,2 +0,0 @@
## Fixed
- Don't resume playback after alarm/call ends if the alarm/call didn't pause playback in the first place
-5
View File
@@ -1,5 +0,0 @@
## Fixed
- Reduced required version of Subsonic API support from servers to 1.13.0 (#53)
- Fixed autocorrect/autofill in server settings input (#60)
- Fixed issues special characters in plain text passwords (and elsewhere) (#81)
-5
View File
@@ -1,5 +0,0 @@
## Fixed
- Reduced required version of Subsonic API support from servers to 1.13.0 (#53)
- Fixed autocorrect/autofill in server settings input (#60)
- Fixed issues special characters in plain text passwords (and elsewhere) (#81)

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