add support for full playlist re-order (#1327)

This commit is contained in:
jeffvli
2025-12-06 17:41:10 -08:00
parent 126b5ed67d
commit 0a7029f7bc
28 changed files with 1301 additions and 59 deletions
@@ -24,8 +24,6 @@ export const PlaylistSongContextMenu = ({ items, type }: PlaylistSongContextMenu
return { ids };
}, [items]);
console.log('items', items, ids);
return (
<ContextMenu.Content
bottomStickyContent={<ContextMenuPreview items={items} itemType={type} />}
@@ -1,14 +1,16 @@
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { lazy, Suspense, useEffect } from 'react';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { ItemListHandle } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
import { PlaylistDetailSongListEditTable } from '/@/renderer/features/playlists/components/playlist-detail-song-list-table';
import { useCurrentServer, useListSettings } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
import { ItemListKey, ListDisplayType, TableColumn } from '/@/shared/types/types';
const PlaylistDetailSongListTable = lazy(() =>
import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then(
@@ -19,9 +21,6 @@ const PlaylistDetailSongListTable = lazy(() =>
);
export const PlaylistDetailSongListContent = () => {
const { display, grid, itemsPerPage, pagination, table } = useListSettings(
ItemListKey.PLAYLIST_SONG,
);
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const { setItemCount } = useListContext();
@@ -71,28 +70,16 @@ export const PlaylistDetailSongListContent = () => {
return (
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListView
data={playlistSongsQuery.data}
display={display}
grid={grid}
itemsPerPage={itemsPerPage}
pagination={pagination}
table={table}
/>
<PlaylistDetailSongList data={playlistSongsQuery.data} />
</Suspense>
);
};
export type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>, 'id'>;
export const PlaylistDetailSongListView = ({
data,
display,
table,
}: ItemListSettings & {
data: PlaylistSongListResponse;
}) => {
export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListResponse }) => {
const server = useCurrentServer();
const { display, table } = useListSettings(ItemListKey.PLAYLIST_SONG);
switch (display) {
case ListDisplayType.TABLE: {
@@ -114,3 +101,149 @@ export const PlaylistDetailSongListView = ({
return null;
}
};
export const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListResponse }) => {
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const { display, table } = useListSettings(ItemListKey.PLAYLIST_SONG);
const [localData, setLocalData] = useState<PlaylistSongListResponse>(data);
const tableRef = useRef<ItemListHandle | null>(null);
// Listen for playlist reorder events
useEffect(() => {
const handleReorder = (payload: {
edge: 'bottom' | 'top' | null;
playlistId: string;
sourceIds: string[];
targetId: string;
}) => {
// Only handle events for this playlist
if (payload.playlistId !== playlistId) {
return;
}
setLocalData((prev) => {
if (!prev?.items || !payload.edge) {
return prev;
}
// Create a list of IDs in current order
const currentIds = prev.items.map((item) => item.id);
// Find the target index
const targetIndex = currentIds.indexOf(payload.targetId);
if (targetIndex === -1) {
return prev;
}
// Remove all source IDs from their current positions
const idsWithoutSources = currentIds.filter(
(id) => !payload.sourceIds.includes(id),
);
// Calculate the insertion index based on the original target position
const sourcesBeforeTarget = payload.sourceIds.filter((id) => {
const sourceIndex = currentIds.indexOf(id);
return sourceIndex !== -1 && sourceIndex < targetIndex;
}).length;
// Calculate the insert index in the filtered list
const insertIndexInFiltered =
payload.edge === 'top'
? targetIndex - sourcesBeforeTarget
: targetIndex - sourcesBeforeTarget + 1;
// Ensure insertIndex is within bounds
const insertIndex = Math.max(
0,
Math.min(insertIndexInFiltered, idsWithoutSources.length),
);
// Insert source IDs at the calculated position
const reorderedIds = [
...idsWithoutSources.slice(0, insertIndex),
...payload.sourceIds,
...idsWithoutSources.slice(insertIndex),
];
// Create a map for quick lookup
const itemMap = new Map(prev.items.map((item) => [item.id, item]));
// Reorder items based on new ID order
const reorderedItems = reorderedIds
.map((id) => itemMap.get(id))
.filter((item): item is NonNullable<typeof item> => item !== undefined);
return {
...prev,
items: reorderedItems,
};
});
};
eventEmitter.on('PLAYLIST_REORDER', handleReorder);
return () => {
eventEmitter.off('PLAYLIST_REORDER', handleReorder);
};
}, [playlistId]);
const columns = useMemo(() => {
return [
{
align: 'center' as 'center' | 'end' | 'start',
id: TableColumn.PLAYLIST_REORDER,
isEnabled: true,
pinned: 'left' as 'left' | 'right' | null,
width: 100,
},
...table.columns,
];
}, [table.columns]);
const { setListData } = useListContext();
useEffect(() => {
setListData?.(localData.items);
}, [localData, setListData]);
switch (display) {
case ListDisplayType.TABLE: {
return (
<PlaylistDetailSongListEditTable
autoFitColumns={table.autoFitColumns}
columns={columns}
data={localData}
enableAlternateRowColors={table.enableAlternateRowColors}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableVerticalBorders={table.enableVerticalBorders}
ref={tableRef}
serverId={server.id}
size={table.size}
/>
);
}
default:
return null;
}
};
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
const { isSmartPlaylist, mode } = useListContext();
if (isSmartPlaylist) {
return <PlaylistDetailSongListView data={data} />;
}
switch (mode) {
case 'edit':
return <PlaylistDetailSongListEdit data={data} />;
case 'view':
return <PlaylistDetailSongListView data={data} />;
default:
return null;
}
};
@@ -1,7 +1,12 @@
import { openContextModal } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import i18n from '/@/i18n/i18n';
import { PLAYLIST_SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useListContext } from '/@/renderer/context/list-context';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
@@ -9,14 +14,25 @@ import { ListRefreshButton } from '/@/renderer/features/shared/components/list-r
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { useContainerQuery } from '/@/renderer/hooks';
import { useCurrentServerId } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
export const PlaylistDetailSongListHeaderFilters = () => {
interface PlaylistDetailSongListHeaderFiltersProps {
isSmartPlaylist?: boolean;
}
export const PlaylistDetailSongListHeaderFilters = ({
isSmartPlaylist,
}: PlaylistDetailSongListHeaderFiltersProps) => {
const { t } = useTranslation();
const { mode, setMode } = useListContext();
const { playlistId } = useParams() as { playlistId: string };
const serverId = useCurrentServerId();
@@ -34,23 +50,42 @@ export const PlaylistDetailSongListHeaderFilters = () => {
});
};
const { ref: containerRef, ...breakpoints } = useContainerQuery();
const isViewEditMode = !isSmartPlaylist && breakpoints.isSm;
const isEditMode = mode === 'edit';
return (
<Flex justify="space-between">
<Flex justify="space-between" ref={containerRef}>
<Group gap="sm" w="100%">
<ListSortByDropdown
defaultSortByValue={SongListSort.ID}
disabled={isEditMode}
itemType={LibraryItem.PLAYLIST_SONG}
listKey={ItemListKey.PLAYLIST_SONG}
/>
<Divider orientation="vertical" />
<ListSortOrderToggleButton
defaultSortOrder={SortOrder.ASC}
disabled={isEditMode}
listKey={ItemListKey.PLAYLIST_SONG}
/>
<ListRefreshButton listKey={ItemListKey.PLAYLIST_SONG} />
<ListRefreshButton disabled={isEditMode} listKey={ItemListKey.PLAYLIST_SONG} />
<MoreButton onClick={handleMore} />
</Group>
<Group gap="sm" wrap="nowrap">
{isViewEditMode && <SaveAndReplaceButton mode={mode} />}
{isViewEditMode && (
<Button
onClick={() => setMode?.(mode === 'edit' ? 'view' : 'edit')}
uppercase
variant="subtle"
>
{mode === 'edit'
? t('common.view', { postProcess: 'titleCase' })
: t('common.edit', { postProcess: 'titleCase' })}
</Button>
)}
<ListConfigMenu
displayTypes={[
{
@@ -66,6 +101,40 @@ export const PlaylistDetailSongListHeaderFilters = () => {
);
};
export const openSaveAndReplaceModal = (playlistId: string, listData: unknown[]) => {
openContextModal({
innerProps: { listData, playlistId },
modalKey: 'saveAndReplace',
size: 'sm',
title: i18n.t('common.saveAndReplace', { postProcess: 'titleCase' }) as string,
});
};
const SaveAndReplaceButton = ({ mode }: { mode: 'edit' | 'view' | undefined }) => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
const { listData } = useListContext();
const handleOpenModal = useCallback(() => {
if (!playlistId || !listData) return;
openSaveAndReplaceModal(playlistId, listData);
}, [playlistId, listData]);
if (mode === 'view') {
return null;
}
return (
<Button
leftSection={<Icon color="error" icon="save" />}
onClick={handleOpenModal}
size="sm"
variant="subtle"
>
{t('common.saveAndReplace', { postProcess: 'titleCase' })}
</Button>
);
};
// const GenreFilterSelection = () => {
// const { t } = useTranslation();
// const { playlistId } = useParams() as { playlistId: string };
@@ -22,7 +22,7 @@ interface PlaylistDetailSongListHeaderProps {
}
export const PlaylistDetailSongListHeader = ({
isSmartPlaylist: isSmartPlaylistProp,
isSmartPlaylist,
}: PlaylistDetailSongListHeaderProps) => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId: string };
@@ -35,7 +35,6 @@ export const PlaylistDetailSongListHeader = ({
initialData: location.state?.item,
});
const isSmartPlaylist = isSmartPlaylistProp ?? detailQuery?.data?.rules;
const playlistDuration = detailQuery?.data?.duration;
return (
@@ -64,7 +63,7 @@ export const PlaylistDetailSongListHeader = ({
<ListSearchInput />
</PageHeader>
<FilterBar>
<PlaylistDetailSongListHeaderFilters />
<PlaylistDetailSongListHeaderFilters isSmartPlaylist={isSmartPlaylist} />
</FilterBar>
</Stack>
);
@@ -96,6 +96,16 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
};
}, [player]);
const getRowId = useMemo(() => {
return (item: unknown) => {
if (!item || typeof item !== 'object') {
return 'id';
}
const song = item as Song;
return song.playlistItemId || song.id;
};
}, []);
return (
<ItemTableList
activeRowId={currentSong?.id}
@@ -109,7 +119,97 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
enableRowHoverHighlight={enableRowHoverHighlight}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
getRowId="playlistItemId"
getRowId={getRowId}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.PLAYLIST_SONG}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
onScrollEnd={handleOnScrollEnd}
overrideControls={overrideControls}
ref={ref}
size={size}
/>
);
},
);
export const PlaylistDetailSongListEditTable = forwardRef<any, PlaylistDetailSongListTableProps>(
(
{
autoFitColumns = false,
columns,
data,
enableAlternateRowColors = false,
enableHorizontalBorders = false,
enableRowHoverHighlight = true,
enableSelection = true,
enableVerticalBorders = false,
saveScrollOffset = true,
size = 'default',
},
ref,
) => {
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: saveScrollOffset,
});
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.PLAYLIST_SONG,
});
const { handleColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.PLAYLIST_SONG,
});
const player = usePlayer();
const currentSong = usePlayerSong();
const overrideControls: Partial<ItemControls> = useMemo(() => {
return {
onDoubleClick: ({ index, internalState, item, meta }) => {
if (!item) {
return;
}
const playType = (meta?.playType as Play) || Play.NOW;
const items = internalState?.getData() as Song[];
if (index !== undefined) {
player.addToQueueByData(items, playType, item.id);
}
},
};
}, [player]);
const getRowId = useMemo(() => {
return (item: unknown) => {
if (!item || typeof item !== 'object') {
return 'id';
}
const song = item as Song;
return song.playlistItemId || song.id;
};
}, []);
return (
<ItemTableList
activeRowId={currentSong?.id}
autoFitColumns={autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={data.items}
enableAlternateRowColors={enableAlternateRowColors}
enableDrag
enableExpansion={false}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
getRowId={getRowId}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
@@ -0,0 +1,91 @@
import { closeAllModals, ContextModalProps } from '@mantine/modals';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useReplacePlaylist } from '/@/renderer/features/playlists/mutations/replace-playlist-mutation';
import { useCurrentServerId } from '/@/renderer/store';
import { ConfirmModal } from '/@/shared/components/modal/modal';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { Song } from '/@/shared/types/domain-types';
export const SaveAndReplaceContextModal = ({
innerProps,
}: ContextModalProps<{ listData: unknown[]; playlistId: string }>) => {
const { t } = useTranslation();
const { listData, playlistId } = innerProps;
const serverId = useCurrentServerId();
const replacePlaylistMutation = useReplacePlaylist({});
// Get current songs from list data
const currentSongIds = useMemo(() => {
if (!listData || !Array.isArray(listData)) {
return [];
}
return listData
.filter((item): item is Song => {
return (
typeof item === 'object' &&
item !== null &&
'id' in item &&
typeof (item as any).id === 'string'
);
})
.map((song) => song.id);
}, [listData]);
const handleConfirm = useCallback(() => {
if (!serverId || !playlistId) {
console.error('serverId or playlistId is not defined');
return;
}
if (currentSongIds.length === 0) {
console.error('currentSongIds is empty');
toast.error({
message: t('error.genericError', { postProcess: 'sentenceCase' }),
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
return;
}
replacePlaylistMutation.mutate(
{
apiClientProps: { serverId },
body: {
songId: currentSongIds,
},
query: {
id: playlistId,
},
},
{
onError: (err) => {
console.error(err);
toast.error({
message: err.message,
title: t('error.genericError', {
postProcess: 'sentenceCase',
}),
});
},
onSuccess: () => {
closeAllModals();
toast.success({
message: t('form.editPlaylist.success', {
postProcess: 'sentenceCase',
}),
});
},
},
);
}, [t, currentSongIds, serverId, playlistId, replacePlaylistMutation]);
return (
<ConfirmModal loading={replacePlaylistMutation.isPending} onConfirm={handleConfirm}>
<Text>{t('form.editPlaylist.editNote', { postProcess: 'sentenceCase' })}</Text>
</ConfirmModal>
);
};
@@ -0,0 +1,50 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useRecentPlaylists } from '/@/renderer/features/playlists/hooks/use-recent-playlists';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { useCurrentServerId } from '/@/renderer/store';
import { ReplacePlaylistArgs, ReplacePlaylistResponse } from '/@/shared/types/domain-types';
export const useReplacePlaylist = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
const serverId = useCurrentServerId();
const { addRecentPlaylist } = useRecentPlaylists(serverId);
return useMutation<ReplacePlaylistResponse, AxiosError, ReplacePlaylistArgs, null>({
mutationFn: (args) => {
return api.controller.replacePlaylist({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables, context) => {
const { apiClientProps } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
exact: false,
queryKey: queryKeys.playlists.list(serverId),
});
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.detail(serverId, variables.query.id),
});
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.songList(serverId, variables.query.id),
});
addRecentPlaylist(variables.query.id);
options?.onSuccess?.(_data, variables, context);
},
...options,
});
};
@@ -386,10 +386,11 @@ const PlaylistDetailSongListRoute = () => {
});
};
const isSmartPlaylist =
const isSmartPlaylist = Boolean(
!detailQuery?.isLoading &&
detailQuery?.data?.rules &&
server?.type === ServerType.NAVIDROME;
detailQuery?.data?.rules &&
server?.type === ServerType.NAVIDROME,
);
const [showQueryBuilder, setShowQueryBuilder] = useState(false);
const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);
@@ -406,18 +407,22 @@ const PlaylistDetailSongListRoute = () => {
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
const [listData, setListData] = useState<unknown[]>([]);
const [mode, setMode] = useState<'edit' | 'view'>('view');
const providerValue = useMemo(() => {
return {
customFilters: undefined,
id: playlistId,
isSmartPlaylist,
itemCount,
listData,
mode,
pageKey: ItemListKey.PLAYLIST_SONG,
setItemCount,
setListData,
setMode,
};
}, [playlistId, itemCount, listData]);
}, [playlistId, isSmartPlaylist, itemCount, listData, mode]);
return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@@ -5,13 +5,14 @@ import { RefreshButton } from '/@/renderer/features/shared/components/refresh-bu
import { ItemListKey } from '/@/shared/types/types';
interface ListRefreshButtonProps {
disabled?: boolean;
listKey: ItemListKey;
}
export const ListRefreshButton = ({ listKey }: ListRefreshButtonProps) => {
export const ListRefreshButton = ({ disabled, listKey }: ListRefreshButtonProps) => {
const handleRefresh = useCallback(() => {
eventEmitter.emit('ITEM_LIST_REFRESH', { key: listKey });
}, [listKey]);
return <RefreshButton onClick={handleRefresh} />;
return <RefreshButton disabled={disabled} onClick={handleRefresh} />;
};
@@ -18,6 +18,7 @@ import { ItemListKey } from '/@/shared/types/types';
interface ListSortByDropdownProps {
defaultSortByValue: string;
disabled?: boolean;
itemType: LibraryItem;
listKey: ItemListKey;
onChange?: (value: string) => void;
@@ -26,6 +27,7 @@ interface ListSortByDropdownProps {
export const ListSortByDropdown = ({
defaultSortByValue,
disabled,
itemType,
listKey,
onChange,
@@ -44,9 +46,15 @@ export const ListSortByDropdown = ({
};
return (
<DropdownMenu position="bottom-start">
<DropdownMenu disabled={disabled} position="bottom-start">
<DropdownMenu.Target>
{target ? target : <Button variant="subtle">{sortByLabel}</Button>}
{target ? (
target
) : (
<Button disabled={disabled} variant="subtle">
{sortByLabel}
</Button>
)}
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[itemType][server.type].map((f) => (
@@ -5,11 +5,13 @@ import { ItemListKey } from '/@/shared/types/types';
interface ListSortOrderToggleButtonProps {
defaultSortOrder: SortOrder;
disabled?: boolean;
listKey: ItemListKey;
}
export const ListSortOrderToggleButton = ({
defaultSortOrder,
disabled,
listKey,
}: ListSortOrderToggleButtonProps) => {
const { setSortOrder, sortOrder } = useSortOrderFilter(defaultSortOrder, listKey);
@@ -20,6 +22,10 @@ export const ListSortOrderToggleButton = ({
};
return (
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={sortOrder as SortOrder} />
<OrderToggleButton
disabled={disabled}
onToggle={handleToggleSortOrder}
sortOrder={sortOrder as SortOrder}
/>
);
};
@@ -5,15 +5,22 @@ import { SortOrder } from '/@/shared/types/domain-types';
interface OrderToggleButtonProps {
buttonProps?: Partial<ActionIconProps>;
disabled?: boolean;
onToggle: () => void;
sortOrder: SortOrder;
}
export const OrderToggleButton = ({ buttonProps, onToggle, sortOrder }: OrderToggleButtonProps) => {
export const OrderToggleButton = ({
buttonProps,
disabled,
onToggle,
sortOrder,
}: OrderToggleButtonProps) => {
const { t } = useTranslation();
return (
<ActionIcon
disabled={disabled}
icon={sortOrder === SortOrder.ASC ? 'sortAsc' : 'sortDesc'}
iconProps={{
size: 'lg',