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>
This commit is contained in:
austinried 2022-04-15 12:11:00 +09:00 committed by GitHub
parent 4905f75564
commit 860a4cec16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1186 additions and 316 deletions

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

@ -0,0 +1,151 @@
{
"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",
"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,151 @@
{
"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"
},
"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,15 @@
{
"resources": {
"album": {
"lists": {
"random": "ランダムアルバム",
"frequent": "よく聴くアルバム",
"recent": "最近再生した",
"starred": "星付きアルバム"
}
},
"song": {
"name": "歌"
}
}
}

View File

@ -0,0 +1 @@
{}

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>
) )
}, },
@ -154,27 +158,30 @@ const MenuHeader = React.memo<{
</View> </View>
)) ))
const OptionStar = React.memo<{ 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('context.actions')
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('unstar') : t('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('resources.artist.actions')
if (!artist || !artistId) { if (!artist || !artistId) {
return <></> return <></>
} }
@ -184,17 +191,19 @@ const OptionViewArtist = React.memo<{
IconComponent={IconFA} IconComponent={IconFA}
name="microphone" name="microphone"
size={26} size={26}
text="View Artist" text={t('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('resources.album.actions')
if (!album || !albumId) { if (!album || !albumId) {
return <></> return <></>
} }
@ -204,7 +213,7 @@ const OptionViewAlbum = React.memo<{
IconComponent={IconFA5} IconComponent={IconFA5}
name="compact-disc" name="compact-disc"
size={26} size={26}
text="View Album" text={t('view')}
onSelect={() => navigation.navigate('album', { id: albumId, title: album })} onSelect={() => navigation.navigate('album', { id: albumId, title: album })}
/> />
) )
@ -318,6 +327,8 @@ const styles = StyleSheet.create({
}, },
optionsWrapper: { optionsWrapper: {
// marginBottom: 10, // marginBottom: 10,
paddingHorizontal: 20,
// backgroundColor: 'purple',
}, },
menuHeader: { menuHeader: {
paddingTop: 14, paddingTop: 14,
@ -348,9 +359,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,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

@ -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('resources')
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(`${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('messages')
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('nothingHere')}
</Text>
</View> </View>
) )
}) })

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)
}

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

@ -47,7 +47,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>
) )
}) })
@ -92,6 +94,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'
@ -116,7 +118,7 @@ const SearchTab = createTabStackNavigator(Search)
type SettingsStackParamList = { type SettingsStackParamList = {
main: undefined main: undefined
server?: { id?: string } server?: { id?: string }
web: { uri: string } web: { uri: string; title?: string }
} }
type ServerScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'server'> type ServerScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'server'>
@ -133,7 +135,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()
@ -156,7 +160,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,7 +172,8 @@ const SettingsTab = () => {
const Tab = createBottomTabNavigator() const Tab = createBottomTabNavigator()
const BottomTabNavigator = () => { const BottomTabNavigator = withSuspense(() => {
const { t } = useTranslation('navigation.tabs')
const firstRun = useFirstRun() const firstRun = useFirstRun()
const resetServer = useStore(store => store.resetServer) const resetServer = useStore(store => store.resetServer)
@ -179,14 +183,14 @@ const BottomTabNavigator = () => {
<></> <></>
) : ( ) : (
<> <>
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: 'Home' }} /> <Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: t('home') }} />
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: 'Library' }} /> <Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: t('library') }} />
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: 'Search' }} /> <Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: t('search') }} />
</> </>
)} )}
<Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: 'Settings' }} /> <Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: t('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('resources')
const marginTop = useSafeAreaInsets().top const marginTop = useSafeAreaInsets().top
return ( return (
@ -22,12 +25,16 @@ const LibraryTopTabNavigator = () => {
indicatorStyle: styles.tabindicatorStyle, indicatorStyle: styles.tabindicatorStyle,
}} }}
initialRouteName="albums"> initialRouteName="albums">
<Tab.Screen name="albums" component={AlbumsTab} options={{ tabBarLabel: 'Albums' }} /> <Tab.Screen name="albums" component={AlbumsTab} options={{ tabBarLabel: t('album.name', { count: 2 }) }} />
<Tab.Screen name="artists" component={ArtistsTab} options={{ tabBarLabel: 'Artists' }} /> <Tab.Screen name="artists" component={ArtistsTab} options={{ tabBarLabel: t('artist.name', { count: 2 }) }} />
<Tab.Screen name="playlists" component={PlaylistsTab} options={{ tabBarLabel: 'Playlists' }} /> <Tab.Screen
name="playlists"
component={PlaylistsTab}
options={{ tabBarLabel: t('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('resources.queue')
<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('name'),
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

@ -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,63 @@ 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, isReady, contextId } = useSetQueue('artist', songs)
const { t } = useTranslation('resources.song.lists')
return ( return (
<> <>
<Header>Top Songs</Header> <Header>{t('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} 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('resources.album')
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('name', { count: 1 })}</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('resources.album.lists')
console.log('type', type, t(type))
return <Header style={styles.header}>{t(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('resources.album.lists')
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(value) }))}
value={filterType}
onSelect={selection => {
setFilterType(selection as GetAlbumList2TypeBase)
}}
title={t('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('resources.artist.lists')
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(value) }))}
value={filterType}
onSelect={selection => setFilterType(selection as ArtistFilterType)}
title={t('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,27 @@ 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()
if (!track) { if (!track) {
return <></> return <></>
} }
let contextName = getContextName(queueContextType) let contextName: string
if (queueContextType === 'album') {
contextName = t('resources.album.name')
} else if (queueContextType === 'artist') {
contextName = t('resources.song.lists.artistTopSongs')
} else if (queueContextType === 'playlist') {
contextName = t('resources.playlist.name')
} else if (queueContextType === 'song') {
contextName = t('search.nowPlayingContext')
}
return ( return (
<HeaderBar <HeaderBar

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,
@ -39,56 +41,68 @@ const SongItem = React.memo<{ item: Song }>(({ item }) => {
) )
}, 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('search')
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('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('resources')
<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('artist.name', { count: 2 })} query={query} type={'artist'} items={results.artists} />
<ResultsCategory name={t('album.name', { count: 2 })} query={query} type={'album'} items={results.albums} />
<ResultsCategory name={t('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('search')
const [text, setText] = useState('') const [text, setText] = useState('')
const searchBarRef = useRef<ReactTextInput>(null) const searchBarRef = useRef<ReactTextInput>(null)
@ -140,7 +154,7 @@ const Search = () => {
<TextInput <TextInput
ref={searchBarRef} ref={searchBarRef}
style={styles.textInput} style={styles.textInput}
placeholder="Search" placeholder={t('inputPlaceholder')}
value={text} value={text}
onChangeText={onChangeText} onChangeText={onChangeText}
/> />
@ -154,7 +168,7 @@ const Search = () => {
</View> </View>
</GradientScrollView> </GradientScrollView>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scroll: { scroll: {

View File

@ -1,11 +1,13 @@
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
@ -52,11 +54,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('search')
const size = 100 const size = 100
const params: Search3Params = { query } const params: Search3Params = { query }
@ -82,7 +85,7 @@ const SearchResultsView: React.FC<{
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
title: `Search: "${query}"`, title: t('headerTitle', { query }),
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
@ -102,7 +105,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'
@ -8,15 +10,16 @@ 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, 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 }) => { }>(({ id }) => {
const { t } = useTranslation('settings.servers')
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)
@ -134,15 +137,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(`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 +173,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('fields.address')}</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholderTextColor="grey" placeholderTextColor="grey"
@ -182,7 +186,7 @@ const ServerView: React.FC<{
onChangeText={setAddress} onChangeText={setAddress}
onBlur={formatAddress} onBlur={formatAddress}
/> />
<Text style={styles.inputTitle}>Username</Text> <Text style={styles.inputTitle}>{t('fields.username')}</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholderTextColor="grey" placeholderTextColor="grey"
@ -195,7 +199,7 @@ const ServerView: React.FC<{
value={username} value={username}
onChangeText={setUsername} onChangeText={setUsername}
/> />
<Text style={styles.inputTitle}>Password</Text> <Text style={styles.inputTitle}>{t('fields.password')}</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholderTextColor="grey" placeholderTextColor="grey"
@ -210,11 +214,11 @@ const ServerView: React.FC<{
onChangeText={setPassword} onChangeText={setPassword}
/> />
<SettingsSwitch <SettingsSwitch
title="Force plain text password" title={t('options.forcePlaintextPassword.title')}
subtitle={ subtitle={
usePlainPassword usePlainPassword
? 'Send password in plain text (legacy, make sure your connection is secure!)' ? t('options.forcePlaintextPassword.descriptionOn')
: 'Send password as token + salt' : t('options.forcePlaintextPassword.descriptionOff')
} }
value={usePlainPassword} value={usePlainPassword}
setValue={togglePlainPassword} setValue={togglePlainPassword}
@ -222,21 +226,21 @@ const ServerView: React.FC<{
<Button <Button
disabled={disableControls()} disabled={disableControls()}
style={styles.button} style={styles.button}
title="Test Connection" title={t('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('actions.delete')}
onPress={remove} onPress={remove}
/> />
<Button disabled={disableControls()} style={styles.button} title="Save" onPress={save} /> <Button disabled={disableControls()} style={styles.button} title={t('actions.save')} onPress={save} />
</View> </View>
</GradientScrollView> </GradientScrollView>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scroll: { scroll: {

View File

@ -5,13 +5,15 @@ 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'
@ -73,25 +75,23 @@ 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('settings.network.values')
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('unlimitedKbps') : t('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 +99,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 +135,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 +148,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 +176,9 @@ const SettingsTextModal = React.memo<{
) )
}) })
function secondsUnit(seconds: string): string { const SettingsContent = withSuspenseMemo(() => {
const numberValue = parseFloat(seconds) const { t } = useTranslation('settings')
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 +208,78 @@ 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('network.values.seconds', { value }), [t])
return ( return (
<View style={styles.content}> <View style={styles.content}>
<Header>Servers</Header> <Header>{t('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('servers.actions.add')}
onPress={() => navigation.navigate('server')} onPress={() => navigation.navigate('server')}
buttonStyle="hollow" buttonStyle="hollow"
/> />
<Header style={styles.header}>Network</Header> <Header style={styles.header}>{t('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('network.options.maxBitrateWifi.title')}
bitrate={maxBitrateWifi}
setBitrate={setMaxBitrateWifi}
/>
<BitrateModal
title={t('network.options.maxBitrateMobile.title')}
bitrate={maxBitrateMobile}
setBitrate={setMaxBitrateMobile}
/>
<SettingsTextModal <SettingsTextModal
title="Minimum buffer time" title={t('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('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('music.name')}</Header>
<SettingsSwitch <SettingsSwitch
title="Scrobble plays" title={t('music.options.scrobble.title')}
subtitle={scrobble ? 'Scrobble play history' : "Don't scrobble play history"} subtitle={scrobble ? t('music.options.scrobble.descriptionOn') : t('music.options.scrobble.descriptionOff')}
value={scrobble} value={scrobble}
setValue={setScrobble} setValue={setScrobble}
/> />
<Header style={styles.header}>Reset</Header> <Header style={styles.header}>{t('reset.name')}</Header>
<Button <Button
disabled={clearing} disabled={clearing}
style={styles.button} style={styles.button}
title="Clear Image Cache" title={t('reset.actions.clearImageCache')}
onPress={clear} onPress={clear}
buttonStyle="hollow" buttonStyle="hollow"
/> />
<Header style={styles.header}>About</Header> <Header style={styles.header}>{t('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('about.version', { version })}
</Text> </Text>
<Button <Button
disabled={clearing} disabled={clearing}
style={styles.button} style={styles.button}
title="Project Homepage" title={t('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('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('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,8 +67,6 @@ const SongListDetails = React.memo<{
return aVal - bVal return aVal - bVal
}) })
} }
} else {
typeName = 'Playlist'
} }
const { setQueue, isReady, contextId } = useSetQueue(type, _songs) const { setQueue, isReady, contextId } = useSetQueue(type, _songs)
@ -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 = {
@ -43,8 +44,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 {
@ -216,15 +217,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

@ -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

@ -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"
@ -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"