Add initial genre list support

This commit is contained in:
jeffvli 2023-07-31 17:16:48 -07:00
parent 4d5085f230
commit 8029712b55
25 changed files with 968 additions and 41 deletions

View file

@ -15,6 +15,10 @@ export interface JFGenreListResponse extends JFBasePaginatedResponse {
export type JFGenreList = JFGenreListResponse;
export enum JFGenreListSort {
NAME = 'Name,SortName',
}
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;

View file

@ -108,6 +108,7 @@ export const contract = c.router({
getGenreList: {
method: 'GET',
path: 'genres',
query: jfType._parameters.genreList,
responses: {
200: jfType._response.genreList,
400: jfType._response.error,

View file

@ -46,6 +46,7 @@ import {
RandomSongListArgs,
LyricsArgs,
LyricsResponse,
genreListSortMap,
} from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize';
@ -116,9 +117,16 @@ const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolde
};
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
const { apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await jfApiClient(apiClientProps).getGenreList();
const res = await jfApiClient(apiClientProps).getGenreList({
query: {
SearchTerm: query?.searchTerm,
SortBy: genreListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
},
});
if (res.status !== 200) {
throw new Error('Failed to get genre list');
@ -126,8 +134,8 @@ const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> =>
return {
items: res.body.Items.map(jfNormalize.genre),
startIndex: 0,
totalRecordCount: res.body?.Items?.length || 0,
startIndex: query.startIndex || 0,
totalRecordCount: res.body?.TotalRecordCount || 0,
};
};

View file

@ -304,10 +304,21 @@ const genre = z.object({
Type: z.string(),
});
const genreList = z.object({
const genreList = pagination.extend({
Items: z.array(genre),
});
const genreListSort = {
NAME: 'Name,SortName',
} as const;
const genreListParameters = paginationParameters.merge(
baseParameters.extend({
SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(genreListSort).optional(),
}),
);
const musicFolder = z.object({
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
@ -352,7 +363,7 @@ const playlist = z.object({
UserData: userData,
});
const jfPlaylistListSort = {
const playlistListSort = {
ALBUM_ARTIST: 'AlbumArtist,SortName',
DURATION: 'Runtime',
NAME: 'SortName',
@ -363,7 +374,7 @@ const jfPlaylistListSort = {
const playlistListParameters = paginationParameters.merge(
baseParameters.extend({
IncludeItemTypes: z.literal('Playlist'),
SortBy: z.nativeEnum(jfPlaylistListSort).optional(),
SortBy: z.nativeEnum(playlistListSort).optional(),
}),
);
@ -461,7 +472,7 @@ const album = z.object({
UserData: userData.optional(),
});
const jfAlbumListSort = {
const albumListSort = {
ALBUM_ARTIST: 'AlbumArtist,SortName',
COMMUNITY_RATING: 'CommunityRating,SortName',
CRITIC_RATING: 'CriticRating,SortName',
@ -479,7 +490,7 @@ const albumListParameters = paginationParameters.merge(
IncludeItemTypes: z.literal('MusicAlbum'),
IsFavorite: z.boolean().optional(),
SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(jfAlbumListSort).optional(),
SortBy: z.nativeEnum(albumListSort).optional(),
Tags: z.string().optional(),
Years: z.string().optional(),
}),
@ -489,7 +500,7 @@ const albumList = pagination.extend({
Items: z.array(album),
});
const jfAlbumArtistListSort = {
const albumArtistListSort = {
ALBUM: 'Album,SortName',
DURATION: 'Runtime,AlbumArtist,Album,SortName',
NAME: 'Name,SortName',
@ -502,7 +513,7 @@ const albumArtistListParameters = paginationParameters.merge(
baseParameters.extend({
Filters: z.string().optional(),
Genres: z.string().optional(),
SortBy: z.nativeEnum(jfAlbumArtistListSort).optional(),
SortBy: z.nativeEnum(albumArtistListSort).optional(),
Years: z.string().optional(),
}),
);
@ -515,7 +526,7 @@ const similarArtistListParameters = baseParameters.extend({
Limit: z.number().optional(),
});
const jfSongListSort = {
const songListSort = {
ALBUM: 'Album,SortName',
ALBUM_ARTIST: 'AlbumArtist,Album,SortName',
ARTIST: 'Artist,Album,SortName',
@ -539,7 +550,7 @@ const songListParameters = paginationParameters.merge(
Genres: z.string().optional(),
IsFavorite: z.boolean().optional(),
SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(jfSongListSort).optional(),
SortBy: z.nativeEnum(songListSort).optional(),
Tags: z.string().optional(),
Years: z.string().optional(),
}),
@ -642,9 +653,14 @@ const lyrics = z.object({
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,
albumList: albumListSort,
collection: jfCollection,
external: jfExternal,
genreList: genreListSort,
image: jfImage,
playlistList: playlistListSort,
songList: songListSort,
},
_parameters: {
addToPlaylist: addToPlaylistParameters,
@ -656,6 +672,7 @@ export const jfType = {
createPlaylist: createPlaylistParameters,
deletePlaylist: deletePlaylistParameters,
favorite: favoriteParameters,
genreList: genreListParameters,
musicFolderList: musicFolderListParameters,
playlistDetail: playlistDetailParameters,
playlistList: playlistListParameters,

View file

@ -88,6 +88,7 @@ export const contract = c.router({
getGenreList: {
method: 'GET',
path: 'genre',
query: ndType._parameters.genreList,
responses: {
200: resultWithHeaders(ndType._response.genreList),
500: resultWithHeaders(ndType._response.error),

View file

@ -38,6 +38,7 @@ import {
PlaylistSongListResponse,
RemoveFromPlaylistResponse,
RemoveFromPlaylistArgs,
genreListSortMap,
} from '../types';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
@ -94,9 +95,17 @@ const getUserList = async (args: UserListArgs): Promise<UserListResponse> => {
};
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
const { apiClientProps } = args;
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getGenreList({});
const res = await ndApiClient(apiClientProps).getGenreList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: genreListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
name: query.searchTerm,
},
});
if (res.status !== 200) {
throw new Error('Failed to get genre list');
@ -104,7 +113,7 @@ const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> =>
return {
items: res.body.data,
startIndex: 0,
startIndex: query.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};

View file

@ -52,6 +52,16 @@ const genre = z.object({
name: z.string(),
});
const genreListSort = {
NAME: 'name',
SONG_COUNT: 'songCount',
} as const;
const genreListParameters = paginationParameters.extend({
_sort: z.nativeEnum(genreListSort).optional(),
name: z.string().optional(),
});
const genreList = z.array(genre);
const albumArtist = z.object({
@ -322,6 +332,7 @@ export const ndType = {
_enum: {
albumArtistList: ndAlbumArtistListSort,
albumList: ndAlbumListSort,
genreList: genreListSort,
playlistList: ndPlaylistListSort,
songList: ndSongListSort,
userList: ndUserListSort,
@ -332,6 +343,7 @@ export const ndType = {
albumList: albumListParameters,
authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters,
genreList: genreListParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
songList: songListParameters,

View file

@ -17,6 +17,7 @@ import type {
RandomSongListQuery,
LyricsQuery,
LyricSearchQuery,
GenreListQuery,
} from './types';
export const splitPaginatedQuery = (key: any) => {
@ -106,7 +107,18 @@ export const queryKeys: Record<
root: (serverId: string) => [serverId, 'artists'] as const,
},
genres: {
list: (serverId: string) => [serverId, 'genres', 'list'] as const,
list: (serverId: string, query?: GenreListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'genres', 'list', filter, pagination] as const;
}
if (query) {
return [serverId, 'genres', 'list', filter] as const;
}
return [serverId, 'genres', 'list'] as const;
},
root: (serverId: string) => [serverId, 'genres'] as const,
},
musicFolders: {

View file

@ -1,4 +1,5 @@
import { z } from 'zod';
import { jfType } from './jellyfin/jellyfin-types';
import {
JFSortOrder,
JFAlbumListSort,
@ -6,8 +7,9 @@ import {
JFAlbumArtistListSort,
JFArtistListSort,
JFPlaylistListSort,
JFGenreListSort,
} from './jellyfin.types';
import { jfType } from './jellyfin/jellyfin-types';
import { ndType } from './navidrome/navidrome-types';
import {
NDSortOrder,
NDOrder,
@ -16,13 +18,14 @@ import {
NDPlaylistListSort,
NDSongListSort,
NDUserListSort,
NDGenreListSort,
} from './navidrome.types';
import { ndType } from './navidrome/navidrome-types';
export enum LibraryItem {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
GENRE = 'genre',
PLAYLIST = 'playlist',
SONG = 'song',
}
@ -292,7 +295,40 @@ export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefine
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
export type GenreListQuery = null;
export enum GenreListSort {
NAME = 'name',
}
export type GenreListQuery = {
_custom?: {
jellyfin?: null;
navidrome?: null;
};
limit?: number;
musicFolderId?: string;
searchTerm?: string;
sortBy: GenreListSort;
sortOrder: SortOrder;
startIndex: number;
};
type GenreListSortMap = {
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
navidrome: Record<GenreListSort, NDGenreListSort | undefined>;
subsonic: Record<UserListSort, undefined>;
};
export const genreListSortMap: GenreListSortMap = {
jellyfin: {
name: JFGenreListSort.NAME,
},
navidrome: {
name: NDGenreListSort.NAME,
},
subsonic: {
name: undefined,
},
};
// Album List
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;

View file

@ -12,6 +12,7 @@ import {
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import orderBy from 'lodash/orderBy';
import { generatePath, useNavigate } from 'react-router';
import { api } from '/@/renderer/api';
import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys';
@ -32,6 +33,7 @@ export type AgGridFetchFn<TResponse, TFilter> = (
interface UseAgGridProps<TFilter> {
contextMenu: SetContextMenuItems;
customFilters?: Partial<TFilter>;
isClientSideSort?: boolean;
isSearchParams?: boolean;
itemCount?: number;
itemType: LibraryItem;
@ -49,6 +51,7 @@ export const useVirtualTable = <TFilter>({
itemCount,
customFilters,
isSearchParams,
isClientSideSort,
}: UseAgGridProps<TFilter>) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
@ -104,6 +107,9 @@ export const useVirtualTable = <TFilter>({
if (itemType === LibraryItem.SONG) {
return queryKeys.songs.list;
}
if (itemType === LibraryItem.GENRE) {
return queryKeys.genres.list;
}
return null;
}, [itemType]);
@ -122,6 +128,9 @@ export const useVirtualTable = <TFilter>({
if (itemType === LibraryItem.SONG) {
return api.controller.getSongList;
}
if (itemType === LibraryItem.GENRE) {
return api.controller.getGenreList;
}
return null;
}, [itemType]);
@ -160,6 +169,17 @@ export const useVirtualTable = <TFilter>({
return res;
})) as BasePaginatedResponse<any>;
if (isClientSideSort && results?.items) {
const sortedResults = orderBy(
results.items,
[(item) => String(item[properties.filter.sortBy]).toLowerCase()],
properties.filter.sortOrder === 'DESC' ? ['desc'] : ['asc'],
);
params.successCallback(sortedResults || [], results?.totalRecordCount || 0);
return;
}
params.successCallback(results?.items || [], results?.totalRecordCount || 0);
},
rowCount: undefined,
@ -168,7 +188,15 @@ export const useVirtualTable = <TFilter>({
params.api.setDatasource(dataSource);
params.api.ensureIndexVisible(initialTableIndex, 'top');
},
[initialTableIndex, queryKeyFn, server, properties.filter, queryClient, queryFn],
[
initialTableIndex,
queryKeyFn,
server,
properties.filter,
queryClient,
isClientSideSort,
queryFn,
],
);
const setParamsTablePagination = useCallback(
@ -206,10 +234,10 @@ export const useVirtualTable = <TFilter>({
if (isSearchParams) {
setSearchParams(
(params) => {
params.set('currentPage', String(event.api?.paginationGetCurrentPage()));
params.set('itemsPerPage', String(event.api?.paginationGetPageSize()));
params.set('totalItems', String(event.api?.paginationGetRowCount()));
params.set('totalPages', String(event.api?.paginationGetTotalPages() + 1));
params.set('currentPage', String(event.api.paginationGetCurrentPage()));
params.set('itemsPerPage', String(event.api.paginationGetPageSize()));
params.set('totalItems', String(event.api.paginationGetRowCount()));
params.set('totalPages', String(event.api.paginationGetTotalPages() + 1));
return params;
},
{ replace: true },

View file

@ -84,6 +84,12 @@ export const PLAYLIST_TABLE_COLUMNS = [
{ label: 'Actions', value: TableColumn.ACTIONS },
];
export const GENRE_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
{ label: 'Title', value: TableColumn.TITLE },
{ label: 'Actions', value: TableColumn.ACTIONS },
];
interface TableConfigDropdownProps {
type: TableType;
}

View file

@ -2,7 +2,7 @@ import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo, useState } from 'react';
import { useListFilterByKey } from '../../../store/list.store';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { useGenreList } from '/@/renderer/features/genres';
@ -27,7 +27,14 @@ export const JellyfinAlbumFilters = ({
const { setFilter } = useListStoreActions();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
const genreListQuery = useGenreList({ query: null, serverId });
const genreListQuery = useGenreList({
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];

View file

@ -5,7 +5,7 @@ import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/rend
import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
interface NavidromeAlbumFiltersProps {
customFilters?: Partial<AlbumListFilter>;
@ -25,7 +25,14 @@ export const NavidromeAlbumFilters = ({
const { filter } = useListStoreByKey({ key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList({ query: null, serverId });
const genreListQuery = useGenreList({
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];

View file

@ -0,0 +1,46 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { lazy, MutableRefObject, Suspense } from 'react';
import { Spinner } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { useListStoreByKey } from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
const GenreListGridView = lazy(() =>
import('/@/renderer/features/genres/components/genre-list-grid-view').then((module) => ({
default: module.GenreListGridView,
})),
);
const GenreListTableView = lazy(() =>
import('/@/renderer/features/genres/components/genre-list-table-view').then((module) => ({
default: module.GenreListTableView,
})),
);
interface AlbumListContentProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const GenreListContent = ({ itemCount, gridRef, tableRef }: AlbumListContentProps) => {
const { pageKey } = useListContext();
const { display } = useListStoreByKey({ key: pageKey });
return (
<Suspense fallback={<Spinner container />}>
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
<GenreListGridView
gridRef={gridRef}
itemCount={itemCount}
/>
) : (
<GenreListTableView
itemCount={itemCount}
tableRef={tableRef}
/>
)}
</Suspense>
);
};

View file

@ -0,0 +1,116 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import AutoSizer, { Size } from 'react-virtualized-auto-sizer';
import { ListOnScrollProps } from 'react-window';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { Album, GenreListQuery, LibraryItem } from '/@/renderer/api/types';
import { ALBUM_CARD_ROWS } from '/@/renderer/components';
import {
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,
} from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
import { CardRow, ListDisplayType } from '/@/renderer/types';
export const GenreListGridView = ({ gridRef, itemCount }: any) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const { pageKey, id } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
const { setGrid } = useListStoreActions();
const [searchParams, setSearchParams] = useSearchParams();
const scrollOffset = searchParams.get('scrollOffset');
const initialScrollOffset = Number(id ? scrollOffset : grid?.scrollOffset) || 0;
const cardRows = useMemo(() => {
const rows: CardRow<Album>[] = [ALBUM_CARD_ROWS.name];
return rows;
}, []);
const handleGridScroll = useCallback(
(e: ListOnScrollProps) => {
if (id) {
setSearchParams(
(params) => {
params.set('scrollOffset', String(e.scrollOffset));
return params;
},
{ replace: true },
);
} else {
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
}
},
[id, pageKey, setGrid, setSearchParams],
);
const fetch = useCallback(
async ({ skip, take }: { skip: number; take: number }) => {
if (!server) {
return [];
}
const query: GenreListQuery = {
...filter,
limit: take,
startIndex: skip,
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albums = await queryClient.fetchQuery({
queryFn: async ({ signal }) => {
return api.controller.getGenreList({
apiClientProps: {
server,
signal,
},
query,
});
},
queryKey,
});
return albums;
},
[filter, queryClient, server],
);
return (
<VirtualGridAutoSizerContainer>
<AutoSizer>
{({ height, width }: Size) => (
<VirtualInfiniteGrid
key={`album-list-${server?.id}-${display}`}
ref={gridRef}
cardRows={cardRows}
display={display || ListDisplayType.CARD}
fetchFn={fetch}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={initialScrollOffset}
itemCount={itemCount || 0}
itemGap={20}
itemSize={grid?.itemsPerRow || 5}
itemType={LibraryItem.GENRE}
loading={itemCount === undefined || itemCount === null}
minimumBatchSize={40}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
width={width}
onScroll={handleGridScroll}
/>
)}
</AutoSizer>
</VirtualGridAutoSizerContainer>
);
};

View file

@ -0,0 +1,305 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { useListContext } from '/@/renderer/context/list-context';
import { OrderToggleButton } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
import {
GenreListFilter,
useCurrentServer,
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
const FILTERS = {
jellyfin: [{ defaultOrder: SortOrder.ASC, name: 'Name', value: GenreListSort.NAME }],
navidrome: [{ defaultOrder: SortOrder.ASC, name: 'Name', value: GenreListSort.NAME }],
};
interface GenreListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => {
const queryClient = useQueryClient();
const { pageKey, customFilters } = useListContext();
const server = useCurrentServer();
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
const { display, filter, table, grid } = useListStoreByKey({ key: pageKey });
const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemType: LibraryItem.GENRE,
server,
});
const sortByLabel =
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)
?.name) ||
'Unknown';
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
const onFilterChange = useCallback(
(filter: GenreListFilter) => {
if (isGrid) {
handleRefreshGrid(gridRef, {
...filter,
...customFilters,
});
} else {
handleRefreshTable(tableRef, {
...filter,
...customFilters,
});
}
},
[customFilters, gridRef, handleRefreshGrid, handleRefreshTable, isGrid, tableRef],
);
const handleRefresh = useCallback(() => {
queryClient.invalidateQueries(queryKeys.genres.list(server?.id || ''));
onFilterChange(filter);
}, [filter, onFilterChange, queryClient, server?.id]);
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const updatedFilters = setFilter({
customFilters,
data: {
sortBy: e.currentTarget.value as GenreListSort,
sortOrder: sortOrder || SortOrder.ASC,
},
itemType: LibraryItem.GENRE,
key: pageKey,
}) as GenreListFilter;
onFilterChange(updatedFilters);
},
[customFilters, onFilterChange, pageKey, server?.type, setFilter],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({
customFilters,
data: { sortOrder: newSortOrder },
itemType: LibraryItem.GENRE,
key: pageKey,
}) as GenreListFilter;
onFilterChange(updatedFilters);
}, [customFilters, filter.sortOrder, onFilterChange, pageKey, setFilter]);
const handleItemSize = (e: number) => {
if (isGrid) {
setGrid({ data: { itemsPerRow: e }, key: pageKey });
} else {
setTable({ data: { rowHeight: e }, key: pageKey });
}
};
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
},
[pageKey, setDisplayType],
);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = table.columns;
if (values.length === 0) {
return setTable({
data: { columns: [] },
key: pageKey,
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
} else {
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
setTable({ data: { columns: newColumns }, key: pageKey });
}
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
return (
<Flex justify="space-between">
<Group
ref={cq.ref}
spacing="sm"
w="100%"
>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw={600}
size="md"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
<DropdownMenu.Item
key={`filter-${f.name}`}
$isActive={f.value === filter.sortBy}
value={f.value}
onClick={handleSetSortBy}
>
{f.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
sortOrder={filter.sortOrder}
onToggle={handleToggleSortOrder}
/>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group
noWrap
spacing="sm"
>
<DropdownMenu
position="bottom-end"
width={425}
>
<DropdownMenu.Target>
<Button
compact
size="md"
tooltip={{ label: 'Configure' }}
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>
{isGrid ? 'Items per row' : 'Item size'}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={isGrid ? grid?.itemsPerRow || 0 : table.rowHeight}
max={isGrid ? 14 : 100}
min={isGrid ? 2 : 25}
onChangeEnd={handleItemSize}
/>
</DropdownMenu.Item>
{(display === ListDisplayType.TABLE ||
display === ListDisplayType.TABLE_PAGINATED) && (
<>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={GENRE_TABLE_COLUMNS}
defaultValue={table?.columns.map(
(column) => column.column,
)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Flex>
);
};

View file

@ -0,0 +1,93 @@
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, MutableRefObject } from 'react';
import { LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
import {
GenreListFilter,
useCurrentServer,
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType } from '/@/renderer/types';
interface GenreListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeaderProps) => {
const cq = useContainerQuery();
const server = useCurrentServer();
const { pageKey } = useListContext();
const { display, filter } = useListStoreByKey({ key: pageKey });
const { setFilter, setTablePagination } = useListStoreActions();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemType: LibraryItem.GENRE,
server,
});
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.GENRE,
key: pageKey,
}) as GenreListFilter;
const filterWithCustom = {
...updatedFilters,
};
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
handleRefreshTable(tableRef, filterWithCustom);
setTablePagination({ data: { currentPage: 0 }, key: pageKey });
} else {
handleRefreshGrid(gridRef, filterWithCustom);
}
}, 500);
return (
<Stack
ref={cq.ref}
spacing={0}
>
<PageHeader backgroundColor="var(--titlebar-bg)">
<Flex
justify="space-between"
w="100%"
>
<LibraryHeaderBar>
<LibraryHeaderBar.Title>Genres</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
onChange={handleSearch}
/>
</Group>
</Flex>
</PageHeader>
<FilterBar>
<GenreListHeaderFilters
gridRef={gridRef}
tableRef={tableRef}
/>
</FilterBar>
</Stack>
);
};

View file

@ -0,0 +1,41 @@
import { LibraryItem } from '/@/renderer/api/types';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { VirtualTable } from '/@/renderer/components/virtual-table';
import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table';
import { useListContext } from '/@/renderer/context/list-context';
import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { useCurrentServer } from '/@/renderer/store';
import { MutableRefObject } from 'react';
interface GenreListTableViewProps {
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const GenreListTableView = ({ tableRef, itemCount }: GenreListTableViewProps) => {
const server = useCurrentServer();
const { pageKey, customFilters } = useListContext();
const tableProps = useVirtualTable({
contextMenu: ALBUM_CONTEXT_MENU_ITEMS,
customFilters,
itemCount,
itemType: LibraryItem.GENRE,
pageKey,
server,
tableRef,
});
return (
<VirtualGridAutoSizerContainer>
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${tableProps.rowHeight}-${server?.id}`}
ref={tableRef}
{...tableProps}
/>
</VirtualGridAutoSizerContainer>
);
};

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { controller } from '/@/renderer/api/controller';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { GenreListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
@ -14,7 +14,7 @@ export const useGenreList = (args: QueryHookArgs<GenreListQuery>) => {
enabled: !!server,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return controller.getGenreList({ apiClientProps: { server, signal }, query });
return api.controller.getGenreList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.genres.list(server?.id || ''),
staleTime: 1000 * 60 * 60,

View file

@ -0,0 +1,96 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useCallback, useMemo, useRef } from 'react';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ListContext } from '/@/renderer/context/list-context';
import { GenreListContent } from '/@/renderer/features/genres/components/genre-list-content';
import { GenreListHeader } from '/@/renderer/features/genres/components/genre-list-header';
import { useGenreList } from '/@/renderer/features/genres/queries/genre-list-query';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AnimatedPage } from '/@/renderer/features/shared';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { Play } from '/@/renderer/types';
const GenreListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const pageKey = 'genre';
const itemCountCheck = useGenreList({
query: {
limit: 1,
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server?.id,
});
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const handlePlay = useCallback(
async (args: { initialSongId?: string; playType: Play }) => {
if (!itemCount || itemCount === 0) return;
const { playType } = args;
const query = {
startIndex: 0,
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumListRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => {
return api.controller.getAlbumList({
apiClientProps: { server, signal },
query,
});
},
queryKey,
});
const albumIds = albumListRes?.items?.map((a) => a.id) || [];
handlePlayQueueAdd?.({
byItemType: {
id: albumIds,
type: LibraryItem.ALBUM,
},
playType,
});
},
[handlePlayQueueAdd, itemCount, server],
);
const providerValue = useMemo(() => {
return {
handlePlay,
pageKey,
};
}, [handlePlay]);
return (
<AnimatedPage>
<ListContext.Provider value={providerValue}>
<GenreListHeader
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
<GenreListContent
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</ListContext.Provider>
</AnimatedPage>
);
};
export default GenreListRoute;

View file

@ -2,7 +2,7 @@ import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react';
import { useListFilterByKey } from '../../../store/list.store';
import { LibraryItem } from '/@/renderer/api/types';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListStoreActions } from '/@/renderer/store';
@ -24,7 +24,14 @@ export const JellyfinSongFilters = ({
const { filter } = useListFilterByKey({ key: pageKey });
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
const genreListQuery = useGenreList({ query: null, serverId });
const genreListQuery = useGenreList({
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];

View file

@ -1,7 +1,7 @@
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react';
import { LibraryItem } from '/@/renderer/api/types';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
@ -22,7 +22,14 @@ export const NavidromeSongFilters = ({
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey({ key: pageKey });
const genreListQuery = useGenreList({ query: null, serverId });
const genreListQuery = useGenreList({
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];

View file

@ -1,18 +1,24 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import { MutableRefObject, useCallback, useMemo } from 'react';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { BasePaginatedResponse, LibraryItem, ServerListItem } from '/@/renderer/api/types';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import orderBy from 'lodash/orderBy';
interface UseHandleListFilterChangeProps {
isClientSideSort?: boolean;
itemType: LibraryItem;
server: ServerListItem | null;
}
export const useListFilterRefresh = ({ server, itemType }: UseHandleListFilterChangeProps) => {
export const useListFilterRefresh = ({
server,
itemType,
isClientSideSort,
}: UseHandleListFilterChangeProps) => {
const queryClient = useQueryClient();
const queryKeyFn: ((serverId: string, query: Record<any, any>) => QueryKey) | null =
@ -29,6 +35,9 @@ export const useListFilterRefresh = ({ server, itemType }: UseHandleListFilterCh
if (itemType === LibraryItem.SONG) {
return queryKeys.songs.list;
}
if (itemType === LibraryItem.GENRE) {
return queryKeys.genres.list;
}
return null;
}, [itemType]);
@ -47,6 +56,9 @@ export const useListFilterRefresh = ({ server, itemType }: UseHandleListFilterCh
if (itemType === LibraryItem.SONG) {
return api.controller.getSongList;
}
if (itemType === LibraryItem.GENRE) {
return api.controller.getGenreList;
}
return null;
}, [itemType]);
@ -79,6 +91,17 @@ export const useListFilterRefresh = ({ server, itemType }: UseHandleListFilterCh
queryKey,
});
if (isClientSideSort && res?.items) {
const sortedResults = orderBy(
res.items,
[(item) => String(item[filter.sortBy]).toLowerCase()],
filter.sortOrder === 'DESC' ? ['desc'] : ['asc'],
);
params.successCallback(sortedResults || [], res?.totalRecordCount || 0);
return;
}
params.successCallback(res?.items || [], res?.totalRecordCount || 0);
},
@ -89,7 +112,7 @@ export const useListFilterRefresh = ({ server, itemType }: UseHandleListFilterCh
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
},
[queryClient, queryFn, queryKeyFn, server],
[isClientSideSort, queryClient, queryFn, queryKeyFn, server],
);
const handleRefreshGrid = useCallback(

View file

@ -58,6 +58,8 @@ const AlbumDetailRoute = lazy(
() => import('/@/renderer/features/albums/routes/album-detail-route'),
);
const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route'));
const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
@ -103,6 +105,11 @@ export const AppRouter = () => {
errorElement={<RouteErrorBoundary />}
path={AppRoute.NOW_PLAYING}
/>
<Route
element={<GenreListRoute />}
errorElement={<RouteErrorBoundary />}
path={AppRoute.LIBRARY_GENRES}
/>
<Route
element={<AlbumListRoute />}
errorElement={<RouteErrorBoundary />}

View file

@ -8,6 +8,8 @@ import {
AlbumArtistListSort,
AlbumListArgs,
AlbumListSort,
GenreListArgs,
GenreListSort,
LibraryItem,
PlaylistListArgs,
PlaylistListSort,
@ -26,10 +28,14 @@ export type AlbumListFilter = Omit<AlbumListArgs['query'], 'startIndex' | 'limit
export type SongListFilter = Omit<SongListArgs['query'], 'startIndex' | 'limit'>;
export type AlbumArtistListFilter = Omit<AlbumArtistListArgs['query'], 'startIndex' | 'limit'>;
export type PlaylistListFilter = Omit<PlaylistListArgs['query'], 'startIndex' | 'limit'>;
export type GenreListFilter = Omit<GenreListArgs['query'], 'startIndex' | 'limit'>;
export type ListKey = keyof ListState['item'] | string;
type FilterType = AlbumListFilter | SongListFilter | AlbumArtistListFilter | PlaylistListFilter;
type FilterType =
| AlbumListFilter
| SongListFilter
| AlbumArtistListFilter
| PlaylistListFilter
| GenreListFilter;
export type ListTableProps = {
pagination: TablePagination;
@ -58,11 +64,14 @@ export interface ListState {
albumArtistAlbum: ListItemProps<AlbumListFilter>;
albumArtistSong: ListItemProps<SongListFilter>;
albumDetail: ListItemProps<any>;
genre: ListItemProps<GenreListFilter>;
playlist: ListItemProps<PlaylistListFilter>;
song: ListItemProps<SongListFilter>;
};
}
export type ListKey = keyof ListState['item'] | string;
export type ListDeterministicArgs = { key: ListKey };
export interface ListSlice extends ListState {
@ -479,6 +488,35 @@ export const useListStore = create<ListSlice>()(
scrollOffset: 0,
},
},
genre: {
display: ListDisplayType.TABLE,
filter: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
},
grid: { itemsPerRow: 5, scrollOffset: 0 },
table: {
autoFit: true,
columns: [
{
column: TableColumn.ROW_INDEX,
width: 50,
},
{
column: TableColumn.TITLE,
width: 500,
},
],
pagination: {
currentPage: 1,
itemsPerPage: 100,
totalItems: 1,
totalPages: 1,
},
rowHeight: 30,
scrollOffset: 0,
},
},
playlist: {
display: ListDisplayType.POSTER,
filter: {