enable reordering non-smart playlists

This commit is contained in:
Kendall Garner 2024-08-25 15:21:56 -07:00
parent 0b383b758e
commit 10fca2dc12
No known key found for this signature in database
GPG Key ID: 18D2767419676C87
11 changed files with 148 additions and 0 deletions

View File

@ -375,6 +375,9 @@
"copiedPath": "path copied successfully", "copiedPath": "path copied successfully",
"openFile": "show track in file manager" "openFile": "show track in file manager"
}, },
"playlist": {
"reorder": "reordering only enabled when sorting by id"
},
"playlistList": { "playlistList": {
"title": "$t(entity.playlist_other)" "title": "$t(entity.playlist_other)"
}, },

View File

@ -57,6 +57,7 @@ import type {
Song, Song,
ServerType, ServerType,
ShareItemResponse, ShareItemResponse,
MoveItemArgs,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller'; import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
@ -100,6 +101,7 @@ export type ControllerEndpoint = Partial<{
getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>; getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>; getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>; getUserList: (args: UserListArgs) => Promise<UserListResponse>;
movePlaylistItem: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>; scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>; search: (args: SearchArgs) => Promise<SearchResponse>;
@ -148,6 +150,7 @@ const endpoints: ApiController = {
getStructuredLyrics: undefined, getStructuredLyrics: undefined,
getTopSongs: jfController.getTopSongList, getTopSongs: jfController.getTopSongList,
getUserList: undefined, getUserList: undefined,
movePlaylistItem: jfController.movePlaylistItem,
removeFromPlaylist: jfController.removeFromPlaylist, removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble, scrobble: jfController.scrobble,
search: jfController.search, search: jfController.search,
@ -188,6 +191,7 @@ const endpoints: ApiController = {
getStructuredLyrics: ssController.getStructuredLyrics, getStructuredLyrics: ssController.getStructuredLyrics,
getTopSongs: ssController.getTopSongList, getTopSongs: ssController.getTopSongList,
getUserList: ndController.getUserList, getUserList: ndController.getUserList,
movePlaylistItem: ndController.movePlaylistItem,
removeFromPlaylist: ndController.removeFromPlaylist, removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble, scrobble: ssController.scrobble,
search: ssController.search3, search: ssController.search3,
@ -541,6 +545,15 @@ const getSimilarSongs = async (args: SimilarSongsArgs) => {
)?.(args); )?.(args);
}; };
const movePlaylistItem = async (args: MoveItemArgs) => {
return (
apiController(
'movePlaylistItem',
args.apiClientProps.server?.type,
) as ControllerEndpoint['movePlaylistItem']
)?.(args);
};
export const controller = { export const controller = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
@ -567,6 +580,7 @@ export const controller = {
getStructuredLyrics, getStructuredLyrics,
getTopSongList, getTopSongList,
getUserList, getUserList,
movePlaylistItem,
removeFromPlaylist, removeFromPlaylist,
scrobble, scrobble,
search, search,

View File

@ -226,6 +226,15 @@ export const contract = c.router({
400: jfType._response.error, 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: { removeFavorite: {
body: jfType._parameters.favorite, body: jfType._parameters.favorite,
method: 'DELETE', method: 'DELETE',

View File

@ -53,6 +53,7 @@ import {
ServerInfoArgs, ServerInfoArgs,
SimilarSongsArgs, SimilarSongsArgs,
Song, Song,
MoveItemArgs,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize'; import { jfNormalize } from './jellyfin-normalize';
@ -1025,6 +1026,23 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
}, []); }, []);
}; };
const movePlaylistItem = async (args: MoveItemArgs): Promise<void> => {
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 = { export const jfController = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
@ -1049,6 +1067,7 @@ export const jfController = {
getSongDetail, getSongDetail,
getSongList, getSongList,
getTopSongList, getTopSongList,
movePlaylistItem,
removeFromPlaylist, removeFromPlaylist,
scrobble, scrobble,
search, search,

View File

@ -681,6 +681,8 @@ export enum JellyfinExtensions {
SONG_LYRICS = 'songLyrics', SONG_LYRICS = 'songLyrics',
} }
const moveItem = z.null();
export const jfType = { export const jfType = {
_enum: { _enum: {
albumArtistList: albumArtistListSort, albumArtistList: albumArtistListSort,
@ -729,6 +731,7 @@ export const jfType = {
genre, genre,
genreList, genreList,
lyrics, lyrics,
moveItem,
musicFolderList, musicFolderList,
playlist, playlist,
playlistList, playlistList,

View File

@ -147,6 +147,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error), 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: { removeFromPlaylist: {
body: null, body: null,
method: 'DELETE', method: 'DELETE',

View File

@ -49,6 +49,7 @@ import {
ShareItemResponse, ShareItemResponse,
SimilarSongsArgs, SimilarSongsArgs,
Song, Song,
MoveItemArgs,
} from '../types'; } from '../types';
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils'; import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types'; import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
@ -613,6 +614,24 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
}, []); }, []);
}; };
const movePlaylistItem = async (args: MoveItemArgs): Promise<void> => {
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 = { export const ndController = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
@ -631,6 +650,7 @@ export const ndController = {
getSongDetail, getSongDetail,
getSongList, getSongList,
getUserList, getUserList,
movePlaylistItem,
removeFromPlaylist, removeFromPlaylist,
shareItem, shareItem,
updatePlaylist, updatePlaylist,

View File

@ -355,6 +355,12 @@ const shareItemParameters = z.object({
resourceType: z.string(), resourceType: z.string(),
}); });
const moveItemParameters = z.object({
insert_before: z.string(),
});
const moveItem = z.null();
export const ndType = { export const ndType = {
_enum: { _enum: {
albumArtistList: ndAlbumArtistListSort, albumArtistList: ndAlbumArtistListSort,
@ -371,6 +377,7 @@ export const ndType = {
authenticate: authenticateParameters, authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters, createPlaylist: createPlaylistParameters,
genreList: genreListParameters, genreList: genreListParameters,
moveItem: moveItemParameters,
playlistList: playlistListParameters, playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters, removeFromPlaylist: removeFromPlaylistParameters,
shareItem: shareItemParameters, shareItem: shareItemParameters,
@ -390,6 +397,7 @@ export const ndType = {
error, error,
genre, genre,
genreList, genreList,
moveItem,
playlist, playlist,
playlistList, playlistList,
playlistSong, playlistSong,

View File

@ -1191,3 +1191,14 @@ export type SimilarSongsQuery = {
export type SimilarSongsArgs = { export type SimilarSongsArgs = {
query: SimilarSongsQuery; query: SimilarSongsQuery;
} & BaseEndpointArgs; } & BaseEndpointArgs;
export type MoveItemQuery = {
endingIndex: number;
playlistId: string;
startingIndex: number;
trackId: string;
};
export type MoveItemArgs = {
query: MoveItemQuery;
} & BaseEndpointArgs;

View File

@ -5,6 +5,7 @@ import type {
IDatasource, IDatasource,
PaginationChangedEvent, PaginationChangedEvent,
RowDoubleClickedEvent, RowDoubleClickedEvent,
RowDragEvent,
} from '@ag-grid-community/core'; } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@ -18,6 +19,7 @@ import {
LibraryItem, LibraryItem,
PlaylistSongListQuery, PlaylistSongListQuery,
QueueSong, QueueSong,
Song,
SongListSort, SongListSort,
SortOrder, SortOrder,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
@ -44,6 +46,7 @@ import {
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { ListDisplayType } from '/@/renderer/types'; import { ListDisplayType } from '/@/renderer/types';
import { useAppFocus } from '/@/renderer/hooks'; import { useAppFocus } from '/@/renderer/hooks';
import { toast } from '/@/renderer/components';
interface PlaylistDetailContentProps { interface PlaylistDetailContentProps {
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
@ -138,6 +141,42 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
[filters, pagination.scrollOffset, playlistId, queryClient, server], [filters, pagination.scrollOffset, playlistId, queryClient, server],
); );
const handleDragEnd = useCallback(
async (e: RowDragEvent<Song>) => {
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 = () => { const handleGridSizeChange = () => {
if (page.table.autoFit) { if (page.table.autoFit) {
tableRef?.current?.api?.sizeColumnsToFit(); tableRef?.current?.api?.sizeColumnsToFit();
@ -254,6 +293,9 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
paginationAutoPageSize={isPaginationEnabled} paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={pagination.itemsPerPage || 100} paginationPageSize={pagination.itemsPerPage || 100}
rowClassRules={rowClassRules} rowClassRules={rowClassRules}
rowDragEntireRow={
filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules
}
rowHeight={page.table.rowHeight || 40} rowHeight={page.table.rowHeight || 40}
rowModelType="infinite" rowModelType="infinite"
onBodyScrollEnd={handleScroll} onBodyScrollEnd={handleScroll}
@ -264,6 +306,7 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
onGridSizeChanged={handleGridSizeChange} onGridSizeChanged={handleGridSizeChange}
onPaginationChanged={onPaginationChanged} onPaginationChanged={onPaginationChanged}
onRowDoubleClicked={handleRowDoubleClick} onRowDoubleClicked={handleRowDoubleClick}
onRowDragEnd={handleDragEnd}
/> />
</VirtualGridAutoSizerContainer> </VirtualGridAutoSizerContainer>
{isPaginationEnabled && ( {isPaginationEnabled && (

View File

@ -57,6 +57,11 @@ import i18n from '/@/i18n/i18n';
const FILTERS = { const FILTERS = {
jellyfin: [ jellyfin: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: SongListSort.ID,
},
{ {
defaultOrder: SortOrder.ASC, defaultOrder: SortOrder.ASC,
name: i18n.t('filter.album', { postProcess: 'titleCase' }), name: i18n.t('filter.album', { postProcess: 'titleCase' }),
@ -403,6 +408,9 @@ export const PlaylistDetailSongListHeaderFilters = ({
compact compact
fw="600" fw="600"
size="md" size="md"
tooltip={{
label: t('page.playlist.reorder', { postProcess: 'sentenceCase' }),
}}
variant="subtle" variant="subtle"
> >
{sortByLabel} {sortByLabel}
@ -421,6 +429,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
))} ))}
</DropdownMenu.Dropdown> </DropdownMenu.Dropdown>
</DropdownMenu> </DropdownMenu>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<OrderToggleButton <OrderToggleButton
sortOrder={filters.sortOrder || SortOrder.ASC} sortOrder={filters.sortOrder || SortOrder.ASC}