41 Commits

Author SHA1 Message Date
austinried
2bf3e8853d 1.3.0 changelog 2022-04-26 13:19:45 +09:00
austinried
e14099472a bump version 1.3.0 2022-04-26 13:17:01 +09:00
austinried
ac06e21f37 Update release.yml 2022-04-26 12:53:43 +09:00
austinried
92e2fd93f9 Create release.yml 2022-04-26 12:49:50 +09:00
jazzyjabroni
a5ccba69ec Translated using Weblate (Danish)
Currently translated at 59.7% (40 of 67 strings)

Added translation using Weblate (Danish)

Co-authored-by: jazzyjabroni <lordcarmack@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/da/
Translation: Subtracks/subtracks
2022-04-26 11:59:34 +09:00
Sargon-Isa
23eb05a368 Translated using Weblate (German)
Currently translated at 100.0% (67 of 67 strings)

Co-authored-by: Sargon-Isa <Sargon_isa@hotmail.de>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/de/
Translation: Subtracks/subtracks
2022-04-26 11:59:34 +09:00
austinried
1f9ee9b462 Merge remote-tracking branch 'weblate/main' into main 2022-04-21 15:01:28 +09:00
Weblate (bot)
237b8d2fc6 Translations update from Hosted Weblate (#112)
* Added translation using Weblate (Chinese (Simplified))

* Translated using Weblate (Chinese (Simplified))

Currently translated at 86.5% (58 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (67 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/zh_Hans/

Co-authored-by: Hillwah <curvycode@gmail.com>
2022-04-21 15:00:36 +09:00
Hillwah
35eada710e Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (67 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/zh_Hans/
2022-04-21 07:58:39 +02:00
Hillwah
86ef5af6f6 Translated using Weblate (Chinese (Simplified))
Currently translated at 86.5% (58 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/zh_Hans/
2022-04-21 07:58:39 +02:00
Hillwah
76b290c8b3 Added translation using Weblate (Chinese (Simplified)) 2022-04-21 07:58:39 +02:00
austinried
a92ad7bfc9 Bugfix/large playlist crash (#111)
* get all song coverArt as they are rendered

doing it all up front was too heavy
temporarily disabled mapping artwork in setQueue, need to fix this

* use cache data for track artwork when available

* fix round art in context menu for songs

* set only the first artwork at play time

then set the rest in the playback service

* handle both cached images and fetching images

* remove commented code

* fix shuffle

fix first thumbnail not being updated on shuffle for now playing background
2022-04-21 14:58:35 +09:00
austinried
1944add558 disable/unmount tabs while clearing image cache 2022-04-21 12:32:01 +09:00
austinried
05e4b46469 fix context menu i18n & view actions 2022-04-21 11:16:28 +09:00
austinried
00652952d8 remove CHECK_LICENSE permission
seems to have been added by react-native-blob-util
2022-04-19 12:55:47 +09:00
retiolus
83864217f9 Translated using Weblate (Catalan)
Currently translated at 89.5% (60 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/ca/
2022-04-19 10:30:08 +09:00
retiolus
b3ab75699e Added translation using Weblate (Catalan) 2022-04-19 10:30:08 +09:00
Saverio Napolitano
5cde911113 Translated using Weblate (Italian)
Currently translated at 100.0% (67 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/it/
2022-04-19 10:30:08 +09:00
Saverio Napolitano
7fda50857f Added translation using Weblate (Italian) 2022-04-19 10:30:08 +09:00
austinried
4ab51ea11a Update issue templates 2022-04-19 10:02:51 +09:00
Austin Riedhammer
fcd5c1b167 Translated using Weblate (Japanese)
Currently translated at 28.3% (19 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/ja/
2022-04-17 15:06:51 +09:00
austinried
2ccb397164 Merge branch 'main' of github.com:austinried/subtracks into main 2022-04-16 20:20:55 +09:00
Weblate (bot)
4e3a3133d7 Translations update from Hosted Weblate (#103)
* Translated using Weblate (German)

Currently translated at 97.0% (65 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/de/

* Translated using Weblate (German)

Currently translated at 100.0% (67 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/de/

Co-authored-by: Sargon <Sargon_isa@hotmail.de>
2022-04-16 20:16:59 +09:00
Sargon
2edd3a73fd Translated using Weblate (German)
Currently translated at 100.0% (67 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/de/
2022-04-16 11:38:38 +02:00
Sargon
4855043cda Translated using Weblate (German)
Currently translated at 97.0% (65 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/de/
2022-04-16 11:38:38 +02:00
austinried
e6e997e4b5 fix plurals falling back to en when no count 2022-04-16 18:38:22 +09:00
austinried
c78fc65279 fix empty strings in nb-NO
(i don't know how to pluralize these though, just placeholders)
2022-04-16 18:06:59 +09:00
austinried
52e95dc959 don't use i18n namespaces
there's no need to keep reloading different parts of the object we already cached
2022-04-16 18:06:06 +09:00
austinried
b8948fb646 fix norwegian language not being selected
use BCP langauge codes for file names
2022-04-16 17:41:36 +09:00
austinried
a91ac29626 Merge remote-tracking branch 'weblate/main' into main 2022-04-16 17:16:19 +09:00
Weblate (bot)
aca677a432 Translations update from Hosted Weblate (#102)
* Added translation using Weblate (German)

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 98.5% (66 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/nb_NO/

* Translated using Weblate (French)

Currently translated at 100.0% (67 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (Russian)

Currently translated at 95.5% (64 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/ru/

* Translated using Weblate (German)

Currently translated at 94.0% (63 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/de/

Co-authored-by: Sargon <Sargon_isa@hotmail.de>
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Clyhtsuriva <aimeric@adjutor.xyz>
Co-authored-by: Nikita Epifanov <nikgreens@protonmail.com>
2022-04-16 17:10:23 +09:00
Sargon
708a404a21 Translated using Weblate (German)
Currently translated at 94.0% (63 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/de/
2022-04-16 10:01:49 +02:00
Nikita Epifanov
7fa861d609 Translated using Weblate (Russian)
Currently translated at 95.5% (64 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/ru/
2022-04-16 10:01:49 +02:00
Clyhtsuriva
5a201c783f Translated using Weblate (French)
Currently translated at 100.0% (67 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/
2022-04-16 10:01:49 +02:00
Allan Nordhøy
f98ed31475 Translated using Weblate (Norwegian Bokmål)
Currently translated at 98.5% (66 of 67 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/nb_NO/
2022-04-16 10:01:49 +02:00
Sargon
6ebf1d265e Added translation using Weblate (German) 2022-04-16 10:01:49 +02:00
austinried
07c4d14adf don't require build for i18n file PRs 2022-04-16 17:01:39 +09:00
Weblate (bot)
6b1b4c2c4f Added translation using Weblate (Russian) (#101)
Co-authored-by: Nikita Epifanov <nikgreens@protonmail.com>
2022-04-15 22:49:15 +09:00
dependabot[bot]
658d134f64 Bump async from 2.6.3 to 2.6.4 (#100)
Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

---
updated-dependencies:
- dependency-name: async
  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-15 14:02:22 +09:00
austinried
a9dbcfb69d add edit server string i18n
set add/edit header title with i18n
fix albums plural in artist view
2022-04-15 12:55:11 +09:00
austinried
860a4cec16 Localization support (#99)
* basic i18n poc

* translate home, filters, tabs

support dot notation in backend for namespaces

* i18n context menu, artist filters, list controls

also nothings here
fix backend not caching fallback

* i18n queue, artist view, search/results

* i18n settings and server view

* Added translation using Weblate (Norwegian Bokmål)

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (6 of 6 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/nb_NO/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/

* fix url escaping

* added some mostly naive text overflow fixes

rewrote filter context menu as a slide in because the old one apparently can't handle dynamic width

* Added translation using Weblate (French)

* Translated using Weblate (French)

Currently translated at 17.4% (11 of 63 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (French)

Currently translated at 19.0% (12 of 63 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (French)

Currently translated at 40.0% (26 of 65 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* add weblate and some pretty badges to readme

* fix link

* Translated using Weblate (French)

Currently translated at 50.7% (33 of 65 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (English)

Currently translated at 100.0% (65 of 65 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/en/

* Translated using Weblate (French)

Currently translated at 90.7% (59 of 65 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* i18n now playing context type

fix overscroll on new filter menu
fix getting default namespace from the i18n backend

* Translated using Weblate (French)

Currently translated at 96.9% (63 of 65 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (66 of 66 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (Japanese) (#98)

Currently translated at 7.5% (5 of 66 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/ja/

Co-authored-by: Austin Riedhammer <austinried@functionkey.xyz>

* little note to remind me why that's there

* update licenses

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Clyhtsuriva <aimeric@adjutor.xyz>
2022-04-15 12:11:00 +09:00
61 changed files with 2847 additions and 667 deletions

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
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.

View File

@@ -0,0 +1,20 @@
---
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
.github/release.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
changelog:
exclude:
labels:
- ignore-for-release
- dependencies
authors:
- weblate
categories:
- title: New
labels:
- enhancement
- title: Fixed
labels:
- bug
- title: Other Changes
labels:
- "*"

View File

@@ -17,6 +17,7 @@ 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

View File

@@ -3,6 +3,8 @@
# #
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"/>
@@ -45,3 +47,10 @@ 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>

View File

@@ -134,8 +134,8 @@ android {
applicationId "com.subtracks" applicationId "com.subtracks"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 8 versionCode 9
versionName '1.2.0' versionName '1.3.0'
} }
splits { splits {
abi { abi {

View File

@@ -2,6 +2,7 @@
<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>

View File

@@ -0,0 +1,152 @@
{
"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 dusuari",
"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}}"
}
}

View File

@@ -0,0 +1,92 @@
{
"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"
}
}
}
}

View File

@@ -0,0 +1,152 @@
{
"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…"
}
}

View File

@@ -0,0 +1,152 @@
{
"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"
}
}
}
}

View File

@@ -0,0 +1,152 @@
{
"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…"
}
}

View File

@@ -0,0 +1,152 @@
{
"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"
}
}

View File

@@ -0,0 +1,55 @@
{
"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": "ホームページ"
}
}
}
}

View File

@@ -0,0 +1,152 @@
{
"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 …"
}
}

View File

@@ -0,0 +1,157 @@
{
"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": "Результаты поиска"
}
}

View File

@@ -0,0 +1,147 @@
{
"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": "什么都没有…"
}
}

View File

@@ -27165,6 +27165,30 @@ 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)
@@ -27192,6 +27216,32 @@ 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.
@@ -29386,6 +29436,32 @@ 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
@@ -29542,6 +29618,32 @@ 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
@@ -31559,6 +31661,33 @@ 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

View File

@@ -3863,6 +3863,30 @@ 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)
@@ -3890,6 +3914,32 @@ 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.
@@ -6084,6 +6134,32 @@ 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
@@ -6240,6 +6316,32 @@ 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
@@ -8257,6 +8359,33 @@ 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

View File

@@ -14,6 +14,9 @@ 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);

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, View, StyleSheet } from 'react-native' import { StatusBar, StyleSheet, View } 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)

View File

@@ -16,7 +16,13 @@ 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 ? <Text style={styles.text}>{title}</Text> : children} {title ? (
<Text style={styles.text} numberOfLines={2} adjustsFontSizeToFit={true}>
{title}
</Text>
) : (
children
)}
</PressableOpacity> </PressableOpacity>
) )
} }
@@ -26,6 +32,7 @@ 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,
}, },
@@ -43,6 +50,7 @@ const styles = StyleSheet.create({
fontFamily: font.bold, fontFamily: font.bold,
color: colors.text.primary, color: colors.text.primary,
paddingHorizontal: 14, paddingHorizontal: 14,
textAlign: 'center',
}, },
}) })

View File

@@ -6,12 +6,14 @@ 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
@@ -106,7 +108,9 @@ 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}>{text}</Text> <Text style={styles.optionText} numberOfLines={1} adjustsFontSizeToFit={true} minimumFontScale={0.6}>
{text}
</Text>
</ContextMenuOption> </ContextMenuOption>
) )
}, },
@@ -115,11 +119,13 @@ 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, title, subtitle }) => ( }>(({ coverArt, artistId, albumId, title, subtitle }) => {
<View style={styles.menuHeader}> let CoverArtComponent = <></>
{artistId ? ( if (artistId) {
CoverArtComponent = (
<CoverArt <CoverArt
type="artist" type="artist"
artistId={artistId} artistId={artistId}
@@ -129,7 +135,20 @@ 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}
@@ -138,43 +157,52 @@ const MenuHeader = React.memo<{
size="thumbnail" size="thumbnail"
fadeDuration={0} fadeDuration={0}
/> />
)} )
<View style={styles.menuHeaderText}> }
<Text numberOfLines={1} style={styles.menuTitle}>
{title}
</Text>
{subtitle ? (
<Text numberOfLines={1} style={styles.menuSubtitle}>
{subtitle}
</Text>
) : (
<></>
)}
</View>
</View>
))
const OptionStar = React.memo<{ return (
<View style={styles.menuHeader}>
{CoverArtComponent}
<View style={styles.menuHeaderText}>
<Text numberOfLines={1} style={styles.menuTitle}>
{title}
</Text>
{subtitle ? (
<Text numberOfLines={1} style={styles.menuSubtitle}>
{subtitle}
</Text>
) : (
<></>
)}
</View>
</View>
)
})
const OptionStar = withSuspenseMemo<{
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 ? 'Unstar' : 'Star') + (text ? ` ${text}` : '')} text={(query.data ? t('context.actions.unstar') : t('context.actions.star')) + (text ? ` ${text}` : '')}
onSelect={() => toggle.mutate()} onSelect={() => toggle.mutate()}
/> />
) )
}) })
const OptionViewArtist = React.memo<{ const OptionViewArtist = withSuspenseMemo<{
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 <></>
} }
@@ -184,17 +212,19 @@ const OptionViewArtist = React.memo<{
IconComponent={IconFA} IconComponent={IconFA}
name="microphone" name="microphone"
size={26} size={26}
text="View Artist" text={t('resources.artist.actions.view')}
onSelect={() => navigation.navigate('artist', { id: artistId, title: artist })} onSelect={() => navigation.navigate('artist', { id: artistId, title: artist })}
/> />
) )
}) })
const OptionViewAlbum = React.memo<{ const OptionViewAlbum = withSuspenseMemo<{
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 <></>
} }
@@ -204,7 +234,7 @@ const OptionViewAlbum = React.memo<{
IconComponent={IconFA5} IconComponent={IconFA5}
name="compact-disc" name="compact-disc"
size={26} size={26}
text="View Album" text={t('resources.album.actions.view')}
onSelect={() => navigation.navigate('album', { id: albumId, title: album })} onSelect={() => navigation.navigate('album', { id: albumId, title: album })}
/> />
) )
@@ -251,7 +281,7 @@ export const SongContextPressable: React.FC<SongContextPressableProps> = props =
return ( return (
<ContextMenu <ContextMenu
{...props} {...props}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} coverArt={song.coverArt} />} menuHeader={<MenuHeader title={song.title} subtitle={song.artist} albumId={song.albumId} />}
menuOptions={ menuOptions={
<> <>
<OptionStar id={song.id} type={song.itemType} /> <OptionStar id={song.id} type={song.itemType} />
@@ -298,7 +328,7 @@ export const NowPlayingContextPressable: React.FC<NowPlayingContextPressableProp
return ( return (
<ContextMenu <ContextMenu
{...props} {...props}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} coverArt={song.coverArt} />} menuHeader={<MenuHeader title={song.title} subtitle={song.artist} albumId={song.albumId} />}
menuOptions={ menuOptions={
<> <>
<OptionStar id={song.id} type={song.itemType} /> <OptionStar id={song.id} type={song.itemType} />
@@ -318,6 +348,8 @@ const styles = StyleSheet.create({
}, },
optionsWrapper: { optionsWrapper: {
// marginBottom: 10, // marginBottom: 10,
paddingHorizontal: 20,
// backgroundColor: 'purple',
}, },
menuHeader: { menuHeader: {
paddingTop: 14, paddingTop: 14,
@@ -348,9 +380,11 @@ const styles = StyleSheet.create({
}, },
option: { option: {
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 20, // paddingHorizontal: 100,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
// backgroundColor: 'blue',
overflow: 'hidden',
}, },
icon: { icon: {
marginRight: 10, marginRight: 10,

View File

@@ -1,4 +1,4 @@
import { useQueryArtistArtPath, useQueryCoverArtPath } from '@app/hooks/query' import { useQueryAlbumCoverArtPath, 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,6 +32,11 @@ 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
@@ -82,7 +87,13 @@ 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 CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps>(props => { const AlbumIdIamge = React.memo<AlbumIdProps>(props => {
const { data, isFetching, isExistingFetching } = useQueryAlbumCoverArtPath(props.albumId, props.size)
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
})
const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps | AlbumIdProps>(props => {
const viewStyles = [props.style] const viewStyles = [props.style]
if (props.round) { if (props.round) {
viewStyles.push(styles.round) viewStyles.push(styles.round)
@@ -93,6 +104,9 @@ const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps>(props => {
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

View File

@@ -1,10 +1,13 @@
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 } from 'react-native' import { Text, StyleSheet, View } from 'react-native'
import { MenuOption, Menu, MenuTrigger, MenuOptions } from 'react-native-popup-menu' import { MenuOption, Menu, MenuTrigger, MenuOptions, renderers } 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
@@ -17,12 +20,14 @@ 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={26} color={colors.accent} /> <Icon name="checkbox-marked-circle" size={32} color={colors.accent} style={styles.icon} />
) : ( ) : (
<Icon name="checkbox-blank-circle-outline" size={26} color={colors.text.secondary} /> <Icon name="checkbox-blank-circle-outline" size={32} color={colors.text.secondary} style={styles.icon} />
)} )}
<Text style={styles.optionText} numberOfLines={1} adjustsFontSizeToFit={true} minimumFontScale={0.6}>
{text}
</Text>
</MenuOption> </MenuOption>
)) ))
@@ -30,9 +35,10 @@ const FilterButton = React.memo<{
value?: string value?: string
data: OptionData[] data: OptionData[]
onSelect?: (selection: string) => void onSelect?: (selection: string) => void
}>(({ value, data, onSelect }) => { title: string
}>(({ value, data, onSelect, title }) => {
return ( return (
<Menu onSelect={onSelect}> <Menu onSelect={onSelect} renderer={SlideInMenu}>
<MenuTrigger <MenuTrigger
customStyles={{ customStyles={{
triggerOuterWrapper: styles.filterOuterWrapper, triggerOuterWrapper: styles.filterOuterWrapper,
@@ -40,16 +46,23 @@ const FilterButton = React.memo<{
triggerTouchable: { style: styles.filter }, triggerTouchable: { style: styles.filter },
TriggerTouchableComponent: PressableOpacity, TriggerTouchableComponent: PressableOpacity,
}}> }}>
<Icon name="filter-variant" color="white" size={30} style={styles.filterIcon} /> <Icon name="filter-variant" color="white" size={30} />
</MenuTrigger> </MenuTrigger>
<MenuOptions <MenuOptions
customStyles={{ customStyles={{
optionsWrapper: styles.optionsWrapper, optionsWrapper: styles.optionsWrapper,
optionsContainer: styles.optionsContainer, optionsContainer: styles.optionsContainer,
}}> }}>
{data.map(o => ( <ScrollView style={styles.optionsScroll} overScrollMode="never">
<Option key={o.value} text={o.text} value={o.value} selected={o.value === value} /> <View style={styles.header}>
))} <Text style={styles.headerText} numberOfLines={2} ellipsizeMode="clip">
{title}
</Text>
</View>
{data.map(o => (
<Option key={o.value} text={o.text} value={o.value} selected={o.value === value} />
))}
</ScrollView>
</MenuOptions> </MenuOptions>
</Menu> </Menu>
) )
@@ -71,28 +84,45 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.accent, backgroundColor: colors.accent,
}, },
filterIcon: { optionsScroll: {
// top: 4, maxHeight: 260,
}, },
optionsWrapper: { optionsWrapper: {
maxWidth: 145, overflow: 'hidden',
}, },
optionsContainer: { optionsContainer: {
backgroundColor: colors.gradient.high, backgroundColor: 'rgba(45, 45, 45, 0.95)',
maxWidth: 145, },
header: {
paddingHorizontal: 20,
// paddingVertical: 10,
marginTop: 16,
marginBottom: 6,
},
headerText: {
fontFamily: font.bold,
fontSize: 20,
color: colors.text.primary,
}, },
option: { option: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 8, paddingVertical: 8,
justifyContent: 'center', paddingHorizontal: 20,
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-start',
}, },
optionText: { optionText: {
color: colors.text.primary,
fontFamily: font.semiBold, fontFamily: font.semiBold,
fontSize: 16, fontSize: 16,
flex: 1, color: colors.text.primary,
},
icon: {
marginRight: 14,
width: 32,
height: 32,
justifyContent: 'center',
alignItems: 'center',
// backgroundColor: 'red',
}, },
}) })

View File

@@ -160,6 +160,10 @@ 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" />

View File

@@ -2,19 +2,22 @@ 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 = React.memo<{ const ListPlayerControls = withSuspenseMemo<{
songs: Song[] songs: Song[]
typeName: string listType: 'album' | 'playlist'
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
play: () => void play: () => void
shuffle: () => void shuffle: () => void
disabled?: boolean disabled?: boolean
}>(({ typeName, style, play, shuffle, disabled }) => { }>(({ listType, 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]}>
@@ -31,7 +34,7 @@ const ListPlayerControls = React.memo<{
</Button> </Button>
</View> </View>
<View style={styles.controlsCenter}> <View style={styles.controlsCenter}>
<Button title={`Play ${typeName}`} disabled={disabled} onPress={play} /> <Button title={t(`resources.${listType}.actions.play`)} disabled={disabled} onPress={play} />
</View> </View>
<View style={styles.controlsSide}> <View style={styles.controlsSide}>
<Button disabled={disabled} onPress={shuffle}> <Button disabled={disabled} onPress={shuffle}>
@@ -55,6 +58,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
maxWidth: '65%',
}, },
}) })

View File

@@ -1,20 +1,25 @@
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 = React.memo<{ const NothingHere = withSuspenseMemo<{
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 }]}>Nothing here...</Text> <Text style={[styles.text, { fontSize: width / 8 }]} numberOfLines={3}>
{t('messages.nothingHere')}
</Text>
</View> </View>
) )
}) })

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 coverArt = useStore(store => store.currentTrack?.coverArt) const albumId = useStore(store => store.currentTrack?.albumId)
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="cover" type="album"
style={{ height: styles.subContainer.height, width: styles.subContainer.height }} style={{ height: styles.subContainer.height, width: styles.subContainer.height }}
coverArt={coverArt} albumId={albumId}
size="thumbnail" size="thumbnail"
fadeDuration={0} fadeDuration={0}
/> />

