client-side only sort for all playlists (#1125)

* initial client-side only sort for all playlists

* allow reordering jellyfin (assume it works properly) and navidrome

* on playlist page, add to queue by sort order
This commit is contained in:
Kendall Garner 2025-09-18 04:06:30 +00:00 committed by GitHub
parent d68165dab5
commit 1d46cd5ff9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 135 additions and 247 deletions

View file

@ -542,10 +542,6 @@ export const JellyfinController: ControllerEndpoint = {
query: { query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags', Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags',
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
Limit: query.limit,
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
StartIndex: query.startIndex,
UserId: apiClientProps.server?.userId, UserId: apiClientProps.server?.userId,
}, },
}); });
@ -556,7 +552,7 @@ export const JellyfinController: ControllerEndpoint = {
return { return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
startIndex: query.startIndex, startIndex: 0,
totalRecordCount: res.body.TotalRecordCount, totalRecordCount: res.body.TotalRecordCount,
}; };
}, },

View file

@ -3,7 +3,6 @@ import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { NDSongListSort } from '/@/shared/api/navidrome.types'; import { NDSongListSort } from '/@/shared/api/navidrome.types';
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize'; import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
import { ndType } from '/@/shared/api/navidrome/navidrome-types';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize'; import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import { SubsonicExtensions } from '/@/shared/api/subsonic/subsonic-types'; import { SubsonicExtensions } from '/@/shared/api/subsonic/subsonic-types';
import { getFeatures, hasFeature, VersionInfo } from '/@/shared/api/utils'; import { getFeatures, hasFeature, VersionInfo } from '/@/shared/api/utils';
@ -430,12 +429,9 @@ export const NavidromeController: ControllerEndpoint = {
id: query.id, id: query.id,
}, },
query: { query: {
_end: query.startIndex + (query.limit || -1), _end: -1,
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC', _order: 'ASC',
_sort: query.sortBy _start: 0,
? songListSortMap.navidrome[query.sortBy]
: ndType._enum.songList.ID,
_start: query.startIndex,
...excludeMissing(apiClientProps.server), ...excludeMissing(apiClientProps.server),
}, },
}); });
@ -446,7 +442,7 @@ export const NavidromeController: ControllerEndpoint = {
return { return {
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server)), items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server)),
startIndex: query?.startIndex || 0, startIndex: 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
}; };
}, },

View file

@ -9,7 +9,6 @@ import type {
LyricsQuery, LyricsQuery,
PlaylistDetailQuery, PlaylistDetailQuery,
PlaylistListQuery, PlaylistListQuery,
PlaylistSongListQuery,
RandomSongListQuery, RandomSongListQuery,
SearchQuery, SearchQuery,
SimilarSongsQuery, SimilarSongsQuery,
@ -191,21 +190,6 @@ export const queryKeys: Record<
if (id) return [serverId, 'playlists', id, 'detail'] as const; if (id) return [serverId, 'playlists', id, 'detail'] as const;
return [serverId, 'playlists', 'detail'] as const; return [serverId, 'playlists', 'detail'] as const;
}, },
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && id && pagination) {
return [serverId, 'playlists', id, 'detailSongList', filter, pagination] as const;
}
if (query && id) {
return [serverId, 'playlists', id, 'detailSongList', filter] as const;
}
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
return [serverId, 'playlists', 'detailSongList'] as const;
},
list: (serverId: string, query?: PlaylistListQuery) => { list: (serverId: string, query?: PlaylistListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query); const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) { if (query && pagination) {
@ -219,16 +203,7 @@ export const queryKeys: Record<
return [serverId, 'playlists', 'list'] as const; return [serverId, 'playlists', 'list'] as const;
}, },
root: (serverId: string) => [serverId, 'playlists'] as const, root: (serverId: string) => [serverId, 'playlists'] as const,
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => { songList: (serverId: string, id?: string) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && id && pagination) {
return [serverId, 'playlists', id, 'songList', filter, pagination] as const;
}
if (query && id) {
return [serverId, 'playlists', id, 'songList', filter] as const;
}
if (id) return [serverId, 'playlists', id, 'songList'] as const; if (id) return [serverId, 'playlists', id, 'songList'] as const;
return [serverId, 'playlists', 'songList'] as const; return [serverId, 'playlists', 'songList'] as const;
}, },

