mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-05 18:19:56 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 972bd22ddf | |||
| e0091f22ce | |||
| 022d227d5e | |||
| 31f12a39b2 | |||
| a6d82374dd | |||
| 42bd8d34d9 | |||
| b397790402 | |||
| 7231f73ba7 | |||
| 37ada07ee2 | |||
| a221a84792 | |||
| aa3c9251f5 | |||
| 751ec7f835 | |||
| 14bad5dbd7 | |||
| 94aa34f6b2 | |||
| da445b815d | |||
| c875146779 | |||
| 9806d2f553 | |||
| 18a7fd0731 | |||
| 062617bb40 |
@@ -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": {
|
||||
|
||||
@@ -493,7 +493,11 @@
|
||||
"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"
|
||||
"compressorThreshold": "Hranice",
|
||||
"enableRomaji_description": "Zobrazit rómadži výslovnost pod japonskými texty.",
|
||||
"enableRomaji": "Povolit generování rómadži",
|
||||
"windowBarTrackinfo_description": "Zobrazit v titulku okna umělce a název aktuální skladby, její pozici ve frontě a stav Přehrává se/Pozastaveno.",
|
||||
"windowBarTrackinfo": "Informace o skladbě v titulku okna"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "Upravit $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -710,7 +714,9 @@
|
||||
"horizontalBorders": "Okraje řádků",
|
||||
"rowHoverHighlight": "Zvýraznění řádku při přejetí myší",
|
||||
"verticalBorders": "Okraje sloupců",
|
||||
"showHeader": "Zobrazit záhlaví"
|
||||
"showHeader": "Zobrazit záhlaví",
|
||||
"albumImageSize": "Velikost obrázku alba",
|
||||
"albumGroupConfig": "Nastavení skupiny alb"
|
||||
},
|
||||
"label": {
|
||||
"releaseDate": "Datum vydání",
|
||||
|
||||
@@ -1175,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",
|
||||
@@ -1216,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})",
|
||||
@@ -1185,7 +1189,9 @@
|
||||
"horizontalBorders": "Bordes de fila",
|
||||
"verticalBorders": "Bordes de columna",
|
||||
"rowHoverHighlight": "Resaltar al pasar el cursor por la fila",
|
||||
"showHeader": "Mostrar cabecera"
|
||||
"showHeader": "Mostrar cabecera",
|
||||
"albumImageSize": "Tamaño de la imagen del álbum",
|
||||
"albumGroupConfig": "Configuración del grupo del álbum"
|
||||
},
|
||||
"view": {
|
||||
"table": "Tabla",
|
||||
|
||||
@@ -1396,7 +1396,8 @@
|
||||
"horizontalBorders": "Ridade piirjooned",
|
||||
"rowHoverHighlight": "Rea esiletõstmine kursoriga",
|
||||
"showHeader": "Kuva päis",
|
||||
"verticalBorders": "Veergude piirjooned"
|
||||
"verticalBorders": "Veergude piirjooned",
|
||||
"albumImageSize": "Kaanepildi suurus"
|
||||
},
|
||||
"label": {
|
||||
"actions": "$t(common.action, {\"count\": 2})",
|
||||
|
||||
@@ -674,9 +674,9 @@
|
||||
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
|
||||
"replayGainMode_description": "Ajuste le gain du volume selon les valeurs de {{ReplayGain}} enregistrées dans les métadonnées du fichier",
|
||||
"replayGainFallback": "Valeur de repli de {{ReplayGain}}",
|
||||
"replayGainClipping_description": "Empêcher la distorsion causée par {{ReplayGain}} en réduisant automatiquement le gain",
|
||||
"replayGainClipping_description": "Prévient l'écrêtage causé par {{ReplayGain}} en réduisant automatiquement le gain",
|
||||
"replayGainPreamp": "Préamplificateur (db) de {{ReplayGain}}",
|
||||
"replayGainClipping": "Distorsion du {{ReplayGain}}",
|
||||
"replayGainClipping": "Écrêtage du {{ReplayGain}}",
|
||||
"replayGainMode": "Mode de {{ReplayGain}}",
|
||||
"replayGainFallback_description": "Gain en dB à appliquer si le fichier n'a pas de tags de {{ReplayGain}}",
|
||||
"replayGainPreamp_description": "Ajuste le gain de préampli appliqué aux valeurs {{ReplayGain}}",
|
||||
@@ -684,7 +684,7 @@
|
||||
"clearCache": "Vider le cache du navigateur",
|
||||
"buttonSize_description": "La taille des boutons de la barre de lecture",
|
||||
"clearQueryCache_description": "Un 'nettoyage léger' de Feishin. Cela actualisera les listes de lecture, les métadonnées des titres, et réinitialisera les paroles enregistrées. Les paramètres, identifiants du serveur et images mises en cache seront conservés",
|
||||
"clearCache_description": "Un 'nettoyage complet' de Feishin. En plus de vider le cache de Feishin, vide le cache du navigateur (images sauvegardées et autres ressources). Les identifiants serveurs et paramètres sont conservés",
|
||||
"clearCache_description": "Un 'nettoyage complet' de Feishin. En plus de vider le cache de Feishin, vide le cache du navigateur (images enregistrée et autres ressources). Les identifiants serveurs et paramètres sont conservés",
|
||||
"buttonSize": "Taille des boutons de la barre de lecture",
|
||||
"clearCacheSuccess": "Cache vidé avec succès",
|
||||
"externalLinks_description": "Activer l'affichage de liens externes (Last.fm, MusicBrainz) sur les pages d'artiste/album",
|
||||
@@ -926,7 +926,31 @@
|
||||
"autoDJ_albumStrategy": "Mode de sélection d'album",
|
||||
"autoDJ_songStrategy": "Mode de sélection de titre",
|
||||
"autoDJ_strategy_option_library_random": "Aléatoire",
|
||||
"autoDJ_strategy_option_similar": "Similaire"
|
||||
"autoDJ_strategy_option_similar": "Similaire",
|
||||
"enableFurigana_description": "Afficher les indications de prononciation (furigana) au-dessus des paroles en kanji Japonais.",
|
||||
"enableFurigana": "Active la génération des furigana",
|
||||
"enableRomaji_description": "Afficher une ligne de prononciation en romaji sous les paroles Japonaises.",
|
||||
"enableRomaji": "Active la génération des romaji",
|
||||
"equalizer_descriptionMpv": "Égaliseur paramétrique via FFmpeg lavfi (MPV)",
|
||||
"equalizer_descriptionWebAudio": "Égaliseur paramétrique via Web Audio API",
|
||||
"equalizer": "Égaliseur",
|
||||
"equalizerBands_description": "Gain par bande. Faire glisser vers le haut/bas ou taper une valeur. Plage : -12 à +12 dB.",
|
||||
"equalizerBands": "Bandes",
|
||||
"equalizerPreamp_description": "Gain d'entrée avant les bandes d'égalisation. Réglez-le sur une valeur négative lors de l'amplification des bandes pour éviter l'écrêtage (MPV).",
|
||||
"equalizerPreamp": "Préampli",
|
||||
"equalizerPreset_description": "Appliquer une courbe d'EQ personnalisé intégrée ou personnalisée",
|
||||
"equalizerPreset": "Préréglage",
|
||||
"equalizerPresetDeletePlaceholder": "Supprimer les personnalisé...",
|
||||
"equalizerPresetGroupBuiltIn": "Intégré",
|
||||
"equalizerPresetGroupCustom": "Personnalisé",
|
||||
"equalizerPresetNamePlaceholder": "Nom de préréglage...",
|
||||
"equalizerPresetSelectPlaceholder": "Sélectionnée un préréglage",
|
||||
"equalizerSavePreset_description": "Enregistrer les paramètres d'EQ actuels en tant que préréglage nommé",
|
||||
"equalizerSavePreset": "Enregistrer le préréglage",
|
||||
"sidebarPlaylistFolderTreeLineColor_description": "Couleur des lignes de l'arborescence (vide pour le thème par défaut)",
|
||||
"sidebarPlaylistFolderTreeLineColor": "Couleur de la ligne de l'arboresence",
|
||||
"windowBarTrackinfo": "Informations de la piste dans le titre de la fenêtre",
|
||||
"windowBarTrackinfo_description": "Afficher le titre et l'artiste de la piste en cours, sa position dans la file d'attente et son état (lecture/pause) dans le titre de la fenêtre."
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
@@ -945,7 +969,7 @@
|
||||
"input_savePassword": "Enregister le mot de passe",
|
||||
"ignoreSsl": "Ignorer ssl $t(common.restartRequired)",
|
||||
"ignoreCors": "Ignorer cors $t(common.restartRequired)",
|
||||
"error_savePassword": "Une erreur s’est produite lors de la tentative de sauvegarde du mot de passe",
|
||||
"error_savePassword": "Une erreur s’est produite lors de la tentative d'enregistrement du mot de passe",
|
||||
"input_preferInstantMix": "Préférer le mix instantané",
|
||||
"input_preferInstantMixDescription": "Utiliser uniquement le mix instantané pour jouer des titres similaires. utile si vous avez des plugins qui modifient ce comportement",
|
||||
"input_preferRemoteUrl": "Préférer une URL publique",
|
||||
@@ -1138,7 +1162,9 @@
|
||||
"horizontalBorders": "Bordures de ligne",
|
||||
"rowHoverHighlight": "Surligner les lignes au survol",
|
||||
"verticalBorders": "Bordure de colonne",
|
||||
"showHeader": "Affiche l'en-tête"
|
||||
"showHeader": "Affiche l'en-tête",
|
||||
"albumGroupConfig": "Configuration du groupe d'albums",
|
||||
"albumImageSize": "Taille de la pochette d'album"
|
||||
},
|
||||
"view": {
|
||||
"table": "Liste",
|
||||
@@ -1266,7 +1292,9 @@
|
||||
"notContains": "Ne contient pas",
|
||||
"notInPlaylist": "N'est pas dans",
|
||||
"notInTheLast": "N'est pas dans le dernier",
|
||||
"startsWith": "Commence par"
|
||||
"startsWith": "Commence par",
|
||||
"isMissing": "Est manquant",
|
||||
"isPresent": "Est présent"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "M",
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +440,30 @@
|
||||
"autoDJ_albumStrategy": "アルバム選択モード",
|
||||
"autoDJ_songStrategy": "選曲モード",
|
||||
"autoDJ_strategy_option_library_random": "ランダム",
|
||||
"autoDJ_strategy_option_similar": "類似"
|
||||
"autoDJ_strategy_option_similar": "類似",
|
||||
"enableFurigana_description": "日本語の漢字歌詞の上にふりがなを表示します。",
|
||||
"enableFurigana": "ふりがなの表示を有効にする",
|
||||
"enableRomaji_description": "日本語の歌詞の下にローマ字の発音表記を表示します。",
|
||||
"enableRomaji": "ローマ字の表示を有効にする",
|
||||
"equalizer": "イコライザー",
|
||||
"equalizerBands_description": "バンドごとのゲイン。上下にドラッグするか、値を入力してください。範囲: -12 ~ +12 dB。",
|
||||
"equalizerBands": "バンド",
|
||||
"equalizerPreset": "プリセット",
|
||||
"equalizerPresetGroupBuiltIn": "内蔵",
|
||||
"equalizerPresetGroupCustom": "カスタム",
|
||||
"equalizerPresetSelectPlaceholder": "プリセットを選択",
|
||||
"equalizerSavePreset_description": "現在のイコライザー設定を名前付きプリセットとして保存します",
|
||||
"equalizerSavePreset": "プリセットを保存",
|
||||
"equalizerPreset_description": "内蔵または保存済みのカスタムイコライザーカーブを適用します",
|
||||
"compressor": "コンプレッサー",
|
||||
"compressorAttack_description": "信号が閾値を超えた後、コンプレッサーが作動するまでの時間を設定します。",
|
||||
"sidebarPlaylistFolders": "フォルダーを有効化",
|
||||
"sidebarPlaylistFolderSeparator_description": "プレイリスト名でフォルダー階層を区切る文字(または文字列)を設定します",
|
||||
"sidebarPlaylistFolderSeparator": "フォルダーセパレーター",
|
||||
"sidebarPlaylistFolders_description": "設定済みの区切り文字が名前に含まれるプレイリストのフォルダービューを作成します",
|
||||
"sidebarPlaylistFolderTreeIndent": "ツリーのインデント",
|
||||
"sidebarPlaylistMode_optionCompact": "コンパクト",
|
||||
"sidebarPlaylistMode_optionExpanded": "拡張"
|
||||
},
|
||||
"action": {
|
||||
"editPlaylist": "$t(entity.playlist, {\"count\": 1}) を編集",
|
||||
@@ -1213,7 +1236,9 @@
|
||||
"isGreaterThan": "より大きい",
|
||||
"isLessThan": "より小さい",
|
||||
"notInPlaylist": "いずれでもない",
|
||||
"notInTheLast": "より前"
|
||||
"notInTheLast": "より前",
|
||||
"isMissing": "欠落している",
|
||||
"isPresent": "存在する"
|
||||
},
|
||||
"visualizer": {
|
||||
"visualizerType": "ビジュアライザーの種類",
|
||||
|
||||
@@ -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": {
|
||||
@@ -1184,7 +1188,9 @@
|
||||
"horizontalBorders": "Obwódki wierszy",
|
||||
"rowHoverHighlight": "Podświetlanie wierszy po najechaniu",
|
||||
"verticalBorders": "Obwódki kolumn",
|
||||
"showHeader": "Pokaż nagłówek"
|
||||
"showHeader": "Pokaż nagłówek",
|
||||
"albumImageSize": "Rozmiar obrazów albumów",
|
||||
"albumGroupConfig": "Konfiguracja grupy albumów"
|
||||
},
|
||||
"label": {
|
||||
"releaseDate": "Data premiery",
|
||||
|
||||
+166
-30
@@ -15,8 +15,8 @@
|
||||
"edit": "Editar",
|
||||
"favorite": "Favorito",
|
||||
"currentSong": "$t(entity.track, {\"count\": 1}) atual",
|
||||
"descending": "Abaixar",
|
||||
"dismiss": "Liberar",
|
||||
"descending": "Decrescente",
|
||||
"dismiss": "Ignorar",
|
||||
"duration": "Duração",
|
||||
"decrease": "Diminuir",
|
||||
"description": "Descrição",
|
||||
@@ -24,7 +24,7 @@
|
||||
"enable": "Habilitar",
|
||||
"clear": "Limpar",
|
||||
"delete": "Deletar",
|
||||
"title": "Titulo",
|
||||
"title": "Título",
|
||||
"create": "Criar",
|
||||
"confirm": "Confirmar",
|
||||
"home": "Início",
|
||||
@@ -59,8 +59,8 @@
|
||||
"forward": "Para frente",
|
||||
"forceRestartRequired": "Reinicie para aplicar as alterações… feche a notificação para reiniciar",
|
||||
"setting_one": "Configuração",
|
||||
"setting_many": "",
|
||||
"setting_other": "",
|
||||
"setting_many": "Configurações",
|
||||
"setting_other": "Configurações",
|
||||
"version": "Versão",
|
||||
"filter_one": "Filtro",
|
||||
"filter_many": "filtros",
|
||||
@@ -69,15 +69,15 @@
|
||||
"saveAndReplace": "Salvar e substituir",
|
||||
"playerMustBePaused": "O player deve estar pausado",
|
||||
"resetToDefault": "Restaurar ao padrão",
|
||||
"reset": "Reiniciar",
|
||||
"reset": "Resetar",
|
||||
"sortOrder": "Ordem",
|
||||
"none": "Nenhum",
|
||||
"menu": "Menu",
|
||||
"restartRequired": "É necessário reiniciar",
|
||||
"previousSong": "Anterior $t(entity.track, {\"count\": 1})",
|
||||
"noResultsFromQuery": "A consulta não retornou resultados",
|
||||
"previousSong": "$t(entity.track, {\"count\": 1}) anterior",
|
||||
"noResultsFromQuery": "A pesquisa não retornou resultados",
|
||||
"quit": "Sair",
|
||||
"search": "Procurar",
|
||||
"search": "Pesquisar",
|
||||
"saveAs": "Salvar como",
|
||||
"yes": "Sim",
|
||||
"random": "Aleatório",
|
||||
@@ -100,7 +100,35 @@
|
||||
"newVersion": "Uma nova versão foi instalada ({{version}})",
|
||||
"viewReleaseNotes": "Ver notas de lançamento",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"sampleRate": "Taxa de amostragem"
|
||||
"sampleRate": "Taxa de amostragem",
|
||||
"countSelected": "{{count}} selecionadas",
|
||||
"explicitStatus": "Explicito",
|
||||
"back": "Voltar",
|
||||
"doNotShowAgain": "Não mostrar novamente",
|
||||
"view": "Ver",
|
||||
"example": "Exemplo",
|
||||
"externalLinks": "Links externos",
|
||||
"openFolder": "Abrir pasta",
|
||||
"faster": "Acelerar",
|
||||
"filter_single": "Single",
|
||||
"filter_multiple": "Multi",
|
||||
"mood": "Clima",
|
||||
"numberOfResults": "{{numberOfResults}} resultados",
|
||||
"noFilters": "Nenhum filtro configurado",
|
||||
"private": "Privado",
|
||||
"public": "Público",
|
||||
"retry": "Tentar novamente",
|
||||
"recordLabel": "Gravadora",
|
||||
"releaseType": "Tipo de lançamento",
|
||||
"rename": "Renomear",
|
||||
"slower": "Mais lento",
|
||||
"sort": "Ordenar",
|
||||
"explicit": "Explícito",
|
||||
"clean": "Limpo",
|
||||
"gridRows": "Linhas da grade",
|
||||
"tableColumns": "Colunas da tabela",
|
||||
"itemsMore": "Mais {{count}}",
|
||||
"newVersionAvailable": "Uma nova versão está disponível"
|
||||
},
|
||||
"action": {
|
||||
"goToPage": "Vá para página",
|
||||
@@ -123,7 +151,8 @@
|
||||
"lastfm": "Abrir em Last.fm",
|
||||
"musicbrainz": "Abrir em MusicBrainz",
|
||||
"listenbrainz": "Abrir no ListenBrainz",
|
||||
"qobuz": "Abrir no Qobuz"
|
||||
"qobuz": "Abrir no Qobuz",
|
||||
"spotify": "Abrir no Spotify"
|
||||
},
|
||||
"toggleSmartPlaylistEditor": "Alternar editor $t(entity.smartPlaylist)",
|
||||
"moveToNext": "Mover para o próximo",
|
||||
@@ -135,7 +164,17 @@
|
||||
"holdToMoveToTop": "Segure para ir ao topo",
|
||||
"moveItems": "Mover itens",
|
||||
"viewMore": "Ver mais",
|
||||
"openApplicationDirectory": "Abrir a pasta do aplicativo"
|
||||
"openApplicationDirectory": "Abrir a pasta do aplicativo",
|
||||
"selectRangeOfItems": "Selecionar uma lista de itens",
|
||||
"collapseAllFolders": "Minimizar todas as pastas",
|
||||
"downloadStarted": "Download de {{count}} items iniciado",
|
||||
"moveUp": "Mover a cima",
|
||||
"moveDown": "Mover a baixo",
|
||||
"holdToMoveToBottom": "Segure para mover ao final",
|
||||
"shuffle": "Shuffle",
|
||||
"shuffleAll": "Shuffle todas",
|
||||
"shuffleSelected": "Shuffle selecionadas",
|
||||
"expandAllFolders": "Expandir todas as pastas"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
@@ -156,7 +195,10 @@
|
||||
"input_username": "Nome de usuário",
|
||||
"ignoreCors": "Ignorar CORS ($t(common.restartRequired))",
|
||||
"input_preferInstantMix": "Preferir mixagem instantânea",
|
||||
"input_preferInstantMixDescription": "Usar apenas a mixagem instantânea para obter músicas semelhantes. Útil se você tiver plugins que modificam esse comportamento"
|
||||
"input_preferInstantMixDescription": "Usar apenas a mixagem instantânea para obter músicas semelhantes. Útil se você tiver plugins que modificam esse comportamento",
|
||||
"input_preferRemoteUrl": "Preferir URL pública",
|
||||
"input_remoteUrl": "URL pública",
|
||||
"input_remoteUrlPlaceholder": "Opcional: URL pública para funções externas"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"title": "Criar $t(entity.playlist, {\"count\": 1})",
|
||||
@@ -179,7 +221,10 @@
|
||||
"title": "Adicionar à $t(entity.playlist, {\"count\": 1})",
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
"input_skipDuplicates": "Pular duplicadas",
|
||||
"success": "Adicionado $t(entity.trackWithCount, {\"count\": {{message}} }) para $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })"
|
||||
"success": "Adicionado $t(entity.trackWithCount, {\"count\": {{message}} }) para $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||
"create": "Criar $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
||||
"noneAdded": "Nenhuma faixa foi adicionada à $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
||||
"searchOrCreate": "Pesquisar $t(entity.playlist, {\"count\": 2}) ou criar uma nova"
|
||||
},
|
||||
"lyricSearch": {
|
||||
"title": "Pesquisa de letras",
|
||||
@@ -203,6 +248,24 @@
|
||||
"enabled": "Modo privado ativado, o status de reprodução agora está oculto para integrações externas",
|
||||
"disabled": "Modo privado desativado, o status de reprodução agora está visível para as integrações externas ativadas",
|
||||
"title": "Modo privado"
|
||||
},
|
||||
"largeFetchConfirmation": {
|
||||
"title": "Adicionar items à lista de reprodução",
|
||||
"description": "Essa ação adicionará todos os itens do filtro atual"
|
||||
},
|
||||
"createRadioStation": {
|
||||
"success": "Estação de rádio criada com sucesso",
|
||||
"title": "Criar estação de rádio",
|
||||
"input_homepageUrl": "URL da página principal",
|
||||
"input_name": "Nome",
|
||||
"input_streamUrl": "URL da stream"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "Estação de rádio atualizada com sucesso"
|
||||
},
|
||||
"lyricsExport": {
|
||||
"export": "Exportar letra",
|
||||
"input_synced": "Exportar letra sincronizada"
|
||||
}
|
||||
},
|
||||
"setting": {
|
||||
@@ -536,7 +599,8 @@
|
||||
"title": "$t(common.home)",
|
||||
"explore": "Explore a sua biblioteca",
|
||||
"recentlyPlayed": "Tocado recentemente",
|
||||
"recentlyReleased": "Lançamentos recentes"
|
||||
"recentlyReleased": "Lançamentos recentes",
|
||||
"genres": "$t(entity.genre, {\"count\": 2})"
|
||||
},
|
||||
"albumArtistList": {
|
||||
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
||||
@@ -572,7 +636,8 @@
|
||||
"playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
"search": "$t(common.search)",
|
||||
"settings": "$t(common.setting, {\"count\": 2})",
|
||||
"myLibrary": "Minha biblioteca"
|
||||
"myLibrary": "Minha biblioteca",
|
||||
"collections": "Coleções"
|
||||
},
|
||||
"playlistList": {
|
||||
"title": "$t(entity.playlist, {\"count\": 2})"
|
||||
@@ -594,7 +659,8 @@
|
||||
"manageServers": "Gerenciar servidores",
|
||||
"settings": "$t(common.setting, {\"count\": 2})",
|
||||
"privateModeOff": "Desativar modo privado",
|
||||
"privateModeOn": "Ativar modo privado"
|
||||
"privateModeOn": "Ativar modo privado",
|
||||
"multipleMusicFolders": "{{count}} pasta(s) de música selecionadas"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToTop": "$t(action.moveToTop)",
|
||||
@@ -620,7 +686,9 @@
|
||||
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
|
||||
"setRating": "$t(action.setRating)",
|
||||
"goToAlbum": "Ir para $t(entity.album, {\"count\": 1})",
|
||||
"goToAlbumArtist": "Ir para $t(entity.albumArtist, {\"count\": 1})"
|
||||
"goToAlbumArtist": "Ir para $t(entity.albumArtist, {\"count\": 1})",
|
||||
"moveItems": "$t(action.moveItems)",
|
||||
"goTo": "Ir para"
|
||||
},
|
||||
"albumArtistDetail": {
|
||||
"viewAllTracks": "Ver todas as $t(entity.track, {\"count\": 2})",
|
||||
@@ -648,7 +716,9 @@
|
||||
"lyricGap": "Espaçamento da letra",
|
||||
"lyricSize": "Tamanho da letra",
|
||||
"dynamicBackground": "Fundo dinâmico",
|
||||
"opacity": "Opacidade"
|
||||
"opacity": "Opacidade",
|
||||
"lyricOpacityNonActive": "Opacidade da letra inativa",
|
||||
"lyricScaleNonActive": "Escala da letra inativa"
|
||||
},
|
||||
"related": "Relacionado",
|
||||
"visualizer": "Visualizador",
|
||||
@@ -679,19 +749,44 @@
|
||||
"hotkeysTab": "Teclas de atalho",
|
||||
"windowTab": "Janela",
|
||||
"advanced": "Avançado",
|
||||
"playbackTab": "Reprodução"
|
||||
"playbackTab": "Reprodução",
|
||||
"analytics": "Analíticas",
|
||||
"updates": "Atualização",
|
||||
"cache": "Cache",
|
||||
"application": "Aplicação",
|
||||
"queryBuilder": "Construtor de pesquisa",
|
||||
"theme": "Tema",
|
||||
"controls": "Controles",
|
||||
"sidebar": "Barra lateral",
|
||||
"remote": "Controle Remoto",
|
||||
"exportImport": "Importar/exportar",
|
||||
"scrobble": "Scrobble",
|
||||
"audio": "Áudio",
|
||||
"lyrics": "Letra",
|
||||
"lyricsDisplay": "Visualizar letra",
|
||||
"transcoding": "Transcoding",
|
||||
"discord": "Discord",
|
||||
"logger": "Logger",
|
||||
"playerFilters": "Filtros de reprodução"
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "Reordenar apenas disponível quando ordenado pelo ID"
|
||||
},
|
||||
"folderList": {
|
||||
"title": "$t(entity.folder, {\"count\": 2})"
|
||||
},
|
||||
"collections": {
|
||||
"overrideExisting": "Substituir atual",
|
||||
"saveAsCollection": "Salvar como coleção"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "Titulo",
|
||||
"disc": "Disco",
|
||||
"mostPlayed": "Mais tocado",
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"album": "$t({\"count\": 1}, entity.album)",
|
||||
"name": "Nome",
|
||||
"biography": "Bibliografia",
|
||||
"biography": "Biografia",
|
||||
"duration": "Duração",
|
||||
"favorited": "Favoritado",
|
||||
"fromYear": "A partir do ano",
|
||||
@@ -717,7 +812,7 @@
|
||||
"releaseYear": "Ano de lançamento",
|
||||
"rating": "Avaliação",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"bpm": "Bpm",
|
||||
"bpm": "BPM",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"comment": "Comentário",
|
||||
"owner": "$t(common.owner)",
|
||||
@@ -727,7 +822,11 @@
|
||||
"isRated": "Possui avaliação",
|
||||
"note": "Nota",
|
||||
"albumCount": "Número de $t(entity.album, {\"count\": 2})",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})"
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"matchAnd": "E",
|
||||
"matchOr": "Ou",
|
||||
"sortName": "Ordenar por nome",
|
||||
"explicitStatus": "$t(common.explicitStatus)"
|
||||
},
|
||||
"player": {
|
||||
"playbackFetchNoResults": "Nenhuma música encontrada",
|
||||
@@ -814,7 +913,13 @@
|
||||
"song_other": "Músicas",
|
||||
"play_one": "{{count}} reprodução",
|
||||
"play_many": "{{count}} reproduções",
|
||||
"play_other": "{{count}} reproduções"
|
||||
"play_other": "{{count}} reproduções",
|
||||
"radioStation_one": "Estação de rádio",
|
||||
"radioStation_many": "Estações de rádio",
|
||||
"radioStation_other": "Estações de rádio",
|
||||
"radioStationWithCount_one": "{{count}} estação de rádio",
|
||||
"radioStationWithCount_many": "{{count}} estações de rádio",
|
||||
"radioStationWithCount_other": "{{count}} estações de rádio"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "Reinicie o servidor para aplicar a nova porta",
|
||||
@@ -829,17 +934,48 @@
|
||||
"credentialsRequired": "Credenciais necessárias",
|
||||
"sessionExpiredError": "Sua sessão expirou",
|
||||
"remoteEnableError": "Ocorreu um erro ao tentar $t(common.enable) o servidor remoto",
|
||||
"localFontAccessDenied": "Acesso negado a fontes locais",
|
||||
"localFontAccessDenied": "Acesso negado às fontes locais",
|
||||
"serverNotSelectedError": "Nenhum servidor selecionado",
|
||||
"remoteDisableError": "Ocorreu um erro ao tentar $t(common.disable) o servidor remoto",
|
||||
"mpvRequired": "Requer MPV",
|
||||
"audioDeviceFetchError": "Ocorreu um erro ao tentar obter dispositivos de áudio",
|
||||
"invalidServer": "Servidor inválido",
|
||||
"loginRateError": "Muitas tentativas de login, tente novamente em alguns segundos",
|
||||
"badAlbum": "Você está vendo este erro por que está música não é parte de algum álbum. um motivo comum para você estar vendo este erro é se a sua música estiver na raiz da sua pasta de músicas. o Jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta",
|
||||
"networkError": "Ocorreu um erro na internet",
|
||||
"badAlbum": "Você está vendo este erro por que está música não é parte de algum álbum. um motivo comum para você estar vendo este erro é se a sua música estiver na raiz da sua pasta de músicas. O Jellyfin apenas agrupa as músicas se elas estiveram na mesma pasta",
|
||||
"networkError": "Ocorreu um erro de rede",
|
||||
"openError": "Não foi possível abrir o arquivo",
|
||||
"badValue": "Opção inválida \"{{value}}\". este valor não existe no momento",
|
||||
"notificationDenied": "As permissões para notificações foram negadas. Esta configuração não tem efeito"
|
||||
"badValue": "Opção inválida \"{{value}}\". Este valor não existe no momento",
|
||||
"notificationDenied": "As permissões para notificações foram negadas. Esta configuração não fará efeito",
|
||||
"invalidJson": "JSON inválido",
|
||||
"multipleServerSaveQueueError": "A fila de reprodução tem uma ou mais faixas que não são do servidor atual. Isso não é suportado",
|
||||
"noNetwork": "Servidor indisponível",
|
||||
"noNetworkDescription": "Não foi possível conectar ao servidor",
|
||||
"playbackPausedDueToError": "Reprodução foi pausada devido a um erro",
|
||||
"saveQueueFailed": "Falha ao salvar a fila de reprodução",
|
||||
"serverLockSingleServer": "Apenas um servidor é permitido quando o servidor estiver trancado",
|
||||
"settingsSyncError": "Foram encontradas discrepâncias entre as configurações do renderizador e do processo principal. Reinicie a aplicação para aplicar as mudanças"
|
||||
},
|
||||
"filterOperator": {
|
||||
"after": "É depois",
|
||||
"afterDate": "Depois de (date)",
|
||||
"before": "Antes de",
|
||||
"beforeDate": "Antes de (date)",
|
||||
"contains": "Contém",
|
||||
"endsWith": "Termina com",
|
||||
"inPlaylist": "Está em",
|
||||
"inTheLast": "Está no último",
|
||||
"inTheRange": "Está entre",
|
||||
"inTheRangeDate": "Está entre (date)",
|
||||
"is": "É",
|
||||
"isNot": "Não é",
|
||||
"isMissing": "Está faltando",
|
||||
"isPresent": "Está presente",
|
||||
"isGreaterThan": "É maior que",
|
||||
"isLessThan": "É menor que",
|
||||
"matchesRegex": "Corresponde ao regex",
|
||||
"notContains": "Não contém",
|
||||
"notInPlaylist": "Não se inclui em",
|
||||
"notInTheLast": "Não está no último",
|
||||
"startsWith": "Começa com"
|
||||
}
|
||||
}
|
||||
|
||||
+188
-1
@@ -1 +1,188 @@
|
||||
{}
|
||||
{
|
||||
"action": {
|
||||
"selectRangeOfItems": "เลือกช่วงของรายการ",
|
||||
"addToFavorites": "เพิ่มลงใน $t(entity.favorite, {\"count\": 2})",
|
||||
"addToPlaylist": "เพิ่มลงใน $t(entity.playlist, {\"count\": 1})",
|
||||
"addOrRemoveFromSelection": "เพิ่มหรือเอาออกจากรายการที่เลือก",
|
||||
"clearQueue": "ล้างคิว",
|
||||
"goToCurrent": "ไปยังรายการปัจจุบัน",
|
||||
"collapseAllFolders": "ยุบโฟลเดอร์ทั้งหมด",
|
||||
"expandAllFolders": "ขยายโฟลเดอร์ทั้งหมด",
|
||||
"createPlaylist": "สร้าง $t(entity.playlist, {\"count\": 1})",
|
||||
"createRadioStation": "สร้าง $t(entity.radioStation, {\"count\": 1})",
|
||||
"deletePlaylist": "ลบ $t(entity.playlist, {\"count\": 1})",
|
||||
"deleteRadioStation": "ลบ $t(entity.radioStation, {\"count\": 1})",
|
||||
"selectAll": "เลือกทั้งหมด",
|
||||
"deselectAll": "ยกเลิกการเลือกทั้งหมด",
|
||||
"downloadStarted": "เริ่มดาวน์โหลด {{count}} รายการ",
|
||||
"editPlaylist": "แก้ไข $t(entity.playlist, {\"count\": 1})",
|
||||
"goToPage": "ไปที่หน้า",
|
||||
"moveToNext": "ย้ายไปถัดไป",
|
||||
"moveToBottom": "ย้ายไปล่างสุด",
|
||||
"moveToTop": "ย้ายไปบนสุด",
|
||||
"moveUp": "เลื่อนขึ้น",
|
||||
"moveDown": "เลื่อนลง",
|
||||
"holdToMoveToTop": "กดค้างเพื่อย้ายไปบนสุด",
|
||||
"holdToMoveToBottom": "กดค้างเพื่อย้ายไปล่างสุด",
|
||||
"moveItems": "ย้ายรายการ",
|
||||
"shuffle": "สุ่ม",
|
||||
"shuffleAll": "สุ่มทั้งหมด",
|
||||
"shuffleSelected": "สุ่มรายการที่เลือก",
|
||||
"removeFromFavorites": "ลบออกจาก $t(entity.favorite, {\"count\": 2})",
|
||||
"removeFromPlaylist": "ลบออกจาก $t(entity.playlist, {\"count\": 1})",
|
||||
"removeFromQueue": "ลบออกจากคิว",
|
||||
"setRating": "ให้คะแนน",
|
||||
"toggleSmartPlaylistEditor": "เปิด/ปิดตัวแก้ไข $t(entity.smartPlaylist)",
|
||||
"viewPlaylists": "ดู $t(entity.playlist, {\"count\": 2})",
|
||||
"viewMore": "ดูเพิ่มเติม",
|
||||
"openApplicationDirectory": "เปิดโฟลเดอร์โปรแกรม",
|
||||
"openIn": {
|
||||
"lastfm": "เปิดใน Last.fm",
|
||||
"listenbrainz": "เปิดใน ListenBrainz",
|
||||
"musicbrainz": "เปิดใน MusicBrainz",
|
||||
"qobuz": "เปิดใน Qobuz",
|
||||
"spotify": "เปิดใน Spotify"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"countSelected": "เลือกแล้ว {{count}} รายการ",
|
||||
"explicitStatus": "สถานะเนื้อหาชัดเจน",
|
||||
"action_other": "การดำเนินการ",
|
||||
"add": "เพิ่ม",
|
||||
"additionalParticipants": "ผู้เข้าร่วมเพิ่มเติม",
|
||||
"newVersion": "ติดตั้งเวอร์ชันใหม่แล้ว {{version}}",
|
||||
"viewReleaseNotes": "ดูบันทึกการเปลี่ยนแปลง",
|
||||
"albumGain": "ระดับความดังของอัลบั้ม",
|
||||
"albumPeak": "ระดับเสียงสูงสุดของอัลบั้ม",
|
||||
"areYouSure": "คุณแน่ใจหรือไม่?",
|
||||
"ascending": "เรียงจากน้อยไปมาก",
|
||||
"back": "ย้อนกลับ",
|
||||
"backward": "ย้อนกลับ",
|
||||
"biography": "ประวัติ",
|
||||
"bitDepth": "ความลึกบิต",
|
||||
"bitrate": "บิตเรต",
|
||||
"bpm": "จังหวะต่อนาที",
|
||||
"cancel": "ยกเลิก",
|
||||
"center": "กึ่งกลาง",
|
||||
"channel_other": "ช่อง",
|
||||
"clear": "ล้าง",
|
||||
"close": "ปิด",
|
||||
"codec": "ตัวแปลงสัญญาณ",
|
||||
"collapse": "ยุบ",
|
||||
"comingSoon": "เร็ว ๆ นี้…",
|
||||
"configure": "กำหนดค่า",
|
||||
"confirm": "ยืนยัน",
|
||||
"create": "สร้าง",
|
||||
"currentSong": "$t(entity.track, {\"count\": 1}) ปัจจุบัน",
|
||||
"decrease": "ลด",
|
||||
"delete": "ลบ",
|
||||
"descending": "เรียงจากมากไปน้อย",
|
||||
"description": "คำอธิบาย",
|
||||
"disable": "ปิดใช้งาน",
|
||||
"disc": "แผ่น",
|
||||
"dismiss": "ปิด",
|
||||
"doNotShowAgain": "ไม่ต้องแสดงอีก",
|
||||
"duration": "ระยะเวลา",
|
||||
"view": "ดู",
|
||||
"edit": "แก้ไข",
|
||||
"enable": "เปิดใช้งาน",
|
||||
"expand": "ขยาย",
|
||||
"example": "ตัวอย่าง",
|
||||
"externalLinks": "ลิงก์ภายนอก",
|
||||
"openFolder": "เปิดโฟลเดอร์",
|
||||
"faster": "เร็วขึ้น",
|
||||
"favorite": "รายการโปรด",
|
||||
"filter_other": "ตัวกรอง",
|
||||
"filters": "ตัวกรอง",
|
||||
"filter_single": "เดี่ยว",
|
||||
"filter_multiple": "หลายรายการ",
|
||||
"forceRestartRequired": "รีสตาร์ตเพื่อใช้การเปลี่ยนแปลง... ปิดการแจ้งเตือนนี้เพื่อรีสตาร์ต",
|
||||
"forward": "ไปข้างหน้า",
|
||||
"gap": "ระยะห่าง",
|
||||
"home": "หน้าแรก",
|
||||
"increase": "เพิ่ม",
|
||||
"left": "ซ้าย",
|
||||
"limit": "จำกัด",
|
||||
"manage": "จัดการ",
|
||||
"maximize": "ขยายใหญ่สุด",
|
||||
"menu": "เมนู",
|
||||
"minimize": "ย่อหน้าต่าง",
|
||||
"modified": "แก้ไขล่าสุด",
|
||||
"grouping": "การจัดกลุ่ม",
|
||||
"mood": "อารมณ์",
|
||||
"name": "ชื่อ",
|
||||
"no": "ไม่",
|
||||
"none": "ไม่มี",
|
||||
"noResultsFromQuery": "ไม่พบผลลัพธ์ที่ตรงกับการค้นหา",
|
||||
"numberOfResults": "พบ {{numberOfResults}} รายการ",
|
||||
"noFilters": "ยังไม่ได้ตั้งค่าตัวกรอง",
|
||||
"note": "หมายเหตุ",
|
||||
"ok": "ตกลง",
|
||||
"owner": "เจ้าของ",
|
||||
"path": "เส้นทาง",
|
||||
"playerMustBePaused": "ต้องหยุดการเล่นก่อน",
|
||||
"preview": "แสดงตัวอย่าง",
|
||||
"previousSong": "$t(entity.track, {\"count\": 1}) ก่อนหน้า",
|
||||
"private": "ส่วนตัว",
|
||||
"public": "สาธารณะ",
|
||||
"quit": "ออก",
|
||||
"random": "สุ่ม",
|
||||
"rating": "คะแนน",
|
||||
"retry": "ลองใหม่",
|
||||
"recordLabel": "ค่ายเพลง",
|
||||
"releaseType": "ประเภทการเผยแพร่",
|
||||
"refresh": "รีเฟรชข้อมูล",
|
||||
"reload": "โหลดใหม่",
|
||||
"rename": "เปลี่ยนชื่อ",
|
||||
"reset": "รีเซ็ต",
|
||||
"resetToDefault": "รีเซ็ตเป็นค่าเริ่มต้น",
|
||||
"restartRequired": "จำเป็นต้องเริ่มโปรแกรมใหม่",
|
||||
"right": "ขวา",
|
||||
"sampleRate": "อัตราการสุ่มตัวอย่าง",
|
||||
"save": "บันทึก",
|
||||
"saveAndReplace": "บันทึกและแทนที่",
|
||||
"saveAs": "บันทึกเป็น...",
|
||||
"search": "ค้นหา",
|
||||
"setting_other": "การตั้งค่า",
|
||||
"slower": "ช้าลง",
|
||||
"share": "แบ่งปัน",
|
||||
"size": "ขนาด",
|
||||
"sort": "เรียงลำดับ",
|
||||
"sortOrder": "ลำดับ",
|
||||
"tags": "แท็ก",
|
||||
"title": "ชื่อเรื่อง",
|
||||
"trackNumber": "แทร็ก",
|
||||
"trackGain": "ระดับขยายเสียงแทร็ก",
|
||||
"trackPeak": "ระดับเสียงสูงสุดของแทร็ก",
|
||||
"translation": "การแปล",
|
||||
"unknown": "ไม่ทราบ",
|
||||
"version": "เวอร์ชัน",
|
||||
"year": "ปี",
|
||||
"yes": "ใช่",
|
||||
"explicit": "เนื้อหาชัดแจ้ง",
|
||||
"clean": "เวอร์ชันสะอาด",
|
||||
"gridRows": "แถวของตาราง",
|
||||
"tableColumns": "คอลัมน์ของตาราง",
|
||||
"itemsMore": "เพิ่มอีก {{count}}",
|
||||
"newVersionAvailable": "มีเวอร์ชันใหม่ให้ใช้งาน"
|
||||
},
|
||||
"entity": {
|
||||
"album_other": "อัลบั้ม",
|
||||
"albumArtist_other": "ศิลปินอัลบั้ม",
|
||||
"albumArtistCount_other": "{{count}} ศิลปินอัลบั้ม",
|
||||
"albumWithCount_other": "{{count}} อัลบั้ม",
|
||||
"radioStation_other": "สถานีวิทยุ",
|
||||
"radioStationWithCount_other": "{{count}} สถานีวิทยุ",
|
||||
"artist_other": "ศิลปิน",
|
||||
"artistWithCount_other": "{{count}} ศิลปิน",
|
||||
"favorite_other": "รายการโปรด",
|
||||
"folder_other": "โฟลเดอร์",
|
||||
"folderWithCount_other": "{{count}} โฟลเดอร์",
|
||||
"genre_other": "แนวเพลง",
|
||||
"genreWithCount_other": "{{count}} แนวเพลง",
|
||||
"playlist_other": "เพลย์ลิสต์",
|
||||
"play_other": "{{count}} เล่น",
|
||||
"playlistWithCount_other": "{{count}} เพลย์ลิสต์",
|
||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1})อัจฉริยะ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})",
|
||||
@@ -778,6 +779,8 @@
|
||||
"accentColor": "Акцентний колір",
|
||||
"useThemeAccentColor": "Використовувати акцентний колір теми",
|
||||
"useThemeAccentColor_description": "Використовувати основний колір визначений у обраній темі замість користувацького акцентного коліру",
|
||||
"useThemePrimaryShade": "Використовувати основний відтінок теми"
|
||||
"useThemePrimaryShade": "Використовувати основний відтінок теми",
|
||||
"useThemePrimaryShade_description": "Використовувати основний відтінок, визначений у обраній темі, для основних варіантів кольорів",
|
||||
"primaryShade": "Основний відтінок"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
"createRadioStation": "创建$t(entity.radioStation, {\"count\": 1})",
|
||||
"deleteRadioStation": "删除$t(entity.radioStation, {\"count\": 1})",
|
||||
"openApplicationDirectory": "打开应用程序目录",
|
||||
"goToCurrent": "转到当前项目"
|
||||
"goToCurrent": "转到当前项目",
|
||||
"collapseAllFolders": "折叠所有文件夹",
|
||||
"expandAllFolders": "展开所有文件夹"
|
||||
},
|
||||
"common": {
|
||||
"increase": "增高",
|
||||
@@ -162,7 +164,9 @@
|
||||
"filter_multiple": "多项",
|
||||
"newVersionAvailable": "新版本现已可用",
|
||||
"numberOfResults": "{{numberOfResults}} 结果",
|
||||
"grouping": "分组"
|
||||
"grouping": "分组",
|
||||
"back": "返回",
|
||||
"openFolder": "打开文件夹"
|
||||
},
|
||||
"entity": {
|
||||
"albumArtist_other": "专辑艺术家",
|
||||
@@ -611,7 +615,9 @@
|
||||
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||
"sidePlayQueueLayout_optionVertical": "垂直",
|
||||
"waveformLoadingDelay": "波形加载延迟",
|
||||
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。"
|
||||
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。",
|
||||
"autoDJ_strategy_option_library_random": "随机",
|
||||
"autoDJ_strategy_option_similar": "相似"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "重启服务器使新端口生效",
|
||||
@@ -729,7 +735,8 @@
|
||||
"followCurrentLyric": "跟随当前歌词",
|
||||
"dynamicImageBlur": "图像模糊大小",
|
||||
"dynamicIsImage": "启用背景图像",
|
||||
"lyricOffset": "歌词延迟补偿(毫秒)"
|
||||
"lyricOffset": "歌词延迟补偿(毫秒)",
|
||||
"lyricOpacityNonActive": "静态歌词不透明度"
|
||||
},
|
||||
"lyrics": "歌词",
|
||||
"related": "相关",
|
||||
@@ -937,7 +944,8 @@
|
||||
"input_skipDuplicates": "跳过重复",
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
"create": "创建 $t(entity.playlist, {\"count\": 1}) {{playlist}}",
|
||||
"searchOrCreate": "搜索 $t(entity.playlist, {\"count\": 2}) 或键入以创建一个新的"
|
||||
"searchOrCreate": "搜索 $t(entity.playlist, {\"count\": 2}) 或键入以创建一个新的",
|
||||
"noneAdded": "没有音轨被添加到 $t(entity.playlist, {\"count\": 1}) '{{playlist}}'"
|
||||
},
|
||||
"createPlaylist": {
|
||||
"title": "创建$t(entity.playlist, {\"count\": 1})",
|
||||
@@ -1013,7 +1021,12 @@
|
||||
"input_played_optionUnplayed": "仅未播放的曲目",
|
||||
"input_played_optionPlayed": "仅已播放的曲目",
|
||||
"input_limit": "有多少首歌?",
|
||||
"input_played": "播放筛选器"
|
||||
"input_played": "播放筛选器",
|
||||
"input_kind_albums": "专辑",
|
||||
"input_kind_songs": "歌曲",
|
||||
"input_kind": "随机播放",
|
||||
"input_limit_albums": "有多少专辑?",
|
||||
"input_limit_songs": "有多少歌曲?"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "电台更新成功"
|
||||
@@ -1175,7 +1188,8 @@
|
||||
"startsWith": "以…开头",
|
||||
"inTheRangeDate": "在(日期)范围内",
|
||||
"notInPlaylist": "不在…中",
|
||||
"notInTheLast": "不在最后"
|
||||
"notInTheLast": "不在最后",
|
||||
"isMissing": "丢失的项目"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "分",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -42,13 +42,13 @@ export const convertFurigana = async (text: string): Promise<string> => {
|
||||
export const convertRomaji = async (text: string): Promise<string> => {
|
||||
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
|
||||
|
||||
if (!KuroshiroClass.Util.hasKana(text)) return text;
|
||||
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 text;
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
@@ -38,13 +38,10 @@ export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabl
|
||||
if (typeof lyrics === 'string') {
|
||||
return await lyricsApi.convertRomaji(lyrics);
|
||||
} else if (Array.isArray(lyrics)) {
|
||||
const text = lyrics.map(([, line]) => line).join('\n');
|
||||
const converted = await lyricsApi.convertRomaji(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.convertRomaji(line)]),
|
||||
);
|
||||
return converted as SynchronizedLyricsArray;
|
||||
}
|
||||
return lyrics;
|
||||
},
|
||||
|
||||
@@ -95,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;
|
||||
@@ -178,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) {
|
||||
@@ -287,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;
|
||||
}
|
||||
|
||||
@@ -309,6 +315,10 @@ export const SynchronizedLyrics = ({
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (programmaticScrollTimeoutRef.current) {
|
||||
clearTimeout(programmaticScrollTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -212,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('');
|
||||
|
||||
@@ -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)),
|
||||
@@ -678,6 +679,7 @@ const WindowSettingsSchema = z.object({
|
||||
startMinimized: z.boolean(),
|
||||
tray: z.boolean(),
|
||||
windowBarStyle: z.nativeEnum(Platform),
|
||||
windowBarTrackinfo: z.boolean(),
|
||||
});
|
||||
|
||||
const QueryValueInputTypeSchema = z.enum([
|
||||
@@ -1165,6 +1167,7 @@ const initialState: SettingsState = {
|
||||
accent: 'rgb(53, 116, 252)',
|
||||
albumBackground: false,
|
||||
albumBackgroundBlur: 3,
|
||||
albumGroupImageSize: 0,
|
||||
artistBackground: true,
|
||||
artistBackgroundBlur: 3,
|
||||
artistItems,
|
||||
@@ -2014,6 +2017,7 @@ const initialState: SettingsState = {
|
||||
startMinimized: false,
|
||||
tray: true,
|
||||
windowBarStyle: platformDefaultWindowBarStyle,
|
||||
windowBarTrackinfo: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2560,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);
|
||||
@@ -2640,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 = () =>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
export function shuffle<T>(array: T[]): T[] {
|
||||
// Create a copy of the array to avoid mutating the original
|
||||
const shuffled = [...array];
|
||||
|
||||
// Loop through the array from the last element to the first
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
// Generate a random index from 0 to i
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
// Swap elements at positions i and j
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
|
||||
return shuffled;
|
||||
return shuffleInPlace(array.slice());
|
||||
}
|
||||
|
||||
export function shuffleInPlace<T>(array: T[]): T[] {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const j = Math.floor(cryptoRandom() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
const randomBuffer = new Uint32Array(1);
|
||||
|
||||
/**
|
||||
* Returns a cryptographically secure random float in [0, 1),
|
||||
* matching the contract of Math.random().
|
||||
*/
|
||||
function cryptoRandom(): number {
|
||||
crypto.getRandomValues(randomBuffer);
|
||||
return randomBuffer[0] / 0x100000000;
|
||||
}
|
||||
|
||||
@@ -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