Compare commits

..

19 Commits

Author SHA1 Message Date
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
63 changed files with 1248 additions and 274 deletions
+5 -1
View File
@@ -1009,7 +1009,11 @@
"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"
"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": {
"column": {
+39 -1
View File
@@ -457,7 +457,45 @@
"autoDJ_albumStrategy": "Režim výběru alb",
"autoDJ_songStrategy": "Režim výběru skladeb",
"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": {
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
+6
View File
@@ -845,6 +845,8 @@
"enableAutoTranslation": "Enable auto translation",
"enableFurigana_description": "Display pronunciation guides (furigana) over Japanese kanji lyrics.",
"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_descriptionWebAudio": "Parametric equalizer via Web Audio API",
"equalizer": "Equalizer",
@@ -1173,6 +1175,8 @@
"webAudio": "Use web audio",
"windowBarStyle_description": "Select the style of the window bar",
"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": "Zoom percentage",
"queryBuilder": "Query builder",
@@ -1214,6 +1218,8 @@
"config": {
"general": {
"advancedSettings": "Advanced settings",
"albumGroupConfig": "Album Group configuration",
"albumImageSize": "Album image size",
"autoFitColumns": "Auto fit columns",
"autosize": "Autosize",
"moveUp": "Move up",
+5 -1
View File
@@ -493,7 +493,11 @@
"compressorMakeupGain_description": "Ganancia de salida aplicada tras la compresión para recuperar el volumen.",
"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": "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": {
"editPlaylist": "Editar $t(entity.playlist, {\"count\": 1})",
+60 -21
View File
@@ -7,7 +7,10 @@
"viewPlaylists": "$t(entity.playlist, {\"count\": 2}) megtekintése",
"openIn": {
"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",
"createPlaylist": "$t(entity.playlist, {\"count\": 1}) létrehozása",
@@ -117,7 +120,7 @@
"none": "Egyik sem",
"restartRequired": "Újraindítás szükséges",
"setting_one": "Beállítás",
"setting_other": "",
"setting_other": "Beállítások",
"translation": "Fordítás",
"rating": "Értékelés",
"reload": "Újratöltés",
@@ -154,7 +157,14 @@
"view": "Nézet",
"noFilters": "Nincs konfigurált szűrő",
"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": {
"albumArtist_one": "Zenész",
@@ -207,8 +217,8 @@
"openError": "A fájl megnyitása sikertelen volt",
"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)",
"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",
"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",
"genericError": "Hiba történt",
"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",
@@ -226,7 +236,10 @@
"noNetworkDescription": "Nem tudok csatlakozni a szerverhez",
"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",
"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": {
"albumCount": "$t(entity.album, {\"count\": 2}) darab",
@@ -269,9 +282,11 @@
"trackNumber": "Sáv",
"artist": "$t(entity.artist, {\"count\": 1})",
"bpm": "Bpm",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel, {\"count\": 2})",
"genre": "$t(entity.genre, {\"count\": 1})",
"explicitStatus": "$t(common.explicitStatus)"
"explicitStatus": "$t(common.explicitStatus)",
"matchAnd": "és",
"matchOr": "vagy"
},
"form": {
"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",
"contextMenu": "Kontextusmenü (jobb klikk) beállítás",
"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_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",
@@ -852,7 +867,7 @@
"sidebarPlaylistList_description": "Lejátszási lista megjelenítése vagy elrejtése az oldalsávban",
"sidebarPlaylistList": "Oldalsáv lejátszási lista",
"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",
"sidePlayQueueStyle": "Oldalsó műsorlista stílus",
"skipDuration": "Átugrás hossza",
@@ -918,7 +933,7 @@
"autoDJ": "Auto DJ",
"autoDJ_timing": "Időzítés",
"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",
"followCurrentSong_description": "Automatikusan görgesse a műsorlistát az aktuálisan lejátszott dalra",
"followCurrentSong": "Kövesd az aktuális dalt",
@@ -959,13 +974,13 @@
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)",
"channels": "$t(common.channel, {\"count\": 2})",
"codec": "$t(common.codec)",
"dateAdded": "Hozzáadva",
"discNumber": "Lemezszám",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"actions": "$t(common.action_other)",
"actions": "$t(common.action, {\"count\": 2})",
"album": "$t(entity.album, {\"count\": 1})",
"albumCount": "$t(entity.album, {\"count\": 2})",
"genreBadge": "$t(entity.genre, {\"count\": 1}) (jelvények)",
@@ -1011,33 +1026,33 @@
}
},
"column": {
"albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"albumCount": "Albumok",
"artist": "Előadó",
"biography": "Életrajz",
"bitrate": "Bitráta",
"bpm": "BPM",
"channels": "$t(common.channel_other)",
"channels": "Csatornák",
"codec": "$t(common.codec)",
"comment": "Komment",
"dateAdded": "Hozzáadva",
"discNumber": "Lemez",
"favorite": "Kedvenc",
"genre": "$t(entity.genre, {\"count\": 1})",
"genre": "Műfaj",
"lastPlayed": "Utoljára játszott",
"path": "Elérési út",
"playCount": "Lejátszások",
"rating": "Értékelés",
"releaseDate": "Megjelenés",
"releaseYear": "Év",
"size": "$t(common.size)",
"songCount": "$t(entity.track, {\"count\": 2})",
"size": "Méret",
"songCount": "Sávok",
"title": "Cím",
"trackNumber": "Sáv",
"album": "Album",
"albumArtist": "Album előadó",
"owner": "Tulajdonos",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)"
"bitDepth": "Bitmélység",
"sampleRate": "Mintavételi frekvencia"
}
},
"queryBuilder": {
@@ -1070,5 +1085,29 @@
"secondShort": "Mp",
"hourShort": "Óra",
"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"
}
}
+5 -1
View File
@@ -1144,7 +1144,11 @@
"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"
"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": {
"config": {
+176 -1
View File
@@ -1 +1,176 @@
{}
{
"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": "สถานีวิทยุ"
}
}
+31 -9
View File
@@ -167,7 +167,7 @@
"version": "Версія",
"year": "Рік",
"yes": "Так",
"explicit": "Експліцитний зміст",
"explicit": "Відвертий зміст",
"gridRows": "Рядки сітки",
"tableColumns": "Стовпці таблиці",
"itemsMore": "{{count}} більше",
@@ -180,9 +180,9 @@
"album_one": "Альбом",
"album_few": "альбоми",
"album_many": "альбомів",
"albumArtist_one": "Виконавець альбому",
"albumArtist_few": "виконавці альбому",
"albumArtist_many": "виконавців альбому",
"albumArtist_one": "Виконавець Альбому",
"albumArtist_few": "Виконавці Альбому",
"albumArtist_many": "Виконавці Альбому",
"albumArtistCount_one": "{{count}} виконавець альбому",
"albumArtistCount_few": "{{count}} виконавці альбому",
"albumArtistCount_many": "{{count}} виконавців альбому",
@@ -190,8 +190,8 @@
"albumWithCount_few": "{{count}} альбоми",
"albumWithCount_many": "{{count}} альбомів",
"radioStation_one": "Радіостанція",
"radioStation_few": "радіостанції",
"radioStation_many": "радіостанцій",
"radioStation_few": "Радіостанції",
"radioStation_many": "Радіостанцій",
"radioStationWithCount_one": "{{count}} радіостанція",
"radioStationWithCount_few": "{{count}} радіостанції",
"radioStationWithCount_many": "{{count}} радіостанцій",
@@ -267,7 +267,8 @@
"systemFontError": "Сталася помилка під час спроби отримати системні шрифти",
"settingsSyncError": "Виявлено розбіжності між налаштуваннями в рендерері та основним процесом. перезапустіть програму, щоб застосувати зміни",
"invalidJson": "Недійсний JSON",
"playbackPausedDueToError": "Відтворення було призупинено через помилку"
"playbackPausedDueToError": "Відтворення було призупинено через помилку",
"serverLockSingleServer": "Коли сервер заблоковано можна використовувати тільки один сервер"
},
"filter": {
"album": "$t(entity.album, {\"count\": 1})",
@@ -683,7 +684,19 @@
"transcoding": "Транскодування",
"discord": "Діскорд",
"logger": "Логгер",
"playerFilters": "Фільтри плеєра"
"playerFilters": "Фільтри плеєра",
"advanced": "Розширені",
"analytics": "Аналітика",
"generalTab": "Загальні",
"hotkeysTab": "Гарячі клавіші",
"playbackTab": "Відтворення",
"windowTab": "Вікно",
"updates": "Оновлення",
"cache": "Кеш",
"application": "Застосунок",
"queryBuilder": "Конструктор черги",
"theme": "Тема",
"controls": "Керування"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
@@ -707,6 +720,13 @@
"artistTracks": "Треки {{artist}}",
"genreTracks": "\"{{genre}}\" $t(entity.track, {\"count\": 2})",
"title": "$t(entity.track, {\"count\": 2})"
},
"playlistList": {
"title": "$t(entity.playlist, {\"count\": 2})"
},
"collections": {
"overrideExisting": "Перевизначити існуючі",
"saveAsCollection": "Зберегти як колекцію"
}
},
"queryBuilder": {
@@ -759,6 +779,8 @@
"accentColor": "Акцентний колір",
"useThemeAccentColor": "Використовувати акцентний колір теми",
"useThemeAccentColor_description": "Використовувати основний колір визначений у обраній темі замість користувацького акцентного коліру",
"useThemePrimaryShade": "Використовувати основний відтінок теми"
"useThemePrimaryShade": "Використовувати основний відтінок теми",
"useThemePrimaryShade_description": "Використовувати основний відтінок, визначений у обраній темі, для основних варіантів кольорів",
"primaryShade": "Основний відтінок"
}
}
+8 -2
View File
@@ -864,7 +864,11 @@
"compressorReset_description": "將所有壓縮器參數恢復為預設值",
"compressorSavePreset_description": "將目前壓縮器設定儲存為具名預設",
"compressorThreshold_description": "開始進行壓縮的訊號電平。",
"compressorThreshold": "閥值"
"compressorThreshold": "閥值",
"enableRomaji_description": "在日文歌詞下方顯示羅馬拼音。",
"enableRomaji": "啟用羅馬拼音顯示",
"windowBarTrackinfo": "在視窗標題列顯示曲目資訊",
"windowBarTrackinfo_description": "在視窗標題列中顯示目前播放曲目的標題與藝人、播放佇列中的位置,以及播放/暫停狀態。"
},
"table": {
"config": {
@@ -898,7 +902,9 @@
"horizontalBorders": "行邊框線",
"rowHoverHighlight": "滑鼠懸停Highlight",
"verticalBorders": "列邊框線",
"showHeader": "顯示標題"
"showHeader": "顯示標題",
"albumGroupConfig": "專輯群組設定",
"albumImageSize": "專輯圖片大小"
},
"label": {
"actions": "$t(common.action, {\"count\": 2})",
+18 -1
View File
@@ -7,16 +7,19 @@ let kuroshiroInstance: any = null;
let initPromise: null | Promise<void> = null;
const getKuroshiro = async () => {
if (kuroshiroInstance) return kuroshiroInstance;
if (initPromise) {
await initPromise;
return kuroshiroInstance;
}
if (kuroshiroInstance) return kuroshiroInstance;
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
kuroshiroInstance = new KuroshiroClass();
initPromise = kuroshiroInstance.init(new KuromojiAnalyzer());
await initPromise;
initPromise = null;
return kuroshiroInstance;
};
@@ -35,3 +38,17 @@ export const convertFurigana = async (text: string): Promise<string> => {
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 { store } from '../settings';
import { convertFurigana } from './furigana';
import { convertFurigana, convertRomaji } from './furigana';
import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius';
import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib';
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) => {
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 { app, ipcMain } from 'electron';
import { app, ipcMain, powerMonitor } from 'electron';
import { access, rm } from 'fs/promises';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
@@ -85,6 +85,19 @@ const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
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;
};
@@ -191,21 +204,44 @@ export const getMpvInstance = () => {
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 mpv = instance || getMpvInstance();
if (mpv) {
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 {
// If quit() fails, try to kill the process directly
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);
}
}
killMpvProcess(mpv);
}
if (!isWindows()) {
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) => {
switch (mpvState) {
case MpvState.DONE:
+6 -1
View File
@@ -124,7 +124,12 @@ ipcMain.on('update-volume', (_event, volume) => {
});
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> = {
+5
View File
@@ -30,8 +30,13 @@ const convertFurigana = (text: string): Promise<string> => {
return ipcRenderer.invoke('lyric-convert-furigana', text);
};
const convertRomaji = (text: string): Promise<string> => {
return ipcRenderer.invoke('lyric-convert-romaji', text);
};
export const lyrics = {
convertFurigana,
convertRomaji,
getRemoteLyricsByRemoteId,
getRemoteLyricsBySong,
searchRemoteLyrics,
+5
View File
@@ -174,6 +174,10 @@ const rendererPlayerFallback = (cb: (data: boolean) => void) => {
ipcRenderer.on('renderer-player-fallback', (_, data) => cb(data));
};
const rendererMpvReconnect = (cb: () => void) => {
ipcRenderer.on('renderer-mpv-reconnect', () => cb());
};
export const mpvPlayer = {
autoNext,
cleanup,
@@ -205,6 +209,7 @@ export const mpvPlayerListener = {
rendererAutoNext,
rendererCurrentTime,
rendererError,
rendererMpvReconnect,
rendererNext,
rendererPause,
rendererPlay,
+1 -1
View File
@@ -128,7 +128,7 @@ export const RemoteContainer = () => {
onClick={() => {
if (status === PlayerStatus.PLAYING) {
send({ event: 'pause' });
} else if (status === PlayerStatus.PAUSED) {
} else {
send({ event: 'play' });
}
}}
+12
View File
@@ -546,6 +546,18 @@ export const controller: GeneralController = {
server.type,
)?.(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) {
const server = getServerById(args.apiClientProps.serverId);
@@ -197,8 +197,8 @@ const JF_FIELDS = {
'SortName',
'ProviderIds',
],
ALBUM_DETAIL: ['Genres', 'DateCreated', 'ChildCount', 'People', 'Tags', 'ProviderIds'],
ALBUM_LIST: ['People', 'Tags', 'Studios', 'SortName', 'ProviderIds', 'ChildCount'],
ALBUM_DETAIL: ['Genres', 'DateCreated', 'ChildCount', 'Tags', 'ProviderIds'],
ALBUM_LIST: ['Tags', 'Studios', 'SortName', 'ProviderIds', 'ChildCount'],
FOLDER: ['Genres', 'DateCreated', 'MediaSources', 'ParentId'],
GENRE: ['ItemCounts'],
PLAYLIST_DETAIL: [
@@ -210,16 +210,7 @@ const JF_FIELDS = {
'SortName',
],
PLAYLIST_LIST: ['ChildCount', 'Genres', 'DateCreated', 'ParentId', 'Overview'],
SONG: [
'Genres',
'DateCreated',
'MediaSources',
'ParentId',
'People',
'Tags',
'SortName',
'ProviderIds',
],
SONG: ['Genres', 'DateCreated', 'MediaSources', 'ParentId', 'Tags', 'SortName', 'ProviderIds'],
} as const;
export const JellyfinController: InternalControllerEndpoint = {
@@ -1056,6 +1047,35 @@ export const JellyfinController: InternalControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).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) => {
const { apiClientProps, query } = args;
@@ -1068,7 +1088,7 @@ export const JellyfinController: InternalControllerEndpoint = {
id: query.id,
},
query: {
Fields: JF_FIELDS.SONG,
Fields: JF_FIELDS.PLAYLIST_DETAIL,
IncludeItemTypes: 'Audio',
UserId: apiClientProps.server?.userId,
},
@@ -19,7 +19,6 @@ import {
DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs,
DeletePlaylistImageResponse,
genreListSortMap,
InternalControllerEndpoint,
playlistListSortMap,
PlaylistSongListArgs,
@@ -596,26 +595,7 @@ export const NavidromeController: InternalControllerEndpoint = {
};
}
const res = await ndApiClient(apiClientProps).getGenreList({
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),
};
return SubsonicController.getGenreList(args);
},
getImageRequest: SubsonicController.getImageRequest,
getImageUrl: SubsonicController.getImageUrl,
@@ -683,6 +663,11 @@ export const NavidromeController: InternalControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).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> => {
const { apiClientProps, query } = args;
+3
View File
@@ -338,6 +338,9 @@ export const queryKeys: Record<
return [serverId, 'playlists', 'songList'] as const;
},
songListIds: (serverId: string, id: string) => {
return [serverId, 'playlists', 'songListIds', id] as const;
},
},
radio: {
list: (serverId: string) => [serverId, 'radio', 'list'] as const,
@@ -1090,9 +1090,15 @@ export const SubsonicController: InternalControllerEndpoint = {
}
switch (query.sortBy) {
case GenreListSort.ALBUM_COUNT:
results = orderBy(results, [(v) => v.albumCount], [sortOrder]);
break;
case GenreListSort.NAME:
results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]);
break;
case GenreListSort.SONG_COUNT:
results = orderBy(results, [(v) => v.songCount], [sortOrder]);
break;
default:
break;
}
@@ -1223,6 +1229,11 @@ export const SubsonicController: InternalControllerEndpoint = {
return results.length;
},
getPlaylistSongIds: async (args) =>
SubsonicController.getPlaylistSongList(args).then((result) => ({
...result,
items: result.items.map((song) => song.id),
})),
getPlaylistSongList: async ({ apiClientProps, query }) => {
const res = await ssApiClient(apiClientProps).getPlaylist({
query: {
@@ -10,7 +10,7 @@ import {
LONG_PRESS_PLAY_BEHAVIOR,
PlayTooltip,
} 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 { Play } from '/@/shared/types/types';
@@ -29,12 +29,33 @@ export const AlbumGroupHeader = ({
}: AlbumGroupHeaderProps): ReactElement => {
const [isHovered, setIsHovered] = useState(false);
const playButtonBehavior = usePlayButtonBehavior();
const albumImageSize = useAlbumGroupImageSize();
const rowHeight = {
compact: TableItemSize.COMPACT,
large: TableItemSize.LARGE,
normal: TableItemSize.DEFAULT,
}[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 (
<div className={styles.container}>
@@ -42,6 +63,7 @@ export const AlbumGroupHeader = ({
className={styles.imageContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={imageContainerStyle}
>
<ItemImage
className={imageColumnStyles.compactImage}
@@ -64,6 +64,12 @@ export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => {
...(needsBorder
? { 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 }
: {}),
}}
/>
);
@@ -31,6 +31,11 @@
padding-left: 0;
}
.container.no-vertical-padding {
padding-top: 0;
padding-bottom: 0;
}
.container.center {
align-items: 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 { 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 { 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 { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
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];
// 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 {
return columns.some((col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled);
}
@@ -402,6 +435,106 @@ export function isLastInAlbumGroup(
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 = (
props: ItemTableListColumn & {
children: React.ReactNode;
@@ -425,6 +558,7 @@ export const TableColumnTextContainer = (
? props.internalState.extractRowId(item)
: undefined;
const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined);
const clampHeight = getAlbumGroupClampHeight(props);
const isDragging = props.isDragging ?? false;
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
className={clsx(styles.container, props.containerClassName, {
[styles.alternateRowEven]:
@@ -529,25 +666,16 @@ export const TableColumnTextContainer = (
[styles.right]: props.columns[props.columnIndex].align === 'end',
[styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight,
[styles.rowSelected]: isDataRow && isSelected,
[styles.withHorizontalBorder]:
props.enableHorizontalBorders &&
props.enableHeader &&
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,
// When clamped, the bottom border is drawn on the spacer below
// instead.
[styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null,
[styles.withVerticalBorder]: showVerticalBorder,
})}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleClick}
onContextMenu={handleContextMenu}
ref={mergedRef}
style={props.style}
style={clampHeight !== null ? { height: clampHeight } : props.style}
>
<Text
className={clsx(styles.content, props.className, {
@@ -561,6 +689,16 @@ export const TableColumnTextContainer = (
</Text>
</div>
);
return (
<ClampedCell
cell={cell}
clampHeight={clampHeight}
outerStyle={props.style}
showHorizontalBorder={showHorizontalBorder}
showVerticalBorder={showVerticalBorder}
/>
);
};
export const TableColumnContainer = (
@@ -586,6 +724,7 @@ export const TableColumnContainer = (
? props.internalState.extractRowId(item)
: undefined;
const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined);
const clampHeight = getAlbumGroupClampHeight(props);
const isDragging = props.isDragging ?? false;
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
className={clsx(styles.container, props.className, {
[styles.alternateRowEven]:
@@ -682,6 +824,8 @@ export const TableColumnContainer = (
[styles.large]: props.size === 'large',
[styles.left]: props.columns[props.columnIndex].align === 'start',
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
[styles.noVerticalPadding]:
props.type === TableColumn.ALBUM_GROUP && (props.albumGroupImageSize ?? 0) > 0,
[styles.paddingLg]: props.cellPadding === 'lg',
[styles.paddingMd]: props.cellPadding === 'md',
[styles.paddingSm]: props.cellPadding === 'sm',
@@ -694,29 +838,33 @@ export const TableColumnContainer = (
props.type !== TableColumn.ALBUM_GROUP,
[styles.rowSelected]:
isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP,
[styles.withHorizontalBorder]:
props.enableHorizontalBorders &&
props.enableHeader &&
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,
// When clamped, the bottom border is drawn on the spacer below instead.
[styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null,
[styles.withVerticalBorder]: showVerticalBorder,
})}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleClick}
onContextMenu={handleContextMenu}
ref={mergedRef}
style={{ ...props.containerStyle, ...props.style }}
style={
clampHeight !== null
? { ...props.containerStyle, height: clampHeight }
: { ...props.containerStyle, ...props.style }
}
>
{props.children}
</div>
);
return (
<ClampedCell
cell={cell}
clampHeight={clampHeight}
outerStyle={props.style}
showHorizontalBorder={showHorizontalBorder}
showVerticalBorder={showVerticalBorder}
/>
);
};
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 { 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 { 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 {
ItemTableListColumnResizeLiveProvider,
type ItemTableListConfig,
@@ -66,7 +70,7 @@ import {
ItemTableListColumnConfig,
} from '/@/renderer/components/item-list/types';
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 { useFocusWithin } from '/@/shared/hooks/use-focus-within';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
@@ -215,6 +219,7 @@ const VirtualizedTableGrid = ({
totalRowCount,
}: VirtualizedTableGridProps) => {
const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig;
const albumGroupImageSize = useAlbumGroupImageSize();
const hoverDelegateRef = useRef<HTMLDivElement | null>(null);
useRowInteractionDelegate({
@@ -403,6 +408,7 @@ const VirtualizedTableGrid = ({
const itemProps: TableItemProps = useMemo(
() => ({
albumGroupImageSize,
cellPadding: tableConfig.cellPadding,
columns: tableConfig.columns,
controls: tableConfig.controls,
@@ -427,7 +433,7 @@ const VirtualizedTableGrid = ({
tableId: tableConfig.tableId,
...gridOnlyProps,
}),
[gridOnlyProps, tableConfig],
[albumGroupImageSize, gridOnlyProps, tableConfig],
);
const pinnedLeftGridMinWidthPx = useMemo(() => {
@@ -760,6 +766,7 @@ export interface TableGroupHeader {
export interface TableItemProps {
adjustedRowIndexMap?: Map<number, number>;
albumGroupImageSize?: number;
calculatedColumnWidths?: number[];
cellPadding?: ItemTableListProps['cellPadding'];
columns: ItemTableListColumnConfig[];
@@ -1275,6 +1282,7 @@ const BaseItemTableList = ({
}: ItemTableListProps) => {
const { playlistId: routePlaylistId } = useParams() as { playlistId?: string };
const tableId = useId();
const albumGroupImageSize = useAlbumGroupImageSize();
const baseItemCount = itemCount ?? data.length;
const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount;
const [centerContainerWidth, setCenterContainerWidth] = useState(0);
@@ -1434,9 +1442,38 @@ const BaseItemTableList = ({
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;
},
[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)
+7
View File
@@ -13,6 +13,7 @@ export type EventMap = {
MPV_RELOAD: MpvReloadEventPayload;
PLAYER_PLAY: PlayerPlayEventPayload;
PLAYER_REPEATED: PlayerRepeatedEventPayload;
PLAYER_STOP: PlayerStopEventPayload;
PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;
@@ -54,6 +55,12 @@ export type PlayerRepeatedEventPayload = {
index: number;
};
export type PlayerStopEventPayload = {
id?: string;
index?: number;
reset: boolean;
};
export type PlaylistMoveEventPayload = {
playlistId: 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 { setMultipleSearchParams } from '/@/renderer/utils/query-params';
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';
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);
@@ -7,7 +7,7 @@ import { ArtistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useArtistListFilters = () => {
const { sortBy } = useSortByFilter<ArtistListSort>(null, ItemListKey.ARTIST);
const { sortBy } = useSortByFilter<ArtistListSort>(ArtistListSort.NAME, ItemListKey.ARTIST);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.ARTIST);
@@ -211,11 +211,11 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
let songsToAdd: string[] = allSongIds;
if (skipDuplicates) {
const queryKey = queryKeys.playlists.songList(serverId, playlistId);
const queryKey = queryKeys.playlists.songListIds(serverId, playlistId);
const playlistSongsRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => {
return api.controller.getPlaylistSongList({
return api.controller.getPlaylistSongIds({
apiClientProps: {
serverId,
signal,
@@ -228,7 +228,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
queryKey,
});
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
const playlistSongIds = playlistSongsRes?.items;
const uniqueSongIds: string[] = [];
for (const songId of allSongIds) {
@@ -81,6 +81,15 @@ export const useDiscordRpc = () => {
privateModeRef.current = privateMode;
}, [privateMode]);
// If the component is unmounted while RPC is enabled, quit RPC
useEffect(() => {
return () => {
if (previousEnabledRef.current) {
discordRpc?.quit();
}
};
}, []);
const setActivity = useCallback(
async (current: ActivityState, trigger: ActivityTrigger) => {
const song = current[0];
@@ -6,7 +6,7 @@ import { GenreListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useGenreListFilters = () => {
const { sortBy } = useSortByFilter<GenreListSort>(null, ItemListKey.GENRE);
const { sortBy } = useSortByFilter<GenreListSort>(GenreListSort.NAME, ItemListKey.GENRE);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.GENRE);
@@ -307,6 +307,20 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
isHidden: !isElectron(),
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: (
<Switch
@@ -14,13 +14,13 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
if (typeof lyrics === 'string') {
return await lyricsApi.convertFurigana(lyrics);
} else if (Array.isArray(lyrics)) {
const text = lyrics.map(([, line]) => line).join('\n');
const converted = await lyricsApi.convertFurigana(text);
const convertedLines = converted.split('\n');
return lyrics.map(([time], i) => [
time,
convertedLines[i] ?? lyrics[i][1],
]) as SynchronizedLyricsArray;
const converted = await Promise.all(
lyrics.map(async ([time, line]) => [
time,
await lyricsApi.convertFurigana(line),
]),
);
return converted as SynchronizedLyricsArray;
}
return lyrics;
},
@@ -28,3 +28,24 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena
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) {
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'> {
alignment: 'center' | 'left' | 'right';
fontSize: number;
romajiText?: null | string;
text: string;
translatedText?: null | string;
}
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 style = useMemo(
@@ -31,6 +41,15 @@ export const LyricLine = memo(
{lines.map((line, index) => (
<span dangerouslySetInnerHTML={{ __html: sanitize(line) }} key={index} />
))}
{romajiText && (
<span
className={styles.romajiLine}
dangerouslySetInnerHTML={{ __html: sanitize(romajiText) }}
/>
)}
{translatedText && (
<span dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }} />
)}
</Stack>
</Box>
);
+16 -1
View File
@@ -14,7 +14,10 @@ import {
type LyricsQueryResult,
} from '/@/renderer/features/lyrics/api/lyrics-api';
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 {
SynchronizedLyrics,
@@ -51,6 +54,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const {
enableAutoTranslation,
enableFurigana,
enableRomaji,
preferLocalLyrics,
translationApiKey,
translationApiProvider,
@@ -119,6 +123,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
}, [data, indexToUse, preferLocalLyrics]);
const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana);
const { data: romajiConvertedLyrics } = useRomajiLyrics(lyrics?.lyrics, !!enableRomaji);
const displayLyrics = useMemo(() => {
if (isLyricsDisabled || !lyrics) return null;
@@ -344,12 +349,22 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
<SynchronizedLyrics
{...(displayLyrics as SynchronizedLyricsProps)}
offsetMs={displayOffsetMs}
romajiLyrics={
enableRomaji
? (romajiConvertedLyrics as SynchronizedLyricsProps['romajiLyrics'])
: null
}
settingsKey={settingsKey}
translatedLyrics={showTranslation ? translatedLyrics : null}
/>
) : (
<UnsynchronizedLyrics
{...(displayLyrics as UnsynchronizedLyricsProps)}
romajiLyrics={
enableRomaji
? (romajiConvertedLyrics as UnsynchronizedLyricsProps['romajiLyrics'])
: null
}
settingsKey={settingsKey}
translatedLyrics={showTranslation ? translatedLyrics : null}
/>
@@ -23,6 +23,7 @@ const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: SynchronizedLyricsArray;
offsetMs?: number;
romajiLyrics?: null | SynchronizedLyricsArray;
settingsKey?: string;
style?: React.CSSProperties;
translatedLyrics?: null | string;
@@ -34,6 +35,7 @@ export const SynchronizedLyrics = ({
name,
offsetMs,
remote,
romajiLyrics,
settingsKey = 'default',
source,
style,
@@ -93,6 +95,7 @@ export const SynchronizedLyrics = ({
const scrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const programmaticScrollRef = useRef(false);
const programmaticScrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const getCurrentLyric = (timeInMs: number) => {
const activeLyrics = lyricRef.current;
@@ -176,9 +179,6 @@ export const SynchronizedLyrics = ({
if (followRef.current && !userScrollingRef.current) {
programmaticScrollRef.current = true;
doc?.scroll({ behavior: 'smooth', top: offsetTop });
setTimeout(() => {
programmaticScrollRef.current = false;
}, 600);
}
if (index !== lyricRef.current!.length - 1) {
@@ -285,6 +285,14 @@ export const SynchronizedLyrics = ({
const handleScroll = () => {
// Ignore programmatic scrolls (auto-scroll)
if (programmaticScrollRef.current) {
if (programmaticScrollTimeoutRef.current) {
clearTimeout(programmaticScrollTimeoutRef.current);
}
programmaticScrollTimeoutRef.current = setTimeout(() => {
programmaticScrollRef.current = false;
}, 150);
return;
}
@@ -307,6 +315,10 @@ export const SynchronizedLyrics = ({
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
if (programmaticScrollTimeoutRef.current) {
clearTimeout(programmaticScrollTimeoutRef.current);
}
};
}, []);
@@ -368,10 +380,9 @@ export const SynchronizedLyrics = ({
handleSeek(time / 1000);
}
}}
text={
text +
(translatedLyrics ? `_BREAK_${translatedLyrics.split('\n')[idx]}` : '')
}
romajiText={romajiLyrics?.[idx]?.[1]}
text={text}
translatedText={translatedLyrics?.split('\n')[idx]}
/>
))}
</div>
@@ -8,6 +8,7 @@ import { FullLyricsMetadata } from '/@/shared/types/domain-types';
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string;
romajiLyrics?: null | string;
settingsKey?: string;
translatedLyrics?: null | string;
}
@@ -17,6 +18,7 @@ export const UnsynchronizedLyrics = ({
lyrics,
name,
remote,
romajiLyrics,
settingsKey = 'default',
source,
translatedLyrics,
@@ -42,6 +44,10 @@ export const UnsynchronizedLyrics = ({
return translatedLyrics ? translatedLyrics.split('\n') : [];
}, [translatedLyrics]);
const romajiLines = useMemo(() => {
return romajiLyrics ? romajiLyrics.split('\n') : [];
}, [romajiLyrics]);
return (
<div className={styles.container} style={{ gap: `${settings.gapUnsync}px` }}>
{settings.showProvider && source && (
@@ -67,7 +73,9 @@ export const UnsynchronizedLyrics = ({
fontSize={settings.fontSizeUnsync}
id={`lyric-${idx}`}
key={idx}
text={text + (translatedLines[idx] ? `_BREAK_${translatedLines[idx]}` : '')}
romajiText={romajiLines[idx]}
text={text}
translatedText={translatedLines[idx]}
/>
))}
</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 { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
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';
const NowPlayingRoute = () => {
@@ -15,12 +15,16 @@ const NowPlayingRoute = () => {
const tableRef = useRef<ItemListHandle | null>(null);
useEffect(() => {
const wasExpanded = useAppStore.getState().sidebar.rightExpanded;
// On page enter, set rightExpanded to false
setSideBar({ rightExpanded: false });
return () => {
// On page exit, set rightExpanded to true
setSideBar({ rightExpanded: true });
if (wasExpanded) {
// On page exit, set rightExpanded to true if it was previously expanded
setSideBar({ rightExpanded: true });
}
};
}, [setSideBar]);
@@ -68,9 +68,13 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
};
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 () => {
eventEmitter.off('MPV_RELOAD', handleMpvReload);
ipc?.removeAllListeners('renderer-mpv-reconnect');
};
}, []);
@@ -208,7 +212,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
if (playerStatus === PlayerStatus.PLAYING) {
mpvPlayer.play();
} else if (playerStatus === PlayerStatus.PAUSED) {
} else {
mpvPlayer.pause();
}
}, [playerStatus]);
@@ -47,6 +47,7 @@ interface PlayerEventsCallbacks {
) => void;
onPlayerSpeed?: (properties: { speed: number }, prev: { speed: number }) => 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;
onQueueCleared?: () => 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);
}
if (callbacks.onPlayerStop) {
eventEmitter.on('PLAYER_STOP', callbacks.onPlayerStop);
}
if (callbacks.onQueueRestored) {
eventEmitter.on('QUEUE_RESTORED', callbacks.onQueueRestored);
}
@@ -193,6 +198,9 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
if (callbacks.onPlayerRepeated) {
eventEmitter.off('PLAYER_REPEATED', callbacks.onPlayerRepeated);
}
if (callbacks.onPlayerStop) {
eventEmitter.off('PLAYER_STOP', callbacks.onPlayerStop);
}
if (callbacks.onQueueRestored) {
eventEmitter.off('QUEUE_RESTORED', callbacks.onQueueRestored);
}
@@ -69,12 +69,12 @@ export function MpvPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
await promise;
setLocalPlayerStatus(status);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} else {
await promise;
setLocalPlayerStatus(status);
}
},
[],
@@ -111,18 +111,18 @@ export function MpvPlayer() {
const status = properties.status;
const volume = usePlayerStore.getState().player.volume;
if (audioFadeOnStatusChange) {
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
}
} else {
if (status === PlayerStatus.PAUSED) {
playerRef.current?.setVolume(0);
setLocalPlayerStatus(PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PLAYING);
} else {
playerRef.current?.setVolume(0);
setLocalPlayerStatus(status);
}
}
},
@@ -60,12 +60,12 @@ export function WaveSurferPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
await promise;
setLocalPlayerStatus(status);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} else {
await promise;
setLocalPlayerStatus(status);
}
},
[isTransitioning],
@@ -190,10 +190,10 @@ export function WaveSurferPlayer() {
},
onPlayerStatus: async (properties) => {
const status = properties.status;
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
}
},
onPlayerVolume: (properties) => {
@@ -89,13 +89,13 @@ export function WebPlayer() {
}, PLAY_PAUSE_FADE_INTERVAL);
});
if (status === PlayerStatus.PAUSED) {
if (status === PlayerStatus.PLAYING) {
setLocalPlayerStatus(status);
await promise;
} else {
await promise;
setLocalPlayerStatus(status);
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,
// or pauseOnNextSongEnd flag), stop all audio instead of restoring volume.
const currentStatus = usePlayerStoreBase.getState().player.status;
if (currentStatus === PlayerStatus.PAUSED) {
if (currentStatus !== PlayerStatus.PLAYING) {
playerRef.current?.pause();
} else {
playerRef.current?.setVolume(volume);
@@ -260,7 +260,7 @@ export function WebPlayer() {
playerRef.current?.player2()?.ref?.getInternalPlayer().pause();
const currentStatus = usePlayerStoreBase.getState().player.status;
if (currentStatus === PlayerStatus.PAUSED) {
if (currentStatus !== PlayerStatus.PLAYING) {
playerRef.current?.pause();
} else {
playerRef.current?.setVolume(volume);
@@ -313,9 +313,9 @@ export function WebPlayer() {
const status = properties.status;
// Reset crossfade transition if paused during a crossfade transition
// Reset crossfade transition if paused/stopped during a crossfade transition
if (
status === PlayerStatus.PAUSED &&
status !== PlayerStatus.PLAYING &&
isTransitioning &&
transitionType === PlayerStyle.CROSSFADE
) {
@@ -331,18 +331,18 @@ export function WebPlayer() {
}
if (audioFadeOnStatusChange) {
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
} else {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, status);
}
} else {
if (status === PlayerStatus.PAUSED) {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
if (status === PlayerStatus.PLAYING) {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(PlayerStatus.PLAYING);
} else {
playerRef.current?.setVolume(volume);
setLocalPlayerStatus(status);
}
}
},
@@ -203,7 +203,7 @@ const CenterPlayButton = ({ disabled }: { disabled?: boolean }) => {
return (
<MainPlayButton
disabled={disabled || currentSongId === undefined}
isPaused={status === PlayerStatus.PAUSED}
isPaused={status !== PlayerStatus.PLAYING}
onClick={mediaTogglePlayPause}
/>
);
@@ -51,7 +51,7 @@ export const MobileFullscreenPlayerControls = memo(
/>
<MainPlayButton
disabled={currentSongId === undefined}
isPaused={status === PlayerStatus.PAUSED}
isPaused={status !== PlayerStatus.PLAYING}
onClick={mediaTogglePlayPause}
style={{
height: '50px',
@@ -213,7 +213,7 @@ export const MobilePlayerbar = () => {
/>
<MainPlayButton
disabled={currentSong?.id === undefined}
isPaused={status === PlayerStatus.PAUSED}
isPaused={status !== PlayerStatus.PLAYING}
onClick={(e) => {
e.stopPropagation();
mediaTogglePlayPause();
@@ -131,6 +131,7 @@ export const useScrobble = () => {
const previousSongRef = useRef<QueueSong | undefined>(undefined);
const previousTimestampRef = useRef<number>(0);
const stopPositionRef = useRef<number>(0);
const stoppedSongIdRef = useRef<string | undefined>(undefined);
const lastProgressEventRef = useRef<number>(0);
const lastSeekEventRef = useRef<number>(0);
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
@@ -499,6 +500,12 @@ export const useScrobble = () => {
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(
{
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();
},
[isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
@@ -231,11 +231,11 @@ export const AddToPlaylistContextModal = ({
const uniqueSongIds: string[] = [];
if (values.skipDuplicates) {
const queryKey = queryKeys.playlists.songList(serverId, playlistId);
const queryKey = queryKeys.playlists.songListIds(serverId, playlistId);
const playlistSongsRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => {
return api.controller.getPlaylistSongList({
return api.controller.getPlaylistSongIds({
apiClientProps: {
serverId,
signal,
@@ -248,7 +248,7 @@ export const AddToPlaylistContextModal = ({
queryKey,
});
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
const playlistSongIds = playlistSongsRes?.items;
for (const songId of allSongIds) {
if (!playlistSongIds?.includes(songId)) {
@@ -11,7 +11,10 @@ import { PlaylistListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
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 { searchTerm, setSearchTerm } = useSearchTermFilter('');
@@ -107,6 +107,20 @@ export const LyricSettings = memo(() => {
isHidden: !isElectron(),
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: (
<Switch
@@ -63,6 +63,30 @@ export const WindowSettings = memo(() => {
isHidden: !isElectron(),
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: (
<Switch
@@ -823,18 +823,38 @@ const GENRE_LIST_FILTERS: Partial<
},
],
[ServerType.NAVIDROME]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name'),
value: GenreListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.songCount'),
value: GenreListSort.SONG_COUNT,
},
],
[ServerType.SUBSONIC]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.ALBUM_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name'),
value: GenreListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumCount'),
value: GenreListSort.SONG_COUNT,
},
],
};
@@ -29,6 +29,7 @@ import {
} from '/@/renderer/store';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge';
import { Button } from '/@/shared/components/button/button';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
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 { useDebouncedState } from '/@/shared/hooks/use-debounced-state';
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 {
enablePinColumnButtons?: boolean;
@@ -72,10 +73,18 @@ export const TableConfig = ({
const { t } = useTranslation();
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 hasAlbumGroupColumn = useMemo(
() => table.columns.some((column) => column.id === TableColumn.ALBUM_GROUP),
[table.columns],
);
const setTableUpdate = useCallback(
(patch: Partial<DataTableProps>) => {
if (tableKey === 'detail') {
@@ -90,6 +99,73 @@ export const TableConfig = ({
);
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 = [
{
component: (
@@ -238,6 +314,7 @@ export const TableConfig = ({
id: 'autoFitColumns',
label: t('table.config.general.autoFitColumns'),
},
...albumGroupOptions,
...(extraOptions || []),
];
@@ -262,6 +339,11 @@ export const TableConfig = ({
listKey,
setTableUpdate,
optionsConfig,
hasAlbumGroupColumn,
albumGroupOpen,
albumGroupImageSize,
imageResTable,
setSettings,
]);
return (
@@ -8,7 +8,7 @@ import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params
import { runInUrlTransition } from '/@/renderer/utils/url-transition';
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 { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
const [searchParams, setSearchParams] = useSearchParams();
+14 -1
View File
@@ -14,7 +14,13 @@ import macMin from './assets/min-mac.png';
import styles from './window-bar.module.css';
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 { Platform, PlayerStatus } from '/@/shared/types/types';
@@ -130,6 +136,8 @@ const MacOsControls = ({ controls, title }: WindowBarControlsProps) => {
export const WindowBar = () => {
const { t } = useTranslation();
const { windowBarStyle } = useWindowSettings();
const windowBarTrackinfo = useWindowBarTrackinfo();
const playerStatus = usePlayerStatus();
const privateMode = useAppStore((state) => state.privateMode);
const handleMinimize = () => minimize();
@@ -153,6 +161,10 @@ export const WindowBar = () => {
const title = useMemo(() => {
const privateModeString = privateMode ? t('page.windowBar.privateMode') : '';
if (!windowBarTrackinfo) {
return `Feishin${privateMode ? ` ${privateModeString}` : ''}`;
}
// Show radio information if radio is active
if (isRadioActive) {
const radioStatusString = !isRadioPlaying ? t('page.windowBar.paused') : '';
@@ -194,6 +206,7 @@ export const WindowBar = () => {
queueLength,
stationName,
t,
windowBarTrackinfo,
]);
useEffect(() => {
+15 -1
View File
@@ -1235,12 +1235,26 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
mediaStop: (options?: { reset?: boolean }) => {
const reset = options?.reset !== false;
set((state) => {
state.player.status = PlayerStatus.PAUSED;
state.player.status = PlayerStatus.STOPPED;
setTimestampStore(0);
if (reset) {
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: () => {
set((state) => {
+12
View File
@@ -474,6 +474,7 @@ export const GeneralSettingsSchema = z.object({
),
albumBackground: z.boolean(),
albumBackgroundBlur: z.number(),
albumGroupImageSize: z.number(),
artistBackground: z.boolean(),
artistBackgroundBlur: z.number(),
artistItems: z.array(SortableItemSchema(ArtistItemSchema)),
@@ -578,6 +579,7 @@ const LyricsSettingsSchema = z.object({
enableAutoTranslation: z.boolean(),
enableFurigana: z.boolean().optional(),
enableNeteaseTranslation: z.boolean(),
enableRomaji: z.boolean().optional(),
fetch: z.boolean(),
follow: z.boolean(),
preferLocalLyrics: z.boolean(),
@@ -677,6 +679,7 @@ const WindowSettingsSchema = z.object({
startMinimized: z.boolean(),
tray: z.boolean(),
windowBarStyle: z.nativeEnum(Platform),
windowBarTrackinfo: z.boolean(),
});
const QueryValueInputTypeSchema = z.enum([
@@ -1164,6 +1167,7 @@ const initialState: SettingsState = {
accent: 'rgb(53, 116, 252)',
albumBackground: false,
albumBackgroundBlur: 3,
albumGroupImageSize: 0,
artistBackground: true,
artistBackgroundBlur: 3,
artistItems,
@@ -1848,6 +1852,7 @@ const initialState: SettingsState = {
enableAutoTranslation: false,
enableFurigana: false,
enableNeteaseTranslation: false,
enableRomaji: false,
fetch: true,
follow: true,
preferLocalLyrics: true,
@@ -2012,6 +2017,7 @@ const initialState: SettingsState = {
startMinimized: false,
tray: true,
windowBarStyle: platformDefaultWindowBarStyle,
windowBarTrackinfo: true,
},
};
@@ -2558,6 +2564,9 @@ export const useWindowSettings = () => useSettingsStore((state) => state.window,
export const useWindowBarStyle = () =>
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 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 useAlbumGroupImageSize = () =>
useSettingsStore((state) => state.general.albumGroupImageSize);
export const useVolumeWidth = () => useSettingsStore((state) => state.general.volumeWidth, shallow);
export const useFollowCurrentSong = () =>
+12 -77
View File
@@ -10,7 +10,6 @@ import {
LibraryItem,
MusicFolder,
Playlist,
RelatedArtist,
Song,
} from '/@/shared/types/domain-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>;
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[]> => {
if (item.Tags) {
const tags: Record<string, string[]> = {};
@@ -106,39 +69,6 @@ const getPlaylistImageId = (item: z.infer<typeof jfType._response.playlist>): nu
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: {
PremiereDate?: string;
ProductionYear?: number;
@@ -189,10 +119,6 @@ const normalizeSong = (
console.warn('Jellyfin song retrieved with no media sources', item);
}
const participants = getPeople(item);
const artists = getArtists(item, participants);
const { releaseDate, releaseYear } = jellyfinPremiereFields(item);
return {
@@ -211,7 +137,16 @@ const normalizeSong = (
})),
albumId: item.AlbumId || `dummy/${item.Id}`,
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,
bitRate,
bpm: null,
@@ -253,7 +188,7 @@ const normalizeSong = (
mbzRecordingId: null,
mbzTrackId: item.ProviderIds?.MusicBrainzTrack || null,
name: item.Name,
participants,
participants: null,
path: path || '',
peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
@@ -328,7 +263,7 @@ const normalizeAlbum = (
name: item.Name,
originalDate: releaseDate,
originalYear,
participants: getPeople(item),
participants: null,
playCount: item.UserData?.PlayCount || 0,
recordLabels: item.Studios?.map((entry) => entry.Name) || [],
releaseDate,
@@ -27,7 +27,9 @@ export enum NDAlbumListSort {
}
export enum NDGenreListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name',
SONG_COUNT = 'songCount',
}
export enum NDPlaylistListSort {
@@ -754,6 +756,8 @@ const tag = z.object({
const tagList = z.array(tag);
export enum NDTagListSort {
ALBUM_COUNT = 'albumCount',
SONG_COUNT = 'songCount',
TAG_VALUE = 'tagValue',
}
+24 -2
View File
@@ -155,7 +155,9 @@ export enum ExternalType {
}
export enum GenreListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name',
SONG_COUNT = 'songCount',
}
export enum ImageType {
@@ -166,7 +168,9 @@ export enum ImageType {
}
export enum TagListSort {
ALBUM_COUNT = 'albumCount',
NAME = 'name',
SONG_COUNT = 'songCount',
}
export type Album = {
@@ -430,19 +434,25 @@ type BaseEndpointArgs = {
type GenreListSortMap = {
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
navidrome: Record<GenreListSort, NDGenreListSort | undefined>;
subsonic: Record<UserListSort, undefined>;
navidrome: Record<GenreListSort, NDGenreListSort>;
subsonic: Record<GenreListSort, undefined>;
};
export const genreListSortMap: GenreListSortMap = {
jellyfin: {
albumCount: undefined,
name: JFGenreListSort.NAME,
songCount: undefined,
},
navidrome: {
albumCount: NDGenreListSort.NAME,
name: NDGenreListSort.NAME,
songCount: NDGenreListSort.NAME,
},
subsonic: {
albumCount: undefined,
name: undefined,
songCount: undefined,
},
};
@@ -454,13 +464,19 @@ type TagListSortMap = {
export const tagListSortMap: TagListSortMap = {
jellyfin: {
albumCount: undefined,
name: undefined,
songCount: undefined,
},
navidrome: {
albumCount: NDTagListSort.ALBUM_COUNT,
name: NDTagListSort.TAG_VALUE,
songCount: NDTagListSort.SONG_COUNT,
},
subsonic: {
albumCount: undefined,
name: undefined,
songCount: undefined,
},
};
@@ -620,6 +636,8 @@ export type AlbumInfo = {
notes: null | string;
};
export type SongIdListResponse = BasePaginatedResponse<string[]>;
export type SongListArgs = BaseEndpointArgs & { query: SongListQuery };
export type SongListCountArgs = BaseEndpointArgs & { query: ListCountQuery<SongListQuery> };
@@ -1506,6 +1524,7 @@ export type ControllerEndpoint = {
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: PlaylistListCountArgs) => Promise<number>;
getPlaylistSongIds: (args: PlaylistSongListArgs) => Promise<SongIdListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
@@ -1660,6 +1679,9 @@ export type InternalControllerEndpoint = {
args: ReplaceApiClientProps<PlaylistListArgs>,
) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: ReplaceApiClientProps<PlaylistListCountArgs>) => Promise<number>;
getPlaylistSongIds: (
args: ReplaceApiClientProps<PlaylistSongListArgs>,
) => Promise<SongIdListResponse>;
getPlaylistSongList: (
args: ReplaceApiClientProps<PlaylistSongListArgs>,
) => Promise<SongListResponse>;
+1
View File
@@ -149,6 +149,7 @@ export enum PlayerShuffle {
export enum PlayerStatus {
PAUSED = 'paused',
PLAYING = 'playing',
STOPPED = 'stopped',
}
export enum PlayerStyle {