View File

@@ -0,0 +1,32 @@
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)
}

View File

@@ -1,8 +1,9 @@
import { CacheItemTypeKey } from '@app/models/cache' import { CacheItemTypeKey } from '@app/models/cache'
import { Album, AlbumCoverArt, Playlist, Song } from '@app/models/library' import { Album, 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'
@@ -31,7 +32,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<AlbumCoverArt>(qk.albumCoverArt(item.id), { albumId: item.id, coverArt: item.coverArt }) queryClient.setQueryData<string | undefined>(qk.albumCoverArt(item.id), item.coverArt)
} }
export const useFetchArtists = () => { export const useFetchArtists = () => {
@@ -109,22 +110,23 @@ export const useFetchPlaylist = () => {
} }
} }
export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise<{ album: Album; songs?: Song[] }> {
const res = await client.getAlbum({ id })
cacheStarredData(res.data.album)
res.data.songs.forEach(cacheStarredData)
cacheAlbumCoverArtData(res.data.album)
return {
album: mapAlbum(res.data.album),
songs: res.data.songs.map(mapSong),
}
}
export const useFetchAlbum = () => { export const useFetchAlbum = () => {
const client = useClient() const client = useClient()
return async (id: string) => fetchAlbum(id, client())
return async (id: string): Promise<{ album: Album; songs?: Song[] }> => {
const res = await client().getAlbum({ id })
cacheStarredData(res.data.album)
res.data.songs.forEach(cacheStarredData)
cacheAlbumCoverArtData(res.data.album)
return {
album: mapAlbum(res.data.album),
songs: res.data.songs.map(mapSong),
}
}
} }
export const useFetchAlbumList = () => { export const useFetchAlbumList = () => {
@@ -196,17 +198,23 @@ export type FetchExisingFileOptions = {
itemId: string itemId: string
} }
export const useFetchExistingFile: () => (options: FetchExisingFileOptions) => Promise<string | undefined> = () => { export async function fetchExistingFile(
const serverId = useStore(store => store.settings.activeServerId) options: FetchExisingFileOptions,
serverId: string | undefined,
): Promise<string | undefined> {
const { itemType, itemId } = options
const fileDir = cacheDir(serverId, itemType, itemId)
return async ({ itemType, itemId }) => { try {
const fileDir = cacheDir(serverId, itemType, itemId) const dir = await RNFS.readDir(fileDir)
try { console.log('existing file:', dir[0].path)
const dir = await RNFS.readDir(fileDir) return dir[0].path
console.log('existing file:', dir[0].path) } catch {}
return dir[0].path }
} catch {}
} export const useFetchExistingFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchExisingFileOptions) => fetchExistingFile(options, serverId)
} }
function assertMimeType(expected?: string, actual?: string) { function assertMimeType(expected?: string, actual?: string) {
@@ -237,69 +245,71 @@ export type FetchFileOptions = FetchExisingFileOptions & {
progress?: (received: number, total: number) => void progress?: (received: number, total: number) => void
} }
export const useFetchFile: () => (options: FetchFileOptions) => Promise<string> = () => { export async function fetchFile(options: FetchFileOptions, serverId: string | undefined): Promise<string> {
const serverId = useStore(store => store.settings.activeServerId) let { itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress } = options
useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster
return async ({ itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress }) => { const fileDir = cacheDir(serverId, itemType, itemId)
useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType)
const fileDir = cacheDir(serverId, itemType, itemId) try {
const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType) await RNFS.unlink(fileDir)
} catch {}
try { const headers = { 'User-Agent': userAgent }
await RNFS.unlink(fileDir)
} catch {}
const headers = { 'User-Agent': userAgent } // we send a HEAD first for two reasons:
// 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects)
// 2. to obtain the mime-type up front so we can use it for the file extension/validation
const headRes = await fetch(fromUrl, { method: 'HEAD', headers })
// we send a HEAD first for two reasons: if (headRes.status > 399) {
// 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects) throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`)
// 2. to obtain the mime-type up front so we can use it for the file extension/validation
const headRes = await fetch(fromUrl, { method: 'HEAD', headers })
if (headRes.status > 399) {
throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`)
}
const contentType = headRes.headers.get('content-type') || undefined
assertMimeType(expectedContentType, contentType)
const contentDisposition = headRes.headers.get('content-disposition') || undefined
const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined
let extension: string | undefined
if (filename) {
extension = path.extname(filename) || undefined
if (extension) {
extension = extension.substring(1)
}
} else if (contentType) {
extension = mime.extension(contentType) || undefined
}
const config = ReactNativeBlobUtil.config({
addAndroidDownloads: {
useDownloadManager: true,
notification: false,
mime: contentType,
description: 'subtracks',
path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt,
},
})
const fetchParams: Parameters<typeof config['fetch']> = ['GET', headRes.url, headers]
let res: FetchBlobResponse
if (progress) {
res = await config.fetch(...fetchParams).progress(progress)
} else {
res = await config.fetch(...fetchParams)
}
const downloadPath = res.path()
queryClient.setQueryData<string>(qk.existingFiles(itemType, itemId), downloadPath)
console.log('downloaded file:', downloadPath)
return downloadPath
} }
const contentType = headRes.headers.get('content-type') || undefined
assertMimeType(expectedContentType, contentType)
const contentDisposition = headRes.headers.get('content-disposition') || undefined
const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined
let extension: string | undefined
if (filename) {
extension = path.extname(filename) || undefined
if (extension) {
extension = extension.substring(1)
}
} else if (contentType) {
extension = mime.extension(contentType) || undefined
}
const config = ReactNativeBlobUtil.config({
addAndroidDownloads: {
useDownloadManager: true,
notification: false,
mime: contentType,
description: 'subtracks',
path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt,
},
})
const fetchParams: Parameters<typeof config['fetch']> = ['GET', headRes.url, headers]
let res: FetchBlobResponse
if (progress) {
res = await config.fetch(...fetchParams).progress(progress)
} else {
res = await config.fetch(...fetchParams)
}
const downloadPath = res.path()
queryClient.setQueryData<string>(qk.existingFiles(itemType, itemId), downloadPath)
console.log('downloaded file:', downloadPath)
return downloadPath
}
export const useFetchFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchFileOptions) => fetchFile(options, serverId)
} }

View File

@@ -1,19 +1,11 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache' import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { Album, AlbumCoverArt, Artist, Playlist, Song, StarrableItemType } from '@app/models/library' import { Album, 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 { import { useInfiniteQuery, useMutation, useQueries, useQuery } from 'react-query'
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
useQueries,
useQuery,
UseQueryResult,
} from 'react-query'
import { import {
useFetchAlbum, useFetchAlbum,
useFetchAlbumList, useFetchAlbumList,
@@ -88,7 +80,7 @@ export const useQueryArtistTopSongs = (artistName?: string) => {
}, },
) )
return useFixCoverArt(querySuccess ? query : backupQuery) return querySuccess ? query : backupQuery
} }
export const useQueryPlaylists = () => useQuery(qk.playlists, useFetchPlaylists()) export const useQueryPlaylists = () => useQuery(qk.playlists, useFetchPlaylists())
@@ -109,7 +101,7 @@ export const useQueryPlaylist = (id: string, placeholderPlaylist?: Playlist) =>
}, },
}) })
return useFixCoverArt(query) return query
} }
export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => { export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
@@ -120,7 +112,7 @@ export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
placeholderAlbum ? { album: placeholderAlbum } : undefined, placeholderAlbum ? { album: placeholderAlbum } : undefined,
}) })
return useFixCoverArt(query) return query
} }
export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => { export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => {
@@ -172,7 +164,7 @@ export const useQuerySearchResults = (params: Search3Params) => {
}, },
) )
return useFixCoverArt(query) return query
} }
export const useQueryHomeLists = (types: GetAlbumList2TypeBase[], size: number) => { export const useQueryHomeLists = (types: GetAlbumList2TypeBase[], size: number) => {
@@ -314,93 +306,18 @@ 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 }
} }
type WithSongs = Song[] | { songs?: Song[] } export const useQueryAlbumCoverArtPath = (albumId?: string, size: CacheImageSize = 'thumbnail') => {
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 songs = getSongs(query.data) const query = useQuery(
const albumIds = _.uniq((songs || []).map(s => s.albumId).filter((id): id is string => id !== undefined)) qk.albumCoverArt(albumId || '-1'),
async () => (await fetchAlbum(albumId || '-1')).album.coverArt,
const coverArts = useQueries( {
albumIds.map(id => ({ enabled: !!albumId,
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, },
})),
) )
if (coverArts.every(c => c.isFetched)) { return useQueryCoverArtPath(query.data, size)
return setSongCoverArt(query, coverArts)
}
return query
} }

View File

@@ -30,39 +30,46 @@ 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 () => {
// disable/invalidate queries setDisableMusicTabs(true)
await Promise.all([
queryClient.cancelQueries(qk.artistArt(), { active: true }),
queryClient.cancelQueries(qk.coverArt(), { active: true }),
queryClient.cancelQueries(qk.existingFiles(), { active: true }),
queryClient.invalidateQueries(qk.artistArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.coverArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.existingFiles(), { refetchActive: false }),
])
// delete all images try {
const itemTypes: CacheItemTypeKey[] = ['artistArt', 'artistArtThumb', 'coverArt', 'coverArtThumb'] // disable/invalidate queries
await Promise.all( await Promise.all([
serverIds.flatMap(id => queryClient.cancelQueries(qk.artistArt(), { active: true }),
itemTypes.map(async type => { queryClient.cancelQueries(qk.coverArt(), { active: true }),
const dir = cacheDir(id, type) queryClient.cancelQueries(qk.existingFiles(), { active: true }),
try { queryClient.invalidateQueries(qk.artistArt(), { refetchActive: false }),
await RNFS.unlink(dir) queryClient.invalidateQueries(qk.coverArt(), { refetchActive: false }),
} catch {} queryClient.invalidateQueries(qk.existingFiles(), { refetchActive: false }),
}), ])
),
)
// change cacheBuster // delete all images
changeCacheBuster() const itemTypes: CacheItemTypeKey[] = ['artistArt', 'artistArtThumb', 'coverArt', 'coverArtThumb']
await Promise.all(
serverIds.flatMap(id =>
itemTypes.map(async type => {
const dir = cacheDir(id, type)
try {
await RNFS.unlink(dir)
} catch {}
}),
),
)
// enable queries // change cacheBuster
await Promise.all([ changeCacheBuster()
queryClient.refetchQueries(qk.existingFiles(), { active: true }), } finally {
queryClient.refetchQueries(qk.artistArt(), { active: true }), setDisableMusicTabs(false)
queryClient.refetchQueries(qk.coverArt(), { active: true }),
]) // enable queries
await Promise.all([
queryClient.refetchQueries(qk.existingFiles(), { active: true }),
queryClient.refetchQueries(qk.artistArt(), { active: true }),
queryClient.refetchQueries(qk.coverArt(), { active: true }),
])
}
} }
} }

View File

@@ -1,13 +1,11 @@
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 = () => {
@@ -92,87 +90,50 @@ 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) => {
const queue = (songs || []).map(mapSongToTrackExt) if (!songs || songs.length === 0) {
return await _setQueue({ queue, type, contextId, ...options }) return
}
const queue = songs.map(mapSongToTrackExt)
const first = queue[options.playTrack || 0]
if (!first.albumId) {
first.artwork = require('@res/fallback.png')
} else {
const albumCoverArt = queryClient.getQueryData<string>(qk.albumCoverArt(first.albumId))
const existingFile = queryClient.getQueryData<string>(qk.existingFiles('coverArtThumb', albumCoverArt))
const downloadFile = queryClient.getQueryData<string>(qk.coverArt(albumCoverArt, 'thumbnail'))
if (existingFile || downloadFile) {
first.artwork = `file://${existingFile || downloadFile}`
}
}
await _setQueue({ queue, type, contextId, ...options })
queueService.emit('set', { queue })
} }
return { setQueue, contextId, isReady: coverArtPaths.every(c => c.isFetched) } return { setQueue, contextId }
} }

