Add playlist list

This commit is contained in:
jeffvli 2022-12-31 03:46:12 -08:00
parent 00a21269dd
commit ec79d91d30
21 changed files with 911 additions and 47 deletions

View file

@ -199,6 +199,10 @@ const getArtistList = async (args: ArtistListArgs) => {
return (apiController('getArtistList') as ControllerEndpoint['getArtistList'])?.(args); return (apiController('getArtistList') as ControllerEndpoint['getArtistList'])?.(args);
}; };
const getPlaylistList = async (args: PlaylistListArgs) => {
return (apiController('getPlaylistList') as ControllerEndpoint['getPlaylistList'])?.(args);
};
export const controller = { export const controller = {
getAlbumArtistList, getAlbumArtistList,
getAlbumDetail, getAlbumDetail,
@ -206,5 +210,6 @@ export const controller = {
getArtistList, getArtistList,
getGenreList, getGenreList,
getMusicFolderList, getMusicFolderList,
getPlaylistList,
getSongList, getSongList,
}; };

View file

@ -22,6 +22,7 @@ import type {
JFGenreListResponse, JFGenreListResponse,
JFMusicFolderList, JFMusicFolderList,
JFMusicFolderListResponse, JFMusicFolderListResponse,
JFPlaylist,
JFPlaylistDetail, JFPlaylistDetail,
JFPlaylistDetailResponse, JFPlaylistDetailResponse,
JFPlaylistList, JFPlaylistList,
@ -32,7 +33,7 @@ import type {
JFSongListResponse, JFSongListResponse,
} from '/@/renderer/api/jellyfin.types'; } from '/@/renderer/api/jellyfin.types';
import { JFCollectionType } from '/@/renderer/api/jellyfin.types'; import { JFCollectionType } from '/@/renderer/api/jellyfin.types';
import type { import {
Album, Album,
AlbumArtist, AlbumArtist,
AlbumArtistDetailArgs, AlbumArtistDetailArgs,
@ -48,13 +49,13 @@ import type {
FavoriteResponse, FavoriteResponse,
GenreListArgs, GenreListArgs,
MusicFolderListArgs, MusicFolderListArgs,
Playlist,
PlaylistDetailArgs, PlaylistDetailArgs,
PlaylistListArgs, PlaylistListArgs,
playlistListSortMap,
PlaylistSongListArgs, PlaylistSongListArgs,
Song, Song,
SongListArgs, SongListArgs,
} from '/@/renderer/api/types';
import {
songListSortMap, songListSortMap,
albumListSortMap, albumListSortMap,
artistListSortMap, artistListSortMap,
@ -396,18 +397,20 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<JFSongLi
}; };
const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList> => { const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList> => {
const { server, signal } = args; const { query, server, signal } = args;
const searchParams = { const searchParams = {
fields: 'ChildCount, Genres, DateCreated, ParentId, Overview', fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
includeItemTypes: 'Playlist', includeItemTypes: 'Playlist',
limit: query.limit,
recursive: true, recursive: true,
sortBy: 'SortName', sortBy: playlistListSortMap.jellyfin[query.sortBy],
sortOrder: 'Ascending', sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex,
}; };
const data = await api const data = await api
.get(`/users/${server?.userId}/items`, { .get(`users/${server?.userId}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential }, headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url, prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams), searchParams: parseSearchParams(searchParams),
@ -415,12 +418,12 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList>
}) })
.json<JFPlaylistListResponse>(); .json<JFPlaylistListResponse>();
const playlistData = data.Items.filter((item) => item.MediaType === 'Audio'); const playlistItems = data.Items.filter((item) => item.MediaType === 'Audio');
return { return {
Items: playlistData, items: playlistItems,
StartIndex: 0, startIndex: 0,
TotalRecordCount: playlistData.length, totalRecordCount: playlistItems.length,
}; };
}; };
@ -690,6 +693,20 @@ const normalizeAlbumArtist = (
}; };
}; };
const normalizePlaylist = (item: JFPlaylist): Playlist => {
return {
duration: item.RunTimeTicks / 10000000,
id: item.Id,
name: item.Name,
public: null,
rules: null,
size: null,
songCount: item?.ChildCount || null,
userId: null,
username: null,
};
};
// const normalizeArtist = (item: any) => { // const normalizeArtist = (item: any) => {
// return { // return {
// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)), // album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),
@ -710,24 +727,6 @@ const normalizeAlbumArtist = (
// }; // };
// }; // };
// const normalizePlaylist = (item: any) => {
// return {
// changed: item.DateLastMediaAdded,
// comment: item.Overview,
// created: item.DateCreated,
// duration: item.RunTimeTicks / 10000000,
// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),
// id: item.Id,
// image: getCoverArtUrl(item, 350),
// owner: undefined,
// public: undefined,
// song: [],
// songCount: item.ChildCount,
// title: item.Name,
// uniqueId: nanoid(),
// };
// };
// const normalizeGenre = (item: any) => { // const normalizeGenre = (item: any) => {
// return { // return {
// albumCount: undefined, // albumCount: undefined,
@ -780,5 +779,6 @@ export const jellyfinApi = {
export const jfNormalize = { export const jfNormalize = {
album: normalizeAlbum, album: normalizeAlbum,
albumArtist: normalizeAlbumArtist, albumArtist: normalizeAlbumArtist,
playlist: normalizePlaylist,
song: normalizeSong, song: normalizeSong,
}; };

View file

@ -63,7 +63,19 @@ export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
Items: JFPlaylist[]; Items: JFPlaylist[];
} }
export type JFPlaylistList = JFPlaylistListResponse; export type JFPlaylistList = {
items: JFPlaylist[];
startIndex: number;
totalRecordCount: number;
};
export enum JFPlaylistListSort {
ALBUM_ARTIST = 'AlbumArtist,SortName',
DURATION = 'Runtime',
NAME = 'SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
SONG_COUNT = 'ChildCount',
}
export type JFPlaylistDetailResponse = JFPlaylist; export type JFPlaylistDetailResponse = JFPlaylist;
@ -485,6 +497,7 @@ type JFBaseParams = {
imageTypeLimit?: number; imageTypeLimit?: number;
parentId?: string; parentId?: string;
recursive?: boolean; recursive?: boolean;
searchTerm?: string;
userId?: string; userId?: string;
}; };

View file

@ -31,6 +31,7 @@ import type {
NDSongList, NDSongList,
NDSongListResponse, NDSongListResponse,
NDAlbumArtist, NDAlbumArtist,
NDPlaylist,
} from '/@/renderer/api/navidrome.types'; } from '/@/renderer/api/navidrome.types';
import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types'; import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import type { import type {
@ -51,6 +52,7 @@ import type {
CreatePlaylistResponse, CreatePlaylistResponse,
PlaylistSongListArgs, PlaylistSongListArgs,
AlbumArtist, AlbumArtist,
Playlist,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { import {
playlistListSortMap, playlistListSortMap,
@ -329,12 +331,13 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<NDPlaylistList>
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC, _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME, _sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME,
_start: query.startIndex, _start: query.startIndex,
...query.ndParams,
}; };
const res = await api.get('api/playlist', { const res = await api.get('api/playlist', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url, prefixUrl: server?.url,
searchParams, searchParams: parseSearchParams(searchParams),
signal, signal,
}); });
@ -521,6 +524,20 @@ const normalizeAlbumArtist = (item: NDAlbumArtist): AlbumArtist => {
}; };
}; };
const normalizePlaylist = (item: NDPlaylist): Playlist => {
return {
duration: item.duration,
id: item.id,
name: item.name,
public: item.public,
rules: item?.rules || null,
size: item.size,
songCount: item.songCount,
userId: item.ownerId,
username: item.ownerName,
};
};
export const navidromeApi = { export const navidromeApi = {
authenticate, authenticate,
createPlaylist, createPlaylist,
@ -540,5 +557,6 @@ export const navidromeApi = {
export const ndNormalize = { export const ndNormalize = {
album: normalizeAlbum, album: normalizeAlbum,
albumArtist: normalizeAlbumArtist, albumArtist: normalizeAlbumArtist,
playlist: normalizePlaylist,
song: normalizeSong, song: normalizeSong,
}; };

View file

@ -287,7 +287,7 @@ export type NDPlaylist = {
ownerName: string; ownerName: string;
path: string; path: string;
public: boolean; public: boolean;
rules: null; rules: Record<string, any> | null;
size: number; size: number;
songCount: number; songCount: number;
sync: boolean; sync: boolean;
@ -309,7 +309,7 @@ export type NDPlaylistListResponse = NDPlaylist[];
export enum NDPlaylistListSort { export enum NDPlaylistListSort {
DURATION = 'duration', DURATION = 'duration',
NAME = 'name', NAME = 'name',
OWNER = 'owner', OWNER = 'ownerName',
PUBLIC = 'public', PUBLIC = 'public',
SONG_COUNT = 'songCount', SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt', UPDATED_AT = 'updatedAt',

View file

@ -4,10 +4,17 @@ import type {
JFAlbumArtist, JFAlbumArtist,
JFGenreList, JFGenreList,
JFMusicFolderList, JFMusicFolderList,
JFPlaylist,
JFSong, JFSong,
} from '/@/renderer/api/jellyfin.types'; } from '/@/renderer/api/jellyfin.types';
import { ndNormalize } from '/@/renderer/api/navidrome.api'; import { ndNormalize } from '/@/renderer/api/navidrome.api';
import type { NDAlbum, NDAlbumArtist, NDGenreList, NDSong } from '/@/renderer/api/navidrome.types'; import type {
NDAlbum,
NDAlbumArtist,
NDGenreList,
NDPlaylist,
NDSong,
} from '/@/renderer/api/navidrome.types';
import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types'; import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types';
import type { import type {
Album, Album,
@ -16,6 +23,7 @@ import type {
RawAlbumListResponse, RawAlbumListResponse,
RawGenreListResponse, RawGenreListResponse,
RawMusicFolderListResponse, RawMusicFolderListResponse,
RawPlaylistListResponse,
RawSongListResponse, RawSongListResponse,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { ServerListItem } from '/@/renderer/types'; import { ServerListItem } from '/@/renderer/types';
@ -163,11 +171,32 @@ const albumArtistList = (
}; };
}; };
const playlistList = (data: RawPlaylistListResponse | undefined, server: ServerListItem | null) => {
let playlists;
switch (server?.type) {
case 'jellyfin':
playlists = data?.items.map((item) => jfNormalize.playlist(item as JFPlaylist));
break;
case 'navidrome':
playlists = data?.items.map((item) => ndNormalize.playlist(item as NDPlaylist));
break;
case 'subsonic':
break;
}
return {
items: playlists,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
};
export const normalize = { export const normalize = {
albumArtistList, albumArtistList,
albumDetail, albumDetail,
albumList, albumList,
genreList, genreList,
musicFolderList, musicFolderList,
playlistList,
songList, songList,
}; };

View file

@ -4,6 +4,7 @@ import type {
AlbumDetailQuery, AlbumDetailQuery,
AlbumArtistListQuery, AlbumArtistListQuery,
ArtistListQuery, ArtistListQuery,
PlaylistListQuery,
} from './types'; } from './types';
export const queryKeys = { export const queryKeys = {
@ -34,6 +35,11 @@ export const queryKeys = {
musicFolders: { musicFolders: {
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const, list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
}, },
playlists: {
list: (serverId: string, query?: PlaylistListQuery) =>
[serverId, 'playlists', 'list', query] as const,
root: (serverId: string) => [serverId, 'playlists'] as const,
},
server: { server: {
root: (serverId: string) => [serverId] as const, root: (serverId: string) => [serverId] as const,
}, },

View file

@ -14,6 +14,7 @@ import {
JFPlaylistList, JFPlaylistList,
JFPlaylistDetail, JFPlaylistDetail,
JFMusicFolderList, JFMusicFolderList,
JFPlaylistListSort,
} from '/@/renderer/api/jellyfin.types'; } from '/@/renderer/api/jellyfin.types';
import { import {
NDSortOrder, NDSortOrder,
@ -243,14 +244,15 @@ export type MusicFolder = {
}; };
export type Playlist = { export type Playlist = {
duration?: number; duration: number | null;
id: string; id: string;
name: string; name: string;
public?: boolean; public: boolean | null;
size?: number; rules?: Record<string, any> | null;
songCount?: number; size: number | null;
userId: string; songCount: number | null;
username: string; userId: string | null;
username: string | null;
}; };
export type GenresResponse = Genre[]; export type GenresResponse = Genre[];
@ -756,11 +758,21 @@ export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefine
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>; export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
export type PlaylistListSort = NDPlaylistListSort; export enum PlaylistListSort {
DURATION = 'duration',
NAME = 'name',
OWNER = 'owner',
PUBLIC = 'public',
SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt',
}
export type PlaylistListQuery = { export type PlaylistListQuery = {
limit?: number; limit?: number;
musicFolderId?: string; ndParams?: {
owner_id?: string;
};
searchTerm?: string;
sortBy: PlaylistListSort; sortBy: PlaylistListSort;
sortOrder: SortOrder; sortOrder: SortOrder;
startIndex: number; startIndex: number;
@ -769,18 +781,18 @@ export type PlaylistListQuery = {
export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs; export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs;
type PlaylistListSortMap = { type PlaylistListSortMap = {
jellyfin: Record<PlaylistListSort, undefined>; jellyfin: Record<PlaylistListSort, JFPlaylistListSort | undefined>;
navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>; navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;
subsonic: Record<PlaylistListSort, undefined>; subsonic: Record<PlaylistListSort, undefined>;
}; };
export const playlistListSortMap: PlaylistListSortMap = { export const playlistListSortMap: PlaylistListSortMap = {
jellyfin: { jellyfin: {
duration: undefined, duration: JFPlaylistListSort.DURATION,
name: undefined, name: JFPlaylistListSort.NAME,
owner: undefined, owner: undefined,
public: undefined, public: undefined,
songCount: undefined, songCount: JFPlaylistListSort.SONG_COUNT,
updatedAt: undefined, updatedAt: undefined,
}, },
navidrome: { navidrome: {

View file

@ -62,6 +62,16 @@ export const ALBUMARTIST_TABLE_COLUMNS = [
{ label: 'Song Count', value: TableColumn.SONG_COUNT }, { label: 'Song Count', value: TableColumn.SONG_COUNT },
]; ];
export const PLAYLIST_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED },
{ label: 'Duration', value: TableColumn.DURATION },
{ label: 'Owner', value: TableColumn.OWNER },
// { label: 'Genre', value: TableColumn.GENRE },
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
];
interface TableConfigDropdownProps { interface TableConfigDropdownProps {
type: TableType; type: TableType;
} }

View file

@ -29,3 +29,10 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ disabled: true, id: 'removeFromFavorites' }, { disabled: true, id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' }, { disabled: true, id: 'setRating' },
]; ];
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' },
{ id: 'playLast' },
{ divider: true, id: 'playNext' },
{ disabled: true, id: 'deletePlaylist' },
];

View file

@ -106,6 +106,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const contextMenuItems = { const contextMenuItems = {
addToFavorites: { id: 'addToFavorites', label: 'Add to favorites', onClick: () => {} }, addToFavorites: { id: 'addToFavorites', label: 'Add to favorites', onClick: () => {} },
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: () => {} }, addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: () => {} },
deletePlaylist: { id: 'deletePlaylist', label: 'Delete playlist', onClick: () => {} },
play: { play: {
id: 'play', id: 'play',
label: 'Play', label: 'Play',

View file

@ -21,7 +21,8 @@ export type ContextMenuItem =
| 'addToPlaylist' | 'addToPlaylist'
| 'addToFavorites' | 'addToFavorites'
| 'removeFromFavorites' | 'removeFromFavorites'
| 'setRating'; | 'setRating'
| 'deletePlaylist';
export type SetContextMenuItems = { export type SetContextMenuItems = {
disabled?: boolean; disabled?: boolean;

View file

@ -0,0 +1,251 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import type {
BodyScrollEvent,
CellContextMenuEvent,
ColDef,
GridReadyEvent,
IDatasource,
PaginationChangedEvent,
RowDoubleClickedEvent,
} from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
getColumnDefs,
TablePagination,
VirtualGridAutoSizerContainer,
VirtualTable,
} from '/@/renderer/components';
import {
useCurrentServer,
usePlaylistListStore,
usePlaylistTablePagination,
useSetPlaylistTable,
useSetPlaylistTablePagination,
} from '/@/renderer/store';
import { LibraryItem, ListDisplayType } from '/@/renderer/types';
import { AnimatePresence } from 'framer-motion';
import debounce from 'lodash/debounce';
import { openContextMenu } from '/@/renderer/features/context-menu';
import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import sortBy from 'lodash/sortBy';
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
import { generatePath, useNavigate } from 'react-router';
import { AppRoute } from '/@/renderer/router/routes';
interface PlaylistListContentProps {
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistListContent = ({ tableRef }: PlaylistListContentProps) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const server = useCurrentServer();
const page = usePlaylistListStore();
const pagination = usePlaylistTablePagination();
const setPagination = useSetPlaylistTablePagination();
const setTable = useSetPlaylistTable();
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const checkPlaylistList = usePlaylistList({
limit: 1,
startIndex: 0,
...page.filter,
});
const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns),
[page.table.columns],
);
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
}, []);
const onGridReady = useCallback(
(params: GridReadyEvent) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const queryKey = queryKeys.playlists.list(server?.id || '', {
limit,
startIndex,
...page.filter,
});
const playlistsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
api.controller.getPlaylistList({
query: {
limit,
startIndex,
...page.filter,
},
server,
signal,
}),
);
const playlists = api.normalize.playlistList(playlistsRes, server);
params.successCallback(playlists?.items || [], playlistsRes?.totalRecordCount);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
},
[page.filter, queryClient, server],
);
const onPaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
// Scroll to top of page on pagination change
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
setPagination({
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
});
},
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
);
const handleGridSizeChange = () => {
if (page.table.autoFit) {
tableRef?.current?.api.sizeColumnsToFit();
}
};
const handleColumnChange = useCallback(() => {
const { columnApi } = tableRef?.current || {};
const columnsOrder = columnApi?.getAllGridColumns();
if (!columnsOrder) return;
const columnsInSettings = page.table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!page.table.autoFit && {
width: column.getActualWidth(),
}),
});
}
}
setTable({ columns: updatedColumns });
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
const debouncedColumnChange = debounce(handleColumnChange, 200);
const handleScroll = (e: BodyScrollEvent) => {
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
setTable({ scrollOffset });
};
const handleContextMenu = (e: CellContextMenuEvent) => {
if (!e.event) return;
const clickEvent = e.event as MouseEvent;
clickEvent.preventDefault();
const selectedNodes = e.api.getSelectedNodes();
const selectedIds = selectedNodes.map((node) => node.data.id);
let selectedRows = sortBy(selectedNodes, ['rowIndex']).map((node) => node.data);
if (!selectedIds.includes(e.data.id)) {
e.api.deselectAll();
e.node.setSelected(true);
selectedRows = [e.data];
}
openContextMenu({
data: selectedRows,
menuItems: PLAYLIST_CONTEXT_MENU_ITEMS,
type: LibraryItem.PLAYLIST,
xPos: clickEvent.clientX,
yPos: clickEvent.clientY,
});
};
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
if (!e.data) return;
navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id }));
};
return (
<Stack
h="100%"
spacing={0}
>
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
animateRows
maintainColumnOrder
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressRowDrag
suppressScrollOnNewData
blockLoadDebounceMillis={200}
cacheBlockSize={200}
cacheOverflowSize={1}
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.id}
infiniteInitialRowCount={checkPlaylistList.data?.totalRecordCount || 100}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={page.table.pagination.itemsPerPage || 100}
rowBuffer={20}
rowHeight={page.table.rowHeight || 40}
rowModelType="infinite"
rowSelection="multiple"
onBodyScrollEnd={handleScroll}
onCellContextMenu={handleContextMenu}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onGridReady={onGridReady}
onGridSizeChanged={handleGridSizeChange}
onPaginationChanged={onPaginationChanged}
onRowDoubleClicked={handleRowDoubleClick}
/>
</VirtualGridAutoSizerContainer>
<AnimatePresence
presenceAffectsLayout
initial={false}
mode="wait"
>
{page.display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pagination={pagination}
setPagination={setPagination}
tableRef={tableRef}
/>
)}
</AnimatePresence>
</Stack>
);
};

View file

@ -0,0 +1,334 @@
import type { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { RiArrowDownSLine, RiMoreFill, RiSortAsc, RiSortDesc } from 'react-icons/ri';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { PlaylistListSort, SortOrder } from '/@/renderer/api/types';
import {
Button,
DropdownMenu,
PageHeader,
Slider,
TextTitle,
Switch,
MultiSelect,
Text,
PLAYLIST_TABLE_COLUMNS,
} from '/@/renderer/components';
import { useContainerQuery } from '/@/renderer/hooks';
import { queryClient } from '/@/renderer/lib/react-query';
import {
PlaylistListFilter,
useCurrentServer,
usePlaylistListStore,
useSetPlaylistFilters,
useSetPlaylistStore,
useSetPlaylistTable,
useSetPlaylistTablePagination,
} from '/@/renderer/store';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
const FILTERS = {
jellyfin: [
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
],
navidrome: [
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: PlaylistListSort.DURATION },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: PlaylistListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Owner', value: PlaylistListSort.OWNER },
{ defaultOrder: SortOrder.DESC, name: 'Public', value: PlaylistListSort.PUBLIC },
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: PlaylistListSort.SONG_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Updated At', value: PlaylistListSort.UPDATED_AT },
],
};
const ORDER = [
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
];
interface PlaylistListHeaderProps {
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const PlaylistListHeader = ({ tableRef }: PlaylistListHeaderProps) => {
const server = useCurrentServer();
const page = usePlaylistListStore();
const setPage = useSetPlaylistStore();
const setFilter = useSetPlaylistFilters();
const setTable = useSetPlaylistTable();
const setPagination = useSetPlaylistTablePagination();
const cq = useContainerQuery();
const sortByLabel =
(server?.type &&
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
(f) => f.value === page.filter.sortBy,
)?.name) ||
'Unknown';
const sortOrderLabel = ORDER.find((s) => s.value === page.filter.sortOrder)?.name;
const handleFilterChange = useCallback(
async (filters?: PlaylistListFilter) => {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const pageFilters = filters || page.filter;
const queryKey = queryKeys.playlists.list(server?.id || '', {
limit,
startIndex,
...pageFilters,
});
const playlistsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
api.controller.getPlaylistList({
query: {
limit,
startIndex,
...pageFilters,
},
server,
signal,
}),
);
const playlists = api.normalize.playlistList(playlistsRes, server);
params.successCallback(playlists?.items || [], playlistsRes?.totalRecordCount);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
setPagination({ currentPage: 0 });
},
[page.filter, server, setPagination, tableRef],
);
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const updatedFilters = setFilter({
sortBy: e.currentTarget.value as PlaylistListSort,
sortOrder: sortOrder || SortOrder.ASC,
});
handleFilterChange(updatedFilters);
},
[handleFilterChange, server?.type, setFilter],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = page.filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({ sortOrder: newSortOrder });
handleFilterChange(updatedFilters);
}, [page.filter.sortOrder, handleFilterChange, setFilter]);
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
const display = e.currentTarget.value as ListDisplayType;
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
if (display === ListDisplayType.TABLE) {
tableRef.current?.api.paginationSetPageSize(tableRef.current.props.infiniteInitialRowCount);
setPagination({ currentPage: 0 });
} else if (display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ currentPage: 0 });
}
},
[page, setPage, setPagination, tableRef],
);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = page.table.columns;
if (values.length === 0) {
return setTable({
columns: [],
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
return setTable({ columns: [...existingColumns, newColumn] });
}
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
return setTable({ columns: newColumns });
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ autoFit: e.currentTarget.checked });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const handleRowHeight = (e: number) => {
setTable({ rowHeight: e });
};
return (
<PageHeader>
<Flex
ref={cq.ref}
direction="row"
justify="space-between"
>
<Flex
align="center"
gap="md"
justify="center"
>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
rightIcon={<RiArrowDownSLine size={15} />}
size="xl"
sx={{ paddingLeft: 0, paddingRight: 0 }}
variant="subtle"
>
<TextTitle
fw="bold"
order={3}
>
Playlists
</TextTitle>
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
value={ListDisplayType.TABLE_PAGINATED}
onClick={handleSetViewType}
>
Table (paginated)
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>Item Size</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={page.table.rowHeight || 0}
label={null}
max={100}
min={25}
onChangeEnd={handleRowHeight}
/>
</DropdownMenu.Item>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={PLAYLIST_TABLE_COLUMNS}
defaultValue={page.table?.columns.map((column) => column.column)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={page.table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
key={`filter-${filter.name}`}
$isActive={filter.value === page.filter.sortBy}
value={filter.value}
onClick={handleSetSortBy}
>
{filter.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
fw="600"
variant="subtle"
onClick={handleToggleSortOrder}
>
{cq.isMd ? (
sortOrderLabel
) : (
<>
{page.filter.sortOrder === SortOrder.ASC ? (
<RiSortAsc size={15} />
) : (
<RiSortDesc size={15} />
)}
</>
)}
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item disabled>Play</DropdownMenu.Item>
<DropdownMenu.Item disabled>Add to queue (last)</DropdownMenu.Item>
<DropdownMenu.Item disabled>Add to queue (next)</DropdownMenu.Item>
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Flex>
</Flex>
</PageHeader>
);
};

View file

@ -0,0 +1 @@
export * from './queries/playlist-list-query';

View file

@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { PlaylistListQuery, RawPlaylistListResponse } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const usePlaylistList = (query: PlaylistListQuery, options?: QueryOptions) => {
const server = useCurrentServer();
return useQuery({
cacheTime: 1000 * 60 * 60,
enabled: !!server?.id,
queryFn: ({ signal }) => api.controller.getPlaylistList({ query, server, signal }),
queryKey: queryKeys.playlists.list(server?.id || '', query),
select: useCallback(
(data: RawPlaylistListResponse | undefined) => api.normalize.playlistList(data, server),
[server],
),
...options,
});
};

View file

@ -0,0 +1,18 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useRef } from 'react';
import { PlaylistListContent } from '/@/renderer/features/playlists/components/playlist-list-content';
import { PlaylistListHeader } from '/@/renderer/features/playlists/components/playlist-list-header';
import { AnimatedPage } from '/@/renderer/features/shared';
const PlaylistListRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
return (
<AnimatedPage>
<PlaylistListHeader tableRef={tableRef} />
<PlaylistListContent tableRef={tableRef} />
</AnimatedPage>
);
};
export default PlaylistListRoute;

View file

@ -22,6 +22,10 @@ const AlbumListRoute = lazy(() => import('/@/renderer/features/albums/routes/alb
const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route')); const SongListRoute = lazy(() => import('/@/renderer/features/songs/routes/song-list-route'));
const PlaylistListRoute = lazy(
() => import('/@/renderer/features/playlists/routes/playlist-list-route'),
);
const ActionRequiredRoute = lazy( const ActionRequiredRoute = lazy(
() => import('/@/renderer/features/action-required/routes/action-required-route'), () => import('/@/renderer/features/action-required/routes/action-required-route'),
); );
@ -64,6 +68,10 @@ export const AppRouter = () => {
element={<SongListRoute />} element={<SongListRoute />}
path={AppRoute.LIBRARY_SONGS} path={AppRoute.LIBRARY_SONGS}
/> />
<Route
element={<PlaylistListRoute />}
path={AppRoute.PLAYLISTS}
/>
<Route <Route
element={<AlbumArtistListRoute />} element={<AlbumArtistListRoute />}
path={AppRoute.LIBRARY_ALBUMARTISTS} path={AppRoute.LIBRARY_ALBUMARTISTS}

View file

@ -4,3 +4,4 @@ export * from './app.store';
export * from './album.store'; export * from './album.store';
export * from './song.store'; export * from './song.store';
export * from './album-artist.store'; export * from './album-artist.store';
export * from './playlist.store';

View file

@ -0,0 +1,125 @@
import merge from 'lodash/merge';
import create from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { PlaylistListArgs, PlaylistListSort, SortOrder } from '/@/renderer/api/types';
import { DataTableProps } from '/@/renderer/store/settings.store';
import { ListDisplayType, TableColumn, TablePagination } from '/@/renderer/types';
type TableProps = {
pagination: TablePagination;
scrollOffset: number;
} & DataTableProps;
type ListProps<T> = {
display: ListDisplayType;
filter: T;
table: TableProps;
};
export type PlaylistListFilter = Omit<PlaylistListArgs['query'], 'startIndex' | 'limit'>;
interface PlaylistState {
list: ListProps<PlaylistListFilter>;
}
export interface PlaylistSlice extends PlaylistState {
actions: {
setFilters: (data: Partial<PlaylistListFilter>) => PlaylistListFilter;
setStore: (data: Partial<PlaylistSlice>) => void;
setTable: (data: Partial<TableProps>) => void;
setTablePagination: (data: Partial<TableProps['pagination']>) => void;
};
}
export const usePlaylistStore = create<PlaylistSlice>()(
persist(
devtools(
immer((set, get) => ({
actions: {
setFilters: (data) => {
set((state) => {
state.list.filter = { ...state.list.filter, ...data };
});
return get().list.filter;
},
setStore: (data) => {
set({ ...get(), ...data });
},
setTable: (data) => {
set((state) => {
state.list.table = { ...state.list.table, ...data };
});
},
setTablePagination: (data) => {
set((state) => {
state.list.table.pagination = { ...state.list.table.pagination, ...data };
});
},
},
list: {
display: ListDisplayType.TABLE,
filter: {
musicFolderId: undefined,
sortBy: PlaylistListSort.NAME,
sortOrder: SortOrder.ASC,
},
table: {
autoFit: true,
columns: [
{
column: TableColumn.ROW_INDEX,
width: 50,
},
{
column: TableColumn.TITLE,
width: 500,
},
{
column: TableColumn.SONG_COUNT,
width: 100,
},
],
pagination: {
currentPage: 1,
itemsPerPage: 100,
totalItems: 1,
totalPages: 1,
},
rowHeight: 40,
scrollOffset: 0,
},
},
})),
{ name: 'store_playlist' },
),
{
merge: (persistedState, currentState) => {
return merge(currentState, persistedState);
},
name: 'store_playlist',
version: 1,
},
),
);
export const usePlaylistStoreActions = () => usePlaylistStore((state) => state.actions);
export const useSetPlaylistStore = () => usePlaylistStore((state) => state.actions.setStore);
export const useSetPlaylistFilters = () => usePlaylistStore((state) => state.actions.setFilters);
export const usePlaylistFilters = () => {
return usePlaylistStore((state) => [state.list.filter, state.actions.setFilters]);
};
export const usePlaylistListStore = () => usePlaylistStore((state) => state.list);
export const usePlaylistTablePagination = () =>
usePlaylistStore((state) => state.list.table.pagination);
export const useSetPlaylistTablePagination = () =>
usePlaylistStore((state) => state.actions.setTablePagination);
export const useSetPlaylistTable = () => usePlaylistStore((state) => state.actions.setTable);

View file

@ -146,6 +146,7 @@ export enum TableColumn {
FAVORITE = 'favorite', FAVORITE = 'favorite',
GENRE = 'genre', GENRE = 'genre',
LAST_PLAYED = 'lastPlayedAt', LAST_PLAYED = 'lastPlayedAt',
OWNER = 'username',
PATH = 'path', PATH = 'path',
PLAY_COUNT = 'playCount', PLAY_COUNT = 'playCount',
RATING = 'rating', RATING = 'rating',