diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ccb4377b..16210264 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -375,6 +375,9 @@ "copiedPath": "path copied successfully", "openFile": "show track in file manager" }, + "playlist": { + "reorder": "reordering only enabled when sorting by id" + }, "playlistList": { "title": "$t(entity.playlist_other)" }, diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 6face864..4e9dd21f 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -57,6 +57,7 @@ import type { Song, ServerType, ShareItemResponse, + MoveItemArgs, } from '/@/renderer/api/types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types'; import { ndController } from '/@/renderer/api/navidrome/navidrome-controller'; @@ -100,6 +101,7 @@ export type ControllerEndpoint = Partial<{ getStructuredLyrics: (args: StructuredLyricsArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; getUserList: (args: UserListArgs) => Promise; + movePlaylistItem: (args: MoveItemArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; scrobble: (args: ScrobbleArgs) => Promise; search: (args: SearchArgs) => Promise; @@ -148,6 +150,7 @@ const endpoints: ApiController = { getStructuredLyrics: undefined, getTopSongs: jfController.getTopSongList, getUserList: undefined, + movePlaylistItem: jfController.movePlaylistItem, removeFromPlaylist: jfController.removeFromPlaylist, scrobble: jfController.scrobble, search: jfController.search, @@ -188,6 +191,7 @@ const endpoints: ApiController = { getStructuredLyrics: ssController.getStructuredLyrics, getTopSongs: ssController.getTopSongList, getUserList: ndController.getUserList, + movePlaylistItem: ndController.movePlaylistItem, removeFromPlaylist: ndController.removeFromPlaylist, scrobble: ssController.scrobble, search: ssController.search3, @@ -541,6 +545,15 @@ const getSimilarSongs = async (args: SimilarSongsArgs) => { )?.(args); }; +const movePlaylistItem = async (args: MoveItemArgs) => { + return ( + apiController( + 'movePlaylistItem', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['movePlaylistItem'] + )?.(args); +}; + export const controller = { addToPlaylist, authenticate, @@ -567,6 +580,7 @@ export const controller = { getStructuredLyrics, getTopSongList, getUserList, + movePlaylistItem, removeFromPlaylist, scrobble, search, diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index f29a01c5..06afc668 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -226,6 +226,15 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + movePlaylistItem: { + body: null, + method: 'POST', + path: 'playlists/:playlistId/items/:itemId/move/:newIdx', + responses: { + 200: jfType._response.moveItem, + 400: jfType._response.error, + }, + }, removeFavorite: { body: jfType._parameters.favorite, method: 'DELETE', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 2b9081aa..83507abc 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -53,6 +53,7 @@ import { ServerInfoArgs, SimilarSongsArgs, Song, + MoveItemArgs, } from '/@/renderer/api/types'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfNormalize } from './jellyfin-normalize'; @@ -1025,6 +1026,23 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { }, []); }; +const movePlaylistItem = async (args: MoveItemArgs): Promise => { + const { apiClientProps, query } = args; + + const res = await jfApiClient(apiClientProps).movePlaylistItem({ + body: null, + params: { + itemId: query.trackId, + newIdx: query.endingIndex.toString(), + playlistId: query.playlistId, + }, + }); + + if (res.status !== 204) { + throw new Error('Failed to move item in playlist'); + } +}; + export const jfController = { addToPlaylist, authenticate, @@ -1049,6 +1067,7 @@ export const jfController = { getSongDetail, getSongList, getTopSongList, + movePlaylistItem, removeFromPlaylist, scrobble, search, diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index 69117215..15f70e1f 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -681,6 +681,8 @@ export enum JellyfinExtensions { SONG_LYRICS = 'songLyrics', } +const moveItem = z.null(); + export const jfType = { _enum: { albumArtistList: albumArtistListSort, @@ -729,6 +731,7 @@ export const jfType = { genre, genreList, lyrics, + moveItem, musicFolderList, playlist, playlistList, diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 535a1535..90477420 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -147,6 +147,15 @@ export const contract = c.router({ 500: resultWithHeaders(ndType._response.error), }, }, + movePlaylistItem: { + body: ndType._parameters.moveItem, + method: 'PUT', + path: 'playlist/:playlistId/tracks/:trackNumber', + responses: { + 200: resultWithHeaders(ndType._response.moveItem), + 400: resultWithHeaders(ndType._response.error), + }, + }, removeFromPlaylist: { body: null, method: 'DELETE', diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index e22d97fc..1e9d9772 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -49,6 +49,7 @@ import { ShareItemResponse, SimilarSongsArgs, Song, + MoveItemArgs, } from '../types'; import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils'; import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types'; @@ -613,6 +614,24 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { }, []); }; +const movePlaylistItem = async (args: MoveItemArgs): Promise => { + const { apiClientProps, query } = args; + + const res = await ndApiClient(apiClientProps).movePlaylistItem({ + body: { + insert_before: (query.endingIndex + 1).toString(), + }, + params: { + playlistId: query.playlistId, + trackNumber: query.startingIndex.toString(), + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to move item in playlist'); + } +}; + export const ndController = { addToPlaylist, authenticate, @@ -631,6 +650,7 @@ export const ndController = { getSongDetail, getSongList, getUserList, + movePlaylistItem, removeFromPlaylist, shareItem, updatePlaylist, diff --git a/src/renderer/api/navidrome/navidrome-types.ts b/src/renderer/api/navidrome/navidrome-types.ts index f7f587ee..af7e8846 100644 --- a/src/renderer/api/navidrome/navidrome-types.ts +++ b/src/renderer/api/navidrome/navidrome-types.ts @@ -355,6 +355,12 @@ const shareItemParameters = z.object({ resourceType: z.string(), }); +const moveItemParameters = z.object({ + insert_before: z.string(), +}); + +const moveItem = z.null(); + export const ndType = { _enum: { albumArtistList: ndAlbumArtistListSort, @@ -371,6 +377,7 @@ export const ndType = { authenticate: authenticateParameters, createPlaylist: createPlaylistParameters, genreList: genreListParameters, + moveItem: moveItemParameters, playlistList: playlistListParameters, removeFromPlaylist: removeFromPlaylistParameters, shareItem: shareItemParameters, @@ -390,6 +397,7 @@ export const ndType = { error, genre, genreList, + moveItem, playlist, playlistList, playlistSong, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 78d77d55..23536eb4 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1191,3 +1191,14 @@ export type SimilarSongsQuery = { export type SimilarSongsArgs = { query: SimilarSongsQuery; } & BaseEndpointArgs; + +export type MoveItemQuery = { + endingIndex: number; + playlistId: string; + startingIndex: number; + trackId: string; +}; + +export type MoveItemArgs = { + query: MoveItemQuery; +} & BaseEndpointArgs; diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx index 298e4742..5954f6b0 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-content.tsx @@ -5,6 +5,7 @@ import type { IDatasource, PaginationChangedEvent, RowDoubleClickedEvent, + RowDragEvent, } from '@ag-grid-community/core'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { useQueryClient } from '@tanstack/react-query'; @@ -18,6 +19,7 @@ import { LibraryItem, PlaylistSongListQuery, QueueSong, + Song, SongListSort, SortOrder, } from '/@/renderer/api/types'; @@ -44,6 +46,7 @@ import { import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { ListDisplayType } from '/@/renderer/types'; import { useAppFocus } from '/@/renderer/hooks'; +import { toast } from '/@/renderer/components'; interface PlaylistDetailContentProps { tableRef: MutableRefObject; @@ -138,6 +141,42 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten [filters, pagination.scrollOffset, playlistId, queryClient, server], ); + const handleDragEnd = useCallback( + async (e: RowDragEvent) => { + if (!e.nodes.length) return; + + const trackId = e.node.data?.playlistItemId; + if (trackId && e.node.rowIndex !== null && e.overIndex !== e.node.rowIndex) { + try { + await api.controller.movePlaylistItem({ + apiClientProps: { + server, + }, + query: { + endingIndex: e.overIndex, + playlistId, + startingIndex: e.node.rowIndex + 1, + trackId, + }, + }); + + setTimeout(() => { + queryClient.invalidateQueries({ + queryKey: queryKeys.playlists.songList(server?.id || '', playlistId), + }); + e.api.refreshInfiniteCache(); + }, 200); + } catch (error) { + toast.error({ + message: (error as Error).message, + title: `Failed to move song ${e.node.data?.name} to ${e.overIndex}`, + }); + } + } + }, + [playlistId, queryClient, server], + ); + const handleGridSizeChange = () => { if (page.table.autoFit) { tableRef?.current?.api?.sizeColumnsToFit(); @@ -254,6 +293,9 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten paginationAutoPageSize={isPaginationEnabled} paginationPageSize={pagination.itemsPerPage || 100} rowClassRules={rowClassRules} + rowDragEntireRow={ + filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules + } rowHeight={page.table.rowHeight || 40} rowModelType="infinite" onBodyScrollEnd={handleScroll} @@ -264,6 +306,7 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten onGridSizeChanged={handleGridSizeChange} onPaginationChanged={onPaginationChanged} onRowDoubleClicked={handleRowDoubleClick} + onRowDragEnd={handleDragEnd} /> {isPaginationEnabled && ( diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx index 6cb06f59..0389be81 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-header-filters.tsx @@ -57,6 +57,11 @@ import i18n from '/@/i18n/i18n'; const FILTERS = { jellyfin: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.id', { postProcess: 'titleCase' }), + value: SongListSort.ID, + }, { defaultOrder: SortOrder.ASC, name: i18n.t('filter.album', { postProcess: 'titleCase' }), @@ -403,6 +408,9 @@ export const PlaylistDetailSongListHeaderFilters = ({ compact fw="600" size="md" + tooltip={{ + label: t('page.playlist.reorder', { postProcess: 'sentenceCase' }), + }} variant="subtle" > {sortByLabel} @@ -421,6 +429,7 @@ export const PlaylistDetailSongListHeaderFilters = ({ ))} +