104 Commits
Author SHA1 Message Date
austinried 4905f75564 Merge branch 'main' of github.com:austinried/subtracks into main 2022-04-12 10:21:37 +09:00
austinried 6b53d9444d bump version 2022-04-12 10:18:33 +09:00
austinried f39a29159a 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 c1a4b5ede8 restore repeat mode when rebuilding the queue (#93) 2022-04-12 09:45:10 +09:00
austinried d0319eb32d remove ruby stuff for iOS 2022-04-11 15:36:18 +09:00
austinried 9524080d00 move back to api 30 2022-04-11 15:15:57 +09:00
austinried cba32e75b3 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 37d81cb87a update licences 2022-04-11 12:28:33 +09:00
austinried f911688c78 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 bfa6b6f08a upgrade to react-native 0.67.4 2022-04-11 11:07:17 +09:00
austinried 87e83516a1 filter topSongs backup search by artistName 2022-04-11 10:47:51 +09:00
austinried 8196704ccd 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] cbd88d0f13 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 ecb706310b 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 caf544069e switch to useSafeAreaInsets().top (#85)
StatusBar.currentHeight seems just broken on some devices
2022-04-04 12:48:10 +09:00
austinried 8bc4caae78 some files still using old folder names 2022-04-03 10:29:23 +09:00
austinried 5a2ab98af5 repeat v1.1.2 changelog for google play 2022-04-01 10:22:23 +09:00
austinried 3ab9ea9fe8 bump version for google play 2022-04-01 10:19:32 +09:00
austinried b761ad8b84 Release/v1.1.2 (#82)
* add changelog

* increment version
2022-03-28 16:16:48 +09:00
austinried 5169b726ce encode values before using in url (#81) 2022-03-28 15:44:58 +09:00
austinried 41f00ef3f1 update build to main branch
add new ignores
2022-03-28 14:32:08 +09:00
dependabot[bot] abdd1667b7 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] b6c689ea7e 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] 2c8bcbcb2f 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 e412b33d4e 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 081251061d 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 09ca4974c5 don't run prepare on yarn install... 2022-03-12 09:24:52 +09:00
austinried d9781f3a47 add build instructions
add yarn script to prepare exoplayer/ffmpeg
2022-02-02 16:03:05 +09:00
austinried ec390f593c 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 6883a556bf fix boolean 2022-01-12 09:25:45 +09:00
Andrej Shadura 100be96fca 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 c1e26d23cf reduce api version to lowest currently needed (#53) 2021-12-18 09:50:16 +09:00
austinried 329ef919f7 add .nvmrc for node 14.x 2021-12-15 14:38:15 +09:00
austinried 336e106e01 changelog for 1.1.1 2021-12-15 14:08:37 +09:00
austinried c0f802c27b bump version 1.1.1 2021-12-15 14:05:51 +09:00
austinried da033e697f 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 5bb32df16b Create FUNDING.yml 2021-12-13 09:34:22 +09:00
austinried 6cb364531b 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 72fa2c765d 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 0643b6d719 use .view method suffix for 1.14.0 (#45) 2021-12-06 10:05:59 +09:00
dependabot[bot] da79b306b2 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 9a6f8b86fc 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 37214fcbdc add f-droid badge to readme 2021-09-11 16:40:32 +09:00
austinried eb578fa5e6 use consistent user agent for requests 2021-09-08 15:49:01 +09:00
austinried 51f79ac8ad stop saying my project is html pls 2021-09-08 10:13:54 +09:00
aux4 ddef0fc2c8 Merge pull request #15 from austinried/exoplayer-extension
Build ExoPlayer ffmpeg extension for ALAC playback
2021-09-08 10:06:38 +09:00
austinried dbc72d5259 add ffmpeg license
reworked licenses into a single generated file
2021-09-08 09:32:35 +09:00
austinried f686294aab too many files in here for watchman 2021-09-07 18:37:19 +09:00
austinried 29cdebe424 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 ef476552e0 remove gradle wrapper that fails validation 2021-09-07 16:48:49 +09:00
austinried dd0d4d52c1 remove unused exoplayer modules
update licenses
2021-09-07 16:36:12 +09:00
austinried 0c825a3d85 don't lint submodules 2021-09-07 15:44:18 +09:00
austinried 2d07d5f1fa Revert "regen lockfile"
This reverts commit 2dc9e71ab5.
2021-09-07 15:38:52 +09:00
austinried 2dc9e71ab5 regen lockfile 2021-09-07 15:35:10 +09:00
austinried 062b0cadd2 Merge branch 'master' into exoplayer-extension 2021-09-07 15:26:04 +09:00
austinried ad92e6d3ff enable ffmpeg prep for GH builds 2021-09-07 15:23:57 +09:00
austinried 328956d84a bump ram for release builds 2021-09-07 15:23:37 +09:00
austinried 9d58e15b39 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 721a42f60f pull in/rely on exoplayer locally 2021-09-07 12:20:52 +09:00
aux4 6a0f5678ec 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] dbfc4172fb 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 2097a9f7fd remove non-working cast button for now 2021-09-04 14:45:08 +09:00
austinried 3053e2b5c9 add changelog for 1.0.1 2021-09-02 18:01:58 +09:00
austinried 7f1585a6ad bump version 1.0.1 2021-09-02 17:23:38 +09:00
austinried 704edfbaaf remove unused permissions 2021-09-01 18:23:13 +09:00
austinried 5622c007f2 fix text cutting off bottom w/fixed height 2021-09-01 15:11:24 +09:00
austinried 3719a71e0f Merge branch 'master' of github.com:austinried/subtracks 2021-09-01 14:53:10 +09:00
aux4 44fb9a022d update readme coming soon 2021-09-01 10:17:38 +09:00
austinried 45cc51192a Merge branch 'master' of github.com:austinried/subtracks 2021-08-27 15:42:22 +09:00
aux4 d981690ba6 update readme with google play link 2021-08-27 15:41:39 +09:00
austinried 161a621d71 address xmldom security issue
https://github.com/advisories/GHSA-6fc8-4gx4-v693
2021-08-27 13:11:03 +09:00
austinried 7da1afa643 bump version 1.0.0 for release 2021-08-27 11:17:58 +09:00
austinried 77e89fc9e2 add version number to about setion 2021-08-27 09:58:49 +09:00
austinried 36e97cb621 update metadata per google play standards 2021-08-26 16:38:30 +09:00
austinried c539376cc4 add metadata for stores 2021-08-26 16:10:49 +09:00
austinried 0465147a7c bump sdk version for google play 2021-08-26 15:19:03 +09:00
austinried 2e96c61b82 let's do some pre-release builds 2021-08-26 14:38:22 +09:00
austinried 08470aaf00 Merge branch 'master' of github.com:austinried/subtracks 2021-08-26 13:41:38 +09:00
aux4 8ee98327fe update readme 2021-08-26 13:41:07 +09:00
austinried a91f455534 add scripts for bumping versions 2021-08-26 13:33:15 +09:00
austinried ac175d091f ignore html for languages 2021-08-26 12:58:35 +09:00
austinried 872dde4e1c remove bad button style 2021-08-26 12:49:26 +09:00
austinried 23807a3506 update header for better dark mode 2021-08-26 12:48:06 +09:00
austinried a3fda31743 added licenses and webview for them
added link to github page
2021-08-26 12:47:56 +09:00
austinried b8f04e5426 update readme 2021-08-26 08:18:29 +09:00
austinried 46eb293f98 fix net state not being right on startup 2021-08-26 08:17:39 +09:00
austinried 5af187ba48 lint builds 2021-08-26 08:07:32 +09:00
austinried 9847cdb894 fix for search clearing text/not scrolling 2021-08-25 16:32:04 +09:00
austinried fac2b62ec2 improved large album/playlist performance
switched to flatlist for all of those
2021-08-25 16:13:35 +09:00
austinried 81b5bfb56b readme update 2021-08-25 11:26:03 +09:00
austinried 7abf5c0e88 settings wording 2021-08-25 10:43:36 +09:00
austinried ed4a32cba3 remove UI option to disable estimateContentLength 2021-08-25 10:29:59 +09:00
austinried 0778c49b18 Merge branch 'master' of github.com:austinried/subtracks 2021-08-25 10:25:03 +09:00
austinried 3db1d795dc fix player crashing at the end of transcoded track 2021-08-25 10:24:42 +09:00
aux4 07fc618fd2 specify android app in readme 2021-08-24 16:00:21 +09:00
austinried 75da5d16ac Merge branch 'master' of github.com:austinried/subtracks 2021-08-24 15:58:01 +09:00
austinried 1d40ac5b35 stop building on non-app file changes 2021-08-24 15:57:46 +09:00
aux4 6bdd052472 added download badge to readme 2021-08-24 15:52:34 +09:00
austinried 74632eff10 added "get it on" badges" 2021-08-24 15:49:41 +09:00
austinried 3055726fb3 better name/group artifacts 2021-08-24 15:31:22 +09:00
aux4 d10a29b931 fix readme link, add server examples 2021-08-24 14:46:07 +09:00
aux4 75a264ca83 fix readme image formatting 2021-08-24 14:27:27 +09:00
austinried dcd764bd68 add images/features/coming soon to readme 2021-08-24 14:23:00 +09:00
aux4 1a596077fa Merge pull request #1 from austinried/github-actions-test
Use actions to build and sign PRs/releases
2021-08-24 11:27:12 +09:00
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: paths-ignore:
- assets/** - assets/**
- .vscode/** - .vscode/**
- android/app/src/main/assets/custom/i18n/**
- .eslintrc.js - .eslintrc.js
- .prettierrc.js - .prettierrc.js
- BUILDING.md - 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. 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 # Screenshots
<p float="left"> <p float="left">
<img src="metadata/en-US/images/phoneScreenshots/01_home.png" alt="home" width="200"/> <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 # Building
See [Building from source](BUILDING.md). 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" applicationId "com.subtracks"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 9 versionCode 8
versionName '1.3.0' versionName '1.2.0'
} }
splits { splits {
abi { abi {
-1
View File
@@ -2,7 +2,6 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission tools:node="remove" android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <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="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"> <application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:usesCleartextTraffic="true" android:networkSecurityConfig="@xml/network_security_config">
<activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustPan"> <activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustPan">
<intent-filter> <intent-filter>
-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 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) 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: 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. 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: 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 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: 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 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: 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 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 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) 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: 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. 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: 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 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: 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 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: 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 Copyright 2013 Naitik Shah
@@ -14,9 +14,6 @@ public class MainActivity extends ReactActivity {
return "subtracks"; 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 @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(null); super.onCreate(null);
+3 -3
View File
@@ -2,12 +2,12 @@ import RootNavigator from '@app/navigation/RootNavigator'
import SplashPage from '@app/screens/SplashPage' import SplashPage from '@app/screens/SplashPage'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import React from 'react' 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 { MenuProvider } from 'react-native-popup-menu'
import { QueryClientProvider } from 'react-query' import { QueryClientProvider } from 'react-query'
import ProgressHook from './components/ProgressHook'
import queryClient from './queryClient' import queryClient from './queryClient'
import { useStore } from './state/store'
const Debug = () => { const Debug = () => {
const currentTrackTitle = useStore(store => store.currentTrack?.title) const currentTrackTitle = useStore(store => store.currentTrack?.title)
+1 -9
View File
@@ -16,13 +16,7 @@ const Button: React.FC<{
onPress={onPress} onPress={onPress}
disabled={disabled} disabled={disabled}
style={[styles.container, buttonStyle !== undefined ? styles[buttonStyle] : {}, style]}> style={[styles.container, buttonStyle !== undefined ? styles[buttonStyle] : {}, style]}>
{title ? ( {title ? <Text style={styles.text}>{title}</Text> : children}
<Text style={styles.text} numberOfLines={2} adjustsFontSizeToFit={true}>
{title}
</Text>
) : (
children
)}
</PressableOpacity> </PressableOpacity>
) )
} }
@@ -32,7 +26,6 @@ const styles = StyleSheet.create({
backgroundColor: colors.accent, backgroundColor: colors.accent,
paddingHorizontal: 10, paddingHorizontal: 10,
minHeight: 42, minHeight: 42,
maxHeight: 42,
justifyContent: 'center', justifyContent: 'center',
borderRadius: 1000, borderRadius: 1000,
}, },
@@ -50,7 +43,6 @@ const styles = StyleSheet.create({
fontFamily: font.bold, fontFamily: font.bold,
color: colors.text.primary, color: colors.text.primary,
paddingHorizontal: 14, 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 { NavigationProp, useNavigation } from '@react-navigation/native'
import { ReactComponentLike } from 'prop-types' import { ReactComponentLike } from 'prop-types'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollView, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native' import { ScrollView, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'
import { Menu, MenuOption, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu' import { Menu, MenuOption, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu'
import IconFA from 'react-native-vector-icons/FontAwesome' import IconFA from 'react-native-vector-icons/FontAwesome'
import IconFA5 from 'react-native-vector-icons/FontAwesome5' import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import CoverArt from './CoverArt' import CoverArt from './CoverArt'
import { Star } from './Star' import { Star } from './Star'
import { withSuspenseMemo } from './withSuspense'
const { SlideInMenu } = renderers const { SlideInMenu } = renderers
@@ -108,9 +106,7 @@ const ContextMenuIconTextOption = React.memo<ContextMenuIconTextOptionProps>(
return ( return (
<ContextMenuOption onSelect={onSelect}> <ContextMenuOption onSelect={onSelect}>
<View style={styles.icon}>{Icon}</View> <View style={styles.icon}>{Icon}</View>
<Text style={styles.optionText} numberOfLines={1} adjustsFontSizeToFit={true} minimumFontScale={0.6}> <Text style={styles.optionText}>{text}</Text>
{text}
</Text>
</ContextMenuOption> </ContextMenuOption>
) )
}, },
@@ -119,13 +115,11 @@ const ContextMenuIconTextOption = React.memo<ContextMenuIconTextOptionProps>(
const MenuHeader = React.memo<{ const MenuHeader = React.memo<{
coverArt?: string coverArt?: string
artistId?: string artistId?: string
albumId?: string
title: string title: string
subtitle?: string subtitle?: string
}>(({ coverArt, artistId, albumId, title, subtitle }) => { }>(({ coverArt, artistId, title, subtitle }) => (
let CoverArtComponent = <></> <View style={styles.menuHeader}>
if (artistId) { {artistId ? (
CoverArtComponent = (
<CoverArt <CoverArt
type="artist" type="artist"
artistId={artistId} artistId={artistId}
@@ -135,20 +129,7 @@ const MenuHeader = React.memo<{
size="thumbnail" size="thumbnail"
fadeDuration={0} fadeDuration={0}
/> />
) ) : (
} else if (albumId) {
CoverArtComponent = (
<CoverArt
type="album"
albumId={albumId}
style={styles.coverArt}
resizeMode="cover"
size="thumbnail"
fadeDuration={0}
/>
)
} else {
CoverArtComponent = (
<CoverArt <CoverArt
type="cover" type="cover"
coverArt={coverArt} coverArt={coverArt}
@@ -157,12 +138,7 @@ const MenuHeader = React.memo<{
size="thumbnail" size="thumbnail"
fadeDuration={0} fadeDuration={0}
/> />
) )}
}
return (
<View style={styles.menuHeader}>
{CoverArtComponent}
<View style={styles.menuHeaderText}> <View style={styles.menuHeaderText}>
<Text numberOfLines={1} style={styles.menuTitle}> <Text numberOfLines={1} style={styles.menuTitle}>
{title} {title}
@@ -176,33 +152,29 @@ const MenuHeader = React.memo<{
)} )}
</View> </View>
</View> </View>
) ))
})
const OptionStar = withSuspenseMemo<{ const OptionStar = React.memo<{
id: string id: string
type: StarrableItemType type: StarrableItemType
additionalText?: string additionalText?: string
}>(({ id, type, additionalText: text }) => { }>(({ id, type, additionalText: text }) => {
const { query, toggle } = useStar(id, type) const { query, toggle } = useStar(id, type)
const { t } = useTranslation()
return ( return (
<ContextMenuIconTextOption <ContextMenuIconTextOption
IconComponentRaw={<Star starred={!!query.data} size={26} />} 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()} onSelect={() => toggle.mutate()}
/> />
) )
}) })
const OptionViewArtist = withSuspenseMemo<{ const OptionViewArtist = React.memo<{
navigation: NavigationProp<any> navigation: NavigationProp<any>
artist?: string artist?: string
artistId?: string artistId?: string
}>(({ navigation, artist, artistId }) => { }>(({ navigation, artist, artistId }) => {
const { t } = useTranslation()
if (!artist || !artistId) { if (!artist || !artistId) {
return <></> return <></>
} }
@@ -212,19 +184,17 @@ const OptionViewArtist = withSuspenseMemo<{
IconComponent={IconFA} IconComponent={IconFA}
name="microphone" name="microphone"
size={26} size={26}
text={t('resources.artist.actions.view')} text="View Artist"
onSelect={() => navigation.navigate('artist', { id: artistId, title: artist })} onSelect={() => navigation.navigate('artist', { id: artistId, title: artist })}
/> />
) )
}) })
const OptionViewAlbum = withSuspenseMemo<{ const OptionViewAlbum = React.memo<{
navigation: NavigationProp<any> navigation: NavigationProp<any>
album?: string album?: string
albumId?: string albumId?: string
}>(({ navigation, album, albumId }) => { }>(({ navigation, album, albumId }) => {
const { t } = useTranslation()
if (!album || !albumId) { if (!album || !albumId) {
return <></> return <></>
} }
@@ -234,7 +204,7 @@ const OptionViewAlbum = withSuspenseMemo<{
IconComponent={IconFA5} IconComponent={IconFA5}
name="compact-disc" name="compact-disc"
size={26} size={26}
text={t('resources.album.actions.view')} text="View Album"
onSelect={() => navigation.navigate('album', { id: albumId, title: album })} onSelect={() => navigation.navigate('album', { id: albumId, title: album })}
/> />
) )
@@ -281,7 +251,7 @@ export const SongContextPressable: React.FC<SongContextPressableProps> = props =
return ( return (
<ContextMenu <ContextMenu
{...props} {...props}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} albumId={song.albumId} />} menuHeader={<MenuHeader title={song.title} subtitle={song.artist} coverArt={song.coverArt} />}
menuOptions={ menuOptions={
<> <>
<OptionStar id={song.id} type={song.itemType} /> <OptionStar id={song.id} type={song.itemType} />
@@ -328,7 +298,7 @@ export const NowPlayingContextPressable: React.FC<NowPlayingContextPressableProp
return ( return (
<ContextMenu <ContextMenu
{...props} {...props}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} albumId={song.albumId} />} menuHeader={<MenuHeader title={song.title} subtitle={song.artist} coverArt={song.coverArt} />}
menuOptions={ menuOptions={
<> <>
<OptionStar id={song.id} type={song.itemType} /> <OptionStar id={song.id} type={song.itemType} />
@@ -348,8 +318,6 @@ const styles = StyleSheet.create({
}, },
optionsWrapper: { optionsWrapper: {
// marginBottom: 10, // marginBottom: 10,
paddingHorizontal: 20,
// backgroundColor: 'purple',
}, },
menuHeader: { menuHeader: {
paddingTop: 14, paddingTop: 14,
@@ -380,11 +348,9 @@ const styles = StyleSheet.create({
}, },
option: { option: {
paddingVertical: 8, paddingVertical: 8,
// paddingHorizontal: 100, paddingHorizontal: 20,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
// backgroundColor: 'blue',
overflow: 'hidden',
}, },
icon: { icon: {
marginRight: 10, 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 { CacheImageSize } from '@app/models/cache'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import React, { useState } from 'react' import React, { useState } from 'react'
@@ -32,11 +32,6 @@ type CoverArtProps = BaseProps & {
coverArt?: string coverArt?: string
} }
type AlbumIdProps = BaseProps & {
type: 'album'
albumId?: string
}
type ImageSourceProps = BaseProps & { type ImageSourceProps = BaseProps & {
data?: string data?: string
isFetching: boolean isFetching: boolean
@@ -87,13 +82,7 @@ const CoverArtImage = React.memo<CoverArtProps>(props => {
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} /> return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
}) })
const AlbumIdIamge = React.memo<AlbumIdProps>(props => { const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps>(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 viewStyles = [props.style] const viewStyles = [props.style]
if (props.round) { if (props.round) {
viewStyles.push(styles.round) viewStyles.push(styles.round)
@@ -104,9 +93,6 @@ const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps | AlbumIdProps>(
case 'artist': case 'artist':
imageComponent = <ArtistImage {...(props as ArtistCoverArtProps)} /> imageComponent = <ArtistImage {...(props as ArtistCoverArtProps)} />
break break
case 'album':
imageComponent = <AlbumIdIamge {...(props as AlbumIdProps)} />
break
default: default:
imageComponent = <CoverArtImage {...(props as CoverArtProps)} /> imageComponent = <CoverArtImage {...(props as CoverArtProps)} />
break break
+21 -51
View File
@@ -1,13 +1,10 @@
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import React from 'react' import React from 'react'
import { Text, StyleSheet, View } from 'react-native' import { Text, StyleSheet } from 'react-native'
import { MenuOption, Menu, MenuTrigger, MenuOptions, renderers } from 'react-native-popup-menu' import { MenuOption, Menu, MenuTrigger, MenuOptions } from 'react-native-popup-menu'
import PressableOpacity from './PressableOpacity' import PressableOpacity from './PressableOpacity'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons' import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { ScrollView } from 'react-native-gesture-handler'
const { SlideInMenu } = renderers
export type OptionData = { export type OptionData = {
value: string value: string
@@ -20,14 +17,12 @@ const Option = React.memo<{
selected?: boolean selected?: boolean
}>(({ text, value, selected }) => ( }>(({ text, value, selected }) => (
<MenuOption style={styles.option} value={value}> <MenuOption style={styles.option} value={value}>
<Text style={styles.optionText}>{text}</Text>
{selected ? ( {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> </MenuOption>
)) ))
@@ -35,10 +30,9 @@ const FilterButton = React.memo<{
value?: string value?: string
data: OptionData[] data: OptionData[]
onSelect?: (selection: string) => void onSelect?: (selection: string) => void
title: string }>(({ value, data, onSelect }) => {
}>(({ value, data, onSelect, title }) => {
return ( return (
<Menu onSelect={onSelect} renderer={SlideInMenu}> <Menu onSelect={onSelect}>
<MenuTrigger <MenuTrigger
customStyles={{ customStyles={{
triggerOuterWrapper: styles.filterOuterWrapper, triggerOuterWrapper: styles.filterOuterWrapper,
@@ -46,23 +40,16 @@ const FilterButton = React.memo<{
triggerTouchable: { style: styles.filter }, triggerTouchable: { style: styles.filter },
TriggerTouchableComponent: PressableOpacity, TriggerTouchableComponent: PressableOpacity,
}}> }}>
<Icon name="filter-variant" color="white" size={30} /> <Icon name="filter-variant" color="white" size={30} style={styles.filterIcon} />
</MenuTrigger> </MenuTrigger>
<MenuOptions <MenuOptions
customStyles={{ customStyles={{
optionsWrapper: styles.optionsWrapper, optionsWrapper: styles.optionsWrapper,
optionsContainer: styles.optionsContainer, 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 => ( {data.map(o => (
<Option key={o.value} text={o.text} value={o.value} selected={o.value === value} /> <Option key={o.value} text={o.text} value={o.value} selected={o.value === value} />
))} ))}
</ScrollView>
</MenuOptions> </MenuOptions>
</Menu> </Menu>
) )
@@ -84,45 +71,28 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.accent, backgroundColor: colors.accent,
}, },
optionsScroll: { filterIcon: {
maxHeight: 260, // top: 4,
}, },
optionsWrapper: { optionsWrapper: {
overflow: 'hidden', maxWidth: 145,
}, },
optionsContainer: { optionsContainer: {
backgroundColor: 'rgba(45, 45, 45, 0.95)', backgroundColor: colors.gradient.high,
}, maxWidth: 145,
header: {
paddingHorizontal: 20,
// paddingVertical: 10,
marginTop: 16,
marginBottom: 6,
},
headerText: {
fontFamily: font.bold,
fontSize: 20,
color: colors.text.primary,
}, },
option: { option: {
paddingVertical: 8,
paddingHorizontal: 20,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', paddingHorizontal: 12,
justifyContent: 'flex-start', paddingVertical: 8,
},
optionText: {
fontFamily: font.semiBold,
fontSize: 16,
color: colors.text.primary,
},
icon: {
marginRight: 14,
width: 32,
height: 32,
justifyContent: 'center', justifyContent: 'center',
alignItems: '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" size="thumbnail"
/> />
) )
} else if (item.itemType === 'song') {
coverArt = (
<CoverArt type="album" albumId={item.albumId} style={artStyle} resizeMode={resizeMode} size="thumbnail" />
)
} else { } else {
coverArt = ( coverArt = (
<CoverArt type="cover" coverArt={item.coverArt} style={artStyle} resizeMode={resizeMode} size="thumbnail" /> <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 { Song } from '@app/models/library'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import React, { useState } from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native' import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
import Icon from 'react-native-vector-icons/Ionicons' import Icon from 'react-native-vector-icons/Ionicons'
import IconMat from 'react-native-vector-icons/MaterialIcons' import IconMat from 'react-native-vector-icons/MaterialIcons'
import { withSuspenseMemo } from './withSuspense'
const ListPlayerControls = withSuspenseMemo<{ const ListPlayerControls = React.memo<{
songs: Song[] songs: Song[]
listType: 'album' | 'playlist' typeName: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
play: () => void play: () => void
shuffle: () => void shuffle: () => void
disabled?: boolean disabled?: boolean
}>(({ listType, style, play, shuffle, disabled }) => { }>(({ typeName, style, play, shuffle, disabled }) => {
const [downloaded, setDownloaded] = useState(false) const [downloaded, setDownloaded] = useState(false)
const { t } = useTranslation()
return ( return (
<View style={[styles.controls, style]}> <View style={[styles.controls, style]}>
@@ -34,7 +31,7 @@ const ListPlayerControls = withSuspenseMemo<{
</Button> </Button>
</View> </View>
<View style={styles.controlsCenter}> <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>
<View style={styles.controlsSide}> <View style={styles.controlsSide}>
<Button disabled={disabled} onPress={shuffle}> <Button disabled={disabled} onPress={shuffle}>
@@ -58,7 +55,6 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
maxWidth: '65%',
}, },
}) })
+2 -7
View File
@@ -1,25 +1,20 @@
import font from '@app/styles/font' import font from '@app/styles/font'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { Text, View, StyleSheet, ViewStyle } from 'react-native' import { Text, View, StyleSheet, ViewStyle } from 'react-native'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons' import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { withSuspenseMemo } from './withSuspense'
const NothingHere = withSuspenseMemo<{ const NothingHere = React.memo<{
height?: number height?: number
width?: number width?: number
style?: ViewStyle style?: ViewStyle
}>(({ height, width, style }) => { }>(({ height, width, style }) => {
const { t } = useTranslation()
height = height || 200 height = height || 200
width = width || 200 width = width || 200
return ( return (
<View style={[styles.container, { height, width }, style]}> <View style={[styles.container, { height, width }, style]}>
<Icon name="music-rest-quarter" color={styles.text.color} size={width / 2} /> <Icon name="music-rest-quarter" color={styles.text.color} size={width / 2} />
<Text style={[styles.text, { fontSize: width / 8 }]} numberOfLines={3}> <Text style={[styles.text, { fontSize: width / 8 }]}>Nothing here...</Text>
{t('messages.nothingHere')}
</Text>
</View> </View>
) )
}) })
+3 -3
View File
@@ -79,7 +79,7 @@ const Controls = React.memo(() => {
const NowPlayingBar = React.memo(() => { const NowPlayingBar = React.memo(() => {
const navigation = useNavigation() const navigation = useNavigation()
const currentTrackExists = useStore(store => !!store.currentTrack) const 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 title = useStore(store => store.currentTrack?.title)
const artist = useStore(store => store.currentTrack?.artist) const artist = useStore(store => store.currentTrack?.artist)
@@ -90,9 +90,9 @@ const NowPlayingBar = React.memo(() => {
<ProgressBar /> <ProgressBar />
<View style={styles.subContainer}> <View style={styles.subContainer}>
<CoverArt <CoverArt
type="album" type="cover"
style={{ height: styles.subContainer.height, width: styles.subContainer.height }} style={{ height: styles.subContainer.height, width: styles.subContainer.height }}
albumId={albumId} coverArt={coverArt}
size="thumbnail" size="thumbnail"
fadeDuration={0} 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 { 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 { mapAlbum, mapArtist, mapArtistInfo, mapPlaylist, mapSong } from '@app/models/map'
import queryClient from '@app/queryClient' import queryClient from '@app/queryClient'
import { useStore } from '@app/state/store' import { useStore } from '@app/state/store'
import { SubsonicApiClient } from '@app/subsonic/api'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params' import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import { cacheDir } from '@app/util/fs' import { cacheDir } from '@app/util/fs'
import { mapCollectionById } from '@app/util/state' 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) { 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 = () => { export const useFetchArtists = () => {
@@ -110,8 +109,11 @@ export const useFetchPlaylist = () => {
} }
} }
export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise<{ album: Album; songs?: Song[] }> { export const useFetchAlbum = () => {
const res = await client.getAlbum({ id }) const client = useClient()
return async (id: string): Promise<{ album: Album; songs?: Song[] }> => {
const res = await client().getAlbum({ id })
cacheStarredData(res.data.album) cacheStarredData(res.data.album)
res.data.songs.forEach(cacheStarredData) res.data.songs.forEach(cacheStarredData)
@@ -122,11 +124,7 @@ export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise
album: mapAlbum(res.data.album), album: mapAlbum(res.data.album),
songs: res.data.songs.map(mapSong), songs: res.data.songs.map(mapSong),
} }
} }
export const useFetchAlbum = () => {
const client = useClient()
return async (id: string) => fetchAlbum(id, client())
} }
export const useFetchAlbumList = () => { export const useFetchAlbumList = () => {
@@ -198,23 +196,17 @@ export type FetchExisingFileOptions = {
itemId: string itemId: string
} }
export async function fetchExistingFile( export const useFetchExistingFile: () => (options: FetchExisingFileOptions) => Promise<string | undefined> = () => {
options: FetchExisingFileOptions, const serverId = useStore(store => store.settings.activeServerId)
serverId: string | undefined,
): Promise<string | undefined> {
const { itemType, itemId } = options
const fileDir = cacheDir(serverId, itemType, itemId)
return async ({ itemType, itemId }) => {
const fileDir = cacheDir(serverId, itemType, itemId)
try { try {
const dir = await RNFS.readDir(fileDir) const dir = await RNFS.readDir(fileDir)
console.log('existing file:', dir[0].path) console.log('existing file:', dir[0].path)
return dir[0].path return dir[0].path
} catch {} } catch {}
} }
export const useFetchExistingFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchExisingFileOptions) => fetchExistingFile(options, serverId)
} }
function assertMimeType(expected?: string, actual?: string) { function assertMimeType(expected?: string, actual?: string) {
@@ -245,8 +237,10 @@ export type FetchFileOptions = FetchExisingFileOptions & {
progress?: (received: number, total: number) => void progress?: (received: number, total: number) => void
} }
export async function fetchFile(options: FetchFileOptions, serverId: string | undefined): Promise<string> { export const useFetchFile: () => (options: FetchFileOptions) => Promise<string> = () => {
let { itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress } = options const serverId = useStore(store => store.settings.activeServerId)
return async ({ itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress }) => {
useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster
const fileDir = cacheDir(serverId, itemType, itemId) const fileDir = cacheDir(serverId, itemType, itemId)
@@ -307,9 +301,5 @@ export async function fetchFile(options: FetchFileOptions, serverId: string | un
console.log('downloaded file:', downloadPath) console.log('downloaded file:', downloadPath)
return 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 { 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 { CollectionById } from '@app/models/state'
import queryClient from '@app/queryClient' import queryClient from '@app/queryClient'
import { useStore } from '@app/state/store' import { useStore } from '@app/state/store'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params' import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import _ from 'lodash' import _ from 'lodash'
import { useInfiniteQuery, useMutation, useQueries, useQuery } from 'react-query' import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
useQueries,
useQuery,
UseQueryResult,
} from 'react-query'
import { import {
useFetchAlbum, useFetchAlbum,
useFetchAlbumList, 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()) 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) => { export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
@@ -112,7 +120,7 @@ export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
placeholderAlbum ? { album: placeholderAlbum } : undefined, placeholderAlbum ? { album: placeholderAlbum } : undefined,
}) })
return query return useFixCoverArt(query)
} }
export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => { 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) => { 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 } 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 fetchAlbum = useFetchAlbum()
const query = useQuery( const songs = getSongs(query.data)
qk.albumCoverArt(albumId || '-1'), const albumIds = _.uniq((songs || []).map(s => s.albumId).filter((id): id is string => id !== undefined))
async () => (await fetchAlbum(albumId || '-1')).album.coverArt,
{ const coverArts = useQueries(
enabled: !!albumId, 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, staleTime: Infinity,
cacheTime: 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 = () => { export const useResetImageCache = () => {
const serverIds = useStoreDeep(store => Object.keys(store.settings.servers)) const serverIds = useStoreDeep(store => Object.keys(store.settings.servers))
const changeCacheBuster = useStore(store => store.changeCacheBuster) const changeCacheBuster = useStore(store => store.changeCacheBuster)
const setDisableMusicTabs = useStore(store => store.setDisableMusicTabs)
return async () => { return async () => {
setDisableMusicTabs(true)
try {
// disable/invalidate queries // disable/invalidate queries
await Promise.all([ await Promise.all([
queryClient.cancelQueries(qk.artistArt(), { active: true }), queryClient.cancelQueries(qk.artistArt(), { active: true }),
@@ -61,8 +57,6 @@ export const useResetImageCache = () => {
// change cacheBuster // change cacheBuster
changeCacheBuster() changeCacheBuster()
} finally {
setDisableMusicTabs(false)
// enable queries // enable queries
await Promise.all([ await Promise.all([
@@ -71,5 +65,4 @@ export const useResetImageCache = () => {
queryClient.refetchQueries(qk.coverArt(), { active: true }), queryClient.refetchQueries(qk.coverArt(), { active: true }),
]) ])
} }
}
} }
+78 -39
View File
@@ -1,11 +1,13 @@
import { Song } from '@app/models/library' import { Song } from '@app/models/library'
import { QueueContextType, TrackExt } from '@app/models/trackplayer' import { QueueContextType, TrackExt } from '@app/models/trackplayer'
import queryClient from '@app/queryClient' import queryClient from '@app/queryClient'
import queueService from '@app/queueservice'
import { useStore, useStoreDeep } from '@app/state/store' import { useStore, useStoreDeep } from '@app/state/store'
import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer' import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer'
import userAgent from '@app/util/userAgent' import userAgent from '@app/util/userAgent'
import _ from 'lodash'
import TrackPlayer from 'react-native-track-player' import TrackPlayer from 'react-native-track-player'
import { useQueries } from 'react-query'
import { useFetchExistingFile, useFetchFile } from './fetch'
import qk from './queryKeys' import qk from './queryKeys'
export const usePlay = () => { export const usePlay = () => {
@@ -90,50 +92,87 @@ export const useIsPlaying = (contextId: string | undefined, track: number) => {
return contextId === queueContextId && track === currentTrackIdx 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[]) => { export const useSetQueue = (type: QueueContextType, songs?: Song[]) => {
const _setQueue = useStore(store => store.setQueue) 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 contextId = `${type}-${songs?.map(s => s.id).join('-')}`
const setQueue = async (options: SetQueueOptions) => { const setQueue = async (options: SetQueueOptions) => {
if (!songs || songs.length === 0) { const queue = (songs || []).map(mapSongToTrackExt)
return return await _setQueue({ queue, type, contextId, ...options })
} }
const queue = songs.map(mapSongToTrackExt) return { setQueue, contextId, isReady: coverArtPaths.every(c => c.isFetched) }
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 }
} }
-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 discNumber?: number
duration?: number duration?: number
starred?: number starred?: number
coverArt?: string
playCount?: number playCount?: number
userRating?: number userRating?: number
averageRating?: number averageRating?: number
+1
View File
@@ -75,6 +75,7 @@ export function mapTrackExtToSong(track: TrackExt): Song {
title: track.title as string, title: track.title as string,
artist: track.artist, artist: track.artist,
album: track.album, album: track.album,
coverArt: track.coverArt,
duration: track.duration, duration: track.duration,
artistId: track.artistId, artistId: track.artistId,
albumId: track.albumId, albumId: track.albumId,
+4 -16
View File
@@ -18,11 +18,10 @@ const BottomTabButton = React.memo<{
isFocused: boolean isFocused: boolean
icon: OutlineFillIcon icon: OutlineFillIcon
navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap> navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap>
disabled?: boolean }>(({ routeKey, label, name, isFocused, icon, navigation }) => {
}>(({ routeKey, label, name, isFocused, icon, navigation, disabled }) => {
const firstRun = useFirstRun() const firstRun = useFirstRun()
disabled = !!disabled || (firstRun && name !== 'settings') const disabled = firstRun && name !== 'settings'
const onPress = () => { const onPress = () => {
const event = navigation.emit({ const event = navigation.emit({
@@ -48,9 +47,7 @@ const BottomTabButton = React.memo<{
return ( return (
<PressableOpacity onPress={onPress} style={styles.button} disabled={disabled}> <PressableOpacity onPress={onPress} style={styles.button} disabled={disabled}>
<Image source={imgSource} style={imgStyle} fadeDuration={0} /> <Image source={imgSource} style={imgStyle} fadeDuration={0} />
<Text style={textStyle} numberOfLines={1} ellipsizeMode="clip"> <Text style={textStyle}>{label}</Text>
{label}
</Text>
</PressableOpacity> </PressableOpacity>
) )
}) })
@@ -68,13 +65,6 @@ const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigat
? options.title ? options.title
: route.name : route.name
let iconKey = route.name
let disabled = false
if (route.name.endsWith('-disabled')) {
iconKey = route.name.split('-')[0]
disabled = true
}
return ( return (
<BottomTabButton <BottomTabButton
key={route.key} key={route.key}
@@ -82,9 +72,8 @@ const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigat
label={label} label={label}
name={route.name} name={route.name}
isFocused={state.index === index} isFocused={state.index === index}
icon={bottomTabIcons[iconKey]} icon={bottomTabIcons[route.name]}
navigation={navigation} navigation={navigation}
disabled={disabled}
/> />
) )
})} })}
@@ -103,7 +92,6 @@ const styles = StyleSheet.create({
}, },
button: { button: {
alignItems: 'center', alignItems: 'center',
flexGrow: 1,
flex: 1, flex: 1,
height: '100%', height: '100%',
}, },
+15 -32
View File
@@ -1,4 +1,3 @@
import { withSuspense } from '@app/components/withSuspense'
import { useFirstRun } from '@app/hooks/settings' import { useFirstRun } from '@app/hooks/settings'
import { Album, Playlist } from '@app/models/library' import { Album, Playlist } from '@app/models/library'
import BottomTabBar from '@app/navigation/BottomTabBar' 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 { BottomTabNavigationProp, createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { RouteProp, StackActions } from '@react-navigation/native' import { RouteProp, StackActions } from '@react-navigation/native'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native' import { StyleSheet } from 'react-native'
import { createNativeStackNavigator, NativeStackNavigationProp } from 'react-native-screens/native-stack' import { createNativeStackNavigator, NativeStackNavigationProp } from 'react-native-screens/native-stack'
@@ -117,8 +115,8 @@ const SearchTab = createTabStackNavigator(Search)
type SettingsStackParamList = { type SettingsStackParamList = {
main: undefined main: undefined
server?: { id?: string; title?: string } server?: { id?: string }
web: { uri: string; title?: string } web: { uri: string }
} }
type ServerScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'server'> type ServerScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'server'>
@@ -127,9 +125,7 @@ type ServerScreenProps = {
route: ServerScreenRouteProp route: ServerScreenRouteProp
navigation: ServerScreenNavigationProp navigation: ServerScreenNavigationProp
} }
const ServerScreen: React.FC<ServerScreenProps> = ({ route }) => ( const ServerScreen: React.FC<ServerScreenProps> = ({ route }) => <ServerView id={route.params?.id} />
<ServerView id={route.params?.id} title={route.params?.title} />
)
type WebScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'web'> type WebScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'web'>
type WebScreenRouteProp = RouteProp<SettingsStackParamList, 'web'> type WebScreenRouteProp = RouteProp<SettingsStackParamList, 'web'>
@@ -137,9 +133,7 @@ type WebScreenProps = {
route: WebScreenRouteProp route: WebScreenRouteProp
navigation: WebScreenNavigationProp navigation: WebScreenNavigationProp
} }
const WebScreen: React.FC<WebScreenProps> = ({ route }) => ( const WebScreen: React.FC<WebScreenProps> = ({ route }) => <WebViewScreen uri={route.params.uri} />
<WebViewScreen uri={route.params.uri} title={route.params.title} />
)
const SettingsStack = createNativeStackNavigator() const SettingsStack = createNativeStackNavigator()
@@ -151,6 +145,7 @@ const SettingsTab = () => {
name="server" name="server"
component={ServerScreen} component={ServerScreen}
options={{ options={{
title: 'Edit Server',
headerStyle: styles.stackheaderStyle, headerStyle: styles.stackheaderStyle,
headerHideShadow: true, headerHideShadow: true,
headerTintColor: 'white', headerTintColor: 'white',
@@ -161,6 +156,7 @@ const SettingsTab = () => {
name="web" name="web"
component={WebScreen} component={WebScreen}
options={{ options={{
title: 'Web View',
headerStyle: styles.stackheaderStyle, headerStyle: styles.stackheaderStyle,
headerHideShadow: true, headerHideShadow: true,
headerTintColor: 'white', headerTintColor: 'white',
@@ -173,37 +169,24 @@ const SettingsTab = () => {
const Tab = createBottomTabNavigator() const Tab = createBottomTabNavigator()
const BottomTabNavigator = withSuspense(() => { const BottomTabNavigator = () => {
const { t } = useTranslation()
const firstRun = useFirstRun() const firstRun = useFirstRun()
const disableMusicTabs = useStore(store => store.disableMusicTabs) const resetServer = useStore(store => store.resetServer)
return ( return (
<Tab.Navigator tabBar={BottomTabBar} initialRouteName={firstRun ? 'settings' : 'home'}> <Tab.Navigator tabBar={BottomTabBar} initialRouteName={firstRun ? 'settings' : 'home'}>
{disableMusicTabs ? ( {resetServer ? (
<> <></>
<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') }}
/>
</>
) : ( ) : (
<> <>
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: t('navigation.tabs.home') }} /> <Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: 'Home' }} />
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: t('navigation.tabs.library') }} /> <Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: 'Library' }} />
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: t('navigation.tabs.search') }} /> <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> </Tab.Navigator>
) )
}) }
export default BottomTabNavigator export default BottomTabNavigator
+5 -20
View File
@@ -1,4 +1,3 @@
import { withSuspense } from '@app/components/withSuspense'
import AlbumsTab from '@app/screens/LibraryAlbums' import AlbumsTab from '@app/screens/LibraryAlbums'
import ArtistsTab from '@app/screens/LibraryArtists' import ArtistsTab from '@app/screens/LibraryArtists'
import PlaylistsTab from '@app/screens/LibraryPlaylists' import PlaylistsTab from '@app/screens/LibraryPlaylists'
@@ -7,14 +6,12 @@ import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font' import font from '@app/styles/font'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs' import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native' import { StyleSheet } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
const Tab = createMaterialTopTabNavigator() const Tab = createMaterialTopTabNavigator()
const LibraryTopTabNavigator = withSuspense(() => { const LibraryTopTabNavigator = () => {
const { t } = useTranslation()
const marginTop = useSafeAreaInsets().top const marginTop = useSafeAreaInsets().top
return ( return (
@@ -25,24 +22,12 @@ const LibraryTopTabNavigator = withSuspense(() => {
indicatorStyle: styles.tabindicatorStyle, indicatorStyle: styles.tabindicatorStyle,
}} }}
initialRouteName="albums"> initialRouteName="albums">
<Tab.Screen <Tab.Screen name="albums" component={AlbumsTab} options={{ tabBarLabel: 'Albums' }} />
name="albums" <Tab.Screen name="artists" component={ArtistsTab} options={{ tabBarLabel: 'Artists' }} />
component={AlbumsTab} <Tab.Screen name="playlists" component={PlaylistsTab} options={{ tabBarLabel: 'Playlists' }} />
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.Navigator> </Tab.Navigator>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
tabBar: { tabBar: {
+3 -9
View File
@@ -1,4 +1,3 @@
import { withSuspense } from '@app/components/withSuspense'
import BottomTabNavigator from '@app/navigation/BottomTabNavigator' import BottomTabNavigator from '@app/navigation/BottomTabNavigator'
import NowPlayingQueue from '@app/screens/NowPlayingQueue' import NowPlayingQueue from '@app/screens/NowPlayingQueue'
import NowPlayingView from '@app/screens/NowPlayingView' import NowPlayingView from '@app/screens/NowPlayingView'
@@ -6,22 +5,18 @@ import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { DarkTheme, NavigationContainer } from '@react-navigation/native' import { DarkTheme, NavigationContainer } from '@react-navigation/native'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { createNativeStackNavigator } from 'react-native-screens/native-stack' import { createNativeStackNavigator } from 'react-native-screens/native-stack'
const NowPlayingStack = createNativeStackNavigator() const NowPlayingStack = createNativeStackNavigator()
const NowPlayingNavigator = withSuspense(() => { const NowPlayingNavigator = () => (
const { t } = useTranslation()
return (
<NowPlayingStack.Navigator> <NowPlayingStack.Navigator>
<NowPlayingStack.Screen name="main" component={NowPlayingView} options={{ headerShown: false }} /> <NowPlayingStack.Screen name="main" component={NowPlayingView} options={{ headerShown: false }} />
<NowPlayingStack.Screen <NowPlayingStack.Screen
name="queue" name="queue"
component={NowPlayingQueue} component={NowPlayingQueue}
options={{ options={{
title: t('resources.queue.name', { count: 1 }), title: 'Queue',
headerStyle: { headerStyle: {
backgroundColor: colors.gradient.high, backgroundColor: colors.gradient.high,
}, },
@@ -35,8 +30,7 @@ const NowPlayingNavigator = withSuspense(() => {
}} }}
/> />
</NowPlayingStack.Navigator> </NowPlayingStack.Navigator>
) )
})
const RootStack = createNativeStackNavigator() const RootStack = createNativeStackNavigator()
+2 -149
View File
@@ -1,14 +1,8 @@
import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer' 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 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 { 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 = () => { const reset = () => {
unstable_batchedUpdates(() => { unstable_batchedUpdates(() => {
@@ -40,81 +34,12 @@ const rebuildQueue = (forcePlay?: boolean) => {
}) })
} }
const updateQueue = () => {
unstable_batchedUpdates(() => {
useStore.getState().updateQueue()
})
}
const setDuckPaused = (duckPaused: boolean) => { const setDuckPaused = (duckPaused: boolean) => {
unstable_batchedUpdates(() => { unstable_batchedUpdates(() => {
useStore.getState().setDuckPaused(duckPaused) 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 let serviceCreated = false
const createService = async () => { const createService = async () => {
@@ -217,78 +142,6 @@ const createService = async () => {
rebuildQueue(true) 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 () { 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 Header from '@app/components/Header'
import HeaderBar from '@app/components/HeaderBar' import HeaderBar from '@app/components/HeaderBar'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryArtist, useQueryArtistTopSongs } from '@app/hooks/query' import { useQueryArtist, useQueryArtistTopSongs } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer' import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Song } from '@app/models/library' 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 { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react' import equal from 'fast-deep-equal/es6/react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
@@ -44,17 +42,15 @@ const AlbumItem = React.memo<{
) )
}, equal) }, equal)
const TopSongs = withSuspenseMemo<{ const TopSongs = React.memo<{
songs: Song[] songs: Song[]
name: string name: string
}>( }>(({ songs, name }) => {
({ songs, name }) => { const { setQueue, isReady, contextId } = useSetQueue('artist', songs)
const { setQueue, contextId } = useSetQueue('artist', songs)
const { t } = useTranslation()
return ( return (
<> <>
<Header>{t('resources.song.lists.artistTopSongs')}</Header> <Header>Top Songs</Header>
{songs.slice(0, 5).map((s, i) => ( {songs.slice(0, 5).map((s, i) => (
<ListItem <ListItem
key={i} key={i}
@@ -64,21 +60,17 @@ const TopSongs = withSuspenseMemo<{
showArt={true} showArt={true}
subtitle={s.album} subtitle={s.album}
onPress={() => setQueue({ title: name, playTrack: i })} onPress={() => setQueue({ title: name, playTrack: i })}
disabled={!isReady}
/> />
))} ))}
</> </>
) )
}, }, equal)
null,
equal,
)
const ArtistAlbums = withSuspenseMemo<{ const ArtistAlbums = React.memo<{
albums: Album[] albums: Album[]
}>( }>(({ albums }) => {
({ albums }) => {
const albumsLayout = useLayout() const albumsLayout = useLayout()
const { t } = useTranslation()
const sortedAlbums = [...albums] const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
@@ -88,7 +80,7 @@ const ArtistAlbums = withSuspenseMemo<{
return ( return (
<> <>
<Header>{t('resources.album.name', { count: 2 })}</Header> <Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}> <View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => ( {sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} /> <AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
@@ -96,10 +88,7 @@ const ArtistAlbums = withSuspenseMemo<{
</View> </View>
</> </>
) )
}, }, equal)
null,
equal,
)
const ArtistViewFallback = React.memo(() => ( const ArtistViewFallback = React.memo(() => (
<GradientBackground style={styles.fallback}> <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 GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header' import Header from '@app/components/Header'
import NothingHere from '@app/components/NothingHere' import NothingHere from '@app/components/NothingHere'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryHomeLists } from '@app/hooks/query' import { useQueryHomeLists } from '@app/hooks/query'
import { Album } from '@app/models/library' import { Album } from '@app/models/library'
import { useStoreDeep } from '@app/state/store' import { useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { GetAlbumList2TypeBase } from '@app/subsonic/params' import { GetAlbumList2TypeBase, GetAlbumListType } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react' import equal from 'fast-deep-equal/es6/react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native' import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' 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<{ const AlbumItem = React.memo<{
album: Album album: Album
}>(({ album }) => { }>(({ album }) => {
@@ -44,12 +49,6 @@ const AlbumItem = React.memo<{
) )
}, equal) }, 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<{ const Category = React.memo<{
type: string type: string
albums: Album[] albums: Album[]
@@ -75,7 +74,7 @@ const Category = React.memo<{
return ( return (
<View style={styles.category}> <View style={styles.category}>
<CategoryHeader type={type} /> <Header style={styles.header}>{titles[type as GetAlbumListType] || ''}</Header>
{albums.length > 0 ? <Albums /> : <Nothing />} {albums.length > 0 ? <Albums /> : <Nothing />}
</View> </View>
) )
+26 -32
View File
@@ -1,18 +1,16 @@
import { AlbumContextPressable } from '@app/components/ContextMenu' import { AlbumContextPressable } from '@app/components/ContextMenu'
import CoverArt from '@app/components/CoverArt' 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 GradientFlatList from '@app/components/GradientFlatList'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryAlbumList } from '@app/hooks/query' import { useQueryAlbumList } from '@app/hooks/query'
import { Album } from '@app/models/library' import { Album } from '@app/models/library'
import { useStore, useStoreDeep } from '@app/state/store' import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { GetAlbumList2TypeBase } from '@app/subsonic/params' import { GetAlbumList2Type, GetAlbumList2TypeBase } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react' import equal from 'fast-deep-equal/es6/react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native' import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'
const AlbumItem = React.memo<{ const AlbumItem = React.memo<{
@@ -55,36 +53,23 @@ const AlbumListRenderItem: React.FC<{
item: { album: Album; size: number; height: number } item: { album: Album; size: number; height: number }
}> = ({ item }) => <AlbumItem album={item.album} size={item.size} height={item.height} /> }> = ({ item }) => <AlbumItem album={item.album} size={item.size} height={item.height} />
const filterValues: GetAlbumList2TypeBase[] = [ const filterOptions: OptionData[] = [
'alphabeticalByName', // { text: 'By Name', value: 'alphabeticalByName' },
'alphabeticalByArtist', { text: 'By Artist', value: 'alphabeticalByArtist' },
'newest', { text: 'Newest', value: 'newest' },
'frequent', { text: 'Frequent', value: 'frequent' },
'recent', { text: 'Recent', value: 'recent' },
'starred', { text: 'Starred', value: 'starred' },
'random', { 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 AlbumsList = () => {
const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type) const filter = useStoreDeep(store => store.settings.screens.library.albumsFilter)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filterType as GetAlbumList2TypeBase, 300) const setFilter = useStore(store => store.setLibraryAlbumFilter)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filter.type as GetAlbumList2TypeBase, 300)
const layout = useWindowDimensions() const layout = useWindowDimensions()
@@ -106,7 +91,16 @@ const AlbumsList = () => {
onEndReachedThreshold={6} onEndReachedThreshold={6}
windowSize={5} windowSize={5}
/> />
<AlbumFilterButton /> <FilterButton
data={filterOptions}
value={filter.type}
onSelect={selection => {
setFilter({
...filter,
type: selection as GetAlbumList2Type,
})
}}
/>
</View> </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 GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryArtists } from '@app/hooks/query' import { useQueryArtists } from '@app/hooks/query'
import { Artist } from '@app/models/library' import { Artist } from '@app/models/library'
import { ArtistFilterType } from '@app/models/settings' import { ArtistFilterType } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store' import { useStore, useStoreDeep } from '@app/state/store'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, View } from 'react-native' import { StyleSheet, View } from 'react-native'
const ArtistRenderItem: React.FC<{ item: Artist }> = ({ item }) => ( const ArtistRenderItem: React.FC<{ item: Artist }> = ({ item }) => (
<ListItem item={item} showArt={true} showStar={false} listStyle="big" style={styles.listItem} /> <ListItem item={item} showArt={true} showStar={false} listStyle="big" style={styles.listItem} />
) )
const filterValues: ArtistFilterType[] = [ const filterOptions: OptionData[] = [
'alphabeticalByName', // { text: 'By Name', value: 'alphabeticalByName' },
'starred', { text: 'Starred', value: 'starred' },
'random', { 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 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 { isLoading, data, refetch } = useQueryArtists()
const [sortedList, setSortedList] = useState<Artist[]>([]) const [sortedList, setSortedList] = useState<Artist[]>([])
@@ -48,7 +32,7 @@ const ArtistsList = () => {
} }
const list = Object.values(data.byId) const list = Object.values(data.byId)
switch (filterType) { switch (filter.type) {
case 'random': case 'random':
setSortedList([...list].sort(() => Math.random() - 0.5)) setSortedList([...list].sort(() => Math.random() - 0.5))
break break
@@ -62,7 +46,7 @@ const ArtistsList = () => {
setSortedList([...list]) setSortedList([...list])
break break
} }
}, [filterType, data]) }, [filter.type, data])
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -76,7 +60,16 @@ const ArtistsList = () => {
windowSize={3} windowSize={3}
contentMarginTop={6} contentMarginTop={6}
/> />
<ArtistFilterButton /> <FilterButton
data={filterOptions}
value={filter.type}
onSelect={selection => {
setFilter({
...filter,
type: selection as ArtistFilterType,
})
}}
/>
</View> </View>
) )
} }
+20 -19
View File
@@ -3,10 +3,9 @@ import HeaderBar from '@app/components/HeaderBar'
import ImageGradientBackground from '@app/components/ImageGradientBackground' import ImageGradientBackground from '@app/components/ImageGradientBackground'
import PressableOpacity from '@app/components/PressableOpacity' import PressableOpacity from '@app/components/PressableOpacity'
import { PressableStar } from '@app/components/Star' import { PressableStar } from '@app/components/Star'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useNext, usePause, usePlay, usePrevious, useSeekTo } from '@app/hooks/trackplayer' import { useNext, usePause, usePlay, usePrevious, useSeekTo } from '@app/hooks/trackplayer'
import { mapTrackExtToSong } from '@app/models/map' import { 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 { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
@@ -14,7 +13,6 @@ import formatDuration from '@app/util/formatDuration'
import Slider from '@react-native-community/slider' import Slider from '@react-native-community/slider'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, StyleSheet, Text, TextStyle, View } from 'react-native' import { ActivityIndicator, StyleSheet, Text, TextStyle, View } from 'react-native'
import { NativeStackScreenProps } from 'react-native-screens/native-stack' import { NativeStackScreenProps } from 'react-native-screens/native-stack'
import { RepeatMode, State } from 'react-native-track-player' 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 Icon from 'react-native-vector-icons/Ionicons'
import IconMatCom from 'react-native-vector-icons/MaterialCommunityIcons' 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?: TrackExt
}>(({ track }) => { }>(({ track }) => {
const queueName = useStore(store => store.queueName) const queueName = useStore(store => store.queueName)
const queueContextType = useStore(store => store.queueContextType) const queueContextType = useStore(store => store.queueContextType)
const { t } = useTranslation()
console.log(t('resources.album.name', { count: 1 }))
if (!track) { if (!track) {
return <></> return <></>
} }
let contextName: string let contextName = getContextName(queueContextType)
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')
}
return ( return (
<HeaderBar <HeaderBar
@@ -90,11 +91,11 @@ const headerStyles = StyleSheet.create({
}) })
const SongCoverArt = () => { const SongCoverArt = () => {
const albumId = useStore(store => store.currentTrack?.albumId) const coverArt = useStore(store => store.currentTrack?.coverArt)
return ( return (
<View style={coverArtStyles.container}> <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> </View>
) )
} }
+15 -43
View File
@@ -4,7 +4,6 @@ import Header from '@app/components/Header'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import NothingHere from '@app/components/NothingHere' import NothingHere from '@app/components/NothingHere'
import TextInput from '@app/components/TextInput' import TextInput from '@app/components/TextInput'
import { withSuspense, withSuspenseMemo } from '@app/components/withSuspense'
import { useQuerySearchResults } from '@app/hooks/query' import { useQuerySearchResults } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer' import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Artist, SearchResults, Song } from '@app/models/library' 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 equal from 'fast-deep-equal/es6/react'
import _ from 'lodash' import _ from 'lodash'
import React, { useCallback, useMemo, useRef, useState } from 'react' import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { import {
ActivityIndicator, ActivityIndicator,
InteractionManager, InteractionManager,
@@ -26,7 +24,7 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
const SongItem = React.memo<{ item: Song }>(({ item }) => { const SongItem = React.memo<{ item: Song }>(({ item }) => {
const { setQueue, contextId } = useSetQueue('song', [item]) const { setQueue, isReady, contextId } = useSetQueue('song', [item])
return ( return (
<ListItem <ListItem
@@ -36,19 +34,18 @@ const SongItem = React.memo<{ item: Song }>(({ item }) => {
showArt={true} showArt={true}
showStar={false} showStar={false}
onPress={() => setQueue({ title: item.title, playTrack: 0 })} onPress={() => setQueue({ title: item.title, playTrack: 0 })}
disabled={!isReady}
/> />
) )
}, equal) }, equal)
const ResultsCategory = withSuspenseMemo<{ const ResultsCategory = React.memo<{
name: string name: string
query: string query: string
items: (Artist | Album | Song)[] items: (Artist | Album | Song)[]
type: 'artist' | 'album' | 'song' type: 'artist' | 'album' | 'song'
}>( }>(({ name, query, type, items }) => {
({ name, query, type, items }) => {
const navigation = useNavigation() const navigation = useNavigation()
const { t } = useTranslation()
if (items.length === 0) { if (items.length === 0) {
return <></> return <></>
@@ -66,7 +63,7 @@ const ResultsCategory = withSuspenseMemo<{
)} )}
{items.length === 5 && ( {items.length === 5 && (
<Button <Button
title={t('search.moreResults')} title="More..."
buttonStyle="hollow" buttonStyle="hollow"
style={styles.more} style={styles.more}
onPress={() => navigation.navigate('results', { query, type: items[0].itemType })} onPress={() => navigation.navigate('results', { query, type: items[0].itemType })}
@@ -74,49 +71,24 @@ const ResultsCategory = withSuspenseMemo<{
)} )}
</> </>
) )
}, }, equal)
null,
equal,
)
const Results = withSuspenseMemo<{ const Results = React.memo<{
results: SearchResults results: SearchResults
query: string query: string
}>( }>(({ results, query }) => {
({ results, query }) => {
const { t } = useTranslation()
return ( return (
<> <>
<ResultsCategory <ResultsCategory name="Artists" query={query} type={'artist'} items={results.artists} />
name={t('resources.artist.name', { count: 2 })} <ResultsCategory name="Albums" query={query} type={'album'} items={results.albums} />
query={query} <ResultsCategory name="Songs" query={query} type={'song'} items={results.songs} />
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}
/>
</> </>
) )
}, }, equal)
null,
equal,
)
const Search = withSuspense(() => { const Search = () => {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const { data, isLoading } = useQuerySearchResults({ query, albumCount: 5, artistCount: 5, songCount: 5 }) const { data, isLoading } = useQuerySearchResults({ query, albumCount: 5, artistCount: 5, songCount: 5 })
const { t } = useTranslation()
const [text, setText] = useState('') const [text, setText] = useState('')
const searchBarRef = useRef<ReactTextInput>(null) const searchBarRef = useRef<ReactTextInput>(null)
@@ -168,7 +140,7 @@ const Search = withSuspense(() => {
<TextInput <TextInput
ref={searchBarRef} ref={searchBarRef}
style={styles.textInput} style={styles.textInput}
placeholder={t('search.inputPlaceholder')} placeholder="Search"
value={text} value={text}
onChangeText={onChangeText} onChangeText={onChangeText}
/> />
@@ -182,7 +154,7 @@ const Search = withSuspense(() => {
</View> </View>
</GradientScrollView> </GradientScrollView>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scroll: { scroll: {
+6 -8
View File
@@ -1,19 +1,17 @@
import GradientFlatList from '@app/components/GradientFlatList' import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem' import ListItem from '@app/components/ListItem'
import { withSuspense } from '@app/components/withSuspense'
import { useQuerySearchResults } from '@app/hooks/query' import { useQuerySearchResults } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer' import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Artist, Song } from '@app/models/library' import { Album, Artist, Song } from '@app/models/library'
import { Search3Params } from '@app/subsonic/params' import { Search3Params } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native' import { StyleSheet } from 'react-native'
type SearchListItemType = Album | Song | Artist type SearchListItemType = Album | Song | Artist
const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => { const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => {
const { setQueue, contextId } = useSetQueue('song', [item]) const { setQueue, isReady, contextId } = useSetQueue('song', [item])
return ( return (
<ListItem <ListItem
@@ -25,6 +23,7 @@ const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => {
listStyle="small" listStyle="small"
onPress={() => setQueue({ title: item.title, playTrack: 0 })} onPress={() => setQueue({ title: item.title, playTrack: 0 })}
style={styles.listItem} 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 SearchResultsRenderItem: React.FC<{ item: SearchListItemType }> = ({ item }) => <ResultsListItem item={item} />
const SearchResultsView = withSuspense<{ const SearchResultsView: React.FC<{
query: string query: string
type: 'album' | 'artist' | 'song' type: 'album' | 'artist' | 'song'
}>(({ query, type }) => { }> = ({ query, type }) => {
const navigation = useNavigation() const navigation = useNavigation()
const { t } = useTranslation()
const size = 100 const size = 100
const params: Search3Params = { query } const params: Search3Params = { query }
@@ -84,7 +82,7 @@ const SearchResultsView = withSuspense<{
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
title: t('search.headerTitle', { query }), title: `Search: "${query}"`,
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
@@ -104,7 +102,7 @@ const SearchResultsView = withSuspense<{
windowSize={5} windowSize={5}
/> />
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
listItem: { listItem: {
+20 -34
View File
@@ -1,7 +1,5 @@
import Button from '@app/components/Button' import Button from '@app/components/Button'
import GradientScrollView from '@app/components/GradientScrollView' import GradientScrollView from '@app/components/GradientScrollView'
import SettingsSwitch from '@app/components/SettingsSwitch'
import { withSuspense } from '@app/components/withSuspense'
import { Server } from '@app/models/settings' import { Server } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store' import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
@@ -9,18 +7,16 @@ import font from '@app/styles/font'
import toast from '@app/util/toast' import toast from '@app/util/toast'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import md5 from 'md5' import md5 from 'md5'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, TextInput, View, ViewStyle } from 'react-native' import { StyleSheet, Text, TextInput, View, ViewStyle } from 'react-native'
import uuid from 'react-native-uuid' import uuid from 'react-native-uuid'
import SettingsSwitch from '@app/components/SettingsSwitch'
const PASSWORD_PLACEHOLDER = 'PASSWORD_PLACEHOLDER' const PASSWORD_PLACEHOLDER = 'PASSWORD_PLACEHOLDER'
const ServerView = withSuspense<{ const ServerView: React.FC<{
id?: string id?: string
title?: string }> = ({ id }) => {
}>(({ id, title }) => {
const { t } = useTranslation()
const navigation = useNavigation() const navigation = useNavigation()
const activeServerId = useStore(store => store.settings.activeServerId) const activeServerId = useStore(store => store.settings.activeServerId)
const servers = useStoreDeep(store => store.settings.servers) const servers = useStoreDeep(store => store.settings.servers)
@@ -40,10 +36,6 @@ const ServerView = withSuspense<{
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
useEffect(() => {
navigation.setOptions({ title })
}, [navigation, title])
const validate = useCallback(() => { const validate = useCallback(() => {
return !!address && !!username && !!password return !!address && !!username && !!password
}, [address, username, password]) }, [address, username, password])
@@ -142,16 +134,15 @@ const ServerView = withSuspense<{
const ping = async () => { const ping = async () => {
const res = await pingServer(potential) const res = await pingServer(potential)
toast( if (res) {
t(`settings.servers.messages.${res ? 'connectionOk' : 'connectionFailed'}`, { toast(`Connection to ${potential.address} OK!`)
address: potential.address, } else {
interpolation: { escapeValue: false }, toast(`Connection to ${potential.address} failed, check settings or server`)
}), }
)
setTesting(false) setTesting(false)
} }
ping() ping()
}, [createServer, pingServer, t]) }, [createServer, pingServer])
const disableControls = useCallback(() => { const disableControls = useCallback(() => {
return !validate() || testing return !validate() || testing
@@ -178,7 +169,7 @@ const ServerView = withSuspense<{
return ( return (
<GradientScrollView style={styles.scroll}> <GradientScrollView style={styles.scroll}>
<View style={styles.content}> <View style={styles.content}>
<Text style={styles.inputTitle}>{t('settings.servers.fields.address')}</Text> <Text style={styles.inputTitle}>Address</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholderTextColor="grey" placeholderTextColor="grey"
@@ -191,7 +182,7 @@ const ServerView = withSuspense<{
onChangeText={setAddress} onChangeText={setAddress}
onBlur={formatAddress} onBlur={formatAddress}
/> />
<Text style={styles.inputTitle}>{t('settings.servers.fields.username')}</Text> <Text style={styles.inputTitle}>Username</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholderTextColor="grey" placeholderTextColor="grey"
@@ -204,7 +195,7 @@ const ServerView = withSuspense<{
value={username} value={username}
onChangeText={setUsername} onChangeText={setUsername}
/> />
<Text style={styles.inputTitle}>{t('settings.servers.fields.password')}</Text> <Text style={styles.inputTitle}>Password</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholderTextColor="grey" placeholderTextColor="grey"
@@ -219,11 +210,11 @@ const ServerView = withSuspense<{
onChangeText={setPassword} onChangeText={setPassword}
/> />
<SettingsSwitch <SettingsSwitch
title={t('settings.servers.options.forcePlaintextPassword.title')} title="Force plain text password"
subtitle={ subtitle={
usePlainPassword usePlainPassword
? t('settings.servers.options.forcePlaintextPassword.descriptionOn') ? 'Send password in plain text (legacy, make sure your connection is secure!)'
: t('settings.servers.options.forcePlaintextPassword.descriptionOff') : 'Send password as token + salt'
} }
value={usePlainPassword} value={usePlainPassword}
setValue={togglePlainPassword} setValue={togglePlainPassword}
@@ -231,26 +222,21 @@ const ServerView = withSuspense<{
<Button <Button
disabled={disableControls()} disabled={disableControls()}
style={styles.button} style={styles.button}
title={t('settings.servers.actions.testConnection')} title="Test Connection"
buttonStyle="hollow" buttonStyle="hollow"
onPress={test} onPress={test}
/> />
<Button <Button
disabled={disableControls()} disabled={disableControls()}
style={[styles.button, styles.delete, deleteStyle]} style={[styles.button, styles.delete, deleteStyle]}
title={t('settings.servers.actions.delete')} title="Delete"
onPress={remove} onPress={remove}
/> />
<Button <Button disabled={disableControls()} style={styles.button} title="Save" onPress={save} />
disabled={disableControls()}
style={styles.button}
title={t('settings.servers.actions.save')}
onPress={save}
/>
</View> </View>
</GradientScrollView> </GradientScrollView>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scroll: { scroll: {
+50 -61
View File
@@ -5,28 +5,25 @@ import PressableOpacity from '@app/components/PressableOpacity'
import SettingsItem from '@app/components/SettingsItem' import SettingsItem from '@app/components/SettingsItem'
import SettingsSwitch from '@app/components/SettingsSwitch' import SettingsSwitch from '@app/components/SettingsSwitch'
import TextInput from '@app/components/TextInput' import TextInput from '@app/components/TextInput'
import { withSuspenseMemo } from '@app/components/withSuspense' import { useSwitchActiveServer, useResetImageCache } from '@app/hooks/settings'
import { useResetImageCache, useSwitchActiveServer } from '@app/hooks/settings'
import { Server } from '@app/models/settings' import { Server } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store' import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import font from '@app/styles/font' import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/core' import { useNavigation } from '@react-navigation/core'
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { KeyboardTypeOptions, Linking, Modal, Pressable, StyleSheet, Text, View } from 'react-native' import { KeyboardTypeOptions, Linking, Modal, Pressable, StyleSheet, Text, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler' import { ScrollView } from 'react-native-gesture-handler'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons' import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { version } from '../../package.json' import { version } from '../../package.json'
const ServerItem = withSuspenseMemo<{ const ServerItem = React.memo<{
server: Server server: Server
}>(({ server }) => { }>(({ server }) => {
const activeServerId = useStore(store => store.settings.activeServerId) const activeServerId = useStore(store => store.settings.activeServerId)
const switchActiveServer = useSwitchActiveServer() const switchActiveServer = useSwitchActiveServer()
const navigation = useNavigation() const navigation = useNavigation()
const { t } = useTranslation()
const setActive = useCallback(() => { const setActive = useCallback(() => {
switchActiveServer(server.id) switchActiveServer(server.id)
@@ -36,7 +33,7 @@ const ServerItem = withSuspenseMemo<{
<SettingsItem <SettingsItem
title={server.address} title={server.address}
subtitle={server.username} 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}> <PressableOpacity style={styles.serverActive} onPress={setActive}>
{activeServerId === server.id ? ( {activeServerId === server.id ? (
<Icon name="checkbox-marked-circle" size={30} color={colors.accent} /> <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 title: string
bitrate: number bitrate: number
setBitrate: (bitrate: number) => void setBitrate: (bitrate: number) => void
}>(({ title, bitrate, setBitrate }) => { }>(({ title, bitrate, setBitrate }) => {
const { t } = useTranslation()
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const toggleModal = useCallback(() => setVisible(!visible), [visible]) 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( const BitrateChoice: React.FC<{ value: number }> = useCallback(
({ value }) => { ({ value }) => {
const text = bitrateString(value)
return ( return (
<ModalChoice <ModalChoice
text={bitrateText(value)} text={text}
value={value} value={value}
setValue={setBitrate} setValue={setBitrate}
closeModal={toggleModal} closeModal={toggleModal}
@@ -104,12 +99,12 @@ const BitrateModal = withSuspenseMemo<{
/> />
) )
}, },
[bitrate, toggleModal, setBitrate, bitrateText], [bitrate, toggleModal, setBitrate],
) )
return ( 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}> <Modal animationType="fade" transparent={true} visible={visible} onRequestClose={toggleModal}>
<Pressable style={styles.modalBackdrop} onPress={toggleModal}> <Pressable style={styles.modalBackdrop} onPress={toggleModal}>
<View style={styles.centeredView}> <View style={styles.centeredView}>
@@ -140,9 +135,9 @@ const SettingsTextModal = React.memo<{
title: string title: string
value: string value: string
setValue: (text: string) => void setValue: (text: string) => void
subtitle: (value: string) => string getUnit?: (text: string) => string
keyboardType?: KeyboardTypeOptions keyboardType?: KeyboardTypeOptions
}>(({ title, value, setValue, subtitle, keyboardType }) => { }>(({ title, value, setValue, getUnit, keyboardType }) => {
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [inputText, setInputText] = useState(value) const [inputText, setInputText] = useState(value)
@@ -153,9 +148,16 @@ const SettingsTextModal = React.memo<{
toggleModal() toggleModal()
}, [inputText, setValue, toggleModal]) }, [inputText, setValue, toggleModal])
const getSubtitle = useCallback(() => {
if (!getUnit) {
return value
}
return value + ' ' + getUnit(value)
}, [getUnit, value])
return ( 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}> <Modal animationType="fade" transparent={true} visible={visible} onRequestClose={toggleModal}>
<Pressable style={styles.modalBackdrop} onPress={toggleModal}> <Pressable style={styles.modalBackdrop} onPress={toggleModal}>
<View style={styles.centeredView}> <View style={styles.centeredView}>
@@ -181,9 +183,15 @@ const SettingsTextModal = React.memo<{
) )
}) })
const SettingsContent = withSuspenseMemo(() => { function secondsUnit(seconds: string): string {
const { t } = useTranslation() 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 servers = useStoreDeep(store => store.settings.servers)
const scrobble = useStore(store => store.settings.scrobble) const scrobble = useStore(store => store.settings.scrobble)
const setScrobble = useStore(store => store.setScrobble) const setScrobble = useStore(store => store.setScrobble)
@@ -213,85 +221,66 @@ const SettingsContent = withSuspenseMemo(() => {
const setMinBufferText = useCallback((text: string) => setMinBuffer(parseFloat(text)), [setMinBuffer]) const setMinBufferText = useCallback((text: string) => setMinBuffer(parseFloat(text)), [setMinBuffer])
const setMaxBufferText = useCallback((text: string) => setMaxBuffer(parseFloat(text)), [setMaxBuffer]) const setMaxBufferText = useCallback((text: string) => setMaxBuffer(parseFloat(text)), [setMaxBuffer])
const secondsText = useCallback((value: string) => t('settings.network.values.seconds', { value }), [t])
return ( return (
<View style={styles.content}> <View style={styles.content}>
<Header>{t('settings.servers.name')}</Header> <Header>Servers</Header>
{Object.values(servers).map(s => ( {Object.values(servers).map(s => (
<ServerItem key={s.id} server={s} /> <ServerItem key={s.id} server={s} />
))} ))}
<Button <Button
style={styles.button} style={styles.button}
title={t('settings.servers.actions.add')} title="Add Server"
onPress={() => navigation.navigate('server', { title: t('settings.servers.actions.add') })} onPress={() => navigation.navigate('server')}
buttonStyle="hollow" buttonStyle="hollow"
/> />
<Header style={styles.header}>{t('settings.network.name')}</Header> <Header style={styles.header}>Network</Header>
<BitrateModal <BitrateModal title="Maximum bitrate (Wi-Fi)" bitrate={maxBitrateWifi} setBitrate={setMaxBitrateWifi} />
title={t('settings.network.options.maxBitrateWifi.title')} <BitrateModal title="Maximum bitrate (mobile)" bitrate={maxBitrateMobile} setBitrate={setMaxBitrateMobile} />
bitrate={maxBitrateWifi}
setBitrate={setMaxBitrateWifi}
/>
<BitrateModal
title={t('settings.network.options.maxBitrateMobile.title')}
bitrate={maxBitrateMobile}
setBitrate={setMaxBitrateMobile}
/>
<SettingsTextModal <SettingsTextModal
title={t('settings.network.options.minBuffer.title')} title="Minimum buffer time"
value={minBuffer.toString()} value={minBuffer.toString()}
setValue={setMinBufferText} setValue={setMinBufferText}
subtitle={secondsText} getUnit={secondsUnit}
keyboardType="numeric" keyboardType="numeric"
/> />
<SettingsTextModal <SettingsTextModal
title={t('settings.network.options.maxBuffer.title')} title="Maximum buffer time"
value={maxBuffer.toString()} value={maxBuffer.toString()}
setValue={setMaxBufferText} setValue={setMaxBufferText}
subtitle={secondsText} getUnit={secondsUnit}
keyboardType="numeric" keyboardType="numeric"
/> />
<Header style={styles.header}>{t('settings.music.name')}</Header> <Header style={styles.header}>Music</Header>
<SettingsSwitch <SettingsSwitch
title={t('settings.music.options.scrobble.title')} title="Scrobble plays"
subtitle={ subtitle={scrobble ? 'Scrobble play history' : "Don't scrobble play history"}
scrobble
? t('settings.music.options.scrobble.descriptionOn')
: t('settings.music.options.scrobble.descriptionOff')
}
value={scrobble} value={scrobble}
setValue={setScrobble} setValue={setScrobble}
/> />
<Header style={styles.header}>{t('settings.reset.name')}</Header> <Header style={styles.header}>Reset</Header>
<Button <Button
disabled={clearing} disabled={clearing}
style={styles.button} style={styles.button}
title={t('settings.reset.actions.clearImageCache')} title="Clear Image Cache"
onPress={clear} onPress={clear}
buttonStyle="hollow" buttonStyle="hollow"
/> />
<Header style={styles.header}>{t('settings.about.name')}</Header> <Header style={styles.header}>About</Header>
<Text style={styles.text}> <Text style={styles.text}>
<Text style={styles.bold}>Subtracks</Text> {t('settings.about.version', { version })} <Text style={styles.bold}>Subtracks</Text> version {version}
</Text> </Text>
<Button <Button
disabled={clearing} disabled={clearing}
style={styles.button} style={styles.button}
title={t('settings.about.actions.projectHomepage')} title="Project Homepage"
onPress={() => Linking.openURL('https://github.com/austinried/subtracks')} onPress={() => Linking.openURL('https://github.com/austinried/subtracks')}
buttonStyle="hollow" buttonStyle="hollow"
/> />
<Button <Button
disabled={clearing} disabled={clearing}
style={styles.button} style={styles.button}
title={t('settings.about.actions.licenses')} title="Licenses"
onPress={() => onPress={() => navigation.navigate('web', { uri: 'file:///android_asset/licenses.html' })}
navigation.navigate('web', {
uri: 'file:///android_asset/licenses.html',
title: t('settings.about.actions.licenses'),
})
}
buttonStyle="hollow" buttonStyle="hollow"
/> />
</View> </View>
+7 -3
View File
@@ -56,8 +56,10 @@ const SongListDetails = React.memo<{
const [headerColor, setHeaderColor] = useState<string | undefined>(undefined) const [headerColor, setHeaderColor] = useState<string | undefined>(undefined)
const _songs = [...(songs || [])] const _songs = [...(songs || [])]
let typeName = ''
if (type === 'album') { if (type === 'album') {
typeName = 'Album'
if (_songs.some(s => s.track === undefined)) { if (_songs.some(s => s.track === undefined)) {
_songs.sort((a, b) => a.title.localeCompare(b.title)) _songs.sort((a, b) => a.title.localeCompare(b.title))
} else { } else {
@@ -67,15 +69,17 @@ const SongListDetails = React.memo<{
return aVal - bVal return aVal - bVal
}) })
} }
} else {
typeName = 'Playlist'
} }
const { setQueue, contextId } = useSetQueue(type, _songs) const { setQueue, isReady, contextId } = useSetQueue(type, _songs)
if (!songList) { if (!songList) {
return <SongListDetailsFallback /> return <SongListDetailsFallback />
} }
const disabled = _songs.length === 0 const disabled = !isReady || _songs.length === 0
const play = (track?: number, shuffle?: boolean) => () => const play = (track?: number, shuffle?: boolean) => () =>
setQueue({ title: songList.name, playTrack: track, shuffle }) setQueue({ title: songList.name, playTrack: track, shuffle })
@@ -121,7 +125,7 @@ const SongListDetails = React.memo<{
<ListPlayerControls <ListPlayerControls
style={styles.controls} style={styles.controls}
songs={_songs} songs={_songs}
listType={type} typeName={typeName}
play={play(undefined, false)} play={play(undefined, false)}
shuffle={play(undefined, true)} shuffle={play(undefined, true)}
disabled={disabled} disabled={disabled}
+2 -10
View File
@@ -1,17 +1,9 @@
import { useNavigation } from '@react-navigation/native' import React from 'react'
import React, { useEffect } from 'react'
import { WebView } from 'react-native-webview' import { WebView } from 'react-native-webview'
const WebViewScreen: React.FC<{ const WebViewScreen: React.FC<{
uri: string uri: string
title?: string }> = ({ uri }) => {
}> = ({ uri, title }) => {
const navigation = useNavigation()
useEffect(() => {
navigation.setOptions({ title })
}, [navigation, title])
return <WebView source={{ 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 { ById } from '@app/models/state'
import { GetStore, SetStore } from '@app/state/store' import { GetStore, SetStore } from '@app/state/store'
import { SubsonicApiClient } from '@app/subsonic/api' import { SubsonicApiClient } from '@app/subsonic/api'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import uuid from 'react-native-uuid' import uuid from 'react-native-uuid'
export type SettingsSlice = { export type SettingsSlice = {
@@ -27,9 +26,7 @@ export type SettingsSlice = {
} }
client?: SubsonicApiClient client?: SubsonicApiClient
resetServer: boolean
disableMusicTabs: boolean
setDisableMusicTabs: (value: boolean) => void
changeCacheBuster: () => void changeCacheBuster: () => void
@@ -46,8 +43,8 @@ export type SettingsSlice = {
pingServer: (server?: Server) => Promise<boolean> pingServer: (server?: Server) => Promise<boolean>
setLibraryAlbumFilterType: (type: GetAlbumList2TypeBase) => void setLibraryAlbumFilter: (filter: AlbumFilterSettings) => void
setLibraryArtistFilterType: (type: ArtistFilterType) => void setLibraryArtistFiler: (filter: ArtistFilterSettings) => void
} }
export function newCacheBuster(): string { export function newCacheBuster(): string {
@@ -81,12 +78,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
cacheBuster: newCacheBuster(), cacheBuster: newCacheBuster(),
}, },
disableMusicTabs: false, resetServer: false,
setDisableMusicTabs: value => {
set(store => {
store.disableMusicTabs = value
})
},
changeCacheBuster: () => { changeCacheBuster: () => {
set(store => { set(store => {
@@ -111,7 +103,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
} }
set(state => { set(state => {
state.disableMusicTabs = true state.resetServer = true
}) })
set(state => { set(state => {
@@ -120,7 +112,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
}) })
set(state => { 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 => { set(state => {
state.settings.screens.library.albumsFilter.type = type state.settings.screens.library.albumsFilter = filter
}) })
}, },
setLibraryArtistFilterType: type => { setLibraryArtistFiler: filter => {
set(state => { 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> setNetState: (netState: 'mobile' | 'wifi') => Promise<void>
rebuildQueue: (forcePlay?: boolean) => Promise<void> rebuildQueue: (forcePlay?: boolean) => Promise<void>
updateQueue: () => Promise<void>
buildStreamUri: (id: string) => string buildStreamUri: (id: string) => string
resetTrackPlayerState: () => void 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 => { buildStreamUri: id => {
const client = get().client const client = get().client
if (!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!", "[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 { AppRegistry } from 'react-native'
import App from '@app/App' import App from '@app/App'
import { name as appName } from '@app/app.json' 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", "name": "subtracks",
"version": "1.3.0", "version": "1.2.0",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
@@ -31,21 +31,18 @@
"@xmldom/xmldom": "^0.7.0", "@xmldom/xmldom": "^0.7.0",
"content-disposition": "^0.5.4", "content-disposition": "^0.5.4",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"i18next": "^21.6.16",
"immer": "^9.0.6", "immer": "^9.0.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"md5": "^2.3.0", "md5": "^2.3.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"path": "^0.12.7", "path": "^0.12.7",
"react": "17.0.2", "react": "17.0.2",
"react-i18next": "^11.16.6",
"react-native": "0.67.4", "react-native": "0.67.4",
"react-native-blob-util": "https://github.com/austinried/react-native-blob-util.git#android-downloadmanager-progress", "react-native-blob-util": "https://github.com/austinried/react-native-blob-util.git#android-downloadmanager-progress",
"react-native-fs": "^2.18.0", "react-native-fs": "^2.18.0",
"react-native-gesture-handler": "^2.3.2", "react-native-gesture-handler": "^2.3.2",
"react-native-image-colors": "^1.3.0", "react-native-image-colors": "^1.3.0",
"react-native-linear-gradient": "^2.5.6", "react-native-linear-gradient": "^2.5.6",
"react-native-localize": "^2.2.1",
"react-native-popup-menu": "^0.15.11", "react-native-popup-menu": "^0.15.11",
"react-native-reanimated": "^2.3.1", "react-native-reanimated": "^2.3.1",
"react-native-safe-area-context": "^3.2.0", "react-native-safe-area-context": "^3.2.0",
+4 -44
View File
@@ -719,13 +719,6 @@
dependencies: dependencies:
regenerator-runtime "^0.13.4" 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": "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2":
version "7.17.8" version "7.17.8"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.8.tgz#3e56e4aff81befa55ac3ac6a0967349fd1c5bca2" 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== integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
async@^2.4.0: async@^2.4.0:
version "2.6.4" version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA== integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
dependencies: dependencies:
lodash "^4.17.14" lodash "^4.17.14"
@@ -3621,18 +3614,11 @@ html-encoding-sniffer@^2.0.1:
dependencies: dependencies:
whatwg-encoding "^1.0.5" whatwg-encoding "^1.0.5"
html-escaper@^2.0.0, html-escaper@^2.0.2: html-escaper@^2.0.0:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== 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: http-errors@1.8.1:
version "1.8.1" version "1.8.1"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" 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" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== 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: iconv-lite@0.4.24:
version "0.4.24" version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" 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" resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.0.tgz#b21c65fe1783743007c8c9a2952b1c8879a77354"
integrity sha512-yQaiOqDmoKqks56LN9MTgY06O0qQHgV4FUrikH357DydArSZHQhl0BJFqGKIZoTqi8JizF9Dxhuk1FIZD6qCaw== 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: "react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1:
version "17.0.2" version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" 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" 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== 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: react-native-popup-menu@^0.15.11:
version "0.15.12" version "0.15.12"
resolved "https://registry.yarnpkg.com/react-native-popup-menu/-/react-native-popup-menu-0.15.12.tgz#386852f4245f8d661a5003776989b9b55c9ce381" 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" resolved "https://registry.yarnpkg.com/vlq/-/vlq-1.0.1.tgz#c003f6e7c0b4c1edd623fd6ee50bbc0d6a1de468"
integrity sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w== 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: w3c-hr-time@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"