mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-02 10:23:27 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adc9e30266 | |||
| fcd4c27fb8 | |||
| c4ef6f3799 | |||
| 073b482fed | |||
| 74939c6417 | |||
| 27a62a2a02 | |||
| ffe59b2c78 | |||
| 4226da94ec | |||
| 6e634972c9 | |||
| 984b85e0e9 | |||
| 9bb38e1df3 | |||
| 4ecd8271a2 | |||
| ce7a319d2f | |||
| 1616021451 | |||
| e2a1d813a9 |
@@ -43,7 +43,13 @@ These variables override app settings **on first run** when no persisted setting
|
|||||||
| `general.showVisualizerInSidebar` | `true` | `FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR` | `true` / `false` — Show visualizer in sidebar. |
|
| `general.showVisualizerInSidebar` | `true` | `FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR` | `true` / `false` — Show visualizer in sidebar. |
|
||||||
| `general.sidebarCollapsedNavigation` | `true` | `FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION` | `true` / `false` — Start with collapsed sidebar nav. |
|
| `general.sidebarCollapsedNavigation` | `true` | `FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION` | `true` / `false` — Start with collapsed sidebar nav. |
|
||||||
| `general.sidebarCollapseShared` | `false` | `FS_GENERAL_SIDEBAR_COLLAPSE_SHARED` | `true` / `false` — Share sidebar collapse state. |
|
| `general.sidebarCollapseShared` | `false` | `FS_GENERAL_SIDEBAR_COLLAPSE_SHARED` | `true` / `false` — Share sidebar collapse state. |
|
||||||
|
| `general.sidebarPlaylistFolders` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS` | `true` / `false` — Group playlists into folders by name separator. |
|
||||||
|
| `general.sidebarPlaylistFolderSeparator` | `/` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR` | Character or string that separates folder levels in a playlist name. Empty = use default. |
|
||||||
|
| `general.sidebarPlaylistFolderTreeIndent` | `16` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT` | Pixels each tree level is indented (0–64). |
|
||||||
|
| `general.sidebarPlaylistFolderTreeLineColor` | *(empty)* | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR` | CSS color for tree connecting lines. Empty = theme default. |
|
||||||
|
| `general.sidebarPlaylistFolderView` | `tree` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW` | `single` / `tree` / `navigation` — How folders are displayed in the sidebar. |
|
||||||
| `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. |
|
| `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. |
|
||||||
|
| `general.sidebarPlaylistMode` | `expanded` | `FS_GENERAL_SIDEBAR_PLAYLIST_MODE` | `compact` / `expanded` — Sidebar playlist row layout. |
|
||||||
| `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. |
|
| `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. |
|
||||||
| `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. |
|
| `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. |
|
||||||
| `general.sideQueueLayout` | `horizontal` | `FS_GENERAL_SIDE_QUEUE_LAYOUT` | `horizontal` / `vertical` — Attached side queue layout orientation. |
|
| `general.sideQueueLayout` | `horizontal` | `FS_GENERAL_SIDE_QUEUE_LAYOUT` | `horizontal` / `vertical` — Attached side queue layout orientation. |
|
||||||
@@ -66,6 +72,9 @@ These variables override app settings **on first run** when no persisted setting
|
|||||||
| `playback.scrobble.scrobbleAtDuration` | `240` | `FS_PLAYBACK_SCROBBLE_AT_DURATION` | Seconds of playback before scrobble. |
|
| `playback.scrobble.scrobbleAtDuration` | `240` | `FS_PLAYBACK_SCROBBLE_AT_DURATION` | Seconds of playback before scrobble. |
|
||||||
| `playback.scrobble.scrobbleAtPercentage` | `75` | `FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE` | Percentage of track before scrobble. |
|
| `playback.scrobble.scrobbleAtPercentage` | `75` | `FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE` | Percentage of track before scrobble. |
|
||||||
| `playback.transcode.enabled` | `false` | `FS_PLAYBACK_TRANSCODE_ENABLED` | `true` / `false` — Enable transcoding. |
|
| `playback.transcode.enabled` | `false` | `FS_PLAYBACK_TRANSCODE_ENABLED` | `true` / `false` — Enable transcoding. |
|
||||||
|
| `playback.transcode.format` | *(unset)* | `FS_PLAYBACK_TRANSCODE_FORMAT` | Transcode format string (codec/container), e.g. server-specific value. Empty = use default. |
|
||||||
|
| `playback.transcode.bitrate` | *(unset)* | `FS_PLAYBACK_TRANSCODE_BITRATE` | Transcode bitrate (number, kbps or as defined by server). |
|
||||||
|
| `playback.filters` | `[]` | `FS_PLAYBACK_FILTERS` | JSON array of player filters: each object needs `id`, `field`, `operator`, `value`; optional `isEnabled`. Invalid JSON or shape is ignored. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ window.FS_GENERAL_SHOW_RATINGS = "${FS_GENERAL_SHOW_RATINGS}";
|
|||||||
window.FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR = "${FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR}";
|
window.FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR = "${FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR}";
|
||||||
window.FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION = "${FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION}";
|
window.FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION = "${FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION}";
|
||||||
window.FS_GENERAL_SIDEBAR_COLLAPSE_SHARED = "${FS_GENERAL_SIDEBAR_COLLAPSE_SHARED}";
|
window.FS_GENERAL_SIDEBAR_COLLAPSE_SHARED = "${FS_GENERAL_SIDEBAR_COLLAPSE_SHARED}";
|
||||||
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS = "${FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS}";
|
||||||
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR = "${FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR}";
|
||||||
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT = "${FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT}";
|
||||||
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR = "${FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR}";
|
||||||
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW = "${FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW}";
|
||||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_LIST = "${FS_GENERAL_SIDEBAR_PLAYLIST_LIST}";
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_LIST = "${FS_GENERAL_SIDEBAR_PLAYLIST_LIST}";
|
||||||
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_MODE = "${FS_GENERAL_SIDEBAR_PLAYLIST_MODE}";
|
||||||
window.FS_GENERAL_SIDEBAR_PLAYLIST_SORTING = "${FS_GENERAL_SIDEBAR_PLAYLIST_SORTING}";
|
window.FS_GENERAL_SIDEBAR_PLAYLIST_SORTING = "${FS_GENERAL_SIDEBAR_PLAYLIST_SORTING}";
|
||||||
window.FS_GENERAL_SIDE_QUEUE_TYPE = "${FS_GENERAL_SIDE_QUEUE_TYPE}";
|
window.FS_GENERAL_SIDE_QUEUE_TYPE = "${FS_GENERAL_SIDE_QUEUE_TYPE}";
|
||||||
window.FS_GENERAL_SIDE_QUEUE_LAYOUT = "${FS_GENERAL_SIDE_QUEUE_LAYOUT}";
|
window.FS_GENERAL_SIDE_QUEUE_LAYOUT = "${FS_GENERAL_SIDE_QUEUE_LAYOUT}";
|
||||||
@@ -58,6 +64,9 @@ window.FS_PLAYBACK_SCROBBLE_NOTIFY = "${FS_PLAYBACK_SCROBBLE_NOTIFY}";
|
|||||||
window.FS_PLAYBACK_SCROBBLE_AT_DURATION = "${FS_PLAYBACK_SCROBBLE_AT_DURATION}";
|
window.FS_PLAYBACK_SCROBBLE_AT_DURATION = "${FS_PLAYBACK_SCROBBLE_AT_DURATION}";
|
||||||
window.FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE = "${FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE}";
|
window.FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE = "${FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE}";
|
||||||
window.FS_PLAYBACK_TRANSCODE_ENABLED = "${FS_PLAYBACK_TRANSCODE_ENABLED}";
|
window.FS_PLAYBACK_TRANSCODE_ENABLED = "${FS_PLAYBACK_TRANSCODE_ENABLED}";
|
||||||
|
window.FS_PLAYBACK_TRANSCODE_FORMAT = "${FS_PLAYBACK_TRANSCODE_FORMAT}";
|
||||||
|
window.FS_PLAYBACK_TRANSCODE_BITRATE = "${FS_PLAYBACK_TRANSCODE_BITRATE}";
|
||||||
|
window.FS_PLAYBACK_FILTERS = "${FS_PLAYBACK_FILTERS}";
|
||||||
|
|
||||||
window.FS_DISCORD_ENABLED = "${FS_DISCORD_ENABLED}";
|
window.FS_DISCORD_ENABLED = "${FS_DISCORD_ENABLED}";
|
||||||
window.FS_DISCORD_CLIENT_ID = "${FS_DISCORD_CLIENT_ID}";
|
window.FS_DISCORD_CLIENT_ID = "${FS_DISCORD_CLIENT_ID}";
|
||||||
|
|||||||
+11
-11
@@ -344,30 +344,30 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "Àlbum",
|
"album_one": "Àlbum",
|
||||||
"album_many": "àlbums",
|
"album_many": "Àlbums",
|
||||||
"album_other": "Àlbums",
|
"album_other": "Àlbums",
|
||||||
"albumWithCount_one": "{{count}} àlbum",
|
"albumWithCount_one": "{{count}} àlbum",
|
||||||
"albumWithCount_many": "{{count}} àlbums",
|
"albumWithCount_many": "{{count}} àlbums",
|
||||||
"albumWithCount_other": "{{count}} àlbums",
|
"albumWithCount_other": "{{count}} àlbums",
|
||||||
"albumArtist_one": "Artista de l'àlbum",
|
"albumArtist_one": "Artista de l'àlbum",
|
||||||
"albumArtist_many": "artistes de l'àlbum",
|
"albumArtist_many": "Artistes de l'àlbum",
|
||||||
"albumArtist_other": "Artistes de l'àlbum",
|
"albumArtist_other": "Artistes de l'àlbum",
|
||||||
"albumArtistCount_one": "{{count}} artista de l'àlbum",
|
"albumArtistCount_one": "{{count}} artista de l'àlbum",
|
||||||
"albumArtistCount_many": "{{count}} artistes de l'àlbum",
|
"albumArtistCount_many": "{{count}} artistes de l'àlbum",
|
||||||
"albumArtistCount_other": "{{count}} artistes de l'àlbum",
|
"albumArtistCount_other": "{{count}} artistes de l'àlbum",
|
||||||
"artist_one": "Artista",
|
"artist_one": "Artista",
|
||||||
"artist_many": "artistes",
|
"artist_many": "Artistes",
|
||||||
"artist_other": "Artistes",
|
"artist_other": "Artistes",
|
||||||
"artistWithCount_one": "{{count}} artista",
|
"artistWithCount_one": "{{count}} artista",
|
||||||
"artistWithCount_many": "{{count}} artistes",
|
"artistWithCount_many": "{{count}} artistes",
|
||||||
"artistWithCount_other": "{{count}} artistes",
|
"artistWithCount_other": "{{count}} artistes",
|
||||||
"playlist_one": "Llista de reproducció",
|
"playlist_one": "Llista de reproducció",
|
||||||
"playlist_many": "llistes de reproducció",
|
"playlist_many": "Llistes de reproducció",
|
||||||
"playlist_other": "Llistes de reproducció",
|
"playlist_other": "Llistes de reproducció",
|
||||||
"playlistWithCount_one": "{{count}} llista de reproducció",
|
"playlistWithCount_one": "{{count}} llista de reproducció",
|
||||||
"playlistWithCount_many": "{{count}} llistes de reproducció",
|
"playlistWithCount_many": "{{count}} llistes de reproducció",
|
||||||
"playlistWithCount_other": "{{count}} llistes de reproducció",
|
"playlistWithCount_other": "{{count}} llistes de reproducció",
|
||||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) intel·ligent",
|
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Intel·ligent",
|
||||||
"play_one": "{{count}} reproducció",
|
"play_one": "{{count}} reproducció",
|
||||||
"play_many": "{{count}} reproduccions",
|
"play_many": "{{count}} reproduccions",
|
||||||
"play_other": "{{count}} reproduccions",
|
"play_other": "{{count}} reproduccions",
|
||||||
@@ -378,25 +378,25 @@
|
|||||||
"genreWithCount_many": "{{count}} gèneres",
|
"genreWithCount_many": "{{count}} gèneres",
|
||||||
"genreWithCount_other": "{{count}} gèneres",
|
"genreWithCount_other": "{{count}} gèneres",
|
||||||
"track_one": "Pista",
|
"track_one": "Pista",
|
||||||
"track_many": "pistes",
|
"track_many": "Pistes",
|
||||||
"track_other": "Pistes",
|
"track_other": "Pistes",
|
||||||
"trackWithCount_one": "{{count}} pista",
|
"trackWithCount_one": "{{count}} pista",
|
||||||
"trackWithCount_many": "{{count}} pistes",
|
"trackWithCount_many": "{{count}} pistes",
|
||||||
"trackWithCount_other": "{{count}} pistes",
|
"trackWithCount_other": "{{count}} pistes",
|
||||||
"folder_one": "Carpeta",
|
"folder_one": "Carpeta",
|
||||||
"folder_many": "carpetes",
|
"folder_many": "Carpetes",
|
||||||
"folder_other": "Carpetes",
|
"folder_other": "Carpetes",
|
||||||
"genre_one": "Gènere",
|
"genre_one": "Gènere",
|
||||||
"genre_many": "gèneres",
|
"genre_many": "Gèneres",
|
||||||
"genre_other": "Gèneres",
|
"genre_other": "Gèneres",
|
||||||
"song_one": "Cançó",
|
"song_one": "Cançó",
|
||||||
"song_many": "cançons",
|
"song_many": "Cançons",
|
||||||
"song_other": "Cançons",
|
"song_other": "Cançons",
|
||||||
"favorite_one": "Preferit",
|
"favorite_one": "Preferit",
|
||||||
"favorite_many": "preferits",
|
"favorite_many": "Preferits",
|
||||||
"favorite_other": "Preferits",
|
"favorite_other": "Preferits",
|
||||||
"radioStation_one": "Emissora de ràdio",
|
"radioStation_one": "Emissora de ràdio",
|
||||||
"radioStation_many": "emissores de ràdio",
|
"radioStation_many": "Emissores de ràdio",
|
||||||
"radioStation_other": "Emissores de ràdio",
|
"radioStation_other": "Emissores de ràdio",
|
||||||
"radioStationWithCount_one": "{{count}} emissora de ràdio",
|
"radioStationWithCount_one": "{{count}} emissora de ràdio",
|
||||||
"radioStationWithCount_many": "{{count}} emissores de ràdio",
|
"radioStationWithCount_many": "{{count}} emissores de ràdio",
|
||||||
|
|||||||
+20
-19
@@ -48,7 +48,8 @@
|
|||||||
"sleepTimer_timeRemaining": "Zbývá {{time}}",
|
"sleepTimer_timeRemaining": "Zbývá {{time}}",
|
||||||
"sleepTimer_setCustom": "Nastavit časovač",
|
"sleepTimer_setCustom": "Nastavit časovač",
|
||||||
"sleepTimer_cancel": "Zrušit časovač",
|
"sleepTimer_cancel": "Zrušit časovač",
|
||||||
"albumRadio": "Rádio alba"
|
"albumRadio": "Rádio alba",
|
||||||
|
"scrobbleForceSubmit": "Vynutit scrobble"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "Vyberte způsob prolnutí u přehrávače zvuku",
|
"crossfadeStyle_description": "Vyberte způsob prolnutí u přehrávače zvuku",
|
||||||
@@ -246,7 +247,7 @@
|
|||||||
"albumBackground_description": "Přidá obrázek alba na pozadí pro stránky alba obsahující obrázky alba",
|
"albumBackground_description": "Přidá obrázek alba na pozadí pro stránky alba obsahující obrázky alba",
|
||||||
"albumBackgroundBlur": "Velikost rozostření obrázku alba na pozadí",
|
"albumBackgroundBlur": "Velikost rozostření obrázku alba na pozadí",
|
||||||
"albumBackgroundBlur_description": "Upraví množství rozostření použité na obrázek alba na pozadí",
|
"albumBackgroundBlur_description": "Upraví množství rozostření použité na obrázek alba na pozadí",
|
||||||
"playerbarOpenDrawer": "Lišta přehrávače jako přEPínač celé obrazovky",
|
"playerbarOpenDrawer": "Lišta přehrávače jako přepínač celé obrazovky",
|
||||||
"playerbarOpenDrawer_description": "Umožňuje kliknutí na lištu přehrávače pro otevření celoobrazovkového přehrávače",
|
"playerbarOpenDrawer_description": "Umožňuje kliknutí na lištu přehrávače pro otevření celoobrazovkového přehrávače",
|
||||||
"artistConfiguration": "Nastavení stránky umělce alba",
|
"artistConfiguration": "Nastavení stránky umělce alba",
|
||||||
"artistConfiguration_description": "Nastavit, které položky na stránce umělce alba budou zobrazeny a v jakém pořadí",
|
"artistConfiguration_description": "Nastavit, které položky na stránce umělce alba budou zobrazeny a v jakém pořadí",
|
||||||
@@ -689,7 +690,7 @@
|
|||||||
"rating": "Hodnocení",
|
"rating": "Hodnocení",
|
||||||
"favorite": "Oblíbené",
|
"favorite": "Oblíbené",
|
||||||
"playCount": "Přehrání",
|
"playCount": "Přehrání",
|
||||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
"albumCount": "Alba",
|
||||||
"releaseYear": "Rok",
|
"releaseYear": "Rok",
|
||||||
"lastPlayed": "Naposledy přehráno",
|
"lastPlayed": "Naposledy přehráno",
|
||||||
"biography": "Biografie",
|
"biography": "Biografie",
|
||||||
@@ -697,20 +698,20 @@
|
|||||||
"bitrate": "Datový tok",
|
"bitrate": "Datový tok",
|
||||||
"title": "Název",
|
"title": "Název",
|
||||||
"bpm": "BPM",
|
"bpm": "BPM",
|
||||||
"dateAdded": "Datum přIDání",
|
"dateAdded": "Datum přidání",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
"artist": "Umělec",
|
||||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
"songCount": "Skladby",
|
||||||
"trackNumber": "Skladba",
|
"trackNumber": "Skladba",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
"genre": "Žánr",
|
||||||
"albumArtist": "Umělec alba",
|
"albumArtist": "Umělec alba",
|
||||||
"path": "Cesta",
|
"path": "Cesta",
|
||||||
"discNumber": "Disk",
|
"discNumber": "Disk",
|
||||||
"channels": "$t(common.channel, {\"count\": 2})",
|
"channels": "Kanály",
|
||||||
"size": "$t(common.size)",
|
"size": "Velikost",
|
||||||
"codec": "$t(common.codec)",
|
"codec": "Kodek",
|
||||||
"owner": "Majitel",
|
"owner": "Majitel",
|
||||||
"bitDepth": "$t(common.bitDepth)",
|
"bitDepth": "Bitová hloubka",
|
||||||
"sampleRate": "$t(common.sampleRate)"
|
"sampleRate": "Vzorkovací frekvence"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -762,10 +763,10 @@
|
|||||||
"search": "Hledat",
|
"search": "Hledat",
|
||||||
"bitrate": "Datový tok",
|
"bitrate": "Datový tok",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||||
"recentlyAdded": "Nedávno přIDáno",
|
"recentlyAdded": "Nedávno přidáno",
|
||||||
"note": "Poznámka",
|
"note": "Poznámka",
|
||||||
"name": "Název",
|
"name": "Název",
|
||||||
"dateAdded": "Datum přIDání",
|
"dateAdded": "Datum přidání",
|
||||||
"releaseDate": "Datum vydání",
|
"releaseDate": "Datum vydání",
|
||||||
"albumCount": "Počet $t(entity.album, {\"count\": 2})",
|
"albumCount": "Počet $t(entity.album, {\"count\": 2})",
|
||||||
"communityRating": "Komunitní hodnocení",
|
"communityRating": "Komunitní hodnocení",
|
||||||
@@ -887,7 +888,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"mostPlayed": "Nejpřehrávanější",
|
"mostPlayed": "Nejpřehrávanější",
|
||||||
"newlyAdded": "Nově přIDáno",
|
"newlyAdded": "Nově přidáno",
|
||||||
"title": "$t(common.home)",
|
"title": "$t(common.home)",
|
||||||
"explore": "Procházet z vaší knihovny",
|
"explore": "Procházet z vaší knihovny",
|
||||||
"recentlyPlayed": "Nedávno přehráno",
|
"recentlyPlayed": "Nedávno přehráno",
|
||||||
@@ -1030,7 +1031,7 @@
|
|||||||
"input_password": "Heslo",
|
"input_password": "Heslo",
|
||||||
"input_legacyAuthentication": "Zapnout zastaralé ověřování",
|
"input_legacyAuthentication": "Zapnout zastaralé ověřování",
|
||||||
"input_name": "Název serveru",
|
"input_name": "Název serveru",
|
||||||
"success": "Server úspěšně přIDán",
|
"success": "Server úspěšně přidán",
|
||||||
"input_savePassword": "Uložit heslo",
|
"input_savePassword": "Uložit heslo",
|
||||||
"ignoreSsl": "Ignorovat ssl $t(common.restartRequired)",
|
"ignoreSsl": "Ignorovat ssl $t(common.restartRequired)",
|
||||||
"ignoreCors": "Ignorovat cors $t(common.restartRequired)",
|
"ignoreCors": "Ignorovat cors $t(common.restartRequired)",
|
||||||
@@ -1042,7 +1043,7 @@
|
|||||||
"input_remoteUrlPlaceholder": "Volitelné: veřejná adresa url pro externí funkce"
|
"input_remoteUrlPlaceholder": "Volitelné: veřejná adresa url pro externí funkce"
|
||||||
},
|
},
|
||||||
"addToPlaylist": {
|
"addToPlaylist": {
|
||||||
"success": "PřIDáno $t(entity.trackWithCount, {\"count\": {{message}} }) do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) přidáno do $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} })",
|
||||||
"title": "Přidat do $t(entity.playlist, {\"count\": 1})",
|
"title": "Přidat do $t(entity.playlist, {\"count\": 1})",
|
||||||
"input_skipDuplicates": "Přeskočit duplicity",
|
"input_skipDuplicates": "Přeskočit duplicity",
|
||||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||||
@@ -1139,7 +1140,7 @@
|
|||||||
"folderWithCount_few": "{{count}} složky",
|
"folderWithCount_few": "{{count}} složky",
|
||||||
"folderWithCount_other": "{{count}} složek",
|
"folderWithCount_other": "{{count}} složek",
|
||||||
"albumArtist_one": "Umělec alba",
|
"albumArtist_one": "Umělec alba",
|
||||||
"albumArtist_few": "umělci alb",
|
"albumArtist_few": "Umělci alb",
|
||||||
"albumArtist_other": "Umělci alb",
|
"albumArtist_other": "Umělci alb",
|
||||||
"track_one": "Skladba",
|
"track_one": "Skladba",
|
||||||
"track_few": "skladby",
|
"track_few": "skladby",
|
||||||
@@ -1176,7 +1177,7 @@
|
|||||||
"song_few": "písničky",
|
"song_few": "písničky",
|
||||||
"song_other": "Písní",
|
"song_other": "Písní",
|
||||||
"radioStation_one": "Stanice rádia",
|
"radioStation_one": "Stanice rádia",
|
||||||
"radioStation_few": "stanice rádia",
|
"radioStation_few": "Stanice rádia",
|
||||||
"radioStation_other": "Stanice rádia",
|
"radioStation_other": "Stanice rádia",
|
||||||
"radioStationWithCount_one": "{{count}} stanice rádia",
|
"radioStationWithCount_one": "{{count}} stanice rádia",
|
||||||
"radioStationWithCount_few": "{{count}} stanice rádia",
|
"radioStationWithCount_few": "{{count}} stanice rádia",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"selectRangeOfItems": "Select a range of items",
|
"selectRangeOfItems": "Select a range of items",
|
||||||
"clearQueue": "Clear queue",
|
"clearQueue": "Clear queue",
|
||||||
"goToCurrent": "Go to current item",
|
"goToCurrent": "Go to current item",
|
||||||
|
"collapseAllFolders": "Collapse all folders",
|
||||||
|
"expandAllFolders": "Expand all folders",
|
||||||
"createPlaylist": "Create $t(entity.playlist, {\"count\": 1})",
|
"createPlaylist": "Create $t(entity.playlist, {\"count\": 1})",
|
||||||
"createRadioStation": "Create $t(entity.radioStation, {\"count\": 1})",
|
"createRadioStation": "Create $t(entity.radioStation, {\"count\": 1})",
|
||||||
"deletePlaylist": "Delete $t(entity.playlist, {\"count\": 1})",
|
"deletePlaylist": "Delete $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -56,6 +58,7 @@
|
|||||||
"albumPeak": "Album peak",
|
"albumPeak": "Album peak",
|
||||||
"areYouSure": "Are you sure?",
|
"areYouSure": "Are you sure?",
|
||||||
"ascending": "Ascending",
|
"ascending": "Ascending",
|
||||||
|
"back": "Back",
|
||||||
"backward": "Backward",
|
"backward": "Backward",
|
||||||
"biography": "Biography",
|
"biography": "Biography",
|
||||||
"bitDepth": "Bit depth",
|
"bitDepth": "Bit depth",
|
||||||
@@ -171,14 +174,14 @@
|
|||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "Album",
|
"album_one": "Album",
|
||||||
"album_other": "Albums",
|
"album_other": "Albums",
|
||||||
"albumArtist_one": "Album artist",
|
"albumArtist_one": "Album Artist",
|
||||||
"albumArtist_other": "Album artists",
|
"albumArtist_other": "Album Artists",
|
||||||
"albumArtistCount_one": "{{count}} album artist",
|
"albumArtistCount_one": "{{count}} album artist",
|
||||||
"albumArtistCount_other": "{{count}} album artists",
|
"albumArtistCount_other": "{{count}} album artists",
|
||||||
"albumWithCount_one": "{{count}} album",
|
"albumWithCount_one": "{{count}} album",
|
||||||
"albumWithCount_other": "{{count}} albums",
|
"albumWithCount_other": "{{count}} albums",
|
||||||
"radioStation_one": "Radio station",
|
"radioStation_one": "Radio Station",
|
||||||
"radioStation_other": "Radio stations",
|
"radioStation_other": "Radio Stations",
|
||||||
"radioStationWithCount_one": "{{count}} radio station",
|
"radioStationWithCount_one": "{{count}} radio station",
|
||||||
"radioStationWithCount_other": "{{count}} radio stations",
|
"radioStationWithCount_other": "{{count}} radio stations",
|
||||||
"artist_one": "Artist",
|
"artist_one": "Artist",
|
||||||
@@ -696,7 +699,8 @@
|
|||||||
"sleepTimer_off": "Off",
|
"sleepTimer_off": "Off",
|
||||||
"sleepTimer_timeRemaining": "{{time}} remaining",
|
"sleepTimer_timeRemaining": "{{time}} remaining",
|
||||||
"sleepTimer_setCustom": "Set timer",
|
"sleepTimer_setCustom": "Set timer",
|
||||||
"sleepTimer_cancel": "Cancel timer"
|
"sleepTimer_cancel": "Cancel timer",
|
||||||
|
"scrobbleForceSubmit": "Force scrobble"
|
||||||
},
|
},
|
||||||
"queryBuilder": {
|
"queryBuilder": {
|
||||||
"standardTags": "Standard tags",
|
"standardTags": "Standard tags",
|
||||||
@@ -873,6 +877,7 @@
|
|||||||
"hotkey_listPlayLast": "List play last",
|
"hotkey_listPlayLast": "List play last",
|
||||||
"hotkey_listPlayNext": "List play next",
|
"hotkey_listPlayNext": "List play next",
|
||||||
"hotkey_listPlayNow": "List play now",
|
"hotkey_listPlayNow": "List play now",
|
||||||
|
"hotkey_listShowPlayingSong": "Show playing song in list",
|
||||||
"hotkey_navigateHome": "Navigate to home",
|
"hotkey_navigateHome": "Navigate to home",
|
||||||
"hotkey_playbackNext": "Next track",
|
"hotkey_playbackNext": "Next track",
|
||||||
"hotkey_playbackPause": "Pause",
|
"hotkey_playbackPause": "Pause",
|
||||||
@@ -1043,8 +1048,25 @@
|
|||||||
"sidebarConfiguration": "Sidebar configuration",
|
"sidebarConfiguration": "Sidebar configuration",
|
||||||
"playerItemConfiguration_description": "Configure what items are shown, and in what order, on the fullscreen player",
|
"playerItemConfiguration_description": "Configure what items are shown, and in what order, on the fullscreen player",
|
||||||
"playerItemConfiguration": "Player item configuration",
|
"playerItemConfiguration": "Player item configuration",
|
||||||
|
"sidebarPlaylistFolders_description": "Create a folder view for playlists that include the configured separator in the name",
|
||||||
|
"sidebarPlaylistFolders": "Enable folders",
|
||||||
|
"sidebarPlaylistFolderSeparator_description": "Character (or string) that separates folder levels in a playlist name",
|
||||||
|
"sidebarPlaylistFolderSeparator": "Folder separator",
|
||||||
|
"sidebarPlaylistFolderView_description": "How folders are displayed in the sidebar",
|
||||||
|
"sidebarPlaylistFolderView": "Folder view",
|
||||||
|
"sidebarPlaylistFolderView_optionSingle": "Single folder",
|
||||||
|
"sidebarPlaylistFolderView_optionTree": "Tree view",
|
||||||
|
"sidebarPlaylistFolderView_optionNavigation": "Navigation view",
|
||||||
|
"sidebarPlaylistFolderTreeIndent_description": "Pixels each tree level is indented",
|
||||||
|
"sidebarPlaylistFolderTreeIndent": "Tree indent",
|
||||||
|
"sidebarPlaylistFolderTreeLineColor_description": "Color of the connecting tree lines (leave empty for theme default)",
|
||||||
|
"sidebarPlaylistFolderTreeLineColor": "Tree line color",
|
||||||
"sidebarPlaylistList_description": "Show or hide the playlist list in the sidebar",
|
"sidebarPlaylistList_description": "Show or hide the playlist list in the sidebar",
|
||||||
"sidebarPlaylistList": "Sidebar playlist list",
|
"sidebarPlaylistList": "Sidebar playlist list",
|
||||||
|
"sidebarPlaylistMode_description": "How each playlist is displayed in the sidebar list",
|
||||||
|
"sidebarPlaylistMode": "Sidebar playlist mode",
|
||||||
|
"sidebarPlaylistMode_optionCompact": "Compact",
|
||||||
|
"sidebarPlaylistMode_optionExpanded": "Expanded",
|
||||||
"sidebarPlaylistSorting_description": "Allows manual playlist sorting in the sidebar using drag and drop instead of the default server order",
|
"sidebarPlaylistSorting_description": "Allows manual playlist sorting in the sidebar using drag and drop instead of the default server order",
|
||||||
"sidebarPlaylistSorting": "Sidebar playlist sorting",
|
"sidebarPlaylistSorting": "Sidebar playlist sorting",
|
||||||
"sidebarPlaylistListFilterRegex_description": "Hide playlists in the sidebar that match this regular expression",
|
"sidebarPlaylistListFilterRegex_description": "Hide playlists in the sidebar that match this regular expression",
|
||||||
|
|||||||
+39
-38
@@ -48,7 +48,8 @@
|
|||||||
"sleepTimer_off": "Apagado",
|
"sleepTimer_off": "Apagado",
|
||||||
"sleepTimer_endOfSong": "Fin de la canción actual",
|
"sleepTimer_endOfSong": "Fin de la canción actual",
|
||||||
"sleepTimer": "Temporizador de apagado",
|
"sleepTimer": "Temporizador de apagado",
|
||||||
"albumRadio": "Radio del álbum"
|
"albumRadio": "Radio del álbum",
|
||||||
|
"scrobbleForceSubmit": "Forzar scrobble"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "Selecciona el estilo de crossfade a usar por el reproductor de audio",
|
"crossfadeStyle_description": "Selecciona el estilo de crossfade a usar por el reproductor de audio",
|
||||||
@@ -263,7 +264,7 @@
|
|||||||
"lastfmApiKey": "Clave API para {{lastfm}}",
|
"lastfmApiKey": "Clave API para {{lastfm}}",
|
||||||
"discordServeImage": "Servir imágenes de {{discord}} desde el servidor",
|
"discordServeImage": "Servir imágenes de {{discord}} desde el servidor",
|
||||||
"discordServeImage_description": "Comparte el arte de la portada para el estado de actividad de {{discord}} desde el propio servidor, solo disponible para Jellyfin y Navidrome. {{discord}} usa un bot para obtener las imágenes, por lo que tu servidor debe ser alcanzable desde el internet público",
|
"discordServeImage_description": "Comparte el arte de la portada para el estado de actividad de {{discord}} desde el propio servidor, solo disponible para Jellyfin y Navidrome. {{discord}} usa un bot para obtener las imágenes, por lo que tu servidor debe ser alcanzable desde el internet público",
|
||||||
"lastfm": "Mostrar enlaces de last.fm",
|
"lastfm": "Mostrar enlaces de Last.fm",
|
||||||
"lastfm_description": "Muestra enlaces a Last.fm en las páginas de artistas/álbumes",
|
"lastfm_description": "Muestra enlaces a Last.fm en las páginas de artistas/álbumes",
|
||||||
"musicbrainz": "Mostrar enlaces de MusicBrainz",
|
"musicbrainz": "Mostrar enlaces de MusicBrainz",
|
||||||
"musicbrainz_description": "Muestra enlaces a MusicBrainz en las páginas de artistas/álbumes, donde exista MusicBrainz ID",
|
"musicbrainz_description": "Muestra enlaces a MusicBrainz en las páginas de artistas/álbumes, donde exista MusicBrainz ID",
|
||||||
@@ -342,7 +343,7 @@
|
|||||||
"playerFilters": "Filtrar las canciones de la cola",
|
"playerFilters": "Filtrar las canciones de la cola",
|
||||||
"playerFilters_description": "Omite la adición de canciones a la cola basado en los siguientes criterios",
|
"playerFilters_description": "Omite la adición de canciones a la cola basado en los siguientes criterios",
|
||||||
"playerbarSlider_description": "La forma de onda no es recomendable en una conexión a Internet lenta o medida",
|
"playerbarSlider_description": "La forma de onda no es recomendable en una conexión a Internet lenta o medida",
|
||||||
"autoDJ": "DJ automático",
|
"autoDJ": "DJ Automático",
|
||||||
"autoDJ_description": "Añade canciones similares a las de la cola automáticamente",
|
"autoDJ_description": "Añade canciones similares a las de la cola automáticamente",
|
||||||
"autoDJ_itemCount": "Recuento de elementos",
|
"autoDJ_itemCount": "Recuento de elementos",
|
||||||
"autoDJ_itemCount_description": "El número de elementos que se ha intentado añadir a la cola cuando DJ automático está activado",
|
"autoDJ_itemCount_description": "El número de elementos que se ha intentado añadir a la cola cuando DJ automático está activado",
|
||||||
@@ -533,7 +534,7 @@
|
|||||||
"comingSoon": "Próximamente…",
|
"comingSoon": "Próximamente…",
|
||||||
"reset": "Restablecer",
|
"reset": "Restablecer",
|
||||||
"disable": "Desactivar",
|
"disable": "Desactivar",
|
||||||
"sortOrder": "Ordenar",
|
"sortOrder": "Orden",
|
||||||
"none": "Ninguno",
|
"none": "Ninguno",
|
||||||
"menu": "Menú",
|
"menu": "Menú",
|
||||||
"restartRequired": "Reinicio requerido",
|
"restartRequired": "Reinicio requerido",
|
||||||
@@ -700,7 +701,7 @@
|
|||||||
"artists": "$t(entity.artist, {\"count\": 2})",
|
"artists": "$t(entity.artist, {\"count\": 2})",
|
||||||
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
"albumArtists": "$t(entity.albumArtist, {\"count\": 2})",
|
||||||
"shared": "Compartido $t(entity.playlist, {\"count\": 2})",
|
"shared": "Compartido $t(entity.playlist, {\"count\": 2})",
|
||||||
"myLibrary": "Mi biblioteca",
|
"myLibrary": "Mi Biblioteca",
|
||||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||||
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
||||||
"collections": "Colecciones"
|
"collections": "Colecciones"
|
||||||
@@ -761,7 +762,7 @@
|
|||||||
"genres": "$t(entity.genre, {\"count\": 2})"
|
"genres": "$t(entity.genre, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"fullscreenPlayer": {
|
"fullscreenPlayer": {
|
||||||
"upNext": "Siguiente",
|
"upNext": "A continuación",
|
||||||
"config": {
|
"config": {
|
||||||
"dynamicBackground": "Fondo dinámico",
|
"dynamicBackground": "Fondo dinámico",
|
||||||
"synchronized": "Sincronizado",
|
"synchronized": "Sincronizado",
|
||||||
@@ -800,7 +801,7 @@
|
|||||||
"updates": "Actualización",
|
"updates": "Actualización",
|
||||||
"cache": "Caché",
|
"cache": "Caché",
|
||||||
"application": "Aplicación",
|
"application": "Aplicación",
|
||||||
"queryBuilder": "Generador de consultas",
|
"queryBuilder": "Generador de Consultas",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"controls": "Controles",
|
"controls": "Controles",
|
||||||
"remote": "Remoto",
|
"remote": "Remoto",
|
||||||
@@ -811,9 +812,9 @@
|
|||||||
"transcoding": "Transcodificación",
|
"transcoding": "Transcodificación",
|
||||||
"discord": "Discord",
|
"discord": "Discord",
|
||||||
"sidebar": "Barra lateral",
|
"sidebar": "Barra lateral",
|
||||||
"playerFilters": "Filtros del reproductor",
|
"playerFilters": "Filtros del Reproductor",
|
||||||
"logger": "Registrador",
|
"logger": "Registrador",
|
||||||
"lyricsDisplay": "Mostrar letras"
|
"lyricsDisplay": "Mostrar Letras"
|
||||||
},
|
},
|
||||||
"albumArtistList": {
|
"albumArtistList": {
|
||||||
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
"title": "$t(entity.albumArtist, {\"count\": 2})"
|
||||||
@@ -926,10 +927,10 @@
|
|||||||
"ignoreSsl": "Ignorar SSL ($t(common.restartRequired))",
|
"ignoreSsl": "Ignorar SSL ($t(common.restartRequired))",
|
||||||
"ignoreCors": "Ignorar CORS ($t(common.restartRequired))",
|
"ignoreCors": "Ignorar CORS ($t(common.restartRequired))",
|
||||||
"error_savePassword": "Un error ocurrió cuando se intentó guardar la contraseña",
|
"error_savePassword": "Un error ocurrió cuando se intentó guardar la contraseña",
|
||||||
"input_preferInstantMix": "Preferir mix instantáneo",
|
"input_preferInstantMix": "Preferir Mix Instantáneo",
|
||||||
"input_preferInstantMixDescription": "Usa solo el mix instantáneo para obtener canciones similares. Útil si tienes complementos que modifican este comportamiento",
|
"input_preferInstantMixDescription": "Usa solo el mix instantáneo para obtener canciones similares. Útil si tienes complementos que modifican este comportamiento",
|
||||||
"input_remoteUrl": "URL pública",
|
"input_remoteUrl": "URL Pública",
|
||||||
"input_preferRemoteUrl": "Preferir URL pública",
|
"input_preferRemoteUrl": "Preferir URL Pública",
|
||||||
"input_remoteUrlPlaceholder": "Opcional: URL pública para características externas"
|
"input_remoteUrlPlaceholder": "Opcional: URL pública para características externas"
|
||||||
},
|
},
|
||||||
"addToPlaylist": {
|
"addToPlaylist": {
|
||||||
@@ -1020,7 +1021,7 @@
|
|||||||
"album": "Álbum",
|
"album": "Álbum",
|
||||||
"favorite": "Favorito",
|
"favorite": "Favorito",
|
||||||
"playCount": "Reproducciones",
|
"playCount": "Reproducciones",
|
||||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
"albumCount": "Álbumes",
|
||||||
"releaseYear": "Año",
|
"releaseYear": "Año",
|
||||||
"lastPlayed": "Última reproducción",
|
"lastPlayed": "Última reproducción",
|
||||||
"biography": "Biografía",
|
"biography": "Biografía",
|
||||||
@@ -1029,19 +1030,19 @@
|
|||||||
"title": "Título",
|
"title": "Título",
|
||||||
"bpm": "BPM",
|
"bpm": "BPM",
|
||||||
"dateAdded": "Fecha de adición",
|
"dateAdded": "Fecha de adición",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
"artist": "Artista",
|
||||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
"songCount": "Pistas",
|
||||||
"trackNumber": "Pista",
|
"trackNumber": "Pista",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
"genre": "Género",
|
||||||
"albumArtist": "Artista del álbum",
|
"albumArtist": "Artista del álbum",
|
||||||
"path": "Ruta",
|
"path": "Ruta",
|
||||||
"discNumber": "Disco",
|
"discNumber": "Disco",
|
||||||
"channels": "$t(common.channel, {\"count\": 2})",
|
"channels": "Canales",
|
||||||
"size": "$t(common.size)",
|
"size": "Tamaño",
|
||||||
"codec": "$t(common.codec)",
|
"codec": "Códecs",
|
||||||
"owner": "Propietario",
|
"owner": "Propietario",
|
||||||
"bitDepth": "$t(common.bitDepth)",
|
"bitDepth": "Profundidad de Bit",
|
||||||
"sampleRate": "$t(common.sampleRate)"
|
"sampleRate": "Frecuencia de Muestreo"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"label": {
|
"label": {
|
||||||
@@ -1080,7 +1081,7 @@
|
|||||||
"sampleRate": "$t(common.sampleRate)",
|
"sampleRate": "$t(common.sampleRate)",
|
||||||
"titleArtist": "$t(common.title) (artista)",
|
"titleArtist": "$t(common.title) (artista)",
|
||||||
"composer": "Compositor",
|
"composer": "Compositor",
|
||||||
"albumGroup": "Grupo del álbum"
|
"albumGroup": "Grupo del Álbum"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"gap": "$t(common.gap)",
|
"gap": "$t(common.gap)",
|
||||||
@@ -1123,27 +1124,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) inteligente",
|
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Inteligente",
|
||||||
"genre_one": "Género",
|
"genre_one": "Género",
|
||||||
"genre_many": "géneros",
|
"genre_many": "Géneros",
|
||||||
"genre_other": "Géneros",
|
"genre_other": "Géneros",
|
||||||
"playlistWithCount_one": "{{count}} lista de reproducción",
|
"playlistWithCount_one": "{{count}} lista de reproducción",
|
||||||
"playlistWithCount_many": "{{count}} listas de reproducción",
|
"playlistWithCount_many": "{{count}} listas de reproducción",
|
||||||
"playlistWithCount_other": "{{count}} listas de reproducción",
|
"playlistWithCount_other": "{{count}} listas de reproducción",
|
||||||
"playlist_one": "Lista de reproducción",
|
"playlist_one": "Lista de reproducción",
|
||||||
"playlist_many": "listas de reproducción",
|
"playlist_many": "Listas de reproducción",
|
||||||
"playlist_other": "Listas de reproducción",
|
"playlist_other": "Listas de reproducción",
|
||||||
"artist_one": "Artista",
|
"artist_one": "Artista",
|
||||||
"artist_many": "artistas",
|
"artist_many": "Artistas",
|
||||||
"artist_other": "Artistas",
|
"artist_other": "Artistas",
|
||||||
"folderWithCount_one": "{{count}} carpeta",
|
"folderWithCount_one": "{{count}} carpeta",
|
||||||
"folderWithCount_many": "{{count}} carpetas",
|
"folderWithCount_many": "{{count}} carpetas",
|
||||||
"folderWithCount_other": "{{count}} carpetas",
|
"folderWithCount_other": "{{count}} carpetas",
|
||||||
"albumArtist_one": "Artista del álbum",
|
"albumArtist_one": "Artista del álbum",
|
||||||
"albumArtist_many": "artistas del álbum",
|
"albumArtist_many": "Artistas del álbum",
|
||||||
"albumArtist_other": "Artistas del álbum",
|
"albumArtist_other": "Artistas del álbum",
|
||||||
"track_one": "Pista",
|
"track_one": "Pista",
|
||||||
"track_many": "pistas",
|
"track_many": "Pistas",
|
||||||
"track_other": "Pistas",
|
"track_other": "Pistas",
|
||||||
"albumArtistCount_one": "{{count}} artista del álbum",
|
"albumArtistCount_one": "{{count}} artista del álbum",
|
||||||
"albumArtistCount_many": "{{count}} artistas del álbum",
|
"albumArtistCount_many": "{{count}} artistas del álbum",
|
||||||
@@ -1152,16 +1153,16 @@
|
|||||||
"albumWithCount_many": "{{count}} álbumes",
|
"albumWithCount_many": "{{count}} álbumes",
|
||||||
"albumWithCount_other": "{{count}} álbumes",
|
"albumWithCount_other": "{{count}} álbumes",
|
||||||
"favorite_one": "Favorito",
|
"favorite_one": "Favorito",
|
||||||
"favorite_many": "favoritos",
|
"favorite_many": "Favoritos",
|
||||||
"favorite_other": "Favoritos",
|
"favorite_other": "Favoritos",
|
||||||
"artistWithCount_one": "{{count}} artista",
|
"artistWithCount_one": "{{count}} artista",
|
||||||
"artistWithCount_many": "{{count}} artistas",
|
"artistWithCount_many": "{{count}} artistas",
|
||||||
"artistWithCount_other": "{{count}} artistas",
|
"artistWithCount_other": "{{count}} artistas",
|
||||||
"folder_one": "Carpeta",
|
"folder_one": "Carpeta",
|
||||||
"folder_many": "carpetas",
|
"folder_many": "Carpetas",
|
||||||
"folder_other": "Carpetas",
|
"folder_other": "Carpetas",
|
||||||
"album_one": "Álbum",
|
"album_one": "Álbum",
|
||||||
"album_many": "álbumes",
|
"album_many": "Álbumes",
|
||||||
"album_other": "Álbumes",
|
"album_other": "Álbumes",
|
||||||
"genreWithCount_one": "{{count}} género",
|
"genreWithCount_one": "{{count}} género",
|
||||||
"genreWithCount_many": "{{count}} géneros",
|
"genreWithCount_many": "{{count}} géneros",
|
||||||
@@ -1173,11 +1174,11 @@
|
|||||||
"play_many": "{{count}} reproducciones",
|
"play_many": "{{count}} reproducciones",
|
||||||
"play_other": "{{count}} reproducciones",
|
"play_other": "{{count}} reproducciones",
|
||||||
"song_one": "Canción",
|
"song_one": "Canción",
|
||||||
"song_many": "canciones",
|
"song_many": "Canciones",
|
||||||
"song_other": "Canciones",
|
"song_other": "Canciones",
|
||||||
"radioStation_one": "Estación de radio",
|
"radioStation_one": "Estación de Radio",
|
||||||
"radioStation_many": "Estaciones de radio",
|
"radioStation_many": "Estaciones de Radio",
|
||||||
"radioStation_other": "Estaciones de radio",
|
"radioStation_other": "Estaciones de Radio",
|
||||||
"radioStationWithCount_one": "{{count}} estación de radio",
|
"radioStationWithCount_one": "{{count}} estación de radio",
|
||||||
"radioStationWithCount_many": "{{count}} estaciones de radio",
|
"radioStationWithCount_many": "{{count}} estaciones de radio",
|
||||||
"radioStationWithCount_other": "{{count}} estaciones de radio"
|
"radioStationWithCount_other": "{{count}} estaciones de radio"
|
||||||
@@ -1197,16 +1198,16 @@
|
|||||||
},
|
},
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"audiobook": "Audiolibro",
|
"audiobook": "Audiolibro",
|
||||||
"audioDrama": "Audio drama",
|
"audioDrama": "Audio Drama",
|
||||||
"compilation": "Compilación",
|
"compilation": "Compilación",
|
||||||
"djMix": "Mezcla del DJ",
|
"djMix": "Mezcla del DJ",
|
||||||
"fieldRecording": "Grabación de campo",
|
"fieldRecording": "Grabación de Campo",
|
||||||
"interview": "Entrevista",
|
"interview": "Entrevista",
|
||||||
"live": "En vivo",
|
"live": "En vivo",
|
||||||
"mixtape": "Recopilatorio",
|
"mixtape": "Recopilatorio",
|
||||||
"remix": "Remix",
|
"remix": "Remix",
|
||||||
"soundtrack": "Banda sonora",
|
"soundtrack": "Banda sonora",
|
||||||
"spokenWord": "Palabra hablada",
|
"spokenWord": "Palabra Hablada",
|
||||||
"demo": "Maqueta"
|
"demo": "Maqueta"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-11
@@ -1011,25 +1011,25 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "Genre",
|
"genre_one": "Genre",
|
||||||
"genre_many": "genres",
|
"genre_many": "Genres",
|
||||||
"genre_other": "Genres",
|
"genre_other": "Genres",
|
||||||
"playlistWithCount_one": "{{count}} liste de lecture",
|
"playlistWithCount_one": "{{count}} liste de lecture",
|
||||||
"playlistWithCount_many": "{{count}} listes de lecture",
|
"playlistWithCount_many": "{{count}} listes de lecture",
|
||||||
"playlistWithCount_other": "{{count}} listes de lecture",
|
"playlistWithCount_other": "{{count}} listes de lecture",
|
||||||
"playlist_one": "Liste de lecture",
|
"playlist_one": "Liste de lecture",
|
||||||
"playlist_many": "listes de lecture",
|
"playlist_many": "Listes de lecture",
|
||||||
"playlist_other": "Listes de lecture",
|
"playlist_other": "Listes de lecture",
|
||||||
"artist_one": "Artiste",
|
"artist_one": "Artiste",
|
||||||
"artist_many": "artistes",
|
"artist_many": "Artistes",
|
||||||
"artist_other": "Artistes",
|
"artist_other": "Artistes",
|
||||||
"folderWithCount_one": "{{count}} dossier",
|
"folderWithCount_one": "{{count}} dossier",
|
||||||
"folderWithCount_many": "{{count}} dossiers",
|
"folderWithCount_many": "{{count}} dossiers",
|
||||||
"folderWithCount_other": "{{count}} dossiers",
|
"folderWithCount_other": "{{count}} dossiers",
|
||||||
"albumArtist_one": "Artiste d'album",
|
"albumArtist_one": "Artiste d'album",
|
||||||
"albumArtist_many": "artistes d'albums",
|
"albumArtist_many": "Artistes d'albums",
|
||||||
"albumArtist_other": "Artistes d'albums",
|
"albumArtist_other": "Artistes d'albums",
|
||||||
"track_one": "Piste",
|
"track_one": "Piste",
|
||||||
"track_many": "pistes",
|
"track_many": "Pistes",
|
||||||
"track_other": "Pistes",
|
"track_other": "Pistes",
|
||||||
"albumArtistCount_one": "{{count}} artiste de l'album",
|
"albumArtistCount_one": "{{count}} artiste de l'album",
|
||||||
"albumArtistCount_many": "{{count}} artistes d'albums",
|
"albumArtistCount_many": "{{count}} artistes d'albums",
|
||||||
@@ -1038,17 +1038,17 @@
|
|||||||
"albumWithCount_many": "{{count}} albums",
|
"albumWithCount_many": "{{count}} albums",
|
||||||
"albumWithCount_other": "{{count}} albums",
|
"albumWithCount_other": "{{count}} albums",
|
||||||
"favorite_one": "Favori",
|
"favorite_one": "Favori",
|
||||||
"favorite_many": "favoris",
|
"favorite_many": "Favoris",
|
||||||
"favorite_other": "Favoris",
|
"favorite_other": "Favoris",
|
||||||
"artistWithCount_one": "{{count}} artiste",
|
"artistWithCount_one": "{{count}} artiste",
|
||||||
"artistWithCount_many": "{{count}} artistes",
|
"artistWithCount_many": "{{count}} artistes",
|
||||||
"artistWithCount_other": "{{count}} artistes",
|
"artistWithCount_other": "{{count}} artistes",
|
||||||
"folder_one": "Dossier",
|
"folder_one": "Dossier",
|
||||||
"folder_many": "dossiers",
|
"folder_many": "Dossiers",
|
||||||
"folder_other": "Dossiers",
|
"folder_other": "Dossiers",
|
||||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) intelligente",
|
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Intelligente",
|
||||||
"album_one": "Album",
|
"album_one": "Album",
|
||||||
"album_many": "albums",
|
"album_many": "Albums",
|
||||||
"album_other": "Albums",
|
"album_other": "Albums",
|
||||||
"genreWithCount_one": "{{count}} genre",
|
"genreWithCount_one": "{{count}} genre",
|
||||||
"genreWithCount_many": "{{count}} genres",
|
"genreWithCount_many": "{{count}} genres",
|
||||||
@@ -1060,10 +1060,10 @@
|
|||||||
"play_many": "{{count}} écoutes",
|
"play_many": "{{count}} écoutes",
|
||||||
"play_other": "{{count}} écoutes",
|
"play_other": "{{count}} écoutes",
|
||||||
"song_one": "Titre",
|
"song_one": "Titre",
|
||||||
"song_many": "titres",
|
"song_many": "Titres",
|
||||||
"song_other": "Titres",
|
"song_other": "Titres",
|
||||||
"radioStation_one": "Station radio",
|
"radioStation_one": "Station radio",
|
||||||
"radioStation_many": "stations radio",
|
"radioStation_many": "Stations radio",
|
||||||
"radioStation_other": "Stations radio",
|
"radioStation_other": "Stations radio",
|
||||||
"radioStationWithCount_one": "{{count}} station radio",
|
"radioStationWithCount_one": "{{count}} station radio",
|
||||||
"radioStationWithCount_many": "{{count}} stations radio",
|
"radioStationWithCount_many": "{{count}} stations radio",
|
||||||
|
|||||||
@@ -160,7 +160,7 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_other": "Album",
|
"album_other": "Album",
|
||||||
"albumArtist_other": "Artis album",
|
"albumArtist_other": "Artis Album",
|
||||||
"albumArtistCount_other": "{{count}} artis album",
|
"albumArtistCount_other": "{{count}} artis album",
|
||||||
"albumWithCount_other": "{{count}} album",
|
"albumWithCount_other": "{{count}} album",
|
||||||
"artist_other": "Artis",
|
"artist_other": "Artis",
|
||||||
@@ -170,14 +170,14 @@
|
|||||||
"folderWithCount_other": "{{count}} folder",
|
"folderWithCount_other": "{{count}} folder",
|
||||||
"genre_other": "Genre",
|
"genre_other": "Genre",
|
||||||
"genreWithCount_other": "{{count}} genre",
|
"genreWithCount_other": "{{count}} genre",
|
||||||
"playlist_other": "Daftar putar",
|
"playlist_other": "Daftar Putar",
|
||||||
"play_other": "Putar {{count}}",
|
"play_other": "Putar {{count}}",
|
||||||
"playlistWithCount_other": "{{count}} daftar putar",
|
"playlistWithCount_other": "{{count}} daftar putar",
|
||||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) pintar",
|
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) pintar",
|
||||||
"track_other": "Pista",
|
"track_other": "Pista",
|
||||||
"song_other": "Lagu",
|
"song_other": "Lagu",
|
||||||
"trackWithCount_other": "{{count}} pista",
|
"trackWithCount_other": "{{count}} pista",
|
||||||
"radioStation_other": "Stasiun radio",
|
"radioStation_other": "Stasiun Radio",
|
||||||
"radioStationWithCount_other": "{{count}} stasiun radio"
|
"radioStationWithCount_other": "{{count}} stasiun radio"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
|||||||
+13
-13
@@ -930,25 +930,25 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "Genere",
|
"genre_one": "Genere",
|
||||||
"genre_many": "generi",
|
"genre_many": "Generi",
|
||||||
"genre_other": "Generi",
|
"genre_other": "Generi",
|
||||||
"playlistWithCount_one": "{{count}} playlist",
|
"playlistWithCount_one": "{{count}} playlist",
|
||||||
"playlistWithCount_many": "{{count}} playlist",
|
"playlistWithCount_many": "{{count}} playlist",
|
||||||
"playlistWithCount_other": "{{count}} playlist",
|
"playlistWithCount_other": "{{count}} playlist",
|
||||||
"playlist_one": "Playlist",
|
"playlist_one": "Playlist",
|
||||||
"playlist_many": "playlist",
|
"playlist_many": "Playlist",
|
||||||
"playlist_other": "Playlist",
|
"playlist_other": "Playlist",
|
||||||
"artist_one": "Artista",
|
"artist_one": "Artista",
|
||||||
"artist_many": "artisti",
|
"artist_many": "Artisti",
|
||||||
"artist_other": "Artisti",
|
"artist_other": "Artisti",
|
||||||
"folderWithCount_one": "{{count}} cartella",
|
"folderWithCount_one": "{{count}} cartella",
|
||||||
"folderWithCount_many": "{{count}} cartelle",
|
"folderWithCount_many": "{{count}} cartelle",
|
||||||
"folderWithCount_other": "{{count}} cartelle",
|
"folderWithCount_other": "{{count}} cartelle",
|
||||||
"albumArtist_one": "Artista album",
|
"albumArtist_one": "Artista Album",
|
||||||
"albumArtist_many": "artisti album",
|
"albumArtist_many": "Artisti Album",
|
||||||
"albumArtist_other": "Artisti album",
|
"albumArtist_other": "Artisti Album",
|
||||||
"track_one": "Traccia",
|
"track_one": "Traccia",
|
||||||
"track_many": "tracce",
|
"track_many": "Tracce",
|
||||||
"track_other": "Tracce",
|
"track_other": "Tracce",
|
||||||
"albumArtistCount_one": "{{count}} artista album",
|
"albumArtistCount_one": "{{count}} artista album",
|
||||||
"albumArtistCount_many": "{{count}} artisti album",
|
"albumArtistCount_many": "{{count}} artisti album",
|
||||||
@@ -957,17 +957,17 @@
|
|||||||
"albumWithCount_many": "{{count}} album",
|
"albumWithCount_many": "{{count}} album",
|
||||||
"albumWithCount_other": "{{count}} album",
|
"albumWithCount_other": "{{count}} album",
|
||||||
"favorite_one": "Preferito",
|
"favorite_one": "Preferito",
|
||||||
"favorite_many": "preferiti",
|
"favorite_many": "Preferiti",
|
||||||
"favorite_other": "Preferiti",
|
"favorite_other": "Preferiti",
|
||||||
"artistWithCount_one": "{{count}} artista",
|
"artistWithCount_one": "{{count}} artista",
|
||||||
"artistWithCount_many": "{{count}} artisti",
|
"artistWithCount_many": "{{count}} artisti",
|
||||||
"artistWithCount_other": "{{count}} artisti",
|
"artistWithCount_other": "{{count}} artisti",
|
||||||
"folder_one": "Cartella",
|
"folder_one": "Cartella",
|
||||||
"folder_many": "cartelle",
|
"folder_many": "Cartelle",
|
||||||
"folder_other": "Cartelle",
|
"folder_other": "Cartelle",
|
||||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) smart",
|
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Smart",
|
||||||
"album_one": "Album",
|
"album_one": "Album",
|
||||||
"album_many": "album",
|
"album_many": "Album",
|
||||||
"album_other": "Album",
|
"album_other": "Album",
|
||||||
"genreWithCount_one": "{{count}} genere",
|
"genreWithCount_one": "{{count}} genere",
|
||||||
"genreWithCount_many": "{{count}} generi",
|
"genreWithCount_many": "{{count}} generi",
|
||||||
@@ -979,10 +979,10 @@
|
|||||||
"play_many": "{{count}} riproduzioni",
|
"play_many": "{{count}} riproduzioni",
|
||||||
"play_other": "{{count}} riproduzioni",
|
"play_other": "{{count}} riproduzioni",
|
||||||
"song_one": "Traccia",
|
"song_one": "Traccia",
|
||||||
"song_many": "tracce",
|
"song_many": "Tracce",
|
||||||
"song_other": "Tracce",
|
"song_other": "Tracce",
|
||||||
"radioStation_one": "Stazione radio",
|
"radioStation_one": "Stazione radio",
|
||||||
"radioStation_many": "stazioni radio",
|
"radioStation_many": "Stazioni radio",
|
||||||
"radioStation_other": "Stazioni radio",
|
"radioStation_other": "Stazioni radio",
|
||||||
"radioStationWithCount_one": "{{count}} stazione radio",
|
"radioStationWithCount_one": "{{count}} stazione radio",
|
||||||
"radioStationWithCount_many": "{{count}} stazioni radio",
|
"radioStationWithCount_many": "{{count}} stazioni radio",
|
||||||
|
|||||||
+20
-19
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"action": {
|
"action": {
|
||||||
"editPlaylist": "Edytuj $t(entity.playlist, {\"count\": 1})",
|
"editPlaylist": "Edytuj $t(entity.playlist, {\"count\": 1})",
|
||||||
"goToPage": "IDź do strony",
|
"goToPage": "Idź do strony",
|
||||||
"clearQueue": "Wyczyść kolejkę",
|
"clearQueue": "Wyczyść kolejkę",
|
||||||
"addToFavorites": "Dodaj do $t(entity.favorite, {\"count\": 2})",
|
"addToFavorites": "Dodaj do $t(entity.favorite, {\"count\": 2})",
|
||||||
"removeFromPlaylist": "Usuń z $t(entity.playlist, {\"count\": 1})",
|
"removeFromPlaylist": "Usuń z $t(entity.playlist, {\"count\": 1})",
|
||||||
@@ -189,8 +189,8 @@
|
|||||||
"folderWithCount_few": "{{count}} katalogi",
|
"folderWithCount_few": "{{count}} katalogi",
|
||||||
"folderWithCount_many": "{{count}} katalogów",
|
"folderWithCount_many": "{{count}} katalogów",
|
||||||
"albumArtist_one": "Wykonawca albumu",
|
"albumArtist_one": "Wykonawca albumu",
|
||||||
"albumArtist_few": "wykonawcy albumu",
|
"albumArtist_few": "Wykonawców albumów",
|
||||||
"albumArtist_many": "wykonawcy albumów",
|
"albumArtist_many": "Wykonawców albumów",
|
||||||
"track_one": "Utwór",
|
"track_one": "Utwór",
|
||||||
"track_few": "utwory",
|
"track_few": "utwory",
|
||||||
"track_many": "utworów",
|
"track_many": "utworów",
|
||||||
@@ -226,8 +226,8 @@
|
|||||||
"song_few": "piosenki",
|
"song_few": "piosenki",
|
||||||
"song_many": "piosenek",
|
"song_many": "piosenek",
|
||||||
"radioStation_one": "Stacja radiowa",
|
"radioStation_one": "Stacja radiowa",
|
||||||
"radioStation_few": "stacje radiowe",
|
"radioStation_few": "Stacje radiowe",
|
||||||
"radioStation_many": "stacji radiowych",
|
"radioStation_many": "Stacji radiowych",
|
||||||
"radioStationWithCount_one": "{{count}} stacja radiowa",
|
"radioStationWithCount_one": "{{count}} stacja radiowa",
|
||||||
"radioStationWithCount_few": "{{count}} stacje radiowych",
|
"radioStationWithCount_few": "{{count}} stacje radiowych",
|
||||||
"radioStationWithCount_many": "{{count}} stacji radiowych"
|
"radioStationWithCount_many": "{{count}} stacji radiowych"
|
||||||
@@ -690,7 +690,8 @@
|
|||||||
"sleepTimer_timeRemaining": "Pozostało {{time}}",
|
"sleepTimer_timeRemaining": "Pozostało {{time}}",
|
||||||
"sleepTimer_setCustom": "Ustaw wyłącznik",
|
"sleepTimer_setCustom": "Ustaw wyłącznik",
|
||||||
"sleepTimer_cancel": "Anuluj wyłączanie",
|
"sleepTimer_cancel": "Anuluj wyłączanie",
|
||||||
"albumRadio": "Radio albumu"
|
"albumRadio": "Radio albumu",
|
||||||
|
"scrobbleForceSubmit": "Wymuś scrobble"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"crossfadeStyle_description": "Wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
|
"crossfadeStyle_description": "Wybierz styl przenikania, który ma być używany do odtwarzania dźwięku",
|
||||||
@@ -887,7 +888,7 @@
|
|||||||
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
"playButtonBehavior_optionPlayShuffled": "$t(player.shuffle)",
|
||||||
"playerbarOpenDrawer_description": "Pozwala przełączyć na odtwarzacz pełnoekranowy po kliknięciu paska odtwarzania",
|
"playerbarOpenDrawer_description": "Pozwala przełączyć na odtwarzacz pełnoekranowy po kliknięciu paska odtwarzania",
|
||||||
"playerbarOpenDrawer": "Przełącznik pełnego ekranu na pasku odtwarzania",
|
"playerbarOpenDrawer": "Przełącznik pełnego ekranu na pasku odtwarzania",
|
||||||
"imageAspectRatio": "Używaj natywnych proporcji OKładki",
|
"imageAspectRatio": "Używaj natywnych proporcji okładki",
|
||||||
"volumeWidth": "Szerokość paska głośności",
|
"volumeWidth": "Szerokość paska głośności",
|
||||||
"discordListening": "Pokazuj status jako słucha",
|
"discordListening": "Pokazuj status jako słucha",
|
||||||
"imageAspectRatio_description": "Jeżeli włączone, okładka będzie pokazywana z użyciem jej natywnych proporcji. dla okładek które nie mają proporcji 1:1, pozostałe miejsce będzie puste",
|
"imageAspectRatio_description": "Jeżeli włączone, okładka będzie pokazywana z użyciem jej natywnych proporcji. dla okładek które nie mają proporcji 1:1, pozostałe miejsce będzie puste",
|
||||||
@@ -912,7 +913,7 @@
|
|||||||
"musicbrainz_description": "Pokazuj linki do MusicBrainz na stronach wykonawców/albumów, gdzie istnieje MusicBrainz ID",
|
"musicbrainz_description": "Pokazuj linki do MusicBrainz na stronach wykonawców/albumów, gdzie istnieje MusicBrainz ID",
|
||||||
"discordPausedStatus": "Pokaż status podczas pauzy",
|
"discordPausedStatus": "Pokaż status podczas pauzy",
|
||||||
"discordServeImage": "Wysyłaj obrazy dla {{discord}} z serwera",
|
"discordServeImage": "Wysyłaj obrazy dla {{discord}} z serwera",
|
||||||
"discordServeImage_description": "Pokazuj OKładki w statusie {{discord}} prosto z serwera, dostępne tylko dla Jellyfin i Navidrome. {{discord}} używa bota do pobierania obrazów, więc twój serwer musi być dostępny publicznie w internecie",
|
"discordServeImage_description": "Pokazuj okładki w statusie {{discord}} prosto z serwera, dostępne tylko dla Jellyfin i Navidrome. {{discord}} używa bota do pobierania obrazów, więc twój serwer musi być dostępny publicznie w internecie",
|
||||||
"analyticsDisable": "Zrezygnuj z analityki bazowanej na użytkowaniu",
|
"analyticsDisable": "Zrezygnuj z analityki bazowanej na użytkowaniu",
|
||||||
"analyticsDisable_description": "Zanonymizowane dane użytkowania są wysyłane do dewelopera w celu poprawienia aplikacji",
|
"analyticsDisable_description": "Zanonymizowane dane użytkowania są wysyłane do dewelopera w celu poprawienia aplikacji",
|
||||||
"artistBackground": "Obraz tła wykonawcy",
|
"artistBackground": "Obraz tła wykonawcy",
|
||||||
@@ -978,7 +979,7 @@
|
|||||||
"queryBuilderCustomFields": "Niestandardowe pola",
|
"queryBuilderCustomFields": "Niestandardowe pola",
|
||||||
"queryBuilderCustomFields_description": "Dodaj niestandardowe pola do użycia w kreatorach zapytań",
|
"queryBuilderCustomFields_description": "Dodaj niestandardowe pola do użycia w kreatorach zapytań",
|
||||||
"followCurrentSong_description": "Automatycznie przewija kolejkę odtwarzania do aktualnie odtwarzanej piosenki",
|
"followCurrentSong_description": "Automatycznie przewija kolejkę odtwarzania do aktualnie odtwarzanej piosenki",
|
||||||
"followCurrentSong": "ŚLEDź aktualną piosenkę",
|
"followCurrentSong": "Śledź aktualną piosenkę",
|
||||||
"playerFilters": "Filtruj piosenki z kolejki",
|
"playerFilters": "Filtruj piosenki z kolejki",
|
||||||
"playerFilters_description": "Nie dodawaj piosenek do kolejki na podstawie poniższych kryteriów",
|
"playerFilters_description": "Nie dodawaj piosenek do kolejki na podstawie poniższych kryteriów",
|
||||||
"playerbarSlider_description": "Krzywe nie są zalecane w przypadku wolnego lub ograniczonego połączenia internetowego",
|
"playerbarSlider_description": "Krzywe nie są zalecane w przypadku wolnego lub ograniczonego połączenia internetowego",
|
||||||
@@ -1004,7 +1005,7 @@
|
|||||||
"imageResolution_description": "Rozdzielczość dla obrazów używanych w programie. użycie wartości 0 ustawi rozdzielczość na natywną",
|
"imageResolution_description": "Rozdzielczość dla obrazów używanych w programie. użycie wartości 0 ustawi rozdzielczość na natywną",
|
||||||
"imageResolution_optionTable": "Tabela",
|
"imageResolution_optionTable": "Tabela",
|
||||||
"imageResolution_optionItemCard": "Karta elementu",
|
"imageResolution_optionItemCard": "Karta elementu",
|
||||||
"imageResolution_optionSidebar": "pasek boczny",
|
"imageResolution_optionSidebar": "Pasek boczny",
|
||||||
"imageResolution_optionHeader": "Nagłówek",
|
"imageResolution_optionHeader": "Nagłówek",
|
||||||
"imageResolution_optionFullScreenPlayer": "Odtwarzacz pełnoekranowy",
|
"imageResolution_optionFullScreenPlayer": "Odtwarzacz pełnoekranowy",
|
||||||
"combinedLyricsAndVisualizer_description": "Połącz tekst i wizualizacje w tym samym panelu",
|
"combinedLyricsAndVisualizer_description": "Połącz tekst i wizualizacje w tym samym panelu",
|
||||||
@@ -1158,7 +1159,7 @@
|
|||||||
"rating": "Ocena",
|
"rating": "Ocena",
|
||||||
"favorite": "Ulubione",
|
"favorite": "Ulubione",
|
||||||
"playCount": "Odtwarzane",
|
"playCount": "Odtwarzane",
|
||||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
"albumCount": "Albumy",
|
||||||
"releaseYear": "Rok",
|
"releaseYear": "Rok",
|
||||||
"lastPlayed": "Ostatnio odtwarzane",
|
"lastPlayed": "Ostatnio odtwarzane",
|
||||||
"biography": "Biografia",
|
"biography": "Biografia",
|
||||||
@@ -1167,19 +1168,19 @@
|
|||||||
"title": "Tytuł",
|
"title": "Tytuł",
|
||||||
"bpm": "BPM",
|
"bpm": "BPM",
|
||||||
"dateAdded": "Data dodania",
|
"dateAdded": "Data dodania",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
"artist": "Wykonawca",
|
||||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
"songCount": "Utwory",
|
||||||
"trackNumber": "Utwór",
|
"trackNumber": "Utwór",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
"genre": "Gatunek",
|
||||||
"albumArtist": "Wykonawca albumu",
|
"albumArtist": "Wykonawca albumu",
|
||||||
"path": "Ścieżka",
|
"path": "Ścieżka",
|
||||||
"discNumber": "Płyta",
|
"discNumber": "Płyta",
|
||||||
"channels": "$t(common.channel, {\"count\": 2})",
|
"channels": "Kanały",
|
||||||
"size": "$t(common.size)",
|
"size": "Rozmiar",
|
||||||
"codec": "$t(common.codec)",
|
"codec": "Kodek",
|
||||||
"owner": "Właściciel",
|
"owner": "Właściciel",
|
||||||
"bitDepth": "$t(common.bitDepth)",
|
"bitDepth": "Głębia bitowa",
|
||||||
"sampleRate": "$t(common.sampleRate)"
|
"sampleRate": "Częstotliwość próbkowania"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"queryBuilder": {
|
"queryBuilder": {
|
||||||
|
|||||||
@@ -764,31 +764,31 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"albumArtist_one": "Artista do álbum",
|
"albumArtist_one": "Artista do álbum",
|
||||||
"albumArtist_many": "artistas do álbum",
|
"albumArtist_many": "Artistas do álbum",
|
||||||
"albumArtist_other": "Artistas do álbum",
|
"albumArtist_other": "Artistas do álbum",
|
||||||
"albumArtistCount_one": "{{count}} artista do álbum",
|
"albumArtistCount_one": "{{count}} artista do álbum",
|
||||||
"albumArtistCount_many": "{{count}} artistas do álbum",
|
"albumArtistCount_many": "{{count}} artistas do álbum",
|
||||||
"albumArtistCount_other": "{{count}} artistas do álbum",
|
"albumArtistCount_other": "{{count}} artistas do álbum",
|
||||||
"album_one": "Álbum",
|
"album_one": "Álbum",
|
||||||
"album_many": "álbuns",
|
"album_many": "Álbuns",
|
||||||
"album_other": "Álbuns",
|
"album_other": "Álbuns",
|
||||||
"artist_one": "Artista",
|
"artist_one": "Artista",
|
||||||
"artist_many": "artistas",
|
"artist_many": "Artistas",
|
||||||
"artist_other": "Artistas",
|
"artist_other": "Artistas",
|
||||||
"albumWithCount_one": "{{count}} álbum",
|
"albumWithCount_one": "{{count}} álbum",
|
||||||
"albumWithCount_many": "{{count}} álbuns",
|
"albumWithCount_many": "{{count}} álbuns",
|
||||||
"albumWithCount_other": "{{count}} álbuns",
|
"albumWithCount_other": "{{count}} álbuns",
|
||||||
"favorite_one": "Favorito",
|
"favorite_one": "Favorito",
|
||||||
"favorite_many": "favoritos",
|
"favorite_many": "Favoritos",
|
||||||
"favorite_other": "Favoritos",
|
"favorite_other": "Favoritos",
|
||||||
"artistWithCount_one": "{{count}} artista",
|
"artistWithCount_one": "{{count}} artista",
|
||||||
"artistWithCount_many": "{{count}} artistas",
|
"artistWithCount_many": "{{count}} artistas",
|
||||||
"artistWithCount_other": "{{count}} artistas",
|
"artistWithCount_other": "{{count}} artistas",
|
||||||
"folder_one": "Pasta",
|
"folder_one": "Pasta",
|
||||||
"folder_many": "pastas",
|
"folder_many": "Pastas",
|
||||||
"folder_other": "Pastas",
|
"folder_other": "Pastas",
|
||||||
"genre_one": "Gênero",
|
"genre_one": "Gênero",
|
||||||
"genre_many": "gêneros",
|
"genre_many": "Gêneros",
|
||||||
"genre_other": "Gêneros",
|
"genre_other": "Gêneros",
|
||||||
"playlistWithCount_one": "{{count}} playlist",
|
"playlistWithCount_one": "{{count}} playlist",
|
||||||
"playlistWithCount_many": "{{count}} playlists",
|
"playlistWithCount_many": "{{count}} playlists",
|
||||||
@@ -806,11 +806,11 @@
|
|||||||
"trackWithCount_many": "{{count}} faixas",
|
"trackWithCount_many": "{{count}} faixas",
|
||||||
"trackWithCount_other": "{{count}} faixas",
|
"trackWithCount_other": "{{count}} faixas",
|
||||||
"track_one": "Faixa",
|
"track_one": "Faixa",
|
||||||
"track_many": "faixas",
|
"track_many": "Faixas",
|
||||||
"track_other": "Faixas",
|
"track_other": "Faixas",
|
||||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) inteligente",
|
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Inteligente",
|
||||||
"song_one": "Música",
|
"song_one": "Música",
|
||||||
"song_many": "músicas",
|
"song_many": "Músicas",
|
||||||
"song_other": "Músicas",
|
"song_other": "Músicas",
|
||||||
"play_one": "{{count}} reprodução",
|
"play_one": "{{count}} reprodução",
|
||||||
"play_many": "{{count}} reproduções",
|
"play_many": "{{count}} reproduções",
|
||||||
|
|||||||
+10
-10
@@ -126,10 +126,10 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"album_one": "Álbum",
|
"album_one": "Álbum",
|
||||||
"album_many": "álbuns",
|
"album_many": "Álbuns",
|
||||||
"album_other": "Álbuns",
|
"album_other": "Álbuns",
|
||||||
"albumArtist_one": "Artista do álbum",
|
"albumArtist_one": "Artista do álbum",
|
||||||
"albumArtist_many": "artistas do álbum",
|
"albumArtist_many": "Artistas do álbum",
|
||||||
"albumArtist_other": "Artistas do álbum",
|
"albumArtist_other": "Artistas do álbum",
|
||||||
"albumArtistCount_one": "{{count}} artista do álbum",
|
"albumArtistCount_one": "{{count}} artista do álbum",
|
||||||
"albumArtistCount_many": "{{count}} artistas do álbum",
|
"albumArtistCount_many": "{{count}} artistas do álbum",
|
||||||
@@ -138,28 +138,28 @@
|
|||||||
"albumWithCount_many": "{{count}} álbuns",
|
"albumWithCount_many": "{{count}} álbuns",
|
||||||
"albumWithCount_other": "{{count}} álbuns",
|
"albumWithCount_other": "{{count}} álbuns",
|
||||||
"artist_one": "Artista",
|
"artist_one": "Artista",
|
||||||
"artist_many": "artistas",
|
"artist_many": "Artistas",
|
||||||
"artist_other": "Artistas",
|
"artist_other": "Artistas",
|
||||||
"artistWithCount_one": "{{count}} artista",
|
"artistWithCount_one": "{{count}} artista",
|
||||||
"artistWithCount_many": "{{count}} artistas",
|
"artistWithCount_many": "{{count}} artistas",
|
||||||
"artistWithCount_other": "{{count}} artistas",
|
"artistWithCount_other": "{{count}} artistas",
|
||||||
"favorite_one": "Favorito",
|
"favorite_one": "Favorito",
|
||||||
"favorite_many": "favoritos",
|
"favorite_many": "Favoritos",
|
||||||
"favorite_other": "Favoritos",
|
"favorite_other": "Favoritos",
|
||||||
"folder_one": "Pasta",
|
"folder_one": "Pasta",
|
||||||
"folder_many": "pastas",
|
"folder_many": "Pastas",
|
||||||
"folder_other": "Pastas",
|
"folder_other": "Pastas",
|
||||||
"folderWithCount_one": "{{count}} pasta",
|
"folderWithCount_one": "{{count}} pasta",
|
||||||
"folderWithCount_many": "{{count}} pastas",
|
"folderWithCount_many": "{{count}} pastas",
|
||||||
"folderWithCount_other": "{{count}} pastas",
|
"folderWithCount_other": "{{count}} pastas",
|
||||||
"genre_one": "Gênero",
|
"genre_one": "Gênero",
|
||||||
"genre_many": "gêneros",
|
"genre_many": "Gêneros",
|
||||||
"genre_other": "Gêneros",
|
"genre_other": "Gêneros",
|
||||||
"genreWithCount_one": "{{count}} gênero",
|
"genreWithCount_one": "{{count}} gênero",
|
||||||
"genreWithCount_many": "{{count}} gêneros",
|
"genreWithCount_many": "{{count}} gêneros",
|
||||||
"genreWithCount_other": "{{count}} gêneros",
|
"genreWithCount_other": "{{count}} gêneros",
|
||||||
"playlist_one": "Playlist",
|
"playlist_one": "Playlist",
|
||||||
"playlist_many": "playlists",
|
"playlist_many": "Playlists",
|
||||||
"playlist_other": "Playlists",
|
"playlist_other": "Playlists",
|
||||||
"play_one": "{{count}} reprodução",
|
"play_one": "{{count}} reprodução",
|
||||||
"play_many": "{{count}} reproduções",
|
"play_many": "{{count}} reproduções",
|
||||||
@@ -167,12 +167,12 @@
|
|||||||
"playlistWithCount_one": "{{count}} playlist",
|
"playlistWithCount_one": "{{count}} playlist",
|
||||||
"playlistWithCount_many": "{{count}} playlists",
|
"playlistWithCount_many": "{{count}} playlists",
|
||||||
"playlistWithCount_other": "{{count}} playlists",
|
"playlistWithCount_other": "{{count}} playlists",
|
||||||
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) inteligente",
|
"smartPlaylist": "$t(entity.playlist, {\"count\": 1}) Inteligente",
|
||||||
"track_one": "Faixa",
|
"track_one": "Faixa",
|
||||||
"track_many": "faixas",
|
"track_many": "Faixas",
|
||||||
"track_other": "Faixas",
|
"track_other": "Faixas",
|
||||||
"song_one": "Música",
|
"song_one": "Música",
|
||||||
"song_many": "músicas",
|
"song_many": "Músicas",
|
||||||
"song_other": "Músicas",
|
"song_other": "Músicas",
|
||||||
"trackWithCount_one": "{{count}} faixa",
|
"trackWithCount_one": "{{count}} faixa",
|
||||||
"trackWithCount_many": "{{count}} faixas",
|
"trackWithCount_many": "{{count}} faixas",
|
||||||
|
|||||||
@@ -580,25 +580,25 @@
|
|||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"genre_one": "Žanr",
|
"genre_one": "Žanr",
|
||||||
"genre_few": "žanrova",
|
"genre_few": "Žanrova",
|
||||||
"genre_other": "Žanrova",
|
"genre_other": "Žanrova",
|
||||||
"playlistWithCount_one": "{{count}} plejlista",
|
"playlistWithCount_one": "{{count}} plejlista",
|
||||||
"playlistWithCount_few": "{{count}} plejlista",
|
"playlistWithCount_few": "{{count}} plejlista",
|
||||||
"playlistWithCount_other": "{{count}} plejlista",
|
"playlistWithCount_other": "{{count}} plejlista",
|
||||||
"playlist_one": "Plejlista",
|
"playlist_one": "Plejlista",
|
||||||
"playlist_few": "plejlista",
|
"playlist_few": "Plejlista",
|
||||||
"playlist_other": "Plejlista",
|
"playlist_other": "Plejlista",
|
||||||
"artist_one": "Umetnik",
|
"artist_one": "Umetnik",
|
||||||
"artist_few": "umetnika",
|
"artist_few": "Umetnika",
|
||||||
"artist_other": "Umetnika",
|
"artist_other": "Umetnika",
|
||||||
"folderWithCount_one": "{{count}} folder",
|
"folderWithCount_one": "{{count}} folder",
|
||||||
"folderWithCount_few": "{{count}} foldera",
|
"folderWithCount_few": "{{count}} foldera",
|
||||||
"folderWithCount_other": "{{count}} foldera",
|
"folderWithCount_other": "{{count}} foldera",
|
||||||
"albumArtist_one": "Album umetnika",
|
"albumArtist_one": "Album umetnika",
|
||||||
"albumArtist_few": "albuma umetnika",
|
"albumArtist_few": "Albuma umetnika",
|
||||||
"albumArtist_other": "Albuma umetnika",
|
"albumArtist_other": "Albuma umetnika",
|
||||||
"track_one": "Pesma",
|
"track_one": "Pesma",
|
||||||
"track_few": "pesama",
|
"track_few": "Pesama",
|
||||||
"track_other": "Pesama",
|
"track_other": "Pesama",
|
||||||
"albumArtistCount_one": "{{count}} album umetnika",
|
"albumArtistCount_one": "{{count}} album umetnika",
|
||||||
"albumArtistCount_few": "{{count}} albuma umetnika",
|
"albumArtistCount_few": "{{count}} albuma umetnika",
|
||||||
@@ -607,17 +607,17 @@
|
|||||||
"albumWithCount_few": "{{count}} albuma",
|
"albumWithCount_few": "{{count}} albuma",
|
||||||
"albumWithCount_other": "{{count}} albuma",
|
"albumWithCount_other": "{{count}} albuma",
|
||||||
"favorite_one": "Favorit",
|
"favorite_one": "Favorit",
|
||||||
"favorite_few": "favorita",
|
"favorite_few": "Favorita",
|
||||||
"favorite_other": "Favorita",
|
"favorite_other": "Favorita",
|
||||||
"artistWithCount_one": "{{count}} umetnik",
|
"artistWithCount_one": "{{count}} umetnik",
|
||||||
"artistWithCount_few": "{{count}} umetnika",
|
"artistWithCount_few": "{{count}} umetnika",
|
||||||
"artistWithCount_other": "{{count}} umetnika",
|
"artistWithCount_other": "{{count}} umetnika",
|
||||||
"folder_one": "Folder",
|
"folder_one": "Folder",
|
||||||
"folder_few": "foldera",
|
"folder_few": "Foldera",
|
||||||
"folder_other": "Foldera",
|
"folder_other": "Foldera",
|
||||||
"smartPlaylist": "Pametna $t(entity.playlist, {\"count\": 1})",
|
"smartPlaylist": "Pametna $t(entity.playlist, {\"count\": 1})",
|
||||||
"album_one": "Album",
|
"album_one": "Album",
|
||||||
"album_few": "albumi",
|
"album_few": "Albumi",
|
||||||
"album_other": "Albuma",
|
"album_other": "Albuma",
|
||||||
"genreWithCount_one": "{{count}} žanr",
|
"genreWithCount_one": "{{count}} žanr",
|
||||||
"genreWithCount_few": "{{count}} žanrova",
|
"genreWithCount_few": "{{count}} žanrova",
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
"share": "分享",
|
"share": "分享",
|
||||||
"tags": "標籤",
|
"tags": "標籤",
|
||||||
"trackGain": "曲目增益",
|
"trackGain": "曲目增益",
|
||||||
"trackPeak": "歌曲峰值",
|
"trackPeak": "曲目峰值",
|
||||||
"translation": "翻譯",
|
"translation": "翻譯",
|
||||||
"doNotShowAgain": "不再顯示",
|
"doNotShowAgain": "不再顯示",
|
||||||
"externalLinks": "外部連結",
|
"externalLinks": "外部連結",
|
||||||
@@ -103,8 +103,8 @@
|
|||||||
"sort": "排序",
|
"sort": "排序",
|
||||||
"tableColumns": "表格欄位",
|
"tableColumns": "表格欄位",
|
||||||
"clean": "清除",
|
"clean": "清除",
|
||||||
"explicitStatus": "Explicit狀態",
|
"explicitStatus": "露骨狀態",
|
||||||
"explicit": "Explicit",
|
"explicit": "露骨",
|
||||||
"gridRows": "網格行",
|
"gridRows": "網格行",
|
||||||
"noFilters": "未設定任何過濾器",
|
"noFilters": "未設定任何過濾器",
|
||||||
"countSelected": "{{count}}個已選取",
|
"countSelected": "{{count}}個已選取",
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
"credentialsRequired": "需要憑證",
|
"credentialsRequired": "需要憑證",
|
||||||
"genericError": "發生了錯誤",
|
"genericError": "發生了錯誤",
|
||||||
"invalidServer": "無效的伺服器",
|
"invalidServer": "無效的伺服器",
|
||||||
"localFontAccessDenied": "無法取得本地字體",
|
"localFontAccessDenied": "無法取得本地字型",
|
||||||
"loginRateError": "登入請求嘗試次數過多,請稍後再試",
|
"loginRateError": "登入請求嘗試次數過多,請稍後再試",
|
||||||
"remoteDisableError": "$t(common.disable)遠端伺服器時出現錯誤",
|
"remoteDisableError": "$t(common.disable)遠端伺服器時出現錯誤",
|
||||||
"remoteEnableError": "$t(common.enable)遠端伺服器時出現錯誤",
|
"remoteEnableError": "$t(common.enable)遠端伺服器時出現錯誤",
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
"remotePortWarning": "重啟伺服器使新連接埠生效",
|
"remotePortWarning": "重啟伺服器使新連接埠生效",
|
||||||
"serverRequired": "需要伺服器",
|
"serverRequired": "需要伺服器",
|
||||||
"sessionExpiredError": "工作階段已過期",
|
"sessionExpiredError": "工作階段已過期",
|
||||||
"systemFontError": "嘗試取得系統字體時出現錯誤",
|
"systemFontError": "嘗試取得系統字型時出現錯誤",
|
||||||
"serverNotSelectedError": "未選擇伺服器",
|
"serverNotSelectedError": "未選擇伺服器",
|
||||||
"mpvRequired": "需要 MPV",
|
"mpvRequired": "需要 MPV",
|
||||||
"playbackError": "無法播放媒體",
|
"playbackError": "無法播放媒體",
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
"title": "$t(common.home)",
|
"title": "$t(common.home)",
|
||||||
"mostPlayed": "最多播放",
|
"mostPlayed": "最多播放",
|
||||||
"newlyAdded": "最近新增的發行",
|
"newlyAdded": "最近新增的發行",
|
||||||
"recentlyReleased": "最近發佈",
|
"recentlyReleased": "最近發行",
|
||||||
"genres": "$t(entity.genre, {\"count\": 2})"
|
"genres": "$t(entity.genre, {\"count\": 2})"
|
||||||
},
|
},
|
||||||
"appMenu": {
|
"appMenu": {
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
"fullscreenPlayer": {
|
"fullscreenPlayer": {
|
||||||
"config": {
|
"config": {
|
||||||
"showLyricProvider": "顯示歌詞提供者",
|
"showLyricProvider": "顯示歌詞提供者",
|
||||||
"useImageAspectRatio": "使用圖片縱橫比",
|
"useImageAspectRatio": "使用圖片長寬比",
|
||||||
"dynamicBackground": "動態背景",
|
"dynamicBackground": "動態背景",
|
||||||
"followCurrentLyric": "跟隨目前歌詞",
|
"followCurrentLyric": "跟隨目前歌詞",
|
||||||
"lyricAlignment": "歌詞對齊",
|
"lyricAlignment": "歌詞對齊",
|
||||||
@@ -322,8 +322,8 @@
|
|||||||
"topSongsFrom": "{{title}} 的熱門歌曲",
|
"topSongsFrom": "{{title}} 的熱門歌曲",
|
||||||
"viewAll": "檢視所有",
|
"viewAll": "檢視所有",
|
||||||
"viewAllTracks": "檢視所有$t(entity.track, {\"count\": 2})",
|
"viewAllTracks": "檢視所有$t(entity.track, {\"count\": 2})",
|
||||||
"groupingTypeAll": "所有發佈類型",
|
"groupingTypeAll": "所有發行類型",
|
||||||
"groupingTypePrimary": "主要發佈類型",
|
"groupingTypePrimary": "主要發行類型",
|
||||||
"favoriteSongs": "最愛歌曲",
|
"favoriteSongs": "最愛歌曲",
|
||||||
"favoriteSongsFrom": "{{title}} 的最愛歌曲",
|
"favoriteSongsFrom": "{{title}} 的最愛歌曲",
|
||||||
"topSongsCommunity": "社群",
|
"topSongsCommunity": "社群",
|
||||||
@@ -417,7 +417,8 @@
|
|||||||
"sleepTimer_timeRemaining": "剩餘 {{time}}",
|
"sleepTimer_timeRemaining": "剩餘 {{time}}",
|
||||||
"sleepTimer_setCustom": "設定定時器",
|
"sleepTimer_setCustom": "設定定時器",
|
||||||
"sleepTimer_cancel": "取消定時器",
|
"sleepTimer_cancel": "取消定時器",
|
||||||
"albumRadio": "專輯電台"
|
"albumRadio": "專輯電台",
|
||||||
|
"scrobbleForceSubmit": "強制紀錄"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"audioPlayer_description": "選擇用於播放的音訊播放器",
|
"audioPlayer_description": "選擇用於播放的音訊播放器",
|
||||||
@@ -440,8 +441,8 @@
|
|||||||
"crossfadeDuration": "淡入淡出持續時間",
|
"crossfadeDuration": "淡入淡出持續時間",
|
||||||
"crossfadeDuration_description": "設定淡入淡出持續時間",
|
"crossfadeDuration_description": "設定淡入淡出持續時間",
|
||||||
"crossfadeStyle_description": "選擇用於音訊播放器的淡入淡出風格",
|
"crossfadeStyle_description": "選擇用於音訊播放器的淡入淡出風格",
|
||||||
"customFontPath": "自定字體路徑",
|
"customFontPath": "自訂字型路徑",
|
||||||
"customFontPath_description": "設定應用程式使用的自定字體路徑",
|
"customFontPath_description": "設定應用程式要使用的自訂字型路徑",
|
||||||
"disableLibraryUpdateOnStartup": "禁用啟動時檢查新版本",
|
"disableLibraryUpdateOnStartup": "禁用啟動時檢查新版本",
|
||||||
"discordApplicationId": "{{discord}} 應用程式 ID",
|
"discordApplicationId": "{{discord}} 應用程式 ID",
|
||||||
"discordApplicationId_description": "{{discord}} Rich Presence 應用程式 ID(預設為 {{defaultId}})",
|
"discordApplicationId_description": "{{discord}} Rich Presence 應用程式 ID(預設為 {{defaultId}})",
|
||||||
@@ -454,12 +455,12 @@
|
|||||||
"enableRemote_description": "啟用遠端控制伺服器,以允許其他設備控制此應用程式",
|
"enableRemote_description": "啟用遠端控制伺服器,以允許其他設備控制此應用程式",
|
||||||
"exitToTray": "關閉時到將視窗最小化",
|
"exitToTray": "關閉時到將視窗最小化",
|
||||||
"followLyric": "跟隨目前歌詞",
|
"followLyric": "跟隨目前歌詞",
|
||||||
"font_description": "設定應用程式使用的字體",
|
"font_description": "設定應用程式使用的字型",
|
||||||
"fontType": "字體類型",
|
"fontType": "字型類型",
|
||||||
"fontType_description": "內建字體可以選擇 Feishin 提供的字體之一。系統字體允許您選擇作業系統提供的任何字體。自定選項允許您使用自己的字體",
|
"fontType_description": "內建字型可以選擇 Feishin 提供的字型之一。系統字型允許您選擇作業系統提供的任何字型。自訂選項允許您使用自己的字型",
|
||||||
"fontType_optionBuiltIn": "內建字體",
|
"fontType_optionBuiltIn": "內建字型",
|
||||||
"fontType_optionCustom": "自定字體",
|
"fontType_optionCustom": "自訂字型",
|
||||||
"fontType_optionSystem": "系統字體",
|
"fontType_optionSystem": "系統字型",
|
||||||
"gaplessAudio": "無間隔音訊",
|
"gaplessAudio": "無間隔音訊",
|
||||||
"gaplessAudio_description": "調整 MPV 無間隔音訊設定",
|
"gaplessAudio_description": "調整 MPV 無間隔音訊設定",
|
||||||
"gaplessAudio_optionWeak": "弱(建議)",
|
"gaplessAudio_optionWeak": "弱(建議)",
|
||||||
@@ -499,7 +500,7 @@
|
|||||||
"lyricFetchProvider_description": "選擇歌詞來源",
|
"lyricFetchProvider_description": "選擇歌詞來源",
|
||||||
"minimizeToTray": "最小化到系統匣",
|
"minimizeToTray": "最小化到系統匣",
|
||||||
"minimizeToTray_description": "將應用程式最小化到系統匣",
|
"minimizeToTray_description": "將應用程式最小化到系統匣",
|
||||||
"minimumScrobbleSeconds": "最小紀錄時間(秒)",
|
"minimumScrobbleSeconds": "最小紀錄時長(秒)",
|
||||||
"minimumScrobbleSeconds_description": "歌曲被記錄為已播放(Scrobble)所需的最小播放時間",
|
"minimumScrobbleSeconds_description": "歌曲被記錄為已播放(Scrobble)所需的最小播放時間",
|
||||||
"mpvExecutablePath": "MPV 執行檔路徑",
|
"mpvExecutablePath": "MPV 執行檔路徑",
|
||||||
"playbackStyle_optionCrossFade": "淡入淡出",
|
"playbackStyle_optionCrossFade": "淡入淡出",
|
||||||
@@ -557,7 +558,7 @@
|
|||||||
"playbackStyle": "播放風格",
|
"playbackStyle": "播放風格",
|
||||||
"exitToTray_description": "退出應用程式時最小化到系統匣而非關閉",
|
"exitToTray_description": "退出應用程式時最小化到系統匣而非關閉",
|
||||||
"followLyric_description": "滾動歌詞到目前播放位置",
|
"followLyric_description": "滾動歌詞到目前播放位置",
|
||||||
"font": "字體",
|
"font": "字型",
|
||||||
"globalMediaHotkeys_description": "啟用或禁用系統媒體快捷鍵以控制播放",
|
"globalMediaHotkeys_description": "啟用或禁用系統媒體快捷鍵以控制播放",
|
||||||
"hotkey_browserBack": "瀏覽器返回",
|
"hotkey_browserBack": "瀏覽器返回",
|
||||||
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
|
"hotkey_favoriteCurrentSong": "收藏 $t(common.currentSong)",
|
||||||
@@ -615,7 +616,7 @@
|
|||||||
"lastfmApiKey_description": "{{lastfm}}的API金鑰。用於封面照",
|
"lastfmApiKey_description": "{{lastfm}}的API金鑰。用於封面照",
|
||||||
"mpvExtraParameters_help": "一行一個",
|
"mpvExtraParameters_help": "一行一個",
|
||||||
"musicbrainz": "顯示 MusicBrainz 連結",
|
"musicbrainz": "顯示 MusicBrainz 連結",
|
||||||
"musicbrainz_description": "在存在 MusicBrainz ID 的藝人/專輯頁面上顯示 MusicBrainz 的連結",
|
"musicbrainz_description": "在擁有 MusicBrainz ID 的藝人/專輯頁面上顯示 MusicBrainz 的連結",
|
||||||
"neteaseTranslation": "啟用網易翻譯",
|
"neteaseTranslation": "啟用網易翻譯",
|
||||||
"neteaseTranslation_description": "啟用後,將從網易取得並顯示翻譯的歌詞(如果有)",
|
"neteaseTranslation_description": "啟用後,將從網易取得並顯示翻譯的歌詞(如果有)",
|
||||||
"passwordStore": "密碼/secret儲存",
|
"passwordStore": "密碼/secret儲存",
|
||||||
@@ -626,7 +627,7 @@
|
|||||||
"startMinimized": "啟動時最小化",
|
"startMinimized": "啟動時最小化",
|
||||||
"startMinimized_description": "在系統匣中啟動應用程式",
|
"startMinimized_description": "在系統匣中啟動應用程式",
|
||||||
"transcode_description": "啟用轉碼到不同格式",
|
"transcode_description": "啟用轉碼到不同格式",
|
||||||
"transcodeBitrate": "要轉碼的位元率",
|
"transcodeBitrate": "轉碼的位元率",
|
||||||
"transcodeBitrate_description": "選擇要轉碼的位元率。 0 表示讓伺服器選擇",
|
"transcodeBitrate_description": "選擇要轉碼的位元率。 0 表示讓伺服器選擇",
|
||||||
"transcodeFormat": "轉碼的格式",
|
"transcodeFormat": "轉碼的格式",
|
||||||
"transcodeFormat_description": "選擇要轉碼的格式。留空來讓伺服器決定",
|
"transcodeFormat_description": "選擇要轉碼的格式。留空來讓伺服器決定",
|
||||||
@@ -708,7 +709,7 @@
|
|||||||
"queryBuilderCustomFields_description": "在查詢建構器中新增自訂欄位",
|
"queryBuilderCustomFields_description": "在查詢建構器中新增自訂欄位",
|
||||||
"followCurrentSong_description": "自動將播放佇列捲動至當前播放的歌曲",
|
"followCurrentSong_description": "自動將播放佇列捲動至當前播放的歌曲",
|
||||||
"followCurrentSong": "跟隨當前歌曲",
|
"followCurrentSong": "跟隨當前歌曲",
|
||||||
"playerbarSlider_description": "不建議在網路速度緩慢或計費的網路下使用波形",
|
"playerbarSlider_description": "不建議在速度緩慢或計費的網路下使用波形",
|
||||||
"playerFilters": "從佇列中過濾歌曲",
|
"playerFilters": "從佇列中過濾歌曲",
|
||||||
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
|
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
|
||||||
"autoDJ": "Auto DJ",
|
"autoDJ": "Auto DJ",
|
||||||
@@ -717,7 +718,7 @@
|
|||||||
"autoDJ_itemCount_description": "在啟用Auto DJ時嘗試加入佇列的歌曲數量",
|
"autoDJ_itemCount_description": "在啟用Auto DJ時嘗試加入佇列的歌曲數量",
|
||||||
"autoDJ_timing_description": "佇列中剩餘多少歌曲時啟動 Auto DJ",
|
"autoDJ_timing_description": "佇列中剩餘多少歌曲時啟動 Auto DJ",
|
||||||
"autoDJ_timing": "觸發時機",
|
"autoDJ_timing": "觸發時機",
|
||||||
"logLevel": "log等級",
|
"logLevel": "Log等級",
|
||||||
"logLevel_description": "設定要顯示的最低日誌等級。Debug 會顯示所有日誌,Error 僅會顯示錯誤訊息",
|
"logLevel_description": "設定要顯示的最低日誌等級。Debug 會顯示所有日誌,Error 僅會顯示錯誤訊息",
|
||||||
"logLevel_optionDebug": "Debug",
|
"logLevel_optionDebug": "Debug",
|
||||||
"logLevel_optionError": "Error",
|
"logLevel_optionError": "Error",
|
||||||
@@ -725,7 +726,7 @@
|
|||||||
"logLevel_optionWarn": "Warn",
|
"logLevel_optionWarn": "Warn",
|
||||||
"useThemeAccentColor": "使用主題強調色",
|
"useThemeAccentColor": "使用主題強調色",
|
||||||
"useThemeAccentColor_description": "使用所選主題中定義的主要顏色,而非自訂的強調色",
|
"useThemeAccentColor_description": "使用所選主題中定義的主要顏色,而非自訂的強調色",
|
||||||
"artistRadioCount_description": "設定為藝人電台與曲目電台擷取的歌曲數量",
|
"artistRadioCount_description": "設定要為藝人電台與曲目電台擷取的歌曲數量",
|
||||||
"imageResolution": "圖片解析度",
|
"imageResolution": "圖片解析度",
|
||||||
"imageResolution_description": "應用程式中所使用圖片的解析度。設定為 0 時,將使用圖片的原始解析度",
|
"imageResolution_description": "應用程式中所使用圖片的解析度。設定為 0 時,將使用圖片的原始解析度",
|
||||||
"imageResolution_optionTable": "表格",
|
"imageResolution_optionTable": "表格",
|
||||||
@@ -760,11 +761,11 @@
|
|||||||
"enableGridMultiSelect_description": "啟用時,允許在網格檢視中選擇多項。停用時,單擊網格項目圖片將導航到項目頁面",
|
"enableGridMultiSelect_description": "啟用時,允許在網格檢視中選擇多項。停用時,單擊網格項目圖片將導航到項目頁面",
|
||||||
"sidebarPlaylistSorting_description": "允許在側邊欄中使用拖放手動對播放清單進行排序,而不是預設的伺服器排序",
|
"sidebarPlaylistSorting_description": "允許在側邊欄中使用拖放手動對播放清單進行排序,而不是預設的伺服器排序",
|
||||||
"sidebarPlaylistListFilterRegex_description": "在側邊欄中隱藏與此正規表達式匹配的播放清單",
|
"sidebarPlaylistListFilterRegex_description": "在側邊欄中隱藏與此正規表達式匹配的播放清單",
|
||||||
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^Daily Mix.*",
|
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^daily mix.*",
|
||||||
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
|
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
|
||||||
"blurExplicitImages": "模糊露骨圖片",
|
"blurExplicitImages": "模糊露骨圖片",
|
||||||
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
|
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
|
||||||
"releaseChannel_optionAlpha": "alpha (每日建構版)",
|
"releaseChannel_optionAlpha": "Alpha (每日建構版)",
|
||||||
"analyticsEnable": "傳送基於使用情況的分析報告",
|
"analyticsEnable": "傳送基於使用情況的分析報告",
|
||||||
"analyticsEnable_description": "匿名化的使用情況資料會傳送給開發者,以協助改進應用程式",
|
"analyticsEnable_description": "匿名化的使用情況資料會傳送給開發者,以協助改進應用程式",
|
||||||
"automaticUpdates": "自動更新",
|
"automaticUpdates": "自動更新",
|
||||||
@@ -783,15 +784,15 @@
|
|||||||
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改",
|
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改",
|
||||||
"spotify_description": "在藝人與專輯頁面顯示 Spotify 的連結",
|
"spotify_description": "在藝人與專輯頁面顯示 Spotify 的連結",
|
||||||
"spotify": "顯示 Spotify 的連結",
|
"spotify": "顯示 Spotify 的連結",
|
||||||
"nativeSpotify_description": "在 Spotify 應用程式中開啟,而非在瀏覽器中開啟",
|
"nativeSpotify_description": "在 Spotify 應用程式而非瀏覽器中開啟",
|
||||||
"nativeSpotify": "使用 Spotify 應用程式",
|
"nativeSpotify": "使用 Spotify 應用程式",
|
||||||
"sidePlayQueueLayout": "側邊播放佇列佈局",
|
"sidePlayQueueLayout": "側邊播放佇列佈局",
|
||||||
"sidePlayQueueLayout_description": "設定吸附側邊播放佇列的佈局",
|
"sidePlayQueueLayout_description": "設定吸附側邊播放佇列的佈局",
|
||||||
"sidePlayQueueLayout_optionHorizontal": "水平",
|
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||||
"sidePlayQueueLayout_optionVertical": "垂直",
|
"sidePlayQueueLayout_optionVertical": "垂直",
|
||||||
"listenbrainz_description": "在藝術家/專輯頁面上顯示 ListenBrainz 的連結",
|
"listenbrainz_description": "在藝人/專輯頁面上顯示 ListenBrainz 的連結",
|
||||||
"listenbrainz": "顯示 ListenBrainz 連結",
|
"listenbrainz": "顯示 ListenBrainz 連結",
|
||||||
"qobuz_description": "在藝術家/專輯頁面上顯示 Qobuz 的連結",
|
"qobuz_description": "在藝人/專輯頁面上顯示 Qobuz 的連結",
|
||||||
"qobuz": "顯示 Qobuz 連結",
|
"qobuz": "顯示 Qobuz 連結",
|
||||||
"waveformLoadingDelay": "波形載入延遲",
|
"waveformLoadingDelay": "波形載入延遲",
|
||||||
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。",
|
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。",
|
||||||
@@ -839,7 +840,7 @@
|
|||||||
"album": "$t(entity.album, {\"count\": 1})",
|
"album": "$t(entity.album, {\"count\": 1})",
|
||||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
"bpm": "$t(common.BPM)",
|
"bpm": "$t(common.bpm)",
|
||||||
"biography": "$t(common.biography)",
|
"biography": "$t(common.biography)",
|
||||||
"bitrate": "$t(common.bitrate)",
|
"bitrate": "$t(common.bitrate)",
|
||||||
"channels": "$t(common.channel, {\"count\": 2})",
|
"channels": "$t(common.channel, {\"count\": 2})",
|
||||||
@@ -882,11 +883,11 @@
|
|||||||
"column": {
|
"column": {
|
||||||
"album": "專輯",
|
"album": "專輯",
|
||||||
"albumArtist": "專輯藝人",
|
"albumArtist": "專輯藝人",
|
||||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
"albumCount": "專輯",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
"artist": "藝人",
|
||||||
"biography": "簡介",
|
"biography": "簡介",
|
||||||
"bitrate": "位元率",
|
"bitrate": "位元率",
|
||||||
"channels": "$t(common.channel, {\"count\": 2})",
|
"channels": "聲道",
|
||||||
"comment": "評論",
|
"comment": "評論",
|
||||||
"dateAdded": "新增日期",
|
"dateAdded": "新增日期",
|
||||||
"discNumber": "光碟",
|
"discNumber": "光碟",
|
||||||
@@ -894,19 +895,19 @@
|
|||||||
"lastPlayed": "最後播放",
|
"lastPlayed": "最後播放",
|
||||||
"path": "路徑",
|
"path": "路徑",
|
||||||
"playCount": "播放次數",
|
"playCount": "播放次數",
|
||||||
"rating": "評價",
|
"rating": "評分",
|
||||||
"releaseDate": "發布日期",
|
"releaseDate": "發行日期",
|
||||||
"releaseYear": "年份",
|
"releaseYear": "年份",
|
||||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
"genre": "曲風",
|
||||||
"bpm": "BPM",
|
"bpm": "BPM",
|
||||||
"songCount": "$t(entity.track, {\"count\": 2})",
|
"songCount": "曲目",
|
||||||
"title": "標題",
|
"title": "標題",
|
||||||
"trackNumber": "曲目編號",
|
"trackNumber": "曲目編號",
|
||||||
"size": "$t(common.size)",
|
"size": "大小",
|
||||||
"codec": "$t(common.codec)",
|
"codec": "編碼",
|
||||||
"owner": "擁有者",
|
"owner": "擁有者",
|
||||||
"bitDepth": "$t(common.bitDepth)",
|
"bitDepth": "位元深度",
|
||||||
"sampleRate": "$t(common.sampleRate)"
|
"sampleRate": "取樣率"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
@@ -985,7 +986,7 @@
|
|||||||
"comment": "評論",
|
"comment": "評論",
|
||||||
"communityRating": "社群評分",
|
"communityRating": "社群評分",
|
||||||
"criticRating": "評論家評分",
|
"criticRating": "評論家評分",
|
||||||
"dateAdded": "已新增日期",
|
"dateAdded": "新增日期",
|
||||||
"disc": "光碟",
|
"disc": "光碟",
|
||||||
"duration": "時長",
|
"duration": "時長",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
@@ -1007,13 +1008,13 @@
|
|||||||
"rating": "評分",
|
"rating": "評分",
|
||||||
"recentlyPlayed": "最近播放",
|
"recentlyPlayed": "最近播放",
|
||||||
"recentlyUpdated": "最近更新",
|
"recentlyUpdated": "最近更新",
|
||||||
"releaseDate": "發布日期",
|
"releaseDate": "發行日期",
|
||||||
"songCount": "曲目數",
|
"songCount": "曲目數",
|
||||||
"album": "$t(entity.album, {\"count\": 1})",
|
"album": "$t(entity.album, {\"count\": 1})",
|
||||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||||
"favorited": "已收藏",
|
"favorited": "已收藏",
|
||||||
"recentlyAdded": "最近新增",
|
"recentlyAdded": "最近新增",
|
||||||
"releaseYear": "發布年份",
|
"releaseYear": "發行年份",
|
||||||
"search": "搜尋",
|
"search": "搜尋",
|
||||||
"title": "標題",
|
"title": "標題",
|
||||||
"toYear": "從年份",
|
"toYear": "從年份",
|
||||||
@@ -1034,8 +1035,8 @@
|
|||||||
"success": "伺服器新增成功",
|
"success": "伺服器新增成功",
|
||||||
"title": "新增伺服器",
|
"title": "新增伺服器",
|
||||||
"error_savePassword": "儲存密碼時出現錯誤",
|
"error_savePassword": "儲存密碼時出現錯誤",
|
||||||
"ignoreCors": "忽略 cors $t(common.restartRequired)",
|
"ignoreCors": "忽略 CORS $t(common.restartRequired)",
|
||||||
"ignoreSsl": "忽略 ssl $t(common.restartRequired)",
|
"ignoreSsl": "忽略 SSL $t(common.restartRequired)",
|
||||||
"input_preferInstantMix": "偏好即時混音",
|
"input_preferInstantMix": "偏好即時混音",
|
||||||
"input_preferInstantMixDescription": "僅使用即時混音功能來取得相似歌曲。若您擁有能修改此行為的外掛,此功能將相當實用",
|
"input_preferInstantMixDescription": "僅使用即時混音功能來取得相似歌曲。若您擁有能修改此行為的外掛,此功能將相當實用",
|
||||||
"input_preferRemoteUrl": "優先使用公開網址",
|
"input_preferRemoteUrl": "優先使用公開網址",
|
||||||
@@ -1098,8 +1099,8 @@
|
|||||||
"successMustClick": "分享建立成功,點擊此處開啟"
|
"successMustClick": "分享建立成功,點擊此處開啟"
|
||||||
},
|
},
|
||||||
"privateMode": {
|
"privateMode": {
|
||||||
"enabled": "已啟用私人模式,播放狀態將對外部整合隱藏",
|
"enabled": "已啟用私人模式,播放狀態現在將對外部整合功能隱藏",
|
||||||
"disabled": "已停用私人模式,播放狀態現對已啟用的外部整合可見",
|
"disabled": "已停用私人模式,啟用的外部整合功能現在可查看播放狀態",
|
||||||
"title": "私人模式"
|
"title": "私人模式"
|
||||||
},
|
},
|
||||||
"largeFetchConfirmation": {
|
"largeFetchConfirmation": {
|
||||||
@@ -1133,7 +1134,7 @@
|
|||||||
"input_offset": "$t(setting.lyricOffset)"
|
"input_offset": "$t(setting.lyricOffset)"
|
||||||
},
|
},
|
||||||
"editRadioStation": {
|
"editRadioStation": {
|
||||||
"success": "電臺更新成功"
|
"success": "電台更新成功"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"releaseType": {
|
"releaseType": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import styles from './drag-preview.module.css';
|
|||||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
import { DragData } from '/@/shared/types/drag-and-drop';
|
import { DragData, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||||
|
|
||||||
interface DragPreviewProps {
|
interface DragPreviewProps {
|
||||||
data: DragData;
|
data: DragData;
|
||||||
@@ -29,7 +29,9 @@ export const DragPreview = memo(({ data }: DragPreviewProps) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const itemCount = items.length;
|
const itemCount = items.length;
|
||||||
const firstItem = items[0];
|
const firstItem = items[0];
|
||||||
const itemName = firstItem ? getItemName(firstItem) : 'Item';
|
const folderName =
|
||||||
|
data.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER ? data.id[0] : undefined;
|
||||||
|
const itemName = folderName || (firstItem ? getItemName(firstItem) : 'Item');
|
||||||
|
|
||||||
const itemImage = useItemImageUrl({
|
const itemImage = useItemImageUrl({
|
||||||
id: (firstItem as { imageId: string })?.imageId,
|
id: (firstItem as { imageId: string })?.imageId,
|
||||||
@@ -50,6 +52,9 @@ export const DragPreview = memo(({ data }: DragPreviewProps) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles['icon-container']}>
|
<div className={styles['icon-container']}>
|
||||||
|
{data.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER && (
|
||||||
|
<Icon icon="folder" size="xl" />
|
||||||
|
)}
|
||||||
{data.itemType === LibraryItem.ALBUM && <Icon icon="album" size="xl" />}
|
{data.itemType === LibraryItem.ALBUM && <Icon icon="album" size="xl" />}
|
||||||
{data.itemType === LibraryItem.SONG && (
|
{data.itemType === LibraryItem.SONG && (
|
||||||
<Icon icon="itemSong" size="xl" />
|
<Icon icon="itemSong" size="xl" />
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ export const useListHotkeys = ({
|
|||||||
focused,
|
focused,
|
||||||
internalState,
|
internalState,
|
||||||
itemType,
|
itemType,
|
||||||
|
onShowPlayingSong,
|
||||||
}: {
|
}: {
|
||||||
controls: ItemControls;
|
controls: ItemControls;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
internalState: ItemListStateActions;
|
internalState: ItemListStateActions;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
|
onShowPlayingSong?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { bindings } = useHotkeySettings();
|
const { bindings } = useHotkeySettings();
|
||||||
const playButtonBehavior = usePlayButtonBehavior();
|
const playButtonBehavior = usePlayButtonBehavior();
|
||||||
@@ -119,5 +121,11 @@ export const useListHotkeys = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
bindings.listShowPlayingSong.hotkey,
|
||||||
|
() => {
|
||||||
|
onShowPlayingSong?.();
|
||||||
|
},
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
ItemTableListColumnConfig,
|
ItemTableListColumnConfig,
|
||||||
} from '/@/renderer/components/item-list/types';
|
} from '/@/renderer/components/item-list/types';
|
||||||
import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context';
|
import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
|
import { usePlayerStore } from '/@/renderer/store';
|
||||||
import { animationProps } from '/@/shared/components/animations/animation-props';
|
import { animationProps } from '/@/shared/components/animations/animation-props';
|
||||||
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
|
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
|
||||||
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
|
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
|
||||||
@@ -1596,11 +1597,22 @@ const BaseItemTableList = ({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onShowPlayingSong = useCallback(() => {
|
||||||
|
const targetId = usePlayerStore.getState().getCurrentSong()?.id;
|
||||||
|
if (!targetId) return;
|
||||||
|
const index =
|
||||||
|
getItemIndex?.(targetId) ??
|
||||||
|
data.findIndex((item) => (item as null | { id?: string })?.id === targetId);
|
||||||
|
if (index === undefined || index < 0) return;
|
||||||
|
handleRef.current?.scrollToIndex(index, { align: 'center', behavior: 'auto' });
|
||||||
|
}, [data, getItemIndex]);
|
||||||
|
|
||||||
useListHotkeys({
|
useListHotkeys({
|
||||||
controls,
|
controls,
|
||||||
focused,
|
focused,
|
||||||
internalState,
|
internalState,
|
||||||
itemType,
|
itemType,
|
||||||
|
onShowPlayingSong,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tableConfigValue = useMemo<ItemTableListConfig>(
|
const tableConfigValue = useMemo<ItemTableListConfig>(
|
||||||
|
|||||||
@@ -59,9 +59,24 @@
|
|||||||
.slider-value-wrapper {
|
.slider-value-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
align-self: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: 50px;
|
max-width: 50px;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
@media (width < 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-value-wrapper-elapsed {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 4.75rem;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
@media (width < 768px) {
|
@media (width < 768px) {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { lazy, Suspense } from 'react';
|
|||||||
import { PlayerbarSeekSlider } from './playerbar-seek-slider';
|
import { PlayerbarSeekSlider } from './playerbar-seek-slider';
|
||||||
import styles from './playerbar-slider.module.css';
|
import styles from './playerbar-slider.module.css';
|
||||||
|
|
||||||
|
import { ScrobbleStatus } from '/@/renderer/features/player/components/scrobble-status';
|
||||||
import {
|
import {
|
||||||
useAppStore,
|
useAppStore,
|
||||||
useAppStoreActions,
|
useAppStoreActions,
|
||||||
@@ -41,17 +42,8 @@ export const PlayerbarSlider = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.sliderContainer}>
|
<div className={styles.sliderContainer}>
|
||||||
<div className={styles.sliderValueWrapper}>
|
<div className={styles.sliderValueWrapperElapsed}>
|
||||||
<Text
|
<ScrobbleStatus formattedTime={formattedTime} />
|
||||||
className={PlaybackSelectors.elapsedTime}
|
|
||||||
fw={600}
|
|
||||||
isMuted
|
|
||||||
isNoSelect
|
|
||||||
size="xs"
|
|
||||||
style={{ userSelect: 'none' }}
|
|
||||||
>
|
|
||||||
{formattedTime}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.sliderWrapper}>
|
<div className={styles.sliderWrapper}>
|
||||||
{isWaveform ? (
|
{isWaveform ? (
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
invokeScrobbleForceSubmit,
|
||||||
|
invokeScrobbleResetListenedState,
|
||||||
|
} from '/@/renderer/features/player/hooks/use-scrobble';
|
||||||
|
import { useAppStore, useScrobbleDebugStore, useSettingsStore } from '/@/renderer/store';
|
||||||
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
|
||||||
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
|
import { Progress } from '/@/shared/components/progress/progress';
|
||||||
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||||
|
|
||||||
|
const scrobbleProgressProps = {
|
||||||
|
'aria-hidden': true,
|
||||||
|
color: 'var(--theme-colors-primary)',
|
||||||
|
size: 'xs' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
|
||||||
|
|
||||||
|
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
|
||||||
|
<Progress {...scrobbleProgressProps} value={value} w="100%" />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const scrobbleEnabled = useSettingsStore((state) => state.playback.scrobble.enabled);
|
||||||
|
const privateMode = useAppStore((state) => state.privateMode);
|
||||||
|
const snapshot = useScrobbleDebugStore((state) => state.snapshot);
|
||||||
|
|
||||||
|
const hookInactive = !scrobbleEnabled || privateMode;
|
||||||
|
|
||||||
|
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1);
|
||||||
|
const listenPercentOfTrack =
|
||||||
|
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
|
||||||
|
|
||||||
|
const durationConditionPct =
|
||||||
|
snapshot.targetDurationSec > 0
|
||||||
|
? clampPct((snapshot.listenedMs / 1000 / snapshot.targetDurationSec) * 100)
|
||||||
|
: 0;
|
||||||
|
const percentConditionPct =
|
||||||
|
snapshot.targetPercentage > 0 && snapshot.trackDurationMs > 0
|
||||||
|
? clampPct((listenPercentOfTrack / snapshot.targetPercentage) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverCard position="top" width={280}>
|
||||||
|
<HoverCard.Target>
|
||||||
|
<Group
|
||||||
|
align="center"
|
||||||
|
aria-label={`${t('player.scrobble')}, ${formattedTime}`}
|
||||||
|
fz="xs"
|
||||||
|
gap="sm"
|
||||||
|
justify="center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ userSelect: 'none' }}
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
aria-hidden
|
||||||
|
color={snapshot.submitted ? 'primary' : 'transparent'}
|
||||||
|
fill={snapshot.submitted ? 'primary' : 'transparent'}
|
||||||
|
icon="circle"
|
||||||
|
size="0.375rem"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className={PlaybackSelectors.elapsedTime}
|
||||||
|
fw={600}
|
||||||
|
fz="inherit"
|
||||||
|
isMuted
|
||||||
|
isNoSelect
|
||||||
|
style={{ userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
{formattedTime}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</HoverCard.Target>
|
||||||
|
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Stack gap="md" p="sm">
|
||||||
|
{hookInactive ? (
|
||||||
|
<Text size="sm">{t('form.privateMode.enabled')}</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs">
|
||||||
|
{`${listenedSec}s / ${snapshot.targetDurationSec}s`}
|
||||||
|
</Text>
|
||||||
|
<ScrobbleConditionProgress value={durationConditionPct} />
|
||||||
|
</Stack>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs">
|
||||||
|
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`}
|
||||||
|
</Text>
|
||||||
|
<ScrobbleConditionProgress value={percentConditionPct} />
|
||||||
|
</Stack>
|
||||||
|
<Group gap="xs" grow wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
disabled={!snapshot.songId}
|
||||||
|
onClick={() => invokeScrobbleResetListenedState()}
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t('common.reset')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!snapshot.songId || snapshot.submitted}
|
||||||
|
onClick={() => invokeScrobbleForceSubmit()}
|
||||||
|
size="xs"
|
||||||
|
variant="filled"
|
||||||
|
>
|
||||||
|
{t('player.scrobbleForceSubmit')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</HoverCard.Dropdown>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||||
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
||||||
import {
|
import {
|
||||||
|
publishScrobbleDebug,
|
||||||
useAppStore,
|
useAppStore,
|
||||||
usePlaybackSettings,
|
usePlaybackSettings,
|
||||||
usePlayerSong,
|
usePlayerSong,
|
||||||
@@ -16,34 +17,64 @@ import { logMsg } from '/@/renderer/utils/logger-message';
|
|||||||
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||||
import { PlayerStatus } from '/@/shared/types/types';
|
import { PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
type ScrobbleManualHandlers = {
|
||||||
|
forceSubmitScrobble: () => void;
|
||||||
|
resetListenedState: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let scrobbleManualHandlers: null | ScrobbleManualHandlers = null;
|
||||||
|
|
||||||
|
export const registerScrobbleManualHandlers = (next: null | ScrobbleManualHandlers) => {
|
||||||
|
scrobbleManualHandlers = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const invokeScrobbleForceSubmit = () => {
|
||||||
|
scrobbleManualHandlers?.forceSubmitScrobble();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const invokeScrobbleResetListenedState = () => {
|
||||||
|
scrobbleManualHandlers?.resetListenedState();
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Scrobble Conditions (match any):
|
Submission (Last.fm / etc.) eligibility uses accumulated listen time:
|
||||||
- If the song has been played for the required percentage
|
- If listened time meets the required percentage of track duration
|
||||||
- If the song has been played for the required duration
|
- If listened time meets the required duration (seconds)
|
||||||
|
|
||||||
Scrobble Events:
|
Listen time advances only while PLAYING, from consecutive timestamp deltas.
|
||||||
- On song timestamp update:
|
Seeks and other timeline jumps re-baseline the next sample without counting
|
||||||
- If the song has been played for the required percentage
|
the jump as listen time; accumulated listen time is kept across seeks.
|
||||||
- If the song has been played for the required duration
|
|
||||||
|
|
||||||
- When the song changes (or is completed):
|
Listen time and submission state reset when the playhead returns to the start
|
||||||
- Current song: Sends the 'playing' scrobble event
|
of the track (position before SCROBBLE_TRACK_BEGIN_SEC), e.g. seek-to-start or
|
||||||
- Resets the 'isCurrentSongScrobbled' state to false
|
restart-from-near-zero. Song change and repeat still reset for a new play-through.
|
||||||
|
|
||||||
- When the song is restarted:
|
Jellyfin progress APIs still use playback position (ticks), not listen time:
|
||||||
- Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false
|
- Periodic timeupdate while playing
|
||||||
- Resets the 'isCurrentSongScrobbled' state to false
|
- timeupdate on seek
|
||||||
|
- pause / unpause
|
||||||
|
|
||||||
- When the song is seeked:
|
Other events:
|
||||||
- Sends the 'timeupdate' scrobble event (Jellyfin only)
|
- When the song changes: sends 'start' when the new track is playing;
|
||||||
|
clears submission flag and listen accumulator for the new track.
|
||||||
|
|
||||||
|
- When the song is restarted (near 0 after 10s+): clears submission flag
|
||||||
|
and listen accumulator.
|
||||||
|
|
||||||
Progress Events:
|
- When the song is seeked: Jellyfin sends timeupdate (throttled). Seeking from
|
||||||
- When the song is playing (Jellyfin only):
|
at/after the intro into the start of the track clears listen accumulator and
|
||||||
- Sends the 'progress' scrobble event on an interval
|
submission flag; other seeks keep accumulated listen time.
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Positions before this time (seconds) count as the start of the track for listen/scrobble resets.
|
||||||
|
const SCROBBLE_TRACK_BEGIN_SEC = 5;
|
||||||
|
|
||||||
|
// Min previous position (seconds) to treat a jump to the start as a full restart.
|
||||||
|
const SCROBBLE_RESTART_PREVIOUS_MIN_SEC = 10;
|
||||||
|
|
||||||
|
// Max seconds between timestamp samples to count as continuous play (above poll interval, below a teleport).
|
||||||
|
const MAX_LISTEN_DELTA_SEC = 5;
|
||||||
|
|
||||||
const checkScrobbleConditions = (args: {
|
const checkScrobbleConditions = (args: {
|
||||||
scrobbleAtDurationMs: number;
|
scrobbleAtDurationMs: number;
|
||||||
scrobbleAtPercentage: number;
|
scrobbleAtPercentage: number;
|
||||||
@@ -56,10 +87,10 @@ const checkScrobbleConditions = (args: {
|
|||||||
? (songCompletedDurationMs / songDurationMs) * 100
|
? (songCompletedDurationMs / songDurationMs) * 100
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const shouldScrobbleBasedOnPercetange = percentageOfSongCompleted >= scrobbleAtPercentage;
|
const shouldScrobbleBasedOnPercentage = percentageOfSongCompleted >= scrobbleAtPercentage;
|
||||||
const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs;
|
const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs;
|
||||||
|
|
||||||
return shouldScrobbleBasedOnPercetange || shouldScrobbleBasedOnDuration;
|
return shouldScrobbleBasedOnPercentage || shouldScrobbleBasedOnDuration;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useScrobble = () => {
|
export const useScrobble = () => {
|
||||||
@@ -77,7 +108,12 @@ export const useScrobble = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
||||||
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
|
const isCurrentSongScrobbledRef = useRef(false);
|
||||||
|
const listenedMsRef = useRef(0);
|
||||||
|
const lastListenSampleTimeRef = useRef<null | number>(null);
|
||||||
|
const scrobbleAtDurationMsRef = useRef(0);
|
||||||
|
const scrobbleAtPercentageRef = useRef(75);
|
||||||
|
|
||||||
const previousSongRef = useRef<QueueSong | undefined>(undefined);
|
const previousSongRef = useRef<QueueSong | undefined>(undefined);
|
||||||
const previousTimestampRef = useRef<number>(0);
|
const previousTimestampRef = useRef<number>(0);
|
||||||
const lastProgressEventRef = useRef<number>(0);
|
const lastProgressEventRef = useRef<number>(0);
|
||||||
@@ -89,30 +125,101 @@ export const useScrobble = () => {
|
|||||||
imageUrlRef.current = imageUrl;
|
imageUrlRef.current = imageUrl;
|
||||||
}, [imageUrl]);
|
}, [imageUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrobbleAtDurationMsRef.current = (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000;
|
||||||
|
scrobbleAtPercentageRef.current = scrobbleSettings?.scrobbleAtPercentage ?? 75;
|
||||||
|
}, [scrobbleSettings?.scrobbleAtDuration, scrobbleSettings?.scrobbleAtPercentage]);
|
||||||
|
|
||||||
|
const flushScrobbleDebug = useCallback(() => {
|
||||||
|
const song = usePlayerStore.getState().getCurrentSong();
|
||||||
|
const status = usePlayerStore.getState().player.status;
|
||||||
|
const positionSec = useTimestampStoreBase.getState().timestamp;
|
||||||
|
const trackDurationMs = song?.duration ?? 0;
|
||||||
|
|
||||||
|
const eligibilityMet = Boolean(
|
||||||
|
song?.id &&
|
||||||
|
checkScrobbleConditions({
|
||||||
|
scrobbleAtDurationMs: scrobbleAtDurationMsRef.current,
|
||||||
|
scrobbleAtPercentage: scrobbleAtPercentageRef.current,
|
||||||
|
songCompletedDurationMs: listenedMsRef.current,
|
||||||
|
songDurationMs: trackDurationMs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
publishScrobbleDebug({
|
||||||
|
eligibilityMet,
|
||||||
|
lastListenSampleTimeSec: lastListenSampleTimeRef.current,
|
||||||
|
listenedMs: listenedMsRef.current,
|
||||||
|
playerStatus: status,
|
||||||
|
positionSec,
|
||||||
|
songId: song?.id,
|
||||||
|
songName: song?.name,
|
||||||
|
submitted: isCurrentSongScrobbledRef.current,
|
||||||
|
targetDurationSec: scrobbleAtDurationMsRef.current / 1000,
|
||||||
|
targetPercentage: scrobbleAtPercentageRef.current,
|
||||||
|
trackDurationMs,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleScrobbleFromProgress = useCallback(
|
const handleScrobbleFromProgress = useCallback(
|
||||||
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
||||||
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
|
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
|
||||||
|
|
||||||
const currentSong = usePlayerStore.getState().getCurrentSong();
|
const currentSong = usePlayerStore.getState().getCurrentSong();
|
||||||
const currentStatus = usePlayerStore.getState().player.status;
|
const currentStatus = usePlayerStore.getState().player.status;
|
||||||
|
|
||||||
if (!currentSong?.id || currentStatus !== PlayerStatus.PLAYING) return;
|
|
||||||
|
|
||||||
const currentTime = properties.timestamp;
|
const currentTime = properties.timestamp;
|
||||||
const previousTime = prev.timestamp;
|
const previousTime = prev.timestamp;
|
||||||
|
|
||||||
// Detect song restart: when timestamp resets to near 0 and was playing for at least 10 seconds
|
if (!currentSong?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStatus !== PlayerStatus.PLAYING) {
|
||||||
|
lastListenSampleTimeRef.current = currentTime;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect song restart: when timestamp resets to near 0 and was playing past the intro
|
||||||
if (
|
if (
|
||||||
currentTime < previousTime &&
|
currentTime < previousTime &&
|
||||||
currentTime < 5 && // Reset to near 0
|
currentTime < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||||
previousTime >= 10 // Was playing for at least 10 seconds
|
previousTime >= SCROBBLE_RESTART_PREVIOUS_MIN_SEC
|
||||||
) {
|
) {
|
||||||
setIsCurrentSongScrobbled(false);
|
isCurrentSongScrobbledRef.current = false;
|
||||||
lastProgressEventRef.current = 0;
|
lastProgressEventRef.current = 0;
|
||||||
previousTimestampRef.current = 0;
|
previousTimestampRef.current = 0;
|
||||||
|
listenedMsRef.current = 0;
|
||||||
|
lastListenSampleTimeRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastSample = lastListenSampleTimeRef.current;
|
||||||
|
if (lastSample === null) {
|
||||||
|
const prevSec = prev.timestamp;
|
||||||
|
if (currentTime > prevSec && currentTime - prevSec <= MAX_LISTEN_DELTA_SEC) {
|
||||||
|
listenedMsRef.current += (currentTime - prevSec) * 1000;
|
||||||
|
}
|
||||||
|
lastListenSampleTimeRef.current = currentTime;
|
||||||
|
} else {
|
||||||
|
const deltaSec = currentTime - lastSample;
|
||||||
|
const jumpedBackToTrackStart =
|
||||||
|
currentTime < lastSample &&
|
||||||
|
currentTime < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||||
|
lastSample >= SCROBBLE_TRACK_BEGIN_SEC;
|
||||||
|
|
||||||
|
if (jumpedBackToTrackStart) {
|
||||||
|
listenedMsRef.current = 0;
|
||||||
|
isCurrentSongScrobbledRef.current = false;
|
||||||
|
lastProgressEventRef.current = 0;
|
||||||
|
lastListenSampleTimeRef.current = currentTime;
|
||||||
|
} else if (currentTime < lastSample || deltaSec > MAX_LISTEN_DELTA_SEC) {
|
||||||
|
lastListenSampleTimeRef.current = currentTime;
|
||||||
|
} else if (deltaSec > 0) {
|
||||||
|
listenedMsRef.current += deltaSec * 1000;
|
||||||
|
lastListenSampleTimeRef.current = currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send Jellyfin progress events every 10 seconds
|
// Send Jellyfin progress events every 10 seconds
|
||||||
if (currentSong._serverType === ServerType.JELLYFIN) {
|
if (currentSong._serverType === ServerType.JELLYFIN) {
|
||||||
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
|
||||||
@@ -144,12 +251,12 @@ export const useScrobble = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should submit scrobble based on conditions
|
// Check if we should submit scrobble based on listened time
|
||||||
if (!isCurrentSongScrobbled) {
|
if (!isCurrentSongScrobbledRef.current) {
|
||||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||||
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
|
scrobbleAtDurationMs: scrobbleAtDurationMsRef.current,
|
||||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
scrobbleAtPercentage: scrobbleAtPercentageRef.current,
|
||||||
songCompletedDurationMs: currentTime * 1000,
|
songCompletedDurationMs: listenedMsRef.current,
|
||||||
songDurationMs: currentSong.duration,
|
songDurationMs: currentSong.duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,25 +284,18 @@ export const useScrobble = () => {
|
|||||||
category: LogCategory.SCROBBLE,
|
category: LogCategory.SCROBBLE,
|
||||||
meta: {
|
meta: {
|
||||||
id: currentSong.id,
|
id: currentSong.id,
|
||||||
reason: 'from song progress',
|
reason: 'from listened time',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
setIsCurrentSongScrobbled(true);
|
isCurrentSongScrobbledRef.current = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||||
isScrobbleEnabled,
|
|
||||||
isPrivateModeEnabled,
|
|
||||||
scrobbleSettings?.scrobbleAtDuration,
|
|
||||||
scrobbleSettings?.scrobbleAtPercentage,
|
|
||||||
isCurrentSongScrobbled,
|
|
||||||
sendScrobble,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrobbleFromSongChange = useCallback(
|
const handleScrobbleFromSongChange = useCallback(
|
||||||
@@ -240,11 +340,16 @@ export const useScrobble = () => {
|
|||||||
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||||
previousSongRef.current = currentSong;
|
previousSongRef.current = currentSong;
|
||||||
previousTimestampRef.current = 0;
|
previousTimestampRef.current = 0;
|
||||||
|
listenedMsRef.current = 0;
|
||||||
|
lastListenSampleTimeRef.current = null;
|
||||||
|
flushScrobbleDebug();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCurrentSongScrobbled(false);
|
isCurrentSongScrobbledRef.current = false;
|
||||||
lastProgressEventRef.current = 0;
|
lastProgressEventRef.current = 0;
|
||||||
|
listenedMsRef.current = 0;
|
||||||
|
lastListenSampleTimeRef.current = null;
|
||||||
|
|
||||||
// Use a timeout to prevent spamming the server when switching songs quickly
|
// Use a timeout to prevent spamming the server when switching songs quickly
|
||||||
clearTimeout(songChangeTimeoutRef.current);
|
clearTimeout(songChangeTimeoutRef.current);
|
||||||
@@ -280,8 +385,15 @@ export const useScrobble = () => {
|
|||||||
|
|
||||||
previousSongRef.current = currentSong;
|
previousSongRef.current = currentSong;
|
||||||
previousTimestampRef.current = 0;
|
previousTimestampRef.current = 0;
|
||||||
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[scrobbleSettings?.notify, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
[
|
||||||
|
flushScrobbleDebug,
|
||||||
|
scrobbleSettings?.notify,
|
||||||
|
isScrobbleEnabled,
|
||||||
|
isPrivateModeEnabled,
|
||||||
|
sendScrobble,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrobbleFromSeek = useCallback(
|
const handleScrobbleFromSeek = useCallback(
|
||||||
@@ -297,8 +409,21 @@ export const useScrobble = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sampleBeforeSeek = lastListenSampleTimeRef.current;
|
||||||
|
lastListenSampleTimeRef.current = properties.timestamp;
|
||||||
|
|
||||||
|
if (
|
||||||
|
properties.timestamp < SCROBBLE_TRACK_BEGIN_SEC &&
|
||||||
|
(sampleBeforeSeek === null || sampleBeforeSeek >= SCROBBLE_TRACK_BEGIN_SEC)
|
||||||
|
) {
|
||||||
|
listenedMsRef.current = 0;
|
||||||
|
isCurrentSongScrobbledRef.current = false;
|
||||||
|
lastProgressEventRef.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Position scrobbles are only relevant for Jellyfin
|
// Position scrobbles are only relevant for Jellyfin
|
||||||
if (currentSong._serverType !== ServerType.JELLYFIN) {
|
if (currentSong._serverType !== ServerType.JELLYFIN) {
|
||||||
|
flushScrobbleDebug();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +432,7 @@ export const useScrobble = () => {
|
|||||||
|
|
||||||
// Only allow seek scrobble once per second
|
// Only allow seek scrobble once per second
|
||||||
if (timeSinceLastSeek < 1000) {
|
if (timeSinceLastSeek < 1000) {
|
||||||
|
flushScrobbleDebug();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,8 +463,9 @@ export const useScrobble = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrobbleFromStatus = useCallback(
|
const handleScrobbleFromStatus = useCallback(
|
||||||
@@ -412,8 +539,10 @@ export const useScrobble = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScrobbleFromRepeat = useCallback(() => {
|
const handleScrobbleFromRepeat = useCallback(() => {
|
||||||
@@ -428,9 +557,11 @@ export const useScrobble = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCurrentSongScrobbled(false);
|
isCurrentSongScrobbledRef.current = false;
|
||||||
lastProgressEventRef.current = 0;
|
lastProgressEventRef.current = 0;
|
||||||
previousTimestampRef.current = 0;
|
previousTimestampRef.current = 0;
|
||||||
|
listenedMsRef.current = 0;
|
||||||
|
lastListenSampleTimeRef.current = null;
|
||||||
|
|
||||||
sendScrobble.mutate(
|
sendScrobble.mutate(
|
||||||
{
|
{
|
||||||
@@ -455,17 +586,81 @@ export const useScrobble = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
|
flushScrobbleDebug();
|
||||||
|
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
|
||||||
|
|
||||||
// Update previous timestamp on progress for use in status change handler
|
// Update previous timestamp on progress for use in status change handler
|
||||||
const handleProgressUpdate = useCallback(
|
const handleProgressUpdate = useCallback(
|
||||||
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
(properties: { timestamp: number }, prev: { timestamp: number }) => {
|
||||||
previousTimestampRef.current = properties.timestamp;
|
previousTimestampRef.current = properties.timestamp;
|
||||||
handleScrobbleFromProgress(properties, prev);
|
handleScrobbleFromProgress(properties, prev);
|
||||||
|
flushScrobbleDebug();
|
||||||
},
|
},
|
||||||
[handleScrobbleFromProgress],
|
[flushScrobbleDebug, handleScrobbleFromProgress],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerScrobbleManualHandlers({
|
||||||
|
forceSubmitScrobble: () => {
|
||||||
|
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const song = usePlayerStore.getState().getCurrentSong();
|
||||||
|
if (!song?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position =
|
||||||
|
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
|
||||||
|
|
||||||
|
sendScrobble.mutate(
|
||||||
|
{
|
||||||
|
apiClientProps: { serverId: song._serverId || '' },
|
||||||
|
query: {
|
||||||
|
albumId: song.albumId,
|
||||||
|
id: song.id,
|
||||||
|
position,
|
||||||
|
submission: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledSubmission, {
|
||||||
|
category: LogCategory.SCROBBLE,
|
||||||
|
meta: {
|
||||||
|
id: song.id,
|
||||||
|
reason: 'forced from UI',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
isCurrentSongScrobbledRef.current = true;
|
||||||
|
flushScrobbleDebug();
|
||||||
|
},
|
||||||
|
resetListenedState: () => {
|
||||||
|
if (!isScrobbleEnabled || isPrivateModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const song = usePlayerStore.getState().getCurrentSong();
|
||||||
|
if (!song?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listenedMsRef.current = 0;
|
||||||
|
isCurrentSongScrobbledRef.current = false;
|
||||||
|
lastProgressEventRef.current = 0;
|
||||||
|
lastListenSampleTimeRef.current = useTimestampStoreBase.getState().timestamp;
|
||||||
|
flushScrobbleDebug();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => registerScrobbleManualHandlers(null);
|
||||||
|
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]);
|
||||||
|
|
||||||
usePlayerEvents(
|
usePlayerEvents(
|
||||||
{
|
{
|
||||||
onCurrentSongChange: handleScrobbleFromSongChange,
|
onCurrentSongChange: handleScrobbleFromSongChange,
|
||||||
|
|||||||
@@ -7,15 +7,29 @@ import {
|
|||||||
SettingsSection,
|
SettingsSection,
|
||||||
} from '/@/renderer/features/settings/components/settings-section';
|
} from '/@/renderer/features/settings/components/settings-section';
|
||||||
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
|
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
|
||||||
|
import { ColorInput } from '/@/shared/components/color-input/color-input';
|
||||||
|
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||||
|
import { Select } from '/@/shared/components/select/select';
|
||||||
import { Switch } from '/@/shared/components/switch/switch';
|
import { Switch } from '/@/shared/components/switch/switch';
|
||||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||||
|
|
||||||
|
type FolderView = 'navigation' | 'single' | 'tree';
|
||||||
|
type PlaylistMode = 'compact' | 'expanded';
|
||||||
|
|
||||||
export const SidebarSettings = memo(() => {
|
export const SidebarSettings = memo(() => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const settings = useGeneralSettings();
|
const settings = useGeneralSettings();
|
||||||
const { setSettings } = useSettingsStoreActions();
|
const { setSettings } = useSettingsStoreActions();
|
||||||
|
|
||||||
|
const handleSetSidebarPlaylistFolders = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistFolders: e.target.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSetSidebarPlaylistList = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleSetSidebarPlaylistList = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setSettings({
|
setSettings({
|
||||||
general: {
|
general: {
|
||||||
@@ -56,6 +70,60 @@ export const SidebarSettings = memo(() => {
|
|||||||
});
|
});
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
const [localSeparator, setLocalSeparator] = useState(settings.sidebarPlaylistFolderSeparator);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSeparator(settings.sidebarPlaylistFolderSeparator);
|
||||||
|
}, [settings.sidebarPlaylistFolderSeparator]);
|
||||||
|
|
||||||
|
const debouncedSetSeparator = useDebouncedCallback((value: string) => {
|
||||||
|
if (value.length === 0) return;
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistFolderSeparator: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const folderViewOptions: Array<{ label: string; value: FolderView }> = [
|
||||||
|
{
|
||||||
|
label: t('setting.sidebarPlaylistFolderView_optionSingle', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'single',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('setting.sidebarPlaylistFolderView_optionTree', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'tree',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('setting.sidebarPlaylistFolderView_optionNavigation', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'navigation',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const playlistModeOptions: Array<{ label: string; value: PlaylistMode }> = [
|
||||||
|
{
|
||||||
|
label: t('setting.sidebarPlaylistMode_optionCompact', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'compact',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('setting.sidebarPlaylistMode_optionExpanded', {
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
value: 'expanded',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const foldersEnabled = settings.sidebarPlaylistFolders;
|
||||||
|
const isTreeView = settings.sidebarPlaylistFolderView === 'tree';
|
||||||
|
|
||||||
const options: SettingOption[] = [
|
const options: SettingOption[] = [
|
||||||
{
|
{
|
||||||
control: (
|
control: (
|
||||||
@@ -98,6 +166,137 @@ export const SidebarSettings = memo(() => {
|
|||||||
}),
|
}),
|
||||||
title: t('setting.sidebarPlaylistSorting'),
|
title: t('setting.sidebarPlaylistSorting'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Select
|
||||||
|
data={playlistModeOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!value) return;
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistMode: value as PlaylistMode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
value={settings.sidebarPlaylistMode}
|
||||||
|
width={200}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistMode', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
title: t('setting.sidebarPlaylistMode', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Switch
|
||||||
|
checked={settings.sidebarPlaylistFolders}
|
||||||
|
onChange={handleSetSidebarPlaylistFolders}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistFolders', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
title: t('setting.sidebarPlaylistFolders', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<TextInput
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
setLocalSeparator(value);
|
||||||
|
debouncedSetSeparator(value);
|
||||||
|
}}
|
||||||
|
value={localSeparator}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistFolderSeparator', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
indent: true,
|
||||||
|
isHidden: !foldersEnabled,
|
||||||
|
title: t('setting.sidebarPlaylistFolderSeparator', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<Select
|
||||||
|
data={folderViewOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!value) return;
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistFolderView: value as FolderView,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
value={settings.sidebarPlaylistFolderView}
|
||||||
|
width={200}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistFolderView', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
indent: true,
|
||||||
|
isHidden: !foldersEnabled,
|
||||||
|
title: t('setting.sidebarPlaylistFolderView', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<NumberInput
|
||||||
|
max={64}
|
||||||
|
min={0}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const value = Number(e.currentTarget.value);
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistFolderTreeIndent: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(64, Math.round(value)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={settings.sidebarPlaylistFolderTreeIndent}
|
||||||
|
width={100}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistFolderTreeIndent', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
indent: true,
|
||||||
|
isHidden: !foldersEnabled || !isTreeView,
|
||||||
|
title: t('setting.sidebarPlaylistFolderTreeIndent', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
control: (
|
||||||
|
<ColorInput
|
||||||
|
format="rgba"
|
||||||
|
onChangeEnd={(value) => {
|
||||||
|
setSettings({
|
||||||
|
general: {
|
||||||
|
sidebarPlaylistFolderTreeLineColor: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
value={settings.sidebarPlaylistFolderTreeLineColor}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
description: t('setting.sidebarPlaylistFolderTreeLineColor', {
|
||||||
|
context: 'description',
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
}),
|
||||||
|
indent: true,
|
||||||
|
isHidden: !foldersEnabled || !isTreeView,
|
||||||
|
title: t('setting.sidebarPlaylistFolderTreeLineColor', { postProcess: 'sentenceCase' }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
control: (
|
control: (
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const BINDINGS_MAP: Record<BindingActions, string> = {
|
|||||||
context: 'listPlayNext',
|
context: 'listPlayNext',
|
||||||
}),
|
}),
|
||||||
listPlayNow: i18n.t('setting.hotkey', { context: 'listPlayNow' }),
|
listPlayNow: i18n.t('setting.hotkey', { context: 'listPlayNow' }),
|
||||||
|
listShowPlayingSong: i18n.t('setting.hotkey', { context: 'listShowPlayingSong' }),
|
||||||
localSearch: i18n.t('setting.hotkey', { context: 'localSearch' }),
|
localSearch: i18n.t('setting.hotkey', { context: 'localSearch' }),
|
||||||
navigateHome: i18n.t('setting.hotkey', {
|
navigateHome: i18n.t('setting.hotkey', {
|
||||||
context: 'navigateHome',
|
context: 'navigateHome',
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.row {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-indented {
|
||||||
|
padding-left: var(--theme-spacing-lg);
|
||||||
|
margin-left: var(--theme-spacing-md);
|
||||||
|
border-left: 2px solid var(--theme-colors-surface);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
|
import styles from './settings-option.module.css';
|
||||||
|
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
@@ -9,15 +11,20 @@ import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
|||||||
interface SettingsOptionProps {
|
interface SettingsOptionProps {
|
||||||
control: React.ReactNode;
|
control: React.ReactNode;
|
||||||
description?: React.ReactNode | string;
|
description?: React.ReactNode | string;
|
||||||
|
indent?: boolean;
|
||||||
note?: string;
|
note?: string;
|
||||||
title: React.ReactNode | string;
|
title: React.ReactNode | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsOptions = memo(
|
export const SettingsOptions = memo(
|
||||||
({ control, description, note, title }: SettingsOptionProps) => {
|
({ control, description, indent, note, title }: SettingsOptionProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" style={{ alignItems: 'center' }} wrap="nowrap">
|
<Group
|
||||||
|
className={indent ? styles.rowIndented : styles.row}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
<Stack
|
<Stack
|
||||||
gap="xs"
|
gap="xs"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { TextTitle } from '/@/shared/components/text-title/text-title';
|
|||||||
export type SettingOption = {
|
export type SettingOption = {
|
||||||
control: ReactNode;
|
control: ReactNode;
|
||||||
description: ReactNode | string;
|
description: ReactNode | string;
|
||||||
|
indent?: boolean;
|
||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
note?: string;
|
note?: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
height: 65px;
|
height: 65px;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,19 @@ export const ActionBar = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Grid display="flex" gap="sm" style={{ padding: '0 var(--theme-spacing-md)' }} w="100%">
|
<Grid
|
||||||
|
display="flex"
|
||||||
|
gap="sm"
|
||||||
|
styles={{
|
||||||
|
inner: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
padding: '0 var(--theme-spacing-md',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Grid.Col span={7}>
|
<Grid.Col span={7}>
|
||||||
<TextInput
|
<TextInput
|
||||||
leftSection={<Icon icon="search" />}
|
leftSection={<Icon icon="search" />}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
.folder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-dragged-over {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
content: "";
|
||||||
|
border: 2px solid var(--theme-colors-primary);
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--theme-spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--theme-spacing-xs) var(--theme-spacing-md);
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--theme-colors-surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
padding-left: var(--theme-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-children {
|
||||||
|
padding-left: var(--playlist-folder-tree-indent, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-branch {
|
||||||
|
position: relative;
|
||||||
|
padding-left: calc(var(--playlist-folder-tree-indent, 16px) - 4px);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: calc(var(--playlist-folder-tree-indent, 16px) - 8px);
|
||||||
|
content: "";
|
||||||
|
border-bottom: 1px solid var(--playlist-folder-tree-line-color, var(--theme-colors-border));
|
||||||
|
border-left: 1px solid var(--playlist-folder-tree-line-color, var(--theme-colors-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
content: "";
|
||||||
|
border-left: 1px solid var(--playlist-folder-tree-line-color, var(--theme-colors-border));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-folder {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--theme-spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--theme-spacing-xs) var(--theme-spacing-md);
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--theme-colors-surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-folder-icon {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
min-width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
background-color: var(--theme-colors-surface);
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-drop-target-dragged-over {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
content: "";
|
||||||
|
border: 2px solid var(--theme-colors-primary);
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,971 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
ComponentPropsWithoutRef,
|
||||||
|
CSSProperties,
|
||||||
|
MouseEvent,
|
||||||
|
ReactElement,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import styles from './playlist-folder-tree.module.css';
|
||||||
|
|
||||||
|
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
|
||||||
|
import { PlaylistRowButton } from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
|
||||||
|
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||||
|
import {
|
||||||
|
useCurrentServerId,
|
||||||
|
useSidebarPlaylistFolders,
|
||||||
|
useSidebarPlaylistFolderSeparator,
|
||||||
|
useSidebarPlaylistFolderTreeIndent,
|
||||||
|
useSidebarPlaylistFolderTreeLineColor,
|
||||||
|
useSidebarPlaylistFolderView,
|
||||||
|
} from '/@/renderer/store';
|
||||||
|
import { Accordion } from '/@/shared/components/accordion/accordion';
|
||||||
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
|
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||||
|
import { LibraryItem, Playlist } from '/@/shared/types/domain-types';
|
||||||
|
import { DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||||
|
|
||||||
|
const STORAGE_KEY_PREFIX = 'feishin:playlist-folder-state';
|
||||||
|
|
||||||
|
export const getPlaylistLeafName = (name: string, separator: string): string => {
|
||||||
|
if (!separator) return name;
|
||||||
|
const segments = name.split(separator).filter((segment) => segment.length > 0);
|
||||||
|
return segments[segments.length - 1] ?? name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPlaylistNameInFolder = (
|
||||||
|
folderPath: string,
|
||||||
|
leafName: string,
|
||||||
|
separator: string,
|
||||||
|
): string => {
|
||||||
|
if (!folderPath) return leafName;
|
||||||
|
return `${folderPath}${separator}${leafName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFolderName = (folderPath: string, separator: string): string => {
|
||||||
|
const segments = folderPath.split(separator).filter((segment) => segment.length > 0);
|
||||||
|
return segments[segments.length - 1] ?? folderPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isDirectChildFolder = (
|
||||||
|
childFolderPath: string,
|
||||||
|
parentFolderPath: string,
|
||||||
|
separator: string,
|
||||||
|
): boolean => {
|
||||||
|
// True when child is exactly one folder level below parent (Rock/Classic under Rock).
|
||||||
|
if (!childFolderPath || !parentFolderPath) return false;
|
||||||
|
if (childFolderPath === parentFolderPath) return false;
|
||||||
|
|
||||||
|
const prefix = `${parentFolderPath}${separator}`;
|
||||||
|
if (!childFolderPath.startsWith(prefix)) return false;
|
||||||
|
|
||||||
|
const relativePath = childFolderPath.slice(prefix.length);
|
||||||
|
return relativePath.length > 0 && !relativePath.includes(separator);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isValidFolderNest = (
|
||||||
|
sourceFolderPath: string,
|
||||||
|
targetFolderPath: string,
|
||||||
|
separator: string,
|
||||||
|
): boolean => {
|
||||||
|
// Folder-on-folder drops are allowed except onto self or onto a descendant folder.
|
||||||
|
if (!sourceFolderPath || !targetFolderPath) return false;
|
||||||
|
if (sourceFolderPath === targetFolderPath) return false;
|
||||||
|
if (targetFolderPath.startsWith(`${sourceFolderPath}${separator}`)) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlaylistsInFolderTree = (
|
||||||
|
playlists: Playlist[],
|
||||||
|
folderPath: string,
|
||||||
|
separator: string,
|
||||||
|
): Playlist[] => {
|
||||||
|
// Every playlist whose name lives under this folder path (including nested subfolders).
|
||||||
|
const prefix = `${folderPath}${separator}`;
|
||||||
|
return playlists.filter((playlist) => playlist.name.startsWith(prefix));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remapPlaylistFolderPath = (
|
||||||
|
playlistName: string,
|
||||||
|
sourceFolderPath: string,
|
||||||
|
targetFolderPath: string,
|
||||||
|
separator: string,
|
||||||
|
): null | string => {
|
||||||
|
// Rename one playlist when its containing folder is dropped onto another folder.
|
||||||
|
const sourcePrefix = `${sourceFolderPath}${separator}`;
|
||||||
|
if (!playlistName.startsWith(sourcePrefix)) return null;
|
||||||
|
|
||||||
|
const remainder = playlistName.slice(sourcePrefix.length);
|
||||||
|
if (!remainder) return null;
|
||||||
|
|
||||||
|
if (isDirectChildFolder(sourceFolderPath, targetFolderPath, separator)) {
|
||||||
|
// Direct parent: flatten playlists into the parent (Rock/Classic/x -> Rock/x).
|
||||||
|
return `${targetFolderPath}${separator}${remainder}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceFolderPath.startsWith(`${targetFolderPath}${separator}`)) {
|
||||||
|
// Higher ancestor: move the folder as a unit (Pop/Rock/Classic/x -> Pop/Classic/x).
|
||||||
|
const folderName = getFolderName(sourceFolderPath, separator);
|
||||||
|
return `${targetFolderPath}${separator}${folderName}${separator}${remainder}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unrelated target: nest the full source folder path (Classic/x -> Rock/Classic/x).
|
||||||
|
return `${targetFolderPath}${separator}${sourceFolderPath}${separator}${remainder}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remapPlaylistToRoot = (playlistName: string, separator: string): string => {
|
||||||
|
return getPlaylistLeafName(playlistName, separator).trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePlaylistName = async (
|
||||||
|
updateMutation: ReturnType<typeof useUpdatePlaylist>,
|
||||||
|
serverId: string,
|
||||||
|
playlist: Playlist,
|
||||||
|
newName: string,
|
||||||
|
) => {
|
||||||
|
if (newName === playlist.name) return;
|
||||||
|
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
apiClientProps: { serverId },
|
||||||
|
body: {
|
||||||
|
comment: playlist.description || '',
|
||||||
|
name: newName,
|
||||||
|
ownerId: playlist.ownerId || '',
|
||||||
|
public: playlist.public || false,
|
||||||
|
queryBuilderRules: playlist.rules ?? undefined,
|
||||||
|
sync: playlist.sync ?? undefined,
|
||||||
|
},
|
||||||
|
query: { id: playlist.id },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlaylistRootDrop = (allPlaylists: Playlist[]) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
|
const separator = useSidebarPlaylistFolderSeparator();
|
||||||
|
const updateMutation = useUpdatePlaylist({});
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
async (source: DragData) => {
|
||||||
|
if (!serverId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
|
||||||
|
const sourceFolderPath =
|
||||||
|
source.id[0] ??
|
||||||
|
(source.metadata as undefined | { folderName?: string })?.folderName;
|
||||||
|
if (!sourceFolderPath) return;
|
||||||
|
|
||||||
|
const affected = getPlaylistsInFolderTree(
|
||||||
|
allPlaylists,
|
||||||
|
sourceFolderPath,
|
||||||
|
separator,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const playlist of affected) {
|
||||||
|
await updatePlaylistName(
|
||||||
|
updateMutation,
|
||||||
|
serverId,
|
||||||
|
playlist,
|
||||||
|
remapPlaylistToRoot(playlist.name, separator),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlists = source.item as Playlist[] | undefined;
|
||||||
|
if (!Array.isArray(playlists) || playlists.length === 0) return;
|
||||||
|
|
||||||
|
for (const playlist of playlists) {
|
||||||
|
await updatePlaylistName(
|
||||||
|
updateMutation,
|
||||||
|
serverId,
|
||||||
|
playlist,
|
||||||
|
remapPlaylistToRoot(playlist.name, separator),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error({
|
||||||
|
message: err instanceof Error ? err.message : undefined,
|
||||||
|
title: t('error.genericError'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[allPlaylists, separator, serverId, t, updateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { isDraggedOver, ref } = useDragDrop<HTMLButtonElement>({
|
||||||
|
drop: {
|
||||||
|
canDrop: (args) => {
|
||||||
|
if (args.source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
|
||||||
|
return Boolean(args.source.id[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.source.itemType !== LibraryItem.PLAYLIST) return false;
|
||||||
|
const items = args.source.item as Playlist[] | undefined;
|
||||||
|
return Array.isArray(items) && items.length > 0;
|
||||||
|
},
|
||||||
|
getData: () => ({
|
||||||
|
id: [''],
|
||||||
|
type: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
|
||||||
|
}),
|
||||||
|
onDrag: () => {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
onDragLeave: () => {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
onDrop: ({ source }) => {
|
||||||
|
void handleDrop(source);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { isDraggedOver, ref };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaylistRootAccordionControlProps extends Omit<
|
||||||
|
ComponentPropsWithoutRef<typeof Accordion.Control>,
|
||||||
|
'ref'
|
||||||
|
> {
|
||||||
|
allPlaylists: Playlist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistRootAccordionControl = ({
|
||||||
|
allPlaylists,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...controlProps
|
||||||
|
}: PlaylistRootAccordionControlProps) => {
|
||||||
|
const { isDraggedOver, ref } = usePlaylistRootDrop(allPlaylists);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.Control
|
||||||
|
className={clsx(className, {
|
||||||
|
[styles.rootDropTargetDraggedOver]: isDraggedOver,
|
||||||
|
})}
|
||||||
|
component="div"
|
||||||
|
ref={ref}
|
||||||
|
role="button"
|
||||||
|
style={{ userSelect: 'none' }}
|
||||||
|
{...controlProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Accordion.Control>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag-and-drop on folder headers: folders can be dragged, and accept folder or playlist drops.
|
||||||
|
const usePlaylistFolderDrop = (folderPath: string, allPlaylists: Playlist[]) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
|
const separator = useSidebarPlaylistFolderSeparator();
|
||||||
|
const updateMutation = useUpdatePlaylist({});
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
async (source: DragData) => {
|
||||||
|
if (!serverId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
|
||||||
|
// Folder drop: rename every playlist under the dragged folder tree.
|
||||||
|
const sourceFolderPath =
|
||||||
|
source.id[0] ??
|
||||||
|
(source.metadata as undefined | { folderName?: string })?.folderName;
|
||||||
|
if (
|
||||||
|
!sourceFolderPath ||
|
||||||
|
!isValidFolderNest(sourceFolderPath, folderPath, separator)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const affected = getPlaylistsInFolderTree(
|
||||||
|
allPlaylists,
|
||||||
|
sourceFolderPath,
|
||||||
|
separator,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const playlist of affected) {
|
||||||
|
const newName = remapPlaylistFolderPath(
|
||||||
|
playlist.name,
|
||||||
|
sourceFolderPath,
|
||||||
|
folderPath,
|
||||||
|
separator,
|
||||||
|
);
|
||||||
|
if (!newName || newName === playlist.name) continue;
|
||||||
|
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
apiClientProps: { serverId },
|
||||||
|
body: {
|
||||||
|
comment: playlist.description || '',
|
||||||
|
name: newName,
|
||||||
|
ownerId: playlist.ownerId || '',
|
||||||
|
public: playlist.public || false,
|
||||||
|
queryBuilderRules: playlist.rules ?? undefined,
|
||||||
|
sync: playlist.sync ?? undefined,
|
||||||
|
},
|
||||||
|
query: { id: playlist.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist drop: move a single playlist into this folder using its leaf name only.
|
||||||
|
const playlists = source.item as Playlist[] | undefined;
|
||||||
|
if (!Array.isArray(playlists) || playlists.length === 0) return;
|
||||||
|
|
||||||
|
for (const playlist of playlists) {
|
||||||
|
const leafName = getPlaylistLeafName(playlist.name, separator);
|
||||||
|
const newName = buildPlaylistNameInFolder(folderPath, leafName, separator);
|
||||||
|
if (newName === playlist.name) continue;
|
||||||
|
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
apiClientProps: { serverId },
|
||||||
|
body: {
|
||||||
|
comment: playlist.description || '',
|
||||||
|
name: newName,
|
||||||
|
ownerId: playlist.ownerId || '',
|
||||||
|
public: playlist.public || false,
|
||||||
|
queryBuilderRules: playlist.rules ?? undefined,
|
||||||
|
sync: playlist.sync ?? undefined,
|
||||||
|
},
|
||||||
|
query: { id: playlist.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error({
|
||||||
|
message: err instanceof Error ? err.message : undefined,
|
||||||
|
title: t('error.genericError'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[allPlaylists, folderPath, separator, serverId, t, updateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLButtonElement>({
|
||||||
|
drag: {
|
||||||
|
// Folders are virtual; drag data carries the folder path, not playlist items.
|
||||||
|
getId: () => [folderPath],
|
||||||
|
getItem: () => [],
|
||||||
|
metadata: { folderName: folderPath },
|
||||||
|
operation: [DragOperation.ADD],
|
||||||
|
target: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
|
||||||
|
},
|
||||||
|
drop: {
|
||||||
|
canDrop: (args) => {
|
||||||
|
if (args.source.type === DragTarget.SIDEBAR_PLAYLIST_FOLDER) {
|
||||||
|
const sourceFolderPath =
|
||||||
|
args.source.id[0] ??
|
||||||
|
(args.source.metadata as undefined | { folderName?: string })?.folderName;
|
||||||
|
if (!sourceFolderPath) return false;
|
||||||
|
return isValidFolderNest(sourceFolderPath, folderPath, separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single playlist rows can also be dropped onto a folder header.
|
||||||
|
if (args.source.itemType !== LibraryItem.PLAYLIST) return false;
|
||||||
|
const items = args.source.item as Playlist[] | undefined;
|
||||||
|
return Array.isArray(items) && items.length > 0;
|
||||||
|
},
|
||||||
|
getData: () => ({
|
||||||
|
id: [folderPath],
|
||||||
|
type: DragTarget.SIDEBAR_PLAYLIST_FOLDER,
|
||||||
|
}),
|
||||||
|
onDrag: () => {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
onDragLeave: () => {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
onDrop: ({ source }) => {
|
||||||
|
void handleDrop(source);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { isDraggedOver, isDragging, ref };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaylistFolderHeaderProps {
|
||||||
|
allPlaylists: Playlist[];
|
||||||
|
folderPath: string;
|
||||||
|
isOpen?: boolean;
|
||||||
|
leafCount: number;
|
||||||
|
name: string;
|
||||||
|
onClick: () => void;
|
||||||
|
variant: 'header' | 'nav';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaylistFolderHeader = ({
|
||||||
|
allPlaylists,
|
||||||
|
folderPath,
|
||||||
|
isOpen,
|
||||||
|
leafCount,
|
||||||
|
name,
|
||||||
|
onClick,
|
||||||
|
variant,
|
||||||
|
}: PlaylistFolderHeaderProps) => {
|
||||||
|
const { isDraggedOver, isDragging, ref } = usePlaylistFolderDrop(folderPath, allPlaylists);
|
||||||
|
|
||||||
|
if (variant === 'nav') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={name}
|
||||||
|
className={clsx(styles.navFolder, {
|
||||||
|
[styles.headerDraggedOver]: isDraggedOver,
|
||||||
|
})}
|
||||||
|
onClick={onClick}
|
||||||
|
ref={ref}
|
||||||
|
style={{ opacity: isDragging ? 0.5 : 1 }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className={styles.navFolderIcon}>
|
||||||
|
<Icon color="muted" icon="folder" size="xl" />
|
||||||
|
</div>
|
||||||
|
<Text className={styles.name} fw={500} size="md">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.count} isMuted size="sm">
|
||||||
|
{leafCount}
|
||||||
|
</Text>
|
||||||
|
<Icon className={styles.navChevron} icon="arrowRightS" size="sm" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={name}
|
||||||
|
className={clsx(styles.header, {
|
||||||
|
[styles.headerDraggedOver]: isDraggedOver,
|
||||||
|
})}
|
||||||
|
onClick={onClick}
|
||||||
|
ref={ref}
|
||||||
|
style={{ opacity: isDragging ? 0.5 : 1 }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={styles.chevron}
|
||||||
|
icon={isOpen ? 'arrowDownS' : 'arrowRightS'}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Icon color="muted" icon="folder" size="sm" />
|
||||||
|
<Text className={styles.name} fw={500} size="md">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text className={styles.count} isMuted size="sm">
|
||||||
|
{leafCount}
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FolderNode = {
|
||||||
|
children: TreeNode[];
|
||||||
|
leafCount: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'folder';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeafNode = {
|
||||||
|
displayName: string;
|
||||||
|
item: Playlist;
|
||||||
|
type: 'leaf';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaylistFolderScope = 'owned' | 'shared';
|
||||||
|
|
||||||
|
export type PlaylistGroup =
|
||||||
|
| { item: Playlist; type: 'root' }
|
||||||
|
| { items: Playlist[]; name: string; type: 'folder' };
|
||||||
|
|
||||||
|
export type TreeNode = FolderNode | LeafNode;
|
||||||
|
|
||||||
|
const splitOnce = (name: string, separator: string): [string, string] | null => {
|
||||||
|
const idx = name.indexOf(separator);
|
||||||
|
// Reject any separators at the end
|
||||||
|
if (idx <= 0 || idx >= name.length - separator.length) return null;
|
||||||
|
return [name.slice(0, idx), name.slice(idx + separator.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const groupPlaylists = (items: Playlist[], separator: string): PlaylistGroup[] => {
|
||||||
|
const folders: PlaylistGroup[] = [];
|
||||||
|
const roots: PlaylistGroup[] = [];
|
||||||
|
const folderIndex = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const split = splitOnce(item.name, separator);
|
||||||
|
if (split) {
|
||||||
|
const [folderName] = split;
|
||||||
|
const existing = folderIndex.get(folderName);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
const group = folders[existing];
|
||||||
|
if (group.type === 'folder') {
|
||||||
|
group.items.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folderIndex.set(folderName, folders.length);
|
||||||
|
folders.push({ items: [item], name: folderName, type: 'folder' });
|
||||||
|
} else {
|
||||||
|
roots.push({ item, type: 'root' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...folders, ...roots];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPlaylistTree = (items: Playlist[], separator: string): TreeNode[] => {
|
||||||
|
const root: TreeNode[] = [];
|
||||||
|
const folderByPath = new Map<string, FolderNode>();
|
||||||
|
|
||||||
|
const ensureFolder = (segments: string[], parent: TreeNode[]): FolderNode => {
|
||||||
|
const path = segments.join(separator);
|
||||||
|
const existing = folderByPath.get(path);
|
||||||
|
if (existing) return existing;
|
||||||
|
const node: FolderNode = {
|
||||||
|
children: [],
|
||||||
|
leafCount: 0,
|
||||||
|
name: segments[segments.length - 1],
|
||||||
|
path,
|
||||||
|
type: 'folder',
|
||||||
|
};
|
||||||
|
folderByPath.set(path, node);
|
||||||
|
parent.push(node);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const segments = separator ? item.name.split(separator) : [item.name];
|
||||||
|
const validSegments = segments.filter((s) => s.length > 0);
|
||||||
|
|
||||||
|
if (validSegments.length <= 1) {
|
||||||
|
root.push({ displayName: item.name, item, type: 'leaf' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent: TreeNode[] = root;
|
||||||
|
const pathStack: string[] = [];
|
||||||
|
for (let i = 0; i < validSegments.length - 1; i++) {
|
||||||
|
pathStack.push(validSegments[i]);
|
||||||
|
const folder = ensureFolder([...pathStack], parent);
|
||||||
|
parent = folder.children;
|
||||||
|
}
|
||||||
|
const leafName = validSegments[validSegments.length - 1];
|
||||||
|
parent.push({ displayName: leafName, item, type: 'leaf' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortFoldersFirst = (nodes: TreeNode[]): TreeNode[] => {
|
||||||
|
const folderNodes: TreeNode[] = [];
|
||||||
|
const leafNodes: TreeNode[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
node.children = sortFoldersFirst(node.children);
|
||||||
|
folderNodes.push(node);
|
||||||
|
} else {
|
||||||
|
leafNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...folderNodes, ...leafNodes];
|
||||||
|
};
|
||||||
|
|
||||||
|
const countLeaves = (nodes: TreeNode[]): number => {
|
||||||
|
let total = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'leaf') {
|
||||||
|
total += 1;
|
||||||
|
} else {
|
||||||
|
node.leafCount = countLeaves(node.children);
|
||||||
|
total += node.leafCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = sortFoldersFirst(root);
|
||||||
|
countLeaves(sorted);
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectFolderPaths = (nodes: TreeNode[]): string[] => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
const walk = (list: TreeNode[]) => {
|
||||||
|
for (const node of list) {
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
paths.push(node.path);
|
||||||
|
walk(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(nodes);
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlaylistFolderState = (scope: PlaylistFolderScope) => {
|
||||||
|
const [expanded, setExpanded] = useLocalStorage<string[]>({
|
||||||
|
defaultValue: [],
|
||||||
|
key: `${STORAGE_KEY_PREFIX}:${scope}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expandedSet = useMemo(() => new Set(expanded), [expanded]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
return Array.from(next);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setMany = useCallback(
|
||||||
|
(paths: string[], shouldExpand: boolean) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (shouldExpand) {
|
||||||
|
for (const p of paths) next.add(p);
|
||||||
|
} else {
|
||||||
|
for (const p of paths) next.delete(p);
|
||||||
|
}
|
||||||
|
return Array.from(next);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { expandedSet, setMany, toggle };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaylistFolderTreeProps {
|
||||||
|
allPlaylists: Playlist[];
|
||||||
|
expandedSet: Set<string>;
|
||||||
|
groups: PlaylistGroup[];
|
||||||
|
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||||
|
onReorder: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;
|
||||||
|
onToggleFolder: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistFolderTree = ({
|
||||||
|
allPlaylists,
|
||||||
|
expandedSet,
|
||||||
|
groups,
|
||||||
|
onContextMenu,
|
||||||
|
onReorder,
|
||||||
|
onToggleFolder,
|
||||||
|
}: PlaylistFolderTreeProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{groups.map((group) => {
|
||||||
|
if (group.type === 'root') {
|
||||||
|
return (
|
||||||
|
<PlaylistRowButton
|
||||||
|
item={group.item}
|
||||||
|
key={group.item.id}
|
||||||
|
name={group.item.name}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
to={group.item.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = expandedSet.has(group.name);
|
||||||
|
return (
|
||||||
|
<div className={styles.folder} key={`folder:${group.name}`}>
|
||||||
|
<PlaylistFolderHeader
|
||||||
|
allPlaylists={allPlaylists}
|
||||||
|
folderPath={group.name}
|
||||||
|
isOpen={isOpen}
|
||||||
|
leafCount={group.items.length}
|
||||||
|
name={group.name}
|
||||||
|
onClick={() => onToggleFolder(group.name)}
|
||||||
|
variant="header"
|
||||||
|
/>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={styles.children}>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<PlaylistRowButton
|
||||||
|
item={item}
|
||||||
|
key={item.id}
|
||||||
|
name={item.name.slice(group.name.length + 1)}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
to={item.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaylistFolderTreeViewProps {
|
||||||
|
allPlaylists: Playlist[];
|
||||||
|
expandedSet: Set<string>;
|
||||||
|
nodes: TreeNode[];
|
||||||
|
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||||
|
onReorder: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;
|
||||||
|
onToggleFolder: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistFolderTreeView = ({
|
||||||
|
allPlaylists,
|
||||||
|
expandedSet,
|
||||||
|
nodes,
|
||||||
|
onContextMenu,
|
||||||
|
onReorder,
|
||||||
|
onToggleFolder,
|
||||||
|
}: PlaylistFolderTreeViewProps) => {
|
||||||
|
const renderNode = (node: TreeNode): ReactElement => {
|
||||||
|
if (node.type === 'leaf') {
|
||||||
|
return (
|
||||||
|
<PlaylistRowButton
|
||||||
|
item={node.item}
|
||||||
|
key={node.item.id}
|
||||||
|
name={node.displayName}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
to={node.item.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = expandedSet.has(node.path);
|
||||||
|
return (
|
||||||
|
<div className={styles.folder} key={`folder:${node.path}`}>
|
||||||
|
<PlaylistFolderHeader
|
||||||
|
allPlaylists={allPlaylists}
|
||||||
|
folderPath={node.path}
|
||||||
|
isOpen={isOpen}
|
||||||
|
leafCount={node.leafCount}
|
||||||
|
name={node.name}
|
||||||
|
onClick={() => onToggleFolder(node.path)}
|
||||||
|
variant="header"
|
||||||
|
/>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={styles.treeChildren}>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<div className={styles.treeBranch} key={getNodeKey(child)}>
|
||||||
|
{renderNode(child)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>{nodes.map((node) => renderNode(node))}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodeKey = (node: TreeNode) =>
|
||||||
|
node.type === 'leaf' ? `leaf:${node.item.id}` : `folder:${node.path}`;
|
||||||
|
|
||||||
|
export interface PlaylistNavigationState {
|
||||||
|
currentName: string | undefined;
|
||||||
|
enter: (name: string) => void;
|
||||||
|
goUp: () => void;
|
||||||
|
pathStack: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlaylistNavigationState = (): PlaylistNavigationState => {
|
||||||
|
const [pathStack, setPathStack] = useState<string[]>([]);
|
||||||
|
const enter = useCallback((name: string) => setPathStack((prev) => [...prev, name]), []);
|
||||||
|
const goUp = useCallback(() => setPathStack((prev) => prev.slice(0, -1)), []);
|
||||||
|
return {
|
||||||
|
currentName: pathStack[pathStack.length - 1],
|
||||||
|
enter,
|
||||||
|
goUp,
|
||||||
|
pathStack,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaylistFolderNavigationViewProps {
|
||||||
|
allPlaylists: Playlist[];
|
||||||
|
nodes: TreeNode[];
|
||||||
|
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||||
|
onEnter: (name: string) => void;
|
||||||
|
onReorder: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;
|
||||||
|
pathStack: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistFolderNavigationView = ({
|
||||||
|
allPlaylists,
|
||||||
|
nodes,
|
||||||
|
onContextMenu,
|
||||||
|
onEnter,
|
||||||
|
onReorder,
|
||||||
|
pathStack,
|
||||||
|
}: PlaylistFolderNavigationViewProps) => {
|
||||||
|
const currentNodes = useMemo(() => {
|
||||||
|
let list = nodes;
|
||||||
|
for (const segment of pathStack) {
|
||||||
|
const folder = list.find(
|
||||||
|
(n): n is FolderNode => n.type === 'folder' && n.name === segment,
|
||||||
|
);
|
||||||
|
if (!folder) return [] as TreeNode[];
|
||||||
|
list = folder.children;
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [nodes, pathStack]);
|
||||||
|
|
||||||
|
const { folders, leaves } = useMemo(() => {
|
||||||
|
const fs: FolderNode[] = [];
|
||||||
|
const ls: LeafNode[] = [];
|
||||||
|
for (const node of currentNodes) {
|
||||||
|
if (node.type === 'folder') fs.push(node);
|
||||||
|
else ls.push(node);
|
||||||
|
}
|
||||||
|
return { folders: fs, leaves: ls };
|
||||||
|
}, [currentNodes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.navigation}>
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<PlaylistFolderHeader
|
||||||
|
allPlaylists={allPlaylists}
|
||||||
|
folderPath={folder.path}
|
||||||
|
key={`navfolder:${folder.path}`}
|
||||||
|
leafCount={folder.leafCount}
|
||||||
|
name={folder.name}
|
||||||
|
onClick={() => onEnter(folder.name)}
|
||||||
|
variant="nav"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{leaves.map((leaf) => (
|
||||||
|
<PlaylistRowButton
|
||||||
|
item={leaf.item}
|
||||||
|
key={leaf.item.id}
|
||||||
|
name={leaf.displayName}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
to={leaf.item.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaylistFolderViewState = {
|
||||||
|
foldersEnabled: boolean;
|
||||||
|
folderView: 'navigation' | 'single' | 'tree';
|
||||||
|
groups: PlaylistGroup[];
|
||||||
|
tree: TreeNode[];
|
||||||
|
treeStyle: CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlaylistFolderViewState = (items: Playlist[]): PlaylistFolderViewState => {
|
||||||
|
const foldersEnabled = useSidebarPlaylistFolders();
|
||||||
|
const folderView = useSidebarPlaylistFolderView();
|
||||||
|
const separator = useSidebarPlaylistFolderSeparator();
|
||||||
|
const treeIndent = useSidebarPlaylistFolderTreeIndent();
|
||||||
|
const treeLineColor = useSidebarPlaylistFolderTreeLineColor();
|
||||||
|
|
||||||
|
const groups = useMemo<PlaylistGroup[]>(
|
||||||
|
() =>
|
||||||
|
foldersEnabled && folderView === 'single'
|
||||||
|
? groupPlaylists(items, separator)
|
||||||
|
: items.map((item) => ({ item, type: 'root' as const })),
|
||||||
|
[foldersEnabled, folderView, items, separator],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tree = useMemo<TreeNode[]>(
|
||||||
|
() =>
|
||||||
|
foldersEnabled && folderView !== 'single' ? buildPlaylistTree(items, separator) : [],
|
||||||
|
[foldersEnabled, folderView, items, separator],
|
||||||
|
);
|
||||||
|
|
||||||
|
const treeStyle = useMemo<CSSProperties>(
|
||||||
|
() => ({
|
||||||
|
...(typeof treeIndent === 'number'
|
||||||
|
? { ['--playlist-folder-tree-indent' as never]: `${treeIndent}px` }
|
||||||
|
: {}),
|
||||||
|
...(treeLineColor
|
||||||
|
? { ['--playlist-folder-tree-line-color' as never]: treeLineColor }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
[treeIndent, treeLineColor],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { foldersEnabled, folderView, groups, tree, treeStyle };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaylistFolderViewsProps extends PlaylistFolderViewState {
|
||||||
|
allPlaylists: Playlist[];
|
||||||
|
expandedSet: Set<string>;
|
||||||
|
navigation: PlaylistNavigationState;
|
||||||
|
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||||
|
onReorder: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;
|
||||||
|
onToggleFolder: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistFolderViews = ({
|
||||||
|
allPlaylists,
|
||||||
|
expandedSet,
|
||||||
|
foldersEnabled,
|
||||||
|
folderView,
|
||||||
|
groups,
|
||||||
|
navigation,
|
||||||
|
onContextMenu,
|
||||||
|
onReorder,
|
||||||
|
onToggleFolder,
|
||||||
|
tree,
|
||||||
|
treeStyle,
|
||||||
|
}: PlaylistFolderViewsProps) => {
|
||||||
|
if (foldersEnabled && folderView === 'tree') {
|
||||||
|
return (
|
||||||
|
<div style={treeStyle}>
|
||||||
|
<PlaylistFolderTreeView
|
||||||
|
allPlaylists={allPlaylists}
|
||||||
|
expandedSet={expandedSet}
|
||||||
|
nodes={tree}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foldersEnabled && folderView === 'navigation') {
|
||||||
|
return (
|
||||||
|
<PlaylistFolderNavigationView
|
||||||
|
allPlaylists={allPlaylists}
|
||||||
|
nodes={tree}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onEnter={navigation.enter}
|
||||||
|
onReorder={onReorder}
|
||||||
|
pathStack={navigation.pathStack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlaylistFolderTree
|
||||||
|
allPlaylists={allPlaylists}
|
||||||
|
expandedSet={expandedSet}
|
||||||
|
groups={groups}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onReorder={onReorder}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,13 +13,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.row-hover {
|
.row-hover {
|
||||||
.metadata {
|
.metadata,
|
||||||
|
.compact-name {
|
||||||
margin-right: 100px;
|
margin-right: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
background-color: var(--theme-colors-surface);
|
background-color: var(--theme-colors-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-compact {
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -29,6 +45,18 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.controls-compact {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: var(--theme-spacing-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 var(--theme-spacing-sm);
|
||||||
|
background: none;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.row-dragged-over {
|
.row-dragged-over {
|
||||||
border-radius: var(--mantine-radius-sm);
|
border-radius: var(--mantine-radius-sm);
|
||||||
box-shadow: 0 0 0 2px var(--theme-colors-primary);
|
box-shadow: 0 0 0 2px var(--theme-colors-primary);
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import {
|
|||||||
PlayTooltip,
|
PlayTooltip,
|
||||||
} from '/@/renderer/features/shared/components/play-button-group';
|
} from '/@/renderer/features/shared/components/play-button-group';
|
||||||
import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';
|
import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';
|
||||||
|
import {
|
||||||
|
collectFolderPaths,
|
||||||
|
PlaylistFolderViews,
|
||||||
|
PlaylistRootAccordionControl,
|
||||||
|
usePlaylistFolderState,
|
||||||
|
usePlaylistFolderViewState,
|
||||||
|
usePlaylistNavigationState,
|
||||||
|
} from '/@/renderer/features/sidebar/components/playlist-folder-tree';
|
||||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +32,7 @@ import {
|
|||||||
useCurrentServerId,
|
useCurrentServerId,
|
||||||
usePermissions,
|
usePermissions,
|
||||||
useSidebarPlaylistListFilterRegex,
|
useSidebarPlaylistListFilterRegex,
|
||||||
|
useSidebarPlaylistMode,
|
||||||
useSidebarPlaylistSorting,
|
useSidebarPlaylistSorting,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { formatDurationString } from '/@/renderer/utils';
|
import { formatDurationString } from '/@/renderer/utils';
|
||||||
@@ -50,7 +59,7 @@ const getPlaylistOrderKey = (serverId: string | undefined, scope: 'owned' | 'sha
|
|||||||
return `playlist_order:${sid}:${scope}`;
|
return `playlist_order:${sid}:${scope}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
|
export interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
|
||||||
item: Playlist;
|
item: Playlist;
|
||||||
name: string;
|
name: string;
|
||||||
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||||
@@ -58,7 +67,7 @@ interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'on
|
|||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaylistRowButton = memo(
|
export const PlaylistRowButton = memo(
|
||||||
({ item, name, onContextMenu, onReorder, to }: PlaylistRowButtonProps) => {
|
({ item, name, onContextMenu, onReorder, to }: PlaylistRowButtonProps) => {
|
||||||
const url = {
|
const url = {
|
||||||
pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }),
|
pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }),
|
||||||
@@ -66,6 +75,8 @@ const PlaylistRowButton = memo(
|
|||||||
};
|
};
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
|
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
|
||||||
|
const sidebarPlaylistMode = useSidebarPlaylistMode();
|
||||||
|
const isCompact = sidebarPlaylistMode === 'compact';
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
@@ -213,6 +224,7 @@ const PlaylistRowButton = memo(
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className={clsx(styles.row, {
|
className={clsx(styles.row, {
|
||||||
|
[styles.rowCompact]: isCompact,
|
||||||
[styles.rowDraggedOver]: isDraggedOver,
|
[styles.rowDraggedOver]: isDraggedOver,
|
||||||
[styles.rowHover]: isHovered,
|
[styles.rowHover]: isHovered,
|
||||||
})}
|
})}
|
||||||
@@ -228,6 +240,15 @@ const PlaylistRowButton = memo(
|
|||||||
}}
|
}}
|
||||||
to={url}
|
to={url}
|
||||||
>
|
>
|
||||||
|
{isCompact ? (
|
||||||
|
<>
|
||||||
|
<Text className={styles.compactName} fw={500} size="md">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{isHovered && <RowControls id={to} onPlay={handlePlay} variant="compact" />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className={styles.rowGroup}>
|
<div className={styles.rowGroup}>
|
||||||
<Image containerClassName={styles.imageContainer} src={imageUrl} />
|
<Image containerClassName={styles.imageContainer} src={imageUrl} />
|
||||||
<div className={styles.metadata}>
|
<div className={styles.metadata}>
|
||||||
@@ -252,7 +273,8 @@ const PlaylistRowButton = memo(
|
|||||||
{formatDurationString(item.duration ?? 0)}
|
{formatDurationString(item.duration ?? 0)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{item.ownerId === permissions.userId && Boolean(item.public) && (
|
{item.ownerId === permissions.userId &&
|
||||||
|
Boolean(item.public) && (
|
||||||
<div className={styles.metadataGroupItem}>
|
<div className={styles.metadataGroupItem}>
|
||||||
<Text isMuted size="sm">
|
<Text isMuted size="sm">
|
||||||
{t('common.public')}
|
{t('common.public')}
|
||||||
@@ -272,6 +294,8 @@ const PlaylistRowButton = memo(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isHovered && <RowControls id={to} onPlay={handlePlay} />}
|
{isHovered && <RowControls id={to} onPlay={handlePlay} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -280,9 +304,11 @@ const PlaylistRowButton = memo(
|
|||||||
const RowControls = ({
|
const RowControls = ({
|
||||||
id,
|
id,
|
||||||
onPlay,
|
onPlay,
|
||||||
|
variant = 'expanded',
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
onPlay: (id: string, playType: Play) => void;
|
onPlay: (id: string, playType: Play) => void;
|
||||||
|
variant?: 'compact' | 'expanded';
|
||||||
}) => {
|
}) => {
|
||||||
const handlePlayNext = usePlayButtonClick({
|
const handlePlayNext = usePlayButtonClick({
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -312,7 +338,11 @@ const RowControls = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIconGroup className={styles.controls}>
|
<ActionIconGroup
|
||||||
|
className={clsx(styles.controls, {
|
||||||
|
[styles.controlsCompact]: variant === 'compact',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<PlayTooltip type={Play.NOW}>
|
<PlayTooltip type={Play.NOW}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="mediaPlay"
|
icon="mediaPlay"
|
||||||
@@ -485,11 +515,62 @@ export const SidebarPlaylistList = () => {
|
|||||||
openCreatePlaylistModal(server, e);
|
openCreatePlaylistModal(server, e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []);
|
||||||
|
const { folderView, groups, tree } = folderViewState;
|
||||||
|
const navigation = usePlaylistNavigationState();
|
||||||
|
const inNavigation = folderView === 'navigation' && navigation.pathStack.length > 0;
|
||||||
|
|
||||||
|
const folderPaths = useMemo(() => {
|
||||||
|
if (folderView === 'single') {
|
||||||
|
return groups.reduce<string[]>((acc, g) => {
|
||||||
|
if (g.type === 'folder') acc.push(g.name);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
return collectFolderPaths(tree);
|
||||||
|
}, [folderView, groups, tree]);
|
||||||
|
|
||||||
|
const { expandedSet, setMany, toggle } = usePlaylistFolderState('owned');
|
||||||
|
const allExpanded =
|
||||||
|
folderPaths.length > 0 && folderPaths.every((path) => expandedSet.has(path));
|
||||||
|
|
||||||
|
const handleToggleAllFolders = useCallback(
|
||||||
|
(e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMany(folderPaths, !allExpanded);
|
||||||
|
},
|
||||||
|
[setMany, folderPaths, allExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNavigateUp = useCallback(
|
||||||
|
(e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigation.goUp();
|
||||||
|
},
|
||||||
|
[navigation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showExpandAll = folderView !== 'navigation' && folderPaths.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion.Item value="playlists">
|
<Accordion.Item value="playlists">
|
||||||
<Accordion.Control component="div" role="button" style={{ userSelect: 'none' }}>
|
<PlaylistRootAccordionControl allPlaylists={playlistItems?.items ?? []}>
|
||||||
<Group justify="space-between" pr="var(--theme-spacing-md)">
|
<Group gap="xs" justify="space-between" pr="var(--theme-spacing-md)" wrap="nowrap">
|
||||||
<Text fw={500}>{t('page.sidebar.playlists')}</Text>
|
<Group gap="xs" style={{ minWidth: 0 }} wrap="nowrap">
|
||||||
|
{inNavigation && (
|
||||||
|
<ActionIcon
|
||||||
|
icon="arrowLeftS"
|
||||||
|
iconProps={{ size: 'lg' }}
|
||||||
|
onClick={handleNavigateUp}
|
||||||
|
size="xs"
|
||||||
|
tooltip={{ label: t('common.back') }}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text className={styles.name} fw={500}>
|
||||||
|
{inNavigation ? navigation.currentName : t('page.sidebar.playlists')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="add"
|
icon="add"
|
||||||
@@ -503,6 +584,27 @@ export const SidebarPlaylistList = () => {
|
|||||||
}}
|
}}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
/>
|
/>
|
||||||
|
{showExpandAll && (
|
||||||
|
<ActionIcon
|
||||||
|
icon={allExpanded ? 'collapseAll' : 'expandAll'}
|
||||||
|
iconProps={{
|
||||||
|
size: 'lg',
|
||||||
|
}}
|
||||||
|
onClick={handleToggleAllFolders}
|
||||||
|
size="xs"
|
||||||
|
tooltip={{
|
||||||
|
label: t(
|
||||||
|
allExpanded
|
||||||
|
? 'action.collapseAllFolders'
|
||||||
|
: 'action.expandAllFolders',
|
||||||
|
{
|
||||||
|
postProcess: 'sentenceCase',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component={Link}
|
component={Link}
|
||||||
icon="list"
|
icon="list"
|
||||||
@@ -519,18 +621,17 @@ export const SidebarPlaylistList = () => {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Accordion.Control>
|
</PlaylistRootAccordionControl>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
{playlistItems?.items?.map((item, index) => (
|
<PlaylistFolderViews
|
||||||
<PlaylistRowButton
|
{...folderViewState}
|
||||||
item={item}
|
allPlaylists={playlistItems?.items ?? []}
|
||||||
key={index}
|
expandedSet={expandedSet}
|
||||||
name={item.name}
|
navigation={navigation}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
to={item.id}
|
onToggleFolder={toggle}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
);
|
);
|
||||||
@@ -668,28 +769,53 @@ export const SidebarSharedPlaylistList = () => {
|
|||||||
setPlaylistOrder(reorderedIds);
|
setPlaylistOrder(reorderedIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []);
|
||||||
|
const navigation = usePlaylistNavigationState();
|
||||||
|
const { expandedSet, toggle } = usePlaylistFolderState('shared');
|
||||||
|
const inNavigation =
|
||||||
|
folderViewState.folderView === 'navigation' && navigation.pathStack.length > 0;
|
||||||
|
|
||||||
|
const handleNavigateUp = useCallback(
|
||||||
|
(e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigation.goUp();
|
||||||
|
},
|
||||||
|
[navigation],
|
||||||
|
);
|
||||||
|
|
||||||
if (playlistItems?.items?.length === 0) {
|
if (playlistItems?.items?.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion.Item value="shared-playlists">
|
<Accordion.Item value="shared-playlists">
|
||||||
<Accordion.Control>
|
<Accordion.Control component="motion.div" role="button" style={{ userSelect: 'none' }}>
|
||||||
<Text fw={500} variant="secondary">
|
<Group gap="xs" style={{ minWidth: 0 }} wrap="nowrap">
|
||||||
{t('page.sidebar.shared')}
|
{inNavigation && (
|
||||||
|
<ActionIcon
|
||||||
|
icon="arrowLeftS"
|
||||||
|
iconProps={{ size: 'lg' }}
|
||||||
|
onClick={handleNavigateUp}
|
||||||
|
size="xs"
|
||||||
|
tooltip={{ label: t('common.back') }}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text className={styles.name} fw={500} variant="secondary">
|
||||||
|
{inNavigation ? navigation.currentName : t('page.sidebar.shared')}
|
||||||
</Text>
|
</Text>
|
||||||
|
</Group>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
{playlistItems?.items?.map((item, index) => (
|
<PlaylistFolderViews
|
||||||
<PlaylistRowButton
|
{...folderViewState}
|
||||||
item={item}
|
allPlaylists={playlistItems?.items ?? []}
|
||||||
key={index}
|
expandedSet={expandedSet}
|
||||||
name={item.name}
|
navigation={navigation}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
to={item.id}
|
onToggleFolder={toggle}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+9
@@ -46,7 +46,13 @@ declare global {
|
|||||||
FS_GENERAL_SIDE_QUEUE_TYPE?: string;
|
FS_GENERAL_SIDE_QUEUE_TYPE?: string;
|
||||||
FS_GENERAL_SIDEBAR_COLLAPSE_SHARED?: string;
|
FS_GENERAL_SIDEBAR_COLLAPSE_SHARED?: string;
|
||||||
FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION?: string;
|
FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION?: string;
|
||||||
|
FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR?: string;
|
||||||
|
FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT?: string;
|
||||||
|
FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR?: string;
|
||||||
|
FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW?: string;
|
||||||
|
FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS?: string;
|
||||||
FS_GENERAL_SIDEBAR_PLAYLIST_LIST?: string;
|
FS_GENERAL_SIDEBAR_PLAYLIST_LIST?: string;
|
||||||
|
FS_GENERAL_SIDEBAR_PLAYLIST_MODE?: string;
|
||||||
FS_GENERAL_SIDEBAR_PLAYLIST_SORTING?: string;
|
FS_GENERAL_SIDEBAR_PLAYLIST_SORTING?: string;
|
||||||
FS_GENERAL_THEME?: string;
|
FS_GENERAL_THEME?: string;
|
||||||
FS_GENERAL_THEME_DARK?: string;
|
FS_GENERAL_THEME_DARK?: string;
|
||||||
@@ -65,13 +71,16 @@ declare global {
|
|||||||
FS_LYRICS_TRANSLATION_API_KEY?: string;
|
FS_LYRICS_TRANSLATION_API_KEY?: string;
|
||||||
FS_LYRICS_TRANSLATION_TARGET_LANGUAGE?: string;
|
FS_LYRICS_TRANSLATION_TARGET_LANGUAGE?: string;
|
||||||
FS_PLAYBACK_AUDIO_FADE_ON_STATUS_CHANGE?: string;
|
FS_PLAYBACK_AUDIO_FADE_ON_STATUS_CHANGE?: string;
|
||||||
|
FS_PLAYBACK_FILTERS?: string;
|
||||||
FS_PLAYBACK_MEDIA_SESSION?: string;
|
FS_PLAYBACK_MEDIA_SESSION?: string;
|
||||||
FS_PLAYBACK_PRESERVE_PITCH?: string;
|
FS_PLAYBACK_PRESERVE_PITCH?: string;
|
||||||
FS_PLAYBACK_SCROBBLE_AT_DURATION?: string;
|
FS_PLAYBACK_SCROBBLE_AT_DURATION?: string;
|
||||||
FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE?: string;
|
FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE?: string;
|
||||||
FS_PLAYBACK_SCROBBLE_ENABLED?: string;
|
FS_PLAYBACK_SCROBBLE_ENABLED?: string;
|
||||||
FS_PLAYBACK_SCROBBLE_NOTIFY?: string;
|
FS_PLAYBACK_SCROBBLE_NOTIFY?: string;
|
||||||
|
FS_PLAYBACK_TRANSCODE_BITRATE?: string;
|
||||||
FS_PLAYBACK_TRANSCODE_ENABLED?: string;
|
FS_PLAYBACK_TRANSCODE_ENABLED?: string;
|
||||||
|
FS_PLAYBACK_TRANSCODE_FORMAT?: string;
|
||||||
FS_PLAYBACK_WEB_AUDIO?: string;
|
FS_PLAYBACK_WEB_AUDIO?: string;
|
||||||
LEGACY_AUTHENTICATION?: boolean | string;
|
LEGACY_AUTHENTICATION?: boolean | string;
|
||||||
REMOTE_URL?: string;
|
REMOTE_URL?: string;
|
||||||
|
|||||||
@@ -1,7 +1,73 @@
|
|||||||
import type { SettingsState } from './settings.store';
|
import type { PlayerFilter, SettingsState } from './settings.store';
|
||||||
|
|
||||||
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||||
|
|
||||||
|
const PLAYER_FILTER_FIELDS = new Set([
|
||||||
|
'albumArtist',
|
||||||
|
'artist',
|
||||||
|
'duration',
|
||||||
|
'favorite',
|
||||||
|
'genre',
|
||||||
|
'name',
|
||||||
|
'note',
|
||||||
|
'path',
|
||||||
|
'playCount',
|
||||||
|
'rating',
|
||||||
|
'year',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PLAYER_FILTER_OPERATORS = new Set([
|
||||||
|
'after',
|
||||||
|
'afterDate',
|
||||||
|
'before',
|
||||||
|
'beforeDate',
|
||||||
|
'contains',
|
||||||
|
'endsWith',
|
||||||
|
'gt',
|
||||||
|
'inTheLast',
|
||||||
|
'inTheRange',
|
||||||
|
'inTheRangeDate',
|
||||||
|
'is',
|
||||||
|
'isNot',
|
||||||
|
'lt',
|
||||||
|
'notContains',
|
||||||
|
'notInTheLast',
|
||||||
|
'regex',
|
||||||
|
'startsWith',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isValidPlayerFilter(item: unknown): item is PlayerFilter {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return false;
|
||||||
|
const o = item as Record<string, unknown>;
|
||||||
|
if (typeof o.id !== 'string') return false;
|
||||||
|
if (typeof o.field !== 'string' || !PLAYER_FILTER_FIELDS.has(o.field)) return false;
|
||||||
|
if (typeof o.operator !== 'string' || !PLAYER_FILTER_OPERATORS.has(o.operator)) return false;
|
||||||
|
if (!isValidPlayerFilterValue(o.value)) return false;
|
||||||
|
if (o.isEnabled !== undefined && typeof o.isEnabled !== 'boolean') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPlayerFilterValue(value: unknown): boolean {
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) return false;
|
||||||
|
return value.every((v) => typeof v === 'string' || typeof v === 'number');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlaybackFiltersJson(raw: string): unknown {
|
||||||
|
const t = raw.trim();
|
||||||
|
if (t === '') return undefined;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(t) as unknown;
|
||||||
|
if (!Array.isArray(v)) return undefined;
|
||||||
|
if (!v.every(isValidPlayerFilter)) return undefined;
|
||||||
|
return v;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const APP_THEMES = new Set([
|
const APP_THEMES = new Set([
|
||||||
'ayuDark',
|
'ayuDark',
|
||||||
'ayuLight',
|
'ayuLight',
|
||||||
@@ -43,6 +109,8 @@ const FONT_TYPES = new Set(['builtIn', 'custom', 'system']);
|
|||||||
const HOME_FEATURE_STYLES = new Set(['multiple', 'single']);
|
const HOME_FEATURE_STYLES = new Set(['multiple', 'single']);
|
||||||
const SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']);
|
const SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']);
|
||||||
const SIDE_QUEUE_LAYOUTS = new Set(['horizontal', 'vertical']);
|
const SIDE_QUEUE_LAYOUTS = new Set(['horizontal', 'vertical']);
|
||||||
|
const SIDEBAR_PLAYLIST_FOLDER_VIEWS = new Set(['navigation', 'single', 'tree']);
|
||||||
|
const SIDEBAR_PLAYLIST_MODES = new Set(['compact', 'expanded']);
|
||||||
|
|
||||||
export type EnvSettingsOverrides = DeepPartial<
|
export type EnvSettingsOverrides = DeepPartial<
|
||||||
Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'>
|
Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'>
|
||||||
@@ -55,28 +123,29 @@ type DeepPartial<T> = {
|
|||||||
interface EnvSettingSpec {
|
interface EnvSettingSpec {
|
||||||
enumSet?: Set<string>;
|
enumSet?: Set<string>;
|
||||||
key: string;
|
key: string;
|
||||||
path: [string, string, string] | [string, string];
|
path: readonly string[];
|
||||||
skipIfEmpty?: boolean;
|
skipIfEmpty?: boolean;
|
||||||
transform?: (raw: string) => unknown;
|
transform?: (raw: string) => unknown;
|
||||||
type: 'bool' | 'enum' | 'num' | 'string';
|
type: 'bool' | 'enum' | 'num' | 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAtPath(
|
function setAtPath(obj: EnvSettingsOverrides, path: readonly string[], value: unknown): void {
|
||||||
obj: EnvSettingsOverrides,
|
if (path.length < 2) return;
|
||||||
path: [string, string, string] | [string, string],
|
let cur: Record<string, unknown> = obj as Record<string, unknown>;
|
||||||
value: unknown,
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
): void {
|
const key = path[i]!;
|
||||||
const [a, b, c] = path;
|
const existing = cur[key];
|
||||||
const root = (obj as Record<string, unknown>)[a] ?? {};
|
const next: Record<string, unknown> =
|
||||||
(obj as Record<string, unknown>)[a] = root;
|
existing !== null &&
|
||||||
const branch = root as Record<string, unknown>;
|
existing !== undefined &&
|
||||||
if (c === undefined) {
|
typeof existing === 'object' &&
|
||||||
branch[b] = value;
|
!Array.isArray(existing)
|
||||||
} else {
|
? { ...(existing as Record<string, unknown>) }
|
||||||
const nested = branch[b] ?? {};
|
: {};
|
||||||
branch[b] = nested;
|
cur[key] = next;
|
||||||
(nested as Record<string, unknown>)[c] = value;
|
cur = next;
|
||||||
}
|
}
|
||||||
|
cur[path[path.length - 1]!] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RGB_ACCENT_REGEX = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/;
|
const RGB_ACCENT_REGEX = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/;
|
||||||
@@ -189,11 +258,48 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
|
|||||||
path: ['general', 'sidebarCollapseShared'],
|
path: ['general', 'sidebarCollapseShared'],
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS',
|
||||||
|
path: ['general', 'sidebarPlaylistFolders'],
|
||||||
|
type: 'bool',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR',
|
||||||
|
path: ['general', 'sidebarPlaylistFolderSeparator'],
|
||||||
|
skipIfEmpty: true,
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT',
|
||||||
|
path: ['general', 'sidebarPlaylistFolderTreeIndent'],
|
||||||
|
transform: (s) => {
|
||||||
|
const n = parseNum(s);
|
||||||
|
return n !== undefined ? Math.min(64, Math.max(0, Math.round(n))) : undefined;
|
||||||
|
},
|
||||||
|
type: 'num',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR',
|
||||||
|
path: ['general', 'sidebarPlaylistFolderTreeLineColor'],
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enumSet: SIDEBAR_PLAYLIST_FOLDER_VIEWS,
|
||||||
|
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW',
|
||||||
|
path: ['general', 'sidebarPlaylistFolderView'],
|
||||||
|
type: 'enum',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_LIST',
|
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_LIST',
|
||||||
path: ['general', 'sidebarPlaylistList'],
|
path: ['general', 'sidebarPlaylistList'],
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
enumSet: SIDEBAR_PLAYLIST_MODES,
|
||||||
|
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_MODE',
|
||||||
|
path: ['general', 'sidebarPlaylistMode'],
|
||||||
|
type: 'enum',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_SORTING',
|
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_SORTING',
|
||||||
path: ['general', 'sidebarPlaylistSorting'],
|
path: ['general', 'sidebarPlaylistSorting'],
|
||||||
@@ -252,6 +358,23 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
|
|||||||
path: ['playback', 'transcode', 'enabled'],
|
path: ['playback', 'transcode', 'enabled'],
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'FS_PLAYBACK_TRANSCODE_FORMAT',
|
||||||
|
path: ['playback', 'transcode', 'format'],
|
||||||
|
skipIfEmpty: true,
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FS_PLAYBACK_TRANSCODE_BITRATE',
|
||||||
|
path: ['playback', 'transcode', 'bitrate'],
|
||||||
|
type: 'num',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FS_PLAYBACK_FILTERS',
|
||||||
|
path: ['playback', 'filters'],
|
||||||
|
transform: parsePlaybackFiltersJson,
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
{ key: 'FS_DISCORD_ENABLED', path: ['discord', 'enabled'], type: 'bool' },
|
{ key: 'FS_DISCORD_ENABLED', path: ['discord', 'enabled'], type: 'bool' },
|
||||||
{
|
{
|
||||||
key: 'FS_DISCORD_CLIENT_ID',
|
key: 'FS_DISCORD_CLIENT_ID',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from './app.store';
|
|||||||
export * from './auth.store';
|
export * from './auth.store';
|
||||||
export * from './full-screen-player.store';
|
export * from './full-screen-player.store';
|
||||||
export * from './player.store';
|
export * from './player.store';
|
||||||
|
export * from './scrobble-debug.store';
|
||||||
export * from './scroll.store';
|
export * from './scroll.store';
|
||||||
export * from './settings.store';
|
export * from './settings.store';
|
||||||
export * from './timestamp.store';
|
export * from './timestamp.store';
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
|
import { PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
export type ScrobbleDebugSnapshot = {
|
||||||
|
eligibilityMet: boolean;
|
||||||
|
lastListenSampleTimeSec: null | number;
|
||||||
|
listenedMs: number;
|
||||||
|
playerStatus: PlayerStatus;
|
||||||
|
positionSec: number;
|
||||||
|
songId?: string;
|
||||||
|
songName?: string;
|
||||||
|
submitted: boolean;
|
||||||
|
targetDurationSec: number;
|
||||||
|
targetPercentage: number;
|
||||||
|
trackDurationMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialSnapshot: ScrobbleDebugSnapshot = {
|
||||||
|
eligibilityMet: false,
|
||||||
|
lastListenSampleTimeSec: null,
|
||||||
|
listenedMs: 0,
|
||||||
|
playerStatus: PlayerStatus.PAUSED,
|
||||||
|
positionSec: 0,
|
||||||
|
songId: undefined,
|
||||||
|
songName: undefined,
|
||||||
|
submitted: false,
|
||||||
|
targetDurationSec: 240,
|
||||||
|
targetPercentage: 75,
|
||||||
|
trackDurationMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScrobbleDebugStore = {
|
||||||
|
snapshot: ScrobbleDebugSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useScrobbleDebugStore = createWithEqualityFn<ScrobbleDebugStore>()(() => ({
|
||||||
|
snapshot: initialSnapshot,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const publishScrobbleDebug = (partial: Partial<ScrobbleDebugSnapshot>) => {
|
||||||
|
useScrobbleDebugStore.setState((state) => ({
|
||||||
|
snapshot: { ...state.snapshot, ...partial },
|
||||||
|
}));
|
||||||
|
};
|
||||||
@@ -160,6 +160,7 @@ const BindingActionsSchema = z.enum([
|
|||||||
'listPlayNext',
|
'listPlayNext',
|
||||||
'listPlayLast',
|
'listPlayLast',
|
||||||
'listNavigateToPage',
|
'listNavigateToPage',
|
||||||
|
'listShowPlayingSong',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const DiscordDisplayTypeSchema = z.enum(['artist', 'feishin', 'song']);
|
const DiscordDisplayTypeSchema = z.enum(['artist', 'feishin', 'song']);
|
||||||
@@ -175,6 +176,10 @@ const SideQueueLayoutSchema = z.enum(['horizontal', 'vertical']);
|
|||||||
|
|
||||||
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
|
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
|
||||||
|
|
||||||
|
const SidebarPlaylistFolderViewSchema = z.enum(['single', 'tree', 'navigation']);
|
||||||
|
|
||||||
|
const SidebarPlaylistModeSchema = z.enum(['compact', 'expanded']);
|
||||||
|
|
||||||
const CollectionSchema = z.object({
|
const CollectionSchema = z.object({
|
||||||
filterQueryString: z.string(),
|
filterQueryString: z.string(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -500,8 +505,14 @@ export const GeneralSettingsSchema = z.object({
|
|||||||
sidebarCollapseShared: z.boolean(),
|
sidebarCollapseShared: z.boolean(),
|
||||||
sidebarItems: z.array(SidebarItemTypeSchema),
|
sidebarItems: z.array(SidebarItemTypeSchema),
|
||||||
sidebarPanelOrder: z.array(SidebarPanelTypeSchema),
|
sidebarPanelOrder: z.array(SidebarPanelTypeSchema),
|
||||||
|
sidebarPlaylistFolders: z.boolean(),
|
||||||
|
sidebarPlaylistFolderSeparator: z.string().min(1),
|
||||||
|
sidebarPlaylistFolderTreeIndent: z.number().int().min(0).max(64),
|
||||||
|
sidebarPlaylistFolderTreeLineColor: z.string(),
|
||||||
|
sidebarPlaylistFolderView: SidebarPlaylistFolderViewSchema,
|
||||||
sidebarPlaylistList: z.boolean(),
|
sidebarPlaylistList: z.boolean(),
|
||||||
sidebarPlaylistListFilterRegex: z.string(),
|
sidebarPlaylistListFilterRegex: z.string(),
|
||||||
|
sidebarPlaylistMode: SidebarPlaylistModeSchema,
|
||||||
sidebarPlaylistSorting: z.boolean(),
|
sidebarPlaylistSorting: z.boolean(),
|
||||||
sideQueueLayout: SideQueueLayoutSchema,
|
sideQueueLayout: SideQueueLayoutSchema,
|
||||||
sideQueueType: SideQueueTypeSchema,
|
sideQueueType: SideQueueTypeSchema,
|
||||||
@@ -756,6 +767,7 @@ export enum BindingActions {
|
|||||||
LIST_PLAY_LAST = 'listPlayLast',
|
LIST_PLAY_LAST = 'listPlayLast',
|
||||||
LIST_PLAY_NEXT = 'listPlayNext',
|
LIST_PLAY_NEXT = 'listPlayNext',
|
||||||
LIST_PLAY_NOW = 'listPlayNow',
|
LIST_PLAY_NOW = 'listPlayNow',
|
||||||
|
LIST_SHOW_PLAYING_SONG = 'listShowPlayingSong',
|
||||||
LOCAL_SEARCH = 'localSearch',
|
LOCAL_SEARCH = 'localSearch',
|
||||||
MUTE = 'volumeMute',
|
MUTE = 'volumeMute',
|
||||||
NAVIGATE_HOME = 'navigateHome',
|
NAVIGATE_HOME = 'navigateHome',
|
||||||
@@ -1169,8 +1181,14 @@ const initialState: SettingsState = {
|
|||||||
sidebarCollapseShared: false,
|
sidebarCollapseShared: false,
|
||||||
sidebarItems,
|
sidebarItems,
|
||||||
sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'],
|
sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'],
|
||||||
|
sidebarPlaylistFolders: true,
|
||||||
|
sidebarPlaylistFolderSeparator: '/',
|
||||||
|
sidebarPlaylistFolderTreeIndent: 16,
|
||||||
|
sidebarPlaylistFolderTreeLineColor: '',
|
||||||
|
sidebarPlaylistFolderView: 'tree',
|
||||||
sidebarPlaylistList: true,
|
sidebarPlaylistList: true,
|
||||||
sidebarPlaylistListFilterRegex: '',
|
sidebarPlaylistListFilterRegex: '',
|
||||||
|
sidebarPlaylistMode: 'expanded',
|
||||||
sidebarPlaylistSorting: false,
|
sidebarPlaylistSorting: false,
|
||||||
sideQueueLayout: 'horizontal',
|
sideQueueLayout: 'horizontal',
|
||||||
sideQueueType: 'sideQueue',
|
sideQueueType: 'sideQueue',
|
||||||
@@ -1205,6 +1223,7 @@ const initialState: SettingsState = {
|
|||||||
listPlayLast: { allowGlobal: false, hotkey: '', isGlobal: false },
|
listPlayLast: { allowGlobal: false, hotkey: '', isGlobal: false },
|
||||||
listPlayNext: { allowGlobal: false, hotkey: '', isGlobal: false },
|
listPlayNext: { allowGlobal: false, hotkey: '', isGlobal: false },
|
||||||
listPlayNow: { allowGlobal: false, hotkey: '', isGlobal: false },
|
listPlayNow: { allowGlobal: false, hotkey: '', isGlobal: false },
|
||||||
|
listShowPlayingSong: { allowGlobal: false, hotkey: 'mod+l', isGlobal: false },
|
||||||
localSearch: { allowGlobal: false, hotkey: 'mod+f', isGlobal: false },
|
localSearch: { allowGlobal: false, hotkey: 'mod+f', isGlobal: false },
|
||||||
navigateHome: { allowGlobal: false, hotkey: '', isGlobal: false },
|
navigateHome: { allowGlobal: false, hotkey: '', isGlobal: false },
|
||||||
next: { allowGlobal: true, hotkey: '', isGlobal: false },
|
next: { allowGlobal: true, hotkey: '', isGlobal: false },
|
||||||
@@ -2552,9 +2571,27 @@ export const useCollections = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useSidebarPlaylistFolders = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistFolders, shallow);
|
||||||
|
|
||||||
|
export const useSidebarPlaylistFolderSeparator = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistFolderSeparator, shallow);
|
||||||
|
|
||||||
|
export const useSidebarPlaylistFolderView = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistFolderView, shallow);
|
||||||
|
|
||||||
|
export const useSidebarPlaylistFolderTreeIndent = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistFolderTreeIndent, shallow);
|
||||||
|
|
||||||
|
export const useSidebarPlaylistFolderTreeLineColor = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistFolderTreeLineColor, shallow);
|
||||||
|
|
||||||
export const useSidebarPlaylistList = () =>
|
export const useSidebarPlaylistList = () =>
|
||||||
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
|
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
|
||||||
|
|
||||||
|
export const useSidebarPlaylistMode = () =>
|
||||||
|
useSettingsStore((state) => state.general.sidebarPlaylistMode, shallow);
|
||||||
|
|
||||||
export const useSidebarPlaylistSorting = () =>
|
export const useSidebarPlaylistSorting = () =>
|
||||||
useSettingsStore((state) => state.general.sidebarPlaylistSorting, shallow);
|
useSettingsStore((state) => state.general.sidebarPlaylistSorting, shallow);
|
||||||
|
|
||||||
|
|||||||
@@ -518,8 +518,9 @@ export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder:
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
},
|
},
|
||||||
|
(v) => v.name.toLowerCase(),
|
||||||
],
|
],
|
||||||
[order, order],
|
[order, order, 'asc'],
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case AlbumListSort.SONG_COUNT:
|
case AlbumListSort.SONG_COUNT:
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ img.size-5xl {
|
|||||||
color: rgb(255 49 49);
|
color: rgb(255 49 49);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-transparent {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.fill {
|
.fill {
|
||||||
fill: transparent;
|
fill: transparent;
|
||||||
}
|
}
|
||||||
@@ -123,6 +127,10 @@ img.size-5xl {
|
|||||||
fill: rgb(255 49 49);
|
fill: rgb(255 49 49);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fill-transparent {
|
||||||
|
fill: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.spin {
|
.spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ import {
|
|||||||
LuChevronLast,
|
LuChevronLast,
|
||||||
LuChevronLeft,
|
LuChevronLeft,
|
||||||
LuChevronRight,
|
LuChevronRight,
|
||||||
|
LuChevronsDownUp,
|
||||||
|
LuChevronsUpDown,
|
||||||
LuChevronUp,
|
LuChevronUp,
|
||||||
|
LuCircle,
|
||||||
LuCircleCheck,
|
LuCircleCheck,
|
||||||
LuCircleX,
|
LuCircleX,
|
||||||
LuClipboardCopy,
|
LuClipboardCopy,
|
||||||
@@ -247,7 +250,9 @@ export const AppIcon = {
|
|||||||
brandSpotify: SpotifyLogoIcon,
|
brandSpotify: SpotifyLogoIcon,
|
||||||
cache: LuCloudDownload,
|
cache: LuCloudDownload,
|
||||||
check: LuCheck,
|
check: LuCheck,
|
||||||
|
circle: LuCircle,
|
||||||
clipboardCopy: LuClipboardCopy,
|
clipboardCopy: LuClipboardCopy,
|
||||||
|
collapseAll: LuChevronsDownUp,
|
||||||
collection: LuPackage2,
|
collection: LuPackage2,
|
||||||
delete: LuTrash,
|
delete: LuTrash,
|
||||||
disc: LuDisc,
|
disc: LuDisc,
|
||||||
@@ -267,6 +272,7 @@ export const AppIcon = {
|
|||||||
emptySongImage: LuMusic,
|
emptySongImage: LuMusic,
|
||||||
error: LuShieldAlert,
|
error: LuShieldAlert,
|
||||||
expand: LuExpand,
|
expand: LuExpand,
|
||||||
|
expandAll: LuChevronsUpDown,
|
||||||
externalLink: LuExternalLink,
|
externalLink: LuExternalLink,
|
||||||
favorite: LuHeart,
|
favorite: LuHeart,
|
||||||
fileJson: LuFileJson,
|
fileJson: LuFileJson,
|
||||||
@@ -384,6 +390,7 @@ type IconColor =
|
|||||||
| 'muted'
|
| 'muted'
|
||||||
| 'primary'
|
| 'primary'
|
||||||
| 'success'
|
| 'success'
|
||||||
|
| 'transparent'
|
||||||
| 'warn';
|
| 'warn';
|
||||||
|
|
||||||
const _Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
|
const _Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
|
||||||
@@ -391,17 +398,19 @@ const _Icon = forwardRef<HTMLDivElement, IconProps>((props, ref) => {
|
|||||||
|
|
||||||
const IconComponent: ComponentType<any> = AppIcon[icon];
|
const IconComponent: ComponentType<any> = AppIcon[icon];
|
||||||
|
|
||||||
|
const colorClassToken = color ?? (fill && fill !== 'transparent' ? fill : undefined);
|
||||||
|
|
||||||
const classNames = useMemo(
|
const classNames = useMemo(
|
||||||
() =>
|
() =>
|
||||||
clsx(className, {
|
clsx(className, {
|
||||||
[styles.fill]: true,
|
[styles.fill]: true,
|
||||||
[styles.pulse]: animate === 'pulse',
|
[styles.pulse]: animate === 'pulse',
|
||||||
[styles.spin]: animate === 'spin',
|
[styles.spin]: animate === 'spin',
|
||||||
[styles[`color-${color || fill}`]]: color || fill,
|
[styles[`color-${colorClassToken}`]]: colorClassToken,
|
||||||
[styles[`fill-${fill}`]]: fill,
|
[styles[`fill-${fill}`]]: fill,
|
||||||
[styles[`size-${size}`]]: true,
|
[styles[`size-${size}`]]: true,
|
||||||
}),
|
}),
|
||||||
[animate, className, color, fill, size],
|
[animate, className, colorClassToken, fill, size],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.root {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
transition: width 0.15s ease-out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ProgressProps as MantineProgressProps } from '@mantine/core';
|
||||||
|
|
||||||
|
import { Progress as MantineProgress } from '@mantine/core';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
import styles from './progress.module.css';
|
||||||
|
|
||||||
|
export interface ProgressProps extends MantineProgressProps {}
|
||||||
|
|
||||||
|
export const Progress = forwardRef<HTMLDivElement, ProgressProps>(
|
||||||
|
({ classNames, style, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<MantineProgress
|
||||||
|
classNames={{
|
||||||
|
root: styles.root,
|
||||||
|
section: styles.section,
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Progress.displayName = 'Progress';
|
||||||
@@ -12,6 +12,7 @@ export enum DragTarget {
|
|||||||
GRID_ROW = 'gridRow',
|
GRID_ROW = 'gridRow',
|
||||||
PLAYLIST = LibraryItem.PLAYLIST,
|
PLAYLIST = LibraryItem.PLAYLIST,
|
||||||
QUEUE_SONG = LibraryItem.QUEUE_SONG,
|
QUEUE_SONG = LibraryItem.QUEUE_SONG,
|
||||||
|
SIDEBAR_PLAYLIST_FOLDER = 'sidebarPlaylistFolder',
|
||||||
SONG = LibraryItem.SONG,
|
SONG = LibraryItem.SONG,
|
||||||
TABLE_COLUMN = 'tableColumn',
|
TABLE_COLUMN = 'tableColumn',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user