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
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
groupPlaylistsByFolder,
nextFolderOrder,
type PlaylistFolder,
} from './playlistFolders';
const folder = (id: string, order: number, name = id): PlaylistFolder =>
({ id, name, order, collapsed: false });
const pl = (id: string) => ({ id });
describe('groupPlaylistsByFolder', () => {
it('places playlists into their assigned folder and the rest in ungrouped', () => {
const folders = [folder('f1', 0), folder('f2', 1)];
const result = groupPlaylistsByFolder(
[pl('a'), pl('b'), pl('c'), pl('d')],
folders,
{ a: 'f1', c: 'f2' },
);
expect(result.folders[0].playlists.map(p => p.id)).toEqual(['a']);
expect(result.folders[1].playlists.map(p => p.id)).toEqual(['c']);
expect(result.ungrouped.map(p => p.id)).toEqual(['b', 'd']);
});
it('returns folders in order, including empty ones', () => {
const result = groupPlaylistsByFolder(
[pl('a')],
[folder('f2', 1, 'B'), folder('f1', 0, 'A')],
{ a: 'f1' },
);
expect(result.folders.map(g => g.folder.id)).toEqual(['f1', 'f2']);
expect(result.folders[1].playlists).toEqual([]);
});
it('treats assignments to a missing folder as ungrouped', () => {
const result = groupPlaylistsByFolder([pl('a')], [folder('f1', 0)], { a: 'gone' });
expect(result.ungrouped.map(p => p.id)).toEqual(['a']);
expect(result.folders[0].playlists).toEqual([]);
});
it('preserves input order within a bucket', () => {
const result = groupPlaylistsByFolder(
[pl('c'), pl('a'), pl('b')],
[folder('f1', 0)],
{ a: 'f1', b: 'f1', c: 'f1' },
);
expect(result.folders[0].playlists.map(p => p.id)).toEqual(['c', 'a', 'b']);
});
});
describe('nextFolderOrder', () => {
it('is 0 for an empty list', () => {
expect(nextFolderOrder([])).toBe(0);
});
it('is one past the highest existing order', () => {
expect(nextFolderOrder([folder('a', 0), folder('b', 5)])).toBe(6);
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* Playlist folders — a local, client-side organisation layer over the server's
* flat playlist list. The Subsonic API has no folder concept, so folders and
* their playlist assignments live only in Psysonic (see `playlistFolderStore`),
* scoped per server. This module holds the shared types and the pure grouping
* function used by every surface that renders folders (sidebar, Playlists page).
*/
export interface PlaylistFolder {
id: string;
name: string;
/** Stable sort order among folders (assigned at creation). */
order: number;
collapsed: boolean;
}
export interface PlaylistFolderGroup<T> {
folder: PlaylistFolder;
playlists: T[];
}
export interface GroupedPlaylists<T> {
/** Folders in display order; each carries its playlists (possibly empty). */
folders: PlaylistFolderGroup<T>[];
/** Playlists not assigned to any (existing) folder, in input order. */
ungrouped: T[];
}
/**
* Split `playlists` into folder groups + an ungrouped remainder.
*
* Folders are returned in `order` (then name) order and always appear, even
* when empty, so a freshly created folder is visible. Playlists keep their
* incoming order within each bucket — callers sort the input upstream. An
* assignment pointing at a folder that no longer exists falls back to ungrouped.
*/
export function groupPlaylistsByFolder<T extends { id: string }>(
playlists: readonly T[],
folders: readonly PlaylistFolder[],
assignments: Readonly<Record<string, string>>,
): GroupedPlaylists<T> {
const orderedFolders = [...folders].sort(
(a, b) => a.order - b.order || a.name.localeCompare(b.name),
);
const byFolder = new Map<string, T[]>();
for (const folder of orderedFolders) byFolder.set(folder.id, []);
const ungrouped: T[] = [];
for (const playlist of playlists) {
const folderId = assignments[playlist.id];
const bucket = folderId != null ? byFolder.get(folderId) : undefined;
if (bucket) bucket.push(playlist);
else ungrouped.push(playlist);
}
return {
folders: orderedFolders.map(folder => ({
folder,
playlists: byFolder.get(folder.id) ?? [],
})),
ungrouped,
};
}
/** Next stable `order` value for a new folder appended to `folders`. */
export function nextFolderOrder(folders: readonly PlaylistFolder[]): number {
return folders.reduce((max, f) => Math.max(max, f.order + 1), 0);
}