diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 20671a3ae..2e0e61efa 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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)", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5b72893c6..31cb01a58 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/features/settings/components/general/sidebar-settings.tsx b/src/renderer/features/settings/components/general/sidebar-settings.tsx index b05fa7dda..1f799912d 100644 --- a/src/renderer/features/settings/components/general/sidebar-settings.tsx +++ b/src/renderer/features/settings/components/general/sidebar-settings.tsx @@ -22,6 +22,14 @@ export const SidebarSettings = memo(() => { }); }; + const handleSetSidebarPlaylistSorting = (e: ChangeEvent) => { + setSettings({ + general: { + sidebarPlaylistSorting: e.target.checked, + }, + }); + }; + const handleSetSidebarCollapsedNavigation = (e: ChangeEvent) => { setSettings({ general: { @@ -44,6 +52,19 @@ export const SidebarSettings = memo(() => { }), title: t('setting.sidebarPlaylistList', { postProcess: 'sentenceCase' }), }, + { + control: ( + + ), + description: t('setting.sidebarPlaylistSorting', { + context: 'description', + postProcess: 'sentenceCase', + }), + title: t('setting.sidebarPlaylistSorting', { postProcess: 'sentenceCase' }), + }, { control: ( { + const sid = serverId || 'local'; + return `playlist_order:${sid}:${scope}`; +}; + interface PlaylistRowButtonProps extends Omit { item: Playlist; name: string; onContextMenu: (e: MouseEvent, 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({ - drag: { - getId: () => { - const draggedItems = getDraggedItems(item, undefined); - return draggedItems.map((draggedItem) => draggedItem.id); + const { isDraggedOver, isDragging, ref } = useDragDrop({ + 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 ( - ) => { - e.preventDefault(); - onContextMenu(e, item); - }} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - ref={ref} - style={{ - opacity: isDragging ? 0.5 : 1, - }} - to={url} - > -
- -
- - {name} - -
-
) => { + e.preventDefault(); + onContextMenu(e, item); + }} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + ref={ref} + style={{ + opacity: isDragging ? 0.5 : 1, + }} + to={url} + > +
+ +
+ + {name} + +
+
+ + + {item.songCount || 0} + +
+
+ + + {formatDurationString(item.duration ?? 0)} + +
+ {item.ownerId === permissions.userId && Boolean(item.public) && ( +
+ + {t('common.public', { postProcess: 'titleCase' })} + +
+ )} + {item.ownerId !== permissions.userId && ( +
+ + + {item.owner} + +
)} - > - - - {item.songCount || 0} -
-
- - - {formatDurationString(item.duration ?? 0)} - -
- {item.ownerId === permissions.userId && Boolean(item.public) && ( -
- - {t('common.public', { postProcess: 'titleCase' })} - -
- )} - {item.ownerId !== permissions.userId && ( -
- - - {item.owner} - -
- )}
-
- {isHovered && } - - ); -}); + {isHovered && } + + ); + }, +); 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({ + 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 = []; 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) => { openCreatePlaylistModal(server, e); @@ -410,12 +513,13 @@ export const SidebarPlaylistList = () => { - {memoizedItemData?.items?.map((item, index) => ( + {playlistItems?.items?.map((item, index) => ( ))} @@ -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({ + 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 = []; 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 = () => { - {memoizedItemData?.items?.map((item, index) => ( + {playlistItems?.items?.map((item, index) => ( ))} diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 51d451a14..bfa09dc8b 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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);