import { openContextModal } from '@mantine/modals'; import { useQuery } from '@tanstack/react-query'; import clsx from 'clsx'; import { motion } from 'motion/react'; import { createContext, memo, MouseEvent, useCallback, useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, Link } from 'react-router'; import styles from './sidebar-playlist-list.module.css'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; 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'; import { openCreatePlaylistModal } from '/@/renderer/features/playlists/components/create-playlist-form'; import { useIsMutatingSidebarPlaylistFolderMove } from '/@/renderer/features/playlists/mutations/sidebar-playlist-folder-move-mutation'; import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls'; import { collectFolderPaths, PlaylistFolderDragExpandProvider, PlaylistFolderViews, PlaylistRootAccordionControl, usePlaylistFolderState, usePlaylistFolderViewState, usePlaylistNavigationState, } from '/@/renderer/features/sidebar/components/playlist-folder-tree'; import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { useDragMonitor } from '/@/renderer/hooks/use-drag-monitor'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentPlaylistContextId, useCurrentServer, useCurrentServerId, usePermissions, useSidebarPlaylistListFilterRegex, useSidebarPlaylistMode, useSidebarPlaylistSorting, } from '/@/renderer/store'; import { formatDurationString } from '/@/renderer/utils'; import { Accordion } from '/@/shared/components/accordion/accordion'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { animationProps } from '/@/shared/components/animations/animation-props'; import { animationVariants } from '/@/shared/components/animations/animation-variants'; import { ButtonProps } from '/@/shared/components/button/button'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; import { Image } from '/@/shared/components/image/image'; import { LoadingOverlay } from '/@/shared/components/loading-overlay/loading-overlay'; import { Text } from '/@/shared/components/text/text'; import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; import { LibraryItem, Playlist, PlaylistListSort, Song, SortOrder, } from '/@/shared/types/domain-types'; import { DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; import { Play } from '/@/shared/types/types'; const MotionLink = motion.create(Link); const playlistRowDimVariants = animationVariants.combine(animationVariants.fadeIn, { hidden: { opacity: 0.5 }, }); const getPlaylistOrderKey = (serverId: string | undefined, scope: 'owned' | 'shared') => { const sid = serverId || 'local'; return `playlist_order:${sid}:${scope}`; }; export const SidebarPlaylistAddDragContext = createContext(false); const isAddToPlaylistDragSource = (source: DragData) => { return ( source.itemType !== undefined && source.type !== DragTarget.PLAYLIST && (source.operation?.includes(DragOperation.ADD) ?? false) ); }; export const useSidebarPlaylistAddDragMonitor = () => { const [isAddDragActive, setIsAddDragActive] = useState(false); const handleAddDragStart = useCallback(() => { setIsAddDragActive(true); }, []); const handleAddDragDrop = useCallback(() => { setIsAddDragActive(false); }, []); useDragMonitor({ canMonitor: isAddToPlaylistDragSource, onDragStart: handleAddDragStart, onDrop: handleAddDragDrop, }); return isAddDragActive; }; export 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; } export 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 sidebarPlaylistMode = useSidebarPlaylistMode(); const isCompact = sidebarPlaylistMode === 'compact'; const activePlaylistId = useCurrentPlaylistContextId(); const isActive = activePlaylistId === item.id; const [isHovered, setIsHovered] = useState(false); const isSmartPlaylist = Boolean(item.rules); const isAddDragActive = useContext(SidebarPlaylistAddDragContext); 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, }, drop: { canDrop: (args) => { // Allow dropping items into a playlist (ADD) const canAdd = !isSmartPlaylist && args.source.itemType !== undefined && args.source.type !== DragTarget.PLAYLIST && (args.source.operation?.includes(DragOperation.ADD) ?? false); // 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: () => { console.log('started drag'); return; }, onDragLeave: () => { return; }, onDrop: (args) => { const sourceItemType = args.source.itemType as LibraryItem; const sourceIds = args.source.id; // 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; } } onReorder(sourceIds, to, args.edge); return; } if (isSmartPlaylist) { return; } 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, modal: 'addToPlaylist', size: 'lg', title: t('form.addToPlaylist.title'), }); }, }, isEnabled: true, }); const player = usePlayer(); const serverId = useCurrentServerId(); const permissions = usePermissions(); 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 isDimmed = isDragging || (isSmartPlaylist && isAddDragActive); return ( ) => { e.preventDefault(); onContextMenu(e, item); }} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} ref={ref} to={url} variants={playlistRowDimVariants} > {isCompact ? ( <> {name} {isHovered && ( handlePlay(to, playType)} /> )} ) : ( <>
{name}
{item.songCount || 0}
{formatDurationString(item.duration ?? 0)}
{item.ownerId === permissions.userId && Boolean(item.public) && (
{t('common.public')}
)} {item.ownerId !== permissions.userId && (
{item.owner}
)}
{isHovered && ( handlePlay(to, playType)} /> )} )}
); }, ); export const SidebarPlaylistList = () => { const player = usePlayer(); const { t } = useTranslation(); const server = useCurrentServer(); const sidebarPlaylistSorting = useSidebarPlaylistSorting(); const filterRegex = useSidebarPlaylistListFilterRegex(); const playlistsQuery = useQuery( playlistsQueries.list({ query: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0, }, serverId: server?.id, }), ); const handlePlayPlaylist = useCallback( (id: string, playType: Play) => { player.addToQueueByFetch(server.id, [id], LibraryItem.PLAYLIST, playType); }, [player, server.id], ); const handleContextMenu = useCallback( (e: MouseEvent, playlist: Playlist) => { e.preventDefault(); e.stopPropagation(); ContextMenuController.call({ cmd: { items: [playlist], type: LibraryItem.PLAYLIST }, event: e, }); }, [], ); 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 }; } let regex: null | RegExp = null; if (filterRegex) { try { regex = new RegExp(filterRegex, 'i'); } catch { // Invalid regex, ignore filtering } } const ownedPlaylistItems: Array = []; for (const playlist of playlistsQuery.data?.items ?? []) { if (!playlist.owner || playlist.owner === server.username) { // Filter out playlists that match the regex if (regex && regex.test(playlist.name)) { continue; } ownedPlaylistItems.push(playlist); } } 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, filterRegex, ]); 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); }; const folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []); const { folderView, groups, tree } = folderViewState; const navigation = usePlaylistNavigationState(); const inNavigation = folderView === 'navigation' && navigation.pathStack.length > 0; const folderPaths = useMemo(() => { if (folderView === 'single') { return groups.reduce((acc, g) => { if (g.type === 'folder') acc.push(g.name); return acc; }, []); } return collectFolderPaths(tree); }, [folderView, groups, tree]); const { expandedSet, setMany, toggle } = usePlaylistFolderState('owned'); const allExpanded = folderPaths.length > 0 && folderPaths.every((path) => expandedSet.has(path)); const handleToggleAllFolders = useCallback( (e: MouseEvent) => { e.stopPropagation(); setMany(folderPaths, !allExpanded); }, [setMany, folderPaths, allExpanded], ); const handleNavigateUp = useCallback( (e: MouseEvent) => { e.stopPropagation(); navigation.goUp(); }, [navigation], ); const showExpandAll = folderView !== 'navigation' && folderPaths.length > 0; const isFolderMovePending = useIsMutatingSidebarPlaylistFolderMove(); return ( {inNavigation && ( )} {inNavigation ? navigation.currentName : t('page.sidebar.playlists')} {showExpandAll && ( )} e.stopPropagation()} size="xs" to={AppRoute.PLAYLISTS} tooltip={{ label: t('action.viewPlaylists'), }} variant="subtle" /> ); }; export const SidebarSharedPlaylistList = () => { const player = usePlayer(); const { t } = useTranslation(); const server = useCurrentServer(); const sidebarPlaylistSorting = useSidebarPlaylistSorting(); const filterRegex = useSidebarPlaylistListFilterRegex(); const playlistsQuery = useQuery( playlistsQueries.list({ query: { sortBy: PlaylistListSort.NAME, sortOrder: SortOrder.ASC, startIndex: 0, }, serverId: server?.id, }), ); const handlePlayPlaylist = useCallback( (id: string, playType: Play) => { if (!server?.id) return; player.addToQueueByFetch(server.id, [id], LibraryItem.PLAYLIST, playType); }, [player, server.id], ); const handleContextMenu = useCallback( (e: MouseEvent, playlist: Playlist) => { e.preventDefault(); e.stopPropagation(); ContextMenuController.call({ cmd: { items: [playlist], type: LibraryItem.PLAYLIST, }, event: e, }); }, [], ); 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 }; } let regex: null | RegExp = null; if (filterRegex) { try { regex = new RegExp(filterRegex, 'i'); } catch { // Invalid regex, ignore filtering } } const sharedPlaylistItems: Array = []; for (const playlist of playlistsQuery.data?.items ?? []) { if (playlist.owner && playlist.owner !== server.username) { // Filter out playlists that match the regex if (regex && regex.test(playlist.name)) { continue; } sharedPlaylistItems.push(playlist); } } if (!sharedPlaylistItems || !sidebarPlaylistSorting || !playlistOrder) { return { ...base, items: sharedPlaylistItems }; } // 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, filterRegex, ]); 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 folderViewState = usePlaylistFolderViewState(playlistItems?.items ?? []); const navigation = usePlaylistNavigationState(); const { expandedSet, setMany, toggle } = usePlaylistFolderState('shared'); const inNavigation = folderViewState.folderView === 'navigation' && navigation.pathStack.length > 0; const handleNavigateUp = useCallback( (e: MouseEvent) => { e.stopPropagation(); navigation.goUp(); }, [navigation], ); const isFolderMovePending = useIsMutatingSidebarPlaylistFolderMove(); if (playlistItems?.items?.length === 0) { return null; } return ( {inNavigation && ( )} {inNavigation ? navigation.currentName : t('page.sidebar.shared')} ); };