mirror of
https://github.com/jeffvli/feishin.git
synced 2024-11-20 06:27:09 +01:00
enable reordering non-smart playlists
This commit is contained in:
parent
0b383b758e
commit
10fca2dc12
@ -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)"
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
@ -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 && (
|
||||||
|
@ -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}
|
||||||
|
Loading…
Reference in New Issue
Block a user