Compare commits

...

6 Commits

Author SHA1 Message Date
jeffvli adc9e30266 Merge branch 'development' of https://github.com/jeffvli/feishin into development 2026-05-13 19:02:54 -07:00
jeffvli fcd4c27fb8 support playlist folder drag/drop 2026-05-13 18:39:06 -07:00
jeffvli c4ef6f3799 add sidebar playlist folder settings to env, add compact sidebar playlist view 2026-05-13 17:26:41 -07:00
Hosted Weblate 073b482fed Translated using Weblate
Currently translated at 100.0% (1209 of 1209 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

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

Translated using Weblate

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

Translated using Weblate

Currently translated at 100.0% (1209 of 1209 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

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

Translated using Weblate

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

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fordas <fordas15@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: linger <linger0517@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-05-14 02:08:22 +02:00
Norman 74939c6417 Adding a hotkey to jump to the currently playing song (only works without pagination) (#2024) 2026-05-13 17:08:05 -07:00
Shawn 27a62a2a02 feat(sidebar): multi-level playlist folders with tree and navigation views (#2017)
Group playlists into folders using a configurable separator (default '/').
Three view modes:
- Single: first-level grouping only (original behavior)
- Tree: full recursive nesting with connecting lines (configurable indent and line color)
- Navigation: drill-down view with stacked breadcrumb chain

Folders are sorted before playlists at every level. New settings render as
indented sub-options under the master 'Enable folders' toggle.
2026-05-13 17:07:45 -07:00
24 changed files with 1760 additions and 129 deletions
+6
View File
@@ -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.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.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 (064). |
| `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.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.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. |
+6
View File
@@ -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_SIDEBAR_COLLAPSED_NAVIGATION = "${FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION}";
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_MODE = "${FS_GENERAL_SIDEBAR_PLAYLIST_MODE}";
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_LAYOUT = "${FS_GENERAL_SIDE_QUEUE_LAYOUT}";
+4 -3
View File
@@ -48,7 +48,8 @@
"sleepTimer_timeRemaining": "Zbývá {{time}}",
"sleepTimer_setCustom": "Nastavit časovač",
"sleepTimer_cancel": "Zrušit časovač",
"albumRadio": "Rádio alba"
"albumRadio": "Rádio alba",
"scrobbleForceSubmit": "Vynutit scrobble"
},
"setting": {
"crossfadeStyle_description": "Vyberte způsob prolnutí u přehrávače zvuku",
@@ -1139,7 +1140,7 @@
"folderWithCount_few": "{{count}} složky",
"folderWithCount_other": "{{count}} složek",
"albumArtist_one": "Umělec alba",
"albumArtist_few": "umělci alb",
"albumArtist_few": "Umělci alb",
"albumArtist_other": "Umělci alb",
"track_one": "Skladba",
"track_few": "skladby",
@@ -1176,7 +1177,7 @@
"song_few": "písničky",
"song_other": "Písní",
"radioStation_one": "Stanice rádia",
"radioStation_few": "stanice rádia",
"radioStation_few": "Stanice rádia",
"radioStation_other": "Stanice rádia",
"radioStationWithCount_one": "{{count}} stanice rádia",
"radioStationWithCount_few": "{{count}} stanice rádia",
+21
View File
@@ -6,6 +6,8 @@
"selectRangeOfItems": "Select a range of items",
"clearQueue": "Clear queue",
"goToCurrent": "Go to current item",
"collapseAllFolders": "Collapse all folders",
"expandAllFolders": "Expand all folders",
"createPlaylist": "Create $t(entity.playlist, {\"count\": 1})",
"createRadioStation": "Create $t(entity.radioStation, {\"count\": 1})",
"deletePlaylist": "Delete $t(entity.playlist, {\"count\": 1})",
@@ -56,6 +58,7 @@
"albumPeak": "Album peak",
"areYouSure": "Are you sure?",
"ascending": "Ascending",
"back": "Back",
"backward": "Backward",
"biography": "Biography",
"bitDepth": "Bit depth",
@@ -874,6 +877,7 @@
"hotkey_listPlayLast": "List play last",
"hotkey_listPlayNext": "List play next",
"hotkey_listPlayNow": "List play now",
"hotkey_listShowPlayingSong": "Show playing song in list",
"hotkey_navigateHome": "Navigate to home",
"hotkey_playbackNext": "Next track",
"hotkey_playbackPause": "Pause",
@@ -1044,8 +1048,25 @@
"sidebarConfiguration": "Sidebar configuration",
"playerItemConfiguration_description": "Configure what items are shown, and in what order, on the fullscreen player",
"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": "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": "Sidebar playlist sorting",
"sidebarPlaylistListFilterRegex_description": "Hide playlists in the sidebar that match this regular expression",
+5 -4
View File
@@ -48,7 +48,8 @@
"sleepTimer_off": "Apagado",
"sleepTimer_endOfSong": "Fin de la canción actual",
"sleepTimer": "Temporizador de apagado",
"albumRadio": "Radio del álbum"
"albumRadio": "Radio del álbum",
"scrobbleForceSubmit": "Forzar scrobble"
},
"setting": {
"crossfadeStyle_description": "Selecciona el estilo de crossfade a usar por el reproductor de audio",
@@ -1175,9 +1176,9 @@
"song_one": "Canción",
"song_many": "Canciones",
"song_other": "Canciones",
"radioStation_one": "Estación de radio",
"radioStation_many": "Estaciones de radio",
"radioStation_other": "Estaciones de radio",
"radioStation_one": "Estación de Radio",
"radioStation_many": "Estaciones de Radio",
"radioStation_other": "Estaciones de Radio",
"radioStationWithCount_one": "{{count}} estación de radio",
"radioStationWithCount_many": "{{count}} estaciones de radio",
"radioStationWithCount_other": "{{count}} estaciones de radio"
+20 -19
View File
@@ -1,7 +1,7 @@
{
"action": {
"editPlaylist": "Edytuj $t(entity.playlist, {\"count\": 1})",
"goToPage": "IDź do strony",
"goToPage": "Idź do strony",
"clearQueue": "Wyczyść kolejkę",
"addToFavorites": "Dodaj do $t(entity.favorite, {\"count\": 2})",
"removeFromPlaylist": "Usuń z $t(entity.playlist, {\"count\": 1})",
@@ -189,8 +189,8 @@
"folderWithCount_few": "{{count}} katalogi",
"folderWithCount_many": "{{count}} katalogów",
"albumArtist_one": "Wykonawca albumu",
"albumArtist_few": "wykonawcy albumu",
"albumArtist_many": "wykonawcy albumów",
"albumArtist_few": "Wykonawców albumów",
"albumArtist_many": "Wykonawców albumów",
"track_one": "Utwór",
"track_few": "utwory",
"track_many": "utworów",
@@ -226,8 +226,8 @@
"song_few": "piosenki",
"song_many": "­piosenek",
"radioStation_one": "Stacja radiowa",
"radioStation_few": "stacje radiowe",
"radioStation_many": "stacji radiowych",
"radioStation_few": "Stacje radiowe",
"radioStation_many": "Stacji radiowych",
"radioStationWithCount_one": "{{count}} stacja radiowa",
"radioStationWithCount_few": "{{count}} stacje radiowych",
"radioStationWithCount_many": "{{count}} stacji radiowych"
@@ -690,7 +690,8 @@
"sleepTimer_timeRemaining": "Pozostało {{time}}",
"sleepTimer_setCustom": "Ustaw wyłącznik",
"sleepTimer_cancel": "Anuluj wyłączanie",
"albumRadio": "Radio albumu"
"albumRadio": "Radio albumu",
"scrobbleForceSubmit": "Wymuś scrobble"
},
"setting": {
"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)",
"playerbarOpenDrawer_description": "Pozwala przełączyć na odtwarzacz pełnoekranowy po kliknięciu paska 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",
"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",
@@ -912,7 +913,7 @@
"musicbrainz_description": "Pokazuj linki do MusicBrainz na stronach wykonawców/albumów, gdzie istnieje MusicBrainz ID",
"discordPausedStatus": "Pokaż status podczas pauzy",
"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_description": "Zanonymizowane dane użytkowania są wysyłane do dewelopera w celu poprawienia aplikacji",
"artistBackground": "Obraz tła wykonawcy",
@@ -978,7 +979,7 @@
"queryBuilderCustomFields": "Niestandardowe pola",
"queryBuilderCustomFields_description": "Dodaj niestandardowe pola do użycia w kreatorach zapytań",
"followCurrentSong_description": "Automatycznie przewija kolejkę odtwarzania do aktualnie odtwarzanej piosenki",
"followCurrentSong": LEDź aktualną piosenkę",
"followCurrentSong": ledź aktualną piosenkę",
"playerFilters": "Filtruj piosenki z kolejki",
"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",
@@ -1004,7 +1005,7 @@
"imageResolution_description": "Rozdzielczość dla obrazów używanych w programie. użycie wartości 0 ustawi rozdzielczość na natywną",
"imageResolution_optionTable": "Tabela",
"imageResolution_optionItemCard": "Karta elementu",
"imageResolution_optionSidebar": "­pasek boczny",
"imageResolution_optionSidebar": "Pasek boczny",
"imageResolution_optionHeader": "Nagłówek",
"imageResolution_optionFullScreenPlayer": "Odtwarzacz pełnoekranowy",
"combinedLyricsAndVisualizer_description": "Połącz tekst i wizualizacje w tym samym panelu",
@@ -1158,7 +1159,7 @@
"rating": "Ocena",
"favorite": "Ulubione",
"playCount": "Odtwarzane",
"albumCount": "$t(entity.album, {\"count\": 2})",
"albumCount": "Albumy",
"releaseYear": "Rok",
"lastPlayed": "Ostatnio odtwarzane",
"biography": "Biografia",
@@ -1167,19 +1168,19 @@
"title": "Tytuł",
"bpm": "BPM",
"dateAdded": "Data dodania",
"artist": "$t(entity.artist, {\"count\": 1})",
"songCount": "$t(entity.track, {\"count\": 2})",
"artist": "Wykonawca",
"songCount": "Utwory",
"trackNumber": "Utwór",
"genre": "$t(entity.genre, {\"count\": 1})",
"genre": "Gatunek",
"albumArtist": "Wykonawca albumu",
"path": "Ścieżka",
"discNumber": "Płyta",
"channels": "$t(common.channel, {\"count\": 2})",
"size": "$t(common.size)",
"codec": "$t(common.codec)",
"channels": "Kanały",
"size": "Rozmiar",
"codec": "Kodek",
"owner": "Właściciel",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)"
"bitDepth": "Głębia bitowa",
"sampleRate": "Częstotliwość próbkowania"
}
},
"queryBuilder": {
+30 -29
View File
@@ -197,7 +197,7 @@
"title": "$t(common.home)",
"mostPlayed": "最多播放",
"newlyAdded": "最近新增的發行",
"recentlyReleased": "最近發",
"recentlyReleased": "最近發",
"genres": "$t(entity.genre, {\"count\": 2})"
},
"appMenu": {
@@ -322,8 +322,8 @@
"topSongsFrom": "{{title}} 的熱門歌曲",
"viewAll": "檢視所有",
"viewAllTracks": "檢視所有$t(entity.track, {\"count\": 2})",
"groupingTypeAll": "所有發類型",
"groupingTypePrimary": "主要發類型",
"groupingTypeAll": "所有發類型",
"groupingTypePrimary": "主要發類型",
"favoriteSongs": "最愛歌曲",
"favoriteSongsFrom": "{{title}} 的最愛歌曲",
"topSongsCommunity": "社群",
@@ -417,7 +417,8 @@
"sleepTimer_timeRemaining": "剩餘 {{time}}",
"sleepTimer_setCustom": "設定定時器",
"sleepTimer_cancel": "取消定時器",
"albumRadio": "專輯電台"
"albumRadio": "專輯電台",
"scrobbleForceSubmit": "強制紀錄"
},
"setting": {
"audioPlayer_description": "選擇用於播放的音訊播放器",
@@ -499,7 +500,7 @@
"lyricFetchProvider_description": "選擇歌詞來源",
"minimizeToTray": "最小化到系統匣",
"minimizeToTray_description": "將應用程式最小化到系統匣",
"minimumScrobbleSeconds": "最小紀錄時(秒)",
"minimumScrobbleSeconds": "最小紀錄時(秒)",
"minimumScrobbleSeconds_description": "歌曲被記錄為已播放(Scrobble)所需的最小播放時間",
"mpvExecutablePath": "MPV 執行檔路徑",
"playbackStyle_optionCrossFade": "淡入淡出",
@@ -615,7 +616,7 @@
"lastfmApiKey_description": "{{lastfm}}的API金鑰。用於封面照",
"mpvExtraParameters_help": "一行一個",
"musicbrainz": "顯示 MusicBrainz 連結",
"musicbrainz_description": "在存在 MusicBrainz ID 的藝人/專輯頁面上顯示 MusicBrainz 的連結",
"musicbrainz_description": "在擁有 MusicBrainz ID 的藝人/專輯頁面上顯示 MusicBrainz 的連結",
"neteaseTranslation": "啟用網易翻譯",
"neteaseTranslation_description": "啟用後,將從網易取得並顯示翻譯的歌詞(如果有)",
"passwordStore": "密碼/secret儲存",
@@ -626,7 +627,7 @@
"startMinimized": "啟動時最小化",
"startMinimized_description": "在系統匣中啟動應用程式",
"transcode_description": "啟用轉碼到不同格式",
"transcodeBitrate": "轉碼的位元率",
"transcodeBitrate": "轉碼的位元率",
"transcodeBitrate_description": "選擇要轉碼的位元率。 0 表示讓伺服器選擇",
"transcodeFormat": "轉碼的格式",
"transcodeFormat_description": "選擇要轉碼的格式。留空來讓伺服器決定",
@@ -708,7 +709,7 @@
"queryBuilderCustomFields_description": "在查詢建構器中新增自訂欄位",
"followCurrentSong_description": "自動將播放佇列捲動至當前播放的歌曲",
"followCurrentSong": "跟隨當前歌曲",
"playerbarSlider_description": "不建議在網路速度緩慢或計費的網路下使用波形",
"playerbarSlider_description": "不建議在速度緩慢或計費的網路下使用波形",
"playerFilters": "從佇列中過濾歌曲",
"playerFilters_description": "根據以下條件,排除要新增至佇列中的歌曲",
"autoDJ": "Auto DJ",
@@ -717,7 +718,7 @@
"autoDJ_itemCount_description": "在啟用Auto DJ時嘗試加入佇列的歌曲數量",
"autoDJ_timing_description": "佇列中剩餘多少歌曲時啟動 Auto DJ",
"autoDJ_timing": "觸發時機",
"logLevel": "log等級",
"logLevel": "Log等級",
"logLevel_description": "設定要顯示的最低日誌等級。Debug 會顯示所有日誌,Error 僅會顯示錯誤訊息",
"logLevel_optionDebug": "Debug",
"logLevel_optionError": "Error",
@@ -725,7 +726,7 @@
"logLevel_optionWarn": "Warn",
"useThemeAccentColor": "使用主題強調色",
"useThemeAccentColor_description": "使用所選主題中定義的主要顏色,而非自訂的強調色",
"artistRadioCount_description": "設定為藝人電台與曲目電台擷取的歌曲數量",
"artistRadioCount_description": "設定為藝人電台與曲目電台擷取的歌曲數量",
"imageResolution": "圖片解析度",
"imageResolution_description": "應用程式中所使用圖片的解析度。設定為 0 時,將使用圖片的原始解析度",
"imageResolution_optionTable": "表格",
@@ -760,7 +761,7 @@
"enableGridMultiSelect_description": "啟用時,允許在網格檢視中選擇多項。停用時,單擊網格項目圖片將導航到項目頁面",
"sidebarPlaylistSorting_description": "允許在側邊欄中使用拖放手動對播放清單進行排序,而不是預設的伺服器排序",
"sidebarPlaylistListFilterRegex_description": "在側邊欄中隱藏與此正規表達式匹配的播放清單",
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^Daily Mix.*",
"sidebarPlaylistListFilterRegex_placeholder": "範例: ^daily mix.*",
"sidebarPlaylistListFilterRegex": "播放清單過濾器正規表達式",
"blurExplicitImages": "模糊露骨圖片",
"blurExplicitImages_description": "標記為露骨的專輯和歌曲封面將被模糊",
@@ -783,15 +784,15 @@
"autosaveCount_description": "在儲存佇列之前,有多少曲目更改。1(最小)表示每次歌曲更改",
"spotify_description": "在藝人與專輯頁面顯示 Spotify 的連結",
"spotify": "顯示 Spotify 的連結",
"nativeSpotify_description": "在 Spotify 應用程式中開啟,而非瀏覽器中開啟",
"nativeSpotify_description": "在 Spotify 應用程式而非瀏覽器中開啟",
"nativeSpotify": "使用 Spotify 應用程式",
"sidePlayQueueLayout": "側邊播放佇列佈局",
"sidePlayQueueLayout_description": "設定吸附側邊播放佇列的佈局",
"sidePlayQueueLayout_optionHorizontal": "水平",
"sidePlayQueueLayout_optionVertical": "垂直",
"listenbrainz_description": "在藝術家/專輯頁面上顯示 ListenBrainz 的連結",
"listenbrainz_description": "在藝/專輯頁面上顯示 ListenBrainz 的連結",
"listenbrainz": "顯示 ListenBrainz 連結",
"qobuz_description": "在藝術家/專輯頁面上顯示 Qobuz 的連結",
"qobuz_description": "在藝/專輯頁面上顯示 Qobuz 的連結",
"qobuz": "顯示 Qobuz 連結",
"waveformLoadingDelay": "波形載入延遲",
"waveformLoadingDelay_description": "載入波形前的延遲(以秒為單位)。如果您在使用網頁播放器時遇到卡頓,請增加此值。",
@@ -839,7 +840,7 @@
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"artist": "$t(entity.artist, {\"count\": 1})",
"bpm": "$t(common.BPM)",
"bpm": "$t(common.bpm)",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"channels": "$t(common.channel, {\"count\": 2})",
@@ -882,11 +883,11 @@
"column": {
"album": "專輯",
"albumArtist": "專輯藝人",
"albumCount": "$t(entity.album, {\"count\": 2})",
"artist": "$t(entity.artist, {\"count\": 1})",
"albumCount": "專輯",
"artist": "藝人",
"biography": "簡介",
"bitrate": "位元率",
"channels": "$t(common.channel, {\"count\": 2})",
"channels": "聲道",
"comment": "評論",
"dateAdded": "新增日期",
"discNumber": "光碟",
@@ -894,19 +895,19 @@
"lastPlayed": "最後播放",
"path": "路徑",
"playCount": "播放次數",
"rating": "評",
"releaseDate": "發日期",
"rating": "評",
"releaseDate": "發日期",
"releaseYear": "年份",
"genre": "$t(entity.genre, {\"count\": 1})",
"genre": "曲風",
"bpm": "BPM",
"songCount": "$t(entity.track, {\"count\": 2})",
"songCount": "曲目",
"title": "標題",
"trackNumber": "曲目編號",
"size": "$t(common.size)",
"codec": "$t(common.codec)",
"size": "大小",
"codec": "編碼",
"owner": "擁有者",
"bitDepth": "$t(common.bitDepth)",
"sampleRate": "$t(common.sampleRate)"
"bitDepth": "位元深度",
"sampleRate": "取樣率"
}
},
"action": {
@@ -1007,13 +1008,13 @@
"rating": "評分",
"recentlyPlayed": "最近播放",
"recentlyUpdated": "最近更新",
"releaseDate": "發日期",
"releaseDate": "發日期",
"songCount": "曲目數",
"album": "$t(entity.album, {\"count\": 1})",
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
"favorited": "已收藏",
"recentlyAdded": "最近新增",
"releaseYear": "發年份",
"releaseYear": "發年份",
"search": "搜尋",
"title": "標題",
"toYear": "從年份",
@@ -1133,7 +1134,7 @@
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "電更新成功"
"success": "電更新成功"
}
},
"releaseType": {
@@ -6,7 +6,7 @@ import styles from './drag-preview.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { Icon } from '/@/shared/components/icon/icon';
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 {
data: DragData;
@@ -29,7 +29,9 @@ export const DragPreview = memo(({ data }: DragPreviewProps) => {
const { t } = useTranslation();
const itemCount = items.length;
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({
id: (firstItem as { imageId: string })?.imageId,
@@ -50,6 +52,9 @@ export const DragPreview = memo(({ data }: DragPreviewProps) => {
</div>
) : (
<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.SONG && (
<Icon icon="itemSong" size="xl" />
@@ -16,11 +16,13 @@ export const useListHotkeys = ({
focused,
internalState,
itemType,
onShowPlayingSong,
}: {
controls: ItemControls;
focused: boolean;
internalState: ItemListStateActions;
itemType: LibraryItem;
onShowPlayingSong?: () => void;
}) => {
const { bindings } = useHotkeySettings();
const playButtonBehavior = usePlayButtonBehavior();
@@ -119,5 +121,11 @@ export const useListHotkeys = ({
}
},
],
[
bindings.listShowPlayingSong.hotkey,
() => {
onShowPlayingSong?.();
},
],
]);
};
@@ -66,6 +66,7 @@ import {
ItemTableListColumnConfig,
} from '/@/renderer/components/item-list/types';
import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerStore } from '/@/renderer/store';
import { animationProps } from '/@/shared/components/animations/animation-props';
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
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({
controls,
focused,
internalState,
itemType,
onShowPlayingSong,
});
const tableConfigValue = useMemo<ItemTableListConfig>(
@@ -7,15 +7,29 @@ import {
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
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 { TextInput } from '/@/shared/components/text-input/text-input';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
type FolderView = 'navigation' | 'single' | 'tree';
type PlaylistMode = 'compact' | 'expanded';
export const SidebarSettings = memo(() => {
const { t } = useTranslation();
const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const handleSetSidebarPlaylistFolders = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({
general: {
sidebarPlaylistFolders: e.target.checked,
},
});
};
const handleSetSidebarPlaylistList = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({
general: {
@@ -56,6 +70,60 @@ export const SidebarSettings = memo(() => {
});
}, 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[] = [
{
control: (
@@ -98,6 +166,137 @@ export const SidebarSettings = memo(() => {
}),
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: (
<Switch
@@ -60,6 +60,7 @@ const BINDINGS_MAP: Record<BindingActions, string> = {
context: 'listPlayNext',
}),
listPlayNow: i18n.t('setting.hotkey', { context: 'listPlayNow' }),
listShowPlayingSong: i18n.t('setting.hotkey', { context: 'listShowPlayingSong' }),
localSearch: i18n.t('setting.hotkey', { context: 'localSearch' }),
navigateHome: i18n.t('setting.hotkey', {
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 styles from './settings-option.module.css';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Stack } from '/@/shared/components/stack/stack';
@@ -9,15 +11,20 @@ import { Tooltip } from '/@/shared/components/tooltip/tooltip';
interface SettingsOptionProps {
control: React.ReactNode;
description?: React.ReactNode | string;
indent?: boolean;
note?: string;
title: React.ReactNode | string;
}
export const SettingsOptions = memo(
({ control, description, note, title }: SettingsOptionProps) => {
({ control, description, indent, note, title }: SettingsOptionProps) => {
return (
<>
<Group justify="space-between" style={{ alignItems: 'center' }} wrap="nowrap">
<Group
className={indent ? styles.rowIndented : styles.row}
justify="space-between"
wrap="nowrap"
>
<Stack
gap="xs"
style={{
@@ -8,6 +8,7 @@ import { TextTitle } from '/@/shared/components/text-title/text-title';
export type SettingOption = {
control: ReactNode;
description: ReactNode | string;
indent?: boolean;
isHidden?: boolean;
note?: string;
title: string;
@@ -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 {
.metadata {
.metadata,
.compact-name {
margin-right: 100px;
}
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 {
position: absolute;
top: 50%;
@@ -29,6 +45,18 @@
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 {
border-radius: var(--mantine-radius-sm);
box-shadow: 0 0 0 2px var(--theme-colors-primary);
@@ -17,6 +17,14 @@ import {
PlayTooltip,
} from '/@/renderer/features/shared/components/play-button-group';
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 { AppRoute } from '/@/renderer/router/routes';
import {
@@ -24,6 +32,7 @@ import {
useCurrentServerId,
usePermissions,
useSidebarPlaylistListFilterRegex,
useSidebarPlaylistMode,
useSidebarPlaylistSorting,
} from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils';
@@ -50,7 +59,7 @@ const getPlaylistOrderKey = (serverId: string | undefined, scope: 'owned' | 'sha
return `playlist_order:${sid}:${scope}`;
};
interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
export interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
item: Playlist;
name: string;
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
@@ -58,7 +67,7 @@ interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'on
to: string;
}
const PlaylistRowButton = memo(
export const PlaylistRowButton = memo(
({ item, name, onContextMenu, onReorder, to }: PlaylistRowButtonProps) => {
const url = {
pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }),
@@ -66,6 +75,8 @@ const PlaylistRowButton = memo(
};
const { t } = useTranslation();
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
const sidebarPlaylistMode = useSidebarPlaylistMode();
const isCompact = sidebarPlaylistMode === 'compact';
const [isHovered, setIsHovered] = useState(false);
@@ -213,6 +224,7 @@ const PlaylistRowButton = memo(
return (
<Link
className={clsx(styles.row, {
[styles.rowCompact]: isCompact,
[styles.rowDraggedOver]: isDraggedOver,
[styles.rowHover]: isHovered,
})}
@@ -228,50 +240,62 @@ const PlaylistRowButton = memo(
}}
to={url}
>
<div className={styles.rowGroup}>
<Image containerClassName={styles.imageContainer} src={imageUrl} />
<div className={styles.metadata}>
<Text className={styles.name} fw={500} size="md">
{isCompact ? (
<>
<Text className={styles.compactName} fw={500} size="md">
{name}
</Text>
<div className={styles.metadataGroup}>
<div
className={clsx(
styles.metadataGroupItem,
styles.metadataGroupItemNoShrink,
)}
>
<Icon color="muted" icon="itemSong" size="sm" />
<Text isMuted size="sm">
{item.songCount || 0}
{isHovered && <RowControls id={to} onPlay={handlePlay} variant="compact" />}
</>
) : (
<>
<div className={styles.rowGroup}>
<Image containerClassName={styles.imageContainer} src={imageUrl} />
<div className={styles.metadata}>
<Text className={styles.name} fw={500} size="md">
{name}
</Text>
</div>
<div className={styles.metadataGroupItem}>
<Icon color="muted" icon="duration" size="sm" />
<Text isMuted size="sm">
{formatDurationString(item.duration ?? 0)}
</Text>
</div>
{item.ownerId === permissions.userId && Boolean(item.public) && (
<div className={styles.metadataGroupItem}>
<Text isMuted size="sm">
{t('common.public')}
</Text>
<div className={styles.metadataGroup}>
<div
className={clsx(
styles.metadataGroupItem,
styles.metadataGroupItemNoShrink,
)}
>
<Icon color="muted" icon="itemSong" size="sm" />
<Text isMuted size="sm">
{item.songCount || 0}
</Text>
</div>
<div className={styles.metadataGroupItem}>
<Icon color="muted" icon="duration" size="sm" />
<Text isMuted size="sm">
{formatDurationString(item.duration ?? 0)}
</Text>
</div>
{item.ownerId === permissions.userId &&
Boolean(item.public) && (
<div className={styles.metadataGroupItem}>
<Text isMuted size="sm">
{t('common.public')}
</Text>
</div>
)}
{item.ownerId !== permissions.userId && (
<div className={styles.metadataGroupItem}>
<Icon color="muted" icon="user" size="sm" />
<Text isMuted size="sm">
{item.owner}
</Text>
</div>
)}
</div>
)}
{item.ownerId !== permissions.userId && (
<div className={styles.metadataGroupItem}>
<Icon color="muted" icon="user" size="sm" />
<Text isMuted size="sm">
{item.owner}
</Text>
</div>
)}
</div>
</div>
</div>
</div>
{isHovered && <RowControls id={to} onPlay={handlePlay} />}
{isHovered && <RowControls id={to} onPlay={handlePlay} />}
</>
)}
</Link>
);
},
@@ -280,9 +304,11 @@ const PlaylistRowButton = memo(
const RowControls = ({
id,
onPlay,
variant = 'expanded',
}: {
id: string;
onPlay: (id: string, playType: Play) => void;
variant?: 'compact' | 'expanded';
}) => {
const handlePlayNext = usePlayButtonClick({
onClick: () => {
@@ -312,7 +338,11 @@ const RowControls = ({
});
return (
<ActionIconGroup className={styles.controls}>
<ActionIconGroup
className={clsx(styles.controls, {
[styles.controlsCompact]: variant === 'compact',
})}
>
<PlayTooltip type={Play.NOW}>
<ActionIcon
icon="mediaPlay"
@@ -485,11 +515,62 @@ export const SidebarPlaylistList = () => {
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 (
<Accordion.Item value="playlists">
<Accordion.Control component="div" role="button" style={{ userSelect: 'none' }}>
<Group justify="space-between" pr="var(--theme-spacing-md)">
<Text fw={500}>{t('page.sidebar.playlists')}</Text>
<PlaylistRootAccordionControl allPlaylists={playlistItems?.items ?? []}>
<Group gap="xs" justify="space-between" pr="var(--theme-spacing-md)" wrap="nowrap">
<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">
<ActionIcon
icon="add"
@@ -503,6 +584,27 @@ export const SidebarPlaylistList = () => {
}}
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
component={Link}
icon="list"
@@ -519,18 +621,17 @@ export const SidebarPlaylistList = () => {
/>
</Group>
</Group>
</Accordion.Control>
</PlaylistRootAccordionControl>
<Accordion.Panel>
{playlistItems?.items?.map((item, index) => (
<PlaylistRowButton
item={item}
key={index}
name={item.name}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
to={item.id}
/>
))}
<PlaylistFolderViews
{...folderViewState}
allPlaylists={playlistItems?.items ?? []}
expandedSet={expandedSet}
navigation={navigation}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
onToggleFolder={toggle}
/>
</Accordion.Panel>
</Accordion.Item>
);
@@ -668,28 +769,53 @@ export const SidebarSharedPlaylistList = () => {
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) {
return null;
}
return (
<Accordion.Item value="shared-playlists">
<Accordion.Control>
<Text fw={500} variant="secondary">
{t('page.sidebar.shared')}
</Text>
<Accordion.Control component="motion.div" role="button" style={{ userSelect: 'none' }}>
<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} variant="secondary">
{inNavigation ? navigation.currentName : t('page.sidebar.shared')}
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
{playlistItems?.items?.map((item, index) => (
<PlaylistRowButton
item={item}
key={index}
name={item.name}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
to={item.id}
/>
))}
<PlaylistFolderViews
{...folderViewState}
allPlaylists={playlistItems?.items ?? []}
expandedSet={expandedSet}
navigation={navigation}
onContextMenu={handleContextMenu}
onReorder={handleReorder}
onToggleFolder={toggle}
/>
</Accordion.Panel>
</Accordion.Item>
);
+6
View File
@@ -46,7 +46,13 @@ declare global {
FS_GENERAL_SIDE_QUEUE_TYPE?: string;
FS_GENERAL_SIDEBAR_COLLAPSE_SHARED?: 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_MODE?: string;
FS_GENERAL_SIDEBAR_PLAYLIST_SORTING?: string;
FS_GENERAL_THEME?: string;
FS_GENERAL_THEME_DARK?: string;
@@ -109,6 +109,8 @@ const FONT_TYPES = new Set(['builtIn', 'custom', 'system']);
const HOME_FEATURE_STYLES = new Set(['multiple', 'single']);
const SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']);
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<
Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'>
@@ -256,11 +258,48 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
path: ['general', 'sidebarCollapseShared'],
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',
path: ['general', 'sidebarPlaylistList'],
type: 'bool',
},
{
enumSet: SIDEBAR_PLAYLIST_MODES,
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_MODE',
path: ['general', 'sidebarPlaylistMode'],
type: 'enum',
},
{
key: 'FS_GENERAL_SIDEBAR_PLAYLIST_SORTING',
path: ['general', 'sidebarPlaylistSorting'],
+37
View File
@@ -160,6 +160,7 @@ const BindingActionsSchema = z.enum([
'listPlayNext',
'listPlayLast',
'listNavigateToPage',
'listShowPlayingSong',
]);
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 SidebarPlaylistFolderViewSchema = z.enum(['single', 'tree', 'navigation']);
const SidebarPlaylistModeSchema = z.enum(['compact', 'expanded']);
const CollectionSchema = z.object({
filterQueryString: z.string(),
id: z.string(),
@@ -500,8 +505,14 @@ export const GeneralSettingsSchema = z.object({
sidebarCollapseShared: z.boolean(),
sidebarItems: z.array(SidebarItemTypeSchema),
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(),
sidebarPlaylistListFilterRegex: z.string(),
sidebarPlaylistMode: SidebarPlaylistModeSchema,
sidebarPlaylistSorting: z.boolean(),
sideQueueLayout: SideQueueLayoutSchema,
sideQueueType: SideQueueTypeSchema,
@@ -756,6 +767,7 @@ export enum BindingActions {
LIST_PLAY_LAST = 'listPlayLast',
LIST_PLAY_NEXT = 'listPlayNext',
LIST_PLAY_NOW = 'listPlayNow',
LIST_SHOW_PLAYING_SONG = 'listShowPlayingSong',
LOCAL_SEARCH = 'localSearch',
MUTE = 'volumeMute',
NAVIGATE_HOME = 'navigateHome',
@@ -1169,8 +1181,14 @@ const initialState: SettingsState = {
sidebarCollapseShared: false,
sidebarItems,
sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'],
sidebarPlaylistFolders: true,
sidebarPlaylistFolderSeparator: '/',
sidebarPlaylistFolderTreeIndent: 16,
sidebarPlaylistFolderTreeLineColor: '',
sidebarPlaylistFolderView: 'tree',
sidebarPlaylistList: true,
sidebarPlaylistListFilterRegex: '',
sidebarPlaylistMode: 'expanded',
sidebarPlaylistSorting: false,
sideQueueLayout: 'horizontal',
sideQueueType: 'sideQueue',
@@ -1205,6 +1223,7 @@ const initialState: SettingsState = {
listPlayLast: { allowGlobal: false, hotkey: '', isGlobal: false },
listPlayNext: { 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 },
navigateHome: { allowGlobal: false, 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 = () =>
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
export const useSidebarPlaylistMode = () =>
useSettingsStore((state) => state.general.sidebarPlaylistMode, shallow);
export const useSidebarPlaylistSorting = () =>
useSettingsStore((state) => state.general.sidebarPlaylistSorting, shallow);
+4
View File
@@ -34,6 +34,8 @@ import {
LuChevronLast,
LuChevronLeft,
LuChevronRight,
LuChevronsDownUp,
LuChevronsUpDown,
LuChevronUp,
LuCircle,
LuCircleCheck,
@@ -250,6 +252,7 @@ export const AppIcon = {
check: LuCheck,
circle: LuCircle,
clipboardCopy: LuClipboardCopy,
collapseAll: LuChevronsDownUp,
collection: LuPackage2,
delete: LuTrash,
disc: LuDisc,
@@ -269,6 +272,7 @@ export const AppIcon = {
emptySongImage: LuMusic,
error: LuShieldAlert,
expand: LuExpand,
expandAll: LuChevronsUpDown,
externalLink: LuExternalLink,
favorite: LuHeart,
fileJson: LuFileJson,
+1
View File
@@ -12,6 +12,7 @@ export enum DragTarget {
GRID_ROW = 'gridRow',
PLAYLIST = LibraryItem.PLAYLIST,
QUEUE_SONG = LibraryItem.QUEUE_SONG,
SIDEBAR_PLAYLIST_FOLDER = 'sidebarPlaylistFolder',
SONG = LibraryItem.SONG,
TABLE_COLUMN = 'tableColumn',
}