From 24af17b8feaa28455d7bfbbd301e5aea1ea0c356 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 30 Dec 2022 21:04:06 -0800 Subject: [PATCH] Add album artist list route --- src/renderer/api/controller.ts | 10 + src/renderer/api/jellyfin.api.ts | 62 ++- src/renderer/api/jellyfin.types.ts | 14 +- src/renderer/api/navidrome.api.ts | 44 +- src/renderer/api/navidrome.types.ts | 7 +- src/renderer/api/normalize.ts | 30 +- src/renderer/api/query-keys.ts | 28 +- src/renderer/api/subsonic.api.ts | 6 +- src/renderer/api/subsonic.types.ts | 6 +- src/renderer/api/types.ts | 19 +- .../virtual-table/table-config-dropdown.tsx | 13 + .../components/album-artist-list-content.tsx | 371 +++++++++++++ .../components/album-artist-list-header.tsx | 502 ++++++++++++++++++ .../queries/album-artist-list-query.ts | 22 + .../routes/album-artist-list-route.tsx | 28 + .../features/sidebar/components/sidebar.tsx | 7 +- src/renderer/router/app-router.tsx | 5 +- src/renderer/store/album-artist.store.ts | 126 +++++ src/renderer/store/index.ts | 1 + 19 files changed, 1269 insertions(+), 32 deletions(-) create mode 100644 src/renderer/features/artists/components/album-artist-list-content.tsx create mode 100644 src/renderer/features/artists/components/album-artist-list-header.tsx create mode 100644 src/renderer/features/artists/queries/album-artist-list-query.ts create mode 100644 src/renderer/features/artists/routes/album-artist-list-route.tsx create mode 100644 src/renderer/store/album-artist.store.ts diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 025454c0..cbc59e6f 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -191,9 +191,19 @@ const getGenreList = async (args: GenreListArgs) => { return (apiController('getGenreList') as ControllerEndpoint['getGenreList'])?.(args); }; +const getAlbumArtistList = async (args: AlbumArtistListArgs) => { + return (apiController('getAlbumArtistList') as ControllerEndpoint['getAlbumArtistList'])?.(args); +}; + +const getArtistList = async (args: ArtistListArgs) => { + return (apiController('getArtistList') as ControllerEndpoint['getArtistList'])?.(args); +}; + export const controller = { + getAlbumArtistList, getAlbumDetail, getAlbumList, + getArtistList, getGenreList, getMusicFolderList, getSongList, diff --git a/src/renderer/api/jellyfin.api.ts b/src/renderer/api/jellyfin.api.ts index d547e1e2..8a6e3180 100644 --- a/src/renderer/api/jellyfin.api.ts +++ b/src/renderer/api/jellyfin.api.ts @@ -2,6 +2,7 @@ import ky from 'ky'; import { nanoid } from 'nanoid/non-secure'; import type { JFAlbum, + JFAlbumArtist, JFAlbumArtistDetail, JFAlbumArtistDetailResponse, JFAlbumArtistList, @@ -33,6 +34,7 @@ import type { import { JFCollectionType } from '/@/renderer/api/jellyfin.types'; import type { Album, + AlbumArtist, AlbumArtistDetailArgs, AlbumArtistListArgs, AlbumDetailArgs, @@ -138,7 +140,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise(); - return data; + return { + items: data.Items, + startIndex: query.startIndex, + totalRecordCount: data.TotalRecordCount, + }; }; const getArtistList = async (args: ArtistListArgs): Promise => { @@ -303,9 +312,11 @@ const getSongList = async (args: SongListArgs): Promise => { const yearsFilter = yearsGroup.length ? getCommaDelimitedString(yearsGroup) : undefined; const albumIdsFilter = query.albumIds ? getCommaDelimitedString(query.albumIds) : undefined; + const artistIdsFilter = query.artistIds ? getCommaDelimitedString(query.artistIds) : undefined; const searchParams: JFSongListParams & { maxYear?: number; minYear?: number } = { albumIds: albumIdsFilter, + artistIds: artistIdsFilter, fields: 'Genres, DateCreated, MediaSources, ParentId', includeItemTypes: 'Audio', limit: query.limit, @@ -496,6 +507,26 @@ const getStreamUrl = (args: { ); }; +const getAlbumArtistCoverArtUrl = (args: { + baseUrl: string; + item: JFAlbumArtist; + size: number; +}) => { + const size = args.size ? args.size : 300; + + if (!args.item.ImageTags?.Primary) { + return null; + } + + return ( + `${args.baseUrl}/Items` + + `/${args.item.Id}` + + '/Images/Primary' + + `?width=${size}&height=${size}` + + '&quality=96' + ); +}; + const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => { const size = args.size ? args.size : 300; @@ -628,6 +659,32 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe }; }; +const normalizeAlbumArtist = ( + item: JFAlbumArtist, + server: ServerListItem, + imageSize?: number, +): AlbumArtist => { + return { + albumCount: null, + backgroundImageUrl: null, + biography: item.Overview || null, + duration: item.RunTimeTicks / 10000000, + genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), + id: item.Id, + imageUrl: getAlbumArtistCoverArtUrl({ + baseUrl: server.url, + item, + size: imageSize || 300, + }), + isFavorite: item.UserData.IsFavorite || false, + lastPlayedAt: null, + name: item.Name, + playCount: item.UserData.PlayCount, + rating: null, + songCount: null, + }; +}; + // const normalizeArtist = (item: any) => { // return { // album: (item.album || []).map((entry: any) => normalizeAlbum(entry)), @@ -717,5 +774,6 @@ export const jellyfinApi = { export const jfNormalize = { album: normalizeAlbum, + albumArtist: normalizeAlbumArtist, song: normalizeSong, }; diff --git a/src/renderer/api/jellyfin.types.ts b/src/renderer/api/jellyfin.types.ts index 0741de45..8359add7 100644 --- a/src/renderer/api/jellyfin.types.ts +++ b/src/renderer/api/jellyfin.types.ts @@ -23,7 +23,11 @@ export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse { Items: JFAlbumArtist[]; } -export type JFAlbumArtistList = JFAlbumArtistListResponse; +export type JFAlbumArtistList = { + items: JFAlbumArtist[]; + startIndex: number; + totalRecordCount: number; +}; export interface JFArtistListResponse extends JFBasePaginatedResponse { Items: JFAlbumArtist[]; @@ -149,6 +153,13 @@ export type JFAlbumArtist = { RunTimeTicks: number; ServerId: string; Type: string; + UserData: { + IsFavorite: boolean; + Key: string; + PlayCount: number; + PlaybackPositionTicks: number; + Played: boolean; + }; }; export type JFArtist = { @@ -474,6 +485,7 @@ type JFBaseParams = { imageTypeLimit?: number; parentId?: string; recursive?: boolean; + userId?: string; }; type JFPaginationParams = { diff --git a/src/renderer/api/navidrome.api.ts b/src/renderer/api/navidrome.api.ts index c48c7fe5..8a40a62c 100644 --- a/src/renderer/api/navidrome.api.ts +++ b/src/renderer/api/navidrome.api.ts @@ -30,6 +30,7 @@ import type { NDPlaylistDetailResponse, NDSongList, NDSongListResponse, + NDAlbumArtist, } from '/@/renderer/api/navidrome.types'; import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types'; import type { @@ -49,6 +50,7 @@ import type { PlaylistDetailArgs, CreatePlaylistResponse, PlaylistSongListArgs, + AlbumArtist, } from '/@/renderer/api/types'; import { playlistListSortMap, @@ -160,15 +162,21 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise(); + const res = await api.get('api/artist', { + headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, + prefixUrl: server?.url, + searchParams: parseSearchParams(searchParams), + signal, + }); - return data; + const data = await res.json(); + const itemCount = res.headers.get('x-total-count'); + + return { + items: data, + startIndex: query.startIndex, + totalRecordCount: Number(itemCount), + }; }; const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { @@ -238,6 +246,7 @@ const getSongList = async (args: SongListArgs): Promise => { _sort: songListSortMap.navidrome[query.sortBy], _start: query.startIndex, album_id: query.albumIds, + artist_id: query.artistIds, title: query.searchTerm, ...query.ndParams, }; @@ -487,6 +496,24 @@ const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: numbe }; }; +const normalizeAlbumArtist = (item: NDAlbumArtist): AlbumArtist => { + return { + albumCount: item.albumCount, + backgroundImageUrl: null, + biography: item.biography, + duration: null, + genres: item.genres, + id: item.id, + imageUrl: item.largeImageUrl, + isFavorite: item.starred, + lastPlayedAt: item.playDate ? item.playDate.split('T')[0] : null, + name: item.name, + playCount: item.playCount, + rating: item.rating, + songCount: item.songCount, + }; +}; + export const navidromeApi = { authenticate, createPlaylist, @@ -505,5 +532,6 @@ export const navidromeApi = { export const ndNormalize = { album: normalizeAlbum, + albumArtist: normalizeAlbumArtist, song: normalizeSong, }; diff --git a/src/renderer/api/navidrome.types.ts b/src/renderer/api/navidrome.types.ts index ef5e5e01..e88face1 100644 --- a/src/renderer/api/navidrome.types.ts +++ b/src/renderer/api/navidrome.types.ts @@ -118,7 +118,11 @@ export type NDAlbumArtist = { export type NDAuthenticationResponse = NDAuthenticate; -export type NDAlbumArtistList = NDAlbumArtist[]; +export type NDAlbumArtistList = { + items: NDAlbumArtist[]; + startIndex: number; + totalRecordCount: number; +}; export type NDAlbumArtistDetail = NDAlbumArtist; @@ -230,6 +234,7 @@ export enum NDSongListSort { export type NDSongListParams = { _sort?: NDSongListSort; album_id?: string[]; + artist_id?: string[]; genre_id?: string; starred?: boolean; } & NDPagination & diff --git a/src/renderer/api/normalize.ts b/src/renderer/api/normalize.ts index 0a143c3c..7736916d 100644 --- a/src/renderer/api/normalize.ts +++ b/src/renderer/api/normalize.ts @@ -1,15 +1,17 @@ import { jfNormalize } from '/@/renderer/api/jellyfin.api'; import type { JFAlbum, + JFAlbumArtist, JFGenreList, JFMusicFolderList, JFSong, } from '/@/renderer/api/jellyfin.types'; import { ndNormalize } from '/@/renderer/api/navidrome.api'; -import type { NDAlbum, NDGenreList, NDSong } from '/@/renderer/api/navidrome.types'; +import type { NDAlbum, NDAlbumArtist, NDGenreList, NDSong } from '/@/renderer/api/navidrome.types'; import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types'; import type { Album, + RawAlbumArtistListResponse, RawAlbumDetailResponse, RawAlbumListResponse, RawGenreListResponse, @@ -136,7 +138,33 @@ const genreList = (data: RawGenreListResponse | undefined, server: ServerListIte return genres; }; +const albumArtistList = ( + data: RawAlbumArtistListResponse | undefined, + server: ServerListItem | null, +) => { + let albumArtists; + switch (server?.type) { + case 'jellyfin': + albumArtists = data?.items.map((item) => + jfNormalize.albumArtist(item as JFAlbumArtist, server), + ); + break; + case 'navidrome': + albumArtists = data?.items.map((item) => ndNormalize.albumArtist(item as NDAlbumArtist)); + break; + case 'subsonic': + break; + } + + return { + items: albumArtists, + startIndex: data?.startIndex, + totalRecordCount: data?.totalRecordCount, + }; +}; + export const normalize = { + albumArtistList, albumDetail, albumList, genreList, diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index e46cfeea..75a6f927 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -1,15 +1,32 @@ -import type { AlbumListQuery, SongListQuery, AlbumDetailQuery } from './types'; +import type { + AlbumListQuery, + SongListQuery, + AlbumDetailQuery, + AlbumArtistListQuery, + ArtistListQuery, +} from './types'; export const queryKeys = { + albumArtists: { + list: (serverId: string, query?: AlbumArtistListQuery) => + [serverId, 'albumArtists', 'list', query] as const, + root: (serverId: string) => [serverId, 'albumArtists'] as const, + }, albums: { - detail: (serverId: string, query: AlbumDetailQuery) => + detail: (serverId: string, query?: AlbumDetailQuery) => [serverId, 'albums', 'detail', query] as const, - list: (serverId: string, query: AlbumListQuery) => [serverId, 'albums', 'list', query] as const, - root: ['albums'], + list: (serverId: string, query?: AlbumListQuery) => + [serverId, 'albums', 'list', query] as const, + root: (serverId: string) => [serverId, 'albums'], serverRoot: (serverId: string) => [serverId, 'albums'], songs: (serverId: string, query: SongListQuery) => [serverId, 'albums', 'songs', query] as const, }, + artists: { + list: (serverId: string, query?: ArtistListQuery) => + [serverId, 'artists', 'list', query] as const, + root: (serverId: string) => [serverId, 'artists'] as const, + }, genres: { list: (serverId: string) => [serverId, 'genres', 'list'] as const, root: (serverId: string) => [serverId, 'genres'] as const, @@ -21,6 +38,7 @@ export const queryKeys = { root: (serverId: string) => [serverId] as const, }, songs: { - list: (serverId: string, query: SongListQuery) => [serverId, 'songs', 'list', query] as const, + list: (serverId: string, query?: SongListQuery) => [serverId, 'songs', 'list', query] as const, + root: (serverId: string) => [serverId, 'songs'] as const, }, }; diff --git a/src/renderer/api/subsonic.api.ts b/src/renderer/api/subsonic.api.ts index 31ff9c22..19710c47 100644 --- a/src/renderer/api/subsonic.api.ts +++ b/src/renderer/api/subsonic.api.ts @@ -177,7 +177,11 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise index.artist); - return artists; + return { + items: artists, + startIndex: query.startIndex, + totalRecordCount: null, + }; }; const getGenreList = async (args: GenreListArgs): Promise => { diff --git a/src/renderer/api/subsonic.types.ts b/src/renderer/api/subsonic.types.ts index 4124db68..867cee19 100644 --- a/src/renderer/api/subsonic.types.ts +++ b/src/renderer/api/subsonic.types.ts @@ -33,7 +33,11 @@ export type SSAlbumArtistDetailResponse = { }; }; -export type SSAlbumArtistList = SSAlbumArtistListEntry[]; +export type SSAlbumArtistList = { + items: SSAlbumArtistListEntry[]; + startIndex: number; + totalRecordCount: number | null; +}; export type SSAlbumArtistListResponse = { artists: { diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index e81b8f5b..779261d1 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -202,13 +202,19 @@ export type Song = { }; export type AlbumArtist = { + albumCount: number | null; + backgroundImageUrl: string | null; biography: string | null; - createdAt: string; + duration: number | null; + genres: Genre[]; id: string; + imageUrl: string | null; + isFavorite: boolean; + lastPlayedAt: string | null; name: string; - remoteCreatedAt: string | null; - serverFolderId: string; - updatedAt: string; + playCount: number | null; + rating: number | null; + songCount: number | null; }; export type RelatedAlbumArtist = { @@ -418,6 +424,7 @@ export enum SongListSort { export type SongListQuery = { albumIds?: string[]; + artistIds?: string[]; jfParams?: { filters?: string; genreIds?: string; @@ -432,7 +439,8 @@ export type SongListQuery = { limit?: number; musicFolderId?: string; ndParams?: { - artist_id?: string; + album_id?: string[]; + artist_id?: string[]; compilation?: boolean; genre_id?: string; has_rating?: boolean; @@ -554,6 +562,7 @@ export type AlbumArtistListQuery = { name?: string; starred?: boolean; }; + searchTerm?: string; sortBy: AlbumArtistListSort; sortOrder: SortOrder; startIndex: number; diff --git a/src/renderer/components/virtual-table/table-config-dropdown.tsx b/src/renderer/components/virtual-table/table-config-dropdown.tsx index b67049fc..224605ee 100644 --- a/src/renderer/components/virtual-table/table-config-dropdown.tsx +++ b/src/renderer/components/virtual-table/table-config-dropdown.tsx @@ -49,6 +49,19 @@ export const ALBUM_TABLE_COLUMNS = [ { label: 'Plays', value: TableColumn.PLAY_COUNT }, ]; +export const ALBUMARTIST_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: 'Biography', value: TableColumn.BIOGRAPHY }, + { label: 'Genre', value: TableColumn.GENRE }, + { label: 'Last Played', value: TableColumn.LAST_PLAYED }, + { label: 'Plays', value: TableColumn.PLAY_COUNT }, + { label: 'Album Count', value: TableColumn.ALBUM_COUNT }, + { label: 'Song Count', value: TableColumn.SONG_COUNT }, +]; + interface TableConfigDropdownProps { type: TableType; } diff --git a/src/renderer/features/artists/components/album-artist-list-content.tsx b/src/renderer/features/artists/components/album-artist-list-content.tsx new file mode 100644 index 00000000..2ff4e156 --- /dev/null +++ b/src/renderer/features/artists/components/album-artist-list-content.tsx @@ -0,0 +1,371 @@ +import { + ALBUMARTIST_CARD_ROWS, + getColumnDefs, + TablePagination, + VirtualGridAutoSizerContainer, + VirtualInfiniteGrid, + VirtualInfiniteGridRef, + VirtualTable, +} from '/@/renderer/components'; +import { AppRoute } from '/@/renderer/router/routes'; +import { ListDisplayType, CardRow, LibraryItem } from '/@/renderer/types'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { MutableRefObject, useCallback, useMemo } from 'react'; +import { ListOnScrollProps } from 'react-window'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { AlbumArtist, AlbumArtistListSort } from '/@/renderer/api/types'; +import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add'; +import { useQueryClient } from '@tanstack/react-query'; +import { + useCurrentServer, + useAlbumArtistListStore, + useAlbumArtistTablePagination, + useSetAlbumArtistStore, + useSetAlbumArtistTable, + useSetAlbumArtistTablePagination, +} from '/@/renderer/store'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { + BodyScrollEvent, + CellContextMenuEvent, + ColDef, + GridReadyEvent, + IDatasource, + PaginationChangedEvent, + RowDoubleClickedEvent, +} from '@ag-grid-community/core'; +import { AnimatePresence } from 'framer-motion'; +import debounce from 'lodash/debounce'; +import { openContextMenu } from '/@/renderer/features/context-menu'; +import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; +import sortBy from 'lodash/sortBy'; +import { generatePath, useNavigate } from 'react-router'; +import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; + +interface AlbumArtistListContentProps { + gridRef: MutableRefObject; + tableRef: MutableRefObject; +} + +export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListContentProps) => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const server = useCurrentServer(); + const page = useAlbumArtistListStore(); + const setPage = useSetAlbumArtistStore(); + const handlePlayQueueAdd = useHandlePlayQueueAdd(); + + const pagination = useAlbumArtistTablePagination(); + const setPagination = useSetAlbumArtistTablePagination(); + const setTable = useSetAlbumArtistTable(); + + const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; + + const checkAlbumArtistList = useAlbumArtistList( + { + limit: 1, + startIndex: 0, + ...page.filter, + }, + { + cacheTime: Infinity, + staleTime: 60 * 1000 * 5, + }, + ); + + const columnDefs: ColDef[] = useMemo( + () => getColumnDefs(page.table.columns), + [page.table.columns], + ); + + const defaultColumnDefs: ColDef = useMemo(() => { + return { + lockPinned: true, + lockVisible: true, + resizable: true, + }; + }, []); + + const onTableReady = useCallback( + (params: GridReadyEvent) => { + const dataSource: IDatasource = { + getRows: async (params) => { + const limit = params.endRow - params.startRow; + const startIndex = params.startRow; + + const queryKey = queryKeys.albumArtists.list(server?.id || '', { + limit, + startIndex, + ...page.filter, + }); + + const albumArtistsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) => + api.controller.getAlbumArtistList({ + query: { + limit, + startIndex, + ...page.filter, + }, + server, + signal, + }), + ); + + const albums = api.normalize.albumArtistList(albumArtistsRes, server); + params.successCallback( + albums?.items || [], + albumArtistsRes?.totalRecordCount || undefined, + ); + }, + rowCount: undefined, + }; + params.api.setDatasource(dataSource); + // params.api.ensureIndexVisible(page.table.scrollOffset || 0, 'top'); + }, + [page.filter, queryClient, server], + ); + + const onTablePaginationChanged = 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 handleTableSizeChange = () => { + if (page.table.autoFit) { + tableRef?.current?.api.sizeColumnsToFit(); + } + }; + + const handleTableColumnChange = 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.getColDef().width, + }), + }); + } + } + + setTable({ columns: updatedColumns }); + }, [page.table.autoFit, page.table.columns, setTable, tableRef]); + + const debouncedTableColumnChange = debounce(handleTableColumnChange, 200); + + const handleTableScroll = (e: BodyScrollEvent) => { + const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0)); + setTable({ scrollOffset }); + }; + + const fetch = useCallback( + async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => { + const queryKey = queryKeys.albumArtists.list(server?.id || '', { + limit, + startIndex, + ...page.filter, + }); + + const albumArtistsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) => + api.controller.getAlbumArtistList({ + query: { + limit, + startIndex, + ...page.filter, + }, + server, + signal, + }), + ); + + return api.normalize.albumArtistList(albumArtistsRes, server); + }, + [page.filter, queryClient, server], + ); + + const handleGridScroll = useCallback( + (e: ListOnScrollProps) => { + setPage({ + list: { + ...page, + grid: { + ...page.grid, + scrollOffset: e.scrollOffset, + }, + }, + }); + }, + [page, setPage], + ); + + const cardRows = useMemo(() => { + const rows: CardRow[] = [ALBUMARTIST_CARD_ROWS.name]; + + switch (page.filter.sortBy) { + case AlbumArtistListSort.DURATION: + rows.push(ALBUMARTIST_CARD_ROWS.duration); + break; + case AlbumArtistListSort.FAVORITED: + break; + case AlbumArtistListSort.NAME: + break; + case AlbumArtistListSort.ALBUM_COUNT: + rows.push(ALBUMARTIST_CARD_ROWS.albumCount); + break; + case AlbumArtistListSort.PLAY_COUNT: + rows.push(ALBUMARTIST_CARD_ROWS.playCount); + break; + case AlbumArtistListSort.RANDOM: + break; + case AlbumArtistListSort.RATING: + rows.push(ALBUMARTIST_CARD_ROWS.rating); + break; + case AlbumArtistListSort.RECENTLY_ADDED: + break; + case AlbumArtistListSort.SONG_COUNT: + rows.push(ALBUMARTIST_CARD_ROWS.songCount); + break; + case AlbumArtistListSort.RELEASE_DATE: + break; + } + + return rows; + }, [page.filter.sortBy]); + + 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: ALBUM_CONTEXT_MENU_ITEMS, + type: LibraryItem.ALBUM, + xPos: clickEvent.clientX, + yPos: clickEvent.clientY, + }); + }; + + const handleRowDoubleClick = (e: RowDoubleClickedEvent) => { + navigate(generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, { albumArtistId: e.data.id })); + }; + + return ( + <> + + {page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER ? ( + + {({ height, width }) => ( + + )} + + ) : ( + data.data.id} + infiniteInitialRowCount={checkAlbumArtistList.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={handleTableScroll} + onCellContextMenu={handleContextMenu} + onColumnMoved={handleTableColumnChange} + onColumnResized={debouncedTableColumnChange} + onGridReady={onTableReady} + onGridSizeChanged={handleTableSizeChange} + onPaginationChanged={onTablePaginationChanged} + onRowDoubleClicked={handleRowDoubleClick} + /> + )} + + {isPaginationEnabled && ( + + {page.display === ListDisplayType.TABLE_PAGINATED && ( + + )} + + )} + + ); +}; diff --git a/src/renderer/features/artists/components/album-artist-list-header.tsx b/src/renderer/features/artists/components/album-artist-list-header.tsx new file mode 100644 index 00000000..cd4486c1 --- /dev/null +++ b/src/renderer/features/artists/components/album-artist-list-header.tsx @@ -0,0 +1,502 @@ +import type { ChangeEvent, MouseEvent, MutableRefObject } from 'react'; +import { useCallback } from 'react'; +import { 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 { useQueryClient } from '@tanstack/react-query'; +import debounce from 'lodash/debounce'; +import { + RiArrowDownSLine, + RiFilter3Line, + RiFolder2Line, + RiMoreFill, + RiSortAsc, + RiSortDesc, +} from 'react-icons/ri'; +import styled from 'styled-components'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { AlbumArtistListSort, ServerType, SortOrder } from '/@/renderer/api/types'; +import { + ALBUMARTIST_TABLE_COLUMNS, + Button, + DropdownMenu, + MultiSelect, + PageHeader, + Popover, + SearchInput, + Slider, + Switch, + Text, + TextTitle, + VirtualInfiniteGridRef, +} from '/@/renderer/components'; +import { useMusicFolders } from '/@/renderer/features/shared'; +import { useContainerQuery } from '/@/renderer/hooks'; +import { + AlbumArtistListFilter, + useAlbumArtistListStore, + useCurrentServer, + useSetAlbumArtistFilters, + useSetAlbumArtistStore, + useSetAlbumArtistTable, + useSetAlbumArtistTablePagination, +} from '/@/renderer/store'; +import { ListDisplayType, TableColumn } from '/@/renderer/types'; + +const FILTERS = { + jellyfin: [ + { defaultOrder: SortOrder.ASC, name: 'Album', value: AlbumArtistListSort.ALBUM }, + { defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumArtistListSort.DURATION }, + { defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumArtistListSort.NAME }, + { defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumArtistListSort.RANDOM }, + { + defaultOrder: SortOrder.DESC, + name: 'Recently Added', + value: AlbumArtistListSort.RECENTLY_ADDED, + }, + // { defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumArtistListSort.RELEASE_DATE }, + ], + navidrome: [ + { defaultOrder: SortOrder.DESC, name: 'Album Count', value: AlbumArtistListSort.ALBUM_COUNT }, + { defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumArtistListSort.FAVORITED }, + { defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumArtistListSort.PLAY_COUNT }, + { defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumArtistListSort.NAME }, + { defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumArtistListSort.RATING }, + { defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumArtistListSort.SONG_COUNT }, + ], +}; + +const ORDER = [ + { name: 'Ascending', value: SortOrder.ASC }, + { name: 'Descending', value: SortOrder.DESC }, +]; + +const HeaderItems = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; +`; + +interface AlbumArtistListHeaderProps { + gridRef: MutableRefObject; + tableRef: MutableRefObject; +} + +export const AlbumArtistListHeader = ({ gridRef, tableRef }: AlbumArtistListHeaderProps) => { + const queryClient = useQueryClient(); + const server = useCurrentServer(); + const setPage = useSetAlbumArtistStore(); + const setFilter = useSetAlbumArtistFilters(); + const page = useAlbumArtistListStore(); + const filters = page.filter; + const cq = useContainerQuery(); + + const musicFoldersQuery = useMusicFolders(); + + const setPagination = useSetAlbumArtistTablePagination(); + const setTable = useSetAlbumArtistTable(); + + const sortByLabel = + (server?.type && + FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) || + 'Unknown'; + + const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown'; + + const handleItemSize = (e: number) => { + if ( + page.display === ListDisplayType.TABLE || + page.display === ListDisplayType.TABLE_PAGINATED + ) { + setTable({ rowHeight: e }); + } else { + setPage({ list: { ...page, grid: { ...page.grid, size: e } } }); + } + }; + + const fetch = useCallback( + async (startIndex: number, limit: number, filters: AlbumArtistListFilter) => { + const queryKey = queryKeys.albumArtists.list(server?.id || '', { + limit, + startIndex, + ...filters, + }); + + const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) => + api.controller.getAlbumArtistList({ + query: { + limit, + startIndex, + ...filters, + }, + server, + signal, + }), + ); + + return api.normalize.albumArtistList(albums, server); + }, + [queryClient, server], + ); + + const handleFilterChange = useCallback( + async (filters: AlbumArtistListFilter) => { + if ( + page.display === ListDisplayType.TABLE || + page.display === ListDisplayType.TABLE_PAGINATED + ) { + const dataSource: IDatasource = { + getRows: async (params) => { + const limit = params.endRow - params.startRow; + const startIndex = params.startRow; + + const queryKey = queryKeys.albumArtists.list(server?.id || '', { + limit, + startIndex, + ...filters, + }); + + const albumArtistsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) => + api.controller.getAlbumArtistList({ + query: { + limit, + startIndex, + ...filters, + }, + server, + signal, + }), + ); + + const albumArtists = api.normalize.albumArtistList(albumArtistsRes, server); + params.successCallback( + albumArtists?.items || [], + albumArtistsRes?.totalRecordCount || undefined, + ); + }, + rowCount: undefined, + }; + tableRef.current?.api.setDatasource(dataSource); + tableRef.current?.api.purgeInfiniteCache(); + tableRef.current?.api.ensureIndexVisible(0, 'top'); + + if (page.display === ListDisplayType.TABLE_PAGINATED) { + setPagination({ currentPage: 0 }); + } + } else { + gridRef.current?.scrollTo(0); + gridRef.current?.resetLoadMoreItemsCache(); + + // Refetching within the virtualized grid may be inconsistent due to it refetching + // using an outdated set of filters. To avoid this, we fetch using the updated filters + // and then set the grid's data here. + const data = await fetch(0, 200, filters); + + if (!data?.items) return; + gridRef.current?.setItemData(data.items); + } + }, + [page.display, tableRef, setPagination, server, queryClient, gridRef, fetch], + ); + + 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 AlbumArtistListSort, + sortOrder: sortOrder || SortOrder.ASC, + }); + + handleFilterChange(updatedFilters); + }, + [handleFilterChange, server?.type, setFilter], + ); + + const handleSetMusicFolder = useCallback( + (e: MouseEvent) => { + if (!e.currentTarget?.value) return; + + let updatedFilters = null; + if (e.currentTarget.value === String(page.filter.musicFolderId)) { + updatedFilters = setFilter({ musicFolderId: undefined }); + } else { + updatedFilters = setFilter({ musicFolderId: e.currentTarget.value }); + } + + handleFilterChange(updatedFilters); + }, + [handleFilterChange, page.filter.musicFolderId, setFilter], + ); + + const handleToggleSortOrder = useCallback(() => { + const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; + const updatedFilters = setFilter({ sortOrder: newSortOrder }); + handleFilterChange(updatedFilters); + }, [filters.sortOrder, handleFilterChange, setFilter]); + + const handleSetViewType = useCallback( + (e: MouseEvent) => { + if (!e.currentTarget?.value) return; + setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } }); + }, + [page, setPage], + ); + + const handleSearch = debounce((e: ChangeEvent) => { + const previousSearchTerm = page.filter.searchTerm; + const searchTerm = e.target.value === '' ? undefined : e.target.value; + const updatedFilters = setFilter({ searchTerm }); + if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters); + }, 500); + + 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 }; + + setTable({ columns: [...existingColumns, newColumn] }); + } else { + // If removing a column + const removed = existingColumns.filter((column) => !values.includes(column.column)); + const newColumns = existingColumns.filter((column) => !removed.includes(column)); + + setTable({ columns: newColumns }); + } + + return tableRef.current?.api.sizeColumnsToFit(); + }; + + const handleAutoFitColumns = (e: ChangeEvent) => { + setTable({ autoFit: e.currentTarget.checked }); + + if (e.currentTarget.checked) { + tableRef.current?.api.sizeColumnsToFit(); + } + }; + + return ( + + + + + + + + + Display type + + Card + + + Poster + + + Table + + + Table (paginated) + + + Item size + + + + {(page.display === ListDisplayType.TABLE || + page.display === ListDisplayType.TABLE_PAGINATED) && ( + <> + Table Columns + + + column.column)} + width={300} + onChange={handleTableColumns} + /> + + Auto Fit Columns + + + + + + )} + + + + + + + + {FILTERS[server?.type as keyof typeof FILTERS].map((filter) => ( + + {filter.name} + + ))} + + + + {server?.type === ServerType.JELLYFIN && ( + + + + + + {musicFoldersQuery.data?.map((folder) => ( + + {folder.name} + + ))} + + + )} + + + + + + {/* {server?.type === ServerType.NAVIDROME ? ( + + ) : ( + + )} */} + + + + + + + + Play + Add to queue (next) + Add to queue (last) + Add to playlist + + + + + + + + + ); +}; diff --git a/src/renderer/features/artists/queries/album-artist-list-query.ts b/src/renderer/features/artists/queries/album-artist-list-query.ts new file mode 100644 index 00000000..3ce19882 --- /dev/null +++ b/src/renderer/features/artists/queries/album-artist-list-query.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import type { AlbumArtistListQuery, RawAlbumArtistListResponse } from '/@/renderer/api/types'; +import type { QueryOptions } from '/@/renderer/lib/react-query'; +import { useCurrentServer } from '/@/renderer/store'; +import { api } from '/@/renderer/api'; + +export const useAlbumArtistList = (query: AlbumArtistListQuery, options?: QueryOptions) => { + const server = useCurrentServer(); + + return useQuery({ + enabled: !!server?.id, + queryFn: ({ signal }) => api.controller.getAlbumArtistList({ query, server, signal }), + queryKey: queryKeys.albumArtists.list(server?.id || '', query), + select: useCallback( + (data: RawAlbumArtistListResponse | undefined) => api.normalize.albumArtistList(data, server), + [server], + ), + ...options, + }); +}; diff --git a/src/renderer/features/artists/routes/album-artist-list-route.tsx b/src/renderer/features/artists/routes/album-artist-list-route.tsx new file mode 100644 index 00000000..74d49b48 --- /dev/null +++ b/src/renderer/features/artists/routes/album-artist-list-route.tsx @@ -0,0 +1,28 @@ +import { VirtualGridContainer, VirtualInfiniteGridRef } from '/@/renderer/components'; +import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header'; +import { AnimatedPage } from '/@/renderer/features/shared'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { useRef } from 'react'; +import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content'; + +const AlbumArtistListRoute = () => { + const gridRef = useRef(null); + const tableRef = useRef(null); + + return ( + + + + + + + ); +}; + +export default AlbumArtistListRoute; diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 3b117f46..fdbd6042 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -183,13 +183,10 @@ export const Sidebar = () => { Tracks - + - Artists + Album Artists { path={AppRoute.LIBRARY_SONGS} /> } - path={AppRoute.LIBRARY_ARTISTS} + element={} + path={AppRoute.LIBRARY_ALBUMARTISTS} /> } diff --git a/src/renderer/store/album-artist.store.ts b/src/renderer/store/album-artist.store.ts new file mode 100644 index 00000000..748f994e --- /dev/null +++ b/src/renderer/store/album-artist.store.ts @@ -0,0 +1,126 @@ +import merge from 'lodash/merge'; +import create from 'zustand'; +import { devtools, persist } from 'zustand/middleware'; +import { immer } from 'zustand/middleware/immer'; +import { AlbumArtistListArgs, AlbumArtistListSort, 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; + grid: { + scrollOffset: number; + size: number; + }; + table: TableProps; +}; + +export type AlbumArtistListFilter = Omit; + +export interface AlbumArtistState { + list: ListProps; +} + +export interface AlbumArtistSlice extends AlbumArtistState { + actions: { + setFilters: (data: Partial) => AlbumArtistListFilter; + setStore: (data: Partial) => void; + setTable: (data: Partial) => void; + setTablePagination: (data: Partial) => void; + }; +} + +export const useAlbumArtistStore = 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: AlbumArtistListSort.NAME, + sortOrder: SortOrder.ASC, + }, + grid: { + scrollOffset: 0, + size: 50, + }, + table: { + autoFit: true, + columns: [ + { + column: TableColumn.ROW_INDEX, + width: 50, + }, + { + column: TableColumn.TITLE_COMBINED, + width: 500, + }, + ], + pagination: { + currentPage: 1, + itemsPerPage: 100, + totalItems: 1, + totalPages: 1, + }, + rowHeight: 60, + scrollOffset: 0, + }, + }, + })), + { name: 'store_artist' }, + ), + { + merge: (persistedState, currentState) => { + return merge(currentState, persistedState); + }, + name: 'store_artist', + version: 1, + }, + ), +); + +export const useAlbumArtistStoreActions = () => useAlbumArtistStore((state) => state.actions); + +export const useSetAlbumArtistStore = () => useAlbumArtistStore((state) => state.actions.setStore); + +export const useSetAlbumArtistFilters = () => + useAlbumArtistStore((state) => state.actions.setFilters); + +export const useAlbumArtistListStore = () => useAlbumArtistStore((state) => state.list); + +export const useAlbumArtistTablePagination = () => + useAlbumArtistStore((state) => state.list.table.pagination); + +export const useSetAlbumArtistTablePagination = () => + useAlbumArtistStore((state) => state.actions.setTablePagination); + +export const useSetAlbumArtistTable = () => useAlbumArtistStore((state) => state.actions.setTable); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 2c462415..bc42b824 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -3,3 +3,4 @@ export * from './player.store'; export * from './app.store'; export * from './album.store'; export * from './song.store'; +export * from './album-artist.store';