feat(playlists): local playlist folders (sidebar + page, DnD, view toggle) (#1119)

* feat(playlists): playlist folder model — store + pure grouping core

Local, per-server folder layer over the server's flat playlist list (the
Subsonic API has no folder concept). Adds:
- playlistFolders.ts: shared types + pure groupPlaylistsByFolder (used by
  every surface), with full unit coverage.
- playlistFolderStore.ts: persisted Zustand store (create/rename/delete/
  assign/collapse), per-server scoped; deleting a folder drops its
  assignments so playlists fall back to ungrouped.

UI surfaces (sidebar + Playlists page) build on this in following commits.

* i18n(playlists): folder strings across all 9 locales

Adds the playlists.folders.* namespace (folder names, move/remove, expand/
collapse, group-by-folders toggle, count plurals, and the local-only notice
explaining that Navidrome and the Subsonic API have no native folder support).

* feat(playlists): folder views, drag-to-folder, move-to-folder menu, view toggle

Surfaces the local folder layer on both the Playlists page and the sidebar:
- Page: collapsible folder sections + ungrouped remainder, each reusing
  VirtualCardGrid; flat grid is kept verbatim when no folders exist or the
  group view is toggled off.
- Drag-to-folder via the shared mouse-based psy-drop system (HTML5 DnD is
  unusable in WebKitGTK); the whole section is the drop zone, and the
  ungrouped zone appears during a drag so a playlist can always return to root.
- "Move to folder" submenu in the playlist context menu (keyboard-accessible
  path; also creates folders on the fly) — stays available offline.
- Header gets a "New folder" action and a "Group by folders" view toggle
  (both context-aware); a notice surfaces the local-only caveat.
- Sidebar renders the same collapsible folder groups.
- groupView preference added to the folder store.

* docs(changelog): note playlist folders (#1119)
This commit is contained in:
Psychotoxical
2026-06-17 21:22:30 +02:00
committed by GitHub
parent ccb2d11fc4
commit ad74578ef6
28 changed files with 1350 additions and 50 deletions
+52 -25
View File
@@ -31,6 +31,9 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
import { Info } from 'lucide-react';
import PlaylistsFolderView from '../components/playlists/PlaylistsFolderView';
import { usePlaylistFolderStore } from '../store/playlistFolderStore';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
@@ -49,6 +52,11 @@ export default function Playlists() {
const playlistsLoading = usePlaylistStore((s) => s.playlistsLoading);
const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const activeServerId = useAuthStore(s => s.activeServerId);
const folderCount = usePlaylistFolderStore(
s => (activeServerId ? s.byServer[activeServerId]?.folders.length ?? 0 : 0),
);
const folderGroupView = usePlaylistFolderStore(s => s.groupView);
const showFolderView = Boolean(activeServerId) && folderCount > 0 && folderGroupView;
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const offlineCtx = useOfflineBrowseContext();
@@ -173,6 +181,28 @@ export default function Playlists() {
targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t,
});
const renderCard = (pl: SubsonicPlaylist) => (
<PlaylistCard
pl={pl}
selectionMode={selectionMode}
draggable={showFolderView}
selectedIds={selectedIds}
selectedPlaylists={selectedPlaylists}
toggleSelect={toggleSelect}
isPlaylistDeletable={isPlaylistDeletable}
deleteConfirmId={deleteConfirmId}
setDeleteConfirmId={setDeleteConfirmId}
handleOpenSmartEditor={handleOpenSmartEditor}
handleDelete={handleDelete}
handlePlay={handlePlay}
playingId={playingId}
smartCoverIdsByPlaylist={smartCoverIdsByPlaylist}
pendingSmart={pendingSmart}
filteredSongCountByPlaylist={filteredSongCountByPlaylist}
filteredDurationByPlaylist={filteredDurationByPlaylist}
/>
);
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
@@ -267,33 +297,30 @@ export default function Playlists() {
{playlists.length === 0 ? (
<div className="empty-state">{t('playlists.empty')}</div>
) : (
<VirtualCardGrid
items={playlists}
itemKey={(pl, _i) => pl.id}
rowVariant="playlist"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={playlists.length}
renderItem={pl => (
<PlaylistCard
pl={pl}
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedPlaylists={selectedPlaylists}
toggleSelect={toggleSelect}
isPlaylistDeletable={isPlaylistDeletable}
deleteConfirmId={deleteConfirmId}
setDeleteConfirmId={setDeleteConfirmId}
handleOpenSmartEditor={handleOpenSmartEditor}
handleDelete={handleDelete}
handlePlay={handlePlay}
playingId={playingId}
smartCoverIdsByPlaylist={smartCoverIdsByPlaylist}
pendingSmart={pendingSmart}
filteredSongCountByPlaylist={filteredSongCountByPlaylist}
filteredDurationByPlaylist={filteredDurationByPlaylist}
<>
{showFolderView && (
<p className="playlist-folder-notice playlist-folder-notice--page">
<Info size={13} /> {t('playlists.folders.localOnlyNotice')}
</p>
)}
{showFolderView && activeServerId ? (
<PlaylistsFolderView
serverId={activeServerId}
playlists={playlists}
renderCard={renderCard}
disableVirtualization={perfFlags.disableMainstageVirtualLists}
/>
) : (
<VirtualCardGrid
items={playlists}
itemKey={(pl, _i) => pl.id}
rowVariant="playlist"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={playlists.length}
renderItem={renderCard}
/>
)}
/>
</>
)}