Compare commits

..

23 Commits

Author SHA1 Message Date
Ahmed Alabsi 022d227d5e perf: use crypto.getRandomValues() for higher-quality shuffle in music player (#2181)
* fix: use crypto-safe randomness in shuffle helpers
2026-07-02 01:01:34 -07:00
Hosted Weblate 31f12a39b2 Translated using Weblate
Currently translated at 13.8% (178 of 1289 strings) (Thai)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/th/

Translated using Weblate

Currently translated at 100.0% (1289 of 1289 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

Currently translated at 97.0% (1251 of 1289 strings) (French)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/

Translated using Weblate

Currently translated at 100.0% (1289 of 1289 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Translated using Weblate

Currently translated at 25.8% (333 of 1289 strings) (Chinese (Simplified Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: Strom.wang <811191336@qq.com>
Co-authored-by: man sun <masrton888@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-07-01 09:01:30 +00:00
Hosted Weblate a6d82374dd Translated using Weblate
Currently translated at 12.8% (166 of 1289 strings) (Thai)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/th/

Co-authored-by: man sun <masrton888@gmail.com>
2026-06-30 08:56:25 +02:00
Kendall Garner 42bd8d34d9 fix(sidebar): only re-expand sidebar when enabled 2026-06-29 21:06:32 -07:00
jeffvli b397790402 additional fix for furigana/romaji lyric handlers (#2188)
- Romaji conversion joined all synced lyric lines into one string. Because the block contained kana somewhere, hasKana passed for the entire array of lyrics.
2026-06-29 20:50:13 -07:00
Hosted Weblate 7231f73ba7 Translated using Weblate
Currently translated at 100.0% (1289 of 1289 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 48.3% (622 of 1287 strings) (Ukrainian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/uk/

Translated using Weblate

Currently translated at 100.0% (1287 of 1287 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Translated using Weblate

Currently translated at 100.0% (1287 of 1287 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

Currently translated at 100.0% (1287 of 1287 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: albatrays <weblate.duct925@passmail.net>
Co-authored-by: linger <linger0517@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-06-30 05:29:06 +02:00
jeffvli 37ada07ee2 add "stopped" playback state and event handlers 2026-06-29 20:23:46 -07:00
York a221a84792 fix: romaji duplicate lines for non-Japanese lyrics (#2188) 2026-06-29 19:11:46 -07:00
Norman aa3c9251f5 feat: album group has a config and can set the image size (#2153)
* Created a new album group configuration which includes (for now) an option to set the image size of the album group artwork.
2026-06-29 19:00:20 -07:00
Benjamin 751ec7f835 fix lyric desync due to scroll issues (#2110) 2026-06-29 18:50:58 -07:00
Hosted Weblate 14bad5dbd7 Translated using Weblate
Currently translated at 100.0% (1287 of 1287 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 69.1% (889 of 1285 strings) (Hungarian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/hu/

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Soderes Sanyi <kennex@protonmail.com>
Co-authored-by: York <goog10216922@gmail.com>
2026-06-29 07:21:14 +02:00
BlackHoleFox 94aa34f6b2 Improve Jellyfin playlist loading and modification performance times (#2184)
* Remove unneeded Fields from getPlaylistSongList

* Add optimized controller function for playlist addition duplication checks

* Remove Jellyfin People data handling

* move artist map inline

---------

Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2026-06-29 05:21:04 +00:00
Kendall Garner da445b815d feat(genre): support sorting by track/album count 2026-06-28 19:39:32 -07:00
Tarulia c875146779 feat: add setting for static window title (#2183) 2026-06-29 01:35:42 +00:00
Kendall Garner 9806d2f553 fix: require all sorts to have default value 2026-06-28 17:36:14 -07:00
Kendall Garner 18a7fd0731 disconnect rpc on discord unmount 2026-06-28 15:31:15 -07:00
Hosted Weblate 062617bb40 Translated using Weblate
Currently translated at 100.0% (1285 of 1285 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 100.0% (1285 of 1285 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1285 of 1285 strings) (Spanish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/es/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: York <goog10216922@gmail.com>
2026-06-28 05:01:23 +00:00
York f8ca8861fc feat: add romaji lyrics display (#2180) 2026-06-26 21:07:58 -07:00
Ryan Kupka 26eea7422d fix: recover mpv playback after the OS resumes from sleep (#2172)
mpv/ffmpeg had no network-level timeout or reconnect options, so a
network stream left open across a system sleep would block forever on
the now-dead TCP connection instead of failing or reconnecting. Since
Node-MPV's IPC commands only resolve when mpv replies, a wedged mpv
process also made quit()/restart hang indefinitely, so the only way
out was to kill the whole app.

- Add --network-timeout and ffmpeg reconnect options to mpv's default
  parameters so a stalled stream fails fast instead of hanging.
- Make the quit() helper resilient to an unresponsive mpv process by
  racing it against a timeout and force-killing as a fallback.
- Listen for Electron's powerMonitor 'resume' event and tell the
  renderer to reload mpv, so playback recovers automatically instead
  of requiring a manual app restart.
2026-06-26 19:18:27 -07:00
Hosted Weblate 21d788226c Translated using Weblate
Currently translated at 100.0% (1283 of 1283 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
2026-06-26 22:01:23 +02:00
Hosted Weblate 9a1bf8f4a9 Translated using Weblate
Currently translated at 48.0% (616 of 1283 strings) (Ukrainian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/uk/

Co-authored-by: albatrays <weblate.duct925@passmail.net>
2026-06-24 15:27:33 +02:00
Hosted Weblate 0fab3ba318 Translated using Weblate
Currently translated at 100.0% (1283 of 1283 strings) (Estonian)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/et/

Translated using Weblate

Currently translated at 100.0% (1283 of 1283 strings) (Catalan)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/ca/

Translated using Weblate

Currently translated at 100.0% (1283 of 1283 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Co-authored-by: Ondo <SparkyOndo@proton.me>
Co-authored-by: rimasx <riks_12@hot.ee>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-06-24 10:01:26 +00:00
Norman 5ddbfcbfee Highlight the playlist in the left panel on play (#2025)
* Fixed bad smart playlist field s

* first try to add playlist highlight

* Simplified calls

* Now works for grids too.

* Derive the playlist highlight from the currently-playing track's origin instead of a stale global field.

* addressed comments
2026-06-24 03:18:02 +00:00
71 changed files with 1536 additions and 315 deletions
+44 -2
View File
@@ -973,7 +973,47 @@
"autoDJ_albumStrategy": "Mode de selecció d'àlbum", "autoDJ_albumStrategy": "Mode de selecció d'àlbum",
"autoDJ_songStrategy": "Mode de selecció de cançó", "autoDJ_songStrategy": "Mode de selecció de cançó",
"autoDJ_strategy_option_library_random": "A l'atzar", "autoDJ_strategy_option_library_random": "A l'atzar",
"autoDJ_strategy_option_similar": "Similar" "autoDJ_strategy_option_similar": "Similar",
"enableFurigana_description": "Mostra guies de pronunciació (furigana) per les lletres en japonès.",
"enableFurigana": "Activa la generació de furigana",
"equalizer_descriptionMpv": "Equalitzador paramètric amb FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Equalitzador paramètric amb l'API de Web Audio",
"equalizer": "Equalitzador",
"equalizerBands_description": "Guany per banda. Arrossegueu-lo amunt o avall, o introduïu-hi un valor. Rang: -12 a +12 dB.",
"equalizerBands": "Bandes",
"equalizerPreamp_description": "Guany d'entrada previ a les bandes de l'equalitzador. Poseu-lo en negatiu quan realceu les bandes per evitar el clipping (MPV).",
"equalizerPreamp": "Preamplificador",
"equalizerPreset_description": "Aplica una corba d'equalitzador personalitzada integrada o desada",
"equalizerPreset": "Preajustament",
"equalizerPresetDeletePlaceholder": "Elimina la personalització...",
"equalizerPresetGroupBuiltIn": "Integrat",
"equalizerPresetGroupCustom": "Personalitzat",
"equalizerPresetNamePlaceholder": "Nom de l'ajust predefinit...",
"equalizerPresetSelectPlaceholder": "Seleccioneu un ajust predefinit",
"equalizerSavePreset_description": "Desa la configuració actual de l'equalitzador com a ajust predefinit amb nom",
"equalizerSavePreset": "Desa l'ajust",
"compressor_descriptionMpv": "Compressor de rang dinàmic amb el compressor de FFmpeg (MPV)",
"compressor_descriptionWebAudio": "Compressor de rang dinàmic amb l'API de Web Audio",
"compressor": "Compressor",
"compressorAttack_description": "La rapidesa amb què el compressor s'activa quan el senyal excedeix el llindar.",
"compressorAttack": "Atac",
"compressorKnee_description": "Amplada de la zona de resposta suau. Com més alt sigui, més gradual serà la transició cap a la compressió.",
"compressorKnee": "Zona de resposta",
"compressorMakeupGain_description": "Guany de sortida aplicat després de la compressió per recuperar volum.",
"compressorMakeupGain": "Guany de compensació",
"compressorPreset_description": "Aplica una configuració personalitzada del compressor integrada o desada",
"compressorRatio_description": "Ràtio de compressio, p. ex. 4 = 4:1.",
"compressorRatio": "Ràtio",
"compressorRelease_description": "Com de ràpid el compressor es desactiva un cop el senyal sigui inferior al llindar.",
"compressorRelease": "Desactivació",
"compressorReset_description": "Restaura tots els paràmetres del compressor als seus valors per defecte",
"compressorSavePreset_description": "Desa la configuració actual del compressor com un ajust predefinit amb nom",
"compressorThreshold_description": "Nivell de senyal a partir del qual comença la compressió.",
"compressorThreshold": "Llindar",
"enableRomaji_description": "Mostra la pronunciació en romaji a sota de la lletra en japonès.",
"enableRomaji": "Activa la generació de romaji",
"windowBarTrackinfo": "Títol de la finestra d'informació de la pista",
"windowBarTrackinfo_description": "Mostra el títol i l'artista de la pista actual, la posició a la cua i l'estat de reproducció al títol de la finestra."
}, },
"table": { "table": {
"column": { "column": {
@@ -1266,7 +1306,9 @@
"notContains": "No conté", "notContains": "No conté",
"notInPlaylist": "No és a", "notInPlaylist": "No és a",
"notInTheLast": "No és a l'últim", "notInTheLast": "No és a l'últim",
"startsWith": "Comença amb" "startsWith": "Comença amb",
"isMissing": "Falta",
"isPresent": "Està present"
}, },
"queryBuilder": { "queryBuilder": {
"standardTags": "Etiquetes estàndard", "standardTags": "Etiquetes estàndard",
+39 -1
View File
@@ -457,7 +457,45 @@
"autoDJ_albumStrategy": "Režim výběru alb", "autoDJ_albumStrategy": "Režim výběru alb",
"autoDJ_songStrategy": "Režim výběru skladeb", "autoDJ_songStrategy": "Režim výběru skladeb",
"autoDJ_strategy_option_library_random": "Náhodně", "autoDJ_strategy_option_library_random": "Náhodně",
"autoDJ_strategy_option_similar": "Podobné" "autoDJ_strategy_option_similar": "Podobné",
"enableFurigana_description": "Zobrazit návody na výslovnost (furigana) u japonských kandži textů.",
"enableFurigana": "Povolit generování furigana",
"equalizer_descriptionMpv": "Parametrický ekvalizér skrze FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Parametrický ekvalizér skrze Web Audio API",
"equalizer": "Ekvalizér",
"equalizerBands_description": "Zisk na pásmo. Posuňte nahoru/dolů nebo zadejte hodnotu. Rozsah: -12 do +12 dB.",
"equalizerBands": "Pásma",
"equalizerPreamp_description": "Vstupní zisk před pásmy ekvalizéru. Při zvýšení pásem nastavte na negativní hodnotu pro zabránění clippingu (MPV).",
"equalizerPreamp": "Předzesilovač",
"equalizerPreset_description": "Použít vestavěnou nebo uloženou vlastní křivku ekvalizéru",
"equalizerPreset": "Předvolba",
"equalizerPresetDeletePlaceholder": "Odstranit vlastní…",
"equalizerPresetGroupBuiltIn": "Vestavěná",
"equalizerPresetGroupCustom": "Vlastní",
"equalizerPresetNamePlaceholder": "Název předvolby…",
"equalizerPresetSelectPlaceholder": "Vybrat předvolbu",
"equalizerSavePreset_description": "Uložit aktuální nastavení ekvalizéru jako pojmenovanou předvolbu",
"equalizerSavePreset": "Uložit předvolbu",
"compressor_descriptionMpv": "Kompresor dynamického rozsahu skrze FFmpeg acompressor (MPV)",
"compressor_descriptionWebAudio": "Kompresor dynamického rozsahu skrze Web Audio API",
"compressor": "Kompresor",
"compressorAttack_description": "Jak rychle se kompresor spustí, když signál překročí hranici.",
"compressorAttack": "Útok",
"compressorKnee_description": "Měkká šířka. Čím vyšší jsou hodnoty, tím pozvolnější je přechod do komprese.",
"compressorKnee": "Koleno",
"compressorMakeupGain_description": "Výstupní zesílení aplikované po kompresi za účelem obnovení hlasitosti.",
"compressorMakeupGain": "Následný zisk",
"compressorPreset_description": "Použít vestavěné nebo uložené vlastní nastavení kompresoru",
"compressorRatio_description": "Poměr komprese, např. 4 = 4:1.",
"compressorRatio": "Poměr",
"compressorRelease_description": "Jak rychle se kompresor uvolní, když signál spadne pod nastavenou hranici.",
"compressorRelease": "Uvolnění",
"compressorReset_description": "Obnovit všechny parametry kompresoru na jejich výchozí hodnoty",
"compressorSavePreset_description": "Uložit aktuální nastavení kompresoru jako pojmenovanou předvolbu",
"compressorThreshold_description": "Úroveň signálu, nad kterou začne komprese.",
"compressorThreshold": "Hranice",
"enableRomaji_description": "Zobrazit rómadži výslovnost pod japonskými texty.",
"enableRomaji": "Povolit generování rómadži"
}, },
"action": { "action": {
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})", "editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
+6
View File
@@ -845,6 +845,8 @@
"enableAutoTranslation": "Enable auto translation", "enableAutoTranslation": "Enable auto translation",
"enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.", "enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.",
"enableFurigana": "Enable furigana generation", "enableFurigana": "Enable furigana generation",
"enableRomaji_description": "Display a romaji pronunciation line under Japanese lyrics.",
"enableRomaji": "Enable romaji generation",
"equalizer_descriptionMpv": "Parametric equalizer via FFmpeg lavfi (MPV)", "equalizer_descriptionMpv": "Parametric equalizer via FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Parametric equalizer via Web Audio API", "equalizer_descriptionWebAudio": "Parametric equalizer via Web Audio API",
"equalizer": "Equalizer", "equalizer": "Equalizer",
@@ -1173,6 +1175,8 @@
"webAudio": "Use web audio", "webAudio": "Use web audio",
"windowBarStyle_description": "Select the style of the window bar", "windowBarStyle_description": "Select the style of the window bar",
"windowBarStyle": "Window bar style", "windowBarStyle": "Window bar style",
"windowBarTrackinfo": "Track info in Window Title",
"windowBarTrackinfo_description": "Show current track's title and artist, queue position, and Playing/Paused state in the Window's Title.",
"zoom_description": "Sets the zoom percentage for the application", "zoom_description": "Sets the zoom percentage for the application",
"zoom": "Zoom percentage", "zoom": "Zoom percentage",
"queryBuilder": "Query builder", "queryBuilder": "Query builder",
@@ -1214,6 +1218,8 @@
"config": { "config": {
"general": { "general": {
"advancedSettings": "Advanced settings", "advancedSettings": "Advanced settings",
"albumGroupConfig": "Album Group configuration",
"albumImageSize": "Album image size",
"autoFitColumns": "Auto fit columns", "autoFitColumns": "Auto fit columns",
"autosize": "Autosize", "autosize": "Autosize",
"moveUp": "Move up", "moveUp": "Move up",
+8 -2
View File
@@ -493,7 +493,11 @@
"compressorMakeupGain_description": "Ganancia de salida aplicada tras la compresión para recuperar el volumen.", "compressorMakeupGain_description": "Ganancia de salida aplicada tras la compresión para recuperar el volumen.",
"compressorMakeupGain": "Ganancia de compensación", "compressorMakeupGain": "Ganancia de compensación",
"compressorAttack_description": "La rapidez con la que el compresor entra en acción una vez que la señal supera el umbral.", "compressorAttack_description": "La rapidez con la que el compresor entra en acción una vez que la señal supera el umbral.",
"compressorAttack": "Ataque" "compressorAttack": "Ataque",
"enableRomaji_description": "Muestra una línea de pronunciación en romaji debajo de las letras japonesas.",
"enableRomaji": "Activar generación de romaji",
"windowBarTrackinfo": "Información de la pista en el título de la ventana",
"windowBarTrackinfo_description": "Muestra el título y artista de la pista actual, posición en la cola, y estado reproduciendo/pausado en el título de la ventana."
}, },
"action": { "action": {
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})", "editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
@@ -1185,7 +1189,9 @@
"horizontalBorders": "Bordes de fila", "horizontalBorders": "Bordes de fila",
"verticalBorders": "Bordes de columna", "verticalBorders": "Bordes de columna",
"rowHoverHighlight": "Resaltar al pasar el cursor por la fila", "rowHoverHighlight": "Resaltar al pasar el cursor por la fila",
"showHeader": "Mostrar cabecera" "showHeader": "Mostrar cabecera",
"albumImageSize": "Tamaño de la imagen del álbum",
"albumGroupConfig": "Configuración del grupo del álbum"
}, },
"view": { "view": {
"table": "Tabla", "table": "Tabla",
+37 -1
View File
@@ -1135,7 +1135,43 @@
"queryBuilderCustomFields_inputLabel": "Nimetus", "queryBuilderCustomFields_inputLabel": "Nimetus",
"queryBuilderCustomFields_inputTag": "Silt", "queryBuilderCustomFields_inputTag": "Silt",
"queryBuilderCustomFields": "Kohandatud väljad", "queryBuilderCustomFields": "Kohandatud väljad",
"queryBuilderCustomFields_description": "Lisa kohandatud välju, mida päringukoosturis kasutada" "queryBuilderCustomFields_description": "Lisa kohandatud välju, mida päringukoosturis kasutada",
"equalizer_descriptionMpv": "Parametriline ekvalaiser FFmpeg lavfi (MPV) kaudu",
"equalizer_descriptionWebAudio": "Parametriline ekvalaiser Web Audio API kaudu",
"equalizer": "Ekvalaiser",
"equalizerBands_description": "Riba põhivõimendus. Lohista üles/alla või sisesta väärtus. Vahemik: -12 kuni +12 dB.",
"equalizerBands": "Ribad",
"equalizerPreamp_description": "Sisendvõimendus enne ekvalaiseri ribasid. Moonutuste vältimiseks määra ribade võimendamisel negatiivne väärtus (MPV).",
"equalizerPreamp": "Eelvõimendus",
"equalizerPreset_description": "Rakenda sisseehitatud või salvestatud kohandatud EQ-häälestus",
"equalizerPreset": "Eelseadistus",
"equalizerPresetDeletePlaceholder": "Kustuta kohandatud...",
"equalizerPresetGroupBuiltIn": "Sisseehitatud",
"equalizerPresetGroupCustom": "Kohandatud",
"equalizerPresetNamePlaceholder": "Eelseadistuse nimi...",
"equalizerPresetSelectPlaceholder": "Vali eelseadistus",
"equalizerSavePreset_description": "Salvesta praegused EQ-seaded nimetatud eelseadistusena",
"equalizerSavePreset": "Salvesta eelseadistus",
"compressor_descriptionMpv": "Dünaamilise vahemiku kompressor FFmpeg acompressori kaudu (MPV)",
"compressor_descriptionWebAudio": "Dünaamilise vahemiku kompressor Web Audio API kaudu",
"enableFurigana_description": "Kuva jaapani kanji-märkide kohal hääldusjuhiseid (furigana).",
"enableFurigana": "Luba furigana kuvamine",
"compressor": "Kompressor",
"compressorAttack_description": "Kui kiiresti kompressor pärast läve ületamist rakendub.",
"compressorAttack": "Rakendumisaeg",
"compressorKnee_description": "Sujuva ülemineku (soft-knee) ulatus. Suuremad väärtused muudavad kompressiooni rakendumise astmelisemaks.",
"compressorKnee": "Üleminek",
"compressorMakeupGain_description": "Väljundvõimendus helitugevuse taastamiseks pärast kompressiooni.",
"compressorMakeupGain": "Väljundvõimendus",
"compressorPreset_description": "Rakenda sisseehitatud või salvestatud kohandatud kompressoriseadistus",
"compressorRatio_description": "Kompressiooniaste, nt 4 = 4:1.",
"compressorRatio": "Kompressiooniaste",
"compressorRelease_description": "Kui kiiresti kompressiooni mõju pärast lävest allapoole langemist lakkab.",
"compressorRelease": "Vabastusaeg",
"compressorReset_description": "Taasta kõigi kompressori parameetrite vaikeväärtused",
"compressorSavePreset_description": "Salvesta praegused kompressori seaded nimetatud eelseadistusena",
"compressorThreshold_description": "Signaali tase, mida ületades kompressioon algab.",
"compressorThreshold": "Lävi"
}, },
"datetime": { "datetime": {
"minuteShort": "m", "minuteShort": "m",
+35 -7
View File
@@ -674,9 +674,9 @@
"playButtonBehavior_optionAddNext": "$t(player.addNext)", "playButtonBehavior_optionAddNext": "$t(player.addNext)",
"replayGainMode_description": "Ajuste le gain du volume selon les valeurs de {{ReplayGain}} enregistrées dans les métadonnées du fichier", "replayGainMode_description": "Ajuste le gain du volume selon les valeurs de {{ReplayGain}} enregistrées dans les métadonnées du fichier",
"replayGainFallback": "Valeur de repli de {{ReplayGain}}", "replayGainFallback": "Valeur de repli de {{ReplayGain}}",
"replayGainClipping_description": "Empêcher la distorsion causée par {{ReplayGain}} en réduisant automatiquement le gain", "replayGainClipping_description": "Prévient l'écrêtage causé par {{ReplayGain}} en réduisant automatiquement le gain",
"replayGainPreamp": "Préamplificateur (db) de {{ReplayGain}}", "replayGainPreamp": "Préamplificateur (db) de {{ReplayGain}}",
"replayGainClipping": "Distorsion du {{ReplayGain}}", "replayGainClipping": "Écrêtage du {{ReplayGain}}",
"replayGainMode": "Mode de {{ReplayGain}}", "replayGainMode": "Mode de {{ReplayGain}}",
"replayGainFallback_description": "Gain en dB à appliquer si le fichier n'a pas de tags de {{ReplayGain}}", "replayGainFallback_description": "Gain en dB à appliquer si le fichier n'a pas de tags de {{ReplayGain}}",
"replayGainPreamp_description": "Ajuste le gain de préampli appliqué aux valeurs {{ReplayGain}}", "replayGainPreamp_description": "Ajuste le gain de préampli appliqué aux valeurs {{ReplayGain}}",
@@ -684,7 +684,7 @@
"clearCache": "Vider le cache du navigateur", "clearCache": "Vider le cache du navigateur",
"buttonSize_description": "La taille des boutons de la barre de lecture", "buttonSize_description": "La taille des boutons de la barre de lecture",
"clearQueryCache_description": "Un 'nettoyage léger' de Feishin. Cela actualisera les listes de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. Les paramètres, identifiants du serveur et images mises en cache seront conservés", "clearQueryCache_description": "Un 'nettoyage léger' de Feishin. Cela actualisera les listes de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. Les paramètres, identifiants du serveur et images mises en cache seront conservés",
"clearCache_description": "Un 'nettoyage complet' de Feishin. En plus de vider le cache de Feishin, vide le cache du navigateur (images sauvegardées et autres ressources). Les identifiants serveurs et paramètres sont conservés", "clearCache_description": "Un 'nettoyage complet' de Feishin. En plus de vider le cache de Feishin, vide le cache du navigateur (images enregistrée et autres ressources). Les identifiants serveurs et paramètres sont conservés",
"buttonSize": "Taille des boutons de la barre de lecture", "buttonSize": "Taille des boutons de la barre de lecture",
"clearCacheSuccess": "Cache vidé avec succès", "clearCacheSuccess": "Cache vidé avec succès",
"externalLinks_description": "Activer l'affichage de liens externes (Last.fm, MusicBrainz) sur les pages d'artiste/album", "externalLinks_description": "Activer l'affichage de liens externes (Last.fm, MusicBrainz) sur les pages d'artiste/album",
@@ -926,7 +926,31 @@
"autoDJ_albumStrategy": "Mode de sélection d'album", "autoDJ_albumStrategy": "Mode de sélection d'album",
"autoDJ_songStrategy": "Mode de sélection de titre", "autoDJ_songStrategy": "Mode de sélection de titre",
"autoDJ_strategy_option_library_random": "Aléatoire", "autoDJ_strategy_option_library_random": "Aléatoire",
"autoDJ_strategy_option_similar": "Similaire" "autoDJ_strategy_option_similar": "Similaire",
"enableFurigana_description": "Afficher les indications de prononciation (furigana) au-dessus des paroles en kanji Japonais.",
"enableFurigana": "Active la génération des furigana",
"enableRomaji_description": "Afficher une ligne de prononciation en romaji sous les paroles Japonaises.",
"enableRomaji": "Active la génération des romaji",
"equalizer_descriptionMpv": "Égaliseur paramétrique via FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Égaliseur paramétrique via Web Audio API",
"equalizer": "Égaliseur",
"equalizerBands_description": "Gain par bande. Faire glisser vers le haut/bas ou taper une valeur. Plage : -12 à +12 dB.",
"equalizerBands": "Bandes",
"equalizerPreamp_description": "Gain d'entrée avant les bandes d'égalisation. Réglez-le sur une valeur négative lors de l'amplification des bandes pour éviter l'écrêtage (MPV).",
"equalizerPreamp": "Préampli",
"equalizerPreset_description": "Appliquer une courbe d'EQ personnalisé intégrée ou personnalisée",
"equalizerPreset": "Préréglage",
"equalizerPresetDeletePlaceholder": "Supprimer les personnalisé...",
"equalizerPresetGroupBuiltIn": "Intégré",
"equalizerPresetGroupCustom": "Personnalisé",
"equalizerPresetNamePlaceholder": "Nom de préréglage...",
"equalizerPresetSelectPlaceholder": "Sélectionnée un préréglage",
"equalizerSavePreset_description": "Enregistrer les paramètres d'EQ actuels en tant que préréglage nommé",
"equalizerSavePreset": "Enregistrer le préréglage",
"sidebarPlaylistFolderTreeLineColor_description": "Couleur des lignes de l'arborescence (vide pour le thème par défaut)",
"sidebarPlaylistFolderTreeLineColor": "Couleur de la ligne de l'arboresence",
"windowBarTrackinfo": "Informations de la piste dans le titre de la fenêtre",
"windowBarTrackinfo_description": "Afficher le titre et l'artiste de la piste en cours, sa position dans la file d'attente et son état (lecture/pause) dans le titre de la fenêtre."
}, },
"form": { "form": {
"deletePlaylist": { "deletePlaylist": {
@@ -945,7 +969,7 @@
"input_savePassword": "Enregister le mot de passe", "input_savePassword": "Enregister le mot de passe",
"ignoreSsl": "Ignorer ssl $t(common.restartRequired)", "ignoreSsl": "Ignorer ssl $t(common.restartRequired)",
"ignoreCors": "Ignorer cors $t(common.restartRequired)", "ignoreCors": "Ignorer cors $t(common.restartRequired)",
"error_savePassword": "Une erreur sest produite lors de la tentative de sauvegarde du mot de passe", "error_savePassword": "Une erreur sest produite lors de la tentative d'enregistrement du mot de passe",
"input_preferInstantMix": "Préférer le mix instantané", "input_preferInstantMix": "Préférer le mix instantané",
"input_preferInstantMixDescription": "Utiliser uniquement le mix instantané pour jouer des titres similaires. utile si vous avez des plugins qui modifient ce comportement", "input_preferInstantMixDescription": "Utiliser uniquement le mix instantané pour jouer des titres similaires. utile si vous avez des plugins qui modifient ce comportement",
"input_preferRemoteUrl": "Préférer une URL publique", "input_preferRemoteUrl": "Préférer une URL publique",
@@ -1138,7 +1162,9 @@
"horizontalBorders": "Bordures de ligne", "horizontalBorders": "Bordures de ligne",
"rowHoverHighlight": "Surligner les lignes au survol", "rowHoverHighlight": "Surligner les lignes au survol",
"verticalBorders": "Bordure de colonne", "verticalBorders": "Bordure de colonne",
"showHeader": "Affiche l'en-tête" "showHeader": "Affiche l'en-tête",
"albumGroupConfig": "Configuration du groupe d'albums",
"albumImageSize": "Taille de la pochette d'album"
}, },
"view": { "view": {
"table": "Liste", "table": "Liste",
@@ -1266,7 +1292,9 @@
"notContains": "Ne contient pas", "notContains": "Ne contient pas",
"notInPlaylist": "N'est pas dans", "notInPlaylist": "N'est pas dans",
"notInTheLast": "N'est pas dans le dernier", "notInTheLast": "N'est pas dans le dernier",
"startsWith": "Commence par" "startsWith": "Commence par",
"isMissing": "Est manquant",
"isPresent": "Est présent"
}, },
"datetime": { "datetime": {
"minuteShort": "M", "minuteShort": "M",
+60 -21
View File
@@ -7,7 +7,10 @@
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) megtekintése", "viewPlaylists": "$t(entity.playlist, {\"count\": 2}) megtekintése",
"openIn": { "openIn": {
"lastfm": "Megnyitás Last.fm-ben", "lastfm": "Megnyitás Last.fm-ben",
"musicbrainz": "Megnyitás MusicBrainz-ben" "musicbrainz": "Megnyitás MusicBrainz-ben",
"listenbrainz": "Megnyitás ListenBrainz-ben",
"qobuz": "Megnyitás Qobuz_ban",
"spotify": "Megnyitás Spotify-ban"
}, },
"clearQueue": "Műsorlista kiürítése", "clearQueue": "Műsorlista kiürítése",
"createPlaylist": "$t(entity.playlist, {\"count\": 1}) létrehozása", "createPlaylist": "$t(entity.playlist, {\"count\": 1}) létrehozása",
@@ -117,7 +120,7 @@
"none": "Egyik sem", "none": "Egyik sem",
"restartRequired": "Újraindítás szükséges", "restartRequired": "Újraindítás szükséges",
"setting_one": "Beállítás", "setting_one": "Beállítás",
"setting_other": "", "setting_other": "Beállítások",
"translation": "Fordítás", "translation": "Fordítás",
"rating": "Értékelés", "rating": "Értékelés",
"reload": "Újratöltés", "reload": "Újratöltés",
@@ -154,7 +157,14 @@
"view": "Nézet", "view": "Nézet",
"noFilters": "Nincs konfigurált szűrő", "noFilters": "Nincs konfigurált szűrő",
"countSelected": "{{count}} kiválasztott", "countSelected": "{{count}} kiválasztott",
"retry": "Újra" "retry": "Újra",
"openFolder": "Mappa megnyitás",
"example": "Példa",
"filter_single": "Egy",
"filter_multiple": "Több",
"mood": "Hangulat",
"numberOfResults": "{{numberOfResults}} eredmény",
"grouping": "Csoportosítás"
}, },
"entity": { "entity": {
"albumArtist_one": "Zenész", "albumArtist_one": "Zenész",
@@ -207,8 +217,8 @@
"openError": "A fájl megnyitása sikertelen volt", "openError": "A fájl megnyitása sikertelen volt",
"playbackError": "Hiba történt a média lejátszásakor", "playbackError": "Hiba történt a média lejátszásakor",
"remoteEnableError": "Hiba történt a távoli szerver műveletkor: $t(common.enable)", "remoteEnableError": "Hiba történt a távoli szerver műveletkor: $t(common.enable)",
"remotePortError": "Hiba történt a távoli szerver PORT-jának beállításakor", "remotePortError": "Hiba történt a távoli szerver port-jának beállításakor",
"remotePortWarning": "Indítsd újra a szervert az új PORT használatához", "remotePortWarning": "Indítsd újra a szervert az új port használatához",
"genericError": "Hiba történt", "genericError": "Hiba történt",
"endpointNotImplementedError": "A(z) {{endpoint}} végpont nincs implementálva a következőhöz: {{serverType}}", "endpointNotImplementedError": "A(z) {{endpoint}} végpont nincs implementálva a következőhöz: {{serverType}}",
"badAlbum": "Azért látod ezt az oldalt mert ez a zeneszám nem része egy albumnak. ez általában akkor történik amikor egy szám a zenekönyvtárad gyökerébe kerül. a Jellyfin csak mappákba rendezett számokat csoportosít", "badAlbum": "Azért látod ezt az oldalt mert ez a zeneszám nem része egy albumnak. ez általában akkor történik amikor egy szám a zenekönyvtárad gyökerébe kerül. a Jellyfin csak mappákba rendezett számokat csoportosít",
@@ -226,7 +236,10 @@
"noNetworkDescription": "Nem tudok csatlakozni a szerverhez", "noNetworkDescription": "Nem tudok csatlakozni a szerverhez",
"saveQueueFailed": "Műsorlista mentése sikertelen", "saveQueueFailed": "Műsorlista mentése sikertelen",
"settingsSyncError": "Eltéréseket találtam a leképző és a fő folyamat beállításai között. Indítsd újra az alkalmazást", "settingsSyncError": "Eltéréseket találtam a leképző és a fő folyamat beállításai között. Indítsd újra az alkalmazást",
"multipleServerSaveQueueError": "A műsorlistában egy vagy több olyan dal található, amely nem az aktuális szerverről származik. Ez nem támogatott" "multipleServerSaveQueueError": "A műsorlistában egy vagy több olyan dal található, amely nem az aktuális szerverről származik. Ez nem támogatott",
"invalidJson": "Érvénytelen JSON",
"playbackPausedDueToError": "Lejátszás szüneteltetve hiba miatt",
"serverLockSingleServer": "A szerver zárolása esetén csak egy szerver engedélyezett"
}, },
"filter": { "filter": {
"albumCount": "$t(entity.album, {\"count\": 2}) darab", "albumCount": "$t(entity.album, {\"count\": 2}) darab",
@@ -269,9 +282,11 @@
"trackNumber": "Sáv", "trackNumber": "Sáv",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "$t(entity.artist, {\"count\": 1})",
"bpm": "Bpm", "bpm": "Bpm",
"channels": "$t(common.channel_other)", "channels": "$t(common.channel, {\"count\": 2})",
"genre": "$t(entity.genre, {\"count\": 1})", "genre": "$t(entity.genre, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)" "explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "és",
"matchOr": "vagy"
}, },
"form": { "form": {
"addServer": { "addServer": {
@@ -671,7 +686,7 @@
"customFontPath_description": "Beállítja az alkalmazáshoz használandó egyéni betűtípus elérési útját", "customFontPath_description": "Beállítja az alkalmazáshoz használandó egyéni betűtípus elérési útját",
"contextMenu": "Kontextusmenü (jobb klikk) beállítás", "contextMenu": "Kontextusmenü (jobb klikk) beállítás",
"crossfadeDuration_description": "Beállítja áthúzás effekt időtartamát", "crossfadeDuration_description": "Beállítja áthúzás effekt időtartamát",
"crossfadeDuration": "Áthúzás Itartam", "crossfadeDuration": "Áthúzás Itartam",
"crossfadeStyle": "Áthúzás stílus", "crossfadeStyle": "Áthúzás stílus",
"crossfadeStyle_description": "Válaszd ki az audiolejátszóhoz használni kívánt áthúzás stílust", "crossfadeStyle_description": "Válaszd ki az audiolejátszóhoz használni kívánt áthúzás stílust",
"releaseChannel_description": "Válassz a stabil kiadás vagy a béta kiadás közül az automatikus frissítésekhez", "releaseChannel_description": "Válassz a stabil kiadás vagy a béta kiadás közül az automatikus frissítésekhez",
@@ -852,7 +867,7 @@
"sidebarPlaylistList_description": "Lejátszási lista megjelenítése vagy elrejtése az oldalsávban", "sidebarPlaylistList_description": "Lejátszási lista megjelenítése vagy elrejtése az oldalsávban",
"sidebarPlaylistList": "Oldalsáv lejátszási lista", "sidebarPlaylistList": "Oldalsáv lejátszási lista",
"sidePlayQueueStyle_description": "Beállítja az oldalsó műsorlista stílusát", "sidePlayQueueStyle_description": "Beállítja az oldalsó műsorlista stílusát",
"mediaSession_description": "Lehetővé teszi a Windows Media Session integrációját, a médiavezérlők és metaadatok megjelenítését a rendszer hangerő-átfedésben és a zárolási képernyőn (csak Windows)", "mediaSession_description": "Bekapcsolja a media session integrációját, a médiavezérlők és metaadatok megjelenítését a rendszer hangerő-átfedésben és a zárolási képernyőn. (Web audiolejátszó szükséges.)",
"mediaSession": "Média munkamenet engedélyezése", "mediaSession": "Média munkamenet engedélyezése",
"sidePlayQueueStyle": "Oldalsó műsorlista stílus", "sidePlayQueueStyle": "Oldalsó műsorlista stílus",
"skipDuration": "Átugrás hossza", "skipDuration": "Átugrás hossza",
@@ -918,7 +933,7 @@
"autoDJ": "Auto DJ", "autoDJ": "Auto DJ",
"autoDJ_timing": "Időzítés", "autoDJ_timing": "Időzítés",
"autoDJ_itemCount": "Elem szám", "autoDJ_itemCount": "Elem szám",
"autoDJ_itemCount_description": "Az auto DJ engedélyezésekor a műsorsorba felvenni kívánt elemek száma", "autoDJ_itemCount_description": "A műsorsorba felvenni kívánt elemek száma",
"autoDJ_timing_description": "Az auto DJ elindulása előtt a műsorlistában maradt dalok száma", "autoDJ_timing_description": "Az auto DJ elindulása előtt a műsorlistában maradt dalok száma",
"followCurrentSong_description": "Automatikusan görgesse a műsorlistát az aktuálisan lejátszott dalra", "followCurrentSong_description": "Automatikusan görgesse a műsorlistát az aktuálisan lejátszott dalra",
"followCurrentSong": "Kövesd az aktuális dalt", "followCurrentSong": "Kövesd az aktuális dalt",
@@ -959,13 +974,13 @@
"biography": "$t(common.biography)", "biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)", "bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)", "bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)", "channels": "$t(common.channel, {\"count\": 2})",
"codec": "$t(common.codec)", "codec": "$t(common.codec)",
"dateAdded": "Hozzáadva", "dateAdded": "Hozzáadva",
"discNumber": "Lemezszám", "discNumber": "Lemezszám",
"duration": "$t(common.duration)", "duration": "$t(common.duration)",
"favorite": "$t(common.favorite)", "favorite": "$t(common.favorite)",
"actions": "$t(common.action_other)", "actions": "$t(common.action, {\"count\": 2})",
"album": "$t(entity.album, {\"count\": 1})", "album": "$t(entity.album, {\"count\": 1})",
"albumCount": "$t(entity.album, {\"count\": 2})", "albumCount": "$t(entity.album, {\"count\": 2})",
"genreBadge": "$t(entity.genre, {\"count\": 1}) (jelvények)", "genreBadge": "$t(entity.genre, {\"count\": 1}) (jelvények)",
@@ -1011,33 +1026,33 @@
} }
}, },
"column": { "column": {
"albumCount": "$t(entity.album, {\"count\": 2})", "albumCount": "Albumok",
"artist": "$t(entity.artist, {\"count\": 1})", "artist": "Előadó",
"biography": "Életrajz", "biography": "Életrajz",
"bitrate": "Bitráta", "bitrate": "Bitráta",
"bpm": "BPM", "bpm": "BPM",
"channels": "$t(common.channel_other)", "channels": "Csatornák",
"codec": "$t(common.codec)", "codec": "$t(common.codec)",
"comment": "Komment", "comment": "Komment",
"dateAdded": "Hozzáadva", "dateAdded": "Hozzáadva",
"discNumber": "Lemez", "discNumber": "Lemez",
"favorite": "Kedvenc", "favorite": "Kedvenc",
"genre": "$t(entity.genre, {\"count\": 1})", "genre": "Műfaj",
"lastPlayed": "Utoljára játszott", "lastPlayed": "Utoljára játszott",
"path": "Elérési út", "path": "Elérési út",
"playCount": "Lejátszások", "playCount": "Lejátszások",
"rating": "Értékelés", "rating": "Értékelés",
"releaseDate": "Megjelenés", "releaseDate": "Megjelenés",
"releaseYear": "Év", "releaseYear": "Év",
"size": "$t(common.size)", "size": "Méret",
"songCount": "$t(entity.track, {\"count\": 2})", "songCount": "Sávok",
"title": "Cím", "title": "Cím",
"trackNumber": "Sáv", "trackNumber": "Sáv",
"album": "Album", "album": "Album",
"albumArtist": "Album előadó", "albumArtist": "Album előadó",
"owner": "Tulajdonos", "owner": "Tulajdonos",
"bitDepth": "$t(common.bitDepth)", "bitDepth": "Bitmélység",
"sampleRate": "$t(common.sampleRate)" "sampleRate": "Mintavételi frekvencia"
} }
}, },
"queryBuilder": { "queryBuilder": {
@@ -1070,5 +1085,29 @@
"secondShort": "Mp", "secondShort": "Mp",
"hourShort": "Óra", "hourShort": "Óra",
"dayShort": "Nap" "dayShort": "Nap"
},
"visualizer": {
"options": {
"weightingFilter": {
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"z": "Z",
"none": "Nincs"
},
"mode": {
"1": "[1] 1/24 oktáv / 240 sáv",
"2": "[2] 1/12 oktáv / 120 sáv",
"3": "[3] 1/8 oktáv / 80 sáv",
"4": "[4] 1/6 oktáv / 60 sáv",
"5": "[5] 1/4 oktáv / 40 sáv",
"6": "[6] 1/3 oktáv / 30 sáv",
"7": "[7] Fél oktáv / 20 sáv",
"8": "[8] Teljes oktáv / 10 sáv",
"10": "[10] Vonal / Területdiagram"
}
},
"showFPS": "Mutat FPS"
} }
} }
+44 -2
View File
@@ -1108,7 +1108,47 @@
"autoDJ_albumStrategy": "Tryb wyboru albumów", "autoDJ_albumStrategy": "Tryb wyboru albumów",
"autoDJ_songStrategy": "Tryb wyboru piosenek", "autoDJ_songStrategy": "Tryb wyboru piosenek",
"autoDJ_strategy_option_library_random": "Losowo", "autoDJ_strategy_option_library_random": "Losowo",
"autoDJ_strategy_option_similar": "Podobne" "autoDJ_strategy_option_similar": "Podobne",
"enableFurigana_description": "Wyświetlaj pomoce wymowy (furigana) nad tekstami Japońskimi kanji.",
"enableFurigana": "Włącz generowanie furigana",
"equalizer_descriptionMpv": "Equalizer parametryczny przez FFmpeg lavfi (MPV)",
"equalizer_descriptionWebAudio": "Parametryczny equalizer przez API Web Audio",
"equalizer": "Equalizer",
"equalizerBands_description": "Wzmocnienie dla poszczególnych pasm. Przesuń w górę/dół lub wpisz wartość. Zakres: -12 do +12 dB.",
"equalizerBands": "Pasma",
"equalizerPreamp_description": "Wzmocnienie sygnału przed pasmami EQ. Ustaw na wartość ujemną podczas wzmacniania pasm, aby zapobiec przesterowaniu (MPV).",
"equalizerPreamp": "Przedwzmacnianie",
"equalizerPreset_description": "Zastosuj wbudowaną lub niestandardową zapisaną krzywą EQ",
"equalizerPreset": "Ustawienia wstępne",
"equalizerPresetDeletePlaceholder": "Usuń niestandardowe...",
"equalizerPresetGroupBuiltIn": "Wbudowane",
"equalizerPresetGroupCustom": "Niestandardowe",
"equalizerPresetNamePlaceholder": "Nazwa ustawień wstępnych...",
"equalizerPresetSelectPlaceholder": "Wybierz ustawienia wstępne",
"equalizerSavePreset_description": "Zapisz aktualne ustawienia EQ jako nazwany zestaw ustawień wstępnych",
"equalizerSavePreset": "Zapisz ustawienia wstępne",
"compressor_descriptionMpv": "Kompresor zakresu dynamicznego przez FFmpeg acompressor (MPV)",
"compressor_descriptionWebAudio": "Kompresor zakresu dynamicznego poprzez API Web Audio",
"compressor": "Kompresor",
"compressorAttack_description": "Jak szybko załączany jest kompresor po przekroczeniu progu przez sygnał.",
"compressorAttack": "Attack",
"compressorKnee_description": "Szerokośc soft-knee. Większe wartości powodują przejście do kompresji bardziej stopniowym.",
"compressorKnee": "Knee",
"compressorMakeupGain_description": "Zwiększenie wyjściowe dodawane po kompresji aby, przywrócić głośność.",
"compressorMakeupGain": "Makeup Gain",
"compressorPreset_description": "Zastosuj wbudowane lub niestandardowe zapisane ustawienie kompresora",
"compressorRatio_description": "Proporcje kompresji, np. 4 = 4:1.",
"compressorRatio": "Proporcje",
"compressorRelease_description": "Jak szybko kompresor odpuszcza po spadnięciu sygnału poniżej progu.",
"compressorRelease": "Odpuszczenie",
"compressorReset_description": "Przywróć wszystkie parametry kompresora do wartości domyślnych",
"compressorSavePreset_description": "Zapisz aktualne ustawienia kompresora jako nazwany zestaw ustawień wstępnych",
"compressorThreshold_description": "Poziom sygnału nad którym rozpoczyna się kompresja.",
"compressorThreshold": "Próg",
"enableRomaji_description": "Wyświetlaj linijkę z wymową romaji pod japońskim tekstem.",
"enableRomaji": "Włącz generowanie romaji",
"windowBarTrackinfo": "Informacje o utworze w tytule okna",
"windowBarTrackinfo_description": "Wyświetlaj tytuł, wykonawcę, pozycję w kolejce i stan Odtwarzania/Wstrzymania w tytule okna."
}, },
"table": { "table": {
"config": { "config": {
@@ -1148,7 +1188,9 @@
"horizontalBorders": "Obwódki wierszy", "horizontalBorders": "Obwódki wierszy",
"rowHoverHighlight": "Podświetlanie wierszy po najechaniu", "rowHoverHighlight": "Podświetlanie wierszy po najechaniu",
"verticalBorders": "Obwódki kolumn", "verticalBorders": "Obwódki kolumn",
"showHeader": "Pokaż nagłówek" "showHeader": "Pokaż nagłówek",
"albumImageSize": "Rozmiar obrazów albumów",
"albumGroupConfig": "Konfiguracja grupy albumów"
}, },
"label": { "label": {
"releaseDate": "Data premiery", "releaseDate": "Data premiery",
+188 -1
View File
@@ -1 +1,188 @@
{} {
"action": {
"selectRangeOfItems": "เลือกช่วงของรายการ",
"addToFavorites": "เพิ่มลงใน $t(entity.favorite, {\"count\": 2})",
"addToPlaylist": "เพิ่มลงใน $t(entity.playlist, {\"count\": 1})",
"addOrRemoveFromSelection": "เพิ่มหรือเอาออกจากรายการที่เลือก",
"clearQueue": "ล้างคิว",
"goToCurrent": "ไปยังรายการปัจจุบัน",
"collapseAllFolders": "ยุบโฟลเดอร์ทั้งหมด",
"expandAllFolders": "ขยายโฟลเดอร์ทั้งหมด",
"createPlaylist": "สร้าง $t(entity.playlist, {\"count\": 1})",
"createRadioStation": "สร้าง $t(entity.radioStation, {\"count\": 1})",
"deletePlaylist": "ลบ $t(entity.playlist, {\"count\": 1})",
"deleteRadioStation": "ลบ $t(entity.radioStation, {\"count\": 1})",
"selectAll": "เลือกทั้งหมด",
"deselectAll": "ยกเลิกการเลือกทั้งหมด",
"downloadStarted": "เริ่มดาวน์โหลด {{count}} รายการ",
"editPlaylist": "แก้ไข $t(entity.playlist, {\"count\": 1})",
"goToPage": "ไปที่หน้า",
"moveToNext": "ย้ายไปถัดไป",
"moveToBottom": "ย้ายไปล่างสุด",
"moveToTop": "ย้ายไปบนสุด",
"moveUp": "เลื่อนขึ้น",
"moveDown": "เลื่อนลง",
"holdToMoveToTop": "กดค้างเพื่อย้ายไปบนสุด",
"holdToMoveToBottom": "กดค้างเพื่อย้ายไปล่างสุด",
"moveItems": "ย้ายรายการ",
"shuffle": "สุ่ม",
"shuffleAll": "สุ่มทั้งหมด",
"shuffleSelected": "สุ่มรายการที่เลือก",
"removeFromFavorites": "ลบออกจาก $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "ลบออกจาก $t(entity.playlist, {\"count\": 1})",
"removeFromQueue": "ลบออกจากคิว",
"setRating": "ให้คะแนน",
"toggleSmartPlaylistEditor": "เปิด/ปิดตัวแก้ไข $t(entity.smartPlaylist)",
"viewPlaylists": "ดู $t(entity.playlist, {\"count\": 2})",
"viewMore": "ดูเพิ่มเติม",
"openApplicationDirectory": "เปิดโฟลเดอร์โปรแกรม",
"openIn": {
"lastfm": "เปิดใน Last.fm",
"listenbrainz": "เปิดใน ListenBrainz",
"musicbrainz": "เปิดใน MusicBrainz",
"qobuz": "เปิดใน Qobuz",
"spotify": "เปิดใน Spotify"
}
},
"common": {
"countSelected": "เลือกแล้ว {{count}} รายการ",
"explicitStatus": "สถานะเนื้อหาชัดเจน",
"action_other": "การดำเนินการ",
"add": "เพิ่ม",
"additionalParticipants": "ผู้เข้าร่วมเพิ่มเติม",
"newVersion": "ติดตั้งเวอร์ชันใหม่แล้ว {{version}}",
"viewReleaseNotes": "ดูบันทึกการเปลี่ยนแปลง",
"albumGain": "ระดับความดังของอัลบั้ม",
"albumPeak": "ระดับเสียงสูงสุดของอัลบั้ม",
"areYouSure": "คุณแน่ใจหรือไม่?",
"ascending": "เรียงจากน้อยไปมาก",
"back": "ย้อนกลับ",
"backward": "ย้อนกลับ",
"biography": "ประวัติ",
"bitDepth": "ความลึกบิต",
"bitrate": "บิตเรต",
"bpm": "จังหวะต่อนาที",
"cancel": "ยกเลิก",
"center": "กึ่งกลาง",
"channel_other": "ช่อง",
"clear": "ล้าง",
"close": "ปิด",
"codec": "ตัวแปลงสัญญาณ",
"collapse": "ยุบ",
"comingSoon": "เร็ว ๆ นี้…",
"configure": "กำหนดค่า",
"confirm": "ยืนยัน",
"create": "สร้าง",
"currentSong": "$t(entity.track, {\"count\": 1}) ปัจจุบัน",
"decrease": "ลด",
"delete": "ลบ",
"descending": "เรียงจากมากไปน้อย",
"description": "คำอธิบาย",
"disable": "ปิดใช้งาน",
"disc": "แผ่น",
"dismiss": "ปิด",
"doNotShowAgain": "ไม่ต้องแสดงอีก",
"duration": "ระยะเวลา",
"view": "ดู",
"edit": "แก้ไข",
"enable": "เปิดใช้งาน",
"expand": "ขยาย",
"example": "ตัวอย่าง",
"externalLinks": "ลิงก์ภายนอก",
"openFolder": "เปิดโฟลเดอร์",
"faster": "เร็วขึ้น",
"favorite": "รายการโปรด",
"filter_other": "ตัวกรอง",
"filters": "ตัวกรอง",
"filter_single": "เดี่ยว",
"filter_multiple": "หลายรายการ",
"forceRestartRequired": "รีสตาร์ตเพื่อใช้การเปลี่ยนแปลง... ปิดการแจ้งเตือนนี้เพื่อรีสตาร์ต",
"forward": "ไปข้างหน้า",
"gap": "ระยะห่าง",
"home": "หน้าแรก",
"increase": "เพิ่ม",
"left": "ซ้าย",
"limit": "จำกัด",
"manage": "จัดการ",
"maximize": "ขยายใหญ่สุด",
"menu": "เมนู",
"minimize": "ย่อหน้าต่าง",
"modified": "แก้ไขล่าสุด",
"grouping": "การจัดกลุ่ม",
"mood": "อารมณ์",
"name": "ชื่อ",
"no": "ไม่",
"none": "ไม่มี",
"noResultsFromQuery": "ไม่พบผลลัพธ์ที่ตรงกับการค้นหา",
"numberOfResults": "พบ {{numberOfResults}} รายการ",
"noFilters": "ยังไม่ได้ตั้งค่าตัวกรอง",
"note": "หมายเหตุ",
"ok": "ตกลง",
"owner": "เจ้าของ",
"path": "เส้นทาง",
"playerMustBePaused": "ต้องหยุดการเล่นก่อน",
"preview": "แสดงตัวอย่าง",
"previousSong": "$t(entity.track, {\"count\": 1}) ก่อนหน้า",
"private": "ส่วนตัว",
"public": "สาธารณะ",
"quit": "ออก",
"random": "สุ่ม",
"rating": "คะแนน",
"retry": "ลองใหม่",
"recordLabel": "ค่ายเพลง",
"releaseType": "ประเภทการเผยแพร่",
"refresh": "รีเฟรชข้อมูล",
"reload": "โหลดใหม่",
"rename": "เปลี่ยนชื่อ",
"reset": "รีเซ็ต",
"resetToDefault": "รีเซ็ตเป็นค่าเริ่มต้น",
"restartRequired": "จำเป็นต้องเริ่มโปรแกรมใหม่",
"right": "ขวา",
"sampleRate": "อัตราการสุ่มตัวอย่าง",
"save": "บันทึก",
"saveAndReplace": "บันทึกและแทนที่",
"saveAs": "บันทึกเป็น...",
"search": "ค้นหา",
"setting_other": "การตั้งค่า",
"slower": "ช้าลง",
"share": "แบ่งปัน",
"size": "ขนาด",
"sort": "เรียงลำดับ",
"sortOrder": "ลำดับ",
"tags": "แท็ก",
"title": "ชื่อเรื่อง",
"trackNumber": "แทร็ก",
"trackGain": "ระดับขยายเสียงแทร็ก",
"trackPeak": "ระดับเสียงสูงสุดของแทร็ก",
"translation": "การแปล",
"unknown": "ไม่ทราบ",
"version": "เวอร์ชัน",
"year": "ปี",
"yes": "ใช่",
"explicit": "เนื้อหาชัดแจ้ง",
"clean": "เวอร์ชันสะอาด",
"gridRows": "แถวของตาราง",
"tableColumns": "คอลัมน์ของตาราง",
"itemsMore": "เพิ่มอีก {{count}}",
"newVersionAvailable": "มีเวอร์ชันใหม่ให้ใช้งาน"
},
"entity": {
"album_other": "อัลบั้ม",
"albumArtist_other": "ศิลปินอัลบั้ม",
"albumArtistCount_other": "{{count}} ศิลปินอัลบั้ม",
"albumWithCount_other": "{{count}} อัลบั้ม",
"radioStation_other": "สถานีวิทยุ",
"radioStationWithCount_other": "{{count}} สถานีวิทยุ",
"artist_other": "ศิลปิน",
"artistWithCount_other": "{{count}} ศิลปิน",
"favorite_other": "รายการโปรด",
"folder_other": "โฟลเดอร์",
"folderWithCount_other": "{{count}} โฟลเดอร์",
"genre_other": "แนวเพลง",
"genreWithCount_other": "{{count}} แนวเพลง",
"playlist_other": "เพลย์ลิสต์",
"play_other": "{{count}} เล่น",
"playlistWithCount_other": "{{count}} เพลย์ลิสต์",
"smartPlaylist": "$t(entity.playlist, {\"count\": 1})อัจฉริยะ"
}
}
+31 -9
View File
@@ -167,7 +167,7 @@
"version": "Версія", "version": "Версія",
"year": "Рік", "year": "Рік",
"yes": "Так", "yes": "Так",
"explicit": "Експліцитний зміст", "explicit": "Відвертий зміст",
"gridRows": "Рядки сітки", "gridRows": "Рядки сітки",
"tableColumns": "Стовпці таблиці", "tableColumns": "Стовпці таблиці",
"itemsMore": "{{count}} більше", "itemsMore": "{{count}} більше",
@@ -180,9 +180,9 @@
"album_one": "Альбом", "album_one": "Альбом",
"album_few": "альбоми", "album_few": "альбоми",
"album_many": "альбомів", "album_many": "альбомів",
"albumArtist_one": "Виконавець альбому", "albumArtist_one": "Виконавець Альбому",
"albumArtist_few": "виконавці альбому", "albumArtist_few": "Виконавці Альбому",
"albumArtist_many": "виконавців альбому", "albumArtist_many": "Виконавці Альбому",
"albumArtistCount_one": "{{count}} виконавець альбому", "albumArtistCount_one": "{{count}} виконавець альбому",
"albumArtistCount_few": "{{count}} виконавці альбому", "albumArtistCount_few": "{{count}} виконавці альбому",
"albumArtistCount_many": "{{count}} виконавців альбому", "albumArtistCount_many": "{{count}} виконавців альбому",
@@ -190,8 +190,8 @@
"albumWithCount_few": "{{count}} альбоми", "albumWithCount_few": "{{count}} альбоми",
"albumWithCount_many": "{{count}} альбомів", "albumWithCount_many": "{{count}} альбомів",
"radioStation_one": "Радіостанція", "radioStation_one": "Радіостанція",
"radioStation_few": "радіостанції", "radioStation_few": "Радіостанції",
"radioStation_many": "радіостанцій", "radioStation_many": "Радіостанцій",
"radioStationWithCount_one": "{{count}} радіостанція", "radioStationWithCount_one": "{{count}} радіостанція",
"radioStationWithCount_few": "{{count}} радіостанції", "radioStationWithCount_few": "{{count}} радіостанції",
"radioStationWithCount_many": "{{count}} радіостанцій", "radioStationWithCount_many": "{{count}} радіостанцій",
@@ -267,7 +267,8 @@
"systemFontError": "Сталася помилка під час спроби отримати системні шрифти", "systemFontError": "Сталася помилка під час спроби отримати системні шрифти",
"settingsSyncError": "Виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни", "settingsSyncError": "Виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни",
"invalidJson": "Недійсний JSON", "invalidJson": "Недійсний JSON",
"playbackPausedDueToError": "Відтворення було призупинено через помилку" "playbackPausedDueToError": "Відтворення було призупинено через помилку",
"serverLockSingleServer": "Коли сервер заблоковано можна використовувати тільки один сервер"
}, },
"filter": { "filter": {
"album": "$t(entity.album, {\"count\": 1})", "album": "$t(entity.album, {\"count\": 1})",
@@ -683,7 +684,19 @@
"transcoding": "Транскодування", "transcoding": "Транскодування",
"discord": "Діскорд", "discord": "Діскорд",
"logger": "Логгер", "logger": "Логгер",
"playerFilters": "Фільтри плеєра" "playerFilters": "Фільтри плеєра",
"advanced": "Розширені",
"analytics": "Аналітика",
"generalTab": "Загальні",
"hotkeysTab": "Гарячі клавіші",
"playbackTab": "Відтворення",
"windowTab": "Вікно",
"updates": "Оновлення",
"cache": "Кеш",
"application": "Застосунок",
"queryBuilder": "Конструктор черги",
"theme": "Тема",
"controls": "Керування"
}, },
"sidebar": { "sidebar": {
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})", "albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
@@ -707,6 +720,13 @@
"artistTracks": "Треки {{artist}}", "artistTracks": "Треки {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})", "genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
"title": "$t(entity.track, {\"count\": 2})" "title": "$t(entity.track, {\"count\": 2})"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
},
"collections": {
"overrideExisting": "Перевизначити існуючі",
"saveAsCollection": "Зберегти як колекцію"
} }
}, },
"queryBuilder": { "queryBuilder": {
@@ -759,6 +779,8 @@
"accentColor": "Акцентний колір", "accentColor": "Акцентний колір",
"useThemeAccentColor": "Використовувати акцентний колір теми", "useThemeAccentColor": "Використовувати акцентний колір теми",
"useThemeAccentColor_description": "Використовувати основний колір визначений у обраній темі замість користувацького акцентного коліру", "useThemeAccentColor_description": "Використовувати основний колір визначений у обраній темі замість користувацького акцентного коліру",
"useThemePrimaryShade": "Використовувати основний відтінок теми" "useThemePrimaryShade": "Використовувати основний відтінок теми",
"useThemePrimaryShade_description": "Використовувати основний відтінок, визначений у обраній темі, для основних варіантів кольорів",
"primaryShade": "Основний відтінок"
} }
} }
+21 -7
View File
@@ -41,7 +41,9 @@
"createRadioStation": "创建$t(entity.radioStation, {\"count\": 1})", "createRadioStation": "创建$t(entity.radioStation, {\"count\": 1})",
"deleteRadioStation": "删除$t(entity.radioStation, {\"count\": 1})", "deleteRadioStation": "删除$t(entity.radioStation, {\"count\": 1})",
"openApplicationDirectory": "打开应用程序目录", "openApplicationDirectory": "打开应用程序目录",
"goToCurrent": "转到当前项目" "goToCurrent": "转到当前项目",
"collapseAllFolders": "折叠所有文件夹",
"expandAllFolders": "展开所有文件夹"
}, },
"common": { "common": {
"increase": "增高", "increase": "增高",
@@ -162,7 +164,9 @@
"filter_multiple": "多项", "filter_multiple": "多项",
"newVersionAvailable": "新版本现已可用", "newVersionAvailable": "新版本现已可用",
"numberOfResults": "{{numberOfResults}} 结果", "numberOfResults": "{{numberOfResults}} 结果",
"grouping": "分组" "grouping": "分组",
"back": "返回",
"openFolder": "打开文件夹"
}, },
"entity": { "entity": {
"albumArtist_other": "专辑艺术家", "albumArtist_other": "专辑艺术家",
@@ -611,7 +615,9 @@
"sidePlayQueueLayout_optionHorizontal": "水平", "sidePlayQueueLayout_optionHorizontal": "水平",
"sidePlayQueueLayout_optionVertical": "垂直", "sidePlayQueueLayout_optionVertical": "垂直",
"waveformLoadingDelay": "波形加载延迟", "waveformLoadingDelay": "波形加载延迟",
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。" "waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。",
"autoDJ_strategy_option_library_random": "随机",
"autoDJ_strategy_option_similar": "相似"
}, },
"error": { "error": {
"remotePortWarning": "重启服务器使新端口生效", "remotePortWarning": "重启服务器使新端口生效",
@@ -729,7 +735,8 @@
"followCurrentLyric": "跟随当前歌词", "followCurrentLyric": "跟随当前歌词",
"dynamicImageBlur": "图像模糊大小", "dynamicImageBlur": "图像模糊大小",
"dynamicIsImage": "启用背景图像", "dynamicIsImage": "启用背景图像",
"lyricOffset": "歌词延迟补偿(毫秒)" "lyricOffset": "歌词延迟补偿(毫秒)",
"lyricOpacityNonActive": "静态歌词不透明度"
}, },
"lyrics": "歌词", "lyrics": "歌词",
"related": "相关", "related": "相关",
@@ -937,7 +944,8 @@
"input_skipDuplicates": "跳过重复", "input_skipDuplicates": "跳过重复",
"input_playlists": "$t(entity.playlist, {\"count\": 2})", "input_playlists": "$t(entity.playlist, {\"count\": 2})",
"create": "创建 $t(entity.playlist, {\"count\": 1}) {{playlist}}", "create": "创建 $t(entity.playlist, {\"count\": 1}) {{playlist}}",
"searchOrCreate": "搜索 $t(entity.playlist, {\"count\": 2}) 或键入以创建一个新的" "searchOrCreate": "搜索 $t(entity.playlist, {\"count\": 2}) 或键入以创建一个新的",
"noneAdded": "没有音轨被添加到 $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
}, },
"createPlaylist": { "createPlaylist": {
"title": "创建$t(entity.playlist, {\"count\": 1})", "title": "创建$t(entity.playlist, {\"count\": 1})",
@@ -1013,7 +1021,12 @@
"input_played_optionUnplayed": "仅未播放的曲目", "input_played_optionUnplayed": "仅未播放的曲目",
"input_played_optionPlayed": "仅已播放的曲目", "input_played_optionPlayed": "仅已播放的曲目",
"input_limit": "有多少首歌?", "input_limit": "有多少首歌?",
"input_played": "播放筛选器" "input_played": "播放筛选器",
"input_kind_albums": "专辑",
"input_kind_songs": "歌曲",
"input_kind": "随机播放",
"input_limit_albums": "有多少专辑?",
"input_limit_songs": "有多少歌曲?"
}, },
"editRadioStation": { "editRadioStation": {
"success": "电台更新成功" "success": "电台更新成功"
@@ -1175,7 +1188,8 @@
"startsWith": "以…开头", "startsWith": "以…开头",
"inTheRangeDate": "在(日期)范围内", "inTheRangeDate": "在(日期)范围内",
"notInPlaylist": "不在…中", "notInPlaylist": "不在…中",
"notInTheLast": "不在最后" "notInTheLast": "不在最后",
"isMissing": "丢失的项目"
}, },
"datetime": { "datetime": {
"minuteShort": "分", "minuteShort": "分",
+8 -2
View File
@@ -864,7 +864,11 @@
"compressorReset_description": "將所有壓縮器參數恢復為預設值", "compressorReset_description": "將所有壓縮器參數恢復為預設值",
"compressorSavePreset_description": "將目前壓縮器設定儲存為具名預設", "compressorSavePreset_description": "將目前壓縮器設定儲存為具名預設",
"compressorThreshold_description": "開始進行壓縮的訊號電平。", "compressorThreshold_description": "開始進行壓縮的訊號電平。",
"compressorThreshold": "閥值" "compressorThreshold": "閥值",
"enableRomaji_description": "在日文歌詞下方顯示羅馬拼音。",
"enableRomaji": "啟用羅馬拼音顯示",
"windowBarTrackinfo": "在視窗標題列顯示曲目資訊",
"windowBarTrackinfo_description": "在視窗標題列中顯示目前播放曲目的標題與藝人、播放佇列中的位置,以及播放/暫停狀態。"
}, },
"table": { "table": {
"config": { "config": {
@@ -898,7 +902,9 @@
"horizontalBorders": "行邊框線", "horizontalBorders": "行邊框線",
"rowHoverHighlight": "滑鼠懸停Highlight", "rowHoverHighlight": "滑鼠懸停Highlight",
"verticalBorders": "列邊框線", "verticalBorders": "列邊框線",
"showHeader": "顯示標題" "showHeader": "顯示標題",
"albumGroupConfig": "專輯群組設定",
"albumImageSize": "專輯圖片大小"
}, },
"label": { "label": {
"actions": "$t(common.action, {\"count\": 2})", "actions": "$t(common.action, {\"count\": 2})",
+18 -1
View File
@@ -7,16 +7,19 @@ let kuroshiroInstance: any = null;
let initPromise: null | Promise<void> = null; let initPromise: null | Promise<void> = null;
const getKuroshiro = async () => { const getKuroshiro = async () => {
if (kuroshiroInstance) return kuroshiroInstance;
if (initPromise) { if (initPromise) {
await initPromise; await initPromise;
return kuroshiroInstance; return kuroshiroInstance;
} }
if (kuroshiroInstance) return kuroshiroInstance;
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
kuroshiroInstance = new KuroshiroClass(); kuroshiroInstance = new KuroshiroClass();
initPromise = kuroshiroInstance.init(new KuromojiAnalyzer()); initPromise = kuroshiroInstance.init(new KuromojiAnalyzer());
await initPromise; await initPromise;
initPromise = null;
return kuroshiroInstance; return kuroshiroInstance;
}; };
@@ -35,3 +38,17 @@ export const convertFurigana = async (text: string): Promise<string> => {
return text; return text;
} }
}; };
export const convertRomaji = async (text: string): Promise<string> => {
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
if (!KuroshiroClass.Util.hasKana(text)) return '';
try {
const kuroshiro = await getKuroshiro();
return await kuroshiro.convert(text, { mode: 'spaced', to: 'romaji' });
} catch (e) {
console.error('Romaji conversion error: ', e);
return '';
}
};
+5 -1
View File
@@ -1,7 +1,7 @@
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { store } from '../settings'; import { store } from '../settings';
import { convertFurigana } from './furigana'; import { convertFurigana, convertRomaji } from './furigana';
import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius'; import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius';
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib'; import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease'; import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease';
@@ -236,3 +236,7 @@ ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => { ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => {
return await convertFurigana(text); return await convertFurigana(text);
}); });
ipcMain.handle('lyric-convert-romaji', async (_event, text: string) => {
return await convertRomaji(text);
});
+57 -10
View File
@@ -1,5 +1,5 @@
import console from 'console'; import console from 'console';
import { app, ipcMain } from 'electron'; import { app, ipcMain, powerMonitor } from 'electron';
import { access, rm } from 'fs/promises'; import { access, rm } from 'fs/promises';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv'; import MpvAPI from 'node-mpv';
@@ -85,6 +85,19 @@ const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
parameters.push('--prefetch-playlist=yes'); parameters.push('--prefetch-playlist=yes');
} }
// Without these, mpv/ffmpeg will block indefinitely on a dead TCP connection
// instead of failing or reconnecting. This commonly happens when the OS network
// adapter resets after the system wakes from sleep while a stream is open.
if (!extraParameters?.some((param) => param.startsWith('--network-timeout'))) {
parameters.push('--network-timeout=10');
}
if (!extraParameters?.some((param) => param.startsWith('--stream-lavf-o'))) {
parameters.push(
'--stream-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_at_eof=1,reconnect_delay_max=5',
);
}
return parameters; return parameters;
}; };
@@ -191,21 +204,44 @@ export const getMpvInstance = () => {
return mpvInstance; return mpvInstance;
}; };
const QUIT_TIMEOUT_MS = 3000;
const killMpvProcess = (mpv: MpvAPI) => {
const mpvProcess = (mpv as any).process || (mpv as any).mpvProcess;
if (mpvProcess && typeof mpvProcess.kill === 'function') {
try {
mpvProcess.kill('SIGTERM');
} catch (killErr) {
mpvLog({ action: 'Failed to kill mpv process' }, killErr as NodeMpvError);
}
}
};
const quit = async (instance?: MpvAPI | null) => { const quit = async (instance?: MpvAPI | null) => {
const mpv = instance || getMpvInstance(); const mpv = instance || getMpvInstance();
if (mpv) { if (mpv) {
try { try {
await mpv.quit(); // mpv.quit() resolves only when mpv replies over IPC. If mpv's command queue
// is wedged (e.g. blocked on a dead network stream after the system resumes
// from sleep), that reply never arrives, so this must not be allowed to hang
// forever - fall back to killing the process directly.
let timedOut = false;
await Promise.race([
mpv.quit(),
new Promise((resolve) => {
setTimeout(() => {
timedOut = true;
resolve(undefined);
}, QUIT_TIMEOUT_MS);
}),
]);
if (timedOut) {
killMpvProcess(mpv);
}
} catch { } catch {
// If quit() fails, try to kill the process directly // If quit() fails, try to kill the process directly
const mpvProcess = (mpv as any).process || (mpv as any).mpvProcess; killMpvProcess(mpv);
if (mpvProcess && typeof mpvProcess.kill === 'function') {
try {
mpvProcess.kill('SIGTERM');
} catch (killErr) {
mpvLog({ action: 'Failed to kill mpv process' }, killErr as NodeMpvError);
}
}
} }
if (!isWindows()) { if (!isWindows()) {
try { try {
@@ -666,6 +702,17 @@ const cleanupMpv = async (force = false) => {
} }
}; };
// When the OS resumes from sleep, any network stream mpv had open is likely dead
// (the connection silently dropped while the network adapter was suspended). Tell
// the renderer to reload mpv so it reconnects with a fresh stream instead of staying
// stuck on the old, now-dead connection until the app is manually restarted.
powerMonitor.on('resume', () => {
if (getMpvInstance()) {
mpvLog({ action: 'System resumed from sleep, reloading mpv' });
getMainWindow()?.webContents.send('renderer-mpv-reconnect');
}
});
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
switch (mpvState) { switch (mpvState) {
case MpvState.DONE: case MpvState.DONE:
+6 -1
View File
@@ -124,7 +124,12 @@ ipcMain.on('update-volume', (_event, volume) => {
}); });
ipcMain.on('update-playback', (_event, status: PlayerStatus) => { ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused'; mprisPlayer.playbackStatus =
status === PlayerStatus.PLAYING
? 'Playing'
: status === PlayerStatus.STOPPED
? 'Stopped'
: 'Paused';
}); });
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = { const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
+5
View File
@@ -30,8 +30,13 @@ const convertFurigana = (text: string): Promise<string> => {
return ipcRenderer.invoke('lyric-convert-furigana', text); return ipcRenderer.invoke('lyric-convert-furigana', text);
}; };
const convertRomaji = (text: string): Promise<string> => {
return ipcRenderer.invoke('lyric-convert-romaji', text);
};
export const lyrics = { export const lyrics = {
convertFurigana, convertFurigana,
convertRomaji,
getRemoteLyricsByRemoteId, getRemoteLyricsByRemoteId,
getRemoteLyricsBySong, getRemoteLyricsBySong,
searchRemoteLyrics, searchRemoteLyrics,
+5
View File
@@ -174,6 +174,10 @@ const rendererPlayerFallback = (cb: (data: boolean) => void) => {
ipcRenderer.on('renderer-player-fallback', (_, data) => cb(data)); ipcRenderer.on('renderer-player-fallback', (_, data) => cb(data));
}; };
const rendererMpvReconnect = (cb: () => void) => {
ipcRenderer.on('renderer-mpv-reconnect', () => cb());
};
export const mpvPlayer = { export const mpvPlayer = {
autoNext, autoNext,
cleanup, cleanup,
@@ -205,6 +209,7 @@ export const mpvPlayerListener = {
rendererAutoNext, rendererAutoNext,
rendererCurrentTime, rendererCurrentTime,
rendererError, rendererError,
rendererMpvReconnect,
rendererNext, rendererNext,
rendererPause, rendererPause,
rendererPlay, rendererPlay,
+1 -1
View File
@@ -128,7 +128,7 @@ export const RemoteContainer = () => {
onClick={() => { onClick={() => {
if (status === PlayerStatus.PLAYING) { if (status === PlayerStatus.PLAYING) {
send({ event: 'pause' }); send({ event: 'pause' });
} else if (status === PlayerStatus.PAUSED) { } else {
send({ event: 'play' }); send({ event: 'play' });
} }
}} }}
+12
View File
@@ -546,6 +546,18 @@ export const controller: GeneralController = {
server.type, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
}, },
getPlaylistSongIds(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: getPlaylistSongIds`);
}
return apiController(
'getPlaylistSongIds',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
getPlaylistSongList(args) { getPlaylistSongList(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -197,8 +197,8 @@ const JF_FIELDS = {
'SortName', 'SortName',
'ProviderIds', 'ProviderIds',
], ],
ALBUM_DETAIL: ['Genres', 'DateCreated', 'ChildCount', 'People', 'Tags', 'ProviderIds'], ALBUM_DETAIL: ['Genres', 'DateCreated', 'ChildCount', 'Tags', 'ProviderIds'],
ALBUM_LIST: ['People', 'Tags', 'Studios', 'SortName', 'ProviderIds', 'ChildCount'], ALBUM_LIST: ['Tags', 'Studios', 'SortName', 'ProviderIds', 'ChildCount'],
FOLDER: ['Genres', 'DateCreated', 'MediaSources', 'ParentId'], FOLDER: ['Genres', 'DateCreated', 'MediaSources', 'ParentId'],
GENRE: ['ItemCounts'], GENRE: ['ItemCounts'],
PLAYLIST_DETAIL: [ PLAYLIST_DETAIL: [
@@ -210,16 +210,7 @@ const JF_FIELDS = {
'SortName', 'SortName',
], ],
PLAYLIST_LIST: ['ChildCount', 'Genres', 'DateCreated', 'ParentId', 'Overview'], PLAYLIST_LIST: ['ChildCount', 'Genres', 'DateCreated', 'ParentId', 'Overview'],
SONG: [ SONG: ['Genres', 'DateCreated', 'MediaSources', 'ParentId', 'Tags', 'SortName', 'ProviderIds'],
'Genres',
'DateCreated',
'MediaSources',
'ParentId',
'People',
'Tags',
'SortName',
'ProviderIds',
],
} as const; } as const;
export const JellyfinController: InternalControllerEndpoint = { export const JellyfinController: InternalControllerEndpoint = {
@@ -1056,6 +1047,35 @@ export const JellyfinController: InternalControllerEndpoint = {
apiClientProps, apiClientProps,
query: { ...query, limit: 1, startIndex: 0 }, query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!), }).then((result) => result!.totalRecordCount!),
getPlaylistSongIds: async (args) => {
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getPlaylistSongList({
params: {
id: query.id,
},
query: {
// XXX: No fields are required for only IDs, which saves processing time between
// the Jellyfin server query, network (MBs vs KBs), and in-app parsing.
IncludeItemTypes: 'Audio',
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist song list IDs');
}
return {
items: res.body.Items.map((item) => item.Id),
startIndex: 0,
totalRecordCount: res.body.TotalRecordCount,
};
},
getPlaylistSongList: async (args) => { getPlaylistSongList: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -1068,7 +1088,7 @@ export const JellyfinController: InternalControllerEndpoint = {
id: query.id, id: query.id,
}, },
query: { query: {
Fields: JF_FIELDS.SONG, Fields: JF_FIELDS.PLAYLIST_DETAIL,
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
UserId: apiClientProps.server?.userId, UserId: apiClientProps.server?.userId,
}, },
@@ -19,7 +19,6 @@ import {
DeleteInternetRadioStationImageResponse, DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs, DeletePlaylistImageArgs,
DeletePlaylistImageResponse, DeletePlaylistImageResponse,
genreListSortMap,
InternalControllerEndpoint, InternalControllerEndpoint,
playlistListSortMap, playlistListSortMap,
PlaylistSongListArgs, PlaylistSongListArgs,
@@ -596,26 +595,7 @@ export const NavidromeController: InternalControllerEndpoint = {
}; };
} }
const res = await ndApiClient(apiClientProps).getGenreList({ return SubsonicController.getGenreList(args);
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: genreListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
library_id: getLibraryId(query.musicFolderId),
name: query.searchTerm,
},
});
if (res.status !== 200) {
throw new Error('Failed to get genre list');
}
return {
items: res.body.data.map((genre) => ndNormalize.genre(genre, apiClientProps.server)),
startIndex: query.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
}, },
getImageRequest: SubsonicController.getImageRequest, getImageRequest: SubsonicController.getImageRequest,
getImageUrl: SubsonicController.getImageUrl, getImageUrl: SubsonicController.getImageUrl,
@@ -683,6 +663,11 @@ export const NavidromeController: InternalControllerEndpoint = {
apiClientProps, apiClientProps,
query: { ...query, limit: 1, startIndex: 0 }, query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!), }).then((result) => result!.totalRecordCount!),
getPlaylistSongIds: async (args) =>
NavidromeController.getPlaylistSongList(args).then((result) => ({
...result,
items: result.items.map((song) => song.id),
})),
getPlaylistSongList: async (args: PlaylistSongListArgs): Promise<PlaylistSongListResponse> => { getPlaylistSongList: async (args: PlaylistSongListArgs): Promise<PlaylistSongListResponse> => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
+3
View File
@@ -338,6 +338,9 @@ export const queryKeys: Record<
return [serverId, 'playlists', 'songList'] as const; return [serverId, 'playlists', 'songList'] as const;
}, },
songListIds: (serverId: string, id: string) => {
return [serverId, 'playlists', 'songListIds', id] as const;
},
}, },
radio: { radio: {
list: (serverId: string) => [serverId, 'radio', 'list'] as const, list: (serverId: string) => [serverId, 'radio', 'list'] as const,
@@ -1090,9 +1090,15 @@ export const SubsonicController: InternalControllerEndpoint = {
} }
switch (query.sortBy) { switch (query.sortBy) {
case GenreListSort.ALBUM_COUNT:
results = orderBy(results, [(v) => v.albumCount], [sortOrder]);
break;
case GenreListSort.NAME: case GenreListSort.NAME:
results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]); results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]);
break; break;
case GenreListSort.SONG_COUNT:
results = orderBy(results, [(v) => v.songCount], [sortOrder]);
break;
default: default:
break; break;
} }
@@ -1223,6 +1229,11 @@ export const SubsonicController: InternalControllerEndpoint = {
return results.length; return results.length;
}, },
getPlaylistSongIds: async (args) =>
SubsonicController.getPlaylistSongList(args).then((result) => ({
...result,
items: result.items.map((song) => song.id),
})),
getPlaylistSongList: async ({ apiClientProps, query }) => { getPlaylistSongList: async ({ apiClientProps, query }) => {
const res = await ssApiClient(apiClientProps).getPlaylist({ const res = await ssApiClient(apiClientProps).getPlaylist({
query: { query: {
@@ -10,7 +10,7 @@ import {
LONG_PRESS_PLAY_BEHAVIOR, LONG_PRESS_PLAY_BEHAVIOR,
PlayTooltip, PlayTooltip,
} from '/@/renderer/features/shared/components/play-button-group'; } from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonBehavior } from '/@/renderer/store'; import { useAlbumGroupImageSize, usePlayButtonBehavior } from '/@/renderer/store';
import { LibraryItem, Song } from '/@/shared/types/domain-types'; import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
@@ -29,12 +29,33 @@ export const AlbumGroupHeader = ({
}: AlbumGroupHeaderProps): ReactElement => { }: AlbumGroupHeaderProps): ReactElement => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
const albumImageSize = useAlbumGroupImageSize();
const rowHeight = { const rowHeight = {
compact: TableItemSize.COMPACT, compact: TableItemSize.COMPACT,
large: TableItemSize.LARGE, large: TableItemSize.LARGE,
normal: TableItemSize.DEFAULT, normal: TableItemSize.DEFAULT,
}[size]; }[size];
const infoHeight = groupRowCount !== undefined ? groupRowCount * rowHeight : undefined; // The album group spans the combined row height, but when the image is
// enlarged the group's last row is grown so the total reaches the img size.
const infoHeight =
groupRowCount !== undefined
? albumImageSize > 0
? Math.max(albumImageSize, groupRowCount * rowHeight)
: groupRowCount * rowHeight
: undefined;
const imageContainerStyle =
albumImageSize > 0
? {
aspectRatio: 'auto',
height: `${albumImageSize}px`,
paddingBottom: 'var(--theme-spacing-xs)',
paddingTop: 'var(--theme-spacing-xs)',
position: 'relative' as const,
width: `${albumImageSize}px`,
zIndex: 1,
}
: undefined;
return ( return (
<div className={styles.container}> <div className={styles.container}>
@@ -42,6 +63,7 @@ export const AlbumGroupHeader = ({
className={styles.imageContainer} className={styles.imageContainer}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
style={imageContainerStyle}
> >
<ItemImage <ItemImage
className={imageColumnStyles.compactImage} className={imageColumnStyles.compactImage}
@@ -64,6 +64,12 @@ export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => {
...(needsBorder ...(needsBorder
? { borderBottom: '1px solid var(--theme-colors-border)' } ? { borderBottom: '1px solid var(--theme-colors-border)' }
: {}), : {}),
// When the cover is enlarged it overflows down from the
// group's first row into these cells; let hover/click pass
// through to reach it.
...((props.albumGroupImageSize ?? 0) > 0
? { pointerEvents: 'none' as const }
: {}),
}} }}
/> />
); );
@@ -64,6 +64,7 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
return draggedItems; return draggedItems;
}, },
itemType, itemType,
metadata: { playlistId },
onDragStart: () => { onDragStart: () => {
if (!item || !isDataRow) { if (!item || !isDataRow) {
return; return;
@@ -248,10 +249,15 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
case DragTarget.SONG: { case DragTarget.SONG: {
const sourceItems = (args.source.item || []) as Song[]; const sourceItems = (args.source.item || []) as Song[];
if (sourceItems.length > 0) { if (sourceItems.length > 0) {
playerContext.addToQueueByData(sourceItems, { const sourcePlaylistId = args.source.metadata?.playlistId as
edge: args.edge, | string
uniqueId: droppedOnUniqueId, | undefined;
}); playerContext.addToQueueByData(
sourceItems,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
undefined,
sourcePlaylistId ?? null,
);
} }
break; break;
} }
@@ -31,6 +31,11 @@
padding-left: 0; padding-left: 0;
} }
.container.no-vertical-padding {
padding-top: 0;
padding-bottom: 0;
}
.container.center { .container.center {
align-items: center; align-items: center;
text-align: center; text-align: center;
@@ -57,7 +57,10 @@ import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table
import { TrackNumberColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-number-column'; import { TrackNumberColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-number-column';
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column'; import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state'; import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list'; import {
TableItemProps,
TableItemSize,
} from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { useItemTableListColumnResizeLive } from '/@/renderer/components/item-list/item-table-list/item-table-list-context'; import { useItemTableListColumnResizeLive } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types'; import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
import { Flex } from '/@/shared/components/flex/flex'; import { Flex } from '/@/shared/components/flex/flex';
@@ -381,6 +384,36 @@ export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nex
const NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_ARTIST, TableColumn.TITLE_COMBINED]; const NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_ARTIST, TableColumn.TITLE_COMBINED];
// Counts how many consecutive rows belong to the same album group as `rowIndex`.
export function getAlbumGroupRowCount(
rowIndex: number,
getRowItem: ((index: number) => unknown) | undefined,
enableHeader: boolean | undefined,
dataLength: number,
): number {
const item = getRowItem?.(rowIndex) as null | undefined | { album?: string };
if (!item?.album) return 1;
const firstDataRow = enableHeader ? 1 : 0;
const maxRow = enableHeader ? dataLength + 1 : dataLength;
let start = rowIndex;
while (start > firstDataRow) {
const prevItem = getRowItem?.(start - 1) as null | undefined | { album?: string };
if (!prevItem || prevItem.album !== item.album) break;
start--;
}
let end = rowIndex;
while (end + 1 < maxRow) {
const nextItem = getRowItem?.(end + 1) as null | undefined | { album?: string };
if (!nextItem || nextItem.album !== item.album) break;
end++;
}
return end - start + 1;
}
export function isAlbumGroupingActive(columns: { id: string; isEnabled?: boolean }[]): boolean { export function isAlbumGroupingActive(columns: { id: string; isEnabled?: boolean }[]): boolean {
return columns.some((col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled); return columns.some((col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled);
} }
@@ -402,6 +435,106 @@ export function isLastInAlbumGroup(
return !nextItem || nextItem.album !== item.album; return !nextItem || nextItem.album !== item.album;
} }
function baseRowHeightForSize(size: ItemTableListColumn['size']): number {
if (size === 'compact') return TableItemSize.COMPACT;
if (size === 'large') return TableItemSize.LARGE;
return TableItemSize.DEFAULT;
}
// Wraps a clamped cell with the spacer that fills the reserved (grown) height
// below it. The spacer carries the group's bottom/right borders so they align
// across all columns.
function ClampedCell({
cell,
clampHeight,
outerStyle,
showHorizontalBorder,
showVerticalBorder,
}: {
cell: ReactElement;
clampHeight: null | number;
outerStyle?: CSSProperties;
showHorizontalBorder: boolean;
showVerticalBorder: boolean;
}): ReactElement {
const grownHeight = typeof outerStyle?.height === 'number' ? outerStyle.height : 0;
const spacerHeight = clampHeight !== null ? grownHeight - clampHeight : 0;
if (clampHeight === null || spacerHeight <= 0) return cell;
return (
<div style={outerStyle}>
{cell}
<div
aria-hidden
style={{
borderBottom: showHorizontalBorder
? '1px solid var(--theme-colors-border)'
: undefined,
borderRight: showVerticalBorder
? '1px solid var(--theme-colors-border)'
: undefined,
height: spacerHeight,
}}
/>
</div>
);
}
// When an enlarged album image extends past the album group's combined row
// height, the last row of the group is grown (in getRowHeight) to reserve the
// leftover space. This returns the standard (un-grown) height to clamp that
// row's non-album cells to, so the track content + hover/selection stay at
// standard height and the reserved space below is left empty (uniform
// background) for the overflowing album image.
function getAlbumGroupClampHeight(props: ItemTableListInnerColumn): null | number {
const albumImageSize = props.albumGroupImageSize ?? 0;
if (albumImageSize <= 0) return null;
if (props.type === TableColumn.ALBUM_GROUP) return null;
if (!isAlbumGroupingActive(props.columns)) return null;
const isDataRow = props.enableHeader ? props.rowIndex > 0 : true;
if (!isDataRow) return null;
const item = props.getRowItem?.(props.rowIndex) as null | undefined | { album?: string };
if (!item?.album) return null;
if (
!isLastInAlbumGroup(props.rowIndex, props.getRowItem, props.enableHeader, props.data.length)
) {
return null;
}
const baseHeight = baseRowHeightForSize(props.size);
const groupRowCount = getAlbumGroupRowCount(
props.rowIndex,
props.getRowItem,
props.enableHeader,
props.data.length,
);
// Only clamp when the row was actually grown to fit the image.
if (albumImageSize <= groupRowCount * baseHeight) return null;
return baseHeight;
}
function showHorizontalBorderFor(props: ItemTableListInnerColumn, isLastRow: boolean): boolean {
if (!props.enableHorizontalBorders || !props.enableHeader || props.rowIndex <= 0) {
return false;
}
if (isAlbumGroupingActive(props.columns)) {
return isLastInAlbumGroup(
props.rowIndex,
props.getRowItem,
!!props.enableHeader,
props.data.length,
);
}
return props.rowIndex === 1 || !isLastRow;
}
export const TableColumnTextContainer = ( export const TableColumnTextContainer = (
props: ItemTableListColumn & { props: ItemTableListColumn & {
children: React.ReactNode; children: React.ReactNode;
@@ -425,6 +558,7 @@ export const TableColumnTextContainer = (
? props.internalState.extractRowId(item) ? props.internalState.extractRowId(item)
: undefined; : undefined;
const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined); const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined);
const clampHeight = getAlbumGroupClampHeight(props);
const isDragging = props.isDragging ?? false; const isDragging = props.isDragging ?? false;
const mergedRef = useMergedRef(containerRef, props.dragRef ?? null); const mergedRef = useMergedRef(containerRef, props.dragRef ?? null);
@@ -507,7 +641,10 @@ export const TableColumnTextContainer = (
} }
}; };
return ( const showHorizontalBorder = showHorizontalBorderFor(props, isLastRow);
const showVerticalBorder = !!props.enableVerticalBorders && !isLastColumn;
const cell = (
<div <div
className={clsx(styles.container, props.containerClassName, { className={clsx(styles.container, props.containerClassName, {
[styles.alternateRowEven]: [styles.alternateRowEven]:
@@ -529,25 +666,16 @@ export const TableColumnTextContainer = (
[styles.right]: props.columns[props.columnIndex].align === 'end', [styles.right]: props.columns[props.columnIndex].align === 'end',
[styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight, [styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight,
[styles.rowSelected]: isDataRow && isSelected, [styles.rowSelected]: isDataRow && isSelected,
[styles.withHorizontalBorder]: // When clamped, the bottom border is drawn on the spacer below
props.enableHorizontalBorders && // instead.
props.enableHeader && [styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null,
props.rowIndex > 0 && [styles.withVerticalBorder]: showVerticalBorder,
(isAlbumGroupingActive(props.columns)
? isLastInAlbumGroup(
props.rowIndex,
props.getRowItem,
!!props.enableHeader,
props.data.length,
)
: props.rowIndex === 1 || !isLastRow),
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
})} })}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
ref={mergedRef} ref={mergedRef}
style={props.style} style={clampHeight !== null ? { height: clampHeight } : props.style}
> >
<Text <Text
className={clsx(styles.content, props.className, { className={clsx(styles.content, props.className, {
@@ -561,6 +689,16 @@ export const TableColumnTextContainer = (
</Text> </Text>
</div> </div>
); );
return (
<ClampedCell
cell={cell}
clampHeight={clampHeight}
outerStyle={props.style}
showHorizontalBorder={showHorizontalBorder}
showVerticalBorder={showVerticalBorder}
/>
);
}; };
export const TableColumnContainer = ( export const TableColumnContainer = (
@@ -586,6 +724,7 @@ export const TableColumnContainer = (
? props.internalState.extractRowId(item) ? props.internalState.extractRowId(item)
: undefined; : undefined;
const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined); const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined);
const clampHeight = getAlbumGroupClampHeight(props);
const isDragging = props.isDragging ?? false; const isDragging = props.isDragging ?? false;
const mergedRef = useMergedRef(containerRef, props.dragRef ?? null); const mergedRef = useMergedRef(containerRef, props.dragRef ?? null);
@@ -668,7 +807,10 @@ export const TableColumnContainer = (
} }
}; };
return ( const showHorizontalBorder = showHorizontalBorderFor(props, isLastRow);
const showVerticalBorder = !!props.enableVerticalBorders && !isLastColumn;
const cell = (
<div <div
className={clsx(styles.container, props.className, { className={clsx(styles.container, props.className, {
[styles.alternateRowEven]: [styles.alternateRowEven]:
@@ -682,6 +824,8 @@ export const TableColumnContainer = (
[styles.large]: props.size === 'large', [styles.large]: props.size === 'large',
[styles.left]: props.columns[props.columnIndex].align === 'start', [styles.left]: props.columns[props.columnIndex].align === 'start',
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type), [styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
[styles.noVerticalPadding]:
props.type === TableColumn.ALBUM_GROUP && (props.albumGroupImageSize ?? 0) > 0,
[styles.paddingLg]: props.cellPadding === 'lg', [styles.paddingLg]: props.cellPadding === 'lg',
[styles.paddingMd]: props.cellPadding === 'md', [styles.paddingMd]: props.cellPadding === 'md',
[styles.paddingSm]: props.cellPadding === 'sm', [styles.paddingSm]: props.cellPadding === 'sm',
@@ -694,29 +838,33 @@ export const TableColumnContainer = (
props.type !== TableColumn.ALBUM_GROUP, props.type !== TableColumn.ALBUM_GROUP,
[styles.rowSelected]: [styles.rowSelected]:
isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP, isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP,
[styles.withHorizontalBorder]: // When clamped, the bottom border is drawn on the spacer below instead.
props.enableHorizontalBorders && [styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null,
props.enableHeader && [styles.withVerticalBorder]: showVerticalBorder,
props.rowIndex > 0 &&
(isAlbumGroupingActive(props.columns)
? isLastInAlbumGroup(
props.rowIndex,
props.getRowItem,
!!props.enableHeader,
props.data.length,
)
: props.rowIndex === 1 || !isLastRow),
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
})} })}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
ref={mergedRef} ref={mergedRef}
style={{ ...props.containerStyle, ...props.style }} style={
clampHeight !== null
? { ...props.containerStyle, height: clampHeight }
: { ...props.containerStyle, ...props.style }
}
> >
{props.children} {props.children}
</div> </div>
); );
return (
<ClampedCell
cell={cell}
clampHeight={clampHeight}
outerStyle={props.style}
showHorizontalBorder={showHorizontalBorder}
showVerticalBorder={showVerticalBorder}
/>
);
}; };
interface ColumnResizeHandleProps { interface ColumnResizeHandleProps {
@@ -44,7 +44,11 @@ import { useTableKeyboardNavigation } from '/@/renderer/components/item-list/ite
import { useTablePaneSync } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync'; import { useTablePaneSync } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync';
import { useTableRowModel } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-row-model'; import { useTableRowModel } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-row-model';
import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index'; import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; import {
getAlbumGroupRowCount,
isLastInAlbumGroup,
ItemTableListColumn,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { import {
ItemTableListColumnResizeLiveProvider, ItemTableListColumnResizeLiveProvider,
type ItemTableListConfig, type ItemTableListConfig,
@@ -66,7 +70,7 @@ import {
ItemTableListColumnConfig, ItemTableListColumnConfig,
} from '/@/renderer/components/item-list/types'; } from '/@/renderer/components/item-list/types';
import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context'; import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerStore } from '/@/renderer/store'; import { useAlbumGroupImageSize, usePlayerStore } from '/@/renderer/store';
import { animationProps } from '/@/shared/components/animations/animation-props'; import { animationProps } from '/@/shared/components/animations/animation-props';
import { useFocusWithin } from '/@/shared/hooks/use-focus-within'; import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref'; import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
@@ -215,6 +219,7 @@ const VirtualizedTableGrid = ({
totalRowCount, totalRowCount,
}: VirtualizedTableGridProps) => { }: VirtualizedTableGridProps) => {
const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig; const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig;
const albumGroupImageSize = useAlbumGroupImageSize();
const hoverDelegateRef = useRef<HTMLDivElement | null>(null); const hoverDelegateRef = useRef<HTMLDivElement | null>(null);
useRowInteractionDelegate({ useRowInteractionDelegate({
@@ -403,6 +408,7 @@ const VirtualizedTableGrid = ({
const itemProps: TableItemProps = useMemo( const itemProps: TableItemProps = useMemo(
() => ({ () => ({
albumGroupImageSize,
cellPadding: tableConfig.cellPadding, cellPadding: tableConfig.cellPadding,
columns: tableConfig.columns, columns: tableConfig.columns,
controls: tableConfig.controls, controls: tableConfig.controls,
@@ -427,7 +433,7 @@ const VirtualizedTableGrid = ({
tableId: tableConfig.tableId, tableId: tableConfig.tableId,
...gridOnlyProps, ...gridOnlyProps,
}), }),
[gridOnlyProps, tableConfig], [albumGroupImageSize, gridOnlyProps, tableConfig],
); );
const pinnedLeftGridMinWidthPx = useMemo(() => { const pinnedLeftGridMinWidthPx = useMemo(() => {
@@ -760,6 +766,7 @@ export interface TableGroupHeader {
export interface TableItemProps { export interface TableItemProps {
adjustedRowIndexMap?: Map<number, number>; adjustedRowIndexMap?: Map<number, number>;
albumGroupImageSize?: number;
calculatedColumnWidths?: number[]; calculatedColumnWidths?: number[];
cellPadding?: ItemTableListProps['cellPadding']; cellPadding?: ItemTableListProps['cellPadding'];
columns: ItemTableListColumnConfig[]; columns: ItemTableListColumnConfig[];
@@ -1275,6 +1282,7 @@ const BaseItemTableList = ({
}: ItemTableListProps) => { }: ItemTableListProps) => {
const { playlistId: routePlaylistId } = useParams() as { playlistId?: string }; const { playlistId: routePlaylistId } = useParams() as { playlistId?: string };
const tableId = useId(); const tableId = useId();
const albumGroupImageSize = useAlbumGroupImageSize();
const baseItemCount = itemCount ?? data.length; const baseItemCount = itemCount ?? data.length;
const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount; const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount;
const [centerContainerWidth, setCenterContainerWidth] = useState(0); const [centerContainerWidth, setCenterContainerWidth] = useState(0);
@@ -1434,9 +1442,38 @@ const BaseItemTableList = ({
return headerHeight; return headerHeight;
} }
// When an album image is enlarged beyond the album group's combined
// row height, grow the group's LAST row to reserve the leftover
// space (so the following album isn't clipped). Other rows keep
// their standard height.
if (
albumGroupImageSize > baseHeight &&
cellProps?.hasAlbumGroupColumn &&
isLastInAlbumGroup(
index,
cellProps.getRowItem,
cellProps.enableHeader,
cellProps.data.length,
)
) {
const item = cellProps.getRowItem?.(index) as null | undefined | { album?: string };
if (item?.album) {
const groupRowCount = getAlbumGroupRowCount(
index,
cellProps.getRowItem,
cellProps.enableHeader,
cellProps.data.length,
);
const lastRowHeight = albumGroupImageSize - (groupRowCount - 1) * baseHeight;
if (lastRowHeight > baseHeight) {
return lastRowHeight;
}
}
}
return baseHeight; return baseHeight;
}, },
[enableHeader, headerHeight, rowHeight, pinnedRowCount, size], [albumGroupImageSize, enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
); );
// Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook) // Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook)
+7
View File
@@ -13,6 +13,7 @@ export type EventMap = {
MPV_RELOAD: MpvReloadEventPayload; MPV_RELOAD: MpvReloadEventPayload;
PLAYER_PLAY: PlayerPlayEventPayload; PLAYER_PLAY: PlayerPlayEventPayload;
PLAYER_REPEATED: PlayerRepeatedEventPayload; PLAYER_REPEATED: PlayerRepeatedEventPayload;
PLAYER_STOP: PlayerStopEventPayload;
PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload; PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload; PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload; PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;
@@ -54,6 +55,12 @@ export type PlayerRepeatedEventPayload = {
index: number; index: number;
}; };
export type PlayerStopEventPayload = {
id?: string;
index?: number;
reset: boolean;
};
export type PlaylistMoveEventPayload = { export type PlaylistMoveEventPayload = {
playlistId: string; playlistId: string;
sourceIds: string[]; sourceIds: string[];
@@ -7,11 +7,14 @@ import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-o
import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { setMultipleSearchParams } from '/@/renderer/utils/query-params'; import { setMultipleSearchParams } from '/@/renderer/utils/query-params';
import { runInUrlTransition } from '/@/renderer/utils/url-transition'; import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { AlbumArtistListSort } from '/@/shared/types/domain-types'; import { AlbumArtistListSort, ArtistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
export const useAlbumArtistListFilters = () => { export const useAlbumArtistListFilters = () => {
const { sortBy } = useSortByFilter<AlbumArtistListSort>(null, ItemListKey.ALBUM_ARTIST); const { sortBy } = useSortByFilter<AlbumArtistListSort>(
ArtistListSort.NAME,
ItemListKey.ALBUM_ARTIST,
);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST); const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST);
@@ -7,7 +7,7 @@ import { ArtistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
export const useArtistListFilters = () => { export const useArtistListFilters = () => {
const { sortBy } = useSortByFilter<ArtistListSort>(null, ItemListKey.ARTIST); const { sortBy } = useSortByFilter<ArtistListSort>(ArtistListSort.NAME, ItemListKey.ARTIST);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.ARTIST); const { sortOrder } = useSortOrderFilter(null, ItemListKey.ARTIST);
@@ -211,11 +211,11 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
let songsToAdd: string[] = allSongIds; let songsToAdd: string[] = allSongIds;
if (skipDuplicates) { if (skipDuplicates) {
const queryKey = queryKeys.playlists.songList(serverId, playlistId); const queryKey = queryKeys.playlists.songListIds(serverId, playlistId);
const playlistSongsRes = await queryClient.fetchQuery({ const playlistSongsRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => { queryFn: ({ signal }) => {
return api.controller.getPlaylistSongList({ return api.controller.getPlaylistSongIds({
apiClientProps: { apiClientProps: {
serverId, serverId,
signal, signal,
@@ -228,7 +228,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
queryKey, queryKey,
}); });
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id); const playlistSongIds = playlistSongsRes?.items;
const uniqueSongIds: string[] = []; const uniqueSongIds: string[] = [];
for (const songId of allSongIds) { for (const songId of allSongIds) {
@@ -81,6 +81,15 @@ export const useDiscordRpc = () => {
privateModeRef.current = privateMode; privateModeRef.current = privateMode;
}, [privateMode]); }, [privateMode]);
// If the component is unmounted while RPC is enabled, quit RPC
useEffect(() => {
return () => {
if (previousEnabledRef.current) {
discordRpc?.quit();
}
};
}, []);
const setActivity = useCallback( const setActivity = useCallback(
async (current: ActivityState, trigger: ActivityTrigger) => { async (current: ActivityState, trigger: ActivityTrigger) => {
const song = current[0]; const song = current[0];
@@ -6,7 +6,7 @@ import { GenreListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
export const useGenreListFilters = () => { export const useGenreListFilters = () => {
const { sortBy } = useSortByFilter<GenreListSort>(null, ItemListKey.GENRE); const { sortBy } = useSortByFilter<GenreListSort>(GenreListSort.NAME, ItemListKey.GENRE);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.GENRE); const { sortOrder } = useSortOrderFilter(null, ItemListKey.GENRE);
@@ -307,6 +307,20 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.enableFurigana'), title: t('setting.enableFurigana'),
}, },
{
control: (
<Switch
aria-label="Enable romaji"
defaultChecked={lyricsSettings.enableRomaji}
onChange={(e) => updateLyricsSetting({ enableRomaji: e.currentTarget.checked })}
/>
),
description: t('setting.enableRomaji', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableRomaji'),
},
{ {
control: ( control: (
<Switch <Switch
@@ -14,13 +14,13 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
if (typeof lyrics === 'string') { if (typeof lyrics === 'string') {
return await lyricsApi.convertFurigana(lyrics); return await lyricsApi.convertFurigana(lyrics);
} else if (Array.isArray(lyrics)) { } else if (Array.isArray(lyrics)) {
const text = lyrics.map(([, line]) => line).join('\n'); const converted = await Promise.all(
const converted = await lyricsApi.convertFurigana(text); lyrics.map(async ([time, line]) => [
const convertedLines = converted.split('\n'); time,
return lyrics.map(([time], i) => [ await lyricsApi.convertFurigana(line),
time, ]),
convertedLines[i] ?? lyrics[i][1], );
]) as SynchronizedLyricsArray; return converted as SynchronizedLyricsArray;
} }
return lyrics; return lyrics;
}, },
@@ -28,3 +28,24 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
staleTime: Infinity, staleTime: Infinity,
}); });
}; };
export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => {
return useQuery({
enabled: enabled && !!lyrics && !!lyricsApi,
queryFn: async () => {
if (!lyrics || !lyricsApi || !enabled) return lyrics;
if (typeof lyrics === 'string') {
return await lyricsApi.convertRomaji(lyrics);
} else if (Array.isArray(lyrics)) {
const converted = await Promise.all(
lyrics.map(async ([time, line]) => [time, await lyricsApi.convertRomaji(line)]),
);
return converted as SynchronizedLyricsArray;
}
return lyrics;
},
queryKey: ['romaji', lyrics],
staleTime: Infinity,
});
};
@@ -25,3 +25,8 @@
.lyric-line:global(.synchronized) { .lyric-line:global(.synchronized) {
cursor: pointer; cursor: pointer;
} }
.romaji-line {
font-size: 0.8em;
font-weight: 600;
}
+20 -1
View File
@@ -10,11 +10,21 @@ import { Stack } from '/@/shared/components/stack/stack';
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
alignment: 'center' | 'left' | 'right'; alignment: 'center' | 'left' | 'right';
fontSize: number; fontSize: number;
romajiText?: null | string;
text: string; text: string;
translatedText?: null | string;
} }
export const LyricLine = memo( export const LyricLine = memo(
({ alignment, className, fontSize, text, ...props }: LyricLineProps) => { ({
alignment,
className,
fontSize,
romajiText,
text,
translatedText,
...props
}: LyricLineProps) => {
const lines = useMemo(() => text.split('_BREAK_'), [text]); const lines = useMemo(() => text.split('_BREAK_'), [text]);
const style = useMemo( const style = useMemo(
@@ -31,6 +41,15 @@ export const LyricLine = memo(
{lines.map((line, index) => ( {lines.map((line, index) => (
<span dangerouslySetInnerHTML={{ __html: sanitize(line) }} key={index} /> <span dangerouslySetInnerHTML={{ __html: sanitize(line) }} key={index} />
))} ))}
{romajiText && (
<span
className={styles.romajiLine}
dangerouslySetInnerHTML={{ __html: sanitize(romajiText) }}
/>
)}
{translatedText && (
<span dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }} />
)}
</Stack> </Stack>
</Box> </Box>
); );
+16 -1
View File
@@ -14,7 +14,10 @@ import {
type LyricsQueryResult, type LyricsQueryResult,
} from '/@/renderer/features/lyrics/api/lyrics-api'; } from '/@/renderer/features/lyrics/api/lyrics-api';
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form'; import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
import { useFuriganaLyrics } from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics'; import {
useFuriganaLyrics,
useRomajiLyrics,
} from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics';
import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions'; import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions';
import { import {
SynchronizedLyrics, SynchronizedLyrics,
@@ -51,6 +54,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const { const {
enableAutoTranslation, enableAutoTranslation,
enableFurigana, enableFurigana,
enableRomaji,
preferLocalLyrics, preferLocalLyrics,
translationApiKey, translationApiKey,
translationApiProvider, translationApiProvider,
@@ -119,6 +123,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
}, [data, indexToUse, preferLocalLyrics]); }, [data, indexToUse, preferLocalLyrics]);
const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana); const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana);
const { data: romajiConvertedLyrics } = useRomajiLyrics(lyrics?.lyrics, !!enableRomaji);
const displayLyrics = useMemo(() => { const displayLyrics = useMemo(() => {
if (isLyricsDisabled || !lyrics) return null; if (isLyricsDisabled || !lyrics) return null;
@@ -344,12 +349,22 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
<SynchronizedLyrics <SynchronizedLyrics
{...(displayLyrics as SynchronizedLyricsProps)} {...(displayLyrics as SynchronizedLyricsProps)}
offsetMs={displayOffsetMs} offsetMs={displayOffsetMs}
romajiLyrics={
enableRomaji
? (romajiConvertedLyrics as SynchronizedLyricsProps['romajiLyrics'])
: null
}
settingsKey={settingsKey} settingsKey={settingsKey}
translatedLyrics={showTranslation ? translatedLyrics : null} translatedLyrics={showTranslation ? translatedLyrics : null}
/> />
) : ( ) : (
<UnsynchronizedLyrics <UnsynchronizedLyrics
{...(displayLyrics as UnsynchronizedLyricsProps)} {...(displayLyrics as UnsynchronizedLyricsProps)}
romajiLyrics={
enableRomaji
? (romajiConvertedLyrics as UnsynchronizedLyricsProps['romajiLyrics'])
: null
}
settingsKey={settingsKey} settingsKey={settingsKey}
translatedLyrics={showTranslation ? translatedLyrics : null} translatedLyrics={showTranslation ? translatedLyrics : null}
/> />
@@ -23,6 +23,7 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> { export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: SynchronizedLyricsArray; lyrics: SynchronizedLyricsArray;
offsetMs?: number; offsetMs?: number;
romajiLyrics?: null | SynchronizedLyricsArray;
settingsKey?: string; settingsKey?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
translatedLyrics?: null | string; translatedLyrics?: null | string;
@@ -34,6 +35,7 @@ export const SynchronizedLyrics = ({
name, name,
offsetMs, offsetMs,
remote, remote,
romajiLyrics,
settingsKey = 'default', settingsKey = 'default',
source, source,
style, style,
@@ -93,6 +95,7 @@ export const SynchronizedLyrics = ({
const scrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null); const scrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const programmaticScrollRef = useRef(false); const programmaticScrollRef = useRef(false);
const programmaticScrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const getCurrentLyric = (timeInMs: number) => { const getCurrentLyric = (timeInMs: number) => {
const activeLyrics = lyricRef.current; const activeLyrics = lyricRef.current;
@@ -176,9 +179,6 @@ export const SynchronizedLyrics = ({
if (followRef.current && !userScrollingRef.current) { if (followRef.current && !userScrollingRef.current) {
programmaticScrollRef.current = true; programmaticScrollRef.current = true;
doc?.scroll({ behavior: 'smooth', top: offsetTop }); doc?.scroll({ behavior: 'smooth', top: offsetTop });
setTimeout(() => {
programmaticScrollRef.current = false;
}, 600);
} }
if (index !== lyricRef.current!.length - 1) { if (index !== lyricRef.current!.length - 1) {
@@ -285,6 +285,14 @@ export const SynchronizedLyrics = ({
const handleScroll = () => { const handleScroll = () => {
// Ignore programmatic scrolls (auto-scroll) // Ignore programmatic scrolls (auto-scroll)
if (programmaticScrollRef.current) { if (programmaticScrollRef.current) {
if (programmaticScrollTimeoutRef.current) {
clearTimeout(programmaticScrollTimeoutRef.current);
}
programmaticScrollTimeoutRef.current = setTimeout(() => {
programmaticScrollRef.current = false;
}, 150);
return; return;
} }
@@ -307,6 +315,10 @@ export const SynchronizedLyrics = ({
if (scrollTimeoutRef.current) { if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current); clearTimeout(scrollTimeoutRef.current);
} }
if (programmaticScrollTimeoutRef.current) {
clearTimeout(programmaticScrollTimeoutRef.current);
}
}; };
}, []); }, []);
@@ -368,10 +380,9 @@ export const SynchronizedLyrics = ({
handleSeek(time / 1000); handleSeek(time / 1000);
} }
}} }}
text={ romajiText={romajiLyrics?.[idx]?.[1]}
text + text={text}
(translatedLyrics ? `_BREAK_${translatedLyrics.split('\n')[idx]}` : '') translatedText={translatedLyrics?.split('\n')[idx]}
}
/> />
))} ))}
</div> </div>
@@ -8,6 +8,7 @@ import { FullLyricsMetadata } from '/@/shared/types/domain-types';
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> { export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string; lyrics: string;
romajiLyrics?: null | string;
settingsKey?: string; settingsKey?: string;
translatedLyrics?: null | string; translatedLyrics?: null | string;
} }
@@ -17,6 +18,7 @@ export const UnsynchronizedLyrics = ({
lyrics, lyrics,
name, name,
remote, remote,
romajiLyrics,
settingsKey = 'default', settingsKey = 'default',
source, source,
translatedLyrics, translatedLyrics,
@@ -42,6 +44,10 @@ export const UnsynchronizedLyrics = ({
return translatedLyrics ? translatedLyrics.split('\n') : []; return translatedLyrics ? translatedLyrics.split('\n') : [];
}, [translatedLyrics]); }, [translatedLyrics]);
const romajiLines = useMemo(() => {
return romajiLyrics ? romajiLyrics.split('\n') : [];
}, [romajiLyrics]);
return ( return (
<div className={styles.container} style={{ gap: `${settings.gapUnsync}px` }}> <div className={styles.container} style={{ gap: `${settings.gapUnsync}px` }}>
{settings.showProvider && source && ( {settings.showProvider && source && (
@@ -67,7 +73,9 @@ export const UnsynchronizedLyrics = ({
fontSize={settings.fontSizeUnsync} fontSize={settings.fontSizeUnsync}
id={`lyric-${idx}`} id={`lyric-${idx}`}
key={idx} key={idx}
text={text + (translatedLines[idx] ? `_BREAK_${translatedLines[idx]}` : '')} romajiText={romajiLines[idx]}
text={text}
translatedText={translatedLines[idx]}
/> />
))} ))}
</div> </div>
@@ -6,7 +6,7 @@ import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queu
import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls'; import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { useAppStoreActions } from '/@/renderer/store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
const NowPlayingRoute = () => { const NowPlayingRoute = () => {
@@ -15,12 +15,16 @@ const NowPlayingRoute = () => {
const tableRef = useRef<ItemListHandle | null>(null); const tableRef = useRef<ItemListHandle | null>(null);
useEffect(() => { useEffect(() => {
const wasExpanded = useAppStore.getState().sidebar.rightExpanded;
// On page enter, set rightExpanded to false // On page enter, set rightExpanded to false
setSideBar({ rightExpanded: false }); setSideBar({ rightExpanded: false });
return () => { return () => {
// On page exit, set rightExpanded to true if (wasExpanded) {
setSideBar({ rightExpanded: true }); // On page exit, set rightExpanded to true if it was previously expanded
setSideBar({ rightExpanded: true });
}
}; };
}, [setSideBar]); }, [setSideBar]);
@@ -68,9 +68,13 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
}; };
eventEmitter.on('MPV_RELOAD', handleMpvReload); eventEmitter.on('MPV_RELOAD', handleMpvReload);
// The main process notifies us after the OS resumes from sleep, since the
// stream mpv had open is likely on a now-dead connection.
mpvPlayerListener?.rendererMpvReconnect(handleMpvReload);
return () => { return () => {
eventEmitter.off('MPV_RELOAD', handleMpvReload); eventEmitter.off('MPV_RELOAD', handleMpvReload);
ipc?.removeAllListeners('renderer-mpv-reconnect');
}; };
}, []); }, []);
@@ -208,7 +212,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
if (playerStatus === PlayerStatus.PLAYING) { if (playerStatus === PlayerStatus.PLAYING) {
mpvPlayer.play(); mpvPlayer.play();
} else if (playerStatus === PlayerStatus.PAUSED) { } else {
mpvPlayer.pause(); mpvPlayer.pause();
} }
}, [playerStatus]); }, [playerStatus]);
@@ -47,6 +47,7 @@ interface PlayerEventsCallbacks {
) => void; ) => void;
onPlayerSpeed?: (properties: { speed: number }, prev: { speed: number }) => void; onPlayerSpeed?: (properties: { speed: number }, prev: { speed: number }) => void;
onPlayerStatus?: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void; onPlayerStatus?: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void;
onPlayerStop?: (properties: { id?: string; index?: number; reset: boolean }) => void;
onPlayerVolume?: (properties: { volume: number }, prev: { volume: number }) => void; onPlayerVolume?: (properties: { volume: number }, prev: { volume: number }) => void;
onQueueCleared?: () => void; onQueueCleared?: () => void;
onQueueRestored?: (properties: { data: Song[]; index: number; position: number }) => void; onQueueRestored?: (properties: { data: Song[]; index: number; position: number }) => void;
@@ -166,6 +167,10 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
eventEmitter.on('PLAYER_REPEATED', callbacks.onPlayerRepeated); eventEmitter.on('PLAYER_REPEATED', callbacks.onPlayerRepeated);
} }
if (callbacks.onPlayerStop) {
eventEmitter.on('PLAYER_STOP', callbacks.onPlayerStop);
}
if (callbacks.onQueueRestored) { if (callbacks.onQueueRestored) {
eventEmitter.on('QUEUE_RESTORED', callbacks.onQueueRestored); eventEmitter.on('QUEUE_RESTORED', callbacks.onQueueRestored);
} }
@@ -193,6 +198,9 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
if (callbacks.onPlayerRepeated) { if (callbacks.onPlayerRepeated) {
eventEmitter.off('PLAYER_REPEATED', callbacks.onPlayerRepeated); eventEmitter.off('PLAYER_REPEATED', callbacks.onPlayerRepeated);
} }
if (callbacks.onPlayerStop) {
eventEmitter.off('PLAYER_STOP', callbacks.onPlayerStop);
}
if (callbacks.onQueueRestored) { if (callbacks.onQueueRestored) {
eventEmitter.off('QUEUE_RESTORED', callbacks.onQueueRestored); eventEmitter.off('QUEUE_RESTORED', callbacks.onQueueRestored);
} }
@@ -69,12 +69,12 @@ export function MpvPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL); }, PLAY_PAUSE_FADE_INTERVAL);
}); });
if (status === PlayerStatus.PAUSED) { if (status === PlayerStatus.PLAYING) {
await promise;
setLocalPlayerStatus(status);
} else if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status); setLocalPlayerStatus(status);
await promise; await promise;
} else {
await promise;
setLocalPlayerStatus(status);
} }
}, },
[], [],
@@ -111,18 +111,18 @@ export function MpvPlayer() {
const status = properties.status; const status = properties.status;
const volume = usePlayerStore.getState().player.volume; const volume = usePlayerStore.getState().player.volume;
if (audioFadeOnStatusChange) { if (audioFadeOnStatusChange) {
if (status === PlayerStatus.PAUSED) { if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING); fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
} }
} else { } else {
if (status === PlayerStatus.PAUSED) { if (status === PlayerStatus.PLAYING) {
playerRef.current?.setVolume(0);
setLocalPlayerStatus(PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
playerRef.current?.setVolume(volume); playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PLAYING); setLocalPlayerStatus(PlayerStatus.PLAYING);
} else {
playerRef.current?.setVolume(0);
setLocalPlayerStatus(status);
} }
} }
}, },
@@ -60,12 +60,12 @@ export function WaveSurferPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL); }, PLAY_PAUSE_FADE_INTERVAL);
}); });
if (status === PlayerStatus.PAUSED) { if (status === PlayerStatus.PLAYING) {
await promise;
setLocalPlayerStatus(status);
} else if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status); setLocalPlayerStatus(status);
await promise; await promise;
} else {
await promise;
setLocalPlayerStatus(status);
} }
}, },
[isTransitioning], [isTransitioning],
@@ -190,10 +190,10 @@ export function WaveSurferPlayer() {
}, },
onPlayerStatus: async (properties) => { onPlayerStatus: async (properties) => {
const status = properties.status; const status = properties.status;
if (status === PlayerStatus.PAUSED) { if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING); fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
} }
}, },
onPlayerVolume: (properties) => { onPlayerVolume: (properties) => {
@@ -89,13 +89,13 @@ export function WebPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL); }, PLAY_PAUSE_FADE_INTERVAL);
}); });
if (status === PlayerStatus.PAUSED) { if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} else {
await promise; await promise;
setLocalPlayerStatus(status); setLocalPlayerStatus(status);
playerRef.current?.setVolume(startVolume); playerRef.current?.setVolume(startVolume);
} else if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} }
}, },
[], [],
@@ -241,7 +241,7 @@ export function WebPlayer() {
// If mediaAutoNext resulted in a paused state (e.g. end of queue, // If mediaAutoNext resulted in a paused state (e.g. end of queue,
// or pauseOnNextSongEnd flag), stop all audio instead of restoring volume. // or pauseOnNextSongEnd flag), stop all audio instead of restoring volume.
const currentStatus = usePlayerStoreBase.getState().player.status; const currentStatus = usePlayerStoreBase.getState().player.status;
if (currentStatus === PlayerStatus.PAUSED) { if (currentStatus !== PlayerStatus.PLAYING) {
playerRef.current?.pause(); playerRef.current?.pause();
} else { } else {
playerRef.current?.setVolume(volume); playerRef.current?.setVolume(volume);
@@ -260,7 +260,7 @@ export function WebPlayer() {
playerRef.current?.player2()?.ref?.getInternalPlayer().pause(); playerRef.current?.player2()?.ref?.getInternalPlayer().pause();
const currentStatus = usePlayerStoreBase.getState().player.status; const currentStatus = usePlayerStoreBase.getState().player.status;
if (currentStatus === PlayerStatus.PAUSED) { if (currentStatus !== PlayerStatus.PLAYING) {
playerRef.current?.pause(); playerRef.current?.pause();
} else { } else {
playerRef.current?.setVolume(volume); playerRef.current?.setVolume(volume);
@@ -313,9 +313,9 @@ export function WebPlayer() {
const status = properties.status; const status = properties.status;
// Reset crossfade transition if paused during a crossfade transition // Reset crossfade transition if paused/stopped during a crossfade transition
if ( if (
status === PlayerStatus.PAUSED && status !== PlayerStatus.PLAYING &&
isTransitioning && isTransitioning &&
transitionType === PlayerStyle.CROSSFADE transitionType === PlayerStyle.CROSSFADE
) { ) {
@@ -331,18 +331,18 @@ export function WebPlayer() {
} }
if (audioFadeOnStatusChange) { if (audioFadeOnStatusChange) {
if (status === PlayerStatus.PAUSED) { if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING); fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
} }
} else { } else {
if (status === PlayerStatus.PAUSED) { if (status === PlayerStatus.PLAYING) {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
playerRef.current?.setVolume(volume); playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PLAYING); setLocalPlayerStatus(PlayerStatus.PLAYING);
} else {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(status);
} }
} }
}, },
@@ -203,7 +203,7 @@ const CenterPlayButton = ({ disabled }: { disabled?: boolean }) => {
return ( return (
<MainPlayButton <MainPlayButton
disabled={disabled || currentSongId === undefined} disabled={disabled || currentSongId === undefined}
isPaused={status === PlayerStatus.PAUSED} isPaused={status !== PlayerStatus.PLAYING}
onClick={mediaTogglePlayPause} onClick={mediaTogglePlayPause}
/> />
); );
@@ -51,7 +51,7 @@ export const MobileFullscreenPlayerControls = memo(
/> />
<MainPlayButton <MainPlayButton
disabled={currentSongId === undefined} disabled={currentSongId === undefined}
isPaused={status === PlayerStatus.PAUSED} isPaused={status !== PlayerStatus.PLAYING}
onClick={mediaTogglePlayPause} onClick={mediaTogglePlayPause}
style={{ style={{
height: '50px', height: '50px',
@@ -213,7 +213,7 @@ export const MobilePlayerbar = () => {
/> />
<MainPlayButton <MainPlayButton
disabled={currentSong?.id === undefined} disabled={currentSong?.id === undefined}
isPaused={status === PlayerStatus.PAUSED} isPaused={status !== PlayerStatus.PLAYING}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
mediaTogglePlayPause(); mediaTogglePlayPause();
@@ -39,7 +39,12 @@ import {
import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types'; import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';
export interface PlayerContext { export interface PlayerContext {
addToQueueByData: (data: Song[], type: AddToQueueType, playSongId?: string) => void; addToQueueByData: (
data: Song[],
type: AddToQueueType,
playSongId?: string,
contextPlaylistId?: null | string,
) => void;
addToQueueByFetch: ( addToQueueByFetch: (
serverId: string, serverId: string,
id: string[], id: string[],
@@ -137,6 +142,23 @@ const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
} }
}; };
const isReplaceQueueType = (type: AddToQueueType): boolean => {
if (typeof type === 'object') return false;
return type === Play.NOW || type === Play.SHUFFLE;
};
// HashRouter puts the route in location.hash, not pathname.
const inferPlaylistContextFromUrl = (): null | string => {
const route = window.location.hash.replace(/^#/, '');
const match = route.match(/^\/playlists\/([^/]+)/);
return match ? match[1] : null;
};
// Stamps each song with the playlist it was queued from, so the sidebar highlight
// can be derived from whichever song is currently playing (see useCurrentPlaylistContextId).
const tagPlaylistContext = (songs: Song[], contextPlaylistId: string): Song[] =>
songs.map((song) => ({ ...song, _contextPlaylistId: contextPlaylistId }));
export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -187,9 +209,20 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}, [doNotShowAgain, setDoNotShowAgain, t]); }, [doNotShowAgain, setDoNotShowAgain, t]);
const addToQueueByData = useCallback( const addToQueueByData = useCallback(
(data: Song[], type: AddToQueueType, playSongId?: string) => { (
data: Song[],
type: AddToQueueType,
playSongId?: string,
contextPlaylistId?: null | string,
) => {
const filters = useSettingsStore.getState().playback.filters; const filters = useSettingsStore.getState().playback.filters;
const filteredData = filterSongsByPlayerFilters(data, filters); let filteredData = filterSongsByPlayerFilters(data, filters);
const resolvedContextId =
contextPlaylistId ??
(isReplaceQueueType(type) ? inferPlaylistContextFromUrl() : null);
if (resolvedContextId) {
filteredData = tagPlaylistContext(filteredData, resolvedContextId);
}
if (typeof type === 'object' && 'edge' in type && type.edge !== null) { if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom'; const edge = type.edge === 'top' ? 'top' : 'bottom';
@@ -279,7 +312,21 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
} }
const filters = useSettingsStore.getState().playback.filters; const filters = useSettingsStore.getState().playback.filters;
const filteredSongs = filterSongsByPlayerFilters(sortedSongs, filters); let filteredSongs = filterSongsByPlayerFilters(sortedSongs, filters);
// Songs from multiple playlists are merged together, so there is no single
// playlist to attribute them to: skip tagging (and URL inference) entirely.
const isMultiPlaylist = itemType === LibraryItem.PLAYLIST && id.length > 1;
const explicitId =
itemType === LibraryItem.PLAYLIST && id.length === 1 ? id[0] : null;
const resolvedContextId =
explicitId ??
(!isMultiPlaylist && isReplaceQueueType(type)
? inferPlaylistContextFromUrl()
: null);
if (resolvedContextId) {
filteredSongs = tagPlaylistContext(filteredSongs, resolvedContextId);
}
if (typeof type === 'object' && 'edge' in type && type.edge !== null) { if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom'; const edge = type.edge === 'top' ? 'top' : 'bottom';
@@ -131,6 +131,7 @@ export const useScrobble = () => {
const previousSongRef = useRef<QueueSong | undefined>(undefined); const previousSongRef = useRef<QueueSong | undefined>(undefined);
const previousTimestampRef = useRef<number>(0); const previousTimestampRef = useRef<number>(0);
const stopPositionRef = useRef<number>(0); const stopPositionRef = useRef<number>(0);
const stoppedSongIdRef = useRef<string | undefined>(undefined);
const lastProgressEventRef = useRef<number>(0); const lastProgressEventRef = useRef<number>(0);
const lastSeekEventRef = useRef<number>(0); const lastSeekEventRef = useRef<number>(0);
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
@@ -499,6 +500,12 @@ export const useScrobble = () => {
const currentStatus = usePlayerStore.getState().player.status; const currentStatus = usePlayerStore.getState().player.status;
// Stop resets seek position; the stop event is reported by handleScrobbleFromStatus.
if (currentStatus === PlayerStatus.STOPPED) {
flushScrobbleDebug();
return;
}
sendScrobble.mutate( sendScrobble.mutate(
{ {
apiClientProps: { serverId: currentSong._serverId || '' }, apiClientProps: { serverId: currentSong._serverId || '' },
@@ -608,6 +615,71 @@ export const useScrobble = () => {
); );
} }
// Send start event when resuming the same song that was stopped.
if (
properties.status === PlayerStatus.PLAYING &&
prev.status === PlayerStatus.STOPPED &&
stoppedSongIdRef.current === currentSong._uniqueId
) {
stoppedSongIdRef.current = undefined;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'start',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, {
category: LogCategory.SCROBBLE,
meta: {
id: currentSong.id,
},
});
},
},
);
}
// Send stop event when status changes to stopped (from an active state)
if (
properties.status === PlayerStatus.STOPPED &&
prev.status !== PlayerStatus.STOPPED
) {
stoppedSongIdRef.current = currentSong._uniqueId;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'stop',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStop, {
category: LogCategory.SCROBBLE,
meta: {
id: currentSong.id,
},
});
},
},
);
}
flushScrobbleDebug(); flushScrobbleDebug();
}, },
[isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate], [isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
@@ -231,11 +231,11 @@ export const AddToPlaylistContextModal = ({
const uniqueSongIds: string[] = []; const uniqueSongIds: string[] = [];
if (values.skipDuplicates) { if (values.skipDuplicates) {
const queryKey = queryKeys.playlists.songList(serverId, playlistId); const queryKey = queryKeys.playlists.songListIds(serverId, playlistId);
const playlistSongsRes = await queryClient.fetchQuery({ const playlistSongsRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => { queryFn: ({ signal }) => {
return api.controller.getPlaylistSongList({ return api.controller.getPlaylistSongIds({
apiClientProps: { apiClientProps: {
serverId, serverId,
signal, signal,
@@ -248,7 +248,7 @@ export const AddToPlaylistContextModal = ({
queryKey, queryKey,
}); });
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id); const playlistSongIds = playlistSongsRes?.items;
for (const songId of allSongIds) { for (const songId of allSongIds) {
if (!playlistSongIds?.includes(songId)) { if (!playlistSongIds?.includes(songId)) {
@@ -11,7 +11,10 @@ import { PlaylistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
export const usePlaylistListFilters = () => { export const usePlaylistListFilters = () => {
const sortByFilter = useSortByFilter<PlaylistListSort>(null, ItemListKey.PLAYLIST); const sortByFilter = useSortByFilter<PlaylistListSort>(
PlaylistListSort.NAME,
ItemListKey.PLAYLIST,
);
const sortOrderFilter = useSortOrderFilter(null, ItemListKey.PLAYLIST); const sortOrderFilter = useSortOrderFilter(null, ItemListKey.PLAYLIST);
const { searchTerm, setSearchTerm } = useSearchTermFilter(''); const { searchTerm, setSearchTerm } = useSearchTermFilter('');
@@ -107,6 +107,20 @@ export const LyricSettings = memo(() => {
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.enableFurigana'), title: t('setting.enableFurigana'),
}, },
{
control: (
<Switch
aria-label="Enable romaji generation"
defaultChecked={settings.enableRomaji}
onChange={(e) => updateSetting({ enableRomaji: e.currentTarget.checked })}
/>
),
description: t('setting.enableRomaji', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableRomaji'),
},
{ {
control: ( control: (
<Switch <Switch
@@ -63,6 +63,30 @@ export const WindowSettings = memo(() => {
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.windowBarStyle'), title: t('setting.windowBarStyle'),
}, },
{
control: (
<Switch
aria-label="Toggle track info in Window Bar"
defaultChecked={settings.windowBarTrackinfo}
onChange={(e) => {
if (!e) return;
setSettings({
window: {
windowBarTrackinfo: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.windowBarTrackinfo', {
context: 'description',
}),
// tab is hidden entirely right now
// but if it was shown we would want to show this option
// as it also controls the tab title in web
isHidden: false,
title: t('setting.windowBarTrackinfo'),
},
{ {
control: ( control: (
<Switch <Switch
@@ -823,18 +823,38 @@ const GENRE_LIST_FILTERS: Partial<
}, },
], ],
[ServerType.NAVIDROME]: [ [ServerType.NAVIDROME]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.ALBUM_COUNT,
},
{ {
defaultOrder: SortOrder.ASC, defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name'), name: i18n.t('filter.name'),
value: GenreListSort.NAME, value: GenreListSort.NAME,
}, },
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.songCount'),
value: GenreListSort.SONG_COUNT,
},
], ],
[ServerType.SUBSONIC]: [ [ServerType.SUBSONIC]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.ALBUM_COUNT,
},
{ {
defaultOrder: SortOrder.ASC, defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name'), name: i18n.t('filter.name'),
value: GenreListSort.NAME, value: GenreListSort.NAME,
}, },
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.SONG_COUNT,
},
], ],
}; };
@@ -29,6 +29,7 @@ import {
} from '/@/renderer/store'; } from '/@/renderer/store';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge'; import { Badge } from '/@/shared/components/badge/badge';
import { Button } from '/@/shared/components/button/button';
import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider'; import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
@@ -41,7 +42,7 @@ import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { useDebouncedState } from '/@/shared/hooks/use-debounced-state'; import { useDebouncedState } from '/@/shared/hooks/use-debounced-state';
import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
import { ItemListKey, ListPaginationType } from '/@/shared/types/types'; import { ItemListKey, ListPaginationType, TableColumn } from '/@/shared/types/types';
interface TableConfigProps { interface TableConfigProps {
enablePinColumnButtons?: boolean; enablePinColumnButtons?: boolean;
@@ -72,10 +73,18 @@ export const TableConfig = ({
const { t } = useTranslation(); const { t } = useTranslation();
const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings; const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings;
const { setList } = useSettingsStoreActions(); const albumGroupImageSize = useSettingsStore((state) => state.general.albumGroupImageSize);
const imageResTable = useSettingsStore((state) => state.general.imageRes.table);
const { setList, setSettings } = useSettingsStoreActions();
const [albumGroupOpen, setAlbumGroupOpen] = useState(false);
const table = tableKey === 'detail' ? (list?.detail ?? list?.table) : list?.table; const table = tableKey === 'detail' ? (list?.detail ?? list?.table) : list?.table;
const hasAlbumGroupColumn = useMemo(
() => table.columns.some((column) => column.id === TableColumn.ALBUM_GROUP),
[table.columns],
);
const setTableUpdate = useCallback( const setTableUpdate = useCallback(
(patch: Partial<DataTableProps>) => { (patch: Partial<DataTableProps>) => {
if (tableKey === 'detail') { if (tableKey === 'detail') {
@@ -90,6 +99,73 @@ export const TableConfig = ({
); );
const advancedSettings = useMemo(() => { const advancedSettings = useMemo(() => {
const albumGroupOptions =
hasAlbumGroupColumn && tableKey === 'main'
? [
{
component: (
<Group justify="flex-end" w="100%">
<Button
onClick={() => setAlbumGroupOpen((prev) => !prev)}
size="compact-md"
variant={albumGroupOpen ? 'subtle' : 'filled'}
>
{t(albumGroupOpen ? 'common.close' : 'common.edit')}
</Button>
</Group>
),
id: 'albumGroupConfig',
label: t('table.config.general.albumGroupConfig'),
},
...(albumGroupOpen
? [
{
component: (
<Group justify="flex-end" w="100%">
<NumberInput
max={2000}
min={0}
onChange={(value) => {
const size = Math.max(
0,
Math.min(
2000,
typeof value === 'number' ? value : 0,
),
);
setSettings({
general: {
albumGroupImageSize: size,
// Source table art must be at least as
// large as the displayed album image.
...(size >= imageResTable
? { imageRes: { table: size } }
: {}),
},
});
}}
rightSection={
<Text isMuted isNoSelect pr="lg" size="sm">
px
</Text>
}
value={albumGroupImageSize}
width={90}
/>
</Group>
),
id: 'albumImageSize',
label: (
<Text pl="md">
{t('table.config.general.albumImageSize')}
</Text>
),
},
]
: []),
]
: [];
const allOptions = [ const allOptions = [
{ {
component: ( component: (
@@ -238,6 +314,7 @@ export const TableConfig = ({
id: 'autoFitColumns', id: 'autoFitColumns',
label: t('table.config.general.autoFitColumns'), label: t('table.config.general.autoFitColumns'),
}, },
...albumGroupOptions,
...(extraOptions || []), ...(extraOptions || []),
]; ];
@@ -262,6 +339,11 @@ export const TableConfig = ({
listKey, listKey,
setTableUpdate, setTableUpdate,
optionsConfig, optionsConfig,
hasAlbumGroupColumn,
albumGroupOpen,
albumGroupImageSize,
imageResTable,
setSettings,
]); ]);
return ( return (
@@ -8,7 +8,7 @@ import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params
import { runInUrlTransition } from '/@/renderer/utils/url-transition'; import { runInUrlTransition } from '/@/renderer/utils/url-transition';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
export const useSortByFilter = <TSortBy>(defaultValue: null | string, listKey: ItemListKey) => { export const useSortByFilter = <TSortBy>(defaultValue: string, listKey: ItemListKey) => {
const server = useCurrentServer(); const server = useCurrentServer();
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey); const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -136,6 +136,10 @@
white-space: nowrap; white-space: nowrap;
} }
.name-active {
color: var(--theme-colors-primary);
}
.image-container { .image-container {
flex-shrink: 0; flex-shrink: 0;
width: 3rem; width: 3rem;
@@ -28,6 +28,7 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { useDragMonitor } from '/@/renderer/hooks/use-drag-monitor'; import { useDragMonitor } from '/@/renderer/hooks/use-drag-monitor';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { import {
useCurrentPlaylistContextId,
useCurrentServer, useCurrentServer,
useCurrentServerId, useCurrentServerId,
usePermissions, usePermissions,
@@ -116,6 +117,8 @@ export const PlaylistRowButton = memo(
const sidebarPlaylistSorting = useSidebarPlaylistSorting(); const sidebarPlaylistSorting = useSidebarPlaylistSorting();
const sidebarPlaylistMode = useSidebarPlaylistMode(); const sidebarPlaylistMode = useSidebarPlaylistMode();
const isCompact = sidebarPlaylistMode === 'compact'; const isCompact = sidebarPlaylistMode === 'compact';
const activePlaylistId = useCurrentPlaylistContextId();
const isActive = activePlaylistId === item.id;
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isSmartPlaylist = Boolean(item.rules); const isSmartPlaylist = Boolean(item.rules);
@@ -292,7 +295,13 @@ export const PlaylistRowButton = memo(
> >
{isCompact ? ( {isCompact ? (
<> <>
<Text className={styles.compactName} fw={500} size="md"> <Text
className={clsx(styles.compactName, {
[styles.nameActive]: isActive,
})}
fw={500}
size="md"
>
{name} {name}
</Text> </Text>
{isHovered && ( {isHovered && (
@@ -307,7 +316,13 @@ export const PlaylistRowButton = memo(
<div className={styles.rowGroup}> <div className={styles.rowGroup}>
<Image containerClassName={styles.imageContainer} src={imageUrl} /> <Image containerClassName={styles.imageContainer} src={imageUrl} />
<div className={styles.metadata}> <div className={styles.metadata}>
<Text className={styles.name} fw={500} size="md"> <Text
className={clsx(styles.name, {
[styles.nameActive]: isActive,
})}
fw={500}
size="md"
>
{name} {name}
</Text> </Text>
<div className={styles.metadataGroup}> <div className={styles.metadataGroup}>
+14 -1
View File
@@ -14,7 +14,13 @@ import macMin from './assets/min-mac.png';
import styles from './window-bar.module.css'; import styles from './window-bar.module.css';
import { useRadioPlayer } from '/@/renderer/features/radio/hooks/use-radio-player'; import { useRadioPlayer } from '/@/renderer/features/radio/hooks/use-radio-player';
import { useAppStore, usePlayerData, usePlayerStatus, useWindowSettings } from '/@/renderer/store'; import {
useAppStore,
usePlayerData,
usePlayerStatus,
useWindowBarTrackinfo,
useWindowSettings,
} from '/@/renderer/store';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { Platform, PlayerStatus } from '/@/shared/types/types'; import { Platform, PlayerStatus } from '/@/shared/types/types';
@@ -130,6 +136,8 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => {
export const WindowBar = () => { export const WindowBar = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { windowBarStyle } = useWindowSettings(); const { windowBarStyle } = useWindowSettings();
const windowBarTrackinfo = useWindowBarTrackinfo();
const playerStatus = usePlayerStatus(); const playerStatus = usePlayerStatus();
const privateMode = useAppStore((state) => state.privateMode); const privateMode = useAppStore((state) => state.privateMode);
const handleMinimize = () => minimize(); const handleMinimize = () => minimize();
@@ -153,6 +161,10 @@ export const WindowBar = () => {
const title = useMemo(() => { const title = useMemo(() => {
const privateModeString = privateMode ? t('page.windowBar.privateMode') : ''; const privateModeString = privateMode ? t('page.windowBar.privateMode') : '';
if (!windowBarTrackinfo) {
return `Feishin${privateMode ? ` ${privateModeString}` : ''}`;
}
// Show radio information if radio is active // Show radio information if radio is active
if (isRadioActive) { if (isRadioActive) {
const radioStatusString = !isRadioPlaying ? t('page.windowBar.paused') : ''; const radioStatusString = !isRadioPlaying ? t('page.windowBar.paused') : '';
@@ -194,6 +206,7 @@ export const WindowBar = () => {
queueLength, queueLength,
stationName, stationName,
t, t,
windowBarTrackinfo,
]); ]);
useEffect(() => { useEffect(() => {
+21 -1
View File
@@ -1235,12 +1235,26 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
mediaStop: (options?: { reset?: boolean }) => { mediaStop: (options?: { reset?: boolean }) => {
const reset = options?.reset !== false; const reset = options?.reset !== false;
set((state) => { set((state) => {
state.player.status = PlayerStatus.PAUSED; state.player.status = PlayerStatus.STOPPED;
setTimestampStore(0); setTimestampStore(0);
if (reset) { if (reset) {
state.player.seekToTimestamp = uniqueSeekToTimestamp(0); state.player.seekToTimestamp = uniqueSeekToTimestamp(0);
} }
}); });
const currentState = get();
const queue = currentState.getQueue();
const currentIndex = currentState.player.index;
const currentSong = queue.items[currentIndex];
eventEmitter.emit('PLAYER_STOP', {
id: currentSong?._uniqueId,
index:
currentIndex !== undefined && currentIndex >= 0
? currentIndex
: undefined,
reset,
});
}, },
mediaToggleMute: () => { mediaToggleMute: () => {
set((state) => { set((state) => {
@@ -1640,6 +1654,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const excludedPlayerKeys = ['playerNum', 'seekToTimestamp', 'status']; const excludedPlayerKeys = ['playerNum', 'seekToTimestamp', 'status'];
// If we're not restoring the play queue, we don't need the index property // If we're not restoring the play queue, we don't need the index property
// (it is meaningless without the queue)
if (!shouldRestorePlayQueue) { if (!shouldRestorePlayQueue) {
excludedPlayerKeys.push('index'); excludedPlayerKeys.push('index');
} }
@@ -2076,6 +2091,7 @@ export const updateQueueSong = (songId: string, updatedSong: Song) => {
const uniqueId = song._uniqueId; const uniqueId = song._uniqueId;
state.queue.songs[song._uniqueId] = { state.queue.songs[song._uniqueId] = {
...updatedSong, ...updatedSong,
_contextPlaylistId: song._contextPlaylistId,
_uniqueId: uniqueId, _uniqueId: uniqueId,
}; };
} }
@@ -2083,6 +2099,10 @@ export const updateQueueSong = (songId: string, updatedSong: Song) => {
}); });
}; };
export const useCurrentPlaylistContextId = () => {
return usePlayerStoreBase((state) => state.getCurrentSong()?._contextPlaylistId ?? null);
};
export const usePlayerMuted = () => { export const usePlayerMuted = () => {
return usePlayerStoreBase((state) => state.player.muted); return usePlayerStoreBase((state) => state.player.muted);
}; };
+12
View File
@@ -474,6 +474,7 @@ export const GeneralSettingsSchema = z.object({
), ),
albumBackground: z.boolean(), albumBackground: z.boolean(),
albumBackgroundBlur: z.number(), albumBackgroundBlur: z.number(),
albumGroupImageSize: z.number(),
artistBackground: z.boolean(), artistBackground: z.boolean(),
artistBackgroundBlur: z.number(), artistBackgroundBlur: z.number(),
artistItems: z.array(SortableItemSchema(ArtistItemSchema)), artistItems: z.array(SortableItemSchema(ArtistItemSchema)),
@@ -578,6 +579,7 @@ const LyricsSettingsSchema = z.object({
enableAutoTranslation: z.boolean(), enableAutoTranslation: z.boolean(),
enableFurigana: z.boolean().optional(), enableFurigana: z.boolean().optional(),
enableNeteaseTranslation: z.boolean(), enableNeteaseTranslation: z.boolean(),
enableRomaji: z.boolean().optional(),
fetch: z.boolean(), fetch: z.boolean(),
follow: z.boolean(), follow: z.boolean(),
preferLocalLyrics: z.boolean(), preferLocalLyrics: z.boolean(),
@@ -677,6 +679,7 @@ const WindowSettingsSchema = z.object({
startMinimized: z.boolean(), startMinimized: z.boolean(),
tray: z.boolean(), tray: z.boolean(),
windowBarStyle: z.nativeEnum(Platform), windowBarStyle: z.nativeEnum(Platform),
windowBarTrackinfo: z.boolean(),
}); });
const QueryValueInputTypeSchema = z.enum([ const QueryValueInputTypeSchema = z.enum([
@@ -1164,6 +1167,7 @@ const initialState: SettingsState = {
accent: 'rgb(53, 116, 252)', accent: 'rgb(53, 116, 252)',
albumBackground: false, albumBackground: false,
albumBackgroundBlur: 3, albumBackgroundBlur: 3,
albumGroupImageSize: 0,
artistBackground: true, artistBackground: true,
artistBackgroundBlur: 3, artistBackgroundBlur: 3,
artistItems, artistItems,
@@ -1848,6 +1852,7 @@ const initialState: SettingsState = {
enableAutoTranslation: false, enableAutoTranslation: false,
enableFurigana: false, enableFurigana: false,
enableNeteaseTranslation: false, enableNeteaseTranslation: false,
enableRomaji: false,
fetch: true, fetch: true,
follow: true, follow: true,
preferLocalLyrics: true, preferLocalLyrics: true,
@@ -2012,6 +2017,7 @@ const initialState: SettingsState = {
startMinimized: false, startMinimized: false,
tray: true, tray: true,
windowBarStyle: platformDefaultWindowBarStyle, windowBarStyle: platformDefaultWindowBarStyle,
windowBarTrackinfo: true,
}, },
}; };
@@ -2558,6 +2564,9 @@ export const useWindowSettings = () => useSettingsStore((state) => state.window,
export const useWindowBarStyle = () => export const useWindowBarStyle = () =>
useSettingsStore((state) => state.window.windowBarStyle, shallow); useSettingsStore((state) => state.window.windowBarStyle, shallow);
export const useWindowBarTrackinfo = () =>
useSettingsStore((state) => state.window.windowBarTrackinfo, shallow);
export const useHotkeySettings = () => useSettingsStore((state) => state.hotkeys, shallow); export const useHotkeySettings = () => useSettingsStore((state) => state.hotkeys, shallow);
export const useHotkeyBindings = () => useSettingsStore((state) => state.hotkeys.bindings, shallow); export const useHotkeyBindings = () => useSettingsStore((state) => state.hotkeys.bindings, shallow);
@@ -2638,6 +2647,9 @@ export const useSkipButtons = () => useSettingsStore((state) => state.general.sk
export const useImageRes = () => useSettingsStore((state) => state.general.imageRes, shallow); export const useImageRes = () => useSettingsStore((state) => state.general.imageRes, shallow);
export const useAlbumGroupImageSize = () =>
useSettingsStore((state) => state.general.albumGroupImageSize);
export const useVolumeWidth = () => useSettingsStore((state) => state.general.volumeWidth, shallow); export const useVolumeWidth = () => useSettingsStore((state) => state.general.volumeWidth, shallow);
export const useFollowCurrentSong = () => export const useFollowCurrentSong = () =>
+13 -13
View File
@@ -1,22 +1,22 @@
export function shuffle<T>(array: T[]): T[] { export function shuffle<T>(array: T[]): T[] {
// Create a copy of the array to avoid mutating the original return shuffleInPlace(array.slice());
const shuffled = [...array];
// Loop through the array from the last element to the first
for (let i = shuffled.length - 1; i > 0; i--) {
// Generate a random index from 0 to i
const j = Math.floor(Math.random() * (i + 1));
// Swap elements at positions i and j
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
} }
export function shuffleInPlace<T>(array: T[]): T[] { export function shuffleInPlace<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) { for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(cryptoRandom() * (i + 1));
[array[i], array[j]] = [array[j], array[i]]; [array[i], array[j]] = [array[j], array[i]];
} }
return array; return array;
} }
const randomBuffer = new Uint32Array(1);
/**
* Returns a cryptographically secure random float in [0, 1),
* matching the contract of Math.random().
*/
function cryptoRandom(): number {
crypto.getRandomValues(randomBuffer);
return randomBuffer[0] / 0x100000000;
}
+12 -77
View File
@@ -10,7 +10,6 @@ import {
LibraryItem, LibraryItem,
MusicFolder, MusicFolder,
Playlist, Playlist,
RelatedArtist,
Song, Song,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ServerListItem, ServerType } from '/@/shared/types/types'; import { ServerListItem, ServerType } from '/@/shared/types/types';
@@ -19,42 +18,6 @@ const TICKS_PER_MS = 10000;
type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>; type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>;
const KEYS_TO_OMIT = new Set(['AlbumArtist', 'Artist']);
const getPeople = (item: AlbumOrSong): null | Record<string, RelatedArtist[]> => {
if (item.People) {
const participants: Record<string, RelatedArtist[]> = {};
for (const person of item.People) {
const key = person.Type || '';
if (KEYS_TO_OMIT.has(key)) {
continue;
}
const item: RelatedArtist = {
// for other roles, we just want to display this and not filter.
// filtering (and links) would require a separate field, PersonIds
id: '',
imageId: null,
imageUrl: null,
name: person.Name,
userFavorite: false,
userRating: null,
};
if (key in participants) {
participants[key].push(item);
} else {
participants[key] = [item];
}
}
return participants;
}
return null;
};
const getTags = (item: AlbumOrSong): null | Record<string, string[]> => { const getTags = (item: AlbumOrSong): null | Record<string, string[]> => {
if (item.Tags) { if (item.Tags) {
const tags: Record<string, string[]> = {}; const tags: Record<string, string[]> = {};
@@ -106,39 +69,6 @@ const getPlaylistImageId = (item: z.infer<typeof jfType._response.playlist>): nu
return null; return null;
}; };
const getArtists = (
item: z.infer<typeof jfType._response.song>,
participants?: null | Record<string, RelatedArtist[]>,
): RelatedArtist[] => {
if (!item?.ArtistItems?.length && !item.AlbumArtists && !participants) {
return [];
}
const result: RelatedArtist[] = [];
(item?.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.forEach((entry) => {
result.push({
id: entry.Id,
imageId: null,
imageUrl: null,
name: entry.Name,
userFavorite: false,
userRating: null,
});
});
if (participants?.['Remixer']) {
const existingIds = new Set(result.map((artist) => artist.id));
for (const participant of participants['Remixer']) {
if (!existingIds.has(participant.id)) {
result.push(participant);
}
}
}
return result;
};
const jellyfinPremiereFields = (item: { const jellyfinPremiereFields = (item: {
PremiereDate?: string; PremiereDate?: string;
ProductionYear?: number; ProductionYear?: number;
@@ -189,10 +119,6 @@ const normalizeSong = (
console.warn('Jellyfin song retrieved with no media sources', item); console.warn('Jellyfin song retrieved with no media sources', item);
} }
const participants = getPeople(item);
const artists = getArtists(item, participants);
const { releaseDate, releaseYear } = jellyfinPremiereFields(item); const { releaseDate, releaseYear } = jellyfinPremiereFields(item);
return { return {
@@ -211,7 +137,16 @@ const normalizeSong = (
})), })),
albumId: item.AlbumId || `dummy/${item.Id}`, albumId: item.AlbumId || `dummy/${item.Id}`,
artistName: item?.ArtistItems?.map((entry) => entry.Name).join(', ') || '', artistName: item?.ArtistItems?.map((entry) => entry.Name).join(', ') || '',
artists, artists: (item?.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map(
(entry) => ({
id: entry.Id,
imageId: null,
imageUrl: null,
name: entry.Name,
userFavorite: false,
userRating: null,
}),
),
bitDepth, bitDepth,
bitRate, bitRate,
bpm: null, bpm: null,
@@ -253,7 +188,7 @@ const normalizeSong = (
mbzRecordingId: null, mbzRecordingId: null,
mbzTrackId: item.ProviderIds?.MusicBrainzTrack || null, mbzTrackId: item.ProviderIds?.MusicBrainzTrack || null,
name: item.Name, name: item.Name,
participants, participants: null,
path: path || '', path: path || '',
peak: null, peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0, playCount: (item.UserData && item.UserData.PlayCount) || 0,
@@ -328,7 +263,7 @@ const normalizeAlbum = (
name: item.Name, name: item.Name,
originalDate: releaseDate, originalDate: releaseDate,
originalYear, originalYear,
participants: getPeople(item), participants: null,
playCount: item.UserData?.PlayCount || 0, playCount: item.UserData?.PlayCount || 0,
recordLabels: item.Studios?.map((entry) => entry.Name) || [], recordLabels: item.Studios?.map((entry) => entry.Name) || [],
releaseDate, releaseDate,
@@ -27,7 +27,9 @@ export enum NDAlbumListSort {
} }
export enum NDGenreListSort { export enum NDGenreListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name', NAME = 'name',
SONG_COUNT = 'songCount',
} }
export enum NDPlaylistListSort { export enum NDPlaylistListSort {
@@ -754,6 +756,8 @@ const tag = z.object({
const tagList = z.array(tag); const tagList = z.array(tag);
export enum NDTagListSort { export enum NDTagListSort {
ALBUM_COUNT = 'albumCount',
SONG_COUNT = 'songCount',
TAG_VALUE = 'tagValue', TAG_VALUE = 'tagValue',
} }
+25 -2
View File
@@ -73,6 +73,7 @@ export interface QueueData {
} }
export type QueueSong = Song & { export type QueueSong = Song & {
_contextPlaylistId?: null | string;
_uniqueId: string; _uniqueId: string;
}; };
@@ -154,7 +155,9 @@ export enum ExternalType {
} }
export enum GenreListSort { export enum GenreListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name', NAME = 'name',
SONG_COUNT = 'songCount',
} }
export enum ImageType { export enum ImageType {
@@ -165,7 +168,9 @@ export enum ImageType {
} }
export enum TagListSort { export enum TagListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name', NAME = 'name',
SONG_COUNT = 'songCount',
} }
export type Album = { export type Album = {
@@ -429,19 +434,25 @@ type BaseEndpointArgs = {
type GenreListSortMap = { type GenreListSortMap = {
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>; jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
navidrome: Record<GenreListSort, NDGenreListSort | undefined>; navidrome: Record<GenreListSort, NDGenreListSort>;
subsonic: Record<UserListSort, undefined>; subsonic: Record<GenreListSort, undefined>;
}; };
export const genreListSortMap: GenreListSortMap = { export const genreListSortMap: GenreListSortMap = {
jellyfin: { jellyfin: {
albumCount: undefined,
name: JFGenreListSort.NAME, name: JFGenreListSort.NAME,
songCount: undefined,
}, },
navidrome: { navidrome: {
albumCount: NDGenreListSort.NAME,
name: NDGenreListSort.NAME, name: NDGenreListSort.NAME,
songCount: NDGenreListSort.NAME,
}, },
subsonic: { subsonic: {
albumCount: undefined,
name: undefined, name: undefined,
songCount: undefined,
}, },
}; };
@@ -453,13 +464,19 @@ type TagListSortMap = {
export const tagListSortMap: TagListSortMap = { export const tagListSortMap: TagListSortMap = {
jellyfin: { jellyfin: {
albumCount: undefined,
name: undefined, name: undefined,
songCount: undefined,
}, },
navidrome: { navidrome: {
albumCount: NDTagListSort.ALBUM_COUNT,
name: NDTagListSort.TAG_VALUE, name: NDTagListSort.TAG_VALUE,
songCount: NDTagListSort.SONG_COUNT,
}, },
subsonic: { subsonic: {
albumCount: undefined,
name: undefined, name: undefined,
songCount: undefined,
}, },
}; };
@@ -619,6 +636,8 @@ export type AlbumInfo = {
notes: null | string; notes: null | string;
}; };
export type SongIdListResponse = BasePaginatedResponse<string[]>;
export type SongListArgs = BaseEndpointArgs & { query: SongListQuery }; export type SongListArgs = BaseEndpointArgs & { query: SongListQuery };
export type SongListCountArgs = BaseEndpointArgs & { query: ListCountQuery<SongListQuery> }; export type SongListCountArgs = BaseEndpointArgs & { query: ListCountQuery<SongListQuery> };
@@ -1505,6 +1524,7 @@ export type ControllerEndpoint = {
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>; getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: PlaylistListCountArgs) => Promise<number>; getPlaylistListCount: (args: PlaylistListCountArgs) => Promise<number>;
getPlaylistSongIds: (args: PlaylistSongListArgs) => Promise<SongIdListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>; getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>; getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
@@ -1659,6 +1679,9 @@ export type InternalControllerEndpoint = {
args: ReplaceApiClientProps<PlaylistListArgs>, args: ReplaceApiClientProps<PlaylistListArgs>,
) => Promise<PlaylistListResponse>; ) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: ReplaceApiClientProps<PlaylistListCountArgs>) => Promise<number>; getPlaylistListCount: (args: ReplaceApiClientProps<PlaylistListCountArgs>) => Promise<number>;
getPlaylistSongIds: (
args: ReplaceApiClientProps<PlaylistSongListArgs>,
) => Promise<SongIdListResponse>;
getPlaylistSongList: ( getPlaylistSongList: (
args: ReplaceApiClientProps<PlaylistSongListArgs>, args: ReplaceApiClientProps<PlaylistSongListArgs>,
) => Promise<SongListResponse>; ) => Promise<SongListResponse>;
+1
View File
@@ -149,6 +149,7 @@ export enum PlayerShuffle {
export enum PlayerStatus { export enum PlayerStatus {
PAUSED = 'paused', PAUSED = 'paused',
PLAYING = 'playing', PLAYING = 'playing',
STOPPED = 'stopped',
} }
export enum PlayerStyle { export enum PlayerStyle {