diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index cbc59e6f..d6024d20 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -199,6 +199,10 @@ const getArtistList = async (args: ArtistListArgs) => { return (apiController('getArtistList') as ControllerEndpoint['getArtistList'])?.(args); }; +const getPlaylistList = async (args: PlaylistListArgs) => { + return (apiController('getPlaylistList') as ControllerEndpoint['getPlaylistList'])?.(args); +}; + export const controller = { getAlbumArtistList, getAlbumDetail, @@ -206,5 +210,6 @@ export const controller = { getArtistList, getGenreList, getMusicFolderList, + getPlaylistList, getSongList, }; diff --git a/src/renderer/api/jellyfin.api.ts b/src/renderer/api/jellyfin.api.ts index a6bb1122..74e058e3 100644 --- a/src/renderer/api/jellyfin.api.ts +++ b/src/renderer/api/jellyfin.api.ts @@ -22,6 +22,7 @@ import type { JFGenreListResponse, JFMusicFolderList, JFMusicFolderListResponse, + JFPlaylist, JFPlaylistDetail, JFPlaylistDetailResponse, JFPlaylistList, @@ -32,7 +33,7 @@ import type { JFSongListResponse, } from '/@/renderer/api/jellyfin.types'; import { JFCollectionType } from '/@/renderer/api/jellyfin.types'; -import type { +import { Album, AlbumArtist, AlbumArtistDetailArgs, @@ -48,13 +49,13 @@ import type { FavoriteResponse, GenreListArgs, MusicFolderListArgs, + Playlist, PlaylistDetailArgs, PlaylistListArgs, + playlistListSortMap, PlaylistSongListArgs, Song, SongListArgs, -} from '/@/renderer/api/types'; -import { songListSortMap, albumListSortMap, artistListSortMap, @@ -396,18 +397,20 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise => { - const { server, signal } = args; + const { query, server, signal } = args; const searchParams = { fields: 'ChildCount, Genres, DateCreated, ParentId, Overview', includeItemTypes: 'Playlist', + limit: query.limit, recursive: true, - sortBy: 'SortName', - sortOrder: 'Ascending', + sortBy: playlistListSortMap.jellyfin[query.sortBy], + sortOrder: sortOrderMap.jellyfin[query.sortOrder], + startIndex: query.startIndex, }; const data = await api - .get(`/users/${server?.userId}/items`, { + .get(`users/${server?.userId}/items`, { headers: { 'X-MediaBrowser-Token': server?.credential }, prefixUrl: server?.url, searchParams: parseSearchParams(searchParams), @@ -415,12 +418,12 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise }) .json(); - const playlistData = data.Items.filter((item) => item.MediaType === 'Audio'); + const playlistItems = data.Items.filter((item) => item.MediaType === 'Audio'); return { - Items: playlistData, - StartIndex: 0, - TotalRecordCount: playlistData.length, + items: playlistItems, + startIndex: 0, + 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) => { // return { // 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) => { // return { // albumCount: undefined, @@ -780,5 +779,6 @@ export const jellyfinApi = { export const jfNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, + playlist: normalizePlaylist, song: normalizeSong, }; diff --git a/src/renderer/api/jellyfin.types.ts b/src/renderer/api/jellyfin.types.ts index 8359add7..26a266bd 100644 --- a/src/renderer/api/jellyfin.types.ts +++ b/src/renderer/api/jellyfin.types.ts @@ -63,7 +63,19 @@ export interface JFPlaylistListResponse extends JFBasePaginatedResponse { 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; @@ -485,6 +497,7 @@ type JFBaseParams = { imageTypeLimit?: number; parentId?: string; recursive?: boolean; + searchTerm?: string; userId?: string; }; diff --git a/src/renderer/api/navidrome.api.ts b/src/renderer/api/navidrome.api.ts index aefc827c..e60c6c58 100644 --- a/src/renderer/api/navidrome.api.ts +++ b/src/renderer/api/navidrome.api.ts @@ -31,6 +31,7 @@ import type { NDSongList, NDSongListResponse, NDAlbumArtist, + NDPlaylist, } from '/@/renderer/api/navidrome.types'; import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types'; import type { @@ -51,6 +52,7 @@ import type { CreatePlaylistResponse, PlaylistSongListArgs, AlbumArtist, + Playlist, } from '/@/renderer/api/types'; import { playlistListSortMap, @@ -329,12 +331,13 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC, _sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME, _start: query.startIndex, + ...query.ndParams, }; const res = await api.get('api/playlist', { headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, prefixUrl: server?.url, - searchParams, + searchParams: parseSearchParams(searchParams), 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 = { authenticate, createPlaylist, @@ -540,5 +557,6 @@ export const navidromeApi = { export const ndNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, + playlist: normalizePlaylist, song: normalizeSong, }; diff --git a/src/renderer/api/navidrome.types.ts b/src/renderer/api/navidrome.types.ts index e88face1..204b914f 100644 --- a/src/renderer/api/navidrome.types.ts +++ b/src/renderer/api/navidrome.types.ts @@ -287,7 +287,7 @@ export type NDPlaylist = { ownerName: string; path: string; public: boolean; - rules: null; + rules: Record | null; size: number; songCount: number; sync: boolean; @@ -309,7 +309,7 @@ export type NDPlaylistListResponse = NDPlaylist[]; export enum NDPlaylistListSort { DURATION = 'duration', NAME = 'name', - OWNER = 'owner', + OWNER = 'ownerName', PUBLIC = 'public', SONG_COUNT = 'songCount', UPDATED_AT = 'updatedAt', diff --git a/src/renderer/api/normalize.ts b/src/renderer/api/normalize.ts index 7736916d..cb7859bd 100644 --- a/src/renderer/api/normalize.ts +++ b/src/renderer/api/normalize.ts @@ -4,10 +4,17 @@ import type { JFAlbumArtist, JFGenreList, JFMusicFolderList, + JFPlaylist, JFSong, } from '/@/renderer/api/jellyfin.types'; 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 type { Album, @@ -16,6 +23,7 @@ import type { RawAlbumListResponse, RawGenreListResponse, RawMusicFolderListResponse, + RawPlaylistListResponse, RawSongListResponse, } from '/@/renderer/api/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 = { albumArtistList, albumDetail, albumList, genreList, musicFolderList, + playlistList, songList, }; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 75a6f927..25d1a025 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -4,6 +4,7 @@ import type { AlbumDetailQuery, AlbumArtistListQuery, ArtistListQuery, + PlaylistListQuery, } from './types'; export const queryKeys = { @@ -34,6 +35,11 @@ export const queryKeys = { musicFolders: { 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: { root: (serverId: string) => [serverId] as const, }, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 779261d1..babe7f8e 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -14,6 +14,7 @@ import { JFPlaylistList, JFPlaylistDetail, JFMusicFolderList, + JFPlaylistListSort, } from '/@/renderer/api/jellyfin.types'; import { NDSortOrder, @@ -243,14 +244,15 @@ export type MusicFolder = { }; export type Playlist = { - duration?: number; + duration: number | null; id: string; name: string; - public?: boolean; - size?: number; - songCount?: number; - userId: string; - username: string; + public: boolean | null; + rules?: Record | null; + size: number | null; + songCount: number | null; + userId: string | null; + username: string | null; }; export type GenresResponse = Genre[]; @@ -756,11 +758,21 @@ export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefine export type PlaylistListResponse = BasePaginatedResponse; -export type PlaylistListSort = NDPlaylistListSort; +export enum PlaylistListSort { + DURATION = 'duration', + NAME = 'name', + OWNER = 'owner', + PUBLIC = 'public', + SONG_COUNT = 'songCount', + UPDATED_AT = 'updatedAt', +} export type PlaylistListQuery = { limit?: number; - musicFolderId?: string; + ndParams?: { + owner_id?: string; + }; + searchTerm?: string; sortBy: PlaylistListSort; sortOrder: SortOrder; startIndex: number; @@ -769,18 +781,18 @@ export type PlaylistListQuery = { export type PlaylistListArgs = { query: PlaylistListQuery } & BaseEndpointArgs; type PlaylistListSortMap = { - jellyfin: Record; + jellyfin: Record; navidrome: Record; subsonic: Record; }; export const playlistListSortMap: PlaylistListSortMap = { jellyfin: { - duration: undefined, - name: undefined, + duration: JFPlaylistListSort.DURATION, + name: JFPlaylistListSort.NAME, owner: undefined, public: undefined, - songCount: undefined, + songCount: JFPlaylistListSort.SONG_COUNT, updatedAt: undefined, }, navidrome: { diff --git a/src/renderer/components/virtual-table/table-config-dropdown.tsx b/src/renderer/components/virtual-table/table-config-dropdown.tsx index 224605ee..139aa46e 100644 --- a/src/renderer/components/virtual-table/table-config-dropdown.tsx +++ b/src/renderer/components/virtual-table/table-config-dropdown.tsx @@ -62,6 +62,16 @@ export const ALBUMARTIST_TABLE_COLUMNS = [ { 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 { type: TableType; } diff --git a/src/renderer/features/context-menu/context-menu-items.tsx b/src/renderer/features/context-menu/context-menu-items.tsx index a0eefa0b..d3fc4cc2 100644 --- a/src/renderer/features/context-menu/context-menu-items.tsx +++ b/src/renderer/features/context-menu/context-menu-items.tsx @@ -29,3 +29,10 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { disabled: true, id: 'removeFromFavorites' }, { disabled: true, id: 'setRating' }, ]; + +export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ + { id: 'play' }, + { id: 'playLast' }, + { divider: true, id: 'playNext' }, + { disabled: true, id: 'deletePlaylist' }, +]; diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index 0ac9d6d9..14875395 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -106,6 +106,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { const contextMenuItems = { addToFavorites: { id: 'addToFavorites', label: 'Add to favorites', onClick: () => {} }, addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: () => {} }, + deletePlaylist: { id: 'deletePlaylist', label: 'Delete playlist', onClick: () => {} }, play: { id: 'play', label: 'Play', diff --git a/src/renderer/features/context-menu/events.ts b/src/renderer/features/context-menu/events.ts index 460a2807..01929a78 100644 --- a/src/renderer/features/context-menu/events.ts +++ b/src/renderer/features/context-menu/events.ts @@ -21,7 +21,8 @@ export type ContextMenuItem = | 'addToPlaylist' | 'addToFavorites' | 'removeFromFavorites' - | 'setRating'; + | 'setRating' + | 'deletePlaylist'; export type SetContextMenuItems = { disabled?: boolean; diff --git a/src/renderer/features/playlists/components/playlist-list-content.tsx b/src/renderer/features/playlists/components/playlist-list-content.tsx new file mode 100644 index 00000000..4cbad0ae --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-list-content.tsx @@ -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; +} + +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 ( + + + 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} + /> + + + {page.display === ListDisplayType.TABLE_PAGINATED && ( + + )} + + + ); +}; diff --git a/src/renderer/features/playlists/components/playlist-list-header.tsx b/src/renderer/features/playlists/components/playlist-list-header.tsx new file mode 100644 index 00000000..6bbf1618 --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-list-header.tsx @@ -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; +} + +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) => { + 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) => { + 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) => { + setTable({ autoFit: e.currentTarget.checked }); + + if (e.currentTarget.checked) { + tableRef.current?.api.sizeColumnsToFit(); + } + }; + + const handleRowHeight = (e: number) => { + setTable({ rowHeight: e }); + }; + + return ( + + + + + + + + + Display type + + Table + + + Table (paginated) + + + Item Size + + + + Table Columns + + + column.column)} + width={300} + onChange={handleTableColumns} + /> + + Auto Fit Columns + + + + + + + + + + + + {FILTERS[server?.type as keyof typeof FILTERS].map((filter) => ( + + {filter.name} + + ))} + + + + + + + + + Play + Add to queue (last) + Add to queue (next) + Add to playlist + + + + + + ); +}; diff --git a/src/renderer/features/playlists/index.ts b/src/renderer/features/playlists/index.ts new file mode 100644 index 00000000..b7a85f71 --- /dev/null +++ b/src/renderer/features/playlists/index.ts @@ -0,0 +1 @@ +export * from './queries/playlist-list-query'; diff --git a/src/renderer/features/playlists/queries/playlist-list-query.ts b/src/renderer/features/playlists/queries/playlist-list-query.ts new file mode 100644 index 00000000..ae353629 --- /dev/null +++ b/src/renderer/features/playlists/queries/playlist-list-query.ts @@ -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, + }); +}; diff --git a/src/renderer/features/playlists/routes/playlist-list-route.tsx b/src/renderer/features/playlists/routes/playlist-list-route.tsx new file mode 100644 index 00000000..ed0369fb --- /dev/null +++ b/src/renderer/features/playlists/routes/playlist-list-route.tsx @@ -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(null); + + return ( + + + + + ); +}; + +export default PlaylistListRoute; diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index bb8f04ec..30370f6f 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -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 PlaylistListRoute = lazy( + () => import('/@/renderer/features/playlists/routes/playlist-list-route'), +); + const ActionRequiredRoute = lazy( () => import('/@/renderer/features/action-required/routes/action-required-route'), ); @@ -64,6 +68,10 @@ export const AppRouter = () => { element={} path={AppRoute.LIBRARY_SONGS} /> + } + path={AppRoute.PLAYLISTS} + /> } path={AppRoute.LIBRARY_ALBUMARTISTS} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index bc42b824..cdd75ea2 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -4,3 +4,4 @@ export * from './app.store'; export * from './album.store'; export * from './song.store'; export * from './album-artist.store'; +export * from './playlist.store'; diff --git a/src/renderer/store/playlist.store.ts b/src/renderer/store/playlist.store.ts new file mode 100644 index 00000000..c2916319 --- /dev/null +++ b/src/renderer/store/playlist.store.ts @@ -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 = { + display: ListDisplayType; + filter: T; + table: TableProps; +}; + +export type PlaylistListFilter = Omit; + +interface PlaylistState { + list: ListProps; +} + +export interface PlaylistSlice extends PlaylistState { + actions: { + setFilters: (data: Partial) => PlaylistListFilter; + setStore: (data: Partial) => void; + setTable: (data: Partial) => void; + setTablePagination: (data: Partial) => void; + }; +} + +export const usePlaylistStore = create()( + 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); diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 83fda025..e8b1be20 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -146,6 +146,7 @@ export enum TableColumn { FAVORITE = 'favorite', GENRE = 'genre', LAST_PLAYED = 'lastPlayedAt', + OWNER = 'username', PATH = 'path', PLAY_COUNT = 'playCount', RATING = 'rating',