View file

@ -759,18 +759,14 @@ export const SubsonicController: ControllerEndpoint = {
throw new Error('Failed to get playlist song list'); throw new Error('Failed to get playlist song list');
} }
let results = const items =
res.body.playlist.entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) || res.body.playlist.entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) ||
[]; [];
if (query.sortBy && query.sortOrder) {
results = sortSongList(results, query.sortBy, query.sortOrder);
}
return { return {
items: results, items,
startIndex: 0, startIndex: 0,
totalRecordCount: results?.length || 0, totalRecordCount: items.length,
}; };
}, },
getRandomSongList: async (args) => { getRandomSongList: async (args) => {

View file

@ -541,7 +541,6 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
}); });
}, },
onSuccess: () => { onSuccess: () => {
ctx.context?.tableRef?.current?.api?.refreshInfiniteCache();
closeAllModals(); closeAllModals();
}, },
}, },

View file

@ -24,8 +24,8 @@
position: relative; position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
height: 100%;
min-width: 0; min-width: 0;
height: 100%;
overflow: hidden; overflow: hidden;
&:hover { &:hover {

View file

@ -4,17 +4,19 @@ import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { import {
PlaylistSongListQuery, PlaylistSongListQuery,
PlaylistSongListQueryClientSide,
ServerListItem, ServerListItem,
SongDetailQuery, SongDetailQuery,
SongListQuery, SongListQuery,
SongListResponse, SongListResponse,
SongListSort, SongListSort,
SortOrder, SortOrder,
sortSongList,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
export const getPlaylistSongsById = async (args: { export const getPlaylistSongsById = async (args: {
id: string; id: string;
query?: Partial<PlaylistSongListQuery>; query?: Partial<PlaylistSongListQueryClientSide>;
queryClient: QueryClient; queryClient: QueryClient;
server: ServerListItem; server: ServerListItem;
}) => { }) => {
@ -22,13 +24,9 @@ export const getPlaylistSongsById = async (args: {
const queryFilter: PlaylistSongListQuery = { const queryFilter: PlaylistSongListQuery = {
id, id,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
...query,
}; };
const queryKey = queryKeys.playlists.songList(server?.id, id, queryFilter); const queryKey = queryKeys.playlists.songList(server?.id, id);
const res = await queryClient.fetchQuery( const res = await queryClient.fetchQuery(
queryKey, queryKey,
@ -46,6 +44,14 @@ export const getPlaylistSongsById = async (args: {
}, },
); );
if (res) {
res.items = sortSongList(
res.items,
query?.sortBy || SongListSort.ID,
query?.sortOrder || SortOrder.ASC,
);
}
return res; return res;
}; };

View file

@ -145,12 +145,7 @@ export const AddToPlaylistContextModal = ({
const uniqueSongIds: string[] = []; const uniqueSongIds: string[] = [];
if (values.skipDuplicates) { if (values.skipDuplicates) {
const query = { const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId);
id: playlistId,
startIndex: 0,
};
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => { const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
if (!server) if (!server)
@ -164,9 +159,6 @@ export const AddToPlaylistContextModal = ({
}, },
query: { query: {
id: playlistId, id: playlistId,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
startIndex: 0,
}, },
}); });
}); });

View file

@ -2,7 +2,6 @@ import type {
BodyScrollEvent, BodyScrollEvent,
ColDef, ColDef,
GridReadyEvent, GridReadyEvent,
IDatasource,
PaginationChangedEvent, PaginationChangedEvent,
RowDoubleClickedEvent, RowDoubleClickedEvent,
RowDragEvent, RowDragEvent,
@ -27,7 +26,6 @@ import {
} from '/@/renderer/features/context-menu/context-menu-items'; } from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { usePlaylistSongList } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
import { useAppFocus } from '/@/renderer/hooks'; import { useAppFocus } from '/@/renderer/hooks';
import { import {
useCurrentServer, useCurrentServer,
@ -42,13 +40,15 @@ import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/s
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { import {
LibraryItem, LibraryItem,
PlaylistSongListQuery, PlaylistSongListQueryClientSide,
QueueSong, QueueSong,
ServerType,
Song, Song,
SongListResponse,
SongListSort, SongListSort,
SortOrder, SortOrder,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ListDisplayType, ServerType } from '/@/shared/types/types'; import { ListDisplayType } from '/@/shared/types/types';
interface PlaylistDetailContentProps { interface PlaylistDetailContentProps {
songs?: Song[]; songs?: Song[];
@ -63,7 +63,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
const currentSong = useCurrentSong(); const currentSong = useCurrentSong();
const server = useCurrentServer(); const server = useCurrentServer();
const page = usePlaylistDetailStore(); const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = useMemo(() => { const filters: PlaylistSongListQueryClientSide = useMemo(() => {
return { return {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID, sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC, sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
@ -88,20 +88,6 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const iSClientSide = server?.type === ServerType.SUBSONIC;
const checkPlaylistList = usePlaylistSongList({
options: {
enabled: !iSClientSide,
},
query: {
id: playlistId,
limit: 1,
startIndex: 0,
},
serverId: server?.id,
});
const columnDefs: ColDef[] = useMemo( const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns, false, 'generic'), () => getColumnDefs(page.table.columns, false, 'generic'),
[page.table.columns], [page.table.columns],
@ -109,51 +95,9 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
const onGridReady = useCallback( const onGridReady = useCallback(
(params: GridReadyEvent) => { (params: GridReadyEvent) => {
if (!iSClientSide) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: PlaylistSongListQuery = {
id: playlistId,
limit,
startIndex,
...filters,
};
const queryKey = queryKeys.playlists.songList(
server?.id || '',
playlistId,
query,
);
if (!server) return;
const songsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query,
}),
);
params.successCallback(
songsRes?.items || [],
songsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
}
params.api?.ensureIndexVisible(pagination.scrollOffset, 'top'); params.api?.ensureIndexVisible(pagination.scrollOffset, 'top');
}, },
[filters, iSClientSide, pagination.scrollOffset, playlistId, queryClient, server], [pagination.scrollOffset],
); );
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
@ -175,12 +119,32 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
}, },
}); });
setTimeout(() => { queryClient.setQueryData<SongListResponse>(
queryClient.invalidateQueries({ queryKeys.playlists.songList(server?.id || '', playlistId),
queryKey: queryKeys.playlists.songList(server?.id || '', playlistId), (previous) => {
}); if (previous?.items) {
e.api.refreshInfiniteCache(); const from = e.node.rowIndex!;
}, 200); const to = e.overIndex;
const item = previous.items[from];
const remaining = previous.items.toSpliced(from, 1);
remaining.splice(to, 0, item);
return {
error: previous.error,
items: remaining,
startIndex: previous.startIndex,
totalRecordCount: previous.totalRecordCount,
};
}
return previous;
},
);
// Nodes have to be redrawn, otherwise the row indexes will be wrong
// Maybe it's possible to only redraw necessary rows to not be as expensive?
tableRef.current?.api.redrawRows();
} catch (error) { } catch (error) {
toast.error({ toast.error({
message: (error as Error).message, message: (error as Error).message,
@ -189,7 +153,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
} }
} }
}, },
[playlistId, queryClient, server], [playlistId, queryClient, server, tableRef],
); );
const handleGridSizeChange = () => { const handleGridSizeChange = () => {
@ -286,7 +250,9 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
const { rowClassRules } = useCurrentSongRowStyles({ tableRef }); const { rowClassRules } = useCurrentSongRowStyles({ tableRef });
const canDrag = const canDrag =
filters.sortBy === SongListSort.ID && !detailQuery?.data?.rules && !iSClientSide; filters.sortBy === SongListSort.ID &&
!detailQuery?.data?.rules &&
server?.type !== ServerType.SUBSONIC;
return ( return (
<> <>
@ -303,9 +269,6 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
status, status,
}} }}
getRowId={(data) => data.data.uniqueId} getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={
iSClientSide ? undefined : checkPlaylistList.data?.totalRecordCount || 100
}
// https://github.com/ag-grid/ag-grid/issues/5284 // https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes // Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`} key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
@ -326,7 +289,7 @@ export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetai
rowData={songs} rowData={songs}
rowDragEntireRow={canDrag} rowDragEntireRow={canDrag}
rowHeight={page.table.rowHeight || 40} rowHeight={page.table.rowHeight || 40}
rowModelType={iSClientSide ? 'clientSide' : 'infinite'} rowModelType="clientSide"
shouldUpdateSong shouldUpdateSong
/> />
</VirtualGridAutoSizerContainer> </VirtualGridAutoSizerContainer>