64
app/i18n.ts Normal file
View File

@@ -0,0 +1,64 @@
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

View File

@@ -43,7 +43,6 @@ 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

View File

@@ -75,7 +75,6 @@ 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,

View File

@@ -18,10 +18,11 @@ const BottomTabButton = React.memo<{
isFocused: boolean isFocused: boolean
icon: OutlineFillIcon icon: OutlineFillIcon
navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap> navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap>
}>(({ routeKey, label, name, isFocused, icon, navigation }) => { disabled?: boolean
}>(({ routeKey, label, name, isFocused, icon, navigation, disabled }) => {
const firstRun = useFirstRun() const firstRun = useFirstRun()
const disabled = firstRun && name !== 'settings' disabled = !!disabled || (firstRun && name !== 'settings')
const onPress = () => { const onPress = () => {
const event = navigation.emit({ const event = navigation.emit({
@@ -47,7 +48,9 @@ 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}>{label}</Text> <Text style={textStyle} numberOfLines={1} ellipsizeMode="clip">
{label}
</Text>
</PressableOpacity> </PressableOpacity>
) )
}) })
@@ -65,6 +68,13 @@ 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}
@@ -72,8 +82,9 @@ 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[route.name]} icon={bottomTabIcons[iconKey]}
navigation={navigation} navigation={navigation}
disabled={disabled}
/> />
) )
})} })}
@@ -92,6 +103,7 @@ const styles = StyleSheet.create({
}, },
button: { button: {
alignItems: 'center', alignItems: 'center',
flexGrow: 1,
flex: 1, flex: 1,
height: '100%', height: '100%',
}, },

