41 Commits

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

Added translation using Weblate (Danish)

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

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 86.5% (58 of 67 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (67 of 67 strings)

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

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

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

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

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

* use cache data for track artwork when available

* fix round art in context menu for songs

* set only the first artwork at play time

then set the rest in the playback service

* handle both cached images and fetching images

* remove commented code

* fix shuffle

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

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

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

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

Currently translated at 97.0% (65 of 67 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (67 of 67 strings)

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

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

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

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

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 98.5% (66 of 67 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (67 of 67 strings)

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

* Translated using Weblate (Russian)

Currently translated at 95.5% (64 of 67 strings)

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

* Translated using Weblate (German)

Currently translated at 94.0% (63 of 67 strings)

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

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

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

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

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

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

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-15 14:02:22 +09:00
austinried
a9dbcfb69d add edit server string i18n
set add/edit header title with i18n
fix albums plural in artist view
2022-04-15 12:55:11 +09:00
austinried
860a4cec16 Localization support (#99)
* basic i18n poc

* translate home, filters, tabs

support dot notation in backend for namespaces

* i18n context menu, artist filters, list controls

also nothings here
fix backend not caching fallback

* i18n queue, artist view, search/results

* i18n settings and server view

* Added translation using Weblate (Norwegian Bokmål)

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (6 of 6 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* fix url escaping

* added some mostly naive text overflow fixes

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

* Added translation using Weblate (French)

* Translated using Weblate (French)

Currently translated at 17.4% (11 of 63 strings)

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

* Translated using Weblate (French)

Currently translated at 19.0% (12 of 63 strings)

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

* Translated using Weblate (French)

Currently translated at 40.0% (26 of 65 strings)

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

* add weblate and some pretty badges to readme

* fix link

* Translated using Weblate (French)

Currently translated at 50.7% (33 of 65 strings)

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

* Translated using Weblate (English)

Currently translated at 100.0% (65 of 65 strings)

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

* Translated using Weblate (French)

Currently translated at 90.7% (59 of 65 strings)

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

* i18n now playing context type

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

* Translated using Weblate (French)

Currently translated at 96.9% (63 of 65 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (66 of 66 strings)

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

* Translated using Weblate (Japanese) (#98)

Currently translated at 7.5% (5 of 66 strings)

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

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

* little note to remind me why that's there

* update licenses

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

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

@@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):**
- Device: [e.g. Pixel 4]
- OS: [e.g. Android 12]
- Subtracks version [e.g. 1.2.0]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

17
.github/release.yml vendored Normal file
View File

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

View File

@@ -17,6 +17,7 @@ on:
paths-ignore:
- assets/**
- .vscode/**
- android/app/src/main/assets/custom/i18n/**
- .eslintrc.js
- .prettierrc.js
- BUILDING.md

View File

@@ -3,6 +3,8 @@
#
Subtracks is an Android open source music streaming app for [Subsonic-API-compatible](http://www.subsonic.org/pages/api.jsp) servers ([Subsonic](http://www.subsonic.org/pages/index.jsp), [Navidrome](https://www.navidrome.org/), [Airsonic](https://airsonic.github.io/), and more). It's designed to give you clean and convenient access to your music in the style of modern media players.
[![Translation status](https://hosted.weblate.org/widgets/subtracks/-/subtracks/svg-badge.svg)](https://hosted.weblate.org/engage/subtracks/) ![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/austinried/subtracks/build-release-debugsign/main) ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/austinried/subtracks?label=github) ![F-Droid](https://img.shields.io/f-droid/v/com.subtracks)
# Screenshots
<p float="left">
<img src="metadata/en-US/images/phoneScreenshots/01_home.png" alt="home" width="200"/>
@@ -45,3 +47,10 @@ Subtracks is an Android open source music streaming app for [Subsonic-API-compat
# Building
See [Building from source](BUILDING.md).
# Translations
Want to see Subtracks in your language? Visit the project on [Weblate](https://hosted.weblate.org/engage/subtracks/) to help!
<a href="https://hosted.weblate.org/engage/subtracks/">
<img src="https://hosted.weblate.org/widgets/subtracks/-/subtracks/multi-auto.svg" alt="Translation status" />
</a>

View File

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

View File

@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission tools:node="remove" android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission tools:node="remove" android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission tools:node="remove" android:name="com.android.vending.CHECK_LICENSE"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:usesCleartextTraffic="true" android:networkSecurityConfig="@xml/network_security_config">
<activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustPan">
<intent-filter>

View File

@@ -0,0 +1,152 @@
{
"resources": {
"album": {
"lists": {
"newest": "Afegit recentment",
"sort": "Ordenar els àlbums",
"random": "Aleatori",
"byGenre": "Per gènere",
"alphabeticalByName": "Pel nom",
"alphabeticalByArtist": "Per artista",
"byYear": "Per any",
"frequent": "Escoltat freqüentment",
"recent": "Reproduït recentment",
"starred": "Favorits"
},
"actions": {
"play": "Reproduir l'àlbum",
"view": "Veure l'àlbum"
},
"name": "Àlbum",
"name_plural": "Àlbums"
},
"artist": {
"lists": {
"sort": "Ordenar els artistes",
"starred": "Preferits",
"alphabeticalByName": "Pel nom",
"random": "Aleatori"
},
"name": "Artista",
"name_plural": "Artistes",
"actions": {
"view": "Veure l'artista"
}
},
"queue": {
"name": "Cua",
"name_plural": "Cues"
},
"song": {
"name": "Cançó",
"name_plural": "Cançons",
"lists": {
"artistTopSongs": "Millors cançons"
}
},
"playlist": {
"actions": {
"play": "Reproduir la llista de reproducció"
},
"name": "Playlist",
"name_plural": "Playlists"
}
},
"context": {
"actions": {
"unstar": "Eliminar dels preferits",
"star": "Afegir als favorits"
}
},
"navigation": {
"tabs": {
"home": "Inici",
"search": "Cercar",
"library": "Biblioteca",
"settings": "Paràmetres"
}
},
"messages": {
"nothingHere": "Aquí no hi ha res…"
},
"settings": {
"servers": {
"fields": {
"username": "Nom dusuari",
"password": "Contrasenya",
"address": "Adreça"
},
"options": {
"forcePlaintextPassword": {
"title": "Forçar la contrasenya de text sense format",
"descriptionOn": "Enviar la contrasenya en text sense format (llegat, assegura't que la teva connexió sigui segura!)",
"descriptionOff": "Enviar la contrasenya com a fitxa + sal"
}
},
"actions": {
"add": "Afegir un servidor",
"testConnection": "Comprovar la connexió",
"save": "Desar",
"edit": "Editar el servidor",
"delete": "Esborrar"
},
"messages": {
"connectionOk": "Connexió a {{address}} OK!",
"connectionFailed": "La connexió a {{address}} ha fallat, comprova la configuració o el servidor"
},
"name": "Servidors"
},
"network": {
"name": "Xarxa",
"values": {
"seconds": "{{value}} segons",
"kbps": "{{value}}kbit/s",
"unlimitedKbps": "Il·limitat"
},
"options": {
"maxBuffer": {
"title": "Temps màxim de buffer"
},
"maxBitrateWifi": {
"title": "Taxa de bits màxima (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Taxa de bits màxima (mòbil)"
},
"minBuffer": {
"title": "Temps mínim de buffer"
}
}
},
"music": {
"options": {
"scrobble": {
"title": "Capturar la lectura",
"descriptionOn": "Capturar l'historial de reproduccions",
"descriptionOff": "No capturar l'historial de reproducció"
}
},
"name": "Música"
},
"reset": {
"name": "Reinicialitzar",
"actions": {
"clearImageCache": "Esborrar la memòria cau d'imatges"
}
},
"about": {
"name": "Quant a",
"version": "versió {{version}}",
"actions": {
"projectHomepage": "Pàgina d'inici del projecte",
"licenses": "Llicències"
}
}
},
"search": {
"nowPlayingContext": "Resultats de la cerca",
"inputPlaceholder": "Cercar",
"moreResults": "Més…",
"headerTitle": "Cercar: {{query}}"
}
}

View File

@@ -0,0 +1,92 @@
{
"resources": {
"album": {
"lists": {
"byGenre": "Efter genre",
"alphabeticalByName": "Efter navn",
"alphabeticalByArtist": "Efter kunstner",
"byYear": "Efter år",
"sort": "Sortér albums"
},
"actions": {
"play": "Afspil album",
"view": "Se album"
}
},
"artist": {
"name": "Kunstner",
"name_plural": "Kunstnere",
"lists": {
"sort": "Sortér kunstnere",
"alphabeticalByName": "Efter navn"
},
"actions": {
"view": "Se kunstnere"
}
},
"song": {
"lists": {
"artistTopSongs": "Top sange"
},
"name": "Sang",
"name_plural": "Sange"
}
},
"navigation": {
"tabs": {
"library": "Bibliotek",
"search": "Søg",
"settings": "Indstillinger"
}
},
"search": {
"inputPlaceholder": "Søg",
"headerTitle": "Søg: {{query}}",
"nowPlayingContext": "Søgeresultater",
"moreResults": "Mere…"
},
"settings": {
"servers": {
"name": "Servere",
"fields": {
"address": "Adresse",
"username": "Brugernavn",
"password": "Adgangskode"
},
"actions": {
"add": "Tilføj server",
"edit": "Redigér server",
"testConnection": "Test forbindelse",
"delete": "Slet",
"save": "Gem"
},
"messages": {
"connectionOk": "Forbindelse til {{address}} OK!"
}
},
"network": {
"name": "Netværk",
"values": {
"kbps": "{{value}}kbps",
"unlimitedKbps": "Ubegrænset",
"seconds": "{{value}} sekunder"
}
},
"music": {
"name": "Musik"
},
"reset": {
"name": "Nulstil",
"actions": {
"clearImageCache": "Ryd billede cache"
}
},
"about": {
"name": "Omkring",
"version": "version {{version}}",
"actions": {
"licenses": "Licenser"
}
}
}
}

View File

@@ -0,0 +1,152 @@
{
"resources": {
"song": {
"lists": {
"artistTopSongs": "Top Lieder"
},
"name": "Lied",
"name_plural": "Lieder"
},
"album": {
"name": "Album",
"name_plural": "Alben",
"lists": {
"sort": "Alben sortieren",
"random": "Zufällig",
"frequent": "Häufig abgespielt",
"recent": "Kürzlich abgespielt",
"starred": "Favoriten",
"byYear": "Nach Jahr",
"byGenre": "Nach Genre",
"alphabeticalByName": "Nach Name",
"newest": "Kürzlich hinzugefügt",
"alphabeticalByArtist": "Nach Interpreten"
},
"actions": {
"play": "Album abspielen",
"view": "Album anzeigen"
}
},
"artist": {
"name": "Interpret",
"name_plural": "Interpreten",
"lists": {
"sort": "Interpreten sortieren",
"random": "Zufällig",
"alphabeticalByName": "Nach Name",
"starred": "Favoriten"
},
"actions": {
"view": "Interpret anzeigen"
}
},
"playlist": {
"name": "Wiedergabeliste",
"name_plural": "Wiedergabelisten",
"actions": {
"play": "Wiedergabeliste abspielen"
}
},
"queue": {
"name": "Warteschlange",
"name_plural": "Warteschlangen"
}
},
"context": {
"actions": {
"star": "Markieren",
"unstar": "Markierung entfernen"
}
},
"navigation": {
"tabs": {
"home": "Startseite",
"library": "Bibliothek",
"search": "Suche",
"settings": "Einstellungen"
}
},
"search": {
"inputPlaceholder": "Suche",
"headerTitle": "Suche: {{query}}",
"nowPlayingContext": "Suchergebnis",
"moreResults": "Mehr…"
},
"settings": {
"servers": {
"fields": {
"address": "Adresse",
"password": "Passwort",
"username": "Nutzername"
},
"options": {
"forcePlaintextPassword": {
"title": "Erzwinge Klartextpasswort",
"descriptionOn": "Passwort als Klartext senden (Veraltet, stellen Sie sicher, dass Ihre Verbindung sicher ist!)",
"descriptionOff": "Sende Passwort als Token + Salt"
}
},
"actions": {
"add": "Server hinzufügen",
"edit": "Server bearbeiten",
"testConnection": "Verbindung testen",
"delete": "Löschen",
"save": "Speichern"
},
"messages": {
"connectionOk": "Verbindung zu {{address}} ist OK!",
"connectionFailed": "Verbindung zu {{address}} fehlgeschlagen, überprüfe Einstellungen oder Server"
},
"name": "Server"
},
"network": {
"name": "Netzwerk",
"values": {
"kbps": "{{value}}kbps",
"unlimitedKbps": "Unbegrenzt",
"seconds": "{{value}} Sekunden"
},
"options": {
"maxBitrateWifi": {
"title": "Maximale Bitrate (WLAN)"
},
"maxBuffer": {
"title": "Maxilmale Pufferzeit"
},
"minBuffer": {
"title": "Minimale Pufferzeit"
},
"maxBitrateMobile": {
"title": "Maximale Bitrate (Mobil)"
}
}
},
"music": {
"name": "Musik",
"options": {
"scrobble": {
"descriptionOn": "Scrobble Wiedergabeverlauf",
"descriptionOff": "Kein Scrobble für Wiedergabeverlauf",
"title": "Scrobble Wiedergabe"
}
}
},
"reset": {
"name": "Zurücksetzen",
"actions": {
"clearImageCache": "Bildzwischenspeicher löschen"
}
},
"about": {
"name": "Über",
"actions": {
"projectHomepage": "Projektseite",
"licenses": "Lizenzen"
},
"version": "Version {{version}}"
}
},
"messages": {
"nothingHere": "Hier ist nichts…"
}
}

View File

@@ -0,0 +1,152 @@
{
"resources": {
"song": {
"name": "Song",
"name_plural": "Songs",
"lists": {
"artistTopSongs": "Top Songs"
}
},
"album": {
"name": "Album",
"name_plural": "Albums",
"lists": {
"sort": "Sort Albums",
"random": "Random",
"newest": "Recently Added",
"frequent": "Frequently Played",
"recent": "Recently Played",
"starred": "Starred",
"alphabeticalByName": "By Name",
"alphabeticalByArtist": "By Artist",
"byYear": "By Year",
"byGenre": "By Genre"
},
"actions": {
"play": "Play Album",
"view": "View Album"
}
},
"artist": {
"name": "Artist",
"name_plural": "Artists",
"lists": {
"sort": "Sort Artists",
"random": "Random",
"starred": "Starred",
"alphabeticalByName": "By Name"
},
"actions": {
"view": "View Artist"
}
},
"playlist": {
"name": "Playlist",
"name_plural": "Playlists",
"actions": {
"play": "Play Playlist"
}
},
"queue": {
"name": "Queue",
"name_plural": "Queues"
}
},
"context": {
"actions": {
"star": "Star",
"unstar": "Unstar"
}
},
"navigation": {
"tabs": {
"home": "Home",
"library": "Library",
"search": "Search",
"settings": "Settings"
}
},
"messages": {
"nothingHere": "Nothing here…"
},
"search": {
"inputPlaceholder": "Search",
"headerTitle": "Search: {{query}}",
"moreResults": "More…",
"nowPlayingContext": "Search Results"
},
"settings": {
"servers": {
"name": "Servers",
"fields": {
"address": "Address",
"username": "Username",
"password": "Password"
},
"options": {
"forcePlaintextPassword": {
"title": "Force plaintext password",
"descriptionOn": "Send password in plaintext (legacy, make sure your connection is secure!)",
"descriptionOff": "Send password as token + salt"
}
},
"actions": {
"add": "Add Server",
"edit": "Edit Server",
"testConnection": "Test Connection",
"delete": "Delete",
"save": "Save"
},
"messages": {
"connectionOk": "Connection to {{address}} OK!",
"connectionFailed": "Connection to {{address}} failed, check settings or server"
}
},
"network": {
"name": "Network",
"values": {
"kbps": "{{value}}kbps",
"unlimitedKbps": "Unlimited",
"seconds": "{{value}} seconds"
},
"options": {
"maxBitrateWifi": {
"title": "Maximum bitrate (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Maximum bitrate (mobile)"
},
"minBuffer": {
"title": "Minimum buffer time"
},
"maxBuffer": {
"title": "Maximum buffer time"
}
}
},
"music": {
"name": "Music",
"options": {
"scrobble": {
"title": "Scrobble plays",
"descriptionOn": "Scrobble play history",
"descriptionOff": "Don't scrobble play history"
}
}
},
"reset": {
"name": "Reset",
"actions": {
"clearImageCache": "Clear Image Cache"
}
},
"about": {
"name": "About",
"version": "version {{version}}",
"actions": {
"projectHomepage": "Project Homepage",
"licenses": "Licenses"
}
}
}
}

View File

@@ -0,0 +1,152 @@
{
"resources": {
"album": {
"name": "Album",
"name_plural": "Albums",
"lists": {
"random": "Aléatoire",
"newest": "Récemment Ajouté",
"frequent": "Fréquemment Joué",
"recent": "Récemment Joué",
"alphabeticalByName": "Par Nom",
"byYear": "Par Année",
"alphabeticalByArtist": "Par Artiste",
"byGenre": "Par Genre",
"starred": "Favoris",
"sort": "Trier les albums"
},
"actions": {
"play": "Jouer l'album",
"view": "Voir l'album"
}
},
"song": {
"name": "Chanson",
"name_plural": "Chansons",
"lists": {
"artistTopSongs": "Meilleures Chansons"
}
},
"artist": {
"name": "Artiste",
"name_plural": "Artistes",
"lists": {
"random": "Aléatoire",
"starred": "Favoris",
"alphabeticalByName": "Par Nom",
"sort": "Trier les artistes"
},
"actions": {
"view": "Voir l'artiste"
}
},
"playlist": {
"actions": {
"play": "Lire la playlist"
},
"name": "Playlist",
"name_plural": "Playlists"
},
"queue": {
"name": "File d'attente",
"name_plural": "Files d'attente"
}
},
"settings": {
"network": {
"values": {
"seconds": "{{value}} secondes",
"unlimitedKbps": "Illimité",
"kbps": "{{value}}kbit/s"
},
"options": {
"maxBitrateWifi": {
"title": "Débit binaire maximum (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Débit binaire maximum (mobile)"
},
"maxBuffer": {
"title": "Temps maximum en mémoire tampon"
},
"minBuffer": {
"title": "Temps minimum en mémoire tampon"
}
},
"name": "Réseau"
},
"servers": {
"fields": {
"username": "Nom d'utilisateur",
"address": "Adresse",
"password": "Mot de passe"
},
"actions": {
"testConnection": "Tester la connexion",
"add": "Ajouter un serveur",
"delete": "Supprimer",
"save": "Sauvegarder",
"edit": "Modifier le serveur"
},
"name": "Serveurs",
"options": {
"forcePlaintextPassword": {
"title": "Forcer le mot de passe en texte clair",
"descriptionOn": "Envoyer le mot de passe en test clair (héritage, assurez-vous que la connexion est sécurisée !)",
"descriptionOff": "Envoyer le mot de passe sous forme de jeton + salage"
}
},
"messages": {
"connectionOk": "Connexion à {{address}} OK !",
"connectionFailed": "Échec de la connexion à {{address}}, vérifiez les paramètres ou le serveur"
}
},
"music": {
"name": "Musique",
"options": {
"scrobble": {
"descriptionOff": "Ne pas scrobbler l'historique de lecture",
"descriptionOn": "Scrobbler l'historique de lecture",
"title": "Scrobbler la lecture"
}
}
},
"about": {
"version": "version {{version}}",
"name": "À propos",
"actions": {
"licenses": "Licenses",
"projectHomepage": "Page d'accueil du projet"
}
},
"reset": {
"actions": {
"clearImageCache": "Vider le cache d'images"
},
"name": "Réinitialiser"
}
},
"navigation": {
"tabs": {
"library": "Bibliothèque",
"home": "Accueil",
"search": "Recherche",
"settings": "Paramètres"
}
},
"search": {
"headerTitle": "Recherche : {{query}}",
"inputPlaceholder": "Recherche",
"moreResults": "Plus…",
"nowPlayingContext": "Résultats de recherche"
},
"context": {
"actions": {
"star": "Mettre en favoris",
"unstar": "Enlever des favoris"
}
},
"messages": {
"nothingHere": "Rien ici…"
}
}

View File

@@ -0,0 +1,152 @@
{
"resources": {
"artist": {
"name": "Artista",
"name_plural": "Artisti",
"actions": {
"view": "Vedi artista"
},
"lists": {
"random": "Casuale",
"starred": "Preferiti",
"sort": "Ordina artisti",
"alphabeticalByName": "Per nome"
}
},
"song": {
"lists": {
"artistTopSongs": "Brani più popolari"
},
"name": "Brano",
"name_plural": "Brani"
},
"album": {
"name": "Album",
"name_plural": "Album",
"lists": {
"random": "Casuale",
"newest": "Aggiunti di recente",
"recent": "Ascoltati di recente",
"alphabeticalByName": "Per nome",
"alphabeticalByArtist": "Per artista",
"byYear": "Per anno",
"byGenre": "Per genere",
"sort": "Ordina album",
"frequent": "Ascoltati frequentemente",
"starred": "Preferiti"
},
"actions": {
"play": "Riproduci album",
"view": "Vedi album"
}
},
"playlist": {
"name": "Playlist",
"name_plural": "Playlist",
"actions": {
"play": "Riproduci playlist"
}
},
"queue": {
"name": "Coda",
"name_plural": "Code"
}
},
"settings": {
"servers": {
"fields": {
"password": "Password",
"address": "Indirizzo",
"username": "Nome utente"
},
"options": {
"forcePlaintextPassword": {
"title": "Forza password in chiaro",
"descriptionOn": "Invia password in chiaro (deprecato, assicurati che la tua connessione sia sicura!)",
"descriptionOff": "Invia la password come token + salt"
}
},
"actions": {
"delete": "Rimuovi",
"edit": "Modifica server",
"add": "Aggiungi server",
"save": "Salva",
"testConnection": "Prova connessione"
},
"name": "Server",
"messages": {
"connectionOk": "Connesso a {{address}} con successo!",
"connectionFailed": "Connessione a {{address}} fallita, controlla le impostazioni o il server"
}
},
"network": {
"name": "Rete",
"values": {
"kbps": "{{value}}kbps",
"unlimitedKbps": "Illimitato",
"seconds": "{{value}} secondi"
},
"options": {
"maxBitrateWifi": {
"title": "Bitrate massimo (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Bitrate massimo (rete dati)"
},
"minBuffer": {
"title": "Tempo di buffer minimo"
},
"maxBuffer": {
"title": "Tempo di buffer massimo"
}
}
},
"music": {
"name": "Musica",
"options": {
"scrobble": {
"title": "Scrobbling delle riproduzioni",
"descriptionOn": "Scrobbling della cronologia di ascolto",
"descriptionOff": "Non eseguire lo scrobbling della cronologia d'ascolto"
}
}
},
"reset": {
"name": "Reimposta",
"actions": {
"clearImageCache": "Pulisci la cache delle immagini"
}
},
"about": {
"name": "Informazioni",
"version": "versione {{version}}",
"actions": {
"projectHomepage": "Pagina principale del progetto",
"licenses": "Licenze"
}
}
},
"context": {
"actions": {
"star": "Aggiungi ai preferiti",
"unstar": "Rimuovi dai preferiti"
}
},
"navigation": {
"tabs": {
"home": "Home",
"library": "Libreria",
"search": "Cerca",
"settings": "Impostazioni"
}
},
"messages": {
"nothingHere": "Non c'è niente qui…"
},
"search": {
"inputPlaceholder": "Ricerca",
"headerTitle": "Ricerca: {{query}}",
"moreResults": "Mostra di più…",
"nowPlayingContext": "Risultati della ricerca"
}
}

View File

@@ -0,0 +1,55 @@
{
"resources": {
"album": {
"lists": {
"random": "ランダムアルバム",
"frequent": "よく聴くアルバム",
"recent": "最近再生した",
"starred": "星付きアルバム"
},
"name": "アルバム"
},
"song": {
"name": "歌",
"lists": {
"artistTopSongs": "人気曲"
}
},
"artist": {
"name": "アーティスト"
},
"playlist": {
"name": "プレイリスト"
}
},
"navigation": {
"tabs": {
"home": "ホーム",
"library": "ライブラリ",
"search": "検索",
"settings": "設定"
}
},
"search": {
"inputPlaceholder": "検索"
},
"settings": {
"servers": {
"name": "サーバ"
},
"network": {
"name": "ネット"
},
"music": {
"name": "音楽"
},
"reset": {
"name": "リセット"
},
"about": {
"actions": {
"projectHomepage": "ホームページ"
}
}
}
}

View File

@@ -0,0 +1,152 @@
{
"resources": {
"artist": {
"name": "Artist",
"name_plural": "Artister",
"lists": {
"sort": "Sorter artister",
"random": "Tilfeldig",
"starred": "Stjernemerket",
"alphabeticalByName": "Etter navn"
},
"actions": {
"view": "Vis artist"
}
},
"playlist": {
"name": "Spilleliste",
"name_plural": "Spillelister",
"actions": {
"play": "Spill av spilleliste"
}
},
"song": {
"lists": {
"artistTopSongs": "Toppspor"
},
"name": "Spor",
"name_plural": "Spor"
},
"album": {
"name": "Album",
"name_plural": "Album",
"lists": {
"sort": "Sorter album",
"random": "Tilfeldig",
"newest": "Nylig tillagt",
"frequent": "Ofte spilt",
"recent": "Nylig spilt",
"starred": "Stjernemerket",
"alphabeticalByName": "Etter navn",
"alphabeticalByArtist": "Etter artist",
"byYear": "Etter år",
"byGenre": "Etter sjanger"
},
"actions": {
"play": "Spill album",
"view": "Vis album"
}
},
"queue": {
"name": "Kø",
"name_plural": "Køer"
}
},
"settings": {
"servers": {
"actions": {
"add": "Legg til tjener",
"testConnection": "Test tilkobling",
"delete": "Slett",
"save": "Lagre",
"edit": "Rediger tjener"
},
"messages": {
"connectionOk": "Tilkobling til {{address}} OK.",
"connectionFailed": "Tilkobling til {{address}} mislyktes. Sjekk innstillingene eller tjeneren."
},
"name": "Tjenere",
"fields": {
"address": "Adresse",
"username": "Brukernavn",
"password": "Passord"
},
"options": {
"forcePlaintextPassword": {
"title": "Påtving klartekstspassord",
"descriptionOn": "Send passord i klartekst (Foreldet. Forsikre deg om at tilkoblingen er sikker.)",
"descriptionOff": "Send passord som symbol + salt"
}
}
},
"network": {
"name": "Nettverk",
"values": {
"kbps": "{{value}} kbps",
"unlimitedKbps": "Ubegrenset",
"seconds": "{{value}} sekunder"
},
"options": {
"maxBitrateWifi": {
"title": "Maksimal bitrate (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Maksimal bitrate (mobil)"
},
"minBuffer": {
"title": "Minimal mellomlagringstid"
},
"maxBuffer": {
"title": "Maksimal mellomlagringstid"
}
}
},
"music": {
"name": "Musikk",
"options": {
"scrobble": {
"title": "Sporinfodelingsavspillinger",
"descriptionOn": "Sporinfodelings-avspillinghistorikk",
"descriptionOff": "Ikke utfør sporinfodeling av avspillingshistorikk"
}
}
},
"reset": {
"name": "Tilbakestill",
"actions": {
"clearImageCache": "Tøm bildehurtiglager"
}
},
"about": {
"name": "Om",
"version": "versjon {{version}}",
"actions": {
"projectHomepage": "Prosjekthjemmeside",
"licenses": "Lisenser"
}
}
},
"context": {
"actions": {
"star": "Stjernemerk",
"unstar": "Fjern stjernemerking"
}
},
"navigation": {
"tabs": {
"home": "Hjem",
"library": "Bibliotek",
"search": "Søk",
"settings": "Innstillinger"
}
},
"search": {
"inputPlaceholder": "Søk",
"headerTitle": "Søk: {{query}}",
"moreResults": "Mer …",
"nowPlayingContext": "Søkeresultater"
},
"messages": {
"nothingHere": "Ingenting her …"
}
}

View File

@@ -0,0 +1,157 @@
{
"settings": {
"servers": {
"actions": {
"delete": "Удалить",
"add": "Добавить сервер",
"edit": "Редактировать сервер",
"testConnection": "Проверить подключение",
"save": "Сохранить"
},
"name": "Серверы",
"fields": {
"address": "Адрес",
"username": "Имя пользователя",
"password": "Пароль"
},
"options": {
"forcePlaintextPassword": {
"descriptionOn": "Отправить пароль в виде текста (устарело, убедитесь, что ваше соединение безопасно!)",
"descriptionOff": "Отправить пароль в виде токена",
"title": "Принудительный текстовый пароль"
}
},
"messages": {
"connectionOk": "Подключение к {{address}} установлено!",
"connectionFailed": "Не удалось подключиться к {{address}}, проверьте настройки или сервер"
}
},
"network": {
"name": "Сеть",
"values": {
"kbps": "{{value}} кбит/с",
"unlimitedKbps": "Без ограничений",
"seconds": "{{value}} секунд"
},
"options": {
"maxBitrateWifi": {
"title": "Максимальный битрейт (Wi-Fi)"
},
"maxBitrateMobile": {
"title": "Максимальный битрейт (мобильный интернет)"
},
"minBuffer": {
"title": "Минимальное время буферизации"
},
"maxBuffer": {
"title": "Максимальное время буферизации"
}
}
},
"music": {
"name": "Музыка",
"options": {
"scrobble": {
"title": "Синхронизация воспроизведения",
"descriptionOff": "Не синхронизировать историю воспроизведений",
"descriptionOn": "Синхронизация истории воспроизведения"
}
}
},
"reset": {
"name": "Сброс",
"actions": {
"clearImageCache": "Очистить кэш изображения"
}
},
"about": {
"name": "О Subtracks",
"version": "версия {{version}}",
"actions": {
"projectHomepage": "Сайт проекта",
"licenses": "Лицензии"
}
}
},
"resources": {
"album": {
"name_0": "Альбом",
"name_1": "Альбома",
"name_2": "Альбомов",
"lists": {
"sort": "Сортировка альбомов",
"random": "Случайно",
"newest": "Недавно добавленные",
"frequent": "Часто проигрываемые",
"recent": "Недавно проигранные",
"starred": "Избранные",
"alphabeticalByName": "По имени",
"alphabeticalByArtist": "По исполнителю",
"byYear": "По году",
"byGenre": "По жанру"
},
"actions": {
"play": "Воспроизвести альбом",
"view": "Посмотреть альбом"
}
},
"song": {
"name_0": "Трек",
"name_1": "Трека",
"name_2": "Треков",
"lists": {
"artistTopSongs": "Лучшие треки"
}
},
"artist": {
"name_0": "Исполнитель",
"name_1": "Исполнителя",
"name_2": "Исполнителей",
"lists": {
"sort": "Сортировать исполнителей",
"random": "Случайно",
"starred": "Избранные",
"alphabeticalByName": "По имени"
},
"actions": {
"view": "Посмотреть исполнителя"
}
},
"playlist": {
"name_0": "Плейлист",
"name_1": "Плейлиста",
"name_2": "Плейлистов",
"actions": {
"play": "Воспроизвести плейлист"
}
},
"queue": {
"name_0": "Очередь",
"name_1": "Очереди",
"name_2": "Очередей"
}
},
"context": {
"actions": {
"star": "Избранное",
"unstar": "Убрать из избранного"
}
},
"navigation": {
"tabs": {
"library": "Библиотека",
"search": "Поиск",
"settings": "Настройки",
"home": "Главная"
}
},
"messages": {
"nothingHere": "Здесь ничего нет…"
},
"search": {
"inputPlaceholder": "Поиск",
"headerTitle": "Поиск: {{query}}",
"moreResults": "Больше…",
"nowPlayingContext": "Результаты поиска"
}
}

View File

@@ -0,0 +1,147 @@
{
"context": {
"actions": {
"unstar": "移除收藏",
"star": "收藏"
}
},
"settings": {
"servers": {
"name": "服务器",
"messages": {
"connectionFailed": "连接到 {{address}} 失败,检查设置或服务器",
"connectionOk": "连接到 {{address}} 正常!"
},
"options": {
"forcePlaintextPassword": {
"title": "强制使用明文密码",
"descriptionOn": "密码以明文发送(不推荐,注意链接安全!)",
"descriptionOff": "密码以 token + salt 加密发送"
}
},
"actions": {
"add": "添加服务器",
"testConnection": "测试连接",
"save": "保存",
"edit": "编辑服务器",
"delete": "删除"
},
"fields": {
"password": "密码",
"username": "用户名",
"address": "地址"
}
},
"network": {
"name": "网络",
"values": {
"seconds": "{{value}} 秒",
"kbps": "{{value}}kbps",
"unlimitedKbps": "不限制"
},
"options": {
"maxBitrateMobile": {
"title": "最大比特率 (3G/4G/5G)"
},
"minBuffer": {
"title": "最小缓冲时间"
},
"maxBuffer": {
"title": "最大缓冲时间"
},
"maxBitrateWifi": {
"title": "最大比特率 (Wi-Fi)"
}
}
},
"music": {
"name": "音乐",
"options": {
"scrobble": {
"title": "Scrobble模式",
"descriptionOn": "Scrobble播放历史",
"descriptionOff": "不记录scrobble历史"
}
}
},
"reset": {
"name": "重置",
"actions": {
"clearImageCache": "清除图片缓存"
}
},
"about": {
"name": "关于",
"version": "版本 {{version}}",
"actions": {
"projectHomepage": "项目地址",
"licenses": "许可"
}
}
},
"resources": {
"album": {
"actions": {
"view": "查看专辑",
"play": "播放专辑"
},
"name": "专辑",
"lists": {
"newest": "最近添加",
"frequent": "播放最多",
"alphabeticalByName": "根据名称",
"alphabeticalByArtist": "根据歌手",
"byYear": "根据年份",
"random": "随机",
"sort": "专辑排序",
"recent": "最近播放",
"byGenre": "根据类型",
"starred": "已收藏"
}
},
"playlist": {
"actions": {
"play": "全部播放"
},
"name": "播放列表"
},
"song": {
"name": "歌曲",
"lists": {
"artistTopSongs": "热门歌曲"
}
},
"artist": {
"name": "歌手",
"lists": {
"starred": "已收藏",
"sort": "歌手排序",
"random": "随机",
"alphabeticalByName": "根据名称"
},
"actions": {
"view": "查看歌手"
}
},
"queue": {
"name": "队列"
}
},
"navigation": {
"tabs": {
"home": "首页",
"library": "所有",
"search": "搜索",
"settings": "设置"
}
},
"search": {
"inputPlaceholder": "搜索",
"headerTitle": "搜索: {{query}}",
"moreResults": "更多…",
"nowPlayingContext": "搜索结果"
},
"messages": {
"nothingHere": "什么都没有…"
}
}

View File

@@ -27165,6 +27165,30 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-----
The following software may be included in this product: html-escaper. A copy of the source code may be downloaded from https://github.com/WebReflection/html-escaper.git. This software contains the following license and notice below:
Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-----
The following software may be included in this product: http-errors. A copy of the source code may be downloaded from https://github.com/jshttp/http-errors.git. This software contains the following license and notice below:
The 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:
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:
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:
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:
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 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:
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:
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:
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:
Copyright 2013 Naitik Shah

View File

@@ -14,6 +14,9 @@ public class MainActivity extends ReactActivity {
return "subtracks";
}
// required by react-native-screens
// "This change is required to avoid crashes related to View state being not persisted consistently across Activity restarts."
// https://reactnavigation.org/docs/getting-started
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(null);

View File

@@ -2,12 +2,12 @@ import RootNavigator from '@app/navigation/RootNavigator'
import SplashPage from '@app/screens/SplashPage'
import colors from '@app/styles/colors'
import React from 'react'
import { StatusBar, View, StyleSheet } from 'react-native'
import ProgressHook from './components/ProgressHook'
import { useStore } from './state/store'
import { StatusBar, StyleSheet, View } from 'react-native'
import { MenuProvider } from 'react-native-popup-menu'
import { QueryClientProvider } from 'react-query'
import ProgressHook from './components/ProgressHook'
import queryClient from './queryClient'
import { useStore } from './state/store'
const Debug = () => {
const currentTrackTitle = useStore(store => store.currentTrack?.title)

View File

@@ -16,7 +16,13 @@ const Button: React.FC<{
onPress={onPress}
disabled={disabled}
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>
)
}
@@ -26,6 +32,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.accent,
paddingHorizontal: 10,
minHeight: 42,
maxHeight: 42,
justifyContent: 'center',
borderRadius: 1000,
},
@@ -43,6 +50,7 @@ const styles = StyleSheet.create({
fontFamily: font.bold,
color: colors.text.primary,
paddingHorizontal: 14,
textAlign: 'center',
},
})

View File

@@ -6,12 +6,14 @@ import font from '@app/styles/font'
import { NavigationProp, useNavigation } from '@react-navigation/native'
import { ReactComponentLike } from 'prop-types'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollView, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'
import { Menu, MenuOption, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu'
import IconFA from 'react-native-vector-icons/FontAwesome'
import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import CoverArt from './CoverArt'
import { Star } from './Star'
import { withSuspenseMemo } from './withSuspense'
const { SlideInMenu } = renderers
@@ -106,7 +108,9 @@ const ContextMenuIconTextOption = React.memo<ContextMenuIconTextOptionProps>(
return (
<ContextMenuOption onSelect={onSelect}>
<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>
)
},
@@ -115,11 +119,13 @@ const ContextMenuIconTextOption = React.memo<ContextMenuIconTextOptionProps>(
const MenuHeader = React.memo<{
coverArt?: string
artistId?: string
albumId?: string
title: string
subtitle?: string
}>(({ coverArt, artistId, title, subtitle }) => (
<View style={styles.menuHeader}>
{artistId ? (
}>(({ coverArt, artistId, albumId, title, subtitle }) => {
let CoverArtComponent = <></>
if (artistId) {
CoverArtComponent = (
<CoverArt
type="artist"
artistId={artistId}
@@ -129,7 +135,20 @@ const MenuHeader = React.memo<{
size="thumbnail"
fadeDuration={0}
/>
) : (
)
} else if (albumId) {
CoverArtComponent = (
<CoverArt
type="album"
albumId={albumId}
style={styles.coverArt}
resizeMode="cover"
size="thumbnail"
fadeDuration={0}
/>
)
} else {
CoverArtComponent = (
<CoverArt
type="cover"
coverArt={coverArt}
@@ -138,43 +157,52 @@ const MenuHeader = React.memo<{
size="thumbnail"
fadeDuration={0}
/>
)}
<View style={styles.menuHeaderText}>
<Text numberOfLines={1} style={styles.menuTitle}>
{title}
</Text>
{subtitle ? (
<Text numberOfLines={1} style={styles.menuSubtitle}>
{subtitle}
</Text>
) : (
<></>
)}
</View>
</View>
))
)
}
const OptionStar = React.memo<{
return (
<View style={styles.menuHeader}>
{CoverArtComponent}
<View style={styles.menuHeaderText}>
<Text numberOfLines={1} style={styles.menuTitle}>
{title}
</Text>
{subtitle ? (
<Text numberOfLines={1} style={styles.menuSubtitle}>
{subtitle}
</Text>
) : (
<></>
)}
</View>
</View>
)
})
const OptionStar = withSuspenseMemo<{
id: string
type: StarrableItemType
additionalText?: string
}>(({ id, type, additionalText: text }) => {
const { query, toggle } = useStar(id, type)
const { t } = useTranslation()
return (
<ContextMenuIconTextOption
IconComponentRaw={<Star starred={!!query.data} size={26} />}
text={(query.data ? 'Unstar' : 'Star') + (text ? ` ${text}` : '')}
text={(query.data ? t('context.actions.unstar') : t('context.actions.star')) + (text ? ` ${text}` : '')}
onSelect={() => toggle.mutate()}
/>
)
})
const OptionViewArtist = React.memo<{
const OptionViewArtist = withSuspenseMemo<{
navigation: NavigationProp<any>
artist?: string
artistId?: string
}>(({ navigation, artist, artistId }) => {
const { t } = useTranslation()
if (!artist || !artistId) {
return <></>
}
@@ -184,17 +212,19 @@ const OptionViewArtist = React.memo<{
IconComponent={IconFA}
name="microphone"
size={26}
text="View Artist"
text={t('resources.artist.actions.view')}
onSelect={() => navigation.navigate('artist', { id: artistId, title: artist })}
/>
)
})
const OptionViewAlbum = React.memo<{
const OptionViewAlbum = withSuspenseMemo<{
navigation: NavigationProp<any>
album?: string
albumId?: string
}>(({ navigation, album, albumId }) => {
const { t } = useTranslation()
if (!album || !albumId) {
return <></>
}
@@ -204,7 +234,7 @@ const OptionViewAlbum = React.memo<{
IconComponent={IconFA5}
name="compact-disc"
size={26}
text="View Album"
text={t('resources.album.actions.view')}
onSelect={() => navigation.navigate('album', { id: albumId, title: album })}
/>
)
@@ -251,7 +281,7 @@ export const SongContextPressable: React.FC<SongContextPressableProps> = props =
return (
<ContextMenu
{...props}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} coverArt={song.coverArt} />}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} albumId={song.albumId} />}
menuOptions={
<>
<OptionStar id={song.id} type={song.itemType} />
@@ -298,7 +328,7 @@ export const NowPlayingContextPressable: React.FC<NowPlayingContextPressableProp
return (
<ContextMenu
{...props}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} coverArt={song.coverArt} />}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} albumId={song.albumId} />}
menuOptions={
<>
<OptionStar id={song.id} type={song.itemType} />
@@ -318,6 +348,8 @@ const styles = StyleSheet.create({
},
optionsWrapper: {
// marginBottom: 10,
paddingHorizontal: 20,
// backgroundColor: 'purple',
},
menuHeader: {
paddingTop: 14,
@@ -348,9 +380,11 @@ const styles = StyleSheet.create({
},
option: {
paddingVertical: 8,
paddingHorizontal: 20,
// paddingHorizontal: 100,
flexDirection: 'row',
alignItems: 'center',
// backgroundColor: 'blue',
overflow: 'hidden',
},
icon: {
marginRight: 10,

View File

@@ -1,4 +1,4 @@
import { useQueryArtistArtPath, useQueryCoverArtPath } from '@app/hooks/query'
import { useQueryAlbumCoverArtPath, useQueryArtistArtPath, useQueryCoverArtPath } from '@app/hooks/query'
import { CacheImageSize } from '@app/models/cache'
import colors from '@app/styles/colors'
import React, { useState } from 'react'
@@ -32,6 +32,11 @@ type CoverArtProps = BaseProps & {
coverArt?: string
}
type AlbumIdProps = BaseProps & {
type: 'album'
albumId?: string
}
type ImageSourceProps = BaseProps & {
data?: string
isFetching: boolean
@@ -82,7 +87,13 @@ const CoverArtImage = React.memo<CoverArtProps>(props => {
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
})
const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps>(props => {
const AlbumIdIamge = React.memo<AlbumIdProps>(props => {
const { data, isFetching, isExistingFetching } = useQueryAlbumCoverArtPath(props.albumId, props.size)
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
})
const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps | AlbumIdProps>(props => {
const viewStyles = [props.style]
if (props.round) {
viewStyles.push(styles.round)
@@ -93,6 +104,9 @@ const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps>(props => {
case 'artist':
imageComponent = <ArtistImage {...(props as ArtistCoverArtProps)} />
break
case 'album':
imageComponent = <AlbumIdIamge {...(props as AlbumIdProps)} />
break
default:
imageComponent = <CoverArtImage {...(props as CoverArtProps)} />
break

View File

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

View File

@@ -160,6 +160,10 @@ const ListItem: React.FC<{
size="thumbnail"
/>
)
} else if (item.itemType === 'song') {
coverArt = (
<CoverArt type="album" albumId={item.albumId} style={artStyle} resizeMode={resizeMode} size="thumbnail" />
)
} else {
coverArt = (
<CoverArt type="cover" coverArt={item.coverArt} style={artStyle} resizeMode={resizeMode} size="thumbnail" />

View File

@@ -2,19 +2,22 @@ import Button from '@app/components/Button'
import { Song } from '@app/models/library'
import colors from '@app/styles/colors'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
import Icon from 'react-native-vector-icons/Ionicons'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import { withSuspenseMemo } from './withSuspense'
const ListPlayerControls = React.memo<{
const ListPlayerControls = withSuspenseMemo<{
songs: Song[]
typeName: string
listType: 'album' | 'playlist'
style?: StyleProp<ViewStyle>
play: () => void
shuffle: () => void
disabled?: boolean
}>(({ typeName, style, play, shuffle, disabled }) => {
}>(({ listType, style, play, shuffle, disabled }) => {
const [downloaded, setDownloaded] = useState(false)
const { t } = useTranslation()
return (
<View style={[styles.controls, style]}>
@@ -31,7 +34,7 @@ const ListPlayerControls = React.memo<{
</Button>
</View>
<View style={styles.controlsCenter}>
<Button title={`Play ${typeName}`} disabled={disabled} onPress={play} />
<Button title={t(`resources.${listType}.actions.play`)} disabled={disabled} onPress={play} />
</View>
<View style={styles.controlsSide}>
<Button disabled={disabled} onPress={shuffle}>
@@ -55,6 +58,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
maxWidth: '65%',
},
})

View File

@@ -1,20 +1,25 @@
import font from '@app/styles/font'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Text, View, StyleSheet, ViewStyle } from 'react-native'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { withSuspenseMemo } from './withSuspense'
const NothingHere = React.memo<{
const NothingHere = withSuspenseMemo<{
height?: number
width?: number
style?: ViewStyle
}>(({ height, width, style }) => {
const { t } = useTranslation()
height = height || 200
width = width || 200
return (
<View style={[styles.container, { height, width }, style]}>
<Icon name="music-rest-quarter" color={styles.text.color} size={width / 2} />
<Text style={[styles.text, { fontSize: width / 8 }]}>Nothing here...</Text>
<Text style={[styles.text, { fontSize: width / 8 }]} numberOfLines={3}>
{t('messages.nothingHere')}
</Text>
</View>
)
})

View File

@@ -79,7 +79,7 @@ const Controls = React.memo(() => {
const NowPlayingBar = React.memo(() => {
const navigation = useNavigation()
const currentTrackExists = useStore(store => !!store.currentTrack)
const coverArt = useStore(store => store.currentTrack?.coverArt)
const albumId = useStore(store => store.currentTrack?.albumId)
const title = useStore(store => store.currentTrack?.title)
const artist = useStore(store => store.currentTrack?.artist)
@@ -90,9 +90,9 @@ const NowPlayingBar = React.memo(() => {
<ProgressBar />
<View style={styles.subContainer}>
<CoverArt
type="cover"
type="album"
style={{ height: styles.subContainer.height, width: styles.subContainer.height }}
coverArt={coverArt}
albumId={albumId}
size="thumbnail"
fadeDuration={0}
/>

View File

@@ -0,0 +1,32 @@
import React, { ComponentType, FunctionComponent, Suspense, SuspenseProps } from 'react'
export function withSuspense<P extends string | number | object>(
WrappedComponent: ComponentType<P>,
fallback: SuspenseProps['fallback'] = null,
): FunctionComponent<P> {
function ComponentWithSuspense(props: P) {
return (
<Suspense fallback={fallback}>
<WrappedComponent {...props} />
</Suspense>
)
}
return ComponentWithSuspense
}
export function withSuspenseMemo<P extends string | number | object>(
WrappedComponent: ComponentType<P>,
fallback: SuspenseProps['fallback'] = null,
propsAreEqual?: Parameters<typeof React.memo>['1'],
) {
function ComponentWithSuspense(props: P) {
return (
<Suspense fallback={fallback}>
<WrappedComponent {...props} />
</Suspense>
)
}
return React.memo(ComponentWithSuspense, propsAreEqual)
}

View File

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

View File

@@ -1,19 +1,11 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { Album, AlbumCoverArt, Artist, Playlist, Song, StarrableItemType } from '@app/models/library'
import { Album, Artist, Playlist, Song, StarrableItemType } from '@app/models/library'
import { CollectionById } from '@app/models/state'
import queryClient from '@app/queryClient'
import { useStore } from '@app/state/store'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import _ from 'lodash'
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
useQueries,
useQuery,
UseQueryResult,
} from 'react-query'
import { useInfiniteQuery, useMutation, useQueries, useQuery } from 'react-query'
import {
useFetchAlbum,
useFetchAlbumList,
@@ -88,7 +80,7 @@ export const useQueryArtistTopSongs = (artistName?: string) => {
},
)
return useFixCoverArt(querySuccess ? query : backupQuery)
return querySuccess ? query : backupQuery
}
export const useQueryPlaylists = () => useQuery(qk.playlists, useFetchPlaylists())
@@ -109,7 +101,7 @@ export const useQueryPlaylist = (id: string, placeholderPlaylist?: Playlist) =>
},
})
return useFixCoverArt(query)
return query
}
export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
@@ -120,7 +112,7 @@ export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
placeholderAlbum ? { album: placeholderAlbum } : undefined,
})
return useFixCoverArt(query)
return query
}
export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => {
@@ -172,7 +164,7 @@ export const useQuerySearchResults = (params: Search3Params) => {
},
)
return useFixCoverArt(query)
return query
}
export const useQueryHomeLists = (types: GetAlbumList2TypeBase[], size: number) => {
@@ -314,93 +306,18 @@ export const useQueryArtistArtPath = (artistId: string, size: CacheImageSize = '
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
}
type WithSongs = Song[] | { songs?: Song[] }
type InfiniteWithSongs = { songs: Song[] }
type AnyDataWithSongs = WithSongs | InfiniteData<InfiniteWithSongs>
type AnyQueryWithSongs = UseQueryResult<WithSongs> | UseInfiniteQueryResult<{ songs: Song[] }>
function getSongs<T extends AnyDataWithSongs>(data: T | undefined): Song[] {
if (!data) {
return []
}
if (Array.isArray(data)) {
return data
}
if ('pages' in data) {
return data.pages.flatMap(p => p.songs)
}
return data.songs || []
}
function setSongCoverArt<T extends AnyQueryWithSongs>(query: T, coverArts: UseQueryResult<AlbumCoverArt>[]): T {
if (!query.data) {
return query
}
const mapSongCoverArt = (song: Song) => ({
...song,
coverArt: coverArts.find(c => c.data?.albumId === song.albumId)?.data?.coverArt,
})
if (Array.isArray(query.data)) {
return {
...query,
data: query.data.map(mapSongCoverArt),
}
}
if ('pages' in query.data) {
return {
...query,
data: {
pages: query.data.pages.map(p => ({
...p,
songs: p.songs.map(mapSongCoverArt),
})),
},
}
}
if (query.data.songs) {
return {
...query,
data: {
...query.data,
songs: query.data.songs.map(mapSongCoverArt),
},
}
}
return query
}
// song cover art comes back from the api as a unique id per song even if it all points to the same
// album art, which prevents us from caching it once, so we need to use the album's cover art
const useFixCoverArt = <T extends AnyQueryWithSongs>(query: T) => {
export const useQueryAlbumCoverArtPath = (albumId?: string, size: CacheImageSize = 'thumbnail') => {
const fetchAlbum = useFetchAlbum()
const songs = getSongs(query.data)
const albumIds = _.uniq((songs || []).map(s => s.albumId).filter((id): id is string => id !== undefined))
const coverArts = useQueries(
albumIds.map(id => ({
queryKey: qk.albumCoverArt(id),
queryFn: async (): Promise<AlbumCoverArt> => {
const res = await fetchAlbum(id)
return { albumId: res.album.id, coverArt: res.album.coverArt }
},
const query = useQuery(
qk.albumCoverArt(albumId || '-1'),
async () => (await fetchAlbum(albumId || '-1')).album.coverArt,
{
enabled: !!albumId,
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isFetched'] as any,
})),
},
)
if (coverArts.every(c => c.isFetched)) {
return setSongCoverArt(query, coverArts)
}
return query
return useQueryCoverArtPath(query.data, size)
}

View File

@@ -30,39 +30,46 @@ export const useFirstRun = () => {
export const useResetImageCache = () => {
const serverIds = useStoreDeep(store => Object.keys(store.settings.servers))
const changeCacheBuster = useStore(store => store.changeCacheBuster)
const setDisableMusicTabs = useStore(store => store.setDisableMusicTabs)
return async () => {
// disable/invalidate queries
await Promise.all([
queryClient.cancelQueries(qk.artistArt(), { active: true }),
queryClient.cancelQueries(qk.coverArt(), { active: true }),
queryClient.cancelQueries(qk.existingFiles(), { active: true }),
queryClient.invalidateQueries(qk.artistArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.coverArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.existingFiles(), { refetchActive: false }),
])
setDisableMusicTabs(true)
// delete all images
const itemTypes: CacheItemTypeKey[] = ['artistArt', 'artistArtThumb', 'coverArt', 'coverArtThumb']
await Promise.all(
serverIds.flatMap(id =>
itemTypes.map(async type => {
const dir = cacheDir(id, type)
try {
await RNFS.unlink(dir)
} catch {}
}),
),
)
try {
// disable/invalidate queries
await Promise.all([
queryClient.cancelQueries(qk.artistArt(), { active: true }),
queryClient.cancelQueries(qk.coverArt(), { active: true }),
queryClient.cancelQueries(qk.existingFiles(), { active: true }),
queryClient.invalidateQueries(qk.artistArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.coverArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.existingFiles(), { refetchActive: false }),
])
// change cacheBuster
changeCacheBuster()
// delete all images
const itemTypes: CacheItemTypeKey[] = ['artistArt', 'artistArtThumb', 'coverArt', 'coverArtThumb']
await Promise.all(
serverIds.flatMap(id =>
itemTypes.map(async type => {
const dir = cacheDir(id, type)
try {
await RNFS.unlink(dir)
} catch {}
}),
),
)
// enable queries
await Promise.all([
queryClient.refetchQueries(qk.existingFiles(), { active: true }),
queryClient.refetchQueries(qk.artistArt(), { active: true }),
queryClient.refetchQueries(qk.coverArt(), { active: true }),
])
// change cacheBuster
changeCacheBuster()
} finally {
setDisableMusicTabs(false)
// enable queries
await Promise.all([
queryClient.refetchQueries(qk.existingFiles(), { active: true }),
queryClient.refetchQueries(qk.artistArt(), { active: true }),
queryClient.refetchQueries(qk.coverArt(), { active: true }),
])
}
}
}

View File

@@ -1,13 +1,11 @@
import { Song } from '@app/models/library'
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
import queryClient from '@app/queryClient'
import queueService from '@app/queueservice'
import { useStore, useStoreDeep } from '@app/state/store'
import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer'
import userAgent from '@app/util/userAgent'
import _ from 'lodash'
import TrackPlayer from 'react-native-track-player'
import { useQueries } from 'react-query'
import { useFetchExistingFile, useFetchFile } from './fetch'
import qk from './queryKeys'
export const usePlay = () => {
@@ -92,87 +90,50 @@ export const useIsPlaying = (contextId: string | undefined, track: number) => {
return contextId === queueContextId && track === currentTrackIdx
}
export function mapSongToTrackExt(song: Song): TrackExt {
return {
id: song.id,
title: song.title,
artist: song.artist || 'Unknown Artist',
album: song.album || 'Unknown Album',
url: useStore.getState().buildStreamUri(song.id),
artwork: require('@res/fallback.png'),
userAgent,
duration: song.duration,
artistId: song.artistId,
albumId: song.albumId,
track: song.track,
discNumber: song.discNumber,
}
}
export const useSetQueue = (type: QueueContextType, songs?: Song[]) => {
const _setQueue = useStore(store => store.setQueue)
const client = useStore(store => store.client)
const buildStreamUri = useStore(store => store.buildStreamUri)
const fetchFile = useFetchFile()
const fetchExistingFile = useFetchExistingFile()
const songCoverArt = _.uniq((songs || []).map(s => s.coverArt)).filter((c): c is string => c !== undefined)
const coverArtPaths = useQueries(
songCoverArt.map(coverArt => ({
queryKey: qk.coverArt(coverArt, 'thumbnail'),
queryFn: async () => {
if (!client) {
return
}
const itemType = 'coverArtThumb'
const existingCache = queryClient.getQueryData<string | undefined>(qk.existingFiles(itemType, coverArt))
if (existingCache) {
return existingCache
}
const existingDisk = await fetchExistingFile({ itemId: coverArt, itemType })
if (existingDisk) {
return existingDisk
}
const fromUrl = client.getCoverArtUri({ id: coverArt, size: '256' })
return await fetchFile({
itemType,
itemId: coverArt,
fromUrl,
expectedContentType: 'image',
})
},
enabled: !!client && !!songs,
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isFetched'] as any,
})),
)
const songCoverArtToPath = _.zipObject(
songCoverArt,
coverArtPaths.map(c => c.data),
)
const mapSongToTrackExt = (s: Song): TrackExt => {
let artwork = require('@res/fallback.png')
if (s.coverArt) {
const filePath = songCoverArtToPath[s.coverArt]
if (filePath) {
artwork = `file://${filePath}`
}
}
return {
id: s.id,
title: s.title,
artist: s.artist || 'Unknown Artist',
album: s.album || 'Unknown Album',
url: buildStreamUri(s.id),
userAgent,
artwork,
coverArt: s.coverArt,
duration: s.duration,
artistId: s.artistId,
albumId: s.albumId,
track: s.track,
discNumber: s.discNumber,
}
}
const contextId = `${type}-${songs?.map(s => s.id).join('-')}`
const setQueue = async (options: SetQueueOptions) => {
const queue = (songs || []).map(mapSongToTrackExt)
return await _setQueue({ queue, type, contextId, ...options })
if (!songs || songs.length === 0) {
return
}
const queue = songs.map(mapSongToTrackExt)
const first = queue[options.playTrack || 0]
if (!first.albumId) {
first.artwork = require('@res/fallback.png')
} else {
const albumCoverArt = queryClient.getQueryData<string>(qk.albumCoverArt(first.albumId))
const existingFile = queryClient.getQueryData<string>(qk.existingFiles('coverArtThumb', albumCoverArt))
const downloadFile = queryClient.getQueryData<string>(qk.coverArt(albumCoverArt, 'thumbnail'))
if (existingFile || downloadFile) {
first.artwork = `file://${existingFile || downloadFile}`
}
}
await _setQueue({ queue, type, contextId, ...options })
queueService.emit('set', { queue })
}
return { setQueue, contextId, isReady: coverArtPaths.every(c => c.isFetched) }
return { setQueue, contextId }
}

64
app/i18n.ts Normal file
View File

@@ -0,0 +1,64 @@
import { BackendModule, LanguageDetectorAsyncModule } from 'i18next'
import path from 'path'
import RNFS from 'react-native-fs'
import * as RNLocalize from 'react-native-localize'
import _ from 'lodash'
const I18N_ASSETS_DIR = path.join('custom', 'i18n')
const cache: {
[language: string]: {
[key: string]: any
}
} = {}
async function loadTranslation(language: string) {
const text = await RNFS.readFileAssets(path.join(I18N_ASSETS_DIR, `${language}.json`), 'utf8')
return JSON.parse(text)
}
async function readTranslation(language: string, namespace: string) {
if (!cache[language]) {
cache[language] = await loadTranslation(language)
}
return namespace === 'translation' ? cache[language] : _.get(cache[language], namespace)
}
export const backend = {
type: 'backend',
init: () => {},
read: async (language, namespace, callback) => {
try {
callback(null, await readTranslation(language, namespace))
} catch (err) {
callback(err as any, null)
}
},
} as BackendModule
export const languageDetector = {
type: 'languageDetector',
async: true,
detect: async callback => {
try {
const languageTags = (await RNFS.readDirAssets(I18N_ASSETS_DIR))
.map(f => f.name)
.filter(n => n.endsWith('.json'))
.map(n => n.slice(0, -5))
console.log('translations available:', languageTags)
console.log(
'locales list:',
RNLocalize.getLocales().map(l => l.languageTag),
)
console.log('best language:', RNLocalize.findBestAvailableLanguage(languageTags)?.languageTag)
callback(RNLocalize.findBestAvailableLanguage(languageTags)?.languageTag)
} catch {
callback(undefined)
}
},
init: () => {},
cacheUserLanguage: () => {},
} as LanguageDetectorAsyncModule

View File

@@ -43,7 +43,6 @@ export interface Song {
discNumber?: number
duration?: number
starred?: number
coverArt?: string
playCount?: number
userRating?: number
averageRating?: number

View File

@@ -75,7 +75,6 @@ export function mapTrackExtToSong(track: TrackExt): Song {
title: track.title as string,
artist: track.artist,
album: track.album,
coverArt: track.coverArt,
duration: track.duration,
artistId: track.artistId,
albumId: track.albumId,

View File

@@ -18,10 +18,11 @@ const BottomTabButton = React.memo<{
isFocused: boolean
icon: OutlineFillIcon
navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap>
}>(({ routeKey, label, name, isFocused, icon, navigation }) => {
disabled?: boolean
}>(({ routeKey, label, name, isFocused, icon, navigation, disabled }) => {
const firstRun = useFirstRun()
const disabled = firstRun && name !== 'settings'
disabled = !!disabled || (firstRun && name !== 'settings')
const onPress = () => {
const event = navigation.emit({
@@ -47,7 +48,9 @@ const BottomTabButton = React.memo<{
return (
<PressableOpacity onPress={onPress} style={styles.button} disabled={disabled}>
<Image source={imgSource} style={imgStyle} fadeDuration={0} />
<Text style={textStyle}>{label}</Text>
<Text style={textStyle} numberOfLines={1} ellipsizeMode="clip">
{label}
</Text>
</PressableOpacity>
)
})
@@ -65,6 +68,13 @@ const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigat
? options.title
: route.name
let iconKey = route.name
let disabled = false
if (route.name.endsWith('-disabled')) {
iconKey = route.name.split('-')[0]
disabled = true
}
return (
<BottomTabButton
key={route.key}
@@ -72,8 +82,9 @@ const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigat
label={label}
name={route.name}
isFocused={state.index === index}
icon={bottomTabIcons[route.name]}
icon={bottomTabIcons[iconKey]}
navigation={navigation}
disabled={disabled}
/>
)
})}
@@ -92,6 +103,7 @@ const styles = StyleSheet.create({
},
button: {
alignItems: 'center',
flexGrow: 1,
flex: 1,
height: '100%',
},

View File

@@ -1,3 +1,4 @@
import { withSuspense } from '@app/components/withSuspense'
import { useFirstRun } from '@app/hooks/settings'
import { Album, Playlist } from '@app/models/library'
import BottomTabBar from '@app/navigation/BottomTabBar'
@@ -16,6 +17,7 @@ import font from '@app/styles/font'
import { BottomTabNavigationProp, createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { RouteProp, StackActions } from '@react-navigation/native'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import { createNativeStackNavigator, NativeStackNavigationProp } from 'react-native-screens/native-stack'
@@ -115,8 +117,8 @@ const SearchTab = createTabStackNavigator(Search)
type SettingsStackParamList = {
main: undefined
server?: { id?: string }
web: { uri: string }
server?: { id?: string; title?: string }
web: { uri: string; title?: string }
}
type ServerScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'server'>
@@ -125,7 +127,9 @@ type ServerScreenProps = {
route: ServerScreenRouteProp
navigation: ServerScreenNavigationProp
}
const ServerScreen: React.FC<ServerScreenProps> = ({ route }) => <ServerView id={route.params?.id} />
const ServerScreen: React.FC<ServerScreenProps> = ({ route }) => (
<ServerView id={route.params?.id} title={route.params?.title} />
)
type WebScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'web'>
type WebScreenRouteProp = RouteProp<SettingsStackParamList, 'web'>
@@ -133,7 +137,9 @@ type WebScreenProps = {
route: WebScreenRouteProp
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()
@@ -145,7 +151,6 @@ const SettingsTab = () => {
name="server"
component={ServerScreen}
options={{
title: 'Edit Server',
headerStyle: styles.stackheaderStyle,
headerHideShadow: true,
headerTintColor: 'white',
@@ -156,7 +161,6 @@ const SettingsTab = () => {
name="web"
component={WebScreen}
options={{
title: 'Web View',
headerStyle: styles.stackheaderStyle,
headerHideShadow: true,
headerTintColor: 'white',
@@ -169,24 +173,37 @@ const SettingsTab = () => {
const Tab = createBottomTabNavigator()
const BottomTabNavigator = () => {
const BottomTabNavigator = withSuspense(() => {
const { t } = useTranslation()
const firstRun = useFirstRun()
const resetServer = useStore(store => store.resetServer)
const disableMusicTabs = useStore(store => store.disableMusicTabs)
return (
<Tab.Navigator tabBar={BottomTabBar} initialRouteName={firstRun ? 'settings' : 'home'}>
{resetServer ? (
<></>
{disableMusicTabs ? (
<>
<Tab.Screen name="home-disabled" children={() => null} options={{ tabBarLabel: t('navigation.tabs.home') }} />
<Tab.Screen
name="library-disabled"
children={() => null}
options={{ tabBarLabel: t('navigation.tabs.library') }}
/>
<Tab.Screen
name="search-disabled"
children={() => null}
options={{ tabBarLabel: t('navigation.tabs.search') }}
/>
</>
) : (
<>
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: 'Home' }} />
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: 'Library' }} />
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: 'Search' }} />
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: t('navigation.tabs.home') }} />
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: t('navigation.tabs.library') }} />
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: t('navigation.tabs.search') }} />
</>
)}
<Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: 'Settings' }} />
<Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: t('navigation.tabs.settings') }} />
</Tab.Navigator>
)
}
})
export default BottomTabNavigator

View File

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

View File

@@ -1,3 +1,4 @@
import { withSuspense } from '@app/components/withSuspense'
import BottomTabNavigator from '@app/navigation/BottomTabNavigator'
import NowPlayingQueue from '@app/screens/NowPlayingQueue'
import NowPlayingView from '@app/screens/NowPlayingView'
@@ -5,32 +6,37 @@ import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { DarkTheme, NavigationContainer } from '@react-navigation/native'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
const NowPlayingStack = createNativeStackNavigator()
const NowPlayingNavigator = () => (
<NowPlayingStack.Navigator>
<NowPlayingStack.Screen name="main" component={NowPlayingView} options={{ headerShown: false }} />
<NowPlayingStack.Screen
name="queue"
component={NowPlayingQueue}
options={{
title: 'Queue',
headerStyle: {
backgroundColor: colors.gradient.high,
},
headerTitleStyle: {
fontSize: 18,
fontFamily: font.semiBold,
color: colors.text.primary,
},
headerHideShadow: true,
headerTintColor: 'white',
}}
/>
</NowPlayingStack.Navigator>
)
const NowPlayingNavigator = withSuspense(() => {
const { t } = useTranslation()
return (
<NowPlayingStack.Navigator>
<NowPlayingStack.Screen name="main" component={NowPlayingView} options={{ headerShown: false }} />
<NowPlayingStack.Screen
name="queue"
component={NowPlayingQueue}
options={{
title: t('resources.queue.name', { count: 1 }),
headerStyle: {
backgroundColor: colors.gradient.high,
},
headerTitleStyle: {
fontSize: 18,
fontFamily: font.semiBold,
color: colors.text.primary,
},
headerHideShadow: true,
headerTintColor: 'white',
}}
/>
</NowPlayingStack.Navigator>
)
})
const RootStack = createNativeStackNavigator()

View File

@@ -1,8 +1,14 @@
import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer'
import TrackPlayer, { Event, State } from 'react-native-track-player'
import { useStore } from './state/store'
import { unstable_batchedUpdates } from 'react-native'
import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo'
import _ from 'lodash'
import { unstable_batchedUpdates } from 'react-native'
import TrackPlayer, { Event, State } from 'react-native-track-player'
import { fetchAlbum, FetchExisingFileOptions, fetchExistingFile, fetchFile, FetchFileOptions } from './hooks/fetch'
import qk from './hooks/queryKeys'
import queryClient from './queryClient'
import queueService from './queueservice'
import { useStore } from './state/store'
import { ReturnedPromiseResolvedType } from './util/types'
const reset = () => {
unstable_batchedUpdates(() => {
@@ -34,12 +40,81 @@ const rebuildQueue = (forcePlay?: boolean) => {
})
}
const updateQueue = () => {
unstable_batchedUpdates(() => {
useStore.getState().updateQueue()
})
}
const setDuckPaused = (duckPaused: boolean) => {
unstable_batchedUpdates(() => {
useStore.getState().setDuckPaused(duckPaused)
})
}
const setQueryDataAlbum = (queryKey: any, data: ReturnedPromiseResolvedType<typeof fetchAlbum>) => {
unstable_batchedUpdates(() => {
queryClient.setQueryData(queryKey, data)
})
}
const setQueryDataExistingFiles = (queryKey: any, data: ReturnedPromiseResolvedType<typeof fetchExistingFile>) => {
unstable_batchedUpdates(() => {
queryClient.setQueryData(queryKey, data)
})
}
const setQueryDataCoverArt = (queryKey: any, data: ReturnedPromiseResolvedType<typeof fetchFile>) => {
unstable_batchedUpdates(() => {
queryClient.setQueryData(queryKey, data)
})
}
function getClient() {
const client = useStore.getState().client
if (!client) {
throw new Error('no client!')
}
return client
}
async function getAlbum(id: string) {
try {
const res = await fetchAlbum(id, getClient())
setQueryDataAlbum(qk.album(id), res)
return res
} catch {}
}
async function getCoverArtThumbExisting(coverArt: string) {
const serverId = useStore.getState().settings.activeServerId
const options: FetchExisingFileOptions = { itemType: 'coverArtThumb', itemId: coverArt }
try {
const res = await fetchExistingFile(options, serverId)
setQueryDataExistingFiles(qk.existingFiles(options.itemType, options.itemId), res)
return res
} catch {}
}
async function getCoverArtThumb(coverArt: string) {
const serverId = useStore.getState().settings.activeServerId
const fromUrl = getClient().getCoverArtUri({ id: coverArt, size: '256' })
const options: FetchFileOptions = {
itemType: 'coverArtThumb',
itemId: coverArt,
fromUrl,
expectedContentType: 'image',
}
try {
const res = await fetchFile(options, serverId)
setQueryDataCoverArt(qk.coverArt(coverArt, 'thumbnail'), res)
return res
} catch {}
}
let serviceCreated = false
const createService = async () => {
@@ -142,6 +217,78 @@ const createService = async () => {
rebuildQueue(true)
}
})
queueService.addListener('set', async ({ queue }) => {
const contextId = useStore.getState().queueContextId
const throwIfQueueChanged = () => {
if (contextId !== useStore.getState().queueContextId) {
throw 'queue-changed'
}
}
const albumIds = _.uniq(queue.map(s => s.albumId)).filter((id): id is string => id !== undefined)
const albumIdImagePath: { [albumId: string]: string | undefined } = {}
for (const albumId of albumIds) {
let coverArt = queryClient.getQueryData<string>(qk.albumCoverArt(albumId))
if (!coverArt) {
throwIfQueueChanged()
console.log('no cached coverArt for album', albumId, 'getting album...')
coverArt = (await getAlbum(albumId))?.album.coverArt
if (!coverArt) {
continue
}
}
let imagePath =
queryClient.getQueryData<string>(qk.existingFiles('coverArtThumb', coverArt)) ||
queryClient.getQueryData<string>(qk.coverArt(coverArt, 'thumbnail'))
if (!imagePath) {
throwIfQueueChanged()
console.log('no cached image for', coverArt, 'getting file...')
imagePath = (await getCoverArtThumbExisting(coverArt)) || (await getCoverArtThumb(coverArt))
if (!imagePath) {
continue
}
}
albumIdImagePath[albumId] = imagePath
}
for (let i = 0; i < queue.length; i++) {
const track = queue[i]
if (typeof track.artwork === 'string') {
continue
}
if (!track.albumId) {
continue
}
let imagePath = albumIdImagePath[track.albumId]
if (!imagePath) {
continue
}
try {
throwIfQueueChanged()
let trackIdx = i
const shuffleOrder = useStore.getState().shuffleOrder
if (shuffleOrder) {
trackIdx = shuffleOrder.indexOf(i)
}
await TrackPlayer.updateMetadataForTrack(trackIdx, { ...track, artwork: `file://${imagePath}` })
} catch {
break
}
}
await trackPlayerCommands.enqueue(async () => {
updateQueue()
})
})
}
module.exports = async function () {

18
app/queueservice.ts Normal file
View File

@@ -0,0 +1,18 @@
/* eslint-disable no-dupe-class-members */
import { EmitterSubscription, NativeEventEmitter } from 'react-native'
import { TrackExt } from './models/trackplayer'
class QueueService extends NativeEventEmitter {
addListener(eventType: 'set', listener: (event: { queue: TrackExt[] }) => void): EmitterSubscription
addListener(eventType: string, listener: (event: any) => void, context?: Object): EmitterSubscription {
return super.addListener(eventType, listener, context)
}
emit(eventType: 'set', event: { queue: TrackExt[] }): void
emit(eventType: string, ...params: any[]): void {
super.emit(eventType, ...params)
}
}
const queueService = new QueueService()
export default queueService

View File

@@ -5,6 +5,7 @@ import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import HeaderBar from '@app/components/HeaderBar'
import ListItem from '@app/components/ListItem'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryArtist, useQueryArtistTopSongs } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Song } from '@app/models/library'
@@ -15,6 +16,7 @@ import { useLayout } from '@react-native-community/hooks'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
@@ -42,53 +44,62 @@ const AlbumItem = React.memo<{
)
}, equal)
const TopSongs = React.memo<{
const TopSongs = withSuspenseMemo<{
songs: Song[]
name: string
}>(({ songs, name }) => {
const { setQueue, isReady, contextId } = useSetQueue('artist', songs)
}>(
({ songs, name }) => {
const { setQueue, contextId } = useSetQueue('artist', songs)
const { t } = useTranslation()
return (
<>
<Header>Top Songs</Header>
{songs.slice(0, 5).map((s, i) => (
<ListItem
key={i}
item={s}
contextId={contextId}
queueId={i}
showArt={true}
subtitle={s.album}
onPress={() => setQueue({ title: name, playTrack: i })}
disabled={!isReady}
/>
))}
</>
)
}, equal)
const ArtistAlbums = React.memo<{
albums: Album[]
}>(({ albums }) => {
const albumsLayout = useLayout()
const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
return (
<>
<Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
return (
<>
<Header>{t('resources.song.lists.artistTopSongs')}</Header>
{songs.slice(0, 5).map((s, i) => (
<ListItem
key={i}
item={s}
contextId={contextId}
queueId={i}
showArt={true}
subtitle={s.album}
onPress={() => setQueue({ title: name, playTrack: i })}
/>
))}
</View>
</>
)
}, equal)
</>
)
},
null,
equal,
)
const ArtistAlbums = withSuspenseMemo<{
albums: Album[]
}>(
({ albums }) => {
const albumsLayout = useLayout()
const { t } = useTranslation()
const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
return (
<>
<Header>{t('resources.album.name', { count: 2 })}</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))}
</View>
</>
)
},
null,
equal,
)
const ArtistViewFallback = React.memo(() => (
<GradientBackground style={styles.fallback}>

View File

@@ -3,25 +3,20 @@ import CoverArt from '@app/components/CoverArt'
import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import NothingHere from '@app/components/NothingHere'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryHomeLists } from '@app/hooks/query'
import { Album } from '@app/models/library'
import { useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { GetAlbumList2TypeBase, GetAlbumListType } from '@app/subsonic/params'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const titles: { [key in GetAlbumListType]?: string } = {
recent: 'Recently Played',
random: 'Random Albums',
frequent: 'Frequently Played',
starred: 'Starred Albums',
}
const AlbumItem = React.memo<{
album: Album
}>(({ album }) => {
@@ -49,6 +44,12 @@ const AlbumItem = React.memo<{
)
}, equal)
const CategoryHeader = withSuspenseMemo<{ type: string }>(({ type }) => {
const { t } = useTranslation()
console.log('type', type, t(`resources.album.lists.${type}`))
return <Header style={styles.header}>{t(`resources.album.lists.${type}`)}</Header>
})
const Category = React.memo<{
type: string
albums: Album[]
@@ -74,7 +75,7 @@ const Category = React.memo<{
return (
<View style={styles.category}>
<Header style={styles.header}>{titles[type as GetAlbumListType] || ''}</Header>
<CategoryHeader type={type} />
{albums.length > 0 ? <Albums /> : <Nothing />}
</View>
)

View File

@@ -1,16 +1,18 @@
import { AlbumContextPressable } from '@app/components/ContextMenu'
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 { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryAlbumList } from '@app/hooks/query'
import { Album } from '@app/models/library'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { GetAlbumList2Type, GetAlbumList2TypeBase } from '@app/subsonic/params'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'
const AlbumItem = React.memo<{
@@ -53,23 +55,36 @@ const AlbumListRenderItem: React.FC<{
item: { album: Album; size: number; height: number }
}> = ({ item }) => <AlbumItem album={item.album} size={item.size} height={item.height} />
const filterOptions: OptionData[] = [
{ text: 'By Name', value: 'alphabeticalByName' },
{ text: 'By Artist', value: 'alphabeticalByArtist' },
{ text: 'Newest', value: 'newest' },
{ text: 'Frequent', value: 'frequent' },
{ text: 'Recent', value: 'recent' },
{ text: 'Starred', value: 'starred' },
{ text: 'Random', value: 'random' },
// { text: 'By Year...', value: 'byYear' },
// { text: 'By Genre...', value: 'byGenre' },
const filterValues: GetAlbumList2TypeBase[] = [
'alphabeticalByName', //
'alphabeticalByArtist',
'newest',
'frequent',
'recent',
'starred',
'random',
]
const AlbumsList = () => {
const filter = useStoreDeep(store => store.settings.screens.library.albumsFilter)
const setFilter = useStore(store => store.setLibraryAlbumFilter)
const AlbumFilterButton = withSuspenseMemo(() => {
const { t } = useTranslation()
const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type)
const setFilterType = useStore(store => store.setLibraryAlbumFilterType)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filter.type as GetAlbumList2TypeBase, 300)
return (
<FilterButton
data={filterValues.map(value => ({ value, text: t(`resources.album.lists.${value}`) }))}
value={filterType}
onSelect={selection => {
setFilterType(selection as GetAlbumList2TypeBase)
}}
title={t('resources.album.lists.sort')}
/>
)
})
const AlbumsList = () => {
const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filterType as GetAlbumList2TypeBase, 300)
const layout = useWindowDimensions()
@@ -91,16 +106,7 @@ const AlbumsList = () => {
onEndReachedThreshold={6}
windowSize={5}
/>
<FilterButton
data={filterOptions}
value={filter.type}
onSelect={selection => {
setFilter({
...filter,
type: selection as GetAlbumList2Type,
})
}}
/>
<AlbumFilterButton />
</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 ListItem from '@app/components/ListItem'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryArtists } from '@app/hooks/query'
import { Artist } from '@app/models/library'
import { ArtistFilterType } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, View } from 'react-native'
const ArtistRenderItem: React.FC<{ item: Artist }> = ({ item }) => (
<ListItem item={item} showArt={true} showStar={false} listStyle="big" style={styles.listItem} />
)
const filterOptions: OptionData[] = [
{ text: 'By Name', value: 'alphabeticalByName' },
{ text: 'Starred', value: 'starred' },
{ text: 'Random', value: 'random' },
const filterValues: ArtistFilterType[] = [
'alphabeticalByName', //
'starred',
'random',
]
const ArtistFilterButton = withSuspenseMemo(() => {
const { t } = useTranslation()
const filterType = useStoreDeep(store => store.settings.screens.library.artistsFilter.type)
const setFilterType = useStore(store => store.setLibraryArtistFilterType)
return (
<FilterButton
data={filterValues.map(value => ({ value, text: t(`resources.artist.lists.${value}`) }))}
value={filterType}
onSelect={selection => setFilterType(selection as ArtistFilterType)}
title={t('resources.artist.lists.sort')}
/>
)
})
const ArtistsList = () => {
const filter = useStoreDeep(store => store.settings.screens.library.artistsFilter)
const setFilter = useStore(store => store.setLibraryArtistFiler)
const filterType = useStore(store => store.settings.screens.library.artistsFilter.type)
const { isLoading, data, refetch } = useQueryArtists()
const [sortedList, setSortedList] = useState<Artist[]>([])
@@ -32,7 +48,7 @@ const ArtistsList = () => {
}
const list = Object.values(data.byId)
switch (filter.type) {
switch (filterType) {
case 'random':
setSortedList([...list].sort(() => Math.random() - 0.5))
break
@@ -46,7 +62,7 @@ const ArtistsList = () => {
setSortedList([...list])
break
}
}, [filter.type, data])
}, [filterType, data])
return (
<View style={styles.container}>
@@ -60,16 +76,7 @@ const ArtistsList = () => {
windowSize={3}
contentMarginTop={6}
/>
<FilterButton
data={filterOptions}
value={filter.type}
onSelect={selection => {
setFilter({
...filter,
type: selection as ArtistFilterType,
})
}}
/>
<ArtistFilterButton />
</View>
)
}

View File

@@ -3,9 +3,10 @@ import HeaderBar from '@app/components/HeaderBar'
import ImageGradientBackground from '@app/components/ImageGradientBackground'
import PressableOpacity from '@app/components/PressableOpacity'
import { PressableStar } from '@app/components/Star'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useNext, usePause, usePlay, usePrevious, useSeekTo } from '@app/hooks/trackplayer'
import { mapTrackExtToSong } from '@app/models/map'
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
import { TrackExt } from '@app/models/trackplayer'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
@@ -13,6 +14,7 @@ import formatDuration from '@app/util/formatDuration'
import Slider from '@react-native-community/slider'
import { useNavigation } from '@react-navigation/native'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, StyleSheet, Text, TextStyle, View } from 'react-native'
import { NativeStackScreenProps } from 'react-native-screens/native-stack'
import { RepeatMode, State } from 'react-native-track-player'
@@ -21,32 +23,29 @@ import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import Icon from 'react-native-vector-icons/Ionicons'
import IconMatCom from 'react-native-vector-icons/MaterialCommunityIcons'
function getContextName(type?: QueueContextType) {
switch (type) {
case 'album':
return 'Album'
case 'artist':
return 'Top Songs'
case 'playlist':
return 'Playlist'
case 'song':
return 'Search Results'
default:
return undefined
}
}
const NowPlayingHeader = React.memo<{
const NowPlayingHeader = withSuspenseMemo<{
track?: TrackExt
}>(({ track }) => {
const queueName = useStore(store => store.queueName)
const queueContextType = useStore(store => store.queueContextType)
const { t } = useTranslation()
console.log(t('resources.album.name', { count: 1 }))
if (!track) {
return <></>
}
let contextName = getContextName(queueContextType)
let contextName: string
if (queueContextType === 'album') {
contextName = t('resources.album.name', { count: 1 })
} else if (queueContextType === 'artist') {
contextName = t('resources.song.lists.artistTopSongs')
} else if (queueContextType === 'playlist') {
contextName = t('resources.playlist.name', { count: 1 })
} else if (queueContextType === 'song') {
contextName = t('search.nowPlayingContext')
}
return (
<HeaderBar
@@ -91,11 +90,11 @@ const headerStyles = StyleSheet.create({
})
const SongCoverArt = () => {
const coverArt = useStore(store => store.currentTrack?.coverArt)
const albumId = useStore(store => store.currentTrack?.albumId)
return (
<View style={coverArtStyles.container}>
<CoverArt type="cover" size="original" coverArt={coverArt} style={coverArtStyles.image} />
<CoverArt type="album" size="original" albumId={albumId} style={coverArtStyles.image} />
</View>
)
}

View File

@@ -4,6 +4,7 @@ import Header from '@app/components/Header'
import ListItem from '@app/components/ListItem'
import NothingHere from '@app/components/NothingHere'
import TextInput from '@app/components/TextInput'
import { withSuspense, withSuspenseMemo } from '@app/components/withSuspense'
import { useQuerySearchResults } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Artist, SearchResults, Song } from '@app/models/library'
@@ -13,6 +14,7 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import _ from 'lodash'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
ActivityIndicator,
InteractionManager,
@@ -24,7 +26,7 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const SongItem = React.memo<{ item: Song }>(({ item }) => {
const { setQueue, isReady, contextId } = useSetQueue('song', [item])
const { setQueue, contextId } = useSetQueue('song', [item])
return (
<ListItem
@@ -34,61 +36,87 @@ const SongItem = React.memo<{ item: Song }>(({ item }) => {
showArt={true}
showStar={false}
onPress={() => setQueue({ title: item.title, playTrack: 0 })}
disabled={!isReady}
/>
)
}, equal)
const ResultsCategory = React.memo<{
const ResultsCategory = withSuspenseMemo<{
name: string
query: string
items: (Artist | Album | Song)[]
type: 'artist' | 'album' | 'song'
}>(({ name, query, type, items }) => {
const navigation = useNavigation()
}>(
({ name, query, type, items }) => {
const navigation = useNavigation()
const { t } = useTranslation()
if (items.length === 0) {
return <></>
}
if (items.length === 0) {
return <></>
}
return (
<>
<Header>{name}</Header>
{items.map(a =>
type === 'song' ? (
<SongItem key={a.id} item={a as Song} />
) : (
<ListItem key={a.id} item={a} showArt={true} showStar={false} />
),
)}
{items.length === 5 && (
<Button
title="More..."
buttonStyle="hollow"
style={styles.more}
onPress={() => navigation.navigate('results', { query, type: items[0].itemType })}
/>
)}
</>
)
}, equal)
return (
<>
<Header>{name}</Header>
{items.map(a =>
type === 'song' ? (
<SongItem key={a.id} item={a as Song} />
) : (
<ListItem key={a.id} item={a} showArt={true} showStar={false} />
),
)}
{items.length === 5 && (
<Button
title={t('search.moreResults')}
buttonStyle="hollow"
style={styles.more}
onPress={() => navigation.navigate('results', { query, type: items[0].itemType })}
/>
)}
</>
)
},
null,
equal,
)
const Results = React.memo<{
const Results = withSuspenseMemo<{
results: SearchResults
query: string
}>(({ results, query }) => {
return (
<>
<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)
}>(
({ results, query }) => {
const { t } = useTranslation()
const Search = () => {
return (
<>
<ResultsCategory
name={t('resources.artist.name', { count: 2 })}
query={query}
type={'artist'}
items={results.artists}
/>
<ResultsCategory
name={t('resources.album.name', { count: 2 })}
query={query}
type={'album'}
items={results.albums}
/>
<ResultsCategory
name={t('resources.song.name', { count: 2 })}
query={query}
type={'song'}
items={results.songs}
/>
</>
)
},
null,
equal,
)
const Search = withSuspense(() => {
const [query, setQuery] = useState('')
const { data, isLoading } = useQuerySearchResults({ query, albumCount: 5, artistCount: 5, songCount: 5 })
const { t } = useTranslation()
const [text, setText] = useState('')
const searchBarRef = useRef<ReactTextInput>(null)
@@ -140,7 +168,7 @@ const Search = () => {
<TextInput
ref={searchBarRef}
style={styles.textInput}
placeholder="Search"
placeholder={t('search.inputPlaceholder')}
value={text}
onChangeText={onChangeText}
/>
@@ -154,7 +182,7 @@ const Search = () => {
</View>
</GradientScrollView>
)
}
})
const styles = StyleSheet.create({
scroll: {

View File

@@ -1,17 +1,19 @@
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { withSuspense } from '@app/components/withSuspense'
import { useQuerySearchResults } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Artist, Song } from '@app/models/library'
import { Search3Params } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
type SearchListItemType = Album | Song | Artist
const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => {
const { setQueue, isReady, contextId } = useSetQueue('song', [item])
const { setQueue, contextId } = useSetQueue('song', [item])
return (
<ListItem
@@ -23,7 +25,6 @@ const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => {
listStyle="small"
onPress={() => setQueue({ title: item.title, playTrack: 0 })}
style={styles.listItem}
disabled={!isReady}
/>
)
}
@@ -52,11 +53,12 @@ const ResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
const SearchResultsRenderItem: React.FC<{ item: SearchListItemType }> = ({ item }) => <ResultsListItem item={item} />
const SearchResultsView: React.FC<{
const SearchResultsView = withSuspense<{
query: string
type: 'album' | 'artist' | 'song'
}> = ({ query, type }) => {
}>(({ query, type }) => {
const navigation = useNavigation()
const { t } = useTranslation()
const size = 100
const params: Search3Params = { query }
@@ -82,7 +84,7 @@ const SearchResultsView: React.FC<{
useEffect(() => {
navigation.setOptions({
title: `Search: "${query}"`,
title: t('search.headerTitle', { query }),
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -102,7 +104,7 @@ const SearchResultsView: React.FC<{
windowSize={5}
/>
)
}
})
const styles = StyleSheet.create({
listItem: {

View File

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

View File

@@ -5,25 +5,28 @@ import PressableOpacity from '@app/components/PressableOpacity'
import SettingsItem from '@app/components/SettingsItem'
import SettingsSwitch from '@app/components/SettingsSwitch'
import TextInput from '@app/components/TextInput'
import { 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 { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/core'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { KeyboardTypeOptions, Linking, Modal, Pressable, StyleSheet, Text, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { version } from '../../package.json'
const ServerItem = React.memo<{
const ServerItem = withSuspenseMemo<{
server: Server
}>(({ server }) => {
const activeServerId = useStore(store => store.settings.activeServerId)
const switchActiveServer = useSwitchActiveServer()
const navigation = useNavigation()
const { t } = useTranslation()
const setActive = useCallback(() => {
switchActiveServer(server.id)
@@ -33,7 +36,7 @@ const ServerItem = React.memo<{
<SettingsItem
title={server.address}
subtitle={server.username}
onPress={() => navigation.navigate('server', { id: server.id })}>
onPress={() => navigation.navigate('server', { id: server.id, title: t('settings.servers.actions.edit') })}>
<PressableOpacity style={styles.serverActive} onPress={setActive}>
{activeServerId === server.id ? (
<Icon name="checkbox-marked-circle" size={30} color={colors.accent} />
@@ -73,25 +76,27 @@ const ModalChoice = React.memo<{
)
})
function bitrateString(bitrate: number): string {
return bitrate === 0 ? 'Unlimited' : `${bitrate}kbps`
}
const BitrateModal = React.memo<{
const BitrateModal = withSuspenseMemo<{
title: string
bitrate: number
setBitrate: (bitrate: number) => void
}>(({ title, bitrate, setBitrate }) => {
const { t } = useTranslation()
const [visible, setVisible] = useState(false)
const toggleModal = useCallback(() => setVisible(!visible), [visible])
const bitrateText = useCallback(
(value: number) =>
value === 0 ? t('settings.network.values.unlimitedKbps') : t('settings.network.values.kbps', { value }),
[t],
)
const BitrateChoice: React.FC<{ value: number }> = useCallback(
({ value }) => {
const text = bitrateString(value)
return (
<ModalChoice
text={text}
text={bitrateText(value)}
value={value}
setValue={setBitrate}
closeModal={toggleModal}
@@ -99,12 +104,12 @@ const BitrateModal = React.memo<{
/>
)
},
[bitrate, toggleModal, setBitrate],
[bitrate, toggleModal, setBitrate, bitrateText],
)
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}>
<Pressable style={styles.modalBackdrop} onPress={toggleModal}>
<View style={styles.centeredView}>
@@ -135,9 +140,9 @@ const SettingsTextModal = React.memo<{
title: string
value: string
setValue: (text: string) => void
getUnit?: (text: string) => string
subtitle: (value: string) => string
keyboardType?: KeyboardTypeOptions
}>(({ title, value, setValue, getUnit, keyboardType }) => {
}>(({ title, value, setValue, subtitle, keyboardType }) => {
const [visible, setVisible] = useState(false)
const [inputText, setInputText] = useState(value)
@@ -148,16 +153,9 @@ const SettingsTextModal = React.memo<{
toggleModal()
}, [inputText, setValue, toggleModal])
const getSubtitle = useCallback(() => {
if (!getUnit) {
return value
}
return value + ' ' + getUnit(value)
}, [getUnit, value])
return (
<>
<SettingsItem title={title} subtitle={getSubtitle()} onPress={toggleModal} />
<SettingsItem title={title} subtitle={subtitle(value)} onPress={toggleModal} />
<Modal animationType="fade" transparent={true} visible={visible} onRequestClose={toggleModal}>
<Pressable style={styles.modalBackdrop} onPress={toggleModal}>
<View style={styles.centeredView}>
@@ -183,15 +181,9 @@ const SettingsTextModal = React.memo<{
)
})
function secondsUnit(seconds: string): string {
const numberValue = parseFloat(seconds)
if (Math.abs(numberValue) !== 1) {
return 'seconds'
}
return 'second'
}
const SettingsContent = withSuspenseMemo(() => {
const { t } = useTranslation()
const SettingsContent = React.memo(() => {
const servers = useStoreDeep(store => store.settings.servers)
const scrobble = useStore(store => store.settings.scrobble)
const setScrobble = useStore(store => store.setScrobble)
@@ -221,66 +213,85 @@ const SettingsContent = React.memo(() => {
const setMinBufferText = useCallback((text: string) => setMinBuffer(parseFloat(text)), [setMinBuffer])
const setMaxBufferText = useCallback((text: string) => setMaxBuffer(parseFloat(text)), [setMaxBuffer])
const secondsText = useCallback((value: string) => t('settings.network.values.seconds', { value }), [t])
return (
<View style={styles.content}>
<Header>Servers</Header>
<Header>{t('settings.servers.name')}</Header>
{Object.values(servers).map(s => (
<ServerItem key={s.id} server={s} />
))}
<Button
style={styles.button}
title="Add Server"
onPress={() => navigation.navigate('server')}
title={t('settings.servers.actions.add')}
onPress={() => navigation.navigate('server', { title: t('settings.servers.actions.add') })}
buttonStyle="hollow"
/>
<Header style={styles.header}>Network</Header>
<BitrateModal title="Maximum bitrate (Wi-Fi)" bitrate={maxBitrateWifi} setBitrate={setMaxBitrateWifi} />
<BitrateModal title="Maximum bitrate (mobile)" bitrate={maxBitrateMobile} setBitrate={setMaxBitrateMobile} />
<Header style={styles.header}>{t('settings.network.name')}</Header>
<BitrateModal
title={t('settings.network.options.maxBitrateWifi.title')}
bitrate={maxBitrateWifi}
setBitrate={setMaxBitrateWifi}
/>
<BitrateModal
title={t('settings.network.options.maxBitrateMobile.title')}
bitrate={maxBitrateMobile}
setBitrate={setMaxBitrateMobile}
/>
<SettingsTextModal
title="Minimum buffer time"
title={t('settings.network.options.minBuffer.title')}
value={minBuffer.toString()}
setValue={setMinBufferText}
getUnit={secondsUnit}
subtitle={secondsText}
keyboardType="numeric"
/>
<SettingsTextModal
title="Maximum buffer time"
title={t('settings.network.options.maxBuffer.title')}
value={maxBuffer.toString()}
setValue={setMaxBufferText}
getUnit={secondsUnit}
subtitle={secondsText}
keyboardType="numeric"
/>
<Header style={styles.header}>Music</Header>
<Header style={styles.header}>{t('settings.music.name')}</Header>
<SettingsSwitch
title="Scrobble plays"
subtitle={scrobble ? 'Scrobble play history' : "Don't scrobble play history"}
title={t('settings.music.options.scrobble.title')}
subtitle={
scrobble
? t('settings.music.options.scrobble.descriptionOn')
: t('settings.music.options.scrobble.descriptionOff')
}
value={scrobble}
setValue={setScrobble}
/>
<Header style={styles.header}>Reset</Header>
<Header style={styles.header}>{t('settings.reset.name')}</Header>
<Button
disabled={clearing}
style={styles.button}
title="Clear Image Cache"
title={t('settings.reset.actions.clearImageCache')}
onPress={clear}
buttonStyle="hollow"
/>
<Header style={styles.header}>About</Header>
<Header style={styles.header}>{t('settings.about.name')}</Header>
<Text style={styles.text}>
<Text style={styles.bold}>Subtracks</Text> version {version}
<Text style={styles.bold}>Subtracks</Text> {t('settings.about.version', { version })}
</Text>
<Button
disabled={clearing}
style={styles.button}
title="Project Homepage"
title={t('settings.about.actions.projectHomepage')}
onPress={() => Linking.openURL('https://github.com/austinried/subtracks')}
buttonStyle="hollow"
/>
<Button
disabled={clearing}
style={styles.button}
title="Licenses"
onPress={() => navigation.navigate('web', { uri: 'file:///android_asset/licenses.html' })}
title={t('settings.about.actions.licenses')}
onPress={() =>
navigation.navigate('web', {
uri: 'file:///android_asset/licenses.html',
title: t('settings.about.actions.licenses'),
})
}
buttonStyle="hollow"
/>
</View>

View File

@@ -56,10 +56,8 @@ const SongListDetails = React.memo<{
const [headerColor, setHeaderColor] = useState<string | undefined>(undefined)
const _songs = [...(songs || [])]
let typeName = ''
if (type === 'album') {
typeName = 'Album'
if (_songs.some(s => s.track === undefined)) {
_songs.sort((a, b) => a.title.localeCompare(b.title))
} else {
@@ -69,17 +67,15 @@ const SongListDetails = React.memo<{
return aVal - bVal
})
}
} else {
typeName = 'Playlist'
}
const { setQueue, isReady, contextId } = useSetQueue(type, _songs)
const { setQueue, contextId } = useSetQueue(type, _songs)
if (!songList) {
return <SongListDetailsFallback />
}
const disabled = !isReady || _songs.length === 0
const disabled = _songs.length === 0
const play = (track?: number, shuffle?: boolean) => () =>
setQueue({ title: songList.name, playTrack: track, shuffle })
@@ -125,7 +121,7 @@ const SongListDetails = React.memo<{
<ListPlayerControls
style={styles.controls}
songs={_songs}
typeName={typeName}
listType={type}
play={play(undefined, false)}
shuffle={play(undefined, true)}
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'
const WebViewScreen: React.FC<{
uri: string
}> = ({ uri }) => {
title?: string
}> = ({ uri, title }) => {
const navigation = useNavigation()
useEffect(() => {
navigation.setOptions({ title })
}, [navigation, title])
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 { GetStore, SetStore } from '@app/state/store'
import { SubsonicApiClient } from '@app/subsonic/api'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import uuid from 'react-native-uuid'
export type SettingsSlice = {
@@ -26,7 +27,9 @@ export type SettingsSlice = {
}
client?: SubsonicApiClient
resetServer: boolean
disableMusicTabs: boolean
setDisableMusicTabs: (value: boolean) => void
changeCacheBuster: () => void
@@ -43,8 +46,8 @@ export type SettingsSlice = {
pingServer: (server?: Server) => Promise<boolean>
setLibraryAlbumFilter: (filter: AlbumFilterSettings) => void
setLibraryArtistFiler: (filter: ArtistFilterSettings) => void
setLibraryAlbumFilterType: (type: GetAlbumList2TypeBase) => void
setLibraryArtistFilterType: (type: ArtistFilterType) => void
}
export function newCacheBuster(): string {
@@ -78,7 +81,12 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
cacheBuster: newCacheBuster(),
},
resetServer: false,
disableMusicTabs: false,
setDisableMusicTabs: value => {
set(store => {
store.disableMusicTabs = value
})
},
changeCacheBuster: () => {
set(store => {
@@ -103,7 +111,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
}
set(state => {
state.resetServer = true
state.disableMusicTabs = true
})
set(state => {
@@ -112,7 +120,7 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
})
set(state => {
state.resetServer = false
state.disableMusicTabs = false
})
},
@@ -216,15 +224,15 @@ export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice
}
},
setLibraryAlbumFilter: filter => {
setLibraryAlbumFilterType: type => {
set(state => {
state.settings.screens.library.albumsFilter = filter
state.settings.screens.library.albumsFilter.type = type
})
},
setLibraryArtistFiler: filter => {
setLibraryArtistFilterType: type => {
set(state => {
state.settings.screens.library.artistsFilter = filter
state.settings.screens.library.artistsFilter.type = type
})
},
})

View File

@@ -55,6 +55,7 @@ export type TrackPlayerSlice = {
setNetState: (netState: 'mobile' | 'wifi') => Promise<void>
rebuildQueue: (forcePlay?: boolean) => Promise<void>
updateQueue: () => Promise<void>
buildStreamUri: (id: string) => string
resetTrackPlayerState: () => void
@@ -314,6 +315,17 @@ export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlaye
})
},
updateQueue: async () => {
const newQueue = await getQueue()
const currentTrack = await getCurrentTrack()
set(state => {
state.queue = newQueue
if (currentTrack !== undefined) {
state.currentTrack = newQueue[currentTrack]
}
})
},
buildStreamUri: id => {
const client = get().client
if (!client) {

2
app/util/types.ts Normal file
View File

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

View File

@@ -11,6 +11,21 @@ LogBox.ignoreLogs([
"[react-native-gesture-handler] Seems like you're using an old API with gesture components, check out new Gestures system!",
])
import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'
import { backend, languageDetector } from '@app/i18n'
import * as RNLocalize from 'react-native-localize'
i18next.use(backend).use(languageDetector).use(initReactI18next).init({
compatibilityJSON: 'v3',
fallbackLng: 'en',
debug: true,
})
RNLocalize.addEventListener('change', () => {
languageDetector.detect(lng => i18next.changeLanguage(lng))
})
import { AppRegistry } from 'react-native'
import App from '@app/App'
import { name as appName } from '@app/app.json'

View File

@@ -0,0 +1,10 @@
**Full Changelog**: https://github.com/austinried/subtracks/compare/v1.2.0...v1.3.0
## What's Changed
### New
* Localization support by @austinried in https://github.com/austinried/subtracks/pull/99
* 9 new languages: Catalan, Chinese (Simplified), Danish, French, German, Italian, Japanese, Norwegian Bokmål, Russian
* Thanks to @comradekingu, @clyhtsuriva, @nortio, @retiolus, @hillwah, @shoddysheep and more users from @weblate!
### Fixed
* Remove unused CHECK_LICENSE permission by @austinried in https://github.com/austinried/subtracks/pull/109
* Fix performance issue/crash with large playlists by @austinried in https://github.com/austinried/subtracks/pull/111

View File

@@ -1,6 +1,6 @@
{
"name": "subtracks",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
@@ -31,18 +31,21 @@
"@xmldom/xmldom": "^0.7.0",
"content-disposition": "^0.5.4",
"fast-deep-equal": "^3.1.3",
"i18next": "^21.6.16",
"immer": "^9.0.6",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"mime-types": "^2.1.35",
"path": "^0.12.7",
"react": "17.0.2",
"react-i18next": "^11.16.6",
"react-native": "0.67.4",
"react-native-blob-util": "https://github.com/austinried/react-native-blob-util.git#android-downloadmanager-progress",
"react-native-fs": "^2.18.0",
"react-native-gesture-handler": "^2.3.2",
"react-native-image-colors": "^1.3.0",
"react-native-linear-gradient": "^2.5.6",
"react-native-localize": "^2.2.1",
"react-native-popup-menu": "^0.15.11",
"react-native-reanimated": "^2.3.1",
"react-native-safe-area-context": "^3.2.0",

View File

@@ -719,6 +719,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2":
version "7.17.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72"
integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2":
version "7.17.8"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.8.tgz#3e56e4aff81befa55ac3ac6a0967349fd1c5bca2"
@@ -1920,9 +1927,9 @@ async-limiter@~1.0.0:
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
async@^2.4.0:
version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
version "2.6.4"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
dependencies:
lodash "^4.17.14"
@@ -3614,11 +3621,18 @@ html-encoding-sniffer@^2.0.1:
dependencies:
whatwg-encoding "^1.0.5"
html-escaper@^2.0.0:
html-escaper@^2.0.0, html-escaper@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
html-parse-stringify@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2"
integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==
dependencies:
void-elements "3.1.0"
http-errors@1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
@@ -3652,6 +3666,13 @@ human-signals@^1.1.1:
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
i18next@^21.6.16:
version "21.6.16"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.6.16.tgz#8cff8c3ba2ffaf8438a8c83fe284083f15cf3941"
integrity sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==
dependencies:
"@babel/runtime" "^7.17.2"
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -6016,6 +6037,15 @@ react-freeze@^1.0.0:
resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.0.tgz#b21c65fe1783743007c8c9a2952b1c8879a77354"
integrity sha512-yQaiOqDmoKqks56LN9MTgY06O0qQHgV4FUrikH357DydArSZHQhl0BJFqGKIZoTqi8JizF9Dxhuk1FIZD6qCaw==
react-i18next@^11.16.6:
version "11.16.6"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.16.6.tgz#e8a07802c391a55e1528673201a2727994787641"
integrity sha512-qa76GnvAPafNSxKNN/XMhdCkVN/9Lm+BpzW5+6FE2ctYUemhbglP7oklGmYiJXlG24p9itqzlJDbCi3SNd3jzA==
dependencies:
"@babel/runtime" "^7.14.5"
html-escaper "^2.0.2"
html-parse-stringify "^3.0.1"
"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
@@ -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"
integrity sha512-HDwEaXcQIuXXCV70O+bK1rizFong3wj+5Q/jSyifKFLg0VWF95xh8XQgfzXwtq0NggL9vNjPKXa016KuFu+VFg==
react-native-localize@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-native-localize/-/react-native-localize-2.2.1.tgz#6fe646833691c6ee8a474df3c8b069402cb1dba8"
integrity sha512-BuPaQWvxLZG1NrCDGqgAnecDrNQu3LED9/Pyl4H2LwTMHcEngXpE5PfVntW2GiLumdr6nUOkWmMnh8PynZqrsw==
react-native-popup-menu@^0.15.11:
version "0.15.12"
resolved "https://registry.yarnpkg.com/react-native-popup-menu/-/react-native-popup-menu-0.15.12.tgz#386852f4245f8d661a5003776989b9b55c9ce381"
@@ -7393,6 +7428,11 @@ vlq@^1.0.0:
resolved "https://registry.yarnpkg.com/vlq/-/vlq-1.0.1.tgz#c003f6e7c0b4c1edd623fd6ee50bbc0d6a1de468"
integrity sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==
void-elements@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
integrity sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=
w3c-hr-time@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"