Enable Playlist in sidebar to be sorted (#1542)

* add playlist reorder in sidebar
This commit is contained in:
Mike Benz
2026-01-29 05:54:20 +01:00
committed by GitHub
parent ced3b491ff
commit 796629b4e6
5 changed files with 377 additions and 182 deletions
+2
View File
@@ -791,6 +791,7 @@
"playButtonBehavior": "Verhalten der Wiedergabetaste", "playButtonBehavior": "Verhalten der Wiedergabetaste",
"volumeWheelStep": "Lautstärkeänderung mit Mausrad", "volumeWheelStep": "Lautstärkeänderung mit Mausrad",
"sidebarPlaylistList_description": "Ein- oder Ausblenden der Playlisten-Liste in der Seitenleiste", "sidebarPlaylistList_description": "Ein- oder Ausblenden der Playlisten-Liste in der Seitenleiste",
"sidebarPlaylistSorting_description": "sortiere Playlists in der Seitenleiste per Drag & Drop anstelle der standardmäßigen Serverreihenfolge",
"sidePlayQueueStyle_description": "legt den Stil der Wiedergabeliste in der Seitenleiste fest", "sidePlayQueueStyle_description": "legt den Stil der Wiedergabeliste in der Seitenleiste fest",
"replayGainMode": "{{ReplayGain}} Modus", "replayGainMode": "{{ReplayGain}} Modus",
"playbackStyle_optionNormal": "Normal", "playbackStyle_optionNormal": "Normal",
@@ -815,6 +816,7 @@
"hotkey_browserBack": "Browser zurück", "hotkey_browserBack": "Browser zurück",
"showSkipButton": "Schaltflächen zum Überspringen anzeigen", "showSkipButton": "Schaltflächen zum Überspringen anzeigen",
"sidebarPlaylistList": "Seitenleiste Playlisten-Liste", "sidebarPlaylistList": "Seitenleiste Playlisten-Liste",
"sidebarPlaylistSorting": "Playlist-Sortierung in der Seitenleiste",
"minimizeToTray": "Zur Taskleiste minimieren", "minimizeToTray": "Zur Taskleiste minimieren",
"skipPlaylistPage": "Playlisten-Seite überspringen", "skipPlaylistPage": "Playlisten-Seite überspringen",
"themeDark": "Erscheinungsbild (dunkel)", "themeDark": "Erscheinungsbild (dunkel)",
+2
View File
@@ -968,6 +968,8 @@
"sidebarConfiguration": "sidebar configuration", "sidebarConfiguration": "sidebar configuration",
"sidebarPlaylistList_description": "show or hide the playlist list in the sidebar", "sidebarPlaylistList_description": "show or hide the playlist list in the sidebar",
"sidebarPlaylistList": "sidebar playlist list", "sidebarPlaylistList": "sidebar playlist list",
"sidebarPlaylistSorting_description": "allows manual playlist sorting in the sidebar using drag and drop instead of the default server order",
"sidebarPlaylistSorting": "sidebar playlist sorting",
"sidePlayQueueStyle_description": "sets the style of the side play queue", "sidePlayQueueStyle_description": "sets the style of the side play queue",
"sidePlayQueueStyle_optionAttached": "attached", "sidePlayQueueStyle_optionAttached": "attached",
"sidePlayQueueStyle_optionDetached": "detached", "sidePlayQueueStyle_optionDetached": "detached",
@@ -22,6 +22,14 @@ export const SidebarSettings = memo(() => {
}); });
}; };
const handleSetSidebarPlaylistSorting = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({
general: {
sidebarPlaylistSorting: e.target.checked,
},
});
};
const handleSetSidebarCollapsedNavigation = (e: ChangeEvent<HTMLInputElement>) => { const handleSetSidebarCollapsedNavigation = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({ setSettings({
general: { general: {
@@ -44,6 +52,19 @@ export const SidebarSettings = memo(() => {
}), }),
title: t('setting.sidebarPlaylistList', { postProcess: 'sentenceCase' }), title: t('setting.sidebarPlaylistList', { postProcess: 'sentenceCase' }),
}, },
{
control: (
<Switch
checked={settings.sidebarPlaylistSorting}
onChange={handleSetSidebarPlaylistSorting}
/>
),
description: t('setting.sidebarPlaylistSorting', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.sidebarPlaylistSorting', { postProcess: 'sentenceCase' }),
},
{ {
control: ( control: (
<Switch <Switch
@@ -8,7 +8,6 @@ import { generatePath, Link } from 'react-router';
import styles from './sidebar-playlist-list.module.css'; import styles from './sidebar-playlist-list.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
@@ -20,7 +19,12 @@ import {
import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click'; import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/renderer/store'; import {
useCurrentServer,
useCurrentServerId,
usePermissions,
useSidebarPlaylistSorting,
} from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils'; import { formatDurationString } from '/@/renderer/utils';
import { Accordion } from '/@/shared/components/accordion/accordion'; import { Accordion } from '/@/shared/components/accordion/accordion';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
@@ -29,6 +33,7 @@ import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Image } from '/@/shared/components/image/image'; import { Image } from '/@/shared/components/image/image';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { import {
LibraryItem, LibraryItem,
Playlist, Playlist,
@@ -39,199 +44,237 @@ import {
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
import { Play } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
const getPlaylistOrderKey = (serverId: string | undefined, scope: 'owned' | 'shared') => {
const sid = serverId || 'local';
return `playlist_order:${sid}:${scope}`;
};
interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> { interface PlaylistRowButtonProps extends Omit<ButtonProps, 'onContextMenu' | 'onPlay'> {
item: Playlist; item: Playlist;
name: string; name: string;
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void; onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
onReorder?: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;
to: string; to: string;
} }
const PlaylistRowButton = memo(({ item, name, onContextMenu, to }: PlaylistRowButtonProps) => { const PlaylistRowButton = memo(
const url = { ({ item, name, onContextMenu, onReorder, to }: PlaylistRowButtonProps) => {
pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }), const url = {
state: { item }, pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }),
}; state: { item },
const { t } = useTranslation(); };
const { t } = useTranslation();
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLAnchorElement>({ const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLAnchorElement>({
drag: { drag: {
getId: () => { getId: () => {
const draggedItems = getDraggedItems(item, undefined); return item && item.id ? [item.id] : [];
return draggedItems.map((draggedItem) => draggedItem.id); },
getItem: () => {
return item ? [item] : [];
},
itemType: LibraryItem.PLAYLIST,
operation: [DragOperation.ADD, DragOperation.REORDER],
target: DragTarget.PLAYLIST,
}, },
getItem: () => { drop: {
const draggedItems = getDraggedItems(item, undefined); canDrop: (args) => {
return draggedItems; // Allow dropping items into a playlist (ADD)
}, const canAdd =
itemType: LibraryItem.PLAYLIST, args.source.itemType !== undefined &&
operation: [DragOperation.ADD], args.source.type !== DragTarget.PLAYLIST &&
target: DragTarget.PLAYLIST, (args.source.operation?.includes(DragOperation.ADD) ?? false);
},
drop: {
canDrop: (args) => {
return (
args.source.itemType !== undefined &&
args.source.type !== DragTarget.PLAYLIST &&
(args.source.operation?.includes(DragOperation.ADD) ?? false)
);
},
getData: () => {
return {
id: [to],
item: [],
itemType: LibraryItem.PLAYLIST,
type: DragTarget.PLAYLIST,
};
},
onDrag: () => {
return;
},
onDragLeave: () => {
return;
},
onDrop: (args) => {
const sourceItemType = args.source.itemType as LibraryItem;
const sourceIds = args.source.id;
const modalProps: { // Allow reordering playlists when source is playlist and operation includes REORDER
albumId?: string[]; // do not allow cross-scope reorders
artistId?: string[]; const canReorder =
folderId?: string[]; args.source.itemType === LibraryItem.PLAYLIST &&
genreId?: string[]; args.source.type === DragTarget.PLAYLIST &&
initialSelectedIds?: string[]; (args.source.operation?.includes(DragOperation.REORDER) ?? false);
playlistId?: string[]; return canAdd || (canReorder && sidebarPlaylistSorting);
songId?: string[]; },
} = { getData: () => {
initialSelectedIds: [to], return {
}; id: [to],
item: [],
itemType: LibraryItem.PLAYLIST,
type: DragTarget.PLAYLIST,
};
},
onDrag: () => {
return;
},
onDragLeave: () => {
return;
},
onDrop: (args) => {
const sourceItemType = args.source.itemType as LibraryItem;
const sourceIds = args.source.id;
switch (sourceItemType) { // Handle playlist reordering locally
case LibraryItem.ALBUM: if (
modalProps.albumId = sourceIds; sourceItemType === LibraryItem.PLAYLIST &&
break; (args.source.operation?.includes(DragOperation.REORDER) ?? false) &&
case LibraryItem.ALBUM_ARTIST: args.edge &&
case LibraryItem.ARTIST: (args.edge === 'top' || args.edge === 'bottom') &&
modalProps.artistId = sourceIds; onReorder
break; ) {
case LibraryItem.FOLDER: const sourceItems = Array.isArray(args.source.item)
modalProps.folderId = sourceIds; ? (args.source.item as Playlist[])
break; : undefined;
case LibraryItem.GENRE:
modalProps.genreId = sourceIds; // Prevent cross-scope reorders (owned <-> shared)
break; if (sourceItems && sourceItems.length > 0) {
case LibraryItem.PLAYLIST: if (sourceItems.some((si) => si.ownerId !== item.ownerId)) {
modalProps.playlistId = sourceIds; return;
break; }
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
if (args.source.item && Array.isArray(args.source.item)) {
const songs = args.source.item as Song[];
modalProps.songId = songs.map((song) => song.id);
} else {
modalProps.songId = sourceIds;
} }
break;
default: onReorder(sourceIds, to, args.edge);
return; return;
} }
openContextModal({ const modalProps: {
innerProps: modalProps, albumId?: string[];
modalKey: 'addToPlaylist', artistId?: string[];
size: 'lg', folderId?: string[];
title: t('form.addToPlaylist.title', { postProcess: 'titleCase' }), genreId?: string[];
}); initialSelectedIds?: string[];
playlistId?: string[];
songId?: string[];
} = {
initialSelectedIds: [to],
};
switch (sourceItemType) {
case LibraryItem.ALBUM:
modalProps.albumId = sourceIds;
break;
case LibraryItem.ALBUM_ARTIST:
case LibraryItem.ARTIST:
modalProps.artistId = sourceIds;
break;
case LibraryItem.FOLDER:
modalProps.folderId = sourceIds;
break;
case LibraryItem.GENRE:
modalProps.genreId = sourceIds;
break;
case LibraryItem.PLAYLIST:
modalProps.playlistId = sourceIds;
break;
case LibraryItem.PLAYLIST_SONG:
case LibraryItem.QUEUE_SONG:
case LibraryItem.SONG:
if (args.source.item && Array.isArray(args.source.item)) {
const songs = args.source.item as Song[];
modalProps.songId = songs.map((song) => song.id);
} else {
modalProps.songId = sourceIds;
}
break;
default:
return;
}
openContextModal({
innerProps: modalProps,
modalKey: 'addToPlaylist',
size: 'lg',
title: t('form.addToPlaylist.title', { postProcess: 'titleCase' }),
});
},
}, },
}, isEnabled: true,
isEnabled: true, });
});
const player = usePlayer(); const player = usePlayer();
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const permissions = usePermissions(); const permissions = usePermissions();
const handlePlay = useCallback( const handlePlay = useCallback(
(id: string, type: Play) => { (id: string, type: Play) => {
player.addToQueueByFetch(serverId, [id], LibraryItem.PLAYLIST, type); player.addToQueueByFetch(serverId, [id], LibraryItem.PLAYLIST, type);
}, },
[player, serverId], [player, serverId],
); );
const imageUrl = useItemImageUrl({ const imageUrl = useItemImageUrl({
id: item.imageId || undefined, id: item.imageId || undefined,
itemType: LibraryItem.PLAYLIST, itemType: LibraryItem.PLAYLIST,
type: 'table', type: 'table',
}); });
return ( return (
<Link <Link
className={clsx(styles.row, { className={clsx(styles.row, {
[styles.rowDraggedOver]: isDraggedOver, [styles.rowDraggedOver]: isDraggedOver,
[styles.rowHover]: isHovered, [styles.rowHover]: isHovered,
})} })}
onContextMenu={(e: MouseEvent<HTMLAnchorElement>) => { onContextMenu={(e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault(); e.preventDefault();
onContextMenu(e, item); onContextMenu(e, item);
}} }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
ref={ref} ref={ref}
style={{ style={{
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
}} }}
to={url} to={url}
> >
<div className={styles.rowGroup}> <div className={styles.rowGroup}>
<Image containerClassName={styles.imageContainer} src={imageUrl} /> <Image containerClassName={styles.imageContainer} src={imageUrl} />
<div className={styles.metadata}> <div className={styles.metadata}>
<Text className={styles.name} fw={500} size="md"> <Text className={styles.name} fw={500} size="md">
{name} {name}
</Text> </Text>
<div className={styles.metadataGroup}> <div className={styles.metadataGroup}>
<div <div
className={clsx( className={clsx(
styles.metadataGroupItem, styles.metadataGroupItem,
styles.metadataGroupItemNoShrink, 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', { postProcess: 'titleCase' })}
</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>
)} )}
>
<Icon color="muted" icon="itemSong" size="sm" />
<Text isMuted size="sm">
{item.songCount || 0}
</Text>
</div> </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', { postProcess: 'titleCase' })}
</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> </div>
</div> </div>
</div>
{isHovered && <RowControls id={to} onPlay={handlePlay} />} {isHovered && <RowControls id={to} onPlay={handlePlay} />}
</Link> </Link>
); );
}); },
);
const RowControls = ({ const RowControls = ({
id, id,
@@ -313,6 +356,7 @@ export const SidebarPlaylistList = () => {
const player = usePlayer(); const player = usePlayer();
const { t } = useTranslation(); const { t } = useTranslation();
const server = useCurrentServer(); const server = useCurrentServer();
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
const playlistsQuery = useQuery( const playlistsQuery = useQuery(
playlistsQueries.list({ playlistsQueries.list({
@@ -344,23 +388,82 @@ export const SidebarPlaylistList = () => {
[], [],
); );
const memoizedItemData = useMemo(() => { const [playlistOrder, setPlaylistOrder] = useLocalStorage<string[]>({
defaultValue: [],
key: getPlaylistOrderKey(server.id, 'owned'),
});
const playlistItems = useMemo(() => {
const base = { handlePlay: handlePlayPlaylist }; const base = { handlePlay: handlePlayPlaylist };
if (!server?.type || !server?.username || !playlistsQuery.data?.items) { if (!server?.type || !server?.username || !playlistsQuery.data?.items) {
return { ...base, items: playlistsQuery.data?.items }; return { ...base, items: playlistsQuery.data?.items };
} }
const owned: Array<[boolean, () => void] | Playlist> = []; const ownedPlaylistItems: Array<Playlist> = [];
for (const playlist of playlistsQuery.data?.items ?? []) { for (const playlist of playlistsQuery.data?.items ?? []) {
if (!playlist.owner || playlist.owner === server.username) { if (!playlist.owner || playlist.owner === server.username) {
owned.push(playlist); ownedPlaylistItems.push(playlist);
} }
} }
return { ...base, items: owned }; if (!ownedPlaylistItems || !sidebarPlaylistSorting || !playlistOrder) {
}, [playlistsQuery.data?.items, handlePlayPlaylist, server?.type, server.username]); return { ...base, items: ownedPlaylistItems };
}
// Apply saved order, include only playlists that still exist
const idMap = new Map(ownedPlaylistItems.map((it) => [it.id, it]));
const ordered = playlistOrder
.map((id) => idMap.get(id))
.filter((it): it is Playlist => it !== undefined);
// Append any new items that weren't in saved order
const remaining = ownedPlaylistItems.filter((it) => !playlistOrder.includes(it.id));
const newPlaylistItems = [...ordered, ...remaining];
return { ...base, items: newPlaylistItems };
}, [
handlePlayPlaylist,
playlistsQuery.data?.items,
server.type,
server.username,
sidebarPlaylistSorting,
playlistOrder,
]);
const handleReorder = (
sourceIds: string[],
targetId: string,
edge: 'bottom' | 'top' | null,
) => {
if (!playlistItems?.items || !edge) return;
const currentIds = playlistItems.items.map((p) => p.id);
const targetIndex = currentIds.indexOf(targetId);
if (targetIndex === -1) return;
const idsWithoutSources = currentIds.filter((id) => !sourceIds.includes(id));
const sourcesBeforeTarget = sourceIds.filter((id) => {
const sourceIndex = currentIds.indexOf(id);
return sourceIndex !== -1 && sourceIndex < targetIndex;
}).length;
const insertIndexInFiltered =
edge === 'top'
? targetIndex - sourcesBeforeTarget
: targetIndex - sourcesBeforeTarget + 1;
const insertIndex = Math.max(0, Math.min(insertIndexInFiltered, idsWithoutSources.length));
const reorderedIds = [
...idsWithoutSources.slice(0, insertIndex),
...sourceIds,
...idsWithoutSources.slice(insertIndex),
];
setPlaylistOrder(reorderedIds);
};
const handleCreatePlaylistModal = (e: MouseEvent<HTMLButtonElement>) => { const handleCreatePlaylistModal = (e: MouseEvent<HTMLButtonElement>) => {
openCreatePlaylistModal(server, e); openCreatePlaylistModal(server, e);
@@ -410,12 +513,13 @@ export const SidebarPlaylistList = () => {
</Group> </Group>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
{memoizedItemData?.items?.map((item, index) => ( {playlistItems?.items?.map((item, index) => (
<PlaylistRowButton <PlaylistRowButton
item={item} item={item}
key={index} key={index}
name={item.name} name={item.name}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onReorder={handleReorder}
to={item.id} to={item.id}
/> />
))} ))}
@@ -428,6 +532,7 @@ export const SidebarSharedPlaylistList = () => {
const player = usePlayer(); const player = usePlayer();
const { t } = useTranslation(); const { t } = useTranslation();
const server = useCurrentServer(); const server = useCurrentServer();
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
const playlistsQuery = useQuery( const playlistsQuery = useQuery(
playlistsQueries.list({ playlistsQueries.list({
@@ -463,25 +568,84 @@ export const SidebarSharedPlaylistList = () => {
[], [],
); );
const memoizedItemData = useMemo(() => { const [playlistOrder, setPlaylistOrder] = useLocalStorage<string[]>({
defaultValue: [],
key: getPlaylistOrderKey(server.id, 'shared'),
});
const playlistItems = useMemo(() => {
const base = { handlePlay: handlePlayPlaylist }; const base = { handlePlay: handlePlayPlaylist };
if (!server?.type || !server?.username || !playlistsQuery.data?.items) { if (!server?.type || !server?.username || !playlistsQuery.data?.items) {
return { ...base, items: playlistsQuery.data?.items }; return { ...base, items: playlistsQuery.data?.items };
} }
const shared: Playlist[] = []; const sharedPlaylistItems: Array<Playlist> = [];
for (const playlist of playlistsQuery.data?.items ?? []) { for (const playlist of playlistsQuery.data?.items ?? []) {
if (playlist.owner && playlist.owner !== server.username) { if (playlist.owner && playlist.owner !== server.username) {
shared.push(playlist); sharedPlaylistItems.push(playlist);
} }
} }
return { ...base, items: shared }; if (!sharedPlaylistItems || !sidebarPlaylistSorting || !playlistOrder) {
}, [handlePlayPlaylist, playlistsQuery.data?.items, server?.type, server.username]); return { ...base, items: sharedPlaylistItems };
}
if (memoizedItemData?.items?.length === 0) { // Apply saved order, include only playlists that still exist
const idMap = new Map(sharedPlaylistItems.map((it) => [it.id, it]));
const ordered = playlistOrder
.map((id) => idMap.get(id))
.filter((it): it is Playlist => it !== undefined);
// Append any new items that weren't in saved order
const remaining = sharedPlaylistItems.filter((it) => !playlistOrder.includes(it.id));
const newPlaylistItems = [...ordered, ...remaining];
return { ...base, items: newPlaylistItems };
}, [
handlePlayPlaylist,
playlistsQuery.data?.items,
server.type,
server.username,
sidebarPlaylistSorting,
playlistOrder,
]);
const handleReorder = (
sourceIds: string[],
targetId: string,
edge: 'bottom' | 'top' | null,
) => {
if (!playlistItems?.items || !edge) return;
const currentIds = playlistItems.items.map((p) => p.id);
const targetIndex = currentIds.indexOf(targetId);
if (targetIndex === -1) return;
const idsWithoutSources = currentIds.filter((id) => !sourceIds.includes(id));
const sourcesBeforeTarget = sourceIds.filter((id) => {
const sourceIndex = currentIds.indexOf(id);
return sourceIndex !== -1 && sourceIndex < targetIndex;
}).length;
const insertIndexInFiltered =
edge === 'top'
? targetIndex - sourcesBeforeTarget
: targetIndex - sourcesBeforeTarget + 1;
const insertIndex = Math.max(0, Math.min(insertIndexInFiltered, idsWithoutSources.length));
const reorderedIds = [
...idsWithoutSources.slice(0, insertIndex),
...sourceIds,
...idsWithoutSources.slice(insertIndex),
];
setPlaylistOrder(reorderedIds);
};
if (playlistItems?.items?.length === 0) {
return null; return null;
} }
@@ -495,12 +659,13 @@ export const SidebarSharedPlaylistList = () => {
</Text> </Text>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
{memoizedItemData?.items?.map((item, index) => ( {playlistItems?.items?.map((item, index) => (
<PlaylistRowButton <PlaylistRowButton
item={item} item={item}
key={index} key={index}
name={item.name} name={item.name}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onReorder={handleReorder}
to={item.id} to={item.id}
/> />
))} ))}
+5
View File
@@ -444,6 +444,7 @@ export const GeneralSettingsSchema = z.object({
sidebarItems: z.array(SidebarItemTypeSchema), sidebarItems: z.array(SidebarItemTypeSchema),
sidebarPanelOrder: z.array(SidebarPanelTypeSchema), sidebarPanelOrder: z.array(SidebarPanelTypeSchema),
sidebarPlaylistList: z.boolean(), sidebarPlaylistList: z.boolean(),
sidebarPlaylistSorting: z.boolean(),
sideQueueType: SideQueueTypeSchema, sideQueueType: SideQueueTypeSchema,
skipButtons: SkipButtonsSchema, skipButtons: SkipButtonsSchema,
theme: z.nativeEnum(AppTheme), theme: z.nativeEnum(AppTheme),
@@ -1007,6 +1008,7 @@ const initialState: SettingsState = {
sidebarItems, sidebarItems,
sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'], sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'],
sidebarPlaylistList: true, sidebarPlaylistList: true,
sidebarPlaylistSorting: false,
sideQueueType: 'sideQueue', sideQueueType: 'sideQueue',
skipButtons: { skipButtons: {
enabled: false, enabled: false,
@@ -2108,6 +2110,9 @@ export const useVolumeWheelStep = () =>
export const useSidebarPlaylistList = () => export const useSidebarPlaylistList = () =>
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow); useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
export const useSidebarPlaylistSorting = () =>
useSettingsStore((state) => state.general.sidebarPlaylistSorting, shallow);
export const useSidebarItems = () => export const useSidebarItems = () =>
useSettingsStore((state) => state.general.sidebarItems, shallow); useSettingsStore((state) => state.general.sidebarItems, shallow);