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
@@ -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}`}>