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
+6
View File
@@ -13,6 +13,10 @@
"moveToNext": "move to next",
"moveToBottom": "move to bottom",
"moveToTop": "move to top",
"moveUp": "move up",
"moveDown": "move down",
"holdToMoveToTop": "hold to move to top",
"holdToMoveToBottom": "hold to move to bottom",
"moveItems": "move items",
"shuffle": "shuffle",
"shuffleAll": "shuffle all",
@@ -69,6 +73,7 @@
"dismiss": "dismiss",
"doNotShowAgain": "do not show this again",
"duration": "duration",
"view": "view",
"edit": "edit",
"enable": "enable",
"expand": "expand",
@@ -317,6 +322,7 @@
},
"editPlaylist": {
"publicJellyfinNote": "Jellyfin for some reason does not expose whether a playlist is public or not. If you wish for this to remain public, please have the following input selected",
"editNote": "manual edits are not recommended for large playlists. are you sure you accept the risk of data loss incurred by overwriting the existing playlist?",
"success": "$t(entity.playlist_one) updated successfully",
"title": "edit $t(entity.playlist_one)"
},
+14
View File
@@ -628,6 +628,20 @@ export const controller: GeneralController = {
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
replacePlaylist(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: replacePlaylist`,
);
}
return apiController(
'replacePlaylist',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
scrobble(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -1,3 +1,4 @@
import { set } from 'idb-keyval';
import chunk from 'lodash/chunk';
import filter from 'lodash/filter';
import orderBy from 'lodash/orderBy';
@@ -1162,6 +1163,113 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
},
replacePlaylist: async (args) => {
const { apiClientProps, body, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
// 1. Fetch existing songs from the playlist
const existingSongsRes = await jfApiClient(apiClientProps).getPlaylistSongList({
params: {
id: query.id,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags',
IncludeItemTypes: 'Audio',
UserId: apiClientProps.server?.userId,
},
});
if (existingSongsRes.status !== 200) {
throw new Error('Failed to fetch existing playlist songs');
}
const existingSongs = existingSongsRes.body.Items.map((item) =>
jfNormalize.song(item, apiClientProps.server),
);
// 2. Get playlist detail to get the name
const playlistDetailRes = await jfApiClient(apiClientProps).getPlaylistDetail({
params: {
id: query.id,
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId',
Ids: query.id,
},
});
if (playlistDetailRes.status !== 200) {
throw new Error('Failed to get playlist detail');
}
const playlist = jfNormalize.playlist(playlistDetailRes.body, apiClientProps.server);
// 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name
const backup = {
id: query.id,
name: playlist.name,
songIds: existingSongs.map((song) => song.id),
timestamp: Date.now(),
};
// Store backup in IndexedDB using idb-keyval
const backupKey = `playlist-backup-${query.id}`;
await set(backupKey, backup);
// 4. Remove all songs from the playlist
if (existingSongs.length > 0) {
const existingPlaylistItemIds = existingSongs
.map((song) => song.playlistItemId)
.filter((id): id is string => id !== undefined && id !== null);
if (existingPlaylistItemIds.length > 0) {
const chunks = chunk(existingPlaylistItemIds, MAX_ITEMS_PER_PLAYLIST_ADD);
for (const chunk of chunks) {
const removeRes = await jfApiClient(apiClientProps).removeFromPlaylist({
params: {
id: query.id,
},
query: {
EntryIds: chunk.join(','),
},
});
if (removeRes.status !== 204) {
throw new Error('Failed to remove songs from playlist');
}
}
}
}
// 5. Add the new song ids to the playlist
if (body.songId.length > 0) {
const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
for (const chunk of chunks) {
const addRes = await jfApiClient(apiClientProps).addToPlaylist({
body: null,
params: {
id: query.id,
},
query: {
Ids: chunk.join(','),
UserId: apiClientProps.server?.userId,
},
});
if (addRes.status !== 204) {
throw new Error('Failed to add songs to playlist');
}
}
}
return null;
},
scrobble: async (args) => {
const { apiClientProps, query } = args;
@@ -1,3 +1,5 @@
import { set } from 'idb-keyval';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
@@ -782,6 +784,95 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
replacePlaylist: async (args) => {
const { apiClientProps, body, query } = args;
// 1. Fetch existing songs from the playlist without any sorts
const existingSongsRes = await ndApiClient(apiClientProps as any).getPlaylistSongList({
params: {
id: query.id,
},
query: {
_end: -1,
_order: 'ASC',
_start: 0,
...excludeMissing(apiClientProps.server),
},
});
if (existingSongsRes.status !== 200) {
throw new Error('Failed to fetch existing playlist songs');
}
const existingSongs = existingSongsRes.body.data.map((item) =>
ndNormalize.song(item, apiClientProps.server),
);
// 2. Get playlist detail to get the name
const playlistDetailRes = await ndApiClient(apiClientProps).getPlaylistDetail({
params: {
id: query.id,
},
});
if (playlistDetailRes.status !== 200) {
throw new Error('Failed to get playlist detail');
}
const playlist = ndNormalize.playlist(playlistDetailRes.body.data, apiClientProps.server);
// 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name
const backup = {
id: query.id,
name: playlist.name,
songIds: existingSongs.map((song) => song.id),
timestamp: Date.now(),
};
// Store backup in IndexedDB using idb-keyval
const backupKey = `playlist-backup-${query.id}`;
await set(backupKey, backup);
// 4. Remove all songs from the playlist
if (existingSongs.length > 0) {
const existingPlaylistItemIds = existingSongs
.map((song) => song.playlistItemId)
.filter((id): id is string => id !== undefined && id !== null);
if (existingPlaylistItemIds.length > 0) {
const removeRes = await ndApiClient(apiClientProps).removeFromPlaylist({
params: {
id: query.id,
},
query: {
id: existingPlaylistItemIds,
},
});
if (removeRes.status !== 200) {
throw new Error('Failed to remove songs from playlist');
}
}
}
// 5. Add the new song ids to the playlist
if (body.songId.length > 0) {
const addRes = await ndApiClient(apiClientProps).addToPlaylist({
body: {
ids: body.songId,
},
params: {
id: query.id,
},
});
if (addRes.status !== 200) {
throw new Error('Failed to add songs to playlist');
}
}
return null;
},
scrobble: SubsonicController.scrobble,
search: SubsonicController.search,
setRating: SubsonicController.setRating,
@@ -1,6 +1,7 @@
import type { ServerInferResponses } from '@ts-rest/core';
import dayjs from 'dayjs';
import { set } from 'idb-keyval';
import filter from 'lodash/filter';
import orderBy from 'lodash/orderBy';
import md5 from 'md5';
@@ -1479,6 +1480,87 @@ export const SubsonicController: InternalControllerEndpoint = {
return null;
},
replacePlaylist: async (args) => {
const { apiClientProps, body, query } = args;
// 1. Fetch existing songs from the playlist
const existingSongsRes = await ssApiClient(apiClientProps).getPlaylist({
query: {
id: query.id,
},
});
if (existingSongsRes.status !== 200) {
throw new Error('Failed to fetch existing playlist songs');
}
const existingSongs =
existingSongsRes.body.playlist.entry?.map((song) =>
ssNormalize.song(song, apiClientProps.server),
) || [];
// 2. Get playlist detail to get the name
const playlistDetailRes = await ssApiClient(apiClientProps).getPlaylist({
query: {
id: query.id,
},
});
if (playlistDetailRes.status !== 200) {
throw new Error('Failed to get playlist detail');
}
const playlist = ssNormalize.playlist(
playlistDetailRes.body.playlist,
apiClientProps.server,
);
// 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name
const backup = {
id: query.id,
name: playlist.name,
songIds: existingSongs.map((song) => song.id),
timestamp: Date.now(),
};
// Store backup in IndexedDB using idb-keyval
const backupKey = `playlist-backup-${query.id}`;
await set(backupKey, backup);
// 4. Remove all songs from the playlist (Subsonic uses indices, not IDs)
if (existingSongs.length > 0) {
// Get indices of all songs (0-based)
// Remove in reverse order to avoid index shifting issues
const songIndices = existingSongs.map((_, index) => index).reverse();
const removeRes = await ssApiClient(apiClientProps).updatePlaylist({
query: {
playlistId: query.id,
songIndexToRemove: songIndices.map((index) => index.toString()),
},
});
if (removeRes.status !== 200) {
throw new Error('Failed to remove songs from playlist');
}
}
// 5. Add the new song ids to the playlist
if (body.songId.length > 0) {
const addRes = await ssApiClient(apiClientProps).updatePlaylist({
query: {
playlistId: query.id,
songIdToAdd: body.songId,
},
});
if (addRes.status !== 200) {
throw new Error('Failed to add songs to playlist');
}
}
return null;
},
scrobble: async (args) => {
const { apiClientProps, query } = args;
@@ -295,8 +295,6 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
console.log(item, itemType);
// For context menus, prioritize the itemType prop when it's PLAYLIST_SONG or QUEUE_SONG
// This is because playlist/queue songs are Song objects (_itemType: SONG) but need special context menus
// Otherwise, use the item's _itemType if available, or fall back to the mapped itemType
@@ -0,0 +1,345 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import {
ItemTableListInnerColumn,
TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useLongPress } from '/@/shared/hooks/use-long-press';
import { LibraryItem } from '/@/shared/types/domain-types';
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
export const PlaylistReorderColumn = (props: ItemTableListInnerColumn) => {
const { t } = useTranslation();
const { playlistId } = useParams() as { playlistId?: string };
const isHeaderEnabled = !!props.enableHeader;
const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true;
const item = isDataRow ? props.data[props.rowIndex] : null;
const isPlaylistSong = props.itemType === LibraryItem.PLAYLIST_SONG;
const { isDraggedOver, ref: dragRef } = useDragDrop<HTMLButtonElement>({
drag: {
getId: () => {
if (!item || !isDataRow || !isPlaylistSong) {
return [];
}
const draggedItems = getDraggedItems(item as any, props.internalState);
return draggedItems.map((draggedItem) => draggedItem.id);
},
getItem: () => {
if (!item || !isDataRow || !isPlaylistSong) {
return [];
}
const draggedItems = getDraggedItems(item as any, props.internalState);
return draggedItems;
},
itemType: LibraryItem.PLAYLIST_SONG,
metadata: { fromReorderHandle: true },
onDragStart: () => {
if (!item || !isDataRow || !isPlaylistSong) {
return;
}
const draggedItems = getDraggedItems(item as any, props.internalState);
if (props.internalState) {
props.internalState.setDragging(draggedItems);
}
},
onDrop: () => {
if (props.internalState) {
props.internalState.setDragging([]);
}
},
operation: [DragOperation.REORDER],
target: DragTargetMap[LibraryItem.PLAYLIST_SONG] || DragTarget.SONG,
},
drop: {
canDrop: (args) => {
// Only allow drops from PLAYLIST_SONG items
return (
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
isPlaylistSong &&
isDataRow
);
},
getData: () => {
if (!item || !isDataRow) {
return {
id: [],
item: [],
itemType: LibraryItem.PLAYLIST_SONG,
type: DragTarget.SONG,
};
}
return {
id: [(item as unknown as { id: string }).id],
item: [item as unknown as unknown[]],
itemType: LibraryItem.PLAYLIST_SONG,
type: DragTargetMap[LibraryItem.PLAYLIST_SONG] || DragTarget.SONG,
};
},
onDrag: () => {
// Visual feedback is handled by isDraggedOver state
},
onDragLeave: () => {
// Visual feedback is handled by isDraggedOver state
},
onDrop: (args) => {
if (!item || !isDataRow || !isPlaylistSong) {
return;
}
// Only handle drops from PLAYLIST_SONG items
if (args.source.itemType !== LibraryItem.PLAYLIST_SONG) {
return;
}
const sourceItems = (args.source.item || []) as any[];
const targetItem = item as any;
if (
sourceItems.length > 0 &&
args.edge &&
(args.edge === 'top' || args.edge === 'bottom') &&
playlistId
) {
// Emit event to reorder playlist songs
eventEmitter.emit('PLAYLIST_REORDER', {
edge: args.edge,
playlistId,
sourceIds: args.source.id,
targetId: targetItem.id,
});
}
if (props.internalState) {
props.internalState.setDragging([]);
}
},
},
isEnabled: isPlaylistSong && isDataRow && !!item,
});
const draggedOverEdge: 'bottom' | 'top' | null =
isDraggedOver === 'top' || isDraggedOver === 'bottom' ? isDraggedOver : null;
const getValidDataItems = useCallback(() => {
return props.data.filter((d) => d !== null && (d as any).id);
}, [props.data]);
const handleMoveUp = useCallback(() => {
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
return;
}
const validItems = getValidDataItems();
const selectedItems = getDraggedItems(item as any, props.internalState);
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
if (sourceIds.length === 0) {
return;
}
let topmostIndex = validItems.length;
for (const selectedItem of selectedItems) {
const index = validItems.findIndex((d) => (d as any).id === selectedItem.id);
if (index !== -1 && index < topmostIndex) {
topmostIndex = index;
}
}
if (topmostIndex <= 0) {
return;
}
const targetItem = validItems[topmostIndex - 1];
eventEmitter.emit('PLAYLIST_REORDER', {
edge: 'top',
playlistId,
sourceIds,
targetId: (targetItem as any).id,
});
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
const handleMoveToTop = useCallback(() => {
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
return;
}
const validItems = getValidDataItems();
const selectedItems = getDraggedItems(item as any, props.internalState);
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
if (sourceIds.length === 0) {
return;
}
const firstItem = validItems[0];
const isAlreadyAtTop = selectedItems.some(
(selectedItem) => (selectedItem as any).id === (firstItem as any).id,
);
if (!firstItem || isAlreadyAtTop) {
return;
}
eventEmitter.emit('PLAYLIST_REORDER', {
edge: 'top',
playlistId,
sourceIds,
targetId: (firstItem as any).id,
});
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
const handleMoveDown = useCallback(() => {
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
return;
}
const validItems = getValidDataItems();
const selectedItems = getDraggedItems(item as any, props.internalState);
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
if (sourceIds.length === 0) {
return;
}
let bottommostIndex = -1;
for (const selectedItem of selectedItems) {
const index = validItems.findIndex((d) => (d as any).id === selectedItem.id);
if (index !== -1 && index > bottommostIndex) {
bottommostIndex = index;
}
}
if (bottommostIndex === -1 || bottommostIndex >= validItems.length - 1) {
return;
}
const targetItem = validItems[bottommostIndex + 1];
eventEmitter.emit('PLAYLIST_REORDER', {
edge: 'bottom',
playlistId,
sourceIds,
targetId: (targetItem as any).id,
});
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
const handleMoveToBottom = useCallback(() => {
if (!item || !isDataRow || !isPlaylistSong || !playlistId) {
return;
}
const validItems = getValidDataItems();
const selectedItems = getDraggedItems(item as any, props.internalState);
const sourceIds = selectedItems.map((draggedItem) => draggedItem.id);
if (sourceIds.length === 0) {
return;
}
const lastItem = validItems[validItems.length - 1];
const isAlreadyAtBottom = selectedItems.some(
(selectedItem) => (selectedItem as any).id === (lastItem as any).id,
);
if (!lastItem || isAlreadyAtBottom) {
return;
}
eventEmitter.emit('PLAYLIST_REORDER', {
edge: 'bottom',
playlistId,
sourceIds,
targetId: (lastItem as any).id,
});
}, [item, isDataRow, isPlaylistSong, playlistId, getValidDataItems, props.internalState]);
const upButtonHandlers = useLongPress<HTMLButtonElement>({
onClick: handleMoveUp,
onLongPress: handleMoveToTop,
});
const downButtonHandlers = useLongPress<HTMLButtonElement>({
onClick: handleMoveDown,
onLongPress: handleMoveToBottom,
});
return (
<TableColumnContainer {...props} isDraggedOver={draggedOverEdge}>
<ActionIconGroup w="100%">
<ActionIcon
{...upButtonHandlers}
icon="arrowUp"
iconProps={{ size: 'md' }}
size="xs"
tooltip={{
label: (
<>
<Stack gap="xs" justify="center">
<Text fw={500} ta="center">
{t('action.moveUp', { postProcess: 'sentenceCase' })}
</Text>
<Text fw={500} isMuted size="xs" ta="center">
{t('action.holdToMoveToTop', {
postProcess: 'sentenceCase',
})}
</Text>
</Stack>
</>
),
}}
variant="default"
/>
<ActionIcon
{...downButtonHandlers}
icon="arrowDown"
iconProps={{ size: 'md' }}
size="xs"
tooltip={{
label: (
<>
<Stack gap="xs" justify="center">
<Text fw={500} ta="center">
{t('action.moveDown', { postProcess: 'sentenceCase' })}
</Text>
<Text fw={500} isMuted size="xs" ta="center">
{t('action.holdToMoveToBottom', {
postProcess: 'sentenceCase',
})}
</Text>
</Stack>
</>
),
}}
variant="default"
/>
<ActionIcon
icon="dragVertical"
iconProps={{ size: 'md' }}
ref={dragRef}
size="xs"
style={{
cursor: isPlaylistSong ? 'grab' : 'default',
}}
variant="default"
/>
</ActionIconGroup>
</TableColumnContainer>
);
};
@@ -11,6 +11,7 @@ import {
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
import clsx from 'clsx';
import React, { CSSProperties, ReactElement, ReactNode, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { CellComponentProps } from 'react-window-v2';
import styles from './item-table-list-column.module.css';
@@ -38,6 +39,7 @@ import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/co
import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column';
import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column';
import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column';
import { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column';
import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column';
import { RowIndexColumn } from '/@/renderer/components/item-list/item-table-list/columns/row-index-column';
import { SizeColumn } from '/@/renderer/components/item-list/item-table-list/columns/size-column';
@@ -46,6 +48,7 @@ import { TitleColumn } from '/@/renderer/components/item-list/item-table-list/co
import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-combined-column';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { Flex } from '/@/shared/components/flex/flex';
import { Icon } from '/@/shared/components/icon/icon';
@@ -74,6 +77,7 @@ export interface ItemTableListInnerColumn extends ItemTableListColumn {
}
export const ItemTableListColumn = (props: ItemTableListColumn) => {
const { playlistId } = useParams() as { playlistId?: string };
const type = props.columns[props.columnIndex].id as TableColumn;
const isHeaderEnabled = !!props.enableHeader;
@@ -171,7 +175,9 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
operation:
props.itemType === LibraryItem.QUEUE_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: [DragOperation.ADD],
: props.itemType === LibraryItem.PLAYLIST_SONG
? [DragOperation.REORDER]
: [DragOperation.ADD],
target: DragTargetMap[props.itemType] || DragTarget.GENERIC,
},
drop: {
@@ -180,10 +186,21 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
return false;
}
// Allow drops for QUEUE_SONG (queue reordering)
if (props.itemType === LibraryItem.QUEUE_SONG) {
return true;
}
// Allow drops for PLAYLIST_SONG (playlist reordering)
// Only allow drops when drag is started from the reorder handle
if (
props.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true
) {
return true;
}
return false;
},
getData: () => {
@@ -331,6 +348,33 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
}
}
// Handle PLAYLIST_SONG reordering
// Only allow drops when drag is started from the reorder handle
if (
args.self.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true &&
playlistId
) {
const sourceItems = (args.source.item || []) as any[];
const targetItem = item as any;
if (
sourceItems.length > 0 &&
args.edge &&
(args.edge === 'top' || args.edge === 'bottom') &&
targetItem
) {
// Emit event to reorder playlist songs
eventEmitter.emit('PLAYLIST_REORDER', {
edge: args.edge,
playlistId,
sourceIds: args.source.id,
targetId: targetItem.id,
});
}
}
if (props.internalState) {
props.internalState.setDragging([]);
}
@@ -469,6 +513,9 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
case TableColumn.PATH:
return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;
case TableColumn.PLAYLIST_REORDER:
return <PlaylistReorderColumn {...props} controls={controls} type={type} />;
case TableColumn.ROW_INDEX:
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
@@ -1213,6 +1260,11 @@ const columnLabelMap: Record<TableColumn, ReactNode | string> = {
[TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', {
postProcess: 'upperCase',
}) as string,
[TableColumn.PLAYLIST_REORDER]: (
<Flex className={styles.headerIconWrapper}>
<Icon icon="dragVertical" />
</Flex>
),
[TableColumn.RELEASE_DATE]: i18n.t('table.column.releaseDate', {
postProcess: 'upperCase',
}) as string,
@@ -1198,6 +1198,29 @@ const BaseItemTableList = ({
};
}, [enableDrag, initialize, osInstance, pinnedRightColumnCount]);
useEffect(() => {
if (pinnedLeftColumnCount === 0) {
return;
}
const { current: root } = pinnedLeftColumnRef;
if (!root || !root.firstElementChild) {
return;
}
const viewport = root.firstElementChild as HTMLElement;
if (enableDrag) {
autoScrollForElements({
canScroll: () => true,
element: viewport,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
}, [enableDrag, pinnedLeftColumnCount]);
// Initialize overlayscrollbars for right pinned columns
useEffect(() => {
if (pinnedRightColumnCount === 0) {
+3
View File
@@ -5,11 +5,14 @@ import { ItemListKey } from '/@/shared/types/types';
interface ListContextProps {
customFilters?: Record<string, unknown>;
id?: string;
isSmartPlaylist?: boolean;
itemCount?: number;
listData?: unknown[];
mode?: 'edit' | 'view';
pageKey: ItemListKey | string;
setItemCount?: (itemCount: number) => void;
setListData?: (items: unknown[]) => void;
setMode?: (mode: 'edit' | 'view') => void;
}
export const ListContext = createContext<ListContextProps>({
+17
View File
@@ -3,6 +3,11 @@ import { LibraryItem } from '/@/shared/types/domain-types';
export type EventMap = {
ITEM_LIST_REFRESH: ItemListRefreshEventPayload;
ITEM_LIST_UPDATE_ITEM: ItemListUpdateItemEventPayload;
PLAYLIST_MOVE_DOWN: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_BOTTOM: PlaylistMoveEventPayload;
PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;
PLAYLIST_MOVE_UP: PlaylistMoveEventPayload;
PLAYLIST_REORDER: PlaylistReorderEventPayload;
USER_FAVORITE: UserFavoriteEventPayload;
USER_RATING: UserRatingEventPayload;
};
@@ -17,6 +22,18 @@ export type ItemListUpdateItemEventPayload = {
key: string;
};
export type PlaylistMoveEventPayload = {
playlistId: string;
sourceIds: string[];
};
export type PlaylistReorderEventPayload = {
edge: 'bottom' | 'top' | null;
playlistId: string;
sourceIds: string[];
targetId: string;
};
export type UserFavoriteEventPayload = {
favorite: boolean;
id: string[];
@@ -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',
+21 -14
View File
@@ -27,6 +27,7 @@ interface UseDraggableProps {
getId: () => string[];
getItem: () => unknown[];
itemType?: LibraryItem;
metadata?: Record<string, unknown>;
onDragStart?: () => void;
onDrop?: () => void;
onGenerateDragPreview?: (data: BaseEventPayload<ElementDragType>) => void;
@@ -66,13 +67,16 @@ export const useDragDrop = <TElement extends HTMLElement>({
const id = drag.getId();
const item = drag.getItem();
const data = dndUtils.generateDragData({
id,
item,
itemType: drag.itemType,
operation: drag.operation,
type: drag.target,
});
const data = dndUtils.generateDragData(
{
id,
item,
itemType: drag.itemType,
operation: drag.operation,
type: drag.target,
},
drag.metadata,
);
return data;
},
onDragStart: () => {
@@ -88,13 +92,16 @@ export const useDragDrop = <TElement extends HTMLElement>({
return drag.onGenerateDragPreview(data);
}
const dragData = dndUtils.generateDragData({
id: drag.getId(),
item: drag.getItem(),
itemType: drag.itemType,
operation: drag.operation,
type: drag.target,
}) as DragData;
const dragData = dndUtils.generateDragData(
{
id: drag.getId(),
item: drag.getItem(),
itemType: drag.itemType,
operation: drag.operation,
type: drag.target,
},
drag.metadata,
) as DragData;
disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage });
setCustomNativeDragPreview({
+2
View File
@@ -3,6 +3,7 @@ import { HashRouter, Route, Routes } from 'react-router';
import { ShuffleAllContextModal } from '/@/renderer/features/player/components/shuffle-all-modal';
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists/components/add-to-playlist-context-modal';
import { SaveAndReplaceContextModal } from '/@/renderer/features/playlists/components/save-and-replace-context-modal';
import { SettingsContextModal } from '/@/renderer/features/settings/components/settings-modal';
import { RouterErrorBoundary } from '/@/renderer/features/shared/components/router-error-boundary';
import { ShareItemContextModal } from '/@/renderer/features/sharing/components/share-item-context-modal';
@@ -83,6 +84,7 @@ export const AppRouter = () => {
modals={{
addToPlaylist: AddToPlaylistContextModal,
base: BaseContextModal,
saveAndReplace: SaveAndReplaceContextModal,
settings: SettingsContextModal,
shareItem: ShareItemContextModal,
shuffleAll: ShuffleAllContextModal,
+8 -2
View File
@@ -1,8 +1,10 @@
import { Modal as MantineModal, ModalProps as MantineModalProps } from '@mantine/core';
import { closeAllModals, ContextModalProps } from '@mantine/modals';
import {
closeAllModals as closeAllModalsMantine,
ContextModalProps,
ModalsProvider as MantineModalsProvider,
ModalsProviderProps as MantineModalsProviderProps,
openModal as openModalMantine,
} from '@mantine/modals';
import React, { ReactNode } from 'react';
@@ -15,6 +17,10 @@ import { Icon } from '/@/shared/components/icon/icon';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Stack } from '/@/shared/components/stack/stack';
export const openModal = openModalMantine;
export const closeAllModals = closeAllModalsMantine;
export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
children?: ReactNode;
handlers: {
@@ -106,7 +112,7 @@ export const ConfirmModal = ({
<Stack>
<Flex>{children}</Flex>
<Group justify="flex-end">
<Button onClick={handleCancel} variant="default">
<Button disabled={loading} onClick={handleCancel} variant="default">
{labels?.cancel ? labels.cancel : 'Cancel'}
</Button>
<Button
+20
View File
@@ -957,6 +957,22 @@ export type RemoveFromPlaylistQuery = {
// Remove from playlist
export type RemoveFromPlaylistResponse = null | undefined;
export type ReplacePlaylistArgs = BaseEndpointArgs & {
body: ReplacePlaylistBody;
query: ReplacePlaylistQuery;
};
export type ReplacePlaylistBody = {
songId: string[];
};
export type ReplacePlaylistQuery = {
id: string;
};
// Replace playlist
export type ReplacePlaylistResponse = null | undefined;
export type SetRatingArgs = BaseEndpointArgs & { query: RatingQuery };
export type ShareItemArgs = BaseEndpointArgs & { body: ShareItemBody };
@@ -1284,6 +1300,7 @@ export type ControllerEndpoint = {
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
@@ -1379,6 +1396,9 @@ export type InternalControllerEndpoint = {
removeFromPlaylist: (
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
) => Promise<RemoveFromPlaylistResponse>;
replacePlaylist: (
args: ReplaceApiClientProps<ReplacePlaylistArgs>,
) => Promise<ReplacePlaylistResponse>;
scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>;
search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>;
setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>;
+1
View File
@@ -177,6 +177,7 @@ export enum TableColumn {
OWNER = 'username',
PATH = 'path',
PLAY_COUNT = 'playCount',
PLAYLIST_REORDER = 'playlistReorder',
RELEASE_DATE = 'releaseDate',
ROW_INDEX = 'rowIndex',
SIZE = 'size',