diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx
index c9c313ed..186fe6e3 100644
--- a/src/renderer/features/artists/components/album-artist-detail-content.tsx
+++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx
@@ -279,9 +279,30 @@ export const AlbumArtistDetailContent = () => {
Add to playlist
+
+
+
{showGenres && (
diff --git a/src/renderer/features/artists/components/album-artist-detail-header.tsx b/src/renderer/features/artists/components/album-artist-detail-header.tsx
index dd79fdac..3415ef02 100644
--- a/src/renderer/features/artists/components/album-artist-detail-header.tsx
+++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx
@@ -37,8 +37,6 @@ export const AlbumArtistDetailHeader = forwardRef(
},
];
- console.log('detailQuery?.data', detailQuery?.data);
-
return (
;
+}
+
+export const AlbumArtistDetailSongListContent = ({
+ itemCount,
+ filter,
+ tableRef,
+}: AlbumArtistSongListContentProps) => {
+ const queryClient = useQueryClient();
+ const server = useCurrentServer();
+ const page = useSongListStore();
+
+ const pagination = useSongTablePagination();
+ const setPagination = useSetSongTablePagination();
+ const setTable = useSetSongTable();
+ const handlePlayQueueAdd = usePlayQueueAdd();
+ const playButtonBehavior = usePlayButtonBehavior();
+
+ const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
+
+ 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.songs.list(server?.id || '', {
+ ...filter,
+ // artistIds: [albumArtistId],
+ limit,
+ // sortBy: SongListSort.ALBUM,
+ // sortOrder: SortOrder.ASC,
+ startIndex,
+ });
+
+ const songsRes = await queryClient.fetchQuery(
+ queryKey,
+ async ({ signal }) =>
+ api.controller.getSongList({
+ query: {
+ // artistIds: [albumArtistId],
+ ...filter,
+ limit,
+ // sortBy: SongListSort.ALBUM,
+ // sortOrder: SortOrder.ASC,
+ startIndex,
+ },
+ server,
+ signal,
+ }),
+ { cacheTime: 1000 * 60 * 1 },
+ );
+
+ const songs = api.normalize.songList(songsRes, server);
+ params.successCallback(songs?.items || [], songsRes?.totalRecordCount);
+ },
+ rowCount: undefined,
+ };
+ params.api.setDatasource(dataSource);
+ params.api.ensureIndexVisible(page.table.scrollOffset, 'top');
+ },
+ [filter, page.table.scrollOffset, 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 = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
+
+ const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
+ if (!e.data) return;
+ handlePlayQueueAdd?.({
+ byData: [e.data],
+ play: playButtonBehavior,
+ });
+ };
+
+ return (
+ <>
+
+ data.data.id}
+ infiniteInitialRowCount={itemCount || 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/artists/components/album-artist-detail-song-list-header.tsx b/src/renderer/features/artists/components/album-artist-detail-song-list-header.tsx
new file mode 100644
index 00000000..f1e0a38d
--- /dev/null
+++ b/src/renderer/features/artists/components/album-artist-detail-song-list-header.tsx
@@ -0,0 +1,471 @@
+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 debounce from 'lodash/debounce';
+import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
+import { RiArrowDownSLine, RiFolder2Line, RiMoreFill, RiSortAsc, RiSortDesc } from 'react-icons/ri';
+import { api } from '/@/renderer/api';
+import { queryKeys } from '/@/renderer/api/query-keys';
+import {
+ LibraryItem,
+ ServerType,
+ SongListQuery,
+ SongListSort,
+ SortOrder,
+} from '/@/renderer/api/types';
+import {
+ Button,
+ DropdownMenu,
+ PageHeader,
+ SearchInput,
+ Slider,
+ TextTitle,
+ Switch,
+ MultiSelect,
+ Text,
+ SONG_TABLE_COLUMNS,
+ Badge,
+ SpinnerIcon,
+} from '/@/renderer/components';
+import { usePlayQueueAdd } from '/@/renderer/features/player';
+import { useMusicFolders } from '/@/renderer/features/shared';
+import { useContainerQuery } from '/@/renderer/hooks';
+import { queryClient } from '/@/renderer/lib/react-query';
+import {
+ SongListFilter,
+ useCurrentServer,
+ useSetSongStore,
+ useSetSongTable,
+ useSetSongTablePagination,
+ useSongListStore,
+} from '/@/renderer/store';
+import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
+
+const FILTERS = {
+ jellyfin: [
+ { defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
+ { defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
+ { defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
+ { defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
+ { defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
+ { defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
+ { defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
+ { defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
+ { defaultOrder: SortOrder.ASC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
+ { defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
+ ],
+ navidrome: [
+ { defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
+ { defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
+ { defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
+ { defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
+ { defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
+ { defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
+ { defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
+ { defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
+ { defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
+ { defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
+ { defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
+ { defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
+ { defaultOrder: SortOrder.DESC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
+ { defaultOrder: SortOrder.DESC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
+ { defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
+ ],
+};
+
+const ORDER = [
+ { name: 'Ascending', value: SortOrder.ASC },
+ { name: 'Descending', value: SortOrder.DESC },
+];
+
+interface AlbumArtistDetailSongListHeaderProps {
+ filter: SongListFilter;
+ itemCount?: number;
+ setFilter: (filter: Partial) => void;
+ tableRef: MutableRefObject;
+ title: string;
+}
+
+export const AlbumArtistDetailSongListHeader = ({
+ filter,
+ setFilter,
+ title,
+ itemCount,
+ tableRef,
+}: AlbumArtistDetailSongListHeaderProps) => {
+ const server = useCurrentServer();
+ const page = useSongListStore();
+ const setPage = useSetSongStore();
+ // const setFilter = useSetSongFilters();
+ const setTable = useSetSongTable();
+ const setPagination = useSetSongTablePagination();
+ const handlePlayQueueAdd = usePlayQueueAdd();
+ const cq = useContainerQuery();
+
+ const musicFoldersQuery = useMusicFolders();
+
+ const sortByLabel =
+ (server?.type &&
+ (FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
+ (f) => f.value === filter.sortBy,
+ )?.name) ||
+ 'Unknown';
+
+ const sortOrderLabel = ORDER.find((s) => s.value === filter.sortOrder)?.name;
+
+ const handleFilterChange = useCallback(
+ async (filters?: SongListFilter) => {
+ const dataSource: IDatasource = {
+ getRows: async (params) => {
+ const limit = params.endRow - params.startRow;
+ const startIndex = params.startRow;
+
+ const pageFilters = filters || filter;
+
+ const queryKey = queryKeys.songs.list(server?.id || '', {
+ limit,
+ startIndex,
+ ...pageFilters,
+ });
+
+ const songsRes = await queryClient.fetchQuery(
+ queryKey,
+ async ({ signal }) =>
+ api.controller.getSongList({
+ query: {
+ limit,
+ startIndex,
+ ...pageFilters,
+ },
+ server,
+ signal,
+ }),
+ { cacheTime: 1000 * 60 * 1 },
+ );
+
+ const songs = api.normalize.songList(songsRes, server);
+ params.successCallback(songs?.items || [], songsRes?.totalRecordCount);
+ },
+ rowCount: undefined,
+ };
+ tableRef.current?.api.setDatasource(dataSource);
+ tableRef.current?.api.purgeInfiniteCache();
+ tableRef.current?.api.ensureIndexVisible(0, 'top');
+ setPagination({ currentPage: 0 });
+ },
+ [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;
+
+ setFilter({
+ sortBy: e.currentTarget.value as SongListSort,
+ sortOrder: sortOrder || SortOrder.ASC,
+ });
+
+ handleFilterChange({
+ ...filter,
+ sortBy: e.currentTarget.value as SongListSort,
+ sortOrder: sortOrder || SortOrder.ASC,
+ });
+ },
+ [filter, 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 = { musicFolderId: undefined };
+ setFilter(updatedFilters);
+ } else {
+ updatedFilters = { musicFolderId: e.currentTarget.value };
+ setFilter(updatedFilters);
+ }
+
+ handleFilterChange({ ...filter, ...updatedFilters });
+ },
+ [filter, handleFilterChange, page.filter.musicFolderId, setFilter],
+ );
+
+ const handleToggleSortOrder = useCallback(() => {
+ const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
+ setFilter({ sortOrder: newSortOrder });
+ handleFilterChange({ ...filter, sortOrder: newSortOrder });
+ }, [filter, 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 handleSearch = debounce((e: ChangeEvent) => {
+ const previousSearchTerm = filter.searchTerm;
+ const searchTerm = e.target.value === '' ? undefined : e.target.value;
+ setFilter({ searchTerm });
+ if (previousSearchTerm !== searchTerm) handleFilterChange({ ...filter, searchTerm });
+ }, 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 };
+
+ 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 });
+ };
+
+ const handleRefresh = () => {
+ queryClient.invalidateQueries(queryKeys.songs.list(server?.id || ''));
+ handleFilterChange(filter);
+ };
+
+ const handlePlay = async (play: Play) => {
+ const query: SongListQuery = { startIndex: 0, ...filter };
+
+ handlePlayQueueAdd?.({
+ byItemType: {
+ id: query,
+ type: LibraryItem.SONG,
+ },
+ play,
+ });
+ };
+
+ return (
+
+
+
+
+
+ }
+ size="xl"
+ sx={{ paddingLeft: 0, paddingRight: 0 }}
+ variant="subtle"
+ >
+
+
+ {title}
+
+
+ {itemCount === null || itemCount === undefined ? : itemCount}
+
+
+
+
+
+ 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}
+
+ ))}
+
+
+
+ {server?.type === ServerType.JELLYFIN && (
+
+
+
+
+
+ {musicFoldersQuery.data?.map((folder) => (
+
+ {folder.name}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ handlePlay(Play.NOW)}>Play
+ handlePlay(Play.LAST)}>
+ Add to queue
+
+ handlePlay(Play.NEXT)}>
+ Add to queue next
+
+
+ Refresh
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/renderer/features/artists/routes/album-artist-detail-song-list-route.tsx b/src/renderer/features/artists/routes/album-artist-detail-song-list-route.tsx
new file mode 100644
index 00000000..a6b60754
--- /dev/null
+++ b/src/renderer/features/artists/routes/album-artist-detail-song-list-route.tsx
@@ -0,0 +1,65 @@
+import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
+import { useSetState } from '@mantine/hooks';
+import { useRef } from 'react';
+import { useParams } from 'react-router';
+import { SongListSort, SortOrder } from '/@/renderer/api/types';
+import { VirtualGridContainer } from '/@/renderer/components';
+import { AlbumArtistDetailSongListContent } from '/@/renderer/features/artists/components/album-artist-detail-song-list-content';
+import { AlbumArtistDetailSongListHeader } from '/@/renderer/features/artists/components/album-artist-detail-song-list-header';
+import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
+import { AnimatedPage } from '/@/renderer/features/shared';
+import { useSongList } from '/@/renderer/features/songs';
+import { SongListFilter } from '/@/renderer/store';
+
+const AlbumArtistDetailSongListRoute = () => {
+ const tableRef = useRef(null);
+ const { albumArtistId } = useParams() as { albumArtistId: string };
+
+ const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
+
+ const [filter, setFilter] = useSetState({
+ artistIds: [albumArtistId],
+ sortBy: SongListSort.ALBUM,
+ sortOrder: SortOrder.ASC,
+ });
+
+ const itemCountCheck = useSongList(
+ {
+ limit: 1,
+ startIndex: 0,
+ ...filter,
+ },
+ {
+ cacheTime: 1000 * 60 * 60 * 2,
+ staleTime: 1000 * 60 * 60 * 2,
+ },
+ );
+
+ const itemCount =
+ itemCountCheck.data?.totalRecordCount === null
+ ? undefined
+ : itemCountCheck.data?.totalRecordCount;
+
+ if (detailQuery.isLoading) return null;
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default AlbumArtistDetailSongListRoute;