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 type JFGenreList = JFGenreListResponse;
export enum JFGenreListSort {
NAME = 'Name,SortName',
}
export type JFAlbumArtistDetailResponse = JFAlbumArtist; export type JFAlbumArtistDetailResponse = JFAlbumArtist;
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse; export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;

View file

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

View file

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

View file

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

View file

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

View file

@ -38,6 +38,7 @@ import {
PlaylistSongListResponse, PlaylistSongListResponse,
RemoveFromPlaylistResponse, RemoveFromPlaylistResponse,
RemoveFromPlaylistArgs, RemoveFromPlaylistArgs,
genreListSortMap,
} from '../types'; } from '../types';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api'; import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize'; 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 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) { if (res.status !== 200) {
throw new Error('Failed to get genre list'); throw new Error('Failed to get genre list');
@ -104,7 +113,7 @@ const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> =>
return { return {
items: res.body.data, items: res.body.data,
startIndex: 0, startIndex: query.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
}; };
}; };

View file

@ -52,6 +52,16 @@ const genre = z.object({
name: z.string(), 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 genreList = z.array(genre);
const albumArtist = z.object({ const albumArtist = z.object({
@ -322,6 +332,7 @@ export const ndType = {
_enum: { _enum: {
albumArtistList: ndAlbumArtistListSort, albumArtistList: ndAlbumArtistListSort,
albumList: ndAlbumListSort, albumList: ndAlbumListSort,
genreList: genreListSort,
playlistList: ndPlaylistListSort, playlistList: ndPlaylistListSort,
songList: ndSongListSort, songList: ndSongListSort,
userList: ndUserListSort, userList: ndUserListSort,
@ -332,6 +343,7 @@ export const ndType = {
albumList: albumListParameters, albumList: albumListParameters,
authenticate: authenticateParameters, authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters, createPlaylist: createPlaylistParameters,
genreList: genreListParameters,
playlistList: playlistListParameters, playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters, removeFromPlaylist: removeFromPlaylistParameters,
songList: songListParameters, songList: songListParameters,

View file

@ -17,6 +17,7 @@ import type {
RandomSongListQuery, RandomSongListQuery,
LyricsQuery, LyricsQuery,
LyricSearchQuery, LyricSearchQuery,
GenreListQuery,
} from './types'; } from './types';
export const splitPaginatedQuery = (key: any) => { export const splitPaginatedQuery = (key: any) => {
@ -106,7 +107,18 @@ export const queryKeys: Record<
root: (serverId: string) => [serverId, 'artists'] as const, root: (serverId: string) => [serverId, 'artists'] as const,
}, },
genres: { 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, root: (serverId: string) => [serverId, 'genres'] as const,
}, },
musicFolders: { musicFolders: {

View file

@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { jfType } from './jellyfin/jellyfin-types';
import { import {
JFSortOrder, JFSortOrder,
JFAlbumListSort, JFAlbumListSort,
@ -6,8 +7,9 @@ import {
JFAlbumArtistListSort, JFAlbumArtistListSort,
JFArtistListSort, JFArtistListSort,
JFPlaylistListSort, JFPlaylistListSort,
JFGenreListSort,
} from './jellyfin.types'; } from './jellyfin.types';
import { jfType } from './jellyfin/jellyfin-types'; import { ndType } from './navidrome/navidrome-types';
import { import {
NDSortOrder, NDSortOrder,
NDOrder, NDOrder,
@ -16,13 +18,14 @@ import {
NDPlaylistListSort, NDPlaylistListSort,
NDSongListSort, NDSongListSort,
NDUserListSort, NDUserListSort,
NDGenreListSort,
} from './navidrome.types'; } from './navidrome.types';
import { ndType } from './navidrome/navidrome-types';
export enum LibraryItem { export enum LibraryItem {
ALBUM = 'album', ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist', ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist', ARTIST = 'artist',
GENRE = 'genre',
PLAYLIST = 'playlist', PLAYLIST = 'playlist',
SONG = 'song', SONG = 'song',
} }
@ -292,7 +295,40 @@ export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefine
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs; 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 // Album List
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined; 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 type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { QueryKey, useQueryClient } from '@tanstack/react-query'; import { QueryKey, useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import orderBy from 'lodash/orderBy';
import { generatePath, useNavigate } from 'react-router'; import { generatePath, useNavigate } from 'react-router';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys'; import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys';
@ -32,6 +33,7 @@ export type AgGridFetchFn<TResponse, TFilter> = (
interface UseAgGridProps<TFilter> { interface UseAgGridProps<TFilter> {
contextMenu: SetContextMenuItems; contextMenu: SetContextMenuItems;
customFilters?: Partial<TFilter>; customFilters?: Partial<TFilter>;
isClientSideSort?: boolean;
isSearchParams?: boolean; isSearchParams?: boolean;
itemCount?: number; itemCount?: number;
itemType: LibraryItem; itemType: LibraryItem;
@ -49,6 +51,7 @@ export const useVirtualTable = <TFilter>({
itemCount, itemCount,
customFilters, customFilters,
isSearchParams, isSearchParams,
isClientSideSort,
}: UseAgGridProps<TFilter>) => { }: UseAgGridProps<TFilter>) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
@ -104,6 +107,9 @@ export const useVirtualTable = <TFilter>({
if (itemType === LibraryItem.SONG) { if (itemType === LibraryItem.SONG) {
return queryKeys.songs.list; return queryKeys.songs.list;
} }
if (itemType === LibraryItem.GENRE) {
return queryKeys.genres.list;
}
return null; return null;
}, [itemType]); }, [itemType]);
@ -122,6 +128,9 @@ export const useVirtualTable = <TFilter>({
if (itemType === LibraryItem.SONG) { if (itemType === LibraryItem.SONG) {
return api.controller.getSongList; return api.controller.getSongList;
} }
if (itemType === LibraryItem.GENRE) {
return api.controller.getGenreList;
}
return null; return null;
}, [itemType]); }, [itemType]);
@ -160,6 +169,17 @@ export const useVirtualTable = <TFilter>({
return res; return res;
})) as BasePaginatedResponse<any>; })) 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); params.successCallback(results?.items || [], results?.totalRecordCount || 0);
}, },
rowCount: undefined, rowCount: undefined,
@ -168,7 +188,15 @@ export const useVirtualTable = <TFilter>({
params.api.setDatasource(dataSource); params.api.setDatasource(dataSource);
params.api.ensureIndexVisible(initialTableIndex, 'top'); params.api.ensureIndexVisible(initialTableIndex, 'top');
}, },
[initialTableIndex, queryKeyFn, server, properties.filter, queryClient, queryFn], [
initialTableIndex,
queryKeyFn,
server,
properties.filter,
queryClient,
isClientSideSort,
queryFn,
],
); );
const setParamsTablePagination = useCallback( const setParamsTablePagination = useCallback(
@ -206,10 +234,10 @@ export const useVirtualTable = <TFilter>({
if (isSearchParams) { if (isSearchParams) {
setSearchParams( setSearchParams(
(params) => { (params) => {
params.set('currentPage', String(event.api?.paginationGetCurrentPage())); params.set('currentPage', String(event.api.paginationGetCurrentPage()));
params.set('itemsPerPage', String(event.api?.paginationGetPageSize())); params.set('itemsPerPage', String(event.api.paginationGetPageSize()));
params.set('totalItems', String(event.api?.paginationGetRowCount())); params.set('totalItems', String(event.api.paginationGetRowCount()));
params.set('totalPages', String(event.api?.paginationGetTotalPages() + 1)); params.set('totalPages', String(event.api.paginationGetTotalPages() + 1));
return params; return params;
}, },
{ replace: true }, { replace: true },

View file

@ -84,6 +84,12 @@ export const PLAYLIST_TABLE_COLUMNS = [
{ label: 'Actions', value: TableColumn.ACTIONS }, { 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 { interface TableConfigDropdownProps {
type: TableType; type: TableType;
} }

View file

@ -2,7 +2,7 @@ import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo, useState } from 'react'; import { ChangeEvent, useMemo, useState } from 'react';
import { useListFilterByKey } from '../../../store/list.store'; 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 { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { useGenreList } from '/@/renderer/features/genres'; import { useGenreList } from '/@/renderer/features/genres';
@ -27,7 +27,14 @@ export const JellyfinAlbumFilters = ({
const { setFilter } = useListStoreActions(); const { setFilter } = useListStoreActions();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library // 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(() => { const genreList = useMemo(() => {
if (!genreListQuery?.data) return []; if (!genreListQuery?.data) return [];

View file

@ -5,7 +5,7 @@ import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/rend
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres'; import { useGenreList } from '/@/renderer/features/genres';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; 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 { interface NavidromeAlbumFiltersProps {
customFilters?: Partial<AlbumListFilter>; customFilters?: Partial<AlbumListFilter>;
@ -25,7 +25,14 @@ export const NavidromeAlbumFilters = ({
const { filter } = useListStoreByKey({ key: pageKey }); const { filter } = useListStoreByKey({ key: pageKey });
const { setFilter } = useListStoreActions(); 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(() => { const genreList = useMemo(() => {
if (!genreListQuery?.data) return []; 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 { useQuery } from '@tanstack/react-query';
import { controller } from '/@/renderer/api/controller'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import type { GenreListQuery } from '/@/renderer/api/types'; import type { GenreListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query'; import type { QueryHookArgs } from '/@/renderer/lib/react-query';
@ -14,7 +14,7 @@ export const useGenreList = (args: QueryHookArgs<GenreListQuery>) => {
enabled: !!server, enabled: !!server,
queryFn: ({ signal }) => { queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found'); 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 || ''), queryKey: queryKeys.genres.list(server?.id || ''),
staleTime: 1000 * 60 * 60, 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 debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react'; import { ChangeEvent, useMemo } from 'react';
import { useListFilterByKey } from '../../../store/list.store'; 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 { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres'; import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListStoreActions } from '/@/renderer/store'; import { SongListFilter, useListStoreActions } from '/@/renderer/store';
@ -24,7 +24,14 @@ export const JellyfinSongFilters = ({
const { filter } = useListFilterByKey({ key: pageKey }); const { filter } = useListFilterByKey({ key: pageKey });
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library // 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(() => { const genreList = useMemo(() => {
if (!genreListQuery?.data) return []; if (!genreListQuery?.data) return [];

View file

@ -1,7 +1,7 @@
import { Divider, Group, Stack } from '@mantine/core'; import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react'; 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 { NumberInput, Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres'; import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
@ -22,7 +22,14 @@ export const NavidromeSongFilters = ({
const { setFilter } = useListStoreActions(); const { setFilter } = useListStoreActions();
const filter = useListFilterByKey({ key: pageKey }); 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(() => { const genreList = useMemo(() => {
if (!genreListQuery?.data) return []; if (!genreListQuery?.data) return [];

View file

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

View file

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

View file

@ -8,6 +8,8 @@ import {
AlbumArtistListSort, AlbumArtistListSort,
AlbumListArgs, AlbumListArgs,
AlbumListSort, AlbumListSort,
GenreListArgs,
GenreListSort,
LibraryItem, LibraryItem,
PlaylistListArgs, PlaylistListArgs,
PlaylistListSort, PlaylistListSort,
@ -26,10 +28,14 @@ export type AlbumListFilter = Omit<AlbumListArgs['query'], 'startIndex' | 'limit
export type SongListFilter = Omit<SongListArgs['query'], 'startIndex' | 'limit'>; export type SongListFilter = Omit<SongListArgs['query'], 'startIndex' | 'limit'>;
export type AlbumArtistListFilter = Omit<AlbumArtistListArgs['query'], 'startIndex' | 'limit'>; export type AlbumArtistListFilter = Omit<AlbumArtistListArgs['query'], 'startIndex' | 'limit'>;
export type PlaylistListFilter = Omit<PlaylistListArgs['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
type FilterType = AlbumListFilter | SongListFilter | AlbumArtistListFilter | PlaylistListFilter; | SongListFilter
| AlbumArtistListFilter
| PlaylistListFilter
| GenreListFilter;
export type ListTableProps = { export type ListTableProps = {
pagination: TablePagination; pagination: TablePagination;
@ -58,11 +64,14 @@ export interface ListState {
albumArtistAlbum: ListItemProps<AlbumListFilter>; albumArtistAlbum: ListItemProps<AlbumListFilter>;
albumArtistSong: ListItemProps<SongListFilter>; albumArtistSong: ListItemProps<SongListFilter>;
albumDetail: ListItemProps<any>; albumDetail: ListItemProps<any>;
genre: ListItemProps<GenreListFilter>;
playlist: ListItemProps<PlaylistListFilter>; playlist: ListItemProps<PlaylistListFilter>;
song: ListItemProps<SongListFilter>; song: ListItemProps<SongListFilter>;
}; };
} }
export type ListKey = keyof ListState['item'] | string;
export type ListDeterministicArgs = { key: ListKey }; export type ListDeterministicArgs = { key: ListKey };
export interface ListSlice extends ListState { export interface ListSlice extends ListState {
@ -479,6 +488,35 @@ export const useListStore = create<ListSlice>()(
scrollOffset: 0, 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: { playlist: {
display: ListDisplayType.POSTER, display: ListDisplayType.POSTER,
filter: { filter: {