View file

@ -1,6 +1,5 @@
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 { IDatasource } from '@ag-grid-community/core';
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
@ -9,7 +8,6 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
@ -23,7 +21,6 @@ import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { import {
PersistedTableColumn, PersistedTableColumn,
SongListFilter,
useCurrentServer, useCurrentServer,
usePlaylistDetailStore, usePlaylistDetailStore,
useSetPlaylistDetailFilters, useSetPlaylistDetailFilters,
@ -42,7 +39,7 @@ import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { import {
LibraryItem, LibraryItem,
PlaylistSongListQuery, PlaylistSongListQueryClientSide,
ServerType, ServerType,
SongListSort, SongListSort,
SortOrder, SortOrder,
@ -155,7 +152,7 @@ const FILTERS = {
}, },
{ {
defaultOrder: SortOrder.ASC, defaultOrder: SortOrder.ASC,
name: i18n.t('filter.playCount', { postProcess: 'titleCase' }), name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
value: SongListSort.GENRE, value: SongListSort.GENRE,
}, },
{ {
@ -240,11 +237,6 @@ const FILTERS = {
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: SongListSort.RECENTLY_ADDED, value: SongListSort.RECENTLY_ADDED,
}, },
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
value: SongListSort.RECENTLY_PLAYED,
},
{ {
defaultOrder: SortOrder.DESC, defaultOrder: SortOrder.DESC,
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }), name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
@ -270,7 +262,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
const setPage = useSetPlaylistStore(); const setPage = useSetPlaylistStore();
const setFilter = useSetPlaylistDetailFilters(); const setFilter = useSetPlaylistDetailFilters();
const page = usePlaylistDetailStore(); const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = { const filters: Partial<PlaylistSongListQueryClientSide> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID, sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC, sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
}; };
@ -297,68 +289,18 @@ export const PlaylistDetailSongListHeaderFilters = ({
const debouncedHandleItemSize = debounce(handleItemSize, 20); const debouncedHandleItemSize = debounce(handleItemSize, 20);
const handleFilterChange = useCallback( const handleFilterChange = useCallback(async () => {
async (filters: SongListFilter) => { tableRef.current?.api.redrawRows();
if (server?.type !== ServerType.SUBSONIC) { tableRef.current?.api.ensureIndexVisible(0, 'top');
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.playlists.songList( if (page.display === ListDisplayType.TABLE_PAGINATED) {
server?.id || '', setPagination({ data: { currentPage: 0 } });
playlistId, }
{ }, [tableRef, page.display, setPagination]);
id: playlistId,
limit,
startIndex,
...filters,
},
);
const songsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getPlaylistSongList({
apiClientProps: {
server,
signal,
},
query: {
id: playlistId,
limit,
startIndex,
...filters,
},
}),
{ cacheTime: 1000 * 60 * 1 },
);
params.successCallback(
songsRes?.items || [],
songsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
} else {
tableRef.current?.api.redrawRows();
tableRef.current?.api.ensureIndexVisible(0, 'top');
}
if (page.display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ data: { currentPage: 0 } });
}
},
[tableRef, page.display, server, playlistId, queryClient, setPagination],
);
const handleRefresh = () => { const handleRefresh = () => {
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || '')); queryClient.invalidateQueries(queryKeys.playlists.songList(server?.id || '', playlistId));
handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters }); handleFilterChange();
}; };
const handleSetSortBy = useCallback( const handleSetSortBy = useCallback(
@ -369,20 +311,20 @@ export const PlaylistDetailSongListHeaderFilters = ({
(f) => f.value === e.currentTarget.value, (f) => f.value === e.currentTarget.value,
)?.defaultOrder; )?.defaultOrder;
const updatedFilters = setFilter(playlistId, { setFilter(playlistId, {
sortBy: e.currentTarget.value as SongListSort, sortBy: e.currentTarget.value as SongListSort,
sortOrder: sortOrder || SortOrder.ASC, sortOrder: sortOrder || SortOrder.ASC,
}); });
handleFilterChange(updatedFilters); handleFilterChange();
}, },
[handleFilterChange, playlistId, server?.type, setFilter], [handleFilterChange, playlistId, server?.type, setFilter],
); );
const handleToggleSortOrder = useCallback(() => { const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter(playlistId, { sortOrder: newSortOrder }); setFilter(playlistId, { sortOrder: newSortOrder });
handleFilterChange(updatedFilters); handleFilterChange();
}, [filters.sortOrder, handleFilterChange, playlistId, setFilter]); }, [filters.sortOrder, handleFilterChange, playlistId, setFilter]);
const handleSetViewType = useCallback( const handleSetViewType = useCallback(
@ -432,6 +374,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
handlePlayQueueAdd?.({ handlePlayQueueAdd?.({
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST }, byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
playType, playType,
query: filters,
}); });
}; };

