104 Commits
Author SHA1 Message Date
austinried abc5050acd Merge branch 'main' of github.com:austinried/subtracks into main 2022-04-12 10:21:37 +09:00
austinried edbcdfc165 bump version 2022-04-12 10:18:33 +09:00
austinried 530ea15a08 Fix pausing after playing a few tracks (#94)
we only get this error when playing so we should always keep playing
2022-04-12 09:45:38 +09:00
austinried 6a4ae03a26 restore repeat mode when rebuilding the queue (#93) 2022-04-12 09:45:10 +09:00
austinried 66a3c62ab5 remove ruby stuff for iOS 2022-04-11 15:36:18 +09:00
austinried 585d735cfe move back to api 30 2022-04-11 15:15:57 +09:00
austinried bf47832c77 downgrade async-storage a bit
some kind of java/jetifier issue in 1.17 that i don't want to deal with
2022-04-11 13:53:11 +09:00
austinried 91ff209dbb update licences 2022-04-11 12:28:33 +09:00
austinried 1a3553dd60 upgade deps
async-storage 1.17.3: required updating sdk to 31, kotlin to 1.6.10
react-native-track-player 2.1.3: added new "android:exported" prop required by API 31
react-native-fs: 2.19.0
2022-04-11 12:14:45 +09:00
austinried 69ec9b1dfe upgrade to react-native 0.67.4 2022-04-11 11:07:17 +09:00
austinried 2170137729 filter topSongs backup search by artistName 2022-04-11 10:47:51 +09:00
austinried 335f30b0d2 React Query refactor (#91)
* initial react-query experiments

* use queries for item screens

send the data we do have over routing to prepopulate (album/playlist)
use number for starred because sending Date freaks out react-navigation

* add in equiv. song cover art fix

* reorg, switch artistview over

start mapping song cover art when any are available

* refactor useStar to queries

fix caching for starred items and album cover art

* add hook to reset queries on server change

* refactor search to use query

* fix song cover art setting

* use query for artistInfo

* remove last bits of library state

* cleanup

* use query key factory

already fixed one wrong key...

* require coverart size

* let's try no promise queues on these for now

* image cache uses query

* perf fix for playlist parsing

also use placeholder data so we don't have to deal with staleness

* drill that disabled

also list controls doesn't need its own songs hook/copy

* switch to react-native-blob-util for downloads

slightly slower but allows us to use DownloadManager, which backgrounds downloads so they are no longer corrupted when the app suspends

* add a fake "top songs" based on artist search

then sorted by play count/ratings
artistview should load now even if topSongs fails

* try not to swap between topSongs/search on refetch

set queueContext by song list so the index isn't off if the list changes

* add content type validation for file fetching

also try to speed up existing file return by limiting fs ops

* if the HEAD fails, don't queue the download

* clean up params

* reimpl clear image cache

* precompute contextId

prevents wrong "is playing" when any mismatch between queue and list

* clear images from all servers

use external files dir instead of cache

* fix pressable disabled flicker

don't retry topsongs on failure
try to optimize setqueue and fixcoverart a bit

* wait for queries during clear

* break out fetchExistingFile from fetchFile

allows to tell if file is coming from disk or not
only show placeholder/loading spinner if actually fetching image

* forgot these wouldn't do anything with objects

* remove query cache when switching servers

* add content-disposition extension gathering

add support for progress hook (needs native support still)

* added custom RNBU pkg with progress changes

* fully unmount tabs when server changes

prevents unwanted requests, gives fresh start on switch
fix fixCoverArt not re-rendering in certain cases on search

* use serverId from fetch deps

* fix lint

* update licenses

* just use the whole lodash package

* make using cache buster optional
2022-04-11 09:40:51 +09:00
dependabot[bot] ccc7f44957 Bump cocoapods-downloader from 1.5.1 to 1.6.3 (#88)
Bumps [cocoapods-downloader](https://github.com/CocoaPods/cocoapods-downloader) from 1.5.1 to 1.6.3.
- [Release notes](https://github.com/CocoaPods/cocoapods-downloader/releases)
- [Changelog](https://github.com/CocoaPods/cocoapods-downloader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/CocoaPods/cocoapods-downloader/compare/1.5.1...1.6.3)

---
updated-dependencies:
- dependency-name: cocoapods-downloader
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-06 17:12:10 +09:00
austinried 7a20181386 remove react-native-get-random-values (#87)
swap in react-native-uuid, which is a js-only impl of uuid without native requirements
2022-04-06 01:22:05 +09:00
austinried accb969e73 switch to useSafeAreaInsets().top (#85)
StatusBar.currentHeight seems just broken on some devices
2022-04-04 12:48:10 +09:00
austinried 9923a8cbcf some files still using old folder names 2022-04-03 10:29:23 +09:00
austinried 387d181073 repeat v1.1.2 changelog for google play 2022-04-01 10:22:23 +09:00
austinried b156e722b6 bump version for google play 2022-04-01 10:19:32 +09:00
austinried f256047b79 Release/v1.1.2 (#82)
* add changelog

* increment version
2022-03-28 16:16:48 +09:00
austinried 6fbb1aefc7 encode values before using in url (#81) 2022-03-28 15:44:58 +09:00
austinried 6a0e2c8be2 update build to main branch
add new ignores
2022-03-28 14:32:08 +09:00
dependabot[bot] 736c4b211b Bump ansi-regex from 4.1.0 to 4.1.1 (#80)
Bumps [ansi-regex](https://github.com/chalk/ansi-regex) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/chalk/ansi-regex/releases)
- [Commits](https://github.com/chalk/ansi-regex/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: ansi-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-28 14:21:17 +09:00
dependabot[bot] b7a9e6cd7a Bump minimist from 1.2.5 to 1.2.6 (#79)
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-28 14:21:05 +09:00
dependabot[bot] bb9b82077c Bump plist from 3.0.4 to 3.0.5 (#78)
Bumps [plist](https://github.com/TooTallNate/node-plist) from 3.0.4 to 3.0.5.
- [Release notes](https://github.com/TooTallNate/node-plist/releases)
- [Changelog](https://github.com/TooTallNate/plist.js/blob/master/History.md)
- [Commits](https://github.com/TooTallNate/node-plist/commits)

---
updated-dependencies:
- dependency-name: plist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-28 14:06:20 +09:00
austinried fed4a4b217 run validation before yarn license (#77)
* run validation before yarn license

* attempt to fix CI errors

https://github.com/yarnpkg/yarn/issues/7212
2022-03-28 14:05:49 +09:00
austinried 5d5188ae5b Library store refactor (#76)
* start of music store refactor

moving stuff into a state cache
better separate it from view logic

* added paginated list/album list

* reworked fetchAlbumList to remove ui state

refactored home screen to use new method
i broke playing songs somehow, JS thread goes into a loop

* don't reset parts manually, do it all at once

* fixed perf issue related to too many rerenders

rerenders were caused by strict equality check on object/array picks
switched artistInfo to new store
updated zustand and fixed deprecation warnings

* update typescript

and use workspace tsc version for vscode

* remove old artistInfo

* switched to new playlist w/songs

removed more unused stuff

* remove unused + (slightly) rework search

* refactor star

* use only original/large imges for covers/artist

fix view artist from context menu
add loading indicators to song list and artist views (show info we have right away)

* set starred/unstar assuming it works

and correct state on error

* reorg, remove old music slice files

* added back fix for song cover art

* sort artists by localCompare name

* update licenses

* fix now playing background grey bar

* update react-native-gesture-handler

for node-fetch security alert

* fix another gradient height grey bar issue

* update licenses again

* remove thumbnail cache

* rename to remove "Library" from methods

* Revert "remove thumbnail cache"

This reverts commit e0db4931f1.

* use ids for lists, pull state later

* Revert "use only original/large imges for covers/artist"

This reverts commit c9aea9065c.

* deep equal ListItem props for now

this needs a bigger refactor

* use immer as middleware

* refactor api client to use string method

hoping to use this for requestKey/deduping next

* use thumbnails in list items

* Revert "refactor api client to use string method"

This reverts commit 234326135b.

* rename/cleanup

* store servers by id

* get rid of settings selectors

* renames for clarity

remove unused estimateContentLength setting

* remove trackplayer selectors

* fix migration for library filter settings

* fixed shuffle order reporting wrong track/queue

* removed the other selectors

* don't actually need es6/react for our state

* fix slow artist sort on star

localeCompare is too slow for large lists
2022-03-28 13:30:57 +09:00
austinried b6e3a20e60 don't run prepare on yarn install... 2022-03-12 09:24:52 +09:00
austinried 97e442f306 add build instructions
add yarn script to prepare exoplayer/ffmpeg
2022-02-02 16:03:05 +09:00
austinried bc14fc9427 Upgrade React Native and deps (#68)
* upgrade RN to 0.67-rc.6
upgrade react-native-reanimated to 2.3.1
debug and release building/working

* update to RN stable 0.67

* yarn upgrade

also use xmldom builtin types

* ignore ndk dir for linting

* upgrade types (RN 0.67 not out yet)

* remove deprecated rule

* actually i like that one, keep for now

* update licenses

* upgrade RN types and use builtin dep

* upgrade RN 0.67.1
2022-01-23 13:36:02 +09:00
austinried 278ccdc471 fix boolean 2022-01-12 09:25:45 +09:00
Andrej Shadura d00eeed815 Fix autocorrect/autofill in server settings (#59) (#60)
Prevent autocorrection on URLs, disable autoCorrect and
autoCapitalise and set importantForAutofill.
2022-01-11 22:08:42 +09:00
austinried 7f098a2085 reduce api version to lowest currently needed (#53) 2021-12-18 09:50:16 +09:00
austinried 6b39d35e84 add .nvmrc for node 14.x 2021-12-15 14:38:15 +09:00
austinried 5a955e40cd changelog for 1.1.1 2021-12-15 14:08:37 +09:00
austinried bfc4b1f988 bump version 1.1.1 2021-12-15 14:05:51 +09:00
austinried c2f48db043 check if we paused on duck before playing again (#51)
prevents music playing after an alarm or call ends if it wasn't paused by that alarm/call in the first place
2021-12-15 13:59:58 +09:00
austinried f25f66fa41 Create FUNDING.yml 2021-12-13 09:34:22 +09:00
austinried 9a4357829b Release/v1.1.0 (#47)
* bump version for 1.1.0

* add changelog for 1.1.0
2021-12-06 11:05:18 +09:00
austinried 052e88fa5d Trust self-signed user certificates (#46)
* add network config to trust user certificates

* allow clear text traffic again
2021-12-06 10:16:06 +09:00
austinried c89a94b208 use .view method suffix for 1.14.0 (#45) 2021-12-06 10:05:59 +09:00
dependabot[bot] c0a93857ac Bump tmpl from 1.0.4 to 1.0.5 (#44)
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

---
updated-dependencies:
- dependency-name: tmpl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-12-04 15:26:22 +09:00
Theo Salzmannandaustinried 0220e86883 FEATURE: add plain text password toggle to settings (#22)
* FEATURE: add plain text password toggle to settings

* clean up state types, lint, and add migrate

Co-authored-by: austinried <4966622+austinried@users.noreply.github.com>
2021-12-03 15:18:05 +09:00
austinried d1ac2df03a add f-droid badge to readme 2021-09-11 16:40:32 +09:00
austinried 3e6a20e0e3 use consistent user agent for requests 2021-09-08 15:49:01 +09:00
austinried ab575548fe stop saying my project is html pls 2021-09-08 10:13:54 +09:00
aux4 c11f3e41a7 Merge pull request #15 from austinried/exoplayer-extension
Build ExoPlayer ffmpeg extension for ALAC playback
2021-09-08 10:06:38 +09:00
austinried 995383df55 add ffmpeg license
reworked licenses into a single generated file
2021-09-08 09:32:35 +09:00
austinried 48a5cd454d too many files in here for watchman 2021-09-07 18:37:19 +09:00
austinried be0aaf2bc6 quiet down wget and unzip
really don't need 5mb of logs every time those run...
2021-09-07 18:32:22 +09:00
austinried a061a2d093 remove gradle wrapper that fails validation 2021-09-07 16:48:49 +09:00
austinried af67fb09a5 remove unused exoplayer modules
update licenses
2021-09-07 16:36:12 +09:00
austinried 2e3af7bed3 don't lint submodules 2021-09-07 15:44:18 +09:00
austinried 80adf0e249 Revert "regen lockfile"
This reverts commit c0c909dee1.
2021-09-07 15:38:52 +09:00
austinried c0c909dee1 regen lockfile 2021-09-07 15:35:10 +09:00
austinried 568bb7caba Merge branch 'master' into exoplayer-extension 2021-09-07 15:26:04 +09:00
austinried 85aaef4528 enable ffmpeg prep for GH builds 2021-09-07 15:23:57 +09:00
austinried d92890ce48 bump ram for release builds 2021-09-07 15:23:37 +09:00
austinried 9f22790412 build and use ffmpeg extension
pointing at my own repo for RNTP to enable extensions in exoplayer
new prepare script needs to be run to build ffmpeg
2021-09-07 14:40:22 +09:00
austinried 01753226e2 pull in/rely on exoplayer locally 2021-09-07 12:20:52 +09:00
aux4 21e0e74b64 Merge pull request #14 from austinried/dependabot/npm_and_yarn/immer-9.0.6
Bump immer from 9.0.5 to 9.0.6
2021-09-04 15:09:13 +09:00
dependabot[bot] 6bc98eeb54 Bump immer from 9.0.5 to 9.0.6
Bumps [immer](https://github.com/immerjs/immer) from 9.0.5 to 9.0.6.
- [Release notes](https://github.com/immerjs/immer/releases)
- [Commits](https://github.com/immerjs/immer/compare/v9.0.5...v9.0.6)

---
updated-dependencies:
- dependency-name: immer
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-04 05:46:58 +00:00
austinried 4f443d9c0e remove non-working cast button for now 2021-09-04 14:45:08 +09:00
austinried d83f2d227f add changelog for 1.0.1 2021-09-02 18:01:58 +09:00
austinried 810d1ee4b5 bump version 1.0.1 2021-09-02 17:23:38 +09:00
austinried 63931e640a remove unused permissions 2021-09-01 18:23:13 +09:00
austinried 3fd7a2112d fix text cutting off bottom w/fixed height 2021-09-01 15:11:24 +09:00
austinried 45bd5aa46e Merge branch 'master' of github.com:austinried/subtracks 2021-09-01 14:53:10 +09:00
aux4 0c117b3113 update readme coming soon 2021-09-01 10:17:38 +09:00
austinried c479e24dbf Merge branch 'master' of github.com:austinried/subtracks 2021-08-27 15:42:22 +09:00
aux4 2b2b7f749c update readme with google play link 2021-08-27 15:41:39 +09:00
austinried 122c686fec address xmldom security issue
https://github.com/advisories/GHSA-6fc8-4gx4-v693
2021-08-27 13:11:03 +09:00
austinried c864a03ece bump version 1.0.0 for release 2021-08-27 11:17:58 +09:00
austinried d7c34db155 add version number to about setion 2021-08-27 09:58:49 +09:00
austinried 32dd0ac0b4 update metadata per google play standards 2021-08-26 16:38:30 +09:00
austinried 30b05feca0 add metadata for stores 2021-08-26 16:10:49 +09:00
austinried eb0ccd00a3 bump sdk version for google play 2021-08-26 15:19:03 +09:00
austinried cc77935cd1 let's do some pre-release builds 2021-08-26 14:38:22 +09:00
austinried de8a484211 Merge branch 'master' of github.com:austinried/subtracks 2021-08-26 13:41:38 +09:00
aux4 49733ae1f1 update readme 2021-08-26 13:41:07 +09:00
austinried 9a8095d02d add scripts for bumping versions 2021-08-26 13:33:15 +09:00
austinried 409ee3bc86 ignore html for languages 2021-08-26 12:58:35 +09:00
austinried 24e6b1c306 remove bad button style 2021-08-26 12:49:26 +09:00
austinried 2c1dd06230 update header for better dark mode 2021-08-26 12:48:06 +09:00
austinried ad829ed93a added licenses and webview for them
added link to github page
2021-08-26 12:47:56 +09:00
austinried 0f1b8d6147 update readme 2021-08-26 08:18:29 +09:00
austinried 68b1a467f4 fix net state not being right on startup 2021-08-26 08:17:39 +09:00
austinried 3e3523baa8 lint builds 2021-08-26 08:07:32 +09:00
austinried 5055b0d9e8 fix for search clearing text/not scrolling 2021-08-25 16:32:04 +09:00
austinried d49c97c2b5 improved large album/playlist performance
switched to flatlist for all of those
2021-08-25 16:13:35 +09:00
austinried f3eac6102a readme update 2021-08-25 11:26:03 +09:00
austinried 92c58cedd9 settings wording 2021-08-25 10:43:36 +09:00
austinried 02d12fef49 remove UI option to disable estimateContentLength 2021-08-25 10:29:59 +09:00
austinried 2624831e1a Merge branch 'master' of github.com:austinried/subtracks 2021-08-25 10:25:03 +09:00
austinried 9576f6b4e7 fix player crashing at the end of transcoded track 2021-08-25 10:24:42 +09:00
aux4 5388f17347 specify android app in readme 2021-08-24 16:00:21 +09:00
austinried c436abc77d Merge branch 'master' of github.com:austinried/subtracks 2021-08-24 15:58:01 +09:00
austinried 06c533be48 stop building on non-app file changes 2021-08-24 15:57:46 +09:00
aux4 4606a21d46 added download badge to readme 2021-08-24 15:52:34 +09:00
austinried 72c25075a4 added "get it on" badges" 2021-08-24 15:49:41 +09:00
austinried 4bc46489cb better name/group artifacts 2021-08-24 15:31:22 +09:00
aux4 a87636c1fe fix readme link, add server examples 2021-08-24 14:46:07 +09:00
aux4 84bc814952 fix readme image formatting 2021-08-24 14:27:27 +09:00
austinried 3142fe7660 add images/features/coming soon to readme 2021-08-24 14:23:00 +09:00
aux4 1ad2e30627 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
61 changed files with 668 additions and 2848 deletions
-32
View File
@@ -1,32 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):**
- Device: [e.g. Pixel 4]
- OS: [e.g. Android 12]
- Subtracks version [e.g. 1.2.0]
**Additional context**
Add any other context about the problem here.
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
-17
View File
@@ -1,17 +0,0 @@
changelog:
exclude:
labels:
- ignore-for-release
- dependencies
authors:
- weblate
categories:
- title: New
labels:
- enhancement
- title: Fixed
labels:
- bug
- title: Other Changes
labels:
- "*"
@@ -17,7 +17,6 @@ on:
paths-ignore:
- assets/**
- .vscode/**
- android/app/src/main/assets/custom/i18n/**
- .eslintrc.js
- .prettierrc.js
- BUILDING.md
-9
View File
@@ -3,8 +3,6 @@
#
Subtracks is an Android open source music streaming app for [Subsonic-API-compatible](http://www.subsonic.org/pages/api.jsp) servers ([Subsonic](http://www.subsonic.org/pages/index.jsp), [Navidrome](https://www.navidrome.org/), [Airsonic](https://airsonic.github.io/), and more). It's designed to give you clean and convenient access to your music in the style of modern media players.
[![Translation status](https://hosted.weblate.org/widgets/subtracks/-/subtracks/svg-badge.svg)](https://hosted.weblate.org/engage/subtracks/) ![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/austinried/subtracks/build-release-debugsign/main) ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/austinried/subtracks?label=github) ![F-Droid](https://img.shields.io/f-droid/v/com.subtracks)
# Screenshots
<p float="left">
<img src="metadata/en-US/images/phoneScreenshots/01_home.png" alt="home" width="200"/>
@@ -47,10 +45,3 @@ Subtracks is an Android open source music streaming app for [Subsonic-API-compat
# Building
See [Building from source](BUILDING.md).
# Translations
Want to see Subtracks in your language? Visit the project on [Weblate](https://hosted.weblate.org/engage/subtracks/) to help!
<a href="https://hosted.weblate.org/engage/subtracks/">
<img src="https://hosted.weblate.org/widgets/subtracks/-/subtracks/multi-auto.svg" alt="Translation status" />
</a>
+2 -2
View File
@@ -134,8 +134,8 @@ android {
applicationId "com.subtracks"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 9
versionName '1.3.0'
versionCode 8
versionName '1.2.0'
}
splits {
abi {
-1
View File
@@ -2,7 +2,6 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission tools:node="remove" android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission tools:node="remove" android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission tools:node="remove" android:name="com.android.vending.CHECK_LICENSE"/>
<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">
<intent-filter>
-152
View File
@@ -1,152 +0,0 @@
{
"resources": {
"album": {
"lists": {
"newest": "Afegit recentment",
"sort": "Ordenar els àlbums",
"random": "Aleatori",
"byGenre": "Per gènere",
"alphabeticalByName": "Pel nom",
"alphabeticalByArtist": "Per artista",
"byYear": "Per any",
"frequent": "Escoltat freqüentment",
"recent": "Reproduït recentment",
"starred": "Favorits"
},
"actions": {
"play": "Reproduir l'àlbum",
"view": "Veure l'àlbum"
},
"name": "Àlbum",
"name_plural": "Àlbums"
},
"artist": {
"lists": {
"sort": "Ordenar els artistes",
"starred": "Preferits",
"alphabeticalByName": "Pel nom",
"random": "Aleatori"
},
"name": "Artista",
"name_plural": "Artistes",
"actions": {
"view": "Veure l'artista"
}
},
"queue": {
"name": "Cua",
"name_plural": "Cues"
},
"song": {
"name": "Cançó",
"name_plural": "Cançons",
"lists": {
"artistTopSongs": "Millors cançons"
}
},
"playlist": {
"actions": {
"play": "Reproduir la llista de reproducció"
},
"name": "Playlist",
"name_plural": "Playlists"
}
},
"context": {
"actions": {
"unstar": "Eliminar dels preferits",
"star": "Afegir als favorits"
}
},
"navigation": {
"tabs": {
"home": "Inici",
"search": "Cercar",
"library": "Biblioteca",
"settings": "Paràmetres"
}
},
"messages": {
"nothingHere": "Aquí no hi ha res…"
},
"settings": {
"servers": {
"fields": {
"username": "Nom d’usuari",
"password": "Contrasenya",
"address": "Adreça"
},
"options": {
"forcePlaintextPassword": {
"title": "Forçar la contrasenya de text sense format",
"descriptionOn": "Enviar la contrasenya en text sense format (llegat, assegura't que la teva connexió sigui segura!)",
"descriptionOff": "Enviar la contrasenya com a fitxa + sal"
}
},
"actions": {
"add": "Afegir un servidor",
"testConnection": "Comprovar la connexió",
"save": "Desar",
"edit": "Editar el servidor",
"delete": "Esborrar"
},
"messages": {
"connectionOk": "Connexió a {{address}} OK!",
"connectionFailed": "La connexió a {{address}} ha fallat, comprova la configuració o el servidor"
},
"name": "Servidors"
},
"network": {
"name": "Xarxa",
"values": {
"seconds": "{{value}} segons",
"kbps": "{{value}}kbit/s",
"unlimitedKbps": "Il·limitat"
},
"options": {
"maxBuffer": {
"title": "Temps màxim de buffer"
},
"maxBitrateWifi": {
"title": "Taxa de bits màxima (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Taxa de bits màxima (mòbil)"
},
"minBuffer": {
"title": "Temps mínim de buffer"
}
}
},
"music": {
"options": {
"scrobble": {
"title": "Capturar la lectura",
"descriptionOn": "Capturar l'historial de reproduccions",
"descriptionOff": "No capturar l'historial de reproducció"
}
},
"name": "Música"
},
"reset": {
"name": "Reinicialitzar",
"actions": {
"clearImageCache": "Esborrar la memòria cau d'imatges"
}
},
"about": {
"name": "Quant a",
"version": "versió {{version}}",
"actions": {
"projectHomepage": "Pàgina d'inici del projecte",
"licenses": "Llicències"
}
}
},
"search": {
"nowPlayingContext": "Resultats de la cerca",
"inputPlaceholder": "Cercar",
"moreResults": "Més…",
"headerTitle": "Cercar: {{query}}"
}
}
-92
View File
@@ -1,92 +0,0 @@
{
"resources": {
"album": {
"lists": {
"byGenre": "Efter genre",
"alphabeticalByName": "Efter navn",
"alphabeticalByArtist": "Efter kunstner",
"byYear": "Efter år",
"sort": "Sortér albums"
},
"actions": {
"play": "Afspil album",
"view": "Se album"
}
},
"artist": {
"name": "Kunstner",
"name_plural": "Kunstnere",
"lists": {
"sort": "Sortér kunstnere",
"alphabeticalByName": "Efter navn"
},
"actions": {
"view": "Se kunstnere"
}
},
"song": {
"lists": {
"artistTopSongs": "Top sange"
},
"name": "Sang",
"name_plural": "Sange"
}
},
"navigation": {
"tabs": {
"library": "Bibliotek",
"search": "Søg",
"settings": "Indstillinger"
}
},
"search": {
"inputPlaceholder": "Søg",
"headerTitle": "Søg: {{query}}",
"nowPlayingContext": "Søgeresultater",
"moreResults": "Mere…"
},
"settings": {
"servers": {
"name": "Servere",
"fields": {
"address": "Adresse",
"username": "Brugernavn",
"password": "Adgangskode"
},
"actions": {
"add": "Tilføj server",
"edit": "Redigér server",
"testConnection": "Test forbindelse",
"delete": "Slet",
"save": "Gem"
},
"messages": {
"connectionOk": "Forbindelse til {{address}} OK!"
}
},
"network": {
"name": "Netværk",
"values": {
"kbps": "{{value}}kbps",
"unlimitedKbps": "Ubegrænset",
"seconds": "{{value}} sekunder"
}
},
"music": {
"name": "Musik"
},
"reset": {
"name": "Nulstil",
"actions": {
"clearImageCache": "Ryd billede cache"
}
},
"about": {
"name": "Omkring",
"version": "version {{version}}",
"actions": {
"licenses": "Licenser"
}
}
}
}
-152
View File
@@ -1,152 +0,0 @@
{
"resources": {
"song": {
"lists": {
"artistTopSongs": "Top Lieder"
},
"name": "Lied",
"name_plural": "Lieder"
},
"album": {
"name": "Album",
"name_plural": "Alben",
"lists": {
"sort": "Alben sortieren",
"random": "Zufällig",
"frequent": "Häufig abgespielt",
"recent": "Kürzlich abgespielt",
"starred": "Favoriten",
"byYear": "Nach Jahr",
"byGenre": "Nach Genre",
"alphabeticalByName": "Nach Name",
"newest": "Kürzlich hinzugefügt",
"alphabeticalByArtist": "Nach Interpreten"
},
"actions": {
"play": "Album abspielen",
"view": "Album anzeigen"
}
},
"artist": {
"name": "Interpret",
"name_plural": "Interpreten",
"lists": {
"sort": "Interpreten sortieren",
"random": "Zufällig",
"alphabeticalByName": "Nach Name",
"starred": "Favoriten"
},
"actions": {
"view": "Interpret anzeigen"
}
},
"playlist": {
"name": "Wiedergabeliste",
"name_plural": "Wiedergabelisten",
"actions": {
"play": "Wiedergabeliste abspielen"
}
},
"queue": {
"name": "Warteschlange",
"name_plural": "Warteschlangen"
}
},
"context": {
"actions": {
"star": "Markieren",
"unstar": "Markierung entfernen"
}
},
"navigation": {
"tabs": {
"home": "Startseite",
"library": "Bibliothek",
"search": "Suche",
"settings": "Einstellungen"
}
},
"search": {
"inputPlaceholder": "Suche",
"headerTitle": "Suche: {{query}}",
"nowPlayingContext": "Suchergebnis",
"moreResults": "Mehr…"
},
"settings": {
"servers": {
"fields": {
"address": "Adresse",
"password": "Passwort",
"username": "Nutzername"
},
"options": {
"forcePlaintextPassword": {
"title": "Erzwinge Klartextpasswort",
"descriptionOn": "Passwort als Klartext senden (Veraltet, stellen Sie sicher, dass Ihre Verbindung sicher ist!)",
"descriptionOff": "Sende Passwort als Token + Salt"
}
},
"actions": {
"add": "Server hinzufügen",
"edit": "Server bearbeiten",
"testConnection": "Verbindung testen",
"delete": "Löschen",
"save": "Speichern"
},
"messages": {
"connectionOk": "Verbindung zu {{address}} ist OK!",
"connectionFailed": "Verbindung zu {{address}} fehlgeschlagen, überprüfe Einstellungen oder Server"
},
"name": "Server"
},
"network": {
"name": "Netzwerk",
"values": {
"kbps": "{{value}}kbps",
"unlimitedKbps": "Unbegrenzt",
"seconds": "{{value}} Sekunden"
},
"options": {
"maxBitrateWifi": {
"title": "Maximale Bitrate (WLAN)"
},
"maxBuffer": {
"title": "Maxilmale Pufferzeit"
},
"minBuffer": {
"title": "Minimale Pufferzeit"
},
"maxBitrateMobile": {
"title": "Maximale Bitrate (Mobil)"
}
}
},
"music": {
"name": "Musik",
"options": {
"scrobble": {
"descriptionOn": "Scrobble Wiedergabeverlauf",
"descriptionOff": "Kein Scrobble für Wiedergabeverlauf",
"title": "Scrobble Wiedergabe"
}
}
},
"reset": {
"name": "Zurücksetzen",
"actions": {
"clearImageCache": "Bildzwischenspeicher löschen"
}
},
"about": {
"name": "Über",
"actions": {
"projectHomepage": "Projektseite",
"licenses": "Lizenzen"
},
"version": "Version {{version}}"
}
},
"messages": {
"nothingHere": "Hier ist nichts…"
}
}
-152
View File
@@ -1,152 +0,0 @@
{
"resources": {
"song": {
"name": "Song",
"name_plural": "Songs",
"lists": {
"artistTopSongs": "Top Songs"
}
},
"album": {
"name": "Album",
"name_plural": "Albums",
"lists": {
"sort": "Sort Albums",
"random": "Random",
"newest": "Recently Added",
"frequent": "Frequently Played",
"recent": "Recently Played",
"starred": "Starred",
"alphabeticalByName": "By Name",
"alphabeticalByArtist": "By Artist",
"byYear": "By Year",
"byGenre": "By Genre"
},
"actions": {
"play": "Play Album",
"view": "View Album"
}
},
"artist": {
"name": "Artist",
"name_plural": "Artists",
"lists": {
"sort": "Sort Artists",
"random": "Random",
"starred": "Starred",
"alphabeticalByName": "By Name"
},
"actions": {
"view": "View Artist"
}
},
"playlist": {
"name": "Playlist",
"name_plural": "Playlists",
"actions": {
"play": "Play Playlist"
}
},
"queue": {
"name": "Queue",
"name_plural": "Queues"
}
},
"context": {
"actions": {
"star": "Star",
"unstar": "Unstar"
}
},
"navigation": {
"tabs": {
"home": "Home",
"library": "Library",
"search": "Search",
"settings": "Settings"
}
},
"messages": {
"nothingHere": "Nothing here…"
},
"search": {
"inputPlaceholder": "Search",
"headerTitle": "Search: {{query}}",
"moreResults": "More…",
"nowPlayingContext": "Search Results"
},
"settings": {
"servers": {
"name": "Servers",
"fields": {
"address": "Address",
"username": "Username",
"password": "Password"
},
"options": {
"forcePlaintextPassword": {
"title": "Force plaintext password",
"descriptionOn": "Send password in plaintext (legacy, make sure your connection is secure!)",
"descriptionOff": "Send password as token + salt"
}
},
"actions": {
"add": "Add Server",
"edit": "Edit Server",
"testConnection": "Test Connection",
"delete": "Delete",
"save": "Save"
},
"messages": {
"connectionOk": "Connection to {{address}} OK!",
"connectionFailed": "Connection to {{address}} failed, check settings or server"
}
},
"network": {
"name": "Network",
"values": {
"kbps": "{{value}}kbps",
"unlimitedKbps": "Unlimited",
"seconds": "{{value}} seconds"
},
"options": {
"maxBitrateWifi": {
"title": "Maximum bitrate (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Maximum bitrate (mobile)"
},
"minBuffer": {
"title": "Minimum buffer time"
},
"maxBuffer": {
"title": "Maximum buffer time"
}
}
},
"music": {
"name": "Music",
"options": {
"scrobble": {
"title": "Scrobble plays",
"descriptionOn": "Scrobble play history",
"descriptionOff": "Don't scrobble play history"
}
}
},
"reset": {
"name": "Reset",
"actions": {
"clearImageCache": "Clear Image Cache"
}
},
"about": {
"name": "About",
"version": "version {{version}}",
"actions": {
"projectHomepage": "Project Homepage",
"licenses": "Licenses"
}
}
}
}
-152
View File
@@ -1,152 +0,0 @@
{
"resources": {
"album": {
"name": "Album",
"name_plural": "Albums",
"lists": {
"random": "Aléatoire",
"newest": "Récemment Ajouté",
"frequent": "Fréquemment Joué",
"recent": "Récemment Joué",
"alphabeticalByName": "Par Nom",
"byYear": "Par Année",
"alphabeticalByArtist": "Par Artiste",
"byGenre": "Par Genre",
"starred": "Favoris",
"sort": "Trier les albums"
},
"actions": {
"play": "Jouer l'album",
"view": "Voir l'album"
}
},
"song": {
"name": "Chanson",
"name_plural": "Chansons",
"lists": {
"artistTopSongs": "Meilleures Chansons"
}
},
"artist": {
"name": "Artiste",
"name_plural": "Artistes",
"lists": {
"random": "Aléatoire",
"starred": "Favoris",
"alphabeticalByName": "Par Nom",
"sort": "Trier les artistes"
},
"actions": {
"view": "Voir l'artiste"
}
},
"playlist": {
"actions": {
"play": "Lire la playlist"
},
"name": "Playlist",
"name_plural": "Playlists"
},
"queue": {
"name": "File d'attente",
"name_plural": "Files d'attente"
}
},
"settings": {
"network": {
"values": {
"seconds": "{{value}} secondes",
"unlimitedKbps": "Illimité",
"kbps": "{{value}}kbit/s"
},
"options": {
"maxBitrateWifi": {
"title": "Débit binaire maximum (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Débit binaire maximum (mobile)"
},
"maxBuffer": {
"title": "Temps maximum en mémoire tampon"
},
"minBuffer": {
"title": "Temps minimum en mémoire tampon"
}
},
"name": "Réseau"
},
"servers": {
"fields": {
"username": "Nom d'utilisateur",
"address": "Adresse",
"password": "Mot de passe"
},
"actions": {
"testConnection": "Tester la connexion",
"add": "Ajouter un serveur",
"delete": "Supprimer",
"save": "Sauvegarder",
"edit": "Modifier le serveur"
},
"name": "Serveurs",
"options": {
"forcePlaintextPassword": {
"title": "Forcer le mot de passe en texte clair",
"descriptionOn": "Envoyer le mot de passe en test clair (héritage, assurez-vous que la connexion est sécurisée !)",
"descriptionOff": "Envoyer le mot de passe sous forme de jeton + salage"
}
},
"messages": {
"connectionOk": "Connexion à {{address}} OK !",
"connectionFailed": "Échec de la connexion à {{address}}, vérifiez les paramètres ou le serveur"
}
},
"music": {
"name": "Musique",
"options": {
"scrobble": {
"descriptionOff": "Ne pas scrobbler l'historique de lecture",
"descriptionOn": "Scrobbler l'historique de lecture",
"title": "Scrobbler la lecture"
}
}
},
"about": {
"version": "version {{version}}",
"name": "À propos",
"actions": {
"licenses": "Licenses",
"projectHomepage": "Page d'accueil du projet"
}
},
"reset": {
"actions": {
"clearImageCache": "Vider le cache d'images"
},
"name": "Réinitialiser"
}
},
"navigation": {
"tabs": {
"library": "Bibliothèque",
"home": "Accueil",
"search": "Recherche",
"settings": "Paramètres"
}
},
"search": {
"headerTitle": "Recherche : {{query}}",
"inputPlaceholder": "Recherche",
"moreResults": "Plus…",
"nowPlayingContext": "Résultats de recherche"
},
"context": {
"actions": {
"star": "Mettre en favoris",
"unstar": "Enlever des favoris"
}
},
"messages": {
"nothingHere": "Rien ici…"
}
}
-152
View File
@@ -1,152 +0,0 @@
{
"resources": {
"artist": {
"name": "Artista",
"name_plural": "Artisti",
"actions": {
"view": "Vedi artista"
},
"lists": {
"random": "Casuale",
"starred": "Preferiti",
"sort": "Ordina artisti",
"alphabeticalByName": "Per nome"
}
},
"song": {
"lists": {
"artistTopSongs": "Brani più popolari"
},
"name": "Brano",
"name_plural": "Brani"
},
"album": {
"name": "Album",
"name_plural": "Album",
"lists": {
"random": "Casuale",
"newest": "Aggiunti di recente",
"recent": "Ascoltati di recente",
"alphabeticalByName": "Per nome",
"alphabeticalByArtist": "Per artista",
"byYear": "Per anno",
"byGenre": "Per genere",
"sort": "Ordina album",
"frequent": "Ascoltati frequentemente",
"starred": "Preferiti"
},
"actions": {
"play": "Riproduci album",
"view": "Vedi album"
}
},
"playlist": {
"name": "Playlist",
"name_plural": "Playlist",
"actions": {
"play": "Riproduci playlist"
}
},
"queue": {
"name": "Coda",
"name_plural": "Code"
}
},
"settings": {
"servers": {
"fields": {
"password": "Password",
"address": "Indirizzo",
"username": "Nome utente"
},
"options": {
"forcePlaintextPassword": {
"title": "Forza password in chiaro",
"descriptionOn": "Invia password in chiaro (deprecato, assicurati che la tua connessione sia sicura!)",
"descriptionOff": "Invia la password come token + salt"
}
},
"actions": {
"delete": "Rimuovi",
"edit": "Modifica server",
"add": "Aggiungi server",
"save": "Salva",
"testConnection": "Prova connessione"
},
"name": "Server",
"messages": {
"connectionOk": "Connesso a {{address}} con successo!",
"connectionFailed": "Connessione a {{address}} fallita, controlla le impostazioni o il server"
}
},
"network": {
"name": "Rete",
"values": {
"kbps": "{{value}}kbps",
"unlimitedKbps": "Illimitato",
"seconds": "{{value}} secondi"
},
"options": {
"maxBitrateWifi": {
"title": "Bitrate massimo (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Bitrate massimo (rete dati)"
},
"minBuffer": {
"title": "Tempo di buffer minimo"
},
"maxBuffer": {
"title": "Tempo di buffer massimo"
}
}
},
"music": {
"name": "Musica",
"options": {
"scrobble": {
"title": "Scrobbling delle riproduzioni",
"descriptionOn": "Scrobbling della cronologia di ascolto",
"descriptionOff": "Non eseguire lo scrobbling della cronologia d'ascolto"
}
}
},
"reset": {
"name": "Reimposta",
"actions": {
"clearImageCache": "Pulisci la cache delle immagini"
}
},
"about": {
"name": "Informazioni",
"version": "versione {{version}}",
"actions": {
"projectHomepage": "Pagina principale del progetto",
"licenses": "Licenze"
}
}
},
"context": {
"actions": {
"star": "Aggiungi ai preferiti",
"unstar": "Rimuovi dai preferiti"
}
},
"navigation": {
"tabs": {
"home": "Home",
"library": "Libreria",
"search": "Cerca",
"settings": "Impostazioni"
}
},
"messages": {
"nothingHere": "Non c'è niente qui…"
},
"search": {
"inputPlaceholder": "Ricerca",
"headerTitle": "Ricerca: {{query}}",
"moreResults": "Mostra di più…",
"nowPlayingContext": "Risultati della ricerca"
}
}
-55
View File
@@ -1,55 +0,0 @@
{
"resources": {
"album": {
"lists": {
"random": "ランダムアルバム",
"frequent": "よく聴くアルバム",
"recent": "最近再生した",
"starred": "星付きアルバム"
},
"name": "アルバム"
},
"song": {
"name": "歌",
"lists": {
"artistTopSongs": "人気曲"
}
},
"artist": {
"name": "アーティスト"
},
"playlist": {
"name": "プレイリスト"
}
},
"navigation": {
"tabs": {
"home": "ホーム",
"library": "ライブラリ",
"search": "検索",
"settings": "設定"
}
},
"search": {
"inputPlaceholder": "検索"
},
"settings": {
"servers": {
"name": "サーバ"
},
"network": {
"name": "ネット"
},
"music": {
"name": "音楽"
},
"reset": {
"name": "リセット"
},
"about": {
"actions": {
"projectHomepage": "ホームページ"
}
}
}
}
-152
View File
@@ -1,152 +0,0 @@
{
"resources": {
"artist": {
"name": "Artist",
"name_plural": "Artister",
"lists": {
"sort": "Sorter artister",
"random": "Tilfeldig",
"starred": "Stjernemerket",
"alphabeticalByName": "Etter navn"
},
"actions": {
"view": "Vis artist"
}
},
"playlist": {
"name": "Spilleliste",
"name_plural": "Spillelister",
"actions": {
"play": "Spill av spilleliste"
}
},
"song": {
"lists": {
"artistTopSongs": "Toppspor"
},
"name": "Spor",
"name_plural": "Spor"
},
"album": {
"name": "Album",
"name_plural": "Album",
"lists": {
"sort": "Sorter album",
"random": "Tilfeldig",
"newest": "Nylig tillagt",
"frequent": "Ofte spilt",
"recent": "Nylig spilt",
"starred": "Stjernemerket",
"alphabeticalByName": "Etter navn",
"alphabeticalByArtist": "Etter artist",
"byYear": "Etter år",
"byGenre": "Etter sjanger"
},
"actions": {
"play": "Spill album",
"view": "Vis album"
}
},
"queue": {
"name": "Kø",
"name_plural": "Køer"
}
},
"settings": {
"servers": {
"actions": {
"add": "Legg til tjener",
"testConnection": "Test tilkobling",
"delete": "Slett",
"save": "Lagre",
"edit": "Rediger tjener"
},
"messages": {
"connectionOk": "Tilkobling til {{address}} OK.",
"connectionFailed": "Tilkobling til {{address}} mislyktes. Sjekk innstillingene eller tjeneren."
},
"name": "Tjenere",
"fields": {
"address": "Adresse",
"username": "Brukernavn",
"password": "Passord"
},
"options": {
"forcePlaintextPassword": {
"title": "Påtving klartekstspassord",
"descriptionOn": "Send passord i klartekst (Foreldet. Forsikre deg om at tilkoblingen er sikker.)",
"descriptionOff": "Send passord som symbol + salt"
}
}
},
"network": {
"name": "Nettverk",
"values": {
"kbps": "{{value}} kbps",
"unlimitedKbps": "Ubegrenset",
"seconds": "{{value}} sekunder"
},
"options": {
"maxBitrateWifi": {
"title": "Maksimal bitrate (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Maksimal bitrate (mobil)"
},
"minBuffer": {
"title": "Minimal mellomlagringstid"
},
"maxBuffer": {
"title": "Maksimal mellomlagringstid"
}
}
},
"music": {
"name": "Musikk",
"options": {
"scrobble": {
"title": "Sporinfodelingsavspillinger",
"descriptionOn": "Sporinfodelings-avspillinghistorikk",
"descriptionOff": "Ikke utfør sporinfodeling av avspillingshistorikk"
}
}
},
"reset": {
"name": "Tilbakestill",
"actions": {
"clearImageCache": "Tøm bildehurtiglager"
}
},
"about": {
"name": "Om",
"version": "versjon {{version}}",
"actions": {
"projectHomepage": "Prosjekthjemmeside",
"licenses": "Lisenser"
}
}
},
"context": {
"actions": {
"star": "Stjernemerk",
"unstar": "Fjern stjernemerking"
}
},
"navigation": {
"tabs": {
"home": "Hjem",
"library": "Bibliotek",
"search": "Søk",
"settings": "Innstillinger"
}
},
"search": {
"inputPlaceholder": "Søk",
"headerTitle": "Søk: {{query}}",
"moreResults": "Mer …",
"nowPlayingContext": "Søkeresultater"
},
"messages": {
"nothingHere": "Ingenting her …"
}
}
-157
View File
@@ -1,157 +0,0 @@
{
"settings": {
"servers": {
"actions": {
"delete": "Удалить",
"add": "Добавить сервер",
"edit": "Редактировать сервер",
"testConnection": "Проверить подключение",
"save": "Сохранить"
},
"name": "Серверы",
"fields": {
"address": "Адрес",
"username": "Имя пользователя",
"password": "Пароль"
},
"options": {
"forcePlaintextPassword": {
"descriptionOn": "Отправить пароль в виде текста (устарело, убедитесь, что ваше соединение безопасно!)",
"descriptionOff": "Отправить пароль в виде токена",
"title": "Принудительный текстовый пароль"
}
},
"messages": {
"connectionOk": "Подключение к {{address}} установлено!",
"connectionFailed": "Не удалось подключиться к {{address}}, проверьте настройки или сервер"
}
},
"network": {
"name": "Сеть",
"values": {
"kbps": "{{value}} кбит/с",
"unlimitedKbps": "Без ограничений",
"seconds": "{{value}} секунд"
},
"options": {
"maxBitrateWifi": {
"title": "Максимальный битрейт (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Максимальный битрейт (мобильный интернет)"
},
"minBuffer": {
"title": "Минимальное время буферизации"
},
"maxBuffer": {
"title": "Максимальное время буферизации"
}
}
},
"music": {
"name": "Музыка",
"options": {
"scrobble": {
"title": "Синхронизация воспроизведения",
"descriptionOff": "Не синхронизировать историю воспроизведений",
"descriptionOn": "Синхронизация истории воспроизведения"
}
}
},
"reset": {
"name": "Сброс",
"actions": {
"clearImageCache": "Очистить кэш изображения"
}
},
"about": {
"name": "О Subtracks",
"version": "версия {{version}}",
"actions": {
"projectHomepage": "Сайт проекта",
"licenses": "Лицензии"
}
}
},
"resources": {
"album": {
"name_0": "Альбом",
"name_1": "Альбома",
"name_2": "Альбомов",
"lists": {
"sort": "Сортировка альбомов",
"random": "Случайно",
"newest": "Недавно добавленные",
"frequent": "Часто проигрываемые",
"recent": "Недавно проигранные",
"starred": "Избранные",
"alphabeticalByName": "По имени",
"alphabeticalByArtist": "По исполнителю",
"byYear": "По году",
"byGenre": "По жанру"
},
"actions": {
"play": "Воспроизвести альбом",
"view": "Посмотреть альбом"
}
},
"song": {
"name_0": "Трек",
"name_1": "Трека",
"name_2": "Треков",
"lists": {
"artistTopSongs": "Лучшие треки"
}
},
"artist": {
"name_0": "Исполнитель",
"name_1": "Исполнителя",
"name_2": "Исполнителей",
"lists": {
"sort": "Сортировать исполнителей",
"random": "Случайно",
"starred": "Избранные",
"alphabeticalByName": "По имени"
},
"actions": {
"view": "Посмотреть исполнителя"
}
},
"playlist": {
"name_0": "Плейлист",
"name_1": "Плейлиста",
"name_2": "Плейлистов",
"actions": {
"play": "Воспроизвести плейлист"
}
},
"queue": {
"name_0": "Очередь",
"name_1": "Очереди",
"name_2": "Очередей"
}
},
"context": {
"actions": {
"star": "Избранное",
"unstar": "Убрать из избранного"
}
},
"navigation": {
"tabs": {
"library": "Библиотека",
"search": "Поиск",
"settings": "Настройки",
"home": "Главная"
}
},
"messages": {
"nothingHere": "Здесь ничего нет…"
},
"search": {
"inputPlaceholder": "Поиск",
"headerTitle": "Поиск: {{query}}",
"moreResults": "Больше…",
"nowPlayingContext": "Результаты поиска"
}
}
-147
View File
@@ -1,147 +0,0 @@
{
"context": {
"actions": {
"unstar": "移除收藏",
"star": "收藏"
}
},
"settings": {
"servers": {
"name": "服务器",
"messages": {
"connectionFailed": "连接到 {{address}} 失败,检查设置或服务器",
"connectionOk": "连接到 {{address}} 正常!"
},
"options": {
"forcePlaintextPassword": {
"title": "强制使用明文密码",
"descriptionOn": "密码以明文发送(不推荐,注意链接安全!)",
"descriptionOff": "密码以 token + salt 加密发送"
}
},
"actions": {
"add": "添加服务器",
"testConnection": "测试连接",
"save": "保存",
"edit": "编辑服务器",
"delete": "删除"
},
"fields": {
"password": "密码",
"username": "用户名",
"address": "地址"
}
},
"network": {
"name": "网络",
"values": {
"seconds": "{{value}} 秒",
"kbps": "{{value}}kbps",
"unlimitedKbps": "不限制"
},
"options": {
"maxBitrateMobile": {
"title": "最大比特率 (3G/4G/5G)"
},
"minBuffer": {
"title": "最小缓冲时间"
},
"maxBuffer": {
"title": "最大缓冲时间"
},
"maxBitrateWifi": {
"title": "最大比特率 (Wi-Fi)"
}
}
},
"music": {
"name": "音乐",
"options": {
"scrobble": {
"title": "Scrobble模式",
"descriptionOn": "Scrobble播放历史",
"descriptionOff": "不记录scrobble历史"
}
}
},
"reset": {
"name": "重置",
"actions": {
"clearImageCache": "清除图片缓存"
}
},
"about": {
"name": "关于",
"version": "版本 {{version}}",
"actions": {
"projectHomepage": "项目地址",
"licenses": "许可"
}
}
},
"resources": {
"album": {
"actions": {
"view": "查看专辑",
"play": "播放专辑"
},
"name": "专辑",
"lists": {
"newest": "最近添加",
"frequent": "播放最多",
"alphabeticalByName": "根据名称",
"alphabeticalByArtist": "根据歌手",
"byYear": "根据年份",
"random": "随机",
"sort": "专辑排序",
"recent": "最近播放",
"byGenre": "根据类型",
"starred": "已收藏"
}
},
"playlist": {
"actions": {
"play": "全部播放"
},
"name": "播放列表"
},
"song": {
"name": "歌曲",
"lists": {
"artistTopSongs": "热门歌曲"
}
},
"artist": {
"name": "歌手",
"lists": {
"starred": "已收藏",
"sort": "歌手排序",
"random": "随机",
"alphabeticalByName": "根据名称"
},
"actions": {
"view": "查看歌手"
}
},
"queue": {
"name": "队列"
}
},
"navigation": {
"tabs": {
"home": "首页",
"library": "所有",
"search": "搜索",
"settings": "设置"
}
},
"search": {
"inputPlaceholder": "搜索",
"headerTitle": "搜索: {{query}}",
"moreResults": "更多…",
"nowPlayingContext": "搜索结果"
},
"messages": {
"nothingHere": "什么都没有…"
}
}
-129
View File
@@ -27165,30 +27165,6 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-----
The following software may be included in this product: html-escaper. A copy of the source code may be downloaded from https://github.com/WebReflection/html-escaper.git. This software contains the following license and notice below:
Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-----
The following software may be included in this product: http-errors. A copy of the source code may be downloaded from https://github.com/jshttp/http-errors.git. This software contains the following license and notice below:
The MIT License (MIT)
@@ -27216,32 +27192,6 @@ THE SOFTWARE.
-----
The following software may be included in this product: i18next. A copy of the source code may be downloaded from https://github.com/i18next/i18next.git. This software contains the following license and notice below:
The MIT License (MIT)
Copyright (c) 2022 i18next
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----
The following software may be included in this product: ieee754. A copy of the source code may be downloaded from git://github.com/feross/ieee754.git. This software contains the following license and notice below:
Copyright 2008 Fair Oaks Labs, Inc.
@@ -29436,32 +29386,6 @@ SOFTWARE.
-----
The following software may be included in this product: react-i18next. A copy of the source code may be downloaded from https://github.com/i18next/react-i18next.git. This software contains the following license and notice below:
The MIT License (MIT)
Copyright (c) 2022 i18next
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----
The following software may be included in this product: react-native-blob-util. A copy of the source code may be downloaded from https://github.com/RonRadtke/react-native-blob-util. This software contains the following license and notice below:
MIT License
@@ -29618,32 +29542,6 @@ SOFTWARE.
-----
The following software may be included in this product: react-native-localize. A copy of the source code may be downloaded from https://github.com/zoontek/react-native-localize.git. This software contains the following license and notice below:
MIT License
Copyright (c) 2017-present, Mathieu Acthernoene
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----
The following software may be included in this product: react-native-popup-menu. A copy of the source code may be downloaded from git+ssh://git@github.com:instea/react-native-popup-menu.git. This software contains the following license and notice below:
ISC License
@@ -31661,33 +31559,6 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
-----
The following software may be included in this product: void-elements. A copy of the source code may be downloaded from https://github.com/pugjs/void-elements.git. This software contains the following license and notice below:
(The MIT License)
Copyright (c) 2014 hemanth
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-----
The following software may be included in this product: walker. A copy of the source code may be downloaded from https://github.com/daaku/nodejs-walker. This software contains the following license and notice below:
Copyright 2013 Naitik Shah
-129
View File
@@ -3863,30 +3863,6 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-----
The following software may be included in this product: html-escaper. A copy of the source code may be downloaded from https://github.com/WebReflection/html-escaper.git. This software contains the following license and notice below:
Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-----
The following software may be included in this product: http-errors. A copy of the source code may be downloaded from https://github.com/jshttp/http-errors.git. This software contains the following license and notice below:
The MIT License (MIT)
@@ -3914,32 +3890,6 @@ THE SOFTWARE.
-----
The following software may be included in this product: i18next. A copy of the source code may be downloaded from https://github.com/i18next/i18next.git. This software contains the following license and notice below:
The MIT License (MIT)
Copyright (c) 2022 i18next
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----
The following software may be included in this product: ieee754. A copy of the source code may be downloaded from git://github.com/feross/ieee754.git. This software contains the following license and notice below:
Copyright 2008 Fair Oaks Labs, Inc.
@@ -6134,32 +6084,6 @@ SOFTWARE.
-----
The following software may be included in this product: react-i18next. A copy of the source code may be downloaded from https://github.com/i18next/react-i18next.git. This software contains the following license and notice below:
The MIT License (MIT)
Copyright (c) 2022 i18next
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----
The following software may be included in this product: react-native-blob-util. A copy of the source code may be downloaded from https://github.com/RonRadtke/react-native-blob-util. This software contains the following license and notice below:
MIT License
@@ -6316,32 +6240,6 @@ SOFTWARE.
-----
The following software may be included in this product: react-native-localize. A copy of the source code may be downloaded from https://github.com/zoontek/react-native-localize.git. This software contains the following license and notice below:
MIT License
Copyright (c) 2017-present, Mathieu Acthernoene
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----
The following software may be included in this product: react-native-popup-menu. A copy of the source code may be downloaded from git+ssh://git@github.com:instea/react-native-popup-menu.git. This software contains the following license and notice below:
ISC License
@@ -8359,33 +8257,6 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
-----
The following software may be included in this product: void-elements. A copy of the source code may be downloaded from https://github.com/pugjs/void-elements.git. This software contains the following license and notice below:
(The MIT License)
Copyright (c) 2014 hemanth
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-----
The following software may be included in this product: walker. A copy of the source code may be downloaded from https://github.com/daaku/nodejs-walker. This software contains the following license and notice below:
Copyright 2013 Naitik Shah
@@ -14,9 +14,6 @@ public class MainActivity extends ReactActivity {
return "subtracks";
}
// required by react-native-screens
// "This change is required to avoid crashes related to View state being not persisted consistently across Activity restarts."
// https://reactnavigation.org/docs/getting-started
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(null);
+3 -3
View File
@@ -2,12 +2,12 @@ import RootNavigator from '@app/navigation/RootNavigator'
import SplashPage from '@app/screens/SplashPage'
import colors from '@app/styles/colors'
import React from 'react'
import { StatusBar, StyleSheet, View } from 'react-native'
import { StatusBar, View, StyleSheet } from 'react-native'
import ProgressHook from './components/ProgressHook'
import { useStore } from './state/store'
import { MenuProvider } from 'react-native-popup-menu'
import { QueryClientProvider } from 'react-query'
import ProgressHook from './components/ProgressHook'
import queryClient from './queryClient'
import { useStore } from './state/store'
const Debug = () => {
const currentTrackTitle = useStore(store => store.currentTrack?.title)
+1 -9
View File
@@ -16,13 +16,7 @@ const Button: React.FC<{
onPress={onPress}
disabled={disabled}
style={[styles.container, buttonStyle !== undefined ? styles[buttonStyle] : {}, style]}>
{title ? (
<Text style={styles.text} numberOfLines={2} adjustsFontSizeToFit={true}>
{title}
</Text>
) : (
children
)}
{title ? <Text style={styles.text}>{title}</Text> : children}
</PressableOpacity>
)
}
@@ -32,7 +26,6 @@ const styles = StyleSheet.create({
backgroundColor: colors.accent,
paddingHorizontal: 10,
minHeight: 42,
maxHeight: 42,
justifyContent: 'center',
borderRadius: 1000,
},
@@ -50,7 +43,6 @@ const styles = StyleSheet.create({
fontFamily: font.bold,
color: colors.text.primary,
paddingHorizontal: 14,
textAlign: 'center',
},
})
+16 -50
View File
@@ -6,14 +6,12 @@ import font from '@app/styles/font'
import { NavigationProp, useNavigation } from '@react-navigation/native'
import { ReactComponentLike } from 'prop-types'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollView, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'
import { Menu, MenuOption, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu'
import IconFA from 'react-native-vector-icons/FontAwesome'
import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import CoverArt from './CoverArt'
import { Star } from './Star'
import { withSuspenseMemo } from './withSuspense'
const { SlideInMenu } = renderers
@@ -108,9 +106,7 @@ const ContextMenuIconTextOption = React.memo<ContextMenuIconTextOptionProps>(
return (
<ContextMenuOption onSelect={onSelect}>
<View style={styles.icon}>{Icon}</View>
<Text style={styles.optionText} numberOfLines={1} adjustsFontSizeToFit={true} minimumFontScale={0.6}>
{text}
</Text>
<Text style={styles.optionText}>{text}</Text>
</ContextMenuOption>
)
},
@@ -119,13 +115,11 @@ const ContextMenuIconTextOption = React.memo<ContextMenuIconTextOptionProps>(
const MenuHeader = React.memo<{
coverArt?: string
artistId?: string
albumId?: string
title: string
subtitle?: string
}>(({ coverArt, artistId, albumId, title, subtitle }) => {
let CoverArtComponent = <></>
if (artistId) {
CoverArtComponent = (
}>(({ coverArt, artistId, title, subtitle }) => (
<View style={styles.menuHeader}>
{artistId ? (
<CoverArt
type="artist"
artistId={artistId}
@@ -135,20 +129,7 @@ const MenuHeader = React.memo<{
size="thumbnail"
fadeDuration={0}
/>
)
} else if (albumId) {
CoverArtComponent = (
<CoverArt
type="album"
albumId={albumId}
style={styles.coverArt}
resizeMode="cover"
size="thumbnail"
fadeDuration={0}
/>
)
} else {
CoverArtComponent = (
) : (
<CoverArt
type="cover"
coverArt={coverArt}
@@ -157,12 +138,7 @@ const MenuHeader = React.memo<{
size="thumbnail"
fadeDuration={0}
/>
)
}
return (
<View style={styles.menuHeader}>
{CoverArtComponent}
)}
<View style={styles.menuHeaderText}>
<Text numberOfLines={1} style={styles.menuTitle}>
{title}
@@ -176,33 +152,29 @@ const MenuHeader = React.memo<{
)}
</View>
</View>
)
})
))
const OptionStar = withSuspenseMemo<{
const OptionStar = React.memo<{
id: string
type: StarrableItemType
additionalText?: string
}>(({ id, type, additionalText: text }) => {
const { query, toggle } = useStar(id, type)
const { t } = useTranslation()
return (
<ContextMenuIconTextOption
IconComponentRaw={<Star starred={!!query.data} size={26} />}
text={(query.data ? t('context.actions.unstar') : t('context.actions.star')) + (text ? ` ${text}` : '')}
text={(query.data ? 'Unstar' : 'Star') + (text ? ` ${text}` : '')}
onSelect={() => toggle.mutate()}
/>
)
})
const OptionViewArtist = withSuspenseMemo<{
const OptionViewArtist = React.memo<{
navigation: NavigationProp<any>
artist?: string
artistId?: string
}>(({ navigation, artist, artistId }) => {
const { t } = useTranslation()
if (!artist || !artistId) {
return <></>
}
@@ -212,19 +184,17 @@ const OptionViewArtist = withSuspenseMemo<{
IconComponent={IconFA}
name="microphone"
size={26}
text={t('resources.artist.actions.view')}
text="View Artist"
onSelect={() => navigation.navigate('artist', { id: artistId, title: artist })}
/>
)
})
const OptionViewAlbum = withSuspenseMemo<{
const OptionViewAlbum = React.memo<{
navigation: NavigationProp<any>
album?: string
albumId?: string
}>(({ navigation, album, albumId }) => {
const { t } = useTranslation()
if (!album || !albumId) {
return <></>
}
@@ -234,7 +204,7 @@ const OptionViewAlbum = withSuspenseMemo<{
IconComponent={IconFA5}
name="compact-disc"
size={26}
text={t('resources.album.actions.view')}
text="View Album"
onSelect={() => navigation.navigate('album', { id: albumId, title: album })}
/>
)
@@ -281,7 +251,7 @@ export const SongContextPressable: React.FC<SongContextPressableProps> = props =
return (
<ContextMenu
{...props}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} albumId={song.albumId} />}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} coverArt={song.coverArt} />}
menuOptions={
<>
<OptionStar id={song.id} type={song.itemType} />
@@ -328,7 +298,7 @@ export const NowPlayingContextPressable: React.FC<NowPlayingContextPressableProp
return (
<ContextMenu
{...props}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} albumId={song.albumId} />}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} coverArt={song.coverArt} />}
menuOptions={
<>
<OptionStar id={song.id} type={song.itemType} />
@@ -348,8 +318,6 @@ const styles = StyleSheet.create({
},
optionsWrapper: {
// marginBottom: 10,
paddingHorizontal: 20,
// backgroundColor: 'purple',
},
menuHeader: {
paddingTop: 14,
@@ -380,11 +348,9 @@ const styles = StyleSheet.create({
},
option: {
paddingVertical: 8,
// paddingHorizontal: 100,
paddingHorizontal: 20,
flexDirection: 'row',
alignItems: 'center',
// backgroundColor: 'blue',
overflow: 'hidden',
},
icon: {
marginRight: 10,
+2 -16
View File
@@ -1,4 +1,4 @@
import { useQueryAlbumCoverArtPath, useQueryArtistArtPath, useQueryCoverArtPath } from '@app/hooks/query'
import { useQueryArtistArtPath, useQueryCoverArtPath } from '@app/hooks/query'
import { CacheImageSize } from '@app/models/cache'
import colors from '@app/styles/colors'
import React, { useState } from 'react'
@@ -32,11 +32,6 @@ type CoverArtProps = BaseProps & {
coverArt?: string
}
type AlbumIdProps = BaseProps & {
type: 'album'
albumId?: string
}
type ImageSourceProps = BaseProps & {
data?: string
isFetching: boolean
@@ -87,13 +82,7 @@ const CoverArtImage = React.memo<CoverArtProps>(props => {
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
})
const AlbumIdIamge = React.memo<AlbumIdProps>(props => {
const { data, isFetching, isExistingFetching } = useQueryAlbumCoverArtPath(props.albumId, props.size)
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
})
const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps | AlbumIdProps>(props => {
const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps>(props => {
const viewStyles = [props.style]
if (props.round) {
viewStyles.push(styles.round)
@@ -104,9 +93,6 @@ const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps | AlbumIdProps>(
case 'artist':
imageComponent = <ArtistImage {...(props as ArtistCoverArtProps)} />
break
case 'album':
imageComponent = <AlbumIdIamge {...(props as AlbumIdProps)} />
break
default:
imageComponent = <CoverArtImage {...(props as CoverArtProps)} />
break
+21 -51
View File
@@ -1,13 +1,10 @@
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import React from 'react'
import { Text, StyleSheet, View } from 'react-native'
import { MenuOption, Menu, MenuTrigger, MenuOptions, renderers } from 'react-native-popup-menu'
import { Text, StyleSheet } from 'react-native'
import { MenuOption, Menu, MenuTrigger, MenuOptions } from 'react-native-popup-menu'
import PressableOpacity from './PressableOpacity'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { ScrollView } from 'react-native-gesture-handler'
const { SlideInMenu } = renderers
export type OptionData = {
value: string
@@ -20,14 +17,12 @@ const Option = React.memo<{
selected?: boolean
}>(({ text, value, selected }) => (
<MenuOption style={styles.option} value={value}>
<Text style={styles.optionText}>{text}</Text>
{selected ? (
<Icon name="checkbox-marked-circle" size={32} color={colors.accent} style={styles.icon} />
<Icon name="checkbox-marked-circle" size={26} color={colors.accent} />
) : (
<Icon name="checkbox-blank-circle-outline" size={32} color={colors.text.secondary} style={styles.icon} />
<Icon name="checkbox-blank-circle-outline" size={26} color={colors.text.secondary} />
)}
<Text style={styles.optionText} numberOfLines={1} adjustsFontSizeToFit={true} minimumFontScale={0.6}>
{text}
</Text>
</MenuOption>
))
@@ -35,10 +30,9 @@ const FilterButton = React.memo<{
value?: string
data: OptionData[]
onSelect?: (selection: string) => void
title: string
}>(({ value, data, onSelect, title }) => {
}>(({ value, data, onSelect }) => {
return (
<Menu onSelect={onSelect} renderer={SlideInMenu}>
<Menu onSelect={onSelect}>
<MenuTrigger
customStyles={{
triggerOuterWrapper: styles.filterOuterWrapper,
@@ -46,23 +40,16 @@ const FilterButton = React.memo<{
triggerTouchable: { style: styles.filter },
TriggerTouchableComponent: PressableOpacity,
}}>
<Icon name="filter-variant" color="white" size={30} />
<Icon name="filter-variant" color="white" size={30} style={styles.filterIcon} />
</MenuTrigger>
<MenuOptions
customStyles={{
optionsWrapper: styles.optionsWrapper,
optionsContainer: styles.optionsContainer,
}}>
<ScrollView style={styles.optionsScroll} overScrollMode="never">
<View style={styles.header}>
<Text style={styles.headerText} numberOfLines={2} ellipsizeMode="clip">
{title}
</Text>
</View>
{data.map(o => (
<Option key={o.value} text={o.text} value={o.value} selected={o.value === value} />
))}
</ScrollView>
</MenuOptions>
</Menu>
)
@@ -84,45 +71,28 @@ const styles = StyleSheet.create({
alignItems: 'center',
backgroundColor: colors.accent,
},
optionsScroll: {
maxHeight: 260,
filterIcon: {
// top: 4,
},
optionsWrapper: {
overflow: 'hidden',
maxWidth: 145,
},
optionsContainer: {
backgroundColor: 'rgba(45, 45, 45, 0.95)',
},
header: {
paddingHorizontal: 20,
// paddingVertical: 10,
marginTop: 16,
marginBottom: 6,
},
headerText: {
fontFamily: font.bold,
fontSize: 20,
color: colors.text.primary,
backgroundColor: colors.gradient.high,
maxWidth: 145,
},
option: {
paddingVertical: 8,
paddingHorizontal: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
},
optionText: {
fontFamily: font.semiBold,
fontSize: 16,
color: colors.text.primary,
},
icon: {
marginRight: 14,
width: 32,
height: 32,
paddingHorizontal: 12,
paddingVertical: 8,
justifyContent: 'center',
alignItems: 'center',
// backgroundColor: 'red',
},
optionText: {
color: colors.text.primary,
fontFamily: font.semiBold,
fontSize: 16,
flex: 1,
},
})
-4
View File
@@ -160,10 +160,6 @@ const ListItem: React.FC<{
size="thumbnail"
/>
)
} else if (item.itemType === 'song') {
coverArt = (
<CoverArt type="album" albumId={item.albumId} style={artStyle} resizeMode={resizeMode} size="thumbnail" />
)
} else {
coverArt = (
<CoverArt type="cover" coverArt={item.coverArt} style={artStyle} resizeMode={resizeMode} size="thumbnail" />
+4 -8
View File
@@ -2,22 +2,19 @@ import Button from '@app/components/Button'
import { Song } from '@app/models/library'
import colors from '@app/styles/colors'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
import Icon from 'react-native-vector-icons/Ionicons'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import { withSuspenseMemo } from './withSuspense'
const ListPlayerControls = withSuspenseMemo<{
const ListPlayerControls = React.memo<{
songs: Song[]
listType: 'album' | 'playlist'
typeName: string
style?: StyleProp<ViewStyle>
play: () => void
shuffle: () => void
disabled?: boolean
}>(({ listType, style, play, shuffle, disabled }) => {
}>(({ typeName, style, play, shuffle, disabled }) => {
const [downloaded, setDownloaded] = useState(false)
const { t } = useTranslation()
return (
<View style={[styles.controls, style]}>
@@ -34,7 +31,7 @@ const ListPlayerControls = withSuspenseMemo<{
</Button>
</View>
<View style={styles.controlsCenter}>
<Button title={t(`resources.${listType}.actions.play`)} disabled={disabled} onPress={play} />
<Button title={`Play ${typeName}`} disabled={disabled} onPress={play} />
</View>
<View style={styles.controlsSide}>
<Button disabled={disabled} onPress={shuffle}>
@@ -58,7 +55,6 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
maxWidth: '65%',
},
})
+2 -7
View File
@@ -1,25 +1,20 @@
import font from '@app/styles/font'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Text, View, StyleSheet, ViewStyle } from 'react-native'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { withSuspenseMemo } from './withSuspense'
const NothingHere = withSuspenseMemo<{
const NothingHere = React.memo<{
height?: number
width?: number
style?: ViewStyle
}>(({ height, width, style }) => {
const { t } = useTranslation()
height = height || 200
width = width || 200
return (
<View style={[styles.container, { height, width }, style]}>
<Icon name="music-rest-quarter" color={styles.text.color} size={width / 2} />
<Text style={[styles.text, { fontSize: width / 8 }]} numberOfLines={3}>
{t('messages.nothingHere')}
</Text>
<Text style={[styles.text, { fontSize: width / 8 }]}>Nothing here...</Text>
</View>
)
})
+3 -3
View File
@@ -79,7 +79,7 @@ const Controls = React.memo(() => {
const NowPlayingBar = React.memo(() => {
const navigation = useNavigation()
const currentTrackExists = useStore(store => !!store.currentTrack)
const albumId = useStore(store => store.currentTrack?.albumId)
const coverArt = useStore(store => store.currentTrack?.coverArt)
const title = useStore(store => store.currentTrack?.title)
const artist = useStore(store => store.currentTrack?.artist)
@@ -90,9 +90,9 @@ const NowPlayingBar = React.memo(() => {
<ProgressBar />
<View style={styles.subContainer}>
<CoverArt
type="album"
type="cover"
style={{ height: styles.subContainer.height, width: styles.subContainer.height }}
albumId={albumId}
coverArt={coverArt}
size="thumbnail"
fadeDuration={0}
/>
-32
View File
@@ -1,32 +0,0 @@
import React, { ComponentType, FunctionComponent, Suspense, SuspenseProps } from 'react'
export function withSuspense<P extends string | number | object>(
WrappedComponent: ComponentType<P>,
fallback: SuspenseProps['fallback'] = null,
): FunctionComponent<P> {
function ComponentWithSuspense(props: P) {
return (
<Suspense fallback={fallback}>
<WrappedComponent {...props} />
</Suspense>
)
}
return ComponentWithSuspense
}
export function withSuspenseMemo<P extends string | number | object>(
WrappedComponent: ComponentType<P>,
fallback: SuspenseProps['fallback'] = null,
propsAreEqual?: Parameters<typeof React.memo>['1'],
) {
function ComponentWithSuspense(props: P) {
return (
<Suspense fallback={fallback}>
<WrappedComponent {...props} />
</Suspense>
)
}
return React.memo(ComponentWithSuspense, propsAreEqual)
}
+18 -28
View File
@@ -1,9 +1,8 @@
import { CacheItemTypeKey } from '@app/models/cache'
import { Album, Playlist, Song } from '@app/models/library'
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 { SubsonicApiClient } from '@app/subsonic/api'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import { cacheDir } from '@app/util/fs'
import { mapCollectionById } from '@app/util/state'
@@ -32,7 +31,7 @@ function cacheStarredData<T extends { id: string; starred?: undefined | any }>(i
}
function cacheAlbumCoverArtData<T extends { id: string; coverArt?: string }>(item: T) {
queryClient.setQueryData<string | undefined>(qk.albumCoverArt(item.id), item.coverArt)
queryClient.setQueryData<AlbumCoverArt>(qk.albumCoverArt(item.id), { albumId: item.id, coverArt: item.coverArt })
}
export const useFetchArtists = () => {
@@ -110,8 +109,11 @@ export const useFetchPlaylist = () => {
}
}
export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise<{ album: Album; songs?: Song[] }> {
const res = await client.getAlbum({ id })
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)
@@ -122,11 +124,7 @@ export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise
album: mapAlbum(res.data.album),
songs: res.data.songs.map(mapSong),
}
}
export const useFetchAlbum = () => {
const client = useClient()
return async (id: string) => fetchAlbum(id, client())
}
}
export const useFetchAlbumList = () => {
@@ -198,23 +196,17 @@ export type FetchExisingFileOptions = {
itemId: string
}
export async function fetchExistingFile(
options: FetchExisingFileOptions,
serverId: string | undefined,
): Promise<string | undefined> {
const { itemType, itemId } = options
const fileDir = cacheDir(serverId, itemType, itemId)
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 {}
}
export const useFetchExistingFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchExisingFileOptions) => fetchExistingFile(options, serverId)
}
}
function assertMimeType(expected?: string, actual?: string) {
@@ -245,8 +237,10 @@ export type FetchFileOptions = FetchExisingFileOptions & {
progress?: (received: number, total: number) => void
}
export async function fetchFile(options: FetchFileOptions, serverId: string | undefined): Promise<string> {
let { itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress } = options
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)
@@ -307,9 +301,5 @@ export async function fetchFile(options: FetchFileOptions, serverId: string | un
console.log('downloaded file:', downloadPath)
return downloadPath
}
export const useFetchFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchFileOptions) => fetchFile(options, serverId)
}
}
+97 -14
View File
@@ -1,11 +1,19 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { Album, Artist, Playlist, Song, StarrableItemType } from '@app/models/library'
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 { useInfiniteQuery, useMutation, useQueries, useQuery } from 'react-query'
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
useQueries,
useQuery,
UseQueryResult,
} from 'react-query'
import {
useFetchAlbum,
useFetchAlbumList,
@@ -80,7 +88,7 @@ export const useQueryArtistTopSongs = (artistName?: string) => {
},
)
return querySuccess ? query : backupQuery
return useFixCoverArt(querySuccess ? query : backupQuery)
}
export const useQueryPlaylists = () => useQuery(qk.playlists, useFetchPlaylists())
@@ -101,7 +109,7 @@ export const useQueryPlaylist = (id: string, placeholderPlaylist?: Playlist) =>
},
})
return query
return useFixCoverArt(query)
}
export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
@@ -112,7 +120,7 @@ export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
placeholderAlbum ? { album: placeholderAlbum } : undefined,
})
return query
return useFixCoverArt(query)
}
export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => {
@@ -164,7 +172,7 @@ export const useQuerySearchResults = (params: Search3Params) => {
},
)
return query
return useFixCoverArt(query)
}
export const useQueryHomeLists = (types: GetAlbumList2TypeBase[], size: number) => {
@@ -306,18 +314,93 @@ export const useQueryArtistArtPath = (artistId: string, size: CacheImageSize = '
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
}
export const useQueryAlbumCoverArtPath = (albumId?: string, size: CacheImageSize = 'thumbnail') => {
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 query = useQuery(
qk.albumCoverArt(albumId || '-1'),
async () => (await fetchAlbum(albumId || '-1')).album.coverArt,
{
enabled: !!albumId,
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,
})),
)
return useQueryCoverArtPath(query.data, size)
if (coverArts.every(c => c.isFetched)) {
return setSongCoverArt(query, coverArts)
}
return query
}
-7
View File
@@ -30,12 +30,8 @@ export const useFirstRun = () => {
export const useResetImageCache = () => {
const serverIds = useStoreDeep(store => Object.keys(store.settings.servers))
const changeCacheBuster = useStore(store => store.changeCacheBuster)
const setDisableMusicTabs = useStore(store => store.setDisableMusicTabs)
return async () => {
setDisableMusicTabs(true)
try {
// disable/invalidate queries
await Promise.all([
queryClient.cancelQueries(qk.artistArt(), { active: true }),
@@ -61,8 +57,6 @@ export const useResetImageCache = () => {
// change cacheBuster
changeCacheBuster()
} finally {
setDisableMusicTabs(false)
// enable queries
await Promise.all([
@@ -71,5 +65,4 @@ export const useResetImageCache = () => {
queryClient.refetchQueries(qk.coverArt(), { active: true }),
])
}
}
}
+78 -39
View File
@@ -1,11 +1,13 @@
import { Song } from '@app/models/library'
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
import queryClient from '@app/queryClient'
import queueService from '@app/queueservice'
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 { useQueries } from 'react-query'
import { useFetchExistingFile, useFetchFile } from './fetch'
import qk from './queryKeys'
export const usePlay = () => {
@@ -90,50 +92,87 @@ export const useIsPlaying = (contextId: string | undefined, track: number) => {
return contextId === queueContextId && track === currentTrackIdx
}
export function mapSongToTrackExt(song: Song): TrackExt {
return {
id: song.id,
title: song.title,
artist: song.artist || 'Unknown Artist',
album: song.album || 'Unknown Album',
url: useStore.getState().buildStreamUri(song.id),
artwork: require('@res/fallback.png'),
userAgent,
duration: song.duration,
artistId: song.artistId,
albumId: song.albumId,
track: song.track,
discNumber: song.discNumber,
}
}
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) => {
if (!songs || songs.length === 0) {
return
const queue = (songs || []).map(mapSongToTrackExt)
return await _setQueue({ queue, type, contextId, ...options })
}
const queue = songs.map(mapSongToTrackExt)
const first = queue[options.playTrack || 0]
if (!first.albumId) {
first.artwork = require('@res/fallback.png')
} else {
const albumCoverArt = queryClient.getQueryData<string>(qk.albumCoverArt(first.albumId))
const existingFile = queryClient.getQueryData<string>(qk.existingFiles('coverArtThumb', albumCoverArt))
const downloadFile = queryClient.getQueryData<string>(qk.coverArt(albumCoverArt, 'thumbnail'))
if (existingFile || downloadFile) {
first.artwork = `file://${existingFile || downloadFile}`
}
}
await _setQueue({ queue, type, contextId, ...options })
queueService.emit('set', { queue })
}
return { setQueue, contextId }
return { setQueue, contextId, isReady: coverArtPaths.every(c => c.isFetched) }
}
-64
View File
@@ -1,64 +0,0 @@
import { BackendModule, LanguageDetectorAsyncModule } from 'i18next'
import path from 'path'
import RNFS from 'react-native-fs'
import * as RNLocalize from 'react-native-localize'
import _ from 'lodash'
const I18N_ASSETS_DIR = path.join('custom', 'i18n')
const cache: {
[language: string]: {
[key: string]: any
}
} = {}
async function loadTranslation(language: string) {
const text = await RNFS.readFileAssets(path.join(I18N_ASSETS_DIR, `${language}.json`), 'utf8')
return JSON.parse(text)
}
async function readTranslation(language: string, namespace: string) {
if (!cache[language]) {
cache[language] = await loadTranslation(language)
}
return namespace === 'translation' ? cache[language] : _.get(cache[language], namespace)
}
export const backend = {
type: 'backend',
init: () => {},
read: async (language, namespace, callback) => {
try {
callback(null, await readTranslation(language, namespace))
} catch (err) {
callback(err as any, null)
}
},
} as BackendModule
export const languageDetector = {
type: 'languageDetector',
async: true,
detect: async callback => {
try {
const languageTags = (await RNFS.readDirAssets(I18N_ASSETS_DIR))
.map(f => f.name)
.filter(n => n.endsWith('.json'))
.map(n => n.slice(0, -5))
console.log('translations available:', languageTags)
console.log(
'locales list:',
RNLocalize.getLocales().map(l => l.languageTag),
)
console.log('best language:', RNLocalize.findBestAvailableLanguage(languageTags)?.languageTag)
callback(RNLocalize.findBestAvailableLanguage(languageTags)?.languageTag)
} catch {
callback(undefined)
}
},
init: () => {},
cacheUserLanguage: () => {},
} as LanguageDetectorAsyncModule
+1
View File
@@ -43,6 +43,7 @@ export interface Song {
discNumber?: number
duration?: number
starred?: number
coverArt?: string
playCount?: number
userRating?: number
averageRating?: number
+1
View File
@@ -75,6 +75,7 @@ export function mapTrackExtToSong(track: TrackExt): Song {
title: track.title as string,
artist: track.artist,
album: track.album,
coverArt: track.coverArt,
duration: track.duration,
artistId: track.artistId,
albumId: track.albumId,
+4 -16
View File
@@ -18,11 +18,10 @@ const BottomTabButton = React.memo<{
isFocused: boolean
icon: OutlineFillIcon
navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap>
disabled?: boolean
}>(({ routeKey, label, name, isFocused, icon, navigation, disabled }) => {
}>(({ routeKey, label, name, isFocused, icon, navigation }) => {
const firstRun = useFirstRun()
disabled = !!disabled || (firstRun && name !== 'settings')
const disabled = firstRun && name !== 'settings'
const onPress = () => {
const event = navigation.emit({
@@ -48,9 +47,7 @@ const BottomTabButton = React.memo<{
return (
<PressableOpacity onPress={onPress} style={styles.button} disabled={disabled}>
<Image source={imgSource} style={imgStyle} fadeDuration={0} />
<Text style={textStyle} numberOfLines={1} ellipsizeMode="clip">
{label}
</Text>
<Text style={textStyle}>{label}</Text>
</PressableOpacity>
)
})
@@ -68,13 +65,6 @@ const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigat
? options.title
: route.name
let iconKey = route.name
let disabled = false
if (route.name.endsWith('-disabled')) {
iconKey = route.name.split('-')[0]
disabled = true
}
return (
<BottomTabButton
key={route.key}
@@ -82,9 +72,8 @@ const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigat
label={label}
name={route.name}
isFocused={state.index === index}
icon={bottomTabIcons[iconKey]}
icon={bottomTabIcons[route.name]}
navigation={navigation}
disabled={disabled}
/>
)
})}
@@ -103,7 +92,6 @@ const styles = StyleSheet.create({
},
button: {
alignItems: 'center',
flexGrow: 1,
flex: 1,
height: '100%',
},
+15 -32
View File
@@ -1,4 +1,3 @@
import { withSuspense } from '@app/components/withSuspense'
import { useFirstRun } from '@app/hooks/settings'
import { Album, Playlist } from '@app/models/library'
import BottomTabBar from '@app/navigation/BottomTabBar'
@@ -17,7 +16,6 @@ import font from '@app/styles/font'
import { BottomTabNavigationProp, createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { RouteProp, StackActions } from '@react-navigation/native'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import { createNativeStackNavigator, NativeStackNavigationProp } from 'react-native-screens/native-stack'
@@ -117,8 +115,8 @@ const SearchTab = createTabStackNavigator(Search)
type SettingsStackParamList = {
main: undefined
server?: { id?: string; title?: string }
web: { uri: string; title?: string }
server?: { id?: string }
web: { uri: string }
}
type ServerScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'server'>
@@ -127,9 +125,7 @@ type ServerScreenProps = {
route: ServerScreenRouteProp
navigation: ServerScreenNavigationProp
}
const ServerScreen: React.FC<ServerScreenProps> = ({ route }) => (
<ServerView id={route.params?.id} title={route.params?.title} />
)
const ServerScreen: React.FC<ServerScreenProps> = ({ route }) => <ServerView id={route.params?.id} />
type WebScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'web'>
type WebScreenRouteProp = RouteProp<SettingsStackParamList, 'web'>
@@ -137,9 +133,7 @@ type WebScreenProps = {
route: WebScreenRouteProp
navigation: WebScreenNavigationProp
}
const WebScreen: React.FC<WebScreenProps> = ({ route }) => (
<WebViewScreen uri={route.params.uri} title={route.params.title} />
)
const WebScreen: React.FC<WebScreenProps> = ({ route }) => <WebViewScreen uri={route.params.uri} />
const SettingsStack = createNativeStackNavigator()
@@ -151,6 +145,7 @@ const SettingsTab = () => {
name="server"
component={ServerScreen}
options={{
title: 'Edit Server',
headerStyle: styles.stackheaderStyle,
headerHideShadow: true,
headerTintColor: 'white',
@@ -161,6 +156,7 @@ const SettingsTab = () => {
name="web"
component={WebScreen}
options={{
title: 'Web View',
headerStyle: styles.stackheaderStyle,
headerHideShadow: true,
headerTintColor: 'white',
@@ -173,37 +169,24 @@ const SettingsTab = () => {
const Tab = createBottomTabNavigator()
const BottomTabNavigator = withSuspense(() => {
const { t } = useTranslation()
const BottomTabNavigator = () => {
const firstRun = useFirstRun()
const disableMusicTabs = useStore(store => store.disableMusicTabs)
const resetServer = useStore(store => store.resetServer)
return (
<Tab.Navigator tabBar={BottomTabBar} initialRouteName={firstRun ? 'settings' : 'home'}>
{disableMusicTabs ? (
<>
<Tab.Screen name="home-disabled" children={() => null} options={{ tabBarLabel: t('navigation.tabs.home') }} />
<Tab.Screen
name="library-disabled"
children={() => null}
options={{ tabBarLabel: t('navigation.tabs.library') }}
/>
<Tab.Screen
name="search-disabled"
children={() => null}
options={{ tabBarLabel: t('navigation.tabs.search') }}
/>
</>
{resetServer ? (
<></>
) : (
<>
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: t('navigation.tabs.home') }} />
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: t('navigation.tabs.library') }} />
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: t('navigation.tabs.search') }} />
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: 'Home' }} />
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: 'Library' }} />
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: 'Search' }} />
</>
)}
<Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: t('navigation.tabs.settings') }} />
<Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: 'Settings' }} />
</Tab.Navigator>
)
})
}
export default BottomTabNavigator
+5 -20
View File
@@ -1,4 +1,3 @@
import { withSuspense } from '@app/components/withSuspense'
import AlbumsTab from '@app/screens/LibraryAlbums'
import ArtistsTab from '@app/screens/LibraryArtists'
import PlaylistsTab from '@app/screens/LibraryPlaylists'
@@ -7,14 +6,12 @@ import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const Tab = createMaterialTopTabNavigator()
const LibraryTopTabNavigator = withSuspense(() => {
const { t } = useTranslation()
const LibraryTopTabNavigator = () => {
const marginTop = useSafeAreaInsets().top
return (
@@ -25,24 +22,12 @@ const LibraryTopTabNavigator = withSuspense(() => {
indicatorStyle: styles.tabindicatorStyle,
}}
initialRouteName="albums">
<Tab.Screen
name="albums"
component={AlbumsTab}
options={{ tabBarLabel: t('resources.album.name', { count: 2 }) }}
/>
<Tab.Screen
name="artists"
component={ArtistsTab}
options={{ tabBarLabel: t('resources.artist.name', { count: 2 }) }}
/>
<Tab.Screen
name="playlists"
component={PlaylistsTab}
options={{ tabBarLabel: t('resources.playlist.name', { count: 2 }) }}
/>
<Tab.Screen name="albums" component={AlbumsTab} options={{ tabBarLabel: 'Albums' }} />
<Tab.Screen name="artists" component={ArtistsTab} options={{ tabBarLabel: 'Artists' }} />
<Tab.Screen name="playlists" component={PlaylistsTab} options={{ tabBarLabel: 'Playlists' }} />
</Tab.Navigator>
)
})
}
const styles = StyleSheet.create({
tabBar: {
+3 -9
View File
@@ -1,4 +1,3 @@
import { withSuspense } from '@app/components/withSuspense'
import BottomTabNavigator from '@app/navigation/BottomTabNavigator'
import NowPlayingQueue from '@app/screens/NowPlayingQueue'
import NowPlayingView from '@app/screens/NowPlayingView'
@@ -6,22 +5,18 @@ import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { DarkTheme, NavigationContainer } from '@react-navigation/native'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
const NowPlayingStack = createNativeStackNavigator()
const NowPlayingNavigator = withSuspense(() => {
const { t } = useTranslation()
return (
const NowPlayingNavigator = () => (
<NowPlayingStack.Navigator>
<NowPlayingStack.Screen name="main" component={NowPlayingView} options={{ headerShown: false }} />
<NowPlayingStack.Screen
name="queue"
component={NowPlayingQueue}
options={{
title: t('resources.queue.name', { count: 1 }),
title: 'Queue',
headerStyle: {
backgroundColor: colors.gradient.high,
},
@@ -35,8 +30,7 @@ const NowPlayingNavigator = withSuspense(() => {
}}
/>
</NowPlayingStack.Navigator>
)
})
)
const RootStack = createNativeStackNavigator()
+2 -149
View File
@@ -1,14 +1,8 @@
import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer'
import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo'
import _ from 'lodash'
import { unstable_batchedUpdates } from 'react-native'
import TrackPlayer, { Event, State } from 'react-native-track-player'
import { fetchAlbum, FetchExisingFileOptions, fetchExistingFile, fetchFile, FetchFileOptions } from './hooks/fetch'
import qk from './hooks/queryKeys'
import queryClient from './queryClient'
import queueService from './queueservice'
import { useStore } from './state/store'
import { ReturnedPromiseResolvedType } from './util/types'
import { unstable_batchedUpdates } from 'react-native'
import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo'
const reset = () => {
unstable_batchedUpdates(() => {
@@ -40,81 +34,12 @@ const rebuildQueue = (forcePlay?: boolean) => {
})
}
const updateQueue = () => {
unstable_batchedUpdates(() => {
useStore.getState().updateQueue()
})
}
const setDuckPaused = (duckPaused: boolean) => {
unstable_batchedUpdates(() => {
useStore.getState().setDuckPaused(duckPaused)
})
}
const setQueryDataAlbum = (queryKey: any, data: ReturnedPromiseResolvedType<typeof fetchAlbum>) => {
unstable_batchedUpdates(() => {
queryClient.setQueryData(queryKey, data)
})
}
const setQueryDataExistingFiles = (queryKey: any, data: ReturnedPromiseResolvedType<typeof fetchExistingFile>) => {
unstable_batchedUpdates(() => {
queryClient.setQueryData(queryKey, data)
})
}
const setQueryDataCoverArt = (queryKey: any, data: ReturnedPromiseResolvedType<typeof fetchFile>) => {
unstable_batchedUpdates(() => {
queryClient.setQueryData(queryKey, data)
})
}
function getClient() {
const client = useStore.getState().client
if (!client) {
throw new Error('no client!')
}
return client
}
async function getAlbum(id: string) {
try {
const res = await fetchAlbum(id, getClient())
setQueryDataAlbum(qk.album(id), res)
return res
} catch {}
}
async function getCoverArtThumbExisting(coverArt: string) {
const serverId = useStore.getState().settings.activeServerId
const options: FetchExisingFileOptions = { itemType: 'coverArtThumb', itemId: coverArt }
try {
const res = await fetchExistingFile(options, serverId)
setQueryDataExistingFiles(qk.existingFiles(options.itemType, options.itemId), res)
return res
} catch {}
}
async function getCoverArtThumb(coverArt: string) {
const serverId = useStore.getState().settings.activeServerId
const fromUrl = getClient().getCoverArtUri({ id: coverArt, size: '256' })
const options: FetchFileOptions = {
itemType: 'coverArtThumb',
itemId: coverArt,
fromUrl,
expectedContentType: 'image',
}
try {
const res = await fetchFile(options, serverId)
setQueryDataCoverArt(qk.coverArt(coverArt, 'thumbnail'), res)
return res
} catch {}
}
let serviceCreated = false
const createService = async () => {
@@ -217,78 +142,6 @@ const createService = async () => {
rebuildQueue(true)
}
})
queueService.addListener('set', async ({ queue }) => {
const contextId = useStore.getState().queueContextId
const throwIfQueueChanged = () => {
if (contextId !== useStore.getState().queueContextId) {
throw 'queue-changed'
}
}
const albumIds = _.uniq(queue.map(s => s.albumId)).filter((id): id is string => id !== undefined)
const albumIdImagePath: { [albumId: string]: string | undefined } = {}
for (const albumId of albumIds) {
let coverArt = queryClient.getQueryData<string>(qk.albumCoverArt(albumId))
if (!coverArt) {
throwIfQueueChanged()
console.log('no cached coverArt for album', albumId, 'getting album...')
coverArt = (await getAlbum(albumId))?.album.coverArt
if (!coverArt) {
continue
}
}
let imagePath =
queryClient.getQueryData<string>(qk.existingFiles('coverArtThumb', coverArt)) ||
queryClient.getQueryData<string>(qk.coverArt(coverArt, 'thumbnail'))
if (!imagePath) {
throwIfQueueChanged()
console.log('no cached image for', coverArt, 'getting file...')
imagePath = (await getCoverArtThumbExisting(coverArt)) || (await getCoverArtThumb(coverArt))
if (!imagePath) {
continue
}
}
albumIdImagePath[albumId] = imagePath
}
for (let i = 0; i < queue.length; i++) {
const track = queue[i]
if (typeof track.artwork === 'string') {
continue
}
if (!track.albumId) {
continue
}
let imagePath = albumIdImagePath[track.albumId]
if (!imagePath) {
continue
}
try {
throwIfQueueChanged()
let trackIdx = i
const shuffleOrder = useStore.getState().shuffleOrder
if (shuffleOrder) {
trackIdx = shuffleOrder.indexOf(i)
}
await TrackPlayer.updateMetadataForTrack(trackIdx, { ...track, artwork: `file://${imagePath}` })
} catch {
break
}
}
await trackPlayerCommands.enqueue(async () => {
updateQueue()
})
})
}
module.exports = async function () {
-18
View File
@@ -1,18 +0,0 @@
/* eslint-disable no-dupe-class-members */
import { EmitterSubscription, NativeEventEmitter } from 'react-native'
import { TrackExt } from './models/trackplayer'
class QueueService extends NativeEventEmitter {
addListener(eventType: 'set', listener: (event: { queue: TrackExt[] }) => void): EmitterSubscription
addListener(eventType: string, listener: (event: any) => void, context?: Object): EmitterSubscription {
return super.addListener(eventType, listener, context)
}
emit(eventType: 'set', event: { queue: TrackExt[] }): void
emit(eventType: string, ...params: any[]): void {
super.emit(eventType, ...params)
}
}
const queueService = new QueueService()
export default queueService
+10 -21
View File
@@ -5,7 +5,6 @@ import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import HeaderBar from '@app/components/HeaderBar'
import ListItem from '@app/components/ListItem'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryArtist, useQueryArtistTopSongs } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Song } from '@app/models/library'
@@ -16,7 +15,6 @@ import { useLayout } from '@react-native-community/hooks'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
@@ -44,17 +42,15 @@ const AlbumItem = React.memo<{
)
}, equal)
const TopSongs = withSuspenseMemo<{
const TopSongs = React.memo<{
songs: Song[]
name: string
}>(
({ songs, name }) => {
const { setQueue, contextId } = useSetQueue('artist', songs)
const { t } = useTranslation()
}>(({ songs, name }) => {
const { setQueue, isReady, contextId } = useSetQueue('artist', songs)
return (
<>
<Header>{t('resources.song.lists.artistTopSongs')}</Header>
<Header>Top Songs</Header>
{songs.slice(0, 5).map((s, i) => (
<ListItem
key={i}
@@ -64,21 +60,17 @@ const TopSongs = withSuspenseMemo<{
showArt={true}
subtitle={s.album}
onPress={() => setQueue({ title: name, playTrack: i })}
disabled={!isReady}
/>
))}
</>
)
},
null,
equal,
)
}, equal)
const ArtistAlbums = withSuspenseMemo<{
const ArtistAlbums = React.memo<{
albums: Album[]
}>(
({ albums }) => {
}>(({ albums }) => {
const albumsLayout = useLayout()
const { t } = useTranslation()
const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name))
@@ -88,7 +80,7 @@ const ArtistAlbums = withSuspenseMemo<{
return (
<>
<Header>{t('resources.album.name', { count: 2 })}</Header>
<Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
@@ -96,10 +88,7 @@ const ArtistAlbums = withSuspenseMemo<{
</View>
</>
)
},
null,
equal,
)
}, equal)
const ArtistViewFallback = React.memo(() => (
<GradientBackground style={styles.fallback}>
+9 -10
View File
@@ -3,20 +3,25 @@ import CoverArt from '@app/components/CoverArt'
import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import NothingHere from '@app/components/NothingHere'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryHomeLists } from '@app/hooks/query'
import { Album } from '@app/models/library'
import { useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import { GetAlbumList2TypeBase, GetAlbumListType } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const titles: { [key in GetAlbumListType]?: string } = {
recent: 'Recently Played',
random: 'Random Albums',
frequent: 'Frequently Played',
starred: 'Starred Albums',
}
const AlbumItem = React.memo<{
album: Album
}>(({ album }) => {
@@ -44,12 +49,6 @@ const AlbumItem = React.memo<{
)
}, equal)
const CategoryHeader = withSuspenseMemo<{ type: string }>(({ type }) => {
const { t } = useTranslation()
console.log('type', type, t(`resources.album.lists.${type}`))
return <Header style={styles.header}>{t(`resources.album.lists.${type}`)}</Header>
})
const Category = React.memo<{
type: string
albums: Album[]
@@ -75,7 +74,7 @@ const Category = React.memo<{
return (
<View style={styles.category}>
<CategoryHeader type={type} />
<Header style={styles.header}>{titles[type as GetAlbumListType] || ''}</Header>
{albums.length > 0 ? <Albums /> : <Nothing />}
</View>
)
+26 -32
View File
@@ -1,18 +1,16 @@
import { AlbumContextPressable } from '@app/components/ContextMenu'
import CoverArt from '@app/components/CoverArt'
import FilterButton from '@app/components/FilterButton'
import FilterButton, { OptionData } from '@app/components/FilterButton'
import GradientFlatList from '@app/components/GradientFlatList'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryAlbumList } from '@app/hooks/query'
import { Album } from '@app/models/library'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import { GetAlbumList2Type, GetAlbumList2TypeBase } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'
const AlbumItem = React.memo<{
@@ -55,36 +53,23 @@ const AlbumListRenderItem: React.FC<{
item: { album: Album; size: number; height: number }
}> = ({ item }) => <AlbumItem album={item.album} size={item.size} height={item.height} />
const filterValues: GetAlbumList2TypeBase[] = [
'alphabeticalByName', //
'alphabeticalByArtist',
'newest',
'frequent',
'recent',
'starred',
'random',
const filterOptions: OptionData[] = [
{ text: 'By Name', value: 'alphabeticalByName' },
{ text: 'By Artist', value: 'alphabeticalByArtist' },
{ text: 'Newest', value: 'newest' },
{ text: 'Frequent', value: 'frequent' },
{ text: 'Recent', value: 'recent' },
{ text: 'Starred', value: 'starred' },
{ text: 'Random', value: 'random' },
// { text: 'By Year...', value: 'byYear' },
// { text: 'By Genre...', value: 'byGenre' },
]
const AlbumFilterButton = withSuspenseMemo(() => {
const { t } = useTranslation()
const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type)
const setFilterType = useStore(store => store.setLibraryAlbumFilterType)
return (
<FilterButton
data={filterValues.map(value => ({ value, text: t(`resources.album.lists.${value}`) }))}
value={filterType}
onSelect={selection => {
setFilterType(selection as GetAlbumList2TypeBase)
}}
title={t('resources.album.lists.sort')}
/>
)
})
const AlbumsList = () => {
const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filterType as GetAlbumList2TypeBase, 300)
const filter = useStoreDeep(store => store.settings.screens.library.albumsFilter)
const setFilter = useStore(store => store.setLibraryAlbumFilter)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filter.type as GetAlbumList2TypeBase, 300)
const layout = useWindowDimensions()
@@ -106,7 +91,16 @@ const AlbumsList = () => {
onEndReachedThreshold={6}
windowSize={5}
/>
<AlbumFilterButton />
<FilterButton
data={filterOptions}
value={filter.type}
onSelect={selection => {
setFilter({
...filter,
type: selection as GetAlbumList2Type,
})
}}
/>
</View>
)
}
+19 -26
View File
@@ -1,42 +1,26 @@
import FilterButton from '@app/components/FilterButton'
import FilterButton, { OptionData } from '@app/components/FilterButton'
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryArtists } from '@app/hooks/query'
import { Artist } from '@app/models/library'
import { ArtistFilterType } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, View } from 'react-native'
const ArtistRenderItem: React.FC<{ item: Artist }> = ({ item }) => (
<ListItem item={item} showArt={true} showStar={false} listStyle="big" style={styles.listItem} />
)
const filterValues: ArtistFilterType[] = [
'alphabeticalByName', //
'starred',
'random',
const filterOptions: OptionData[] = [
{ text: 'By Name', value: 'alphabeticalByName' },
{ text: 'Starred', value: 'starred' },
{ text: 'Random', value: 'random' },
]
const ArtistFilterButton = withSuspenseMemo(() => {
const { t } = useTranslation()
const filterType = useStoreDeep(store => store.settings.screens.library.artistsFilter.type)
const setFilterType = useStore(store => store.setLibraryArtistFilterType)
return (
<FilterButton
data={filterValues.map(value => ({ value, text: t(`resources.artist.lists.${value}`) }))}
value={filterType}
onSelect={selection => setFilterType(selection as ArtistFilterType)}
title={t('resources.artist.lists.sort')}
/>
)
})
const ArtistsList = () => {
const filterType = useStore(store => store.settings.screens.library.artistsFilter.type)
const filter = useStoreDeep(store => store.settings.screens.library.artistsFilter)
const setFilter = useStore(store => store.setLibraryArtistFiler)
const { isLoading, data, refetch } = useQueryArtists()
const [sortedList, setSortedList] = useState<Artist[]>([])
@@ -48,7 +32,7 @@ const ArtistsList = () => {
}
const list = Object.values(data.byId)
switch (filterType) {
switch (filter.type) {
case 'random':
setSortedList([...list].sort(() => Math.random() - 0.5))
break
@@ -62,7 +46,7 @@ const ArtistsList = () => {
setSortedList([...list])
break
}
}, [filterType, data])
}, [filter.type, data])
return (
<View style={styles.container}>
@@ -76,7 +60,16 @@ const ArtistsList = () => {
windowSize={3}
contentMarginTop={6}
/>
<ArtistFilterButton />
<FilterButton
data={filterOptions}
value={filter.type}
onSelect={selection => {
setFilter({
...filter,
type: selection as ArtistFilterType,
})
}}
/>
</View>
)
}
+20 -19
View File
@@ -3,10 +3,9 @@ import HeaderBar from '@app/components/HeaderBar'
import ImageGradientBackground from '@app/components/ImageGradientBackground'
import PressableOpacity from '@app/components/PressableOpacity'
import { PressableStar } from '@app/components/Star'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useNext, usePause, usePlay, usePrevious, useSeekTo } from '@app/hooks/trackplayer'
import { mapTrackExtToSong } from '@app/models/map'
import { TrackExt } from '@app/models/trackplayer'
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
@@ -14,7 +13,6 @@ import formatDuration from '@app/util/formatDuration'
import Slider from '@react-native-community/slider'
import { useNavigation } from '@react-navigation/native'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, StyleSheet, Text, TextStyle, View } from 'react-native'
import { NativeStackScreenProps } from 'react-native-screens/native-stack'
import { RepeatMode, State } from 'react-native-track-player'
@@ -23,29 +21,32 @@ import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import Icon from 'react-native-vector-icons/Ionicons'
import IconMatCom from 'react-native-vector-icons/MaterialCommunityIcons'
const NowPlayingHeader = withSuspenseMemo<{
function getContextName(type?: QueueContextType) {
switch (type) {
case 'album':
return 'Album'
case 'artist':
return 'Top Songs'
case 'playlist':
return 'Playlist'
case 'song':
return 'Search Results'
default:
return undefined
}
}
const NowPlayingHeader = React.memo<{
track?: TrackExt
}>(({ track }) => {
const queueName = useStore(store => store.queueName)
const queueContextType = useStore(store => store.queueContextType)
const { t } = useTranslation()
console.log(t('resources.album.name', { count: 1 }))
if (!track) {
return <></>
}
let contextName: string
if (queueContextType === 'album') {
contextName = t('resources.album.name', { count: 1 })
} else if (queueContextType === 'artist') {
contextName = t('resources.song.lists.artistTopSongs')
} else if (queueContextType === 'playlist') {
contextName = t('resources.playlist.name', { count: 1 })
} else if (queueContextType === 'song') {
contextName = t('search.nowPlayingContext')
}
let contextName = getContextName(queueContextType)
return (
<HeaderBar
@@ -90,11 +91,11 @@ const headerStyles = StyleSheet.create({
})
const SongCoverArt = () => {
const albumId = useStore(store => store.currentTrack?.albumId)
const coverArt = useStore(store => store.currentTrack?.coverArt)
return (
<View style={coverArtStyles.container}>
<CoverArt type="album" size="original" albumId={albumId} style={coverArtStyles.image} />
<CoverArt type="cover" size="original" coverArt={coverArt} style={coverArtStyles.image} />
</View>
)
}
+15 -43
View File
@@ -4,7 +4,6 @@ import Header from '@app/components/Header'
import ListItem from '@app/components/ListItem'
import NothingHere from '@app/components/NothingHere'
import TextInput from '@app/components/TextInput'
import { withSuspense, withSuspenseMemo } from '@app/components/withSuspense'
import { useQuerySearchResults } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Artist, SearchResults, Song } from '@app/models/library'
@@ -14,7 +13,6 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import _ from 'lodash'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
ActivityIndicator,
InteractionManager,
@@ -26,7 +24,7 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const SongItem = React.memo<{ item: Song }>(({ item }) => {
const { setQueue, contextId } = useSetQueue('song', [item])
const { setQueue, isReady, contextId } = useSetQueue('song', [item])
return (
<ListItem
@@ -36,19 +34,18 @@ const SongItem = React.memo<{ item: Song }>(({ item }) => {
showArt={true}
showStar={false}
onPress={() => setQueue({ title: item.title, playTrack: 0 })}
disabled={!isReady}
/>
)
}, equal)
const ResultsCategory = withSuspenseMemo<{
const ResultsCategory = React.memo<{
name: string
query: string
items: (Artist | Album | Song)[]
type: 'artist' | 'album' | 'song'
}>(
({ name, query, type, items }) => {
}>(({ name, query, type, items }) => {
const navigation = useNavigation()
const { t } = useTranslation()
if (items.length === 0) {
return <></>
@@ -66,7 +63,7 @@ const ResultsCategory = withSuspenseMemo<{
)}
{items.length === 5 && (
<Button
title={t('search.moreResults')}
title="More..."
buttonStyle="hollow"
style={styles.more}
onPress={() => navigation.navigate('results', { query, type: items[0].itemType })}
@@ -74,49 +71,24 @@ const ResultsCategory = withSuspenseMemo<{
)}
</>
)
},
null,
equal,
)
}, equal)
const Results = withSuspenseMemo<{
const Results = React.memo<{
results: SearchResults
query: string
}>(
({ results, query }) => {
const { t } = useTranslation()
}>(({ results, query }) => {
return (
<>
<ResultsCategory
name={t('resources.artist.name', { count: 2 })}
query={query}
type={'artist'}
items={results.artists}
/>
<ResultsCategory
name={t('resources.album.name', { count: 2 })}
query={query}
type={'album'}
items={results.albums}
/>
<ResultsCategory
name={t('resources.song.name', { count: 2 })}
query={query}
type={'song'}
items={results.songs}
/>
<ResultsCategory name="Artists" query={query} type={'artist'} items={results.artists} />
<ResultsCategory name="Albums" query={query} type={'album'} items={results.albums} />
<ResultsCategory name="Songs" query={query} type={'song'} items={results.songs} />
</>
)
},
null,
equal,
)
}, equal)
const Search = withSuspense(() => {
const Search = () => {
const [query, setQuery] = useState('')
const { data, isLoading } = useQuerySearchResults({ query, albumCount: 5, artistCount: 5, songCount: 5 })
const { t } = useTranslation()
const [text, setText] = useState('')
const searchBarRef = useRef<ReactTextInput>(null)
@@ -168,7 +140,7 @@ const Search = withSuspense(() => {
<TextInput
ref={searchBarRef}
style={styles.textInput}
placeholder={t('search.inputPlaceholder')}
placeholder="Search"
value={text}
onChangeText={onChangeText}
/>
@@ -182,7 +154,7 @@ const Search = withSuspense(() => {
</View>
</GradientScrollView>
)
})
}
const styles = StyleSheet.create({
scroll: {
+6 -8
View File
@@ -1,19 +1,17 @@
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { withSuspense } from '@app/components/withSuspense'
import { useQuerySearchResults } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Artist, Song } from '@app/models/library'
import { Search3Params } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
type SearchListItemType = Album | Song | Artist
const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => {
const { setQueue, contextId } = useSetQueue('song', [item])
const { setQueue, isReady, contextId } = useSetQueue('song', [item])
return (
<ListItem
@@ -25,6 +23,7 @@ const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => {
listStyle="small"
onPress={() => setQueue({ title: item.title, playTrack: 0 })}
style={styles.listItem}
disabled={!isReady}
/>
)
}
@@ -53,12 +52,11 @@ const ResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
const SearchResultsRenderItem: React.FC<{ item: SearchListItemType }> = ({ item }) => <ResultsListItem item={item} />
const SearchResultsView = withSuspense<{
const SearchResultsView: React.FC<{
query: string
type: 'album' | 'artist' | 'song'
}>(({ query, type }) => {
}> = ({ query, type }) => {
const navigation = useNavigation()
const { t } = useTranslation()
const size = 100
const params: Search3Params = { query }
@@ -84,7 +82,7 @@ const SearchResultsView = withSuspense<{
useEffect(() => {
navigation.setOptions({
title: t('search.headerTitle', { query }),
title: `Search: "${query}"`,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -104,7 +102,7 @@ const SearchResultsView = withSuspense<{
windowSize={5}
/>
)
})
}
const styles = StyleSheet.create({
listItem: {
+20 -34
View File
@@ -1,7 +1,5 @@
import Button from '@app/components/Button'
import GradientScrollView from '@app/components/GradientScrollView'
import SettingsSwitch from '@app/components/SettingsSwitch'
import { withSuspense } from '@app/components/withSuspense'
import { Server } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
@@ -9,18 +7,16 @@ import font from '@app/styles/font'
import toast from '@app/util/toast'
import { useNavigation } from '@react-navigation/native'
import md5 from 'md5'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import React, { useCallback, useState } from 'react'
import { StyleSheet, Text, TextInput, View, ViewStyle } from 'react-native'
import uuid from 'react-native-uuid'
import SettingsSwitch from '@app/components/SettingsSwitch'
const PASSWORD_PLACEHOLDER = 'PASSWORD_PLACEHOLDER'
const ServerView = withSuspense<{
const ServerView: React.FC<{
id?: string
title?: string
}>(({ id, title }) => {
const { t } = useTranslation()
}> = ({ id }) => {
const navigation = useNavigation()
const activeServerId = useStore(store => store.settings.activeServerId)
const servers = useStoreDeep(store => store.settings.servers)
@@ -40,10 +36,6 @@ const ServerView = withSuspense<{
const [testing, setTesting] = useState(false)
useEffect(() => {
navigation.setOptions({ title })
}, [navigation, title])
const validate = useCallback(() => {
return !!address && !!username && !!password
}, [address, username, password])
@@ -142,16 +134,15 @@ const ServerView = withSuspense<{
const ping = async () => {
const res = await pingServer(potential)
toast(
t(`settings.servers.messages.${res ? 'connectionOk' : 'connectionFailed'}`, {
address: potential.address,
interpolation: { escapeValue: false },
}),
)
if (res) {
toast(`Connection to ${potential.address} OK!`)
} else {
toast(`Connection to ${potential.address} failed, check settings or server`)
}
setTesting(false)
}
ping()
}, [createServer, pingServer, t])
}, [createServer, pingServer])
const disableControls = useCallback(() => {
return !validate() || testing
@@ -178,7 +169,7 @@ const ServerView = withSuspense<{
return (
<GradientScrollView style={styles.scroll}>
<View style={styles.content}>
<Text style={styles.inputTitle}>{t('settings.servers.fields.address')}</Text>
<Text style={styles.inputTitle}>Address</Text>
<TextInput
style={styles.input}
placeholderTextColor="grey"
@@ -191,7 +182,7 @@ const ServerView = withSuspense<{
onChangeText={setAddress}
onBlur={formatAddress}
/>
<Text style={styles.inputTitle}>{t('settings.servers.fields.username')}</Text>
<Text style={styles.inputTitle}>Username</Text>
<TextInput
style={styles.input}
placeholderTextColor="grey"
@@ -204,7 +195,7 @@ const ServerView = withSuspense<{
value={username}
onChangeText={setUsername}
/>
<Text style={styles.inputTitle}>{t('settings.servers.fields.password')}</Text>
<Text style={styles.inputTitle}>Password</Text>
<TextInput
style={styles.input}
placeholderTextColor="grey"
@@ -219,11 +210,11 @@ const ServerView = withSuspense<{
onChangeText={setPassword}
/>
<SettingsSwitch
title={t('settings.servers.options.forcePlaintextPassword.title')}
title="Force plain text password"
subtitle={
usePlainPassword
? t('settings.servers.options.forcePlaintextPassword.descriptionOn')
: t('settings.servers.options.forcePlaintextPassword.descriptionOff')
? 'Send password in plain text (legacy, make sure your connection is secure!)'
: 'Send password as token + salt'
}
value={usePlainPassword}
setValue={togglePlainPassword}
@@ -231,26 +222,21 @@ const ServerView = withSuspense<{
<Button
disabled={disableControls()}
style={styles.button}
title={t('settings.servers.actions.testConnection')}
title="Test Connection"
buttonStyle="hollow"
onPress={test}
/>
<Button
disabled={disableControls()}
style={[styles.button, styles.delete, deleteStyle]}
title={t('settings.servers.actions.delete')}
title="Delete"
onPress={remove}
/>
<Button
disabled={disableControls()}
style={styles.button}
title={t('settings.servers.actions.save')}
onPress={save}
/>
<Button disabled={disableControls()} style={styles.button} title="Save" onPress={save} />
</View>
</GradientScrollView>
)
})
}
const styles = StyleSheet.create({
scroll: {
+50 -61
View File
@@ -5,28 +5,25 @@ import PressableOpacity from '@app/components/PressableOpacity'
import SettingsItem from '@app/components/SettingsItem'
import SettingsSwitch from '@app/components/SettingsSwitch'
import TextInput from '@app/components/TextInput'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useResetImageCache, useSwitchActiveServer } from '@app/hooks/settings'
import { useSwitchActiveServer, useResetImageCache } from '@app/hooks/settings'
import { Server } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/core'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { KeyboardTypeOptions, Linking, Modal, Pressable, StyleSheet, Text, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { version } from '../../package.json'
const ServerItem = withSuspenseMemo<{
const ServerItem = React.memo<{
server: Server
}>(({ server }) => {
const activeServerId = useStore(store => store.settings.activeServerId)
const switchActiveServer = useSwitchActiveServer()
const navigation = useNavigation()
const { t } = useTranslation()
const setActive = useCallback(() => {
switchActiveServer(server.id)
@@ -36,7 +33,7 @@ const ServerItem = withSuspenseMemo<{
<SettingsItem
title={server.address}
subtitle={server.username}
onPress={() => navigation.navigate('server', { id: server.id, title: t('settings.servers.actions.edit') })}>
onPress={() => navigation.navigate('server', { id: server.id })}>
<PressableOpacity style={styles.serverActive} onPress={setActive}>
{activeServerId === server.id ? (
<Icon name="checkbox-marked-circle" size={30} color={colors.accent} />
@@ -76,27 +73,25 @@ const ModalChoice = React.memo<{
)
})
const BitrateModal = withSuspenseMemo<{
function bitrateString(bitrate: number): string {
return bitrate === 0 ? 'Unlimited' : `${bitrate}kbps`
}
const BitrateModal = React.memo<{
title: string
bitrate: number
setBitrate: (bitrate: number) => void
}>(({ title, bitrate, setBitrate }) => {
const { t } = useTranslation()
const [visible, setVisible] = useState(false)
const toggleModal = useCallback(() => setVisible(!visible), [visible])
const bitrateText = useCallback(
(value: number) =>
value === 0 ? t('settings.network.values.unlimitedKbps') : t('settings.network.values.kbps', { value }),
[t],
)
const BitrateChoice: React.FC<{ value: number }> = useCallback(
({ value }) => {
const text = bitrateString(value)
return (
<ModalChoice
text={bitrateText(value)}
text={text}
value={value}
setValue={setBitrate}
closeModal={toggleModal}
@@ -104,12 +99,12 @@ const BitrateModal = withSuspenseMemo<{
/>
)
},
[bitrate, toggleModal, setBitrate, bitrateText],
[bitrate, toggleModal, setBitrate],
)
return (
<>
<SettingsItem title={title} subtitle={bitrateText(bitrate)} onPress={toggleModal} />
<SettingsItem title={title} subtitle={bitrateString(bitrate)} onPress={toggleModal} />
<Modal animationType="fade" transparent={true} visible={visible} onRequestClose={toggleModal}>
<Pressable style={styles.modalBackdrop} onPress={toggleModal}>
<View style={styles.centeredView}>
@@ -140,9 +135,9 @@ const SettingsTextModal = React.memo<{
title: string
value: string
setValue: (text: string) => void
subtitle: (value: string) => string
getUnit?: (text: string) => string
keyboardType?: KeyboardTypeOptions
}>(({ title, value, setValue, subtitle, keyboardType }) => {
}>(({ title, value, setValue, getUnit, keyboardType }) => {
const [visible, setVisible] = useState(false)
const [inputText, setInputText] = useState(value)
@@ -153,9 +148,16 @@ const SettingsTextModal = React.memo<{
toggleModal()
}, [inputText, setValue, toggleModal])
const getSubtitle = useCallback(() => {
if (!getUnit) {
return value
}
return value + ' ' + getUnit(value)
}, [getUnit, value])
return (
<>
<SettingsItem title={title} subtitle={subtitle(value)} onPress={toggleModal} />
<SettingsItem title={title} subtitle={getSubtitle()} onPress={toggleModal} />
<Modal animationType="fade" transparent={true} visible={visible} onRequestClose={toggleModal}>
<Pressable style={styles.modalBackdrop} onPress={toggleModal}>
<View style={styles.centeredView}>
@@ -181,9 +183,15 @@ const SettingsTextModal = React.memo<{
)
})
const SettingsContent = withSuspenseMemo(() => {
const { t } = useTranslation()
function secondsUnit(seconds: string): string {
const numberValue = parseFloat(seconds)
if (Math.abs(numberValue) !== 1) {
return 'seconds'
}
return 'second'
}
const SettingsContent = React.memo(() => {
const servers = useStoreDeep(store => store.settings.servers)
const scrobble = useStore(store => store.settings.scrobble)
const setScrobble = useStore(store => store.setScrobble)
@@ -213,85 +221,66 @@ const SettingsContent = withSuspenseMemo(() => {
const setMinBufferText = useCallback((text: string) => setMinBuffer(parseFloat(text)), [setMinBuffer])
const setMaxBufferText = useCallback((text: string) => setMaxBuffer(parseFloat(text)), [setMaxBuffer])
const secondsText = useCallback((value: string) => t('settings.network.values.seconds', { value }), [t])
return (
<View style={styles.content}>
<Header>{t('settings.servers.name')}</Header>
<Header>Servers</Header>
{Object.values(servers).map(s => (
<ServerItem key={s.id} server={s} />
))}
<Button
style={styles.button}
title={t('settings.servers.actions.add')}
onPress={() => navigation.navigate('server', { title: t('settings.servers.actions.add') })}
title="Add Server"
onPress={() => navigation.navigate('server')}
buttonStyle="hollow"
/>
<Header style={styles.header}>{t('settings.network.name')}</Header>
<BitrateModal
title={t('settings.network.options.maxBitrateWifi.title')}
bitrate={maxBitrateWifi}
setBitrate={setMaxBitrateWifi}
/>
<BitrateModal
title={t('settings.network.options.maxBitrateMobile.title')}
bitrate={maxBitrateMobile}
setBitrate={setMaxBitrateMobile}
/>
<Header style={styles.header}>Network</Header>
<BitrateModal title="Maximum bitrate (Wi-Fi)" bitrate={maxBitrateWifi} setBitrate={setMaxBitrateWifi} />
<BitrateModal title="Maximum bitrate (mobile)" bitrate={maxBitrateMobile} setBitrate={setMaxBitrateMobile} />
<SettingsTextModal
title={t('settings.network.options.minBuffer.title')}
title="Minimum buffer time"
value={minBuffer.toString()}
setValue={setMinBufferText}
subtitle={secondsText}
getUnit={secondsUnit}
keyboardType="numeric"
/>
<SettingsTextModal
title={t('settings.network.options.maxBuffer.title')}
title="Maximum buffer time"
value={maxBuffer.toString()}
setValue={setMaxBufferText}
subtitle={secondsText}
getUnit={secondsUnit}
keyboardType="numeric"
/>
<Header style={styles.header}>{t('settings.music.name')}</Header>
<Header style={styles.header}>Music</Header>
<SettingsSwitch
title={t('settings.music.options.scrobble.title')}
subtitle={
scrobble
? t('settings.music.options.scrobble.descriptionOn')
: t('settings.music.options.scrobble.descriptionOff')
}
title="Scrobble plays"
subtitle={scrobble ? 'Scrobble play history' : "Don't scrobble play history"}
value={scrobble}
setValue={setScrobble}
/>
<Header style={styles.header}>{t('settings.reset.name')}</Header>
<Header style={styles.header}>Reset</Header>
<Button
disabled={clearing}
style={styles.button}
title={t('settings.reset.actions.clearImageCache')}
title="Clear Image Cache"
onPress={clear}
buttonStyle="hollow"
/>
<Header style={styles.header}>{t('settings.about.name')}</Header>
<Header style={styles.header}>About</Header>
<Text style={styles.text}>
<Text style={styles.bold}>Subtracks</Text> {t('settings.about.version', { version })}
<Text style={styles.bold}>Subtracks</Text> version {version}
</Text>
<Button
disabled={clearing}
style={styles.button}
title={t('settings.about.actions.projectHomepage')}
title="Project Homepage"
onPress={() => Linking.openURL('https://github.com/austinried/subtracks')}
buttonStyle="hollow"
/>
<Button
disabled={clearing}
style={styles.button}
title={t('settings.about.actions.licenses')}
onPress={() =>
navigation.navigate('web', {
uri: 'file:///android_asset/licenses.html',
title: t('settings.about.actions.licenses'),
})
}
title="Licenses"
onPress={() => navigation.navigate('web', { uri: 'file:///android_asset/licenses.html' })}
buttonStyle="hollow"
/>
</View>
+7 -3
View File
@@ -56,8 +56,10 @@ const SongListDetails = React.memo<{
const [headerColor, setHeaderColor] = useState<string | undefined>(undefined)
const _songs = [...(songs || [])]
let typeName = ''
if (type === 'album') {
typeName = 'Album'
if (_songs.some(s => s.track === undefined)) {
_songs.sort((a, b) => a.title.localeCompare(b.title))
} else {
@@ -67,15 +69,17 @@ const SongListDetails = React.memo<{
return aVal - bVal
})
}
} else {
typeName = 'Playlist'
}
const { setQueue, contextId } = useSetQueue(type, _songs)
const { setQueue, isReady, contextId } = useSetQueue(type, _songs)
if (!songList) {
return <SongListDetailsFallback />
}
const disabled = _songs.length === 0
const disabled = !isReady || _songs.length === 0
const play = (track?: number, shuffle?: boolean) => () =>
setQueue({ title: songList.name, playTrack: track, shuffle })
@@ -121,7 +125,7 @@ const SongListDetails = React.memo<{
<ListPlayerControls
style={styles.controls}
songs={_songs}
listType={type}
typeName={typeName}
play={play(undefined, false)}
shuffle={play(undefined, true)}
disabled={disabled}
+2 -10
View File
@@ -1,17 +1,9 @@
import { useNavigation } from '@react-navigation/native'
import React, { useEffect } from 'react'
import React from 'react'
import { WebView } from 'react-native-webview'
const WebViewScreen: React.FC<{
uri: string
title?: string
}> = ({ uri, title }) => {
const navigation = useNavigation()
useEffect(() => {
navigation.setOptions({ title })
}, [navigation, title])
}> = ({ uri }) => {
return <WebView source={{ uri }} />
}
+11 -19
View File
@@ -1,8 +1,7 @@
import { AlbumFilterSettings, ArtistFilterSettings, ArtistFilterType, Server } from '@app/models/settings'
import { AlbumFilterSettings, ArtistFilterSettings, Server } from '@app/models/settings'
import { ById } from '@app/models/state'
import { GetStore, SetStore } from '@app/state/store'
import { SubsonicApiClient } from '@app/subsonic/api'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import uuid from 'react-native-uuid'
export type SettingsSlice = {
@@ -27,9 +26,7 @@ export type SettingsSlice = {
}
client?: SubsonicApiClient
disableMusicTabs: boolean
setDisableMusicTabs: (value: boolean) => void
resetServer: boolean
changeCacheBuster: () => void
@@ -46,8 +43,8 @@ export type SettingsSlice = {
pingServer: (server?: Server) => Promise<boolean>
setLibraryAlbumFilterType: (type: GetAlbumList2TypeBase) => void
setLibraryArtistFilterType: (type: ArtistFilterType) => void
setLibraryAlbumFilter: (filter: AlbumFilterSettings) => void
setLibraryArtistFiler: (filter: ArtistFilterSettings) => void
}
export function newCacheBuster(): string {
@@ -81,12 +78,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
cacheBuster: newCacheBuster(),
},
disableMusicTabs: false,
setDisableMusicTabs: value => {
set(store => {
store.disableMusicTabs = value
})
},
resetServer: false,
changeCacheBuster: () => {
set(store => {
@@ -111,7 +103,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
}
set(state => {
state.disableMusicTabs = true
state.resetServer = true
})
set(state => {
@@ -120,7 +112,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
})
set(state => {
state.disableMusicTabs = false
state.resetServer = false
})
},
@@ -224,15 +216,15 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
}
},
setLibraryAlbumFilterType: type => {
setLibraryAlbumFilter: filter => {
set(state => {
state.settings.screens.library.albumsFilter.type = type
state.settings.screens.library.albumsFilter = filter
})
},
setLibraryArtistFilterType: type => {
setLibraryArtistFiler: filter => {
set(state => {
state.settings.screens.library.artistsFilter.type = type
state.settings.screens.library.artistsFilter = filter
})
},
})
-12
View File
@@ -55,7 +55,6 @@ export type TrackPlayerSlice = {
setNetState: (netState: 'mobile' | 'wifi') => Promise<void>
rebuildQueue: (forcePlay?: boolean) => Promise<void>
updateQueue: () => Promise<void>
buildStreamUri: (id: string) => string
resetTrackPlayerState: () => void
@@ -315,17 +314,6 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
})
},
updateQueue: async () => {
const newQueue = await getQueue()
const currentTrack = await getCurrentTrack()
set(state => {
state.queue = newQueue
if (currentTrack !== undefined) {
state.currentTrack = newQueue[currentTrack]
}
})
},
buildStreamUri: id => {
const client = get().client
if (!client) {
-2
View File
@@ -1,2 +0,0 @@
export type PromiseResolvedType<T> = T extends Promise<infer R> ? R : never
export type ReturnedPromiseResolvedType<T extends (...args: any) => any> = PromiseResolvedType<ReturnType<T>>
-15
View File
@@ -11,21 +11,6 @@ LogBox.ignoreLogs([
"[react-native-gesture-handler] Seems like you're using an old API with gesture components, check out new Gestures system!",
])
import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'
import { backend, languageDetector } from '@app/i18n'
import * as RNLocalize from 'react-native-localize'
i18next.use(backend).use(languageDetector).use(initReactI18next).init({
compatibilityJSON: 'v3',
fallbackLng: 'en',
debug: true,
})
RNLocalize.addEventListener('change', () => {
languageDetector.detect(lng => i18next.changeLanguage(lng))
})
import { AppRegistry } from 'react-native'
import App from '@app/App'
import { name as appName } from '@app/app.json'
-10
View File
@@ -1,10 +0,0 @@
**Full Changelog**: https://github.com/austinried/subtracks/compare/v1.2.0...v1.3.0
## What's Changed
### New
* Localization support by @austinried in https://github.com/austinried/subtracks/pull/99
* 9 new languages: Catalan, Chinese (Simplified), Danish, French, German, Italian, Japanese, Norwegian Bokmål, Russian
* Thanks to @comradekingu, @clyhtsuriva, @nortio, @retiolus, @hillwah, @shoddysheep and more users from @weblate!
### Fixed
* Remove unused CHECK_LICENSE permission by @austinried in https://github.com/austinried/subtracks/pull/109
* Fix performance issue/crash with large playlists by @austinried in https://github.com/austinried/subtracks/pull/111
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "subtracks",
"version": "1.3.0",
"version": "1.2.0",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
@@ -31,21 +31,18 @@
"@xmldom/xmldom": "^0.7.0",
"content-disposition": "^0.5.4",
"fast-deep-equal": "^3.1.3",
"i18next": "^21.6.16",
"immer": "^9.0.6",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"mime-types": "^2.1.35",
"path": "^0.12.7",
"react": "17.0.2",
"react-i18next": "^11.16.6",
"react-native": "0.67.4",
"react-native-blob-util": "https://github.com/austinried/react-native-blob-util.git#android-downloadmanager-progress",
"react-native-fs": "^2.18.0",
"react-native-gesture-handler": "^2.3.2",
"react-native-image-colors": "^1.3.0",
"react-native-linear-gradient": "^2.5.6",
"react-native-localize": "^2.2.1",
"react-native-popup-menu": "^0.15.11",
"react-native-reanimated": "^2.3.1",
"react-native-safe-area-context": "^3.2.0",
+4 -44
View File
@@ -719,13 +719,6 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2":
version "7.17.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72"
integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2":
version "7.17.8"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.8.tgz#3e56e4aff81befa55ac3ac6a0967349fd1c5bca2"
@@ -1927,9 +1920,9 @@ async-limiter@~1.0.0:
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
async@^2.4.0:
version "2.6.4"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
dependencies:
lodash "^4.17.14"
@@ -3621,18 +3614,11 @@ html-encoding-sniffer@^2.0.1:
dependencies:
whatwg-encoding "^1.0.5"
html-escaper@^2.0.0, html-escaper@^2.0.2:
html-escaper@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
html-parse-stringify@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2"
integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==
dependencies:
void-elements "3.1.0"
http-errors@1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
@@ -3666,13 +3652,6 @@ human-signals@^1.1.1:
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
i18next@^21.6.16:
version "21.6.16"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.6.16.tgz#8cff8c3ba2ffaf8438a8c83fe284083f15cf3941"
integrity sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==
dependencies:
"@babel/runtime" "^7.17.2"
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -6037,15 +6016,6 @@ react-freeze@^1.0.0:
resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.0.tgz#b21c65fe1783743007c8c9a2952b1c8879a77354"
integrity sha512-yQaiOqDmoKqks56LN9MTgY06O0qQHgV4FUrikH357DydArSZHQhl0BJFqGKIZoTqi8JizF9Dxhuk1FIZD6qCaw==
react-i18next@^11.16.6:
version "11.16.6"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.16.6.tgz#e8a07802c391a55e1528673201a2727994787641"
integrity sha512-qa76GnvAPafNSxKNN/XMhdCkVN/9Lm+BpzW5+6FE2ctYUemhbglP7oklGmYiJXlG24p9itqzlJDbCi3SNd3jzA==
dependencies:
"@babel/runtime" "^7.14.5"
html-escaper "^2.0.2"
html-parse-stringify "^3.0.1"
"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
@@ -6108,11 +6078,6 @@ react-native-linear-gradient@^2.5.6:
resolved "https://registry.yarnpkg.com/react-native-linear-gradient/-/react-native-linear-gradient-2.5.6.tgz#96215cbc5ec7a01247a20890888aa75b834d44a0"
integrity sha512-HDwEaXcQIuXXCV70O+bK1rizFong3wj+5Q/jSyifKFLg0VWF95xh8XQgfzXwtq0NggL9vNjPKXa016KuFu+VFg==
react-native-localize@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-native-localize/-/react-native-localize-2.2.1.tgz#6fe646833691c6ee8a474df3c8b069402cb1dba8"
integrity sha512-BuPaQWvxLZG1NrCDGqgAnecDrNQu3LED9/Pyl4H2LwTMHcEngXpE5PfVntW2GiLumdr6nUOkWmMnh8PynZqrsw==
react-native-popup-menu@^0.15.11:
version "0.15.12"
resolved "https://registry.yarnpkg.com/react-native-popup-menu/-/react-native-popup-menu-0.15.12.tgz#386852f4245f8d661a5003776989b9b55c9ce381"
@@ -7428,11 +7393,6 @@ vlq@^1.0.0:
resolved "https://registry.yarnpkg.com/vlq/-/vlq-1.0.1.tgz#c003f6e7c0b4c1edd623fd6ee50bbc0d6a1de468"
integrity sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==
void-elements@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
integrity sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=
w3c-hr-time@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"