mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
Enable Playlist in sidebar to be sorted (#1542)
* add playlist reorder in sidebar
This commit is contained in:
@@ -791,6 +791,7 @@
|
||||
"playButtonBehavior": "Verhalten der Wiedergabetaste",
|
||||
"volumeWheelStep": "Lautstärkeänderung mit Mausrad",
|
||||
"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",
|
||||
"replayGainMode": "{{ReplayGain}} Modus",
|
||||
"playbackStyle_optionNormal": "Normal",
|
||||
@@ -815,6 +816,7 @@
|
||||
"hotkey_browserBack": "Browser zurück",
|
||||
"showSkipButton": "Schaltflächen zum Überspringen anzeigen",
|
||||
"sidebarPlaylistList": "Seitenleiste Playlisten-Liste",
|
||||
"sidebarPlaylistSorting": "Playlist-Sortierung in der Seitenleiste",
|
||||
"minimizeToTray": "Zur Taskleiste minimieren",
|
||||
"skipPlaylistPage": "Playlisten-Seite überspringen",
|
||||
"themeDark": "Erscheinungsbild (dunkel)",
|
||||
|
||||
@@ -968,6 +968,8 @@
|
||||
"sidebarConfiguration": "sidebar configuration",
|
||||
"sidebarPlaylistList_description": "show or hide the playlist list in the sidebar",
|
||||
"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_optionAttached": "attached",
|
||||
"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>) => {
|
||||
setSettings({
|
||||
general: {
|
||||
@@ -44,6 +52,19 @@ export const SidebarSettings = memo(() => {
|
||||
}),
|
||||
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: (
|
||||
<Switch
|
||||
|
||||
@@ -8,7 +8,6 @@ import { generatePath, Link } from 'react-router';
|
||||
import styles from './sidebar-playlist-list.module.css';
|
||||
|
||||
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 { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
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 { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
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 { Accordion } from '/@/shared/components/accordion/accordion';
|
||||
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 { Image } from '/@/shared/components/image/image';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
import {
|
||||
LibraryItem,
|
||||
Playlist,
|
||||
@@ -39,199 +44,237 @@ import {
|
||||
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||
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'> {
|
||||
item: Playlist;
|
||||
name: string;
|
||||
onContextMenu: (e: MouseEvent<HTMLAnchorElement>, item: Playlist) => void;
|
||||
onReorder?: (sourceIds: string[], targetId: string, edge: 'bottom' | 'top' | null) => void;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const PlaylistRowButton = memo(({ item, name, onContextMenu, to }: PlaylistRowButtonProps) => {
|
||||
const url = {
|
||||
pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }),
|
||||
state: { item },
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const PlaylistRowButton = memo(
|
||||
({ item, name, onContextMenu, onReorder, to }: PlaylistRowButtonProps) => {
|
||||
const url = {
|
||||
pathname: generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: to }),
|
||||
state: { item },
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLAnchorElement>({
|
||||
drag: {
|
||||
getId: () => {
|
||||
const draggedItems = getDraggedItems(item, undefined);
|
||||
return draggedItems.map((draggedItem) => draggedItem.id);
|
||||
const { isDraggedOver, isDragging, ref } = useDragDrop<HTMLAnchorElement>({
|
||||
drag: {
|
||||
getId: () => {
|
||||
return item && item.id ? [item.id] : [];
|
||||
},
|
||||
getItem: () => {
|
||||
return item ? [item] : [];
|
||||
},
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
operation: [DragOperation.ADD, DragOperation.REORDER],
|
||||
target: DragTarget.PLAYLIST,
|
||||
},
|
||||
getItem: () => {
|
||||
const draggedItems = getDraggedItems(item, undefined);
|
||||
return draggedItems;
|
||||
},
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
operation: [DragOperation.ADD],
|
||||
target: DragTarget.PLAYLIST,
|
||||
},
|
||||
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;
|
||||
drop: {
|
||||
canDrop: (args) => {
|
||||
// Allow dropping items into a playlist (ADD)
|
||||
const canAdd =
|
||||
args.source.itemType !== undefined &&
|
||||
args.source.type !== DragTarget.PLAYLIST &&
|
||||
(args.source.operation?.includes(DragOperation.ADD) ?? false);
|
||||
|
||||
const modalProps: {
|
||||
albumId?: string[];
|
||||
artistId?: string[];
|
||||
folderId?: string[];
|
||||
genreId?: string[];
|
||||
initialSelectedIds?: string[];
|
||||
playlistId?: string[];
|
||||
songId?: string[];
|
||||
} = {
|
||||
initialSelectedIds: [to],
|
||||
};
|
||||
// Allow reordering playlists when source is playlist and operation includes REORDER
|
||||
// do not allow cross-scope reorders
|
||||
const canReorder =
|
||||
args.source.itemType === LibraryItem.PLAYLIST &&
|
||||
args.source.type === DragTarget.PLAYLIST &&
|
||||
(args.source.operation?.includes(DragOperation.REORDER) ?? false);
|
||||
return canAdd || (canReorder && sidebarPlaylistSorting);
|
||||
},
|
||||
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;
|
||||
|
||||
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;
|
||||
// Handle playlist reordering locally
|
||||
if (
|
||||
sourceItemType === LibraryItem.PLAYLIST &&
|
||||
(args.source.operation?.includes(DragOperation.REORDER) ?? false) &&
|
||||
args.edge &&
|
||||
(args.edge === 'top' || args.edge === 'bottom') &&
|
||||
onReorder
|
||||
) {
|
||||
const sourceItems = Array.isArray(args.source.item)
|
||||
? (args.source.item as Playlist[])
|
||||
: undefined;
|
||||
|
||||
// Prevent cross-scope reorders (owned <-> shared)
|
||||
if (sourceItems && sourceItems.length > 0) {
|
||||
if (sourceItems.some((si) => si.ownerId !== item.ownerId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
||||
onReorder(sourceIds, to, args.edge);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
openContextModal({
|
||||
innerProps: modalProps,
|
||||
modalKey: 'addToPlaylist',
|
||||
size: 'lg',
|
||||
title: t('form.addToPlaylist.title', { postProcess: 'titleCase' }),
|
||||
});
|
||||
const modalProps: {
|
||||
albumId?: string[];
|
||||
artistId?: string[];
|
||||
folderId?: string[];
|
||||
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 serverId = useCurrentServerId();
|
||||
const player = usePlayer();
|
||||
const serverId = useCurrentServerId();
|
||||
|
||||
const permissions = usePermissions();
|
||||
const permissions = usePermissions();
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(id: string, type: Play) => {
|
||||
player.addToQueueByFetch(serverId, [id], LibraryItem.PLAYLIST, type);
|
||||
},
|
||||
[player, serverId],
|
||||
);
|
||||
const handlePlay = useCallback(
|
||||
(id: string, type: Play) => {
|
||||
player.addToQueueByFetch(serverId, [id], LibraryItem.PLAYLIST, type);
|
||||
},
|
||||
[player, serverId],
|
||||
);
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: item.imageId || undefined,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
type: 'table',
|
||||
});
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: item.imageId || undefined,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
type: 'table',
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={clsx(styles.row, {
|
||||
[styles.rowDraggedOver]: isDraggedOver,
|
||||
[styles.rowHover]: isHovered,
|
||||
})}
|
||||
onContextMenu={(e: MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
onContextMenu(e, item);
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
ref={ref}
|
||||
style={{
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}}
|
||||
to={url}
|
||||
>
|
||||
<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 className={styles.metadataGroup}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.metadataGroupItem,
|
||||
styles.metadataGroupItemNoShrink,
|
||||
return (
|
||||
<Link
|
||||
className={clsx(styles.row, {
|
||||
[styles.rowDraggedOver]: isDraggedOver,
|
||||
[styles.rowHover]: isHovered,
|
||||
})}
|
||||
onContextMenu={(e: MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
onContextMenu(e, item);
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
ref={ref}
|
||||
style={{
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}}
|
||||
to={url}
|
||||
>
|
||||
<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 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', { 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 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>
|
||||
|
||||
{isHovered && <RowControls id={to} onPlay={handlePlay} />}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
{isHovered && <RowControls id={to} onPlay={handlePlay} />}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const RowControls = ({
|
||||
id,
|
||||
@@ -313,6 +356,7 @@ export const SidebarPlaylistList = () => {
|
||||
const player = usePlayer();
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
|
||||
|
||||
const playlistsQuery = useQuery(
|
||||
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 };
|
||||
|
||||
if (!server?.type || !server?.username || !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 ?? []) {
|
||||
if (!playlist.owner || playlist.owner === server.username) {
|
||||
owned.push(playlist);
|
||||
ownedPlaylistItems.push(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...base, items: owned };
|
||||
}, [playlistsQuery.data?.items, handlePlayPlaylist, server?.type, server.username]);
|
||||
if (!ownedPlaylistItems || !sidebarPlaylistSorting || !playlistOrder) {
|
||||
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>) => {
|
||||
openCreatePlaylistModal(server, e);
|
||||
@@ -410,12 +513,13 @@ export const SidebarPlaylistList = () => {
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{memoizedItemData?.items?.map((item, index) => (
|
||||
{playlistItems?.items?.map((item, index) => (
|
||||
<PlaylistRowButton
|
||||
item={item}
|
||||
key={index}
|
||||
name={item.name}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReorder={handleReorder}
|
||||
to={item.id}
|
||||
/>
|
||||
))}
|
||||
@@ -428,6 +532,7 @@ export const SidebarSharedPlaylistList = () => {
|
||||
const player = usePlayer();
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const sidebarPlaylistSorting = useSidebarPlaylistSorting();
|
||||
|
||||
const playlistsQuery = useQuery(
|
||||
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 };
|
||||
|
||||
if (!server?.type || !server?.username || !playlistsQuery.data?.items) {
|
||||
return { ...base, items: playlistsQuery.data?.items };
|
||||
}
|
||||
|
||||
const shared: Playlist[] = [];
|
||||
const sharedPlaylistItems: Array<Playlist> = [];
|
||||
|
||||
for (const playlist of playlistsQuery.data?.items ?? []) {
|
||||
if (playlist.owner && playlist.owner !== server.username) {
|
||||
shared.push(playlist);
|
||||
sharedPlaylistItems.push(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...base, items: shared };
|
||||
}, [handlePlayPlaylist, playlistsQuery.data?.items, server?.type, server.username]);
|
||||
if (!sharedPlaylistItems || !sidebarPlaylistSorting || !playlistOrder) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -495,12 +659,13 @@ export const SidebarSharedPlaylistList = () => {
|
||||
</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{memoizedItemData?.items?.map((item, index) => (
|
||||
{playlistItems?.items?.map((item, index) => (
|
||||
<PlaylistRowButton
|
||||
item={item}
|
||||
key={index}
|
||||
name={item.name}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReorder={handleReorder}
|
||||
to={item.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -444,6 +444,7 @@ export const GeneralSettingsSchema = z.object({
|
||||
sidebarItems: z.array(SidebarItemTypeSchema),
|
||||
sidebarPanelOrder: z.array(SidebarPanelTypeSchema),
|
||||
sidebarPlaylistList: z.boolean(),
|
||||
sidebarPlaylistSorting: z.boolean(),
|
||||
sideQueueType: SideQueueTypeSchema,
|
||||
skipButtons: SkipButtonsSchema,
|
||||
theme: z.nativeEnum(AppTheme),
|
||||
@@ -1007,6 +1008,7 @@ const initialState: SettingsState = {
|
||||
sidebarItems,
|
||||
sidebarPanelOrder: ['queue', 'lyrics', 'visualizer'],
|
||||
sidebarPlaylistList: true,
|
||||
sidebarPlaylistSorting: false,
|
||||
sideQueueType: 'sideQueue',
|
||||
skipButtons: {
|
||||
enabled: false,
|
||||
@@ -2108,6 +2110,9 @@ export const useVolumeWheelStep = () =>
|
||||
export const useSidebarPlaylistList = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistList, shallow);
|
||||
|
||||
export const useSidebarPlaylistSorting = () =>
|
||||
useSettingsStore((state) => state.general.sidebarPlaylistSorting, shallow);
|
||||
|
||||
export const useSidebarItems = () =>
|
||||
useSettingsStore((state) => state.general.sidebarItems, shallow);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user