View file

@ -9,12 +9,17 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters'; import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer, usePlaylistDetailStore } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Badge } from '/@/shared/components/badge/badge'; import { Badge } from '/@/shared/components/badge/badge';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { LibraryItem } from '/@/shared/types/domain-types'; import {
LibraryItem,
PlaylistSongListQueryClientSide,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
interface PlaylistDetailHeaderProps { interface PlaylistDetailHeaderProps {
@ -33,11 +38,17 @@ export const PlaylistDetailSongListHeader = ({
const server = useCurrentServer(); const server = useCurrentServer();
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id }); const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQueryClientSide> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
const handlePlay = async (playType: Play) => { const handlePlay = async (playType: Play) => {
handlePlayQueueAdd?.({ handlePlayQueueAdd?.({
byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST }, byItemType: { id: [playlistId], type: LibraryItem.PLAYLIST },
playType, playType,
query: filters,
}); });
}; };

View file

@ -29,9 +29,6 @@ export const useAddToPlaylist = (args: MutationHookArgs) => {
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false }); queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id)); queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
queryClient.invalidateQueries(
queryKeys.playlists.detailSongList(serverId, variables.query.id),
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
queryKeys.playlists.songList(serverId, variables.query.id), queryKeys.playlists.songList(serverId, variables.query.id),
); );

