mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-01 08:10:05 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d82374dd | |||
| 42bd8d34d9 | |||
| b397790402 | |||
| 7231f73ba7 | |||
| 37ada07ee2 | |||
| a221a84792 | |||
| aa3c9251f5 | |||
| 751ec7f835 | |||
| 14bad5dbd7 | |||
| 94aa34f6b2 | |||
| da445b815d | |||
| c875146779 | |||
| 9806d2f553 | |||
| 18a7fd0731 | |||
| 062617bb40 | |||
| f8ca8861fc | |||
| 26eea7422d | |||
| 21d788226c | |||
| 9a1bf8f4a9 |
@@ -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": {
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 IDőtartam",
|
||||
"crossfadeDuration": "Áthúzás Idő tartam",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": "สถานีวิทยุ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Основний відтінок"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -128,7 +128,7 @@ export const RemoteContainer = () => {
|
||||
onClick={() => {
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
send({ event: 'pause' });
|
||||
} else if (status === PlayerStatus.PAUSED) {
|
||||
} else {
|
||||
send({ event: 'play' });
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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(() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -149,6 +149,7 @@ export enum PlayerShuffle {
|
||||
export enum PlayerStatus {
|
||||
PAUSED = 'paused',
|
||||
PLAYING = 'playing',
|
||||
STOPPED = 'stopped',
|
||||
}
|
||||
|
||||
export enum PlayerStyle {
|
||||
|
||||
Reference in New Issue
Block a user