mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
+52
-25
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user