View file

@ -29,7 +29,7 @@ export const useRemoveFromPlaylist = (options?: MutationOptions) => {
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false }); queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id)); queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
queryClient.invalidateQueries( queryClient.invalidateQueries(
queryKeys.playlists.detailSongList(serverId, variables.query.id), queryKeys.playlists.songList(serverId, variables.query.id),
); );
}, },
...options, ...options,

View file

@ -20,7 +20,7 @@ export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>)
query, query,
}); });
}, },
queryKey: queryKeys.playlists.songList(server?.id || '', query.id, query), queryKey: queryKeys.playlists.songList(server?.id || '', query.id),
...options, ...options,
}); });
}; };

View file

@ -2,7 +2,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { closeAllModals, openModal } from '@mantine/modals'; import { closeAllModals, openModal } from '@mantine/modals';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate, useParams } from 'react-router'; import { generatePath, useNavigate, useParams } from 'react-router';
@ -22,12 +22,7 @@ import { Box } from '/@/shared/components/box/box';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { import { ServerType, SongListSort, SortOrder, sortSongList } from '/@/shared/types/domain-types';
PlaylistSongListQuery,
ServerType,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
const PlaylistDetailSongListRoute = () => { const PlaylistDetailSongListRoute = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -148,22 +143,25 @@ const PlaylistDetailSongListRoute = () => {
}; };
const page = usePlaylistDetailStore(); const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
const itemCountCheck = usePlaylistSongList({ const playlistSongs = usePlaylistSongList({
query: { query: {
id: playlistId, id: playlistId,
limit: 1,
startIndex: 0,
...filters,
}, },
serverId: server?.id, serverId: server?.id,
}); });
const itemCount = itemCountCheck.data?.totalRecordCount || itemCountCheck.data?.items.length; const itemCount = playlistSongs.data?.totalRecordCount ?? undefined;
const filterSortedSongs = useMemo(() => {
if (playlistSongs.data?.items) {
const sortBy = page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID;
const sortOrder = page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC;
return sortSongList(playlistSongs.data?.items, sortBy, sortOrder);
} else {
return [];
}
}, [playlistSongs.data?.items, page?.table.id, playlistId]);
return ( return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}> <AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@ -203,12 +201,7 @@ const PlaylistDetailSongListRoute = () => {
</Box> </Box>
</motion.div> </motion.div>
)} )}
<PlaylistDetailSongListContent <PlaylistDetailSongListContent songs={filterSortedSongs} tableRef={tableRef} />
songs={
server?.type === ServerType.SUBSONIC ? itemCountCheck.data?.items : undefined
}
tableRef={tableRef}
/>
</AnimatedPage> </AnimatedPage>
); );
}; };

