Support playlists

This commit is contained in:
jeffvli
2023-12-08 08:06:56 -08:00
parent 18ec50b2a3
commit d347221be5
12 changed files with 298 additions and 191 deletions
@@ -535,7 +535,6 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongList
query: { query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId', Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
Limit: query.limit,
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined, SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined, SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
StartIndex: 0, StartIndex: 0,
@@ -549,7 +548,7 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongList
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,
}; };
}; };
@@ -303,7 +303,7 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
body: { body: {
comment: body.comment, comment: body.comment,
name: body.name, name: body.name,
public: body._custom?.navidrome?.public, public: body.public,
rules: body._custom?.navidrome?.rules, rules: body._custom?.navidrome?.rules,
sync: body._custom?.navidrome?.sync, sync: body._custom?.navidrome?.sync,
}, },
@@ -410,12 +410,11 @@ const getPlaylistSongList = async (
id: query.id, id: query.id,
}, },
query: { query: {
_end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC', _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
_sort: query.sortBy _sort: query.sortBy
? songListSortMap.navidrome[query.sortBy] ? songListSortMap.navidrome[query.sortBy]
: ndType._enum.songList.ID, : ndType._enum.songList.ID,
_start: query.startIndex, _start: 0,
}, },
}); });
@@ -425,7 +424,7 @@ const getPlaylistSongList = async (
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),
}; };
}; };
@@ -1,5 +1,7 @@
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import shuffle from 'lodash/shuffle';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
import reverse from 'lodash/reverse';
import md5 from 'md5'; import md5 from 'md5';
import { fsLog } from '/@/logger'; import { fsLog } from '/@/logger';
import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api';
@@ -13,6 +15,7 @@ import {
GenreListSort, GenreListSort,
LibraryItem, LibraryItem,
PlaylistListSort, PlaylistListSort,
SongListSort,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils'; import { randomString } from '/@/renderer/utils';
@@ -566,6 +569,25 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount: res.body['subsonic-response'].musicFolders.musicFolder.length, totalRecordCount: res.body['subsonic-response'].musicFolders.musicFolder.length,
}; };
}, },
getPlaylistDetail: async (args) => {
const { query, apiClientProps } = args;
const res = await subsonicApiClient(apiClientProps).getPlaylist({
query: {
id: query.id,
},
});
if (res.status !== 200) {
fsLog.error('Failed to get playlist detail');
throw new Error('Failed to get playlist detail');
}
return subsonicNormalize.playlist(
res.body['subsonic-response'].playlist,
apiClientProps.server,
);
},
getPlaylistList: async (args) => { getPlaylistList: async (args) => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
@@ -619,7 +641,7 @@ export const SubsonicController: ControllerEndpoint = {
}; };
}, },
getPlaylistListCount: async (args) => { getPlaylistListCount: async (args) => {
const { apiClientProps } = args; const { query, apiClientProps } = args;
const res = await subsonicApiClient(apiClientProps).getPlaylists({}); const res = await subsonicApiClient(apiClientProps).getPlaylists({});
@@ -628,8 +650,127 @@ export const SubsonicController: ControllerEndpoint = {
throw new Error('Failed to get playlist list count'); throw new Error('Failed to get playlist list count');
} }
if (query.searchTerm) {
const searchResults = filter(
res.body['subsonic-response'].playlists.playlist,
(playlist) => {
return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase());
},
);
return searchResults.length;
}
return res.body['subsonic-response'].playlists.playlist.length; return res.body['subsonic-response'].playlists.playlist.length;
}, },
getPlaylistSongList: async (args) => {
const { query, apiClientProps } = args;
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
const res = await subsonicApiClient(apiClientProps).getPlaylist({
query: {
id: query.id,
},
});
if (res.status !== 200) {
fsLog.error('Failed to get playlist song list');
throw new Error('Failed to get playlist song list');
}
let results = res.body['subsonic-response'].playlist.entry || [];
if (query.searchTerm) {
const searchResults = filter(results, (entry) => {
return entry.title.toLowerCase().includes(query.searchTerm!.toLowerCase());
});
results = searchResults;
}
if (query.sortBy) {
switch (query.sortBy) {
case SongListSort.ALBUM:
results = orderBy(
results,
[(v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[sortOrder, 'asc', 'asc'],
);
break;
case SongListSort.ALBUM_ARTIST:
results = orderBy(
results,
['albumArtist', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[sortOrder, sortOrder, 'asc', 'asc'],
);
break;
case SongListSort.ARTIST:
results = orderBy(
results,
['artist', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[sortOrder, sortOrder, 'asc', 'asc'],
);
break;
case SongListSort.DURATION:
results = orderBy(results, ['duration'], [sortOrder]);
break;
case SongListSort.FAVORITED:
results = orderBy(
results,
['starred', (v) => v.title.toLowerCase()],
[sortOrder],
);
break;
case SongListSort.GENRE:
results = orderBy(
results,
['genre', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[sortOrder, sortOrder, 'asc', 'asc'],
);
break;
case SongListSort.ID:
if (sortOrder === 'desc') {
results = reverse(results);
}
break;
case SongListSort.NAME:
results = orderBy(results, [(v) => v.title.toLowerCase()], [sortOrder]);
break;
case SongListSort.PLAY_COUNT:
results = orderBy(results, ['playCount'], [sortOrder]);
break;
case SongListSort.RANDOM:
results = shuffle(results);
break;
case SongListSort.RATING:
results = orderBy(
results,
['userRating', (v) => v.title.toLowerCase()],
[sortOrder],
);
break;
case SongListSort.RECENTLY_ADDED:
results = orderBy(results, ['created'], [sortOrder]);
break;
case SongListSort.YEAR:
results = orderBy(
results,
['year', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[sortOrder, 'asc', 'asc', 'asc'],
);
break;
default:
break;
}
}
return {
items: results?.map((song) => subsonicNormalize.song(song, apiClientProps.server, '')),
startIndex: 0,
totalRecordCount: results?.length || 0,
};
},
getRandomSongList: async (args) => { getRandomSongList: async (args) => {
const { query, apiClientProps } = args; const { query, apiClientProps } = args;
+1 -1
View File
@@ -109,7 +109,7 @@ const playlist = z.object({
coverArt: z.string().optional(), coverArt: z.string().optional(),
created: z.string(), created: z.string(),
duration: z.number(), duration: z.number(),
entry: z.array(song), entry: z.array(song).optional(),
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
owner: z.string(), owner: z.string(),
+4 -4
View File
@@ -815,6 +815,7 @@ export type CreatePlaylistBody = {
}; };
comment?: string; comment?: string;
name: string; name: string;
public?: boolean;
}; };
export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs; export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs;
@@ -935,10 +936,9 @@ export type PlaylistSongListResponse = BasePaginatedResponse<Song[]> | null | un
export type PlaylistSongListQuery = { export type PlaylistSongListQuery = {
id: string; id: string;
limit?: number; searchTerm?: string;
sortBy?: SongListSort; sortBy: SongListSort;
sortOrder?: SortOrder; sortOrder: SortOrder;
startIndex: number;
}; };
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs; export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
+3 -2
View File
@@ -23,7 +23,6 @@ export const getPlaylistSongsById = async (args: {
id, id,
sortBy: SongListSort.ID, sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC, sortOrder: SortOrder.ASC,
startIndex: 0,
...query, ...query,
}; };
@@ -139,7 +138,9 @@ export const getGenreSongsById = async (args: {
); );
data.items.push(...res!.items); data.items.push(...res!.items);
data.totalRecordCount += res!.totalRecordCount; if (data.totalRecordCount) {
data.totalRecordCount += res!.totalRecordCount || 0;
}
} }
return data; return data;
@@ -136,7 +136,6 @@ export const AddToPlaylistContextModal = ({
if (values.skipDuplicates) { if (values.skipDuplicates) {
const query = { const query = {
id: playlistId, id: playlistId,
startIndex: 0,
}; };
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query); const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
@@ -151,7 +150,11 @@ export const AddToPlaylistContextModal = ({
server, server,
signal, signal,
}, },
query: { id: playlistId, startIndex: 0 }, query: {
id: playlistId,
sortBy: SongListSort.ID,
sortOrder: SortOrder.ASC,
},
}); });
}); });
@@ -32,6 +32,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
}, },
comment: '', comment: '',
name: '', name: '',
public: false,
}, },
}); });
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false); const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
@@ -86,7 +87,8 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
); );
}); });
const isPublicDisplayed = server?.type === ServerType.NAVIDROME; const isPublicDisplayed =
server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC;
const isSubmitDisabled = !form.values.name || mutation.isLoading; const isSubmitDisabled = !form.values.name || mutation.isLoading;
return ( return (
@@ -115,7 +117,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
context: 'public', context: 'public',
postProcess: 'titleCase', postProcess: 'titleCase',
})} })}
{...form.getInputProps('_custom.navidrome.public', { {...form.getInputProps('public', {
type: 'checkbox', type: 'checkbox',
})} })}
/> />
@@ -2,25 +2,15 @@ import type {
BodyScrollEvent, BodyScrollEvent,
ColDef, ColDef,
GridReadyEvent, GridReadyEvent,
IDatasource,
PaginationChangedEvent, PaginationChangedEvent,
RowDoubleClickedEvent, RowDoubleClickedEvent,
} 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 { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { MutableRefObject, useCallback, useMemo } from 'react'; import { MutableRefObject, useCallback, useMemo } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { api } from '/@/renderer/api'; import { LibraryItem, QueueSong, Song } from '/@/renderer/api/types';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
LibraryItem,
PlaylistSongListQuery,
QueueSong,
SongListSort,
SortOrder,
} from '/@/renderer/api/types';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid'; import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { TablePagination, VirtualTable, getColumnDefs } from '/@/renderer/components/virtual-table'; import { TablePagination, VirtualTable, getColumnDefs } from '/@/renderer/components/virtual-table';
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles'; import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
@@ -31,7 +21,7 @@ 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 { import {
useCurrentServer, useCurrentServer,
useCurrentSong, useCurrentSong,
@@ -43,26 +33,19 @@ import {
} from '/@/renderer/store'; } from '/@/renderer/store';
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';
interface PlaylistDetailContentProps { interface PlaylistDetailContentProps {
songs: Song[];
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailContentProps) => { export const PlaylistDetailSongListContent = ({ songs, tableRef }: PlaylistDetailContentProps) => {
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const queryClient = useQueryClient();
const status = useCurrentStatus(); const status = useCurrentStatus();
const isFocused = useAppFocus(); const isFocused = useAppFocus();
const currentSong = useCurrentSong(); const currentSong = useCurrentSong();
const server = useCurrentServer(); const server = useCurrentServer();
const page = usePlaylistDetailStore(); const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = useMemo(() => {
return {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
};
}, [page?.table.id, playlistId]);
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id }); const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
@@ -82,15 +65,6 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const checkPlaylistList = usePlaylistSongList({
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],
@@ -98,44 +72,9 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
const onGridReady = useCallback( const onGridReady = useCallback(
(params: GridReadyEvent) => { (params: GridReadyEvent) => {
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, pagination.scrollOffset, playlistId, queryClient, server], [pagination.scrollOffset],
); );
const handleGridSizeChange = () => { const handleGridSizeChange = () => {
@@ -249,13 +188,13 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
status, status,
}} }}
getRowId={(data) => data.data.uniqueId} getRowId={(data) => data.data.uniqueId}
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
pagination={isPaginationEnabled} pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled} paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={pagination.itemsPerPage || 100} paginationPageSize={pagination.itemsPerPage || 100}
rowClassRules={rowClassRules} rowClassRules={rowClassRules}
rowData={songs}
rowHeight={page.table.rowHeight || 40} rowHeight={page.table.rowHeight || 40}
rowModelType="infinite" rowModelType="clientSide"
onBodyScrollEnd={handleScroll} onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu} onCellContextMenu={handleContextMenu}
onColumnMoved={handleColumnChange} onColumnMoved={handleColumnChange}
@@ -1,53 +1,50 @@
import { useCallback, ChangeEvent, MutableRefObject, MouseEvent } from 'react';
import { IDatasource } 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 { Divider, Flex, Group, Stack } from '@mantine/core'; import { Divider, Flex, Group, Stack } from '@mantine/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 { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
RiMoreFill,
RiSettings3Fill,
RiPlayFill,
RiAddCircleFill,
RiAddBoxFill, RiAddBoxFill,
RiEditFill, RiAddCircleFill,
RiDeleteBinFill, RiDeleteBinFill,
RiEditFill,
RiMoreFill,
RiPlayFill,
RiRefreshLine, RiRefreshLine,
RiSettings3Fill,
} from 'react-icons/ri'; } from 'react-icons/ri';
import { api } from '/@/renderer/api'; import { useNavigate, useParams } from 'react-router';
import i18n from '/@/i18n/i18n';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types'; import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
import { import {
DropdownMenu,
Button, Button,
Slider, ConfirmModal,
DropdownMenu,
MultiSelect, MultiSelect,
Slider,
Switch, Switch,
Text, Text,
ConfirmModal,
toast, toast,
} from '/@/renderer/components'; } from '/@/renderer/components';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { OrderToggleButton } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks'; import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { import {
useCurrentServer, useCurrentServer,
SongListFilter,
usePlaylistDetailStore, usePlaylistDetailStore,
useSetPlaylistDetailFilters, useSetPlaylistDetailFilters,
useSetPlaylistDetailTable, useSetPlaylistDetailTable,
useSetPlaylistStore, useSetPlaylistStore,
useSetPlaylistTablePagination, useSetPlaylistTablePagination,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { ListDisplayType, ServerType, Play, TableColumn } from '/@/renderer/types'; import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { useParams, useNavigate } from 'react-router';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { AppRoute } from '/@/renderer/router/routes';
import { OrderToggleButton } from '/@/renderer/features/shared';
import i18n from '/@/i18n/i18n';
const FILTERS = { const FILTERS = {
jellyfin: [ jellyfin: [
@@ -150,7 +147,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,
}, },
{ {
@@ -184,6 +181,68 @@ const FILTERS = {
value: SongListSort.YEAR, value: SongListSort.YEAR,
}, },
], ],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: SongListSort.ID,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.album', { postProcess: 'titleCase' }),
value: SongListSort.ALBUM,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: SongListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
value: SongListSort.ARTIST,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
value: SongListSort.DURATION,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }),
value: SongListSort.FAVORITED,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.genre', { postProcess: 'titleCase' }),
value: SongListSort.GENRE,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: SongListSort.NAME,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.rating', { postProcess: 'titleCase' }),
value: SongListSort.RATING,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: SongListSort.RECENTLY_ADDED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
value: SongListSort.RECENTLY_PLAYED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
value: SongListSort.YEAR,
},
],
}; };
interface PlaylistDetailSongListHeaderFiltersProps { interface PlaylistDetailSongListHeaderFiltersProps {
@@ -228,56 +287,18 @@ export const PlaylistDetailSongListHeaderFilters = ({
setTable({ rowHeight: e }); setTable({ rowHeight: e });
}; };
const handleFilterChange = useCallback( const handleFilterChange = useCallback(async () => {
async (filters: SongListFilter) => { tableRef.current?.api.redrawRows();
const dataSource: IDatasource = { tableRef.current?.api.ensureIndexVisible(0, 'top');
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, { if (page.display === ListDisplayType.TABLE_PAGINATED) {
id: playlistId, setPagination({ data: { currentPage: 0 } });
limit, }
startIndex, }, [tableRef, page.display, setPagination]);
...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');
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.albums.list(server?.id || ''));
handleFilterChange({ ...page?.table.id[playlistId].filter, ...filters }); handleFilterChange();
}; };
const handleSetSortBy = useCallback( const handleSetSortBy = useCallback(
@@ -288,20 +309,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(
@@ -1,6 +1,6 @@
import { MutableRefObject } from 'react'; import { MutableRefObject } from 'react';
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 { Stack } from '@mantine/core'; import { Flex, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { LibraryItem } from '/@/renderer/api/types'; import { LibraryItem } from '/@/renderer/api/types';
@@ -45,23 +45,30 @@ export const PlaylistDetailSongListHeader = ({
return ( return (
<Stack spacing={0}> <Stack spacing={0}>
<PageHeader backgroundColor="var(--titlebar-bg)"> <PageHeader backgroundColor="var(--titlebar-bg)">
<LibraryHeaderBar> <Flex
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} /> justify="space-between"
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title> w="100%"
<Paper >
fw="600" <LibraryHeaderBar>
px="1rem" <LibraryHeaderBar.PlayButton
py="0.3rem" onClick={() => handlePlay(playButtonBehavior)}
radius="sm" />
> <LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
{itemCount === null || itemCount === undefined ? ( <Paper
<SpinnerIcon /> fw="600"
) : ( px="1rem"
itemCount py="0.3rem"
)} radius="sm"
</Paper> >
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>} {itemCount === null || itemCount === undefined ? (
</LibraryHeaderBar> <SpinnerIcon />
) : (
itemCount
)}
</Paper>
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
</LibraryHeaderBar>
</Flex>
</PageHeader> </PageHeader>
<Paper p="1rem"> <Paper p="1rem">
<PlaylistDetailSongListHeaderFilters <PlaylistDetailSongListHeaderFilters
@@ -139,28 +139,20 @@ const PlaylistDetailSongListRoute = () => {
const page = usePlaylistDetailStore(); const page = usePlaylistDetailStore();
const filters: Partial<PlaylistSongListQuery> = { const filters: Partial<PlaylistSongListQuery> = {
sortBy: page?.table.id[playlistId]?.filter?.sortBy || SongListSort.ID, sortBy: page?.table.id[playlistId]?.filter?.sortBy,
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC, sortOrder: page?.table.id[playlistId]?.filter?.sortOrder,
}; };
const itemCountCheck = usePlaylistSongList({ const { data } = usePlaylistSongList({
options: {
cacheTime: 1000 * 60 * 60 * 2,
staleTime: 1000 * 60 * 60 * 2,
},
query: { query: {
id: playlistId, id: playlistId,
limit: 1, sortBy: filters.sortBy || SongListSort.ID,
startIndex: 0, sortOrder: filters.sortOrder || SortOrder.ASC,
...filters,
}, },
serverId: server?.id, serverId: server?.id,
}); });
const itemCount = const itemCount = data?.items.length;
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
return ( return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}> <AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@@ -206,7 +198,10 @@ const PlaylistDetailSongListRoute = () => {
</Paper> </Paper>
</Box> </Box>
)} )}
<PlaylistDetailSongListContent tableRef={tableRef} /> <PlaylistDetailSongListContent
songs={data?.items || []}
tableRef={tableRef}
/>
</AnimatedPage> </AnimatedPage>
); );
}; };