View File

@@ -1,3 +1,4 @@
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'
@@ -16,6 +17,7 @@ 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'
@@ -115,8 +117,8 @@ const SearchTab = createTabStackNavigator(Search)
type SettingsStackParamList = { type SettingsStackParamList = {
main: undefined main: undefined
server?: { id?: string } server?: { id?: string; title?: string }
web: { uri: string } web: { uri: string; title?: string }
} }
type ServerScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'server'> type ServerScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'server'>
@@ -125,7 +127,9 @@ type ServerScreenProps = {
route: ServerScreenRouteProp route: ServerScreenRouteProp
navigation: ServerScreenNavigationProp navigation: ServerScreenNavigationProp
} }
const ServerScreen: React.FC<ServerScreenProps> = ({ route }) => <ServerView id={route.params?.id} /> const ServerScreen: React.FC<ServerScreenProps> = ({ route }) => (
<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'>
@@ -133,7 +137,9 @@ type WebScreenProps = {
route: WebScreenRouteProp route: WebScreenRouteProp
navigation: WebScreenNavigationProp navigation: WebScreenNavigationProp
} }
const WebScreen: React.FC<WebScreenProps> = ({ route }) => <WebViewScreen uri={route.params.uri} /> const WebScreen: React.FC<WebScreenProps> = ({ route }) => (
<WebViewScreen uri={route.params.uri} title={route.params.title} />
)
const SettingsStack = createNativeStackNavigator() const SettingsStack = createNativeStackNavigator()
@@ -145,7 +151,6 @@ 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',
@@ -156,7 +161,6 @@ 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',
@@ -169,24 +173,37 @@ const SettingsTab = () => {
const Tab = createBottomTabNavigator() const Tab = createBottomTabNavigator()
const BottomTabNavigator = () => { const BottomTabNavigator = withSuspense(() => {
const { t } = useTranslation()
const firstRun = useFirstRun() const firstRun = useFirstRun()
const resetServer = useStore(store => store.resetServer) const disableMusicTabs = useStore(store => store.disableMusicTabs)
return ( return (
<Tab.Navigator tabBar={BottomTabBar} initialRouteName={firstRun ? 'settings' : 'home'}> <Tab.Navigator tabBar={BottomTabBar} initialRouteName={firstRun ? 'settings' : 'home'}>
{resetServer ? ( {disableMusicTabs ? (
<></> <>
<Tab.Screen name="home-disabled" children={() => null} options={{ tabBarLabel: t('navigation.tabs.home') }} />
<Tab.Screen
name="library-disabled"
children={() => null}
options={{ tabBarLabel: t('navigation.tabs.library') }}
/>
<Tab.Screen
name="search-disabled"
children={() => null}
options={{ tabBarLabel: t('navigation.tabs.search') }}
/>
</>
) : ( ) : (
<> <>
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: 'Home' }} /> <Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: t('navigation.tabs.home') }} />
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: 'Library' }} /> <Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: t('navigation.tabs.library') }} />
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: 'Search' }} /> <Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: t('navigation.tabs.search') }} />
</> </>
)} )}
<Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: 'Settings' }} /> <Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: t('navigation.tabs.settings') }} />
</Tab.Navigator> </Tab.Navigator>
) )
} })
export default BottomTabNavigator export default BottomTabNavigator

