mirror of
https://github.com/austinried/subtracks.git
synced 2026-02-10 06:52:43 +01:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf3e8853d | ||
|
|
e14099472a | ||
|
|
ac06e21f37 | ||
|
|
92e2fd93f9 | ||
|
|
a5ccba69ec | ||
|
|
23eb05a368 | ||
|
|
1f9ee9b462 | ||
|
|
237b8d2fc6 | ||
|
|
35eada710e | ||
|
|
86ef5af6f6 | ||
|
|
76b290c8b3 | ||
|
|
a92ad7bfc9 | ||
|
|
1944add558 | ||
|
|
05e4b46469 | ||
|
|
00652952d8 | ||
|
|
83864217f9 | ||
|
|
b3ab75699e | ||
|
|
5cde911113 | ||
|
|
7fda50857f | ||
|
|
4ab51ea11a | ||
|
|
fcd5c1b167 | ||
|
|
2ccb397164 | ||
|
|
4e3a3133d7 | ||
|
|
2edd3a73fd | ||
|
|
4855043cda | ||
|
|
e6e997e4b5 | ||
|
|
c78fc65279 | ||
|
|
52e95dc959 | ||
|
|
b8948fb646 | ||
|
|
a91ac29626 | ||
|
|
aca677a432 | ||
|
|
708a404a21 | ||
|
|
7fa861d609 | ||
|
|
5a201c783f | ||
|
|
f98ed31475 | ||
|
|
6ebf1d265e | ||
|
|
07c4d14adf | ||
|
|
6b1b4c2c4f | ||
|
|
658d134f64 | ||
|
|
a9dbcfb69d | ||
|
|
860a4cec16 |
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
17
.github/release.yml
vendored
Normal 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:
|
||||
- "*"
|
||||
@@ -17,6 +17,7 @@ on:
|
||||
paths-ignore:
|
||||
- assets/**
|
||||
- .vscode/**
|
||||
- android/app/src/main/assets/custom/i18n/**
|
||||
- .eslintrc.js
|
||||
- .prettierrc.js
|
||||
- BUILDING.md
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](https://hosted.weblate.org/engage/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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
152
android/app/src/main/assets/custom/i18n/ca.json
vendored
Normal file
152
android/app/src/main/assets/custom/i18n/ca.json
vendored
Normal 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 d’usuari",
|
||||
"password": "Contrasenya",
|
||||
"address": "Adreça"
|
||||
},
|
||||
"options": {
|
||||
"forcePlaintextPassword": {
|
||||
"title": "Forçar la contrasenya de text sense format",
|
||||
"descriptionOn": "Enviar la contrasenya en text sense format (llegat, assegura't que la teva connexió sigui segura!)",
|
||||
"descriptionOff": "Enviar la contrasenya com a fitxa + sal"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"add": "Afegir un servidor",
|
||||
"testConnection": "Comprovar la connexió",
|
||||
"save": "Desar",
|
||||
"edit": "Editar el servidor",
|
||||
"delete": "Esborrar"
|
||||
},
|
||||
"messages": {
|
||||
"connectionOk": "Connexió a {{address}} OK!",
|
||||
"connectionFailed": "La connexió a {{address}} ha fallat, comprova la configuració o el servidor"
|
||||
},
|
||||
"name": "Servidors"
|
||||
},
|
||||
"network": {
|
||||
"name": "Xarxa",
|
||||
"values": {
|
||||
"seconds": "{{value}} segons",
|
||||
"kbps": "{{value}}kbit/s",
|
||||
"unlimitedKbps": "Il·limitat"
|
||||
},
|
||||
"options": {
|
||||
"maxBuffer": {
|
||||
"title": "Temps màxim de buffer"
|
||||
},
|
||||
"maxBitrateWifi": {
|
||||
"title": "Taxa de bits màxima (Wi-Fi)"
|
||||
},
|
||||
"maxBitrateMobile": {
|
||||
"title": "Taxa de bits màxima (mòbil)"
|
||||
},
|
||||
"minBuffer": {
|
||||
"title": "Temps mínim de buffer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"music": {
|
||||
"options": {
|
||||
"scrobble": {
|
||||
"title": "Capturar la lectura",
|
||||
"descriptionOn": "Capturar l'historial de reproduccions",
|
||||
"descriptionOff": "No capturar l'historial de reproducció"
|
||||
}
|
||||
},
|
||||
"name": "Música"
|
||||
},
|
||||
"reset": {
|
||||
"name": "Reinicialitzar",
|
||||
"actions": {
|
||||
"clearImageCache": "Esborrar la memòria cau d'imatges"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"name": "Quant a",
|
||||
"version": "versió {{version}}",
|
||||
"actions": {
|
||||
"projectHomepage": "Pàgina d'inici del projecte",
|
||||
"licenses": "Llicències"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"nowPlayingContext": "Resultats de la cerca",
|
||||
"inputPlaceholder": "Cercar",
|
||||
"moreResults": "Més…",
|
||||
"headerTitle": "Cercar: {{query}}"
|
||||
}
|
||||
}
|
||||
92
android/app/src/main/assets/custom/i18n/da.json
vendored
Normal file
92
android/app/src/main/assets/custom/i18n/da.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
android/app/src/main/assets/custom/i18n/de.json
vendored
Normal file
152
android/app/src/main/assets/custom/i18n/de.json
vendored
Normal 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…"
|
||||
}
|
||||
}
|
||||
152
android/app/src/main/assets/custom/i18n/en.json
vendored
Normal file
152
android/app/src/main/assets/custom/i18n/en.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
android/app/src/main/assets/custom/i18n/fr.json
vendored
Normal file
152
android/app/src/main/assets/custom/i18n/fr.json
vendored
Normal 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…"
|
||||
}
|
||||
}
|
||||
152
android/app/src/main/assets/custom/i18n/it.json
vendored
Normal file
152
android/app/src/main/assets/custom/i18n/it.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
55
android/app/src/main/assets/custom/i18n/ja.json
vendored
Normal file
55
android/app/src/main/assets/custom/i18n/ja.json
vendored
Normal 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": "ホームページ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
android/app/src/main/assets/custom/i18n/nb-NO.json
vendored
Normal file
152
android/app/src/main/assets/custom/i18n/nb-NO.json
vendored
Normal 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 …"
|
||||
}
|
||||
}
|
||||
157
android/app/src/main/assets/custom/i18n/ru.json
vendored
Normal file
157
android/app/src/main/assets/custom/i18n/ru.json
vendored
Normal 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": "Результаты поиска"
|
||||
}
|
||||
}
|
||||
147
android/app/src/main/assets/custom/i18n/zh-Hans.json
vendored
Normal file
147
android/app/src/main/assets/custom/i18n/zh-Hans.json
vendored
Normal 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": "什么都没有…"
|
||||
}
|
||||
}
|
||||
129
android/app/src/main/assets/licenses.html
vendored
129
android/app/src/main/assets/licenses.html
vendored
@@ -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
|
||||
|
||||
129
android/app/src/main/assets/licenses/npm.txt
vendored
129
android/app/src/main/assets/licenses/npm.txt
vendored
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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%',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
32
app/components/withSuspense.tsx
Normal file
32
app/components/withSuspense.tsx
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
64
app/i18n.ts
Normal 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
|
||||
@@ -43,7 +43,6 @@ export interface Song {
|
||||
discNumber?: number
|
||||
duration?: number
|
||||
starred?: number
|
||||
coverArt?: string
|
||||
playCount?: number
|
||||
userRating?: number
|
||||
averageRating?: number
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
18
app/queueservice.ts
Normal 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
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
2
app/util/types.ts
Normal 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>>
|
||||
15
index.js
15
index.js
@@ -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'
|
||||
|
||||
10
metadata/en-US/changelogs/9.txt
Normal file
10
metadata/en-US/changelogs/9.txt
Normal 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
|
||||
@@ -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",
|
||||
|
||||
48
yarn.lock
48
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user