View file

@ -988,10 +988,11 @@ export type PlaylistSongListArgs = BaseEndpointArgs & { query: PlaylistSongListQ
export type PlaylistSongListQuery = { export type PlaylistSongListQuery = {
id: string; id: string;
limit?: number; };
export type PlaylistSongListQueryClientSide = {
sortBy?: SongListSort; sortBy?: SongListSort;
sortOrder?: SortOrder; sortOrder?: SortOrder;
startIndex: number;
}; };
// Playlist Songs // Playlist Songs
@ -1400,7 +1401,7 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder
case SongListSort.ALBUM_ARTIST: case SongListSort.ALBUM_ARTIST:
results = orderBy( results = orderBy(
results, results,
['albumArtist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'], [(v) => v.albumArtists[0]?.name.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'], [order, order, 'asc', 'asc'],
); );
break; break;
@ -1408,11 +1409,23 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder
case SongListSort.ARTIST: case SongListSort.ARTIST:
results = orderBy( results = orderBy(
results, results,
['artist', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'], [(v) => v.artistName?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'], [order, order, 'asc', 'asc'],
); );
break; break;
case SongListSort.BPM:
results = orderBy(results, ['bpm'], [order]);
break;
case SongListSort.CHANNELS:
results = orderBy(results, ['channels'], [order]);
break;
case SongListSort.COMMENT:
results = orderBy(results, ['comment'], [order]);
break;
case SongListSort.DURATION: case SongListSort.DURATION:
results = orderBy(results, ['duration'], [order]); results = orderBy(results, ['duration'], [order]);
break; break;
@ -1425,7 +1438,7 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder
results = orderBy( results = orderBy(
results, results,
[ [
(v) => v.genres?.[0].name.toLowerCase(), (v) => v.genres?.[0]?.name.toLowerCase(),
(v) => v.album?.toLowerCase(), (v) => v.album?.toLowerCase(),
'discNumber', 'discNumber',
'trackNumber', 'trackNumber',
@ -1457,13 +1470,21 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder
break; break;
case SongListSort.RECENTLY_ADDED: case SongListSort.RECENTLY_ADDED:
results = orderBy(results, ['created'], [order]); results = orderBy(results, ['createdAt'], [order]);
break;
case SongListSort.RECENTLY_PLAYED:
results = orderBy(results, ['lastPlayedAt'], [order]);
break;
case SongListSort.RELEASE_DATE:
results = orderBy(results, ['releaseDate'], [order]);
break; break;
case SongListSort.YEAR: case SongListSort.YEAR:
results = orderBy( results = orderBy(
results, results,
['year', (v) => v.album?.toLowerCase(), 'discNumber', 'track'], ['releaseYear', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[order, 'asc', 'asc', 'asc'], [order, 'asc', 'asc', 'asc'],
); );
break; break;