View File

@@ -1,3 +1,4 @@
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'
@@ -6,12 +7,14 @@ import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font' import font from '@app/styles/font'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs' import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import React from 'react' import React from 'react'
import { 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 = () => { const LibraryTopTabNavigator = withSuspense(() => {
const { t } = useTranslation()
const marginTop = useSafeAreaInsets().top const marginTop = useSafeAreaInsets().top
return ( return (
@@ -22,12 +25,24 @@ const LibraryTopTabNavigator = () => {
indicatorStyle: styles.tabindicatorStyle, indicatorStyle: styles.tabindicatorStyle,
}} }}
initialRouteName="albums"> initialRouteName="albums">
<Tab.Screen name="albums" component={AlbumsTab} options={{ tabBarLabel: 'Albums' }} /> <Tab.Screen
<Tab.Screen name="artists" component={ArtistsTab} options={{ tabBarLabel: 'Artists' }} /> name="albums"
<Tab.Screen name="playlists" component={PlaylistsTab} options={{ tabBarLabel: 'Playlists' }} /> component={AlbumsTab}
options={{ tabBarLabel: t('resources.album.name', { count: 2 }) }}
/>
<Tab.Screen
name="artists"
component={ArtistsTab}
options={{ tabBarLabel: t('resources.artist.name', { count: 2 }) }}
/>
<Tab.Screen
name="playlists"
component={PlaylistsTab}
options={{ tabBarLabel: t('resources.playlist.name', { count: 2 }) }}
/>
</Tab.Navigator> </Tab.Navigator>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
tabBar: { tabBar: {

View File

@@ -1,3 +1,4 @@
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'
@@ -5,32 +6,37 @@ 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 = () => ( const NowPlayingNavigator = withSuspense(() => {
<NowPlayingStack.Navigator> const { t } = useTranslation()
<NowPlayingStack.Screen name="main" component={NowPlayingView} options={{ headerShown: false }} />
<NowPlayingStack.Screen return (
name="queue" <NowPlayingStack.Navigator>
component={NowPlayingQueue} <NowPlayingStack.Screen name="main" component={NowPlayingView} options={{ headerShown: false }} />
options={{ <NowPlayingStack.Screen
title: 'Queue', name="queue"
headerStyle: { component={NowPlayingQueue}
backgroundColor: colors.gradient.high, options={{
}, title: t('resources.queue.name', { count: 1 }),
headerTitleStyle: { headerStyle: {
fontSize: 18, backgroundColor: colors.gradient.high,
fontFamily: font.semiBold, },
color: colors.text.primary, headerTitleStyle: {
}, fontSize: 18,
headerHideShadow: true, fontFamily: font.semiBold,
headerTintColor: 'white', color: colors.text.primary,
}} },
/> headerHideShadow: true,
</NowPlayingStack.Navigator> headerTintColor: 'white',
) }}
/>
</NowPlayingStack.Navigator>
)
})
const RootStack = createNativeStackNavigator() const RootStack = createNativeStackNavigator()

View File

@@ -1,8 +1,14 @@
import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer' import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer'
import TrackPlayer, { Event, State } from 'react-native-track-player'
import { useStore } from './state/store'
import { unstable_batchedUpdates } from 'react-native'
import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo' import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo'
import _ from 'lodash'
import { unstable_batchedUpdates } from 'react-native'
import TrackPlayer, { Event, State } from 'react-native-track-player'
import { fetchAlbum, FetchExisingFileOptions, fetchExistingFile, fetchFile, FetchFileOptions } from './hooks/fetch'
import qk from './hooks/queryKeys'
import queryClient from './queryClient'
import queueService from './queueservice'
import { useStore } from './state/store'
import { ReturnedPromiseResolvedType } from './util/types'
const reset = () => { const reset = () => {
unstable_batchedUpdates(() => { unstable_batchedUpdates(() => {
@@ -34,12 +40,81 @@ 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 () => {
@@ -142,6 +217,78 @@ 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
app/queueservice.ts Normal file
View File

@@ -0,0 +1,18 @@
/* 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

View File

@@ -5,6 +5,7 @@ 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'
@@ -15,6 +16,7 @@ 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'
@@ -42,53 +44,62 @@ const AlbumItem = React.memo<{
) )
}, equal) }, equal)
const TopSongs = React.memo<{ const TopSongs = withSuspenseMemo<{
songs: Song[] songs: Song[]
name: string name: string
}>(({ songs, name }) => { }>(
const { setQueue, isReady, contextId } = useSetQueue('artist', songs) ({ songs, name }) => {
const { setQueue, contextId } = useSetQueue('artist', songs)
const { t } = useTranslation()
return ( return (
<> <>
<Header>Top Songs</Header> <Header>{t('resources.song.lists.artistTopSongs')}</Header>
{songs.slice(0, 5).map((s, i) => ( {songs.slice(0, 5).map((s, i) => (
<ListItem <ListItem
key={i} key={i}
item={s} item={s}
contextId={contextId} contextId={contextId}
queueId={i} queueId={i}
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)
const ArtistAlbums = React.memo<{
albums: Album[]
}>(({ albums }) => {
const albumsLayout = useLayout()
const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
return (
<>
<Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))} ))}
</View> </>
</> )
) },
}, equal) null,
equal,
)
const ArtistAlbums = withSuspenseMemo<{
albums: Album[]
}>(
({ albums }) => {
const albumsLayout = useLayout()
const { t } = useTranslation()
const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
return (
<>
<Header>{t('resources.album.name', { count: 2 })}</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))}
</View>
</>
)
},
null,
equal,
)
const ArtistViewFallback = React.memo(() => ( const ArtistViewFallback = React.memo(() => (
<GradientBackground style={styles.fallback}> <GradientBackground style={styles.fallback}>

View File

@@ -3,25 +3,20 @@ 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, GetAlbumListType } from '@app/subsonic/params' import { 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 { 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 }) => {
@@ -49,6 +44,12 @@ 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[]
@@ -74,7 +75,7 @@ const Category = React.memo<{
return ( return (
<View style={styles.category}> <View style={styles.category}>
<Header style={styles.header}>{titles[type as GetAlbumListType] || ''}</Header> <CategoryHeader type={type} />
{albums.length > 0 ? <Albums /> : <Nothing />} {albums.length > 0 ? <Albums /> : <Nothing />}
</View> </View>
) )

View File

@@ -1,16 +1,18 @@
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, { OptionData } from '@app/components/FilterButton' import FilterButton 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 { GetAlbumList2Type, GetAlbumList2TypeBase } from '@app/subsonic/params' import { 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<{
@@ -53,23 +55,36 @@ 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 filterOptions: OptionData[] = [ const filterValues: GetAlbumList2TypeBase[] = [
{ text: 'By Name', value: 'alphabeticalByName' }, 'alphabeticalByName', //
{ text: 'By Artist', value: 'alphabeticalByArtist' }, 'alphabeticalByArtist',
{ text: 'Newest', value: 'newest' }, 'newest',
{ text: 'Frequent', value: 'frequent' }, 'frequent',
{ text: 'Recent', value: 'recent' }, 'recent',
{ text: 'Starred', value: 'starred' }, 'starred',
{ text: 'Random', value: 'random' }, 'random',
// { text: 'By Year...', value: 'byYear' },
// { text: 'By Genre...', value: 'byGenre' },
] ]
const AlbumsList = () => { const AlbumFilterButton = withSuspenseMemo(() => {
const filter = useStoreDeep(store => store.settings.screens.library.albumsFilter) const { t } = useTranslation()
const setFilter = useStore(store => store.setLibraryAlbumFilter) const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type)
const setFilterType = useStore(store => store.setLibraryAlbumFilterType)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filter.type as GetAlbumList2TypeBase, 300) return (
<FilterButton
data={filterValues.map(value => ({ value, text: t(`resources.album.lists.${value}`) }))}
value={filterType}
onSelect={selection => {
setFilterType(selection as GetAlbumList2TypeBase)
}}
title={t('resources.album.lists.sort')}
/>
)
})
const AlbumsList = () => {
const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filterType as GetAlbumList2TypeBase, 300)
const layout = useWindowDimensions() const layout = useWindowDimensions()
@@ -91,16 +106,7 @@ const AlbumsList = () => {
onEndReachedThreshold={6} onEndReachedThreshold={6}
windowSize={5} windowSize={5}
/> />
<FilterButton <AlbumFilterButton />
data={filterOptions}
value={filter.type}
onSelect={selection => {
setFilter({
...filter,
type: selection as GetAlbumList2Type,
})
}}
/>
</View> </View>
) )
} }

View File

@@ -1,26 +1,42 @@
import FilterButton, { OptionData } from '@app/components/FilterButton' import FilterButton 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 filterOptions: OptionData[] = [ const filterValues: ArtistFilterType[] = [
{ text: 'By Name', value: 'alphabeticalByName' }, 'alphabeticalByName', //
{ text: 'Starred', value: 'starred' }, 'starred',
{ text: 'Random', value: 'random' }, '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 filter = useStoreDeep(store => store.settings.screens.library.artistsFilter) const filterType = useStore(store => store.settings.screens.library.artistsFilter.type)
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[]>([])
@@ -32,7 +48,7 @@ const ArtistsList = () => {
} }
const list = Object.values(data.byId) const list = Object.values(data.byId)
switch (filter.type) { switch (filterType) {
case 'random': case 'random':
setSortedList([...list].sort(() => Math.random() - 0.5)) setSortedList([...list].sort(() => Math.random() - 0.5))
break break
@@ -46,7 +62,7 @@ const ArtistsList = () => {
setSortedList([...list]) setSortedList([...list])
break break
} }
}, [filter.type, data]) }, [filterType, data])
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -60,16 +76,7 @@ const ArtistsList = () => {
windowSize={3} windowSize={3}
contentMarginTop={6} contentMarginTop={6}
/> />
<FilterButton <ArtistFilterButton />
data={filterOptions}
value={filter.type}
onSelect={selection => {
setFilter({
...filter,
type: selection as ArtistFilterType,
})
}}
/>
</View> </View>
) )
} }

View File

@@ -3,9 +3,10 @@ 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 { QueueContextType, TrackExt } from '@app/models/trackplayer' import { 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'
@@ -13,6 +14,7 @@ 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'
@@ -21,32 +23,29 @@ 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'
function getContextName(type?: QueueContextType) { const NowPlayingHeader = withSuspenseMemo<{
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 = getContextName(queueContextType) let contextName: string
if (queueContextType === 'album') {
contextName = t('resources.album.name', { count: 1 })
} else if (queueContextType === 'artist') {
contextName = t('resources.song.lists.artistTopSongs')
} else if (queueContextType === 'playlist') {
contextName = t('resources.playlist.name', { count: 1 })
} else if (queueContextType === 'song') {
contextName = t('search.nowPlayingContext')
}
return ( return (
<HeaderBar <HeaderBar
@@ -91,11 +90,11 @@ const headerStyles = StyleSheet.create({
}) })
const SongCoverArt = () => { const SongCoverArt = () => {
const coverArt = useStore(store => store.currentTrack?.coverArt) const albumId = useStore(store => store.currentTrack?.albumId)
return ( return (
<View style={coverArtStyles.container}> <View style={coverArtStyles.container}>
<CoverArt type="cover" size="original" coverArt={coverArt} style={coverArtStyles.image} /> <CoverArt type="album" size="original" albumId={albumId} style={coverArtStyles.image} />
</View> </View>
) )
} }

View File

@@ -4,6 +4,7 @@ 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'
@@ -13,6 +14,7 @@ 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,
@@ -24,7 +26,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, isReady, contextId } = useSetQueue('song', [item]) const { setQueue, contextId } = useSetQueue('song', [item])
return ( return (
<ListItem <ListItem
@@ -34,61 +36,87 @@ 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 = React.memo<{ const ResultsCategory = withSuspenseMemo<{
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 }) => { }>(
const navigation = useNavigation() ({ name, query, type, items }) => {
const navigation = useNavigation()
const { t } = useTranslation()
if (items.length === 0) { if (items.length === 0) {
return <></> return <></>
} }
return ( return (
<> <>
<Header>{name}</Header> <Header>{name}</Header>
{items.map(a => {items.map(a =>
type === 'song' ? ( type === 'song' ? (
<SongItem key={a.id} item={a as Song} /> <SongItem key={a.id} item={a as Song} />
) : ( ) : (
<ListItem key={a.id} item={a} showArt={true} showStar={false} /> <ListItem key={a.id} item={a} showArt={true} showStar={false} />
), ),
)} )}
{items.length === 5 && ( {items.length === 5 && (
<Button <Button
title="More..." title={t('search.moreResults')}
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 })}
/> />
)} )}
</> </>
) )
}, equal) },
null,
equal,
)
const Results = React.memo<{ const Results = withSuspenseMemo<{
results: SearchResults results: SearchResults
query: string query: string
}>(({ results, query }) => { }>(
return ( ({ results, query }) => {
<> const { t } = useTranslation()
<ResultsCategory name="Artists" query={query} type={'artist'} items={results.artists} />
<ResultsCategory name="Albums" query={query} type={'album'} items={results.albums} />
<ResultsCategory name="Songs" query={query} type={'song'} items={results.songs} />
</>
)
}, equal)
const Search = () => { return (
<>
<ResultsCategory
name={t('resources.artist.name', { count: 2 })}
query={query}
type={'artist'}
items={results.artists}
/>
<ResultsCategory
name={t('resources.album.name', { count: 2 })}
query={query}
type={'album'}
items={results.albums}
/>
<ResultsCategory
name={t('resources.song.name', { count: 2 })}
query={query}
type={'song'}
items={results.songs}
/>
</>
)
},
null,
equal,
)
const Search = withSuspense(() => {
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)
@@ -140,7 +168,7 @@ const Search = () => {
<TextInput <TextInput
ref={searchBarRef} ref={searchBarRef}
style={styles.textInput} style={styles.textInput}
placeholder="Search" placeholder={t('search.inputPlaceholder')}
value={text} value={text}
onChangeText={onChangeText} onChangeText={onChangeText}
/> />
@@ -154,7 +182,7 @@ const Search = () => {
</View> </View>
</GradientScrollView> </GradientScrollView>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scroll: { scroll: {

View File

@@ -1,17 +1,19 @@
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, isReady, contextId } = useSetQueue('song', [item]) const { setQueue, contextId } = useSetQueue('song', [item])
return ( return (
<ListItem <ListItem
@@ -23,7 +25,6 @@ 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}
/> />
) )
} }
@@ -52,11 +53,12 @@ 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: React.FC<{ const SearchResultsView = withSuspense<{
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 }
@@ -82,7 +84,7 @@ const SearchResultsView: React.FC<{
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
title: `Search: "${query}"`, title: t('search.headerTitle', { query }),
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
@@ -102,7 +104,7 @@ const SearchResultsView: React.FC<{
windowSize={5} windowSize={5}
/> />
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
listItem: { listItem: {

View File

@@ -1,5 +1,7 @@
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'
@@ -7,16 +9,18 @@ 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, useState } from 'react' import React, { useCallback, useEffect, 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: React.FC<{ const ServerView = withSuspense<{
id?: string id?: string
}> = ({ id }) => { title?: string
}>(({ 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)
@@ -36,6 +40,10 @@ const ServerView: React.FC<{
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])
@@ -134,15 +142,16 @@ const ServerView: React.FC<{
const ping = async () => { const ping = async () => {
const res = await pingServer(potential) const res = await pingServer(potential)
if (res) { toast(
toast(`Connection to ${potential.address} OK!`) t(`settings.servers.messages.${res ? 'connectionOk' : 'connectionFailed'}`, {
} else { address: potential.address,
toast(`Connection to ${potential.address} failed, check settings or server`) interpolation: { escapeValue: false },
} }),
)
setTesting(false) setTesting(false)
} }
ping() ping()
}, [createServer, pingServer]) }, [createServer, pingServer, t])
const disableControls = useCallback(() => { const disableControls = useCallback(() => {
return !validate() || testing return !validate() || testing
@@ -169,7 +178,7 @@ const ServerView: React.FC<{
return ( return (
<GradientScrollView style={styles.scroll}> <GradientScrollView style={styles.scroll}>
<View style={styles.content}> <View style={styles.content}>
<Text style={styles.inputTitle}>Address</Text> <Text style={styles.inputTitle}>{t('settings.servers.fields.address')}</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholderTextColor="grey" placeholderTextColor="grey"
@@ -182,7 +191,7 @@ const ServerView: React.FC<{
onChangeText={setAddress} onChangeText={setAddress}
onBlur={formatAddress} onBlur={formatAddress}
/> />
<Text style={styles.inputTitle}>Username</Text> <Text style={styles.inputTitle}>{t('settings.servers.fields.username')}</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholderTextColor="grey" placeholderTextColor="grey"
@@ -195,7 +204,7 @@ const ServerView: React.FC<{
value={username} value={username}
onChangeText={setUsername} onChangeText={setUsername}
/> />
<Text style={styles.inputTitle}>Password</Text> <Text style={styles.inputTitle}>{t('settings.servers.fields.password')}</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholderTextColor="grey" placeholderTextColor="grey"
@@ -210,11 +219,11 @@ const ServerView: React.FC<{
onChangeText={setPassword} onChangeText={setPassword}
/> />
<SettingsSwitch <SettingsSwitch
title="Force plain text password" title={t('settings.servers.options.forcePlaintextPassword.title')}
subtitle={ subtitle={
usePlainPassword usePlainPassword
? 'Send password in plain text (legacy, make sure your connection is secure!)' ? t('settings.servers.options.forcePlaintextPassword.descriptionOn')
: 'Send password as token + salt' : t('settings.servers.options.forcePlaintextPassword.descriptionOff')
} }
value={usePlainPassword} value={usePlainPassword}
setValue={togglePlainPassword} setValue={togglePlainPassword}
@@ -222,21 +231,26 @@ const ServerView: React.FC<{
<Button <Button
disabled={disableControls()} disabled={disableControls()}
style={styles.button} style={styles.button}
title="Test Connection" title={t('settings.servers.actions.testConnection')}
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="Delete" title={t('settings.servers.actions.delete')}
onPress={remove} onPress={remove}
/> />
<Button disabled={disableControls()} style={styles.button} title="Save" onPress={save} /> <Button
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: {

View File

@@ -5,25 +5,28 @@ import PressableOpacity from '@app/components/PressableOpacity'
import SettingsItem from '@app/components/SettingsItem' import SettingsItem from '@app/components/SettingsItem'
import SettingsSwitch from '@app/components/SettingsSwitch' import SettingsSwitch from '@app/components/SettingsSwitch'
import TextInput from '@app/components/TextInput' import TextInput from '@app/components/TextInput'
import { useSwitchActiveServer, useResetImageCache } from '@app/hooks/settings' import { withSuspenseMemo } from '@app/components/withSuspense'
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 = React.memo<{ const ServerItem = withSuspenseMemo<{
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)
@@ -33,7 +36,7 @@ const ServerItem = React.memo<{
<SettingsItem <SettingsItem
title={server.address} title={server.address}
subtitle={server.username} subtitle={server.username}
onPress={() => navigation.navigate('server', { id: server.id })}> onPress={() => navigation.navigate('server', { id: server.id, title: t('settings.servers.actions.edit') })}>
<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} />
@@ -73,25 +76,27 @@ const ModalChoice = React.memo<{
) )
}) })
function bitrateString(bitrate: number): string { const BitrateModal = withSuspenseMemo<{
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={text} text={bitrateText(value)}
value={value} value={value}
setValue={setBitrate} setValue={setBitrate}
closeModal={toggleModal} closeModal={toggleModal}
@@ -99,12 +104,12 @@ const BitrateModal = React.memo<{
/> />
) )
}, },
[bitrate, toggleModal, setBitrate], [bitrate, toggleModal, setBitrate, bitrateText],
) )
return ( return (
<> <>
<SettingsItem title={title} subtitle={bitrateString(bitrate)} onPress={toggleModal} /> <SettingsItem title={title} subtitle={bitrateText(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}>
@@ -135,9 +140,9 @@ const SettingsTextModal = React.memo<{
title: string title: string
value: string value: string
setValue: (text: string) => void setValue: (text: string) => void
getUnit?: (text: string) => string subtitle: (value: string) => string
keyboardType?: KeyboardTypeOptions keyboardType?: KeyboardTypeOptions
}>(({ title, value, setValue, getUnit, keyboardType }) => { }>(({ title, value, setValue, subtitle, keyboardType }) => {
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [inputText, setInputText] = useState(value) const [inputText, setInputText] = useState(value)
@@ -148,16 +153,9 @@ 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={getSubtitle()} onPress={toggleModal} /> <SettingsItem title={title} subtitle={subtitle(value)} 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}>
@@ -183,15 +181,9 @@ const SettingsTextModal = React.memo<{
) )
}) })
function secondsUnit(seconds: string): string { const SettingsContent = withSuspenseMemo(() => {
const numberValue = parseFloat(seconds) const { t } = useTranslation()
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)
@@ -221,66 +213,85 @@ const SettingsContent = React.memo(() => {
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>Servers</Header> <Header>{t('settings.servers.name')}</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="Add Server" title={t('settings.servers.actions.add')}
onPress={() => navigation.navigate('server')} onPress={() => navigation.navigate('server', { title: t('settings.servers.actions.add') })}
buttonStyle="hollow" buttonStyle="hollow"
/> />
<Header style={styles.header}>Network</Header> <Header style={styles.header}>{t('settings.network.name')}</Header>
<BitrateModal title="Maximum bitrate (Wi-Fi)" bitrate={maxBitrateWifi} setBitrate={setMaxBitrateWifi} /> <BitrateModal
<BitrateModal title="Maximum bitrate (mobile)" bitrate={maxBitrateMobile} setBitrate={setMaxBitrateMobile} /> title={t('settings.network.options.maxBitrateWifi.title')}
bitrate={maxBitrateWifi}
setBitrate={setMaxBitrateWifi}
/>
<BitrateModal
title={t('settings.network.options.maxBitrateMobile.title')}
bitrate={maxBitrateMobile}
setBitrate={setMaxBitrateMobile}
/>
<SettingsTextModal <SettingsTextModal
title="Minimum buffer time" title={t('settings.network.options.minBuffer.title')}
value={minBuffer.toString()} value={minBuffer.toString()}
setValue={setMinBufferText} setValue={setMinBufferText}
getUnit={secondsUnit} subtitle={secondsText}
keyboardType="numeric" keyboardType="numeric"
/> />
<SettingsTextModal <SettingsTextModal
title="Maximum buffer time" title={t('settings.network.options.maxBuffer.title')}
value={maxBuffer.toString()} value={maxBuffer.toString()}
setValue={setMaxBufferText} setValue={setMaxBufferText}
getUnit={secondsUnit} subtitle={secondsText}
keyboardType="numeric" keyboardType="numeric"
/> />
<Header style={styles.header}>Music</Header> <Header style={styles.header}>{t('settings.music.name')}</Header>
<SettingsSwitch <SettingsSwitch
title="Scrobble plays" title={t('settings.music.options.scrobble.title')}
subtitle={scrobble ? 'Scrobble play history' : "Don't scrobble play history"} subtitle={
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}>Reset</Header> <Header style={styles.header}>{t('settings.reset.name')}</Header>
<Button <Button
disabled={clearing} disabled={clearing}
style={styles.button} style={styles.button}
title="Clear Image Cache" title={t('settings.reset.actions.clearImageCache')}
onPress={clear} onPress={clear}
buttonStyle="hollow" buttonStyle="hollow"
/> />
<Header style={styles.header}>About</Header> <Header style={styles.header}>{t('settings.about.name')}</Header>
<Text style={styles.text}> <Text style={styles.text}>
<Text style={styles.bold}>Subtracks</Text> version {version} <Text style={styles.bold}>Subtracks</Text> {t('settings.about.version', { version })}
</Text> </Text>
<Button <Button
disabled={clearing} disabled={clearing}
style={styles.button} style={styles.button}
title="Project Homepage" title={t('settings.about.actions.projectHomepage')}
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="Licenses" title={t('settings.about.actions.licenses')}
onPress={() => navigation.navigate('web', { uri: 'file:///android_asset/licenses.html' })} onPress={() =>
navigation.navigate('web', {
uri: 'file:///android_asset/licenses.html',
title: t('settings.about.actions.licenses'),
})
}
buttonStyle="hollow" buttonStyle="hollow"
/> />
</View> </View>

View File

@@ -56,10 +56,8 @@ 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 {
@@ -69,17 +67,15 @@ const SongListDetails = React.memo<{
return aVal - bVal return aVal - bVal
}) })
} }
} else {
typeName = 'Playlist'
} }
const { setQueue, isReady, contextId } = useSetQueue(type, _songs) const { setQueue, contextId } = useSetQueue(type, _songs)
if (!songList) { if (!songList) {
return <SongListDetailsFallback /> return <SongListDetailsFallback />
} }
const disabled = !isReady || _songs.length === 0 const disabled = _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 })
@@ -125,7 +121,7 @@ const SongListDetails = React.memo<{
<ListPlayerControls <ListPlayerControls
style={styles.controls} style={styles.controls}
songs={_songs} songs={_songs}
typeName={typeName} listType={type}
play={play(undefined, false)} play={play(undefined, false)}
shuffle={play(undefined, true)} shuffle={play(undefined, true)}
disabled={disabled} disabled={disabled}

View File

@@ -1,9 +1,17 @@
import React from 'react' import { useNavigation } from '@react-navigation/native'
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
}> = ({ uri }) => { title?: string
}> = ({ uri, title }) => {
const navigation = useNavigation()
useEffect(() => {
navigation.setOptions({ title })
}, [navigation, title])
return <WebView source={{ uri }} /> return <WebView source={{ uri }} />
} }

View File

@@ -1,7 +1,8 @@
import { AlbumFilterSettings, ArtistFilterSettings, Server } from '@app/models/settings' import { AlbumFilterSettings, ArtistFilterSettings, ArtistFilterType, 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 = {
@@ -26,7 +27,9 @@ export type SettingsSlice = {
} }
client?: SubsonicApiClient client?: SubsonicApiClient
resetServer: boolean
disableMusicTabs: boolean
setDisableMusicTabs: (value: boolean) => void
changeCacheBuster: () => void changeCacheBuster: () => void
@@ -43,8 +46,8 @@ export type SettingsSlice = {
pingServer: (server?: Server) => Promise<boolean> pingServer: (server?: Server) => Promise<boolean>
setLibraryAlbumFilter: (filter: AlbumFilterSettings) => void setLibraryAlbumFilterType: (type: GetAlbumList2TypeBase) => void
setLibraryArtistFiler: (filter: ArtistFilterSettings) => void setLibraryArtistFilterType: (type: ArtistFilterType) => void
} }
export function newCacheBuster(): string { export function newCacheBuster(): string {
@@ -78,7 +81,12 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
cacheBuster: newCacheBuster(), cacheBuster: newCacheBuster(),
}, },
resetServer: false, disableMusicTabs: false,
setDisableMusicTabs: value => {
set(store => {
store.disableMusicTabs = value
})
},
changeCacheBuster: () => { changeCacheBuster: () => {
set(store => { set(store => {
@@ -103,7 +111,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
} }
set(state => { set(state => {
state.resetServer = true state.disableMusicTabs = true
}) })
set(state => { set(state => {
@@ -112,7 +120,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
}) })
set(state => { set(state => {
state.resetServer = false state.disableMusicTabs = false
}) })
}, },
@@ -216,15 +224,15 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
} }
}, },
setLibraryAlbumFilter: filter => { setLibraryAlbumFilterType: type => {
set(state => { set(state => {
state.settings.screens.library.albumsFilter = filter state.settings.screens.library.albumsFilter.type = type
}) })
}, },
setLibraryArtistFiler: filter => { setLibraryArtistFilterType: type => {
set(state => { set(state => {
state.settings.screens.library.artistsFilter = filter state.settings.screens.library.artistsFilter.type = type
}) })
}, },
}) })

View File

@@ -55,6 +55,7 @@ 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
@@ -314,6 +315,17 @@ 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
app/util/types.ts Normal file
View File

@@ -0,0 +1,2 @@
export type PromiseResolvedType<T> = T extends Promise<infer R> ? R : never
export type ReturnedPromiseResolvedType<T extends (...args: any) => any> = PromiseResolvedType<ReturnType<T>>

View File

@@ -11,6 +11,21 @@ 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'

View File

@@ -0,0 +1,10 @@
**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

View File

@@ -1,6 +1,6 @@
{ {
"name": "subtracks", "name": "subtracks",
"version": "1.2.0", "version": "1.3.0",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
@@ -31,18 +31,21 @@
"@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",

View File

@@ -719,6 +719,13 @@
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"
@@ -1920,9 +1927,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.3" version "2.6.4"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
dependencies: dependencies:
lodash "^4.17.14" lodash "^4.17.14"
@@ -3614,11 +3621,18 @@ 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.0, html-escaper@^2.0.2:
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"
@@ -3652,6 +3666,13 @@ 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"
@@ -6016,6 +6037,15 @@ 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"
@@ -6078,6 +6108,11 @@ 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"
@@ -7393,6 +7428,11 @@ 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"