From d3132ad57041609ccee8160071037a8df68b9329 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 24 Nov 2025 20:19:09 -0800 Subject: [PATCH] reimplement genre detail route --- .../api/jellyfin/jellyfin-controller.ts | 20 ++-- .../api/navidrome/navidrome-controller.ts | 4 +- .../api/subsonic/subsonic-controller.ts | 36 +++--- .../item-list/helpers/get-title-path.ts | 2 +- .../columns/genre-badge-column.tsx | 2 +- .../item-table-list/columns/genre-column.tsx | 2 +- .../albums/components/album-list-content.tsx | 24 +++- .../components/album-list-header-filters.tsx | 34 +++++- .../components/jellyfin-album-filters.tsx | 4 +- .../components/navidrome-album-filters.tsx | 26 +---- .../components/subsonic-album-filters.tsx | 2 +- .../features/genres/api/genres-api.ts | 28 ++++- .../components/genre-detail-content.tsx | 69 ++++++++++++ .../genres/components/genre-detail-header.tsx | 106 ++++++++++++++++++ .../genres/routes/genre-detail-route.tsx | 56 +++++++++ .../home/components/featured-genres.tsx | 2 +- .../shared/components/list-search-input.tsx | 5 +- .../shared/hooks/use-search-term-filter.ts | 8 +- .../components/jellyfin-song-filters.tsx | 17 +-- .../components/navidrome-song-filters.tsx | 13 +-- .../songs/components/song-list-content.tsx | 24 +++- .../components/song-list-header-filters.tsx | 34 +++++- .../components/subsonic-song-filters.tsx | 20 +--- .../features/songs/routes/song-list-route.tsx | 59 +--------- src/renderer/hooks/use-genre-route.ts | 11 +- src/renderer/router/app-router.tsx | 12 +- src/renderer/router/routes.ts | 5 +- src/renderer/store/settings.store.ts | 2 + src/shared/components/icon/icon.tsx | 2 + src/shared/types/domain-types.ts | 4 +- 30 files changed, 443 insertions(+), 190 deletions(-) create mode 100644 src/renderer/features/genres/components/genre-detail-content.tsx create mode 100644 src/renderer/features/genres/components/genre-detail-header.tsx create mode 100644 src/renderer/features/genres/routes/genre-detail-route.tsx diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index eaa82c1ed..db829b7d5 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -208,7 +208,7 @@ export const JellyfinController: InternalControllerEndpoint = { Fields: 'Genres, DateCreated, ExternalUrls, Overview', ImageTypeLimit: 1, Limit: query.limit, - ParentId: query.musicFolderId, + ParentId: getMusicFolderId(query.musicFolderId), Recursive: true, SearchTerm: query.searchTerm, SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name', @@ -316,11 +316,11 @@ export const JellyfinController: InternalControllerEndpoint = { query: { ...artistQuery, Fields: 'People, Tags', - GenreIds: query.genres ? query.genres.join(',') : undefined, + GenreIds: query.genreIds ? query.genreIds.join(',') : undefined, IncludeItemTypes: 'MusicAlbum', IsFavorite: query.favorite, Limit: query.limit, - ParentId: query.musicFolderId, + ParentId: getMusicFolderId(query.musicFolderId), Recursive: true, SearchTerm: query.searchTerm, SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName', @@ -354,7 +354,7 @@ export const JellyfinController: InternalControllerEndpoint = { Fields: 'Genres, DateCreated, ExternalUrls, Overview', ImageTypeLimit: 1, Limit: query.limit, - ParentId: query.musicFolderId, + ParentId: getMusicFolderId(query.musicFolderId), Recursive: true, SearchTerm: query.searchTerm, SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name', @@ -398,7 +398,7 @@ export const JellyfinController: InternalControllerEndpoint = { EnableTotalRecordCount: true, Fields: 'ItemCounts', Limit: query.limit, - ParentId: query?.musicFolderId, + ParentId: getMusicFolderId(query.musicFolderId), Recursive: true, SearchTerm: query?.searchTerm, SortBy: genreListSortMap.jellyfin[query.sortBy] || 'SortName', @@ -588,7 +588,7 @@ export const JellyfinController: InternalControllerEndpoint = { ? true : undefined, Limit: query.limit, - ParentId: query.musicFolderId, + ParentId: getMusicFolderId(query.musicFolderId), Recursive: true, SortBy: JFSongListSort.RANDOM, SortOrder: JFSortOrder.ASC, @@ -742,7 +742,7 @@ export const JellyfinController: InternalControllerEndpoint = { IncludeItemTypes: 'Audio', IsFavorite: query.favorite, Limit: query.limit, - ParentId: query.musicFolderId, + ParentId: getMusicFolderId(query.musicFolderId), Recursive: true, SearchTerm: query.searchTerm, SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName', @@ -777,7 +777,7 @@ export const JellyfinController: InternalControllerEndpoint = { IncludeItemTypes: 'Audio', IsFavorite: query.favorite, Limit: query.limit, - ParentId: query.musicFolderId, + ParentId: getMusicFolderId(query.musicFolderId), Recursive: true, SearchTerm: query.searchTerm, SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName', @@ -1160,3 +1160,7 @@ export const JellyfinController: InternalControllerEndpoint = { // totalRecordCount: res.body.TotalRecordCount, // }; // }; + +function getMusicFolderId(musicFolderId?: string | string[]) { + return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId; +} diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 154fbba9d..4ff23be3b 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -284,8 +284,8 @@ export const NavidromeController: InternalControllerEndpoint = { const { apiClientProps, query } = args; const genres = hasFeature(apiClientProps.server, ServerFeature.BFR) - ? query.genres - : query.genres?.[0]; + ? query.genreIds + : query.genreIds?.[0]; const res = await ndApiClient(apiClientProps).getAlbumList({ query: { diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 0963cf7e5..11524041c 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -219,7 +219,7 @@ export const SubsonicController: InternalControllerEndpoint = { const res = await ssApiClient(apiClientProps).getArtists({ query: { - musicFolderId: query.musicFolderId, + musicFolderId: getMusicFolderId(query.musicFolderId), }, }); @@ -340,7 +340,7 @@ export const SubsonicController: InternalControllerEndpoint = { if (query.favorite) { const res = await ssApiClient(apiClientProps).getStarred({ query: { - musicFolderId: query.musicFolderId, + musicFolderId: getMusicFolderId(query.musicFolderId), }, }); @@ -360,7 +360,7 @@ export const SubsonicController: InternalControllerEndpoint = { }; } - if (query.genres?.length) { + if (query.genreIds?.length) { type = AlbumListSortType.BY_GENRE; } @@ -397,8 +397,8 @@ export const SubsonicController: InternalControllerEndpoint = { const res = await ssApiClient(apiClientProps).getAlbumList2({ query: { fromYear, - genre: query.genres?.length ? query.genres[0] : undefined, - musicFolderId: query.musicFolderId, + genre: query.genreIds?.length ? query.genreIds[0] : undefined, + musicFolderId: getMusicFolderId(query.musicFolderId), offset: query.startIndex, size: query.limit, toYear, @@ -485,7 +485,7 @@ export const SubsonicController: InternalControllerEndpoint = { if (query.favorite) { const res = await ssApiClient(apiClientProps).getStarred({ query: { - musicFolderId: query.musicFolderId, + musicFolderId: getMusicFolderId(query.musicFolderId), }, }); @@ -502,7 +502,7 @@ export const SubsonicController: InternalControllerEndpoint = { let startIndex = 0; let totalRecordCount = 0; - if (query.genres?.length) { + if (query.genreIds?.length) { type = AlbumListSortType.BY_GENRE; } @@ -530,8 +530,8 @@ export const SubsonicController: InternalControllerEndpoint = { const res = await ssApiClient(apiClientProps).getAlbumList2({ query: { fromYear, - genre: query.genres?.length ? query.genres[0] : undefined, - musicFolderId: query.musicFolderId, + genre: query.genreIds?.length ? query.genreIds[0] : undefined, + musicFolderId: getMusicFolderId(query.musicFolderId), offset: startIndex, size: MAX_SUBSONIC_ITEMS, toYear, @@ -567,7 +567,7 @@ export const SubsonicController: InternalControllerEndpoint = { const res = await ssApiClient(apiClientProps).getArtists({ query: { - musicFolderId: query.musicFolderId, + musicFolderId: getMusicFolderId(query.musicFolderId), }, }); @@ -783,7 +783,7 @@ export const SubsonicController: InternalControllerEndpoint = { query: { fromYear: query.minYear, genre: query.genre, - musicFolderId: query.musicFolderId, + musicFolderId: getMusicFolderId(query.musicFolderId), size: query.limit, toYear: query.maxYear, }, @@ -938,7 +938,7 @@ export const SubsonicController: InternalControllerEndpoint = { query: { count: query.limit, genre: query.genreIds[0], - musicFolderId: query.musicFolderId, + musicFolderId: getMusicFolderId(query.musicFolderId), offset: query.startIndex, }, }); @@ -959,7 +959,7 @@ export const SubsonicController: InternalControllerEndpoint = { if (query.favorite) { const res = await ssApiClient(apiClientProps).getStarred({ query: { - musicFolderId: query.musicFolderId, + musicFolderId: getMusicFolderId(query.musicFolderId), }, }); @@ -1127,7 +1127,7 @@ export const SubsonicController: InternalControllerEndpoint = { query: { count: 1, genre: query.genreIds[0], - musicFolderId: query.musicFolderId, + musicFolderId: getMusicFolderId(query.musicFolderId), offset: sectionIndex, }, }); @@ -1152,7 +1152,7 @@ export const SubsonicController: InternalControllerEndpoint = { query: { count: MAX_SUBSONIC_ITEMS, genre: query.genreIds[0], - musicFolderId: query.musicFolderId, + musicFolderId: getMusicFolderId(query.musicFolderId), offset: startIndex, }, }); @@ -1175,7 +1175,7 @@ export const SubsonicController: InternalControllerEndpoint = { if (query.favorite) { const res = await ssApiClient(apiClientProps).getStarred({ query: { - musicFolderId: query.musicFolderId, + musicFolderId: getMusicFolderId(query.musicFolderId), }, }); @@ -1423,3 +1423,7 @@ export const SubsonicController: InternalControllerEndpoint = { return null; }, }; + +function getMusicFolderId(musicFolderId?: string | string[]) { + return Array.isArray(musicFolderId) ? musicFolderId[0] : musicFolderId; +} diff --git a/src/renderer/components/item-list/helpers/get-title-path.ts b/src/renderer/components/item-list/helpers/get-title-path.ts index 910422a63..9bf780a80 100644 --- a/src/renderer/components/item-list/helpers/get-title-path.ts +++ b/src/renderer/components/item-list/helpers/get-title-path.ts @@ -12,7 +12,7 @@ export const getTitlePath = (itemType: LibraryItem, id: string) => { case LibraryItem.ARTIST: return generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, { artistId: id }); case LibraryItem.GENRE: - return generatePath(AppRoute.LIBRARY_GENRES_ALBUMS, { genreId: id }); + return generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: id }); case LibraryItem.PLAYLIST: return generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: id }); default: diff --git a/src/renderer/components/item-list/item-table-list/columns/genre-badge-column.tsx b/src/renderer/components/item-list/item-table-list/columns/genre-badge-column.tsx index 427f81443..b4229e95e 100644 --- a/src/renderer/components/item-list/item-table-list/columns/genre-badge-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/genre-badge-column.tsx @@ -26,7 +26,7 @@ const GenreBadgeColumn = (props: ItemTableListInnerColumn) => { if (!row) return []; return row.map((genre) => { const { color, isLight } = stringToColor(genre.name); - const path = generatePath(AppRoute.LIBRARY_GENRES_ALBUMS, { genreId: genre.id }); + const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id }); return { ...genre, color, isLight, path }; }); }, [row]); diff --git a/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx b/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx index c3f4b1578..090eb15c4 100644 --- a/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx @@ -22,7 +22,7 @@ const GenreColumn = (props: ItemTableListInnerColumn) => { const genres = useMemo(() => { if (!row) return []; return row.map((genre) => { - const path = generatePath(AppRoute.LIBRARY_GENRES_ALBUMS, { + const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id, }); return { ...genre, path }; diff --git a/src/renderer/features/albums/components/album-list-content.tsx b/src/renderer/features/albums/components/album-list-content.tsx index abac11954..7463cd106 100644 --- a/src/renderer/features/albums/components/album-list-content.tsx +++ b/src/renderer/features/albums/components/album-list-content.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useMemo } from 'react'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store'; @@ -46,7 +46,7 @@ export const AlbumListContent = () => { ); }; -export type OverrideAlbumListQuery = Omit; +export type OverrideAlbumListQuery = Omit, 'limit' | 'startIndex'>; export const AlbumListView = ({ display, @@ -60,6 +60,18 @@ export const AlbumListView = ({ const { query } = useAlbumListFilters(); + const mergedQuery = useMemo(() => { + if (!overrideQuery) { + return query; + } + + return { + ...overrideQuery, + sortBy: overrideQuery.sortBy || query.sortBy, + sortOrder: overrideQuery.sortOrder || query.sortOrder, + }; + }, [query, overrideQuery]); + switch (display) { case ListDisplayType.GRID: { switch (pagination) { @@ -69,7 +81,7 @@ export const AlbumListView = ({ gap={grid.itemGap} itemsPerPage={itemsPerPage} itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined} - query={overrideQuery ?? query} + query={mergedQuery} serverId={server.id} /> ); @@ -80,7 +92,7 @@ export const AlbumListView = ({ gap={grid.itemGap} itemsPerPage={itemsPerPage} itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined} - query={overrideQuery ?? query} + query={mergedQuery} serverId={server.id} /> ); @@ -101,7 +113,7 @@ export const AlbumListView = ({ enableRowHoverHighlight={table.enableRowHoverHighlight} enableVerticalBorders={table.enableVerticalBorders} itemsPerPage={itemsPerPage} - query={overrideQuery ?? query} + query={mergedQuery} serverId={server.id} size={table.size} /> @@ -117,7 +129,7 @@ export const AlbumListView = ({ enableRowHoverHighlight={table.enableRowHoverHighlight} enableVerticalBorders={table.enableVerticalBorders} itemsPerPage={itemsPerPage} - query={overrideQuery ?? query} + query={mergedQuery} serverId={server.id} size={table.size} /> diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index 6970ef7bd..03f2d8c62 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -1,19 +1,51 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; +import { GenreTarget, useGenreTarget, useSettingsStoreActions } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; import { AlbumListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; -export const AlbumListHeaderFilters = () => { +export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget?: boolean }) => { + const { t } = useTranslation(); + const target = useGenreTarget(); + const { setGenreBehavior } = useSettingsStoreActions(); + + const choice = useMemo(() => { + return target === GenreTarget.ALBUM + ? t('entity.album_other', { postProcess: 'titleCase' }) + : t('entity.track_other', { postProcess: 'titleCase' }); + }, [target, t]); + + const handleToggleGenreTarget = () => { + setGenreBehavior(target === GenreTarget.ALBUM ? GenreTarget.TRACK : GenreTarget.ALBUM); + }; + return ( + {toggleGenreTarget && ( + <> + + + + )} { setAlbumArtist(e ?? null); diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index dedbd6fb4..9e7c59e5c 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -9,7 +9,7 @@ import { } from '/@/renderer/components/select-with-invalid-data'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; -import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; +import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; import { useCurrentServer } from '/@/renderer/store'; import { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types'; @@ -22,12 +22,7 @@ import { Stack } from '/@/shared/components/stack/stack'; import { Switch } from '/@/shared/components/switch/switch'; import { Text } from '/@/shared/components/text/text'; import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select'; -import { - AlbumArtistListSort, - GenreListSort, - LibraryItem, - SortOrder, -} from '/@/shared/types/domain-types'; +import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; interface NavidromeAlbumFiltersProps { @@ -52,20 +47,7 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil setRecentlyPlayed, } = useAlbumListFilters(); - const genreListQuery = useQuery( - genresQueries.list({ - options: { - gcTime: 1000 * 60 * 2, - staleTime: 1000 * 60 * 1, - }, - query: { - sortBy: GenreListSort.NAME, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId, - }), - ); + const genreListQuery = useGenreList(); const genreList = useMemo(() => { if (!genreListQuery?.data) return []; @@ -152,7 +134,7 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil label: artist.name, value: artist.id, })); - }, [albumArtistListQuery?.data?.items]); + }, [albumArtistListQuery.data?.items]); const handleTagFilter = debounce((tag: string, e: null | string) => { setCustom((prev) => ({ diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index 9a12c5fbd..01daee0d3 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -105,7 +105,7 @@ export const SubsonicAlbumFilters = ({ const handleGenresFilter = debounce((e: null | string) => { setGenreId(e ?? null); const updatedFilters: Partial = { - genres: e ? [e] : undefined, + genreIds: e ? [e] : undefined, }; onFilterChange(updatedFilters as AlbumListFilter); }, 250); diff --git a/src/renderer/features/genres/api/genres-api.ts b/src/renderer/features/genres/api/genres-api.ts index bdaaa66d6..13965b259 100644 --- a/src/renderer/features/genres/api/genres-api.ts +++ b/src/renderer/features/genres/api/genres-api.ts @@ -1,9 +1,15 @@ -import { queryOptions } from '@tanstack/react-query'; +import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { GenreListQuery, ListCountQuery } from '/@/shared/types/domain-types'; +import { useCurrentServerId } from '/@/renderer/store'; +import { + GenreListQuery, + GenreListSort, + ListCountQuery, + SortOrder, +} from '/@/shared/types/domain-types'; export const genresQueries = { list: (args: QueryHookArgs) => { @@ -37,3 +43,21 @@ export const genresQueries = { }); }, }; + +export const useGenreList = () => { + const serverId = useCurrentServerId(); + + return useSuspenseQuery({ + ...genresQueries.list({ + query: { + limit: -1, + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }), + gcTime: Infinity, + staleTime: Infinity, + }); +}; diff --git a/src/renderer/features/genres/components/genre-detail-content.tsx b/src/renderer/features/genres/components/genre-detail-content.tsx new file mode 100644 index 000000000..7f877b6ec --- /dev/null +++ b/src/renderer/features/genres/components/genre-detail-content.tsx @@ -0,0 +1,69 @@ +import { Suspense, useMemo } from 'react'; +import { useParams } from 'react-router'; + +import { AlbumListView } from '/@/renderer/features/albums/components/album-list-content'; +import { SongListView } from '/@/renderer/features/songs/components/song-list-content'; +import { GenreTarget, useGenreTarget, useListSettings } from '/@/renderer/store'; +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { ItemListKey } from '/@/shared/types/types'; + +export const GenreDetailContent = () => { + const genreTarget = useGenreTarget(); + + switch (genreTarget) { + case GenreTarget.ALBUM: + return ; + case GenreTarget.TRACK: + return ; + default: + return null; + } +}; + +function GenreDetailContentAlbums() { + const { genreId } = useParams() as { genreId: string }; + const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM); + + const overrideQuery = useMemo(() => { + return { + genreIds: [genreId], + }; + }, [genreId]); + + return ( + }> + + + ); +} + +function GenreDetailContentSongs() { + const { genreId } = useParams() as { genreId: string }; + const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.SONG); + + const overrideQuery = useMemo(() => { + return { + genreIds: [genreId], + }; + }, [genreId]); + + return ( + }> + + + ); +} diff --git a/src/renderer/features/genres/components/genre-detail-header.tsx b/src/renderer/features/genres/components/genre-detail-header.tsx new file mode 100644 index 000000000..9854f165b --- /dev/null +++ b/src/renderer/features/genres/components/genre-detail-header.tsx @@ -0,0 +1,106 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { PageHeader } from '/@/renderer/components/page-header/page-header'; +import { useListContext } from '/@/renderer/context/list-context'; +import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters'; +import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; +import { FilterBar } from '/@/renderer/features/shared/components/filter-bar'; +import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar'; +import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input'; +import { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters'; +import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; +import { GenreTarget, useGenreTarget } from '/@/renderer/store'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +interface GenreDetailHeaderProps { + title?: string; +} + +export const GenreDetailHeader = ({ title }: GenreDetailHeaderProps) => { + const { t } = useTranslation(); + + const { itemCount } = useListContext(); + const pageTitle = title || t('page.genreList.title', { postProcess: 'titleCase' }); + + const genreTarget = useGenreTarget(); + + return ( + + + + + {pageTitle} + + {itemCount} + + + + + + + + {genreTarget === GenreTarget.ALBUM ? ( + + ) : ( + + )} + + + ); +}; + +const PlayButton = () => { + const genreTarget = useGenreTarget(); + + switch (genreTarget) { + case GenreTarget.ALBUM: + return ; + case GenreTarget.TRACK: + return ; + default: + return null; + } +}; + +const AlbumPlayButton = () => { + const { query } = useAlbumListFilters(); + const { id } = useListContext(); + + const mergedQuery = useMemo(() => { + return { + ...query, + genreIds: [id], + }; + }, [query, id]); + + return ( + + ); +}; + +const SongPlayButton = () => { + const { query } = useSongListFilters(); + const { id } = useListContext(); + + const mergedQuery = useMemo(() => { + return { + ...query, + genreIds: [id], + }; + }, [query, id]); + + return ( + + ); +}; diff --git a/src/renderer/features/genres/routes/genre-detail-route.tsx b/src/renderer/features/genres/routes/genre-detail-route.tsx new file mode 100644 index 000000000..caa753f58 --- /dev/null +++ b/src/renderer/features/genres/routes/genre-detail-route.tsx @@ -0,0 +1,56 @@ +import { useMemo, useState } from 'react'; +import { useLocation, useParams } from 'react-router'; + +import { ListContext } from '/@/renderer/context/list-context'; +import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; +import { GenreDetailContent } from '/@/renderer/features/genres/components/genre-detail-content'; +import { GenreDetailHeader } from '/@/renderer/features/genres/components/genre-detail-header'; +import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; +import { LibraryContainer } from '/@/renderer/features/shared/components/library-container'; +import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; + +const GenreDetailRoute = () => { + const { genreId } = useParams() as { genreId: string }; + const pageKey = 'genre'; + + const [itemCount, setItemCount] = useState(undefined); + + const providerValue = useMemo(() => { + return { + id: genreId, + itemCount, + pageKey, + setItemCount, + }; + }, [genreId, itemCount, pageKey, setItemCount]); + + const { data: genres } = useGenreList(); + + const name = useMemo(() => { + return genres?.items.find((g) => g.id === genreId)?.name || '—'; + }, [genreId, genres]); + + const location = useLocation(); + console.log('location', location.pathname); + + return ( + + + + + + + + + ); +}; + +const GenreDetailRouteWithBoundary = () => { + return ( + + + + ); +}; + +export default GenreDetailRouteWithBoundary; diff --git a/src/renderer/features/home/components/featured-genres.tsx b/src/renderer/features/home/components/featured-genres.tsx index 808134787..239fd88d0 100644 --- a/src/renderer/features/home/components/featured-genres.tsx +++ b/src/renderer/features/home/components/featured-genres.tsx @@ -99,7 +99,7 @@ export const FeaturedGenres = () => { return visibleGenres.map((genre: Genre) => { const { color, isLight } = stringToColor(genre.name); - const path = generatePath(AppRoute.LIBRARY_GENRES_ALBUMS, { genreId: genre.id }); + const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id }); return { ...genre, diff --git a/src/renderer/features/shared/components/list-search-input.tsx b/src/renderer/features/shared/components/list-search-input.tsx index 8d2645abc..f055a69cc 100644 --- a/src/renderer/features/shared/components/list-search-input.tsx +++ b/src/renderer/features/shared/components/list-search-input.tsx @@ -5,6 +5,9 @@ export const ListSearchInput = () => { const { searchTerm, setSearchTerm } = useSearchTermFilter(); return ( - setSearchTerm(e.target.value)} /> + setSearchTerm(e.target.value || null)} + /> ); }; diff --git a/src/renderer/features/shared/hooks/use-search-term-filter.ts b/src/renderer/features/shared/hooks/use-search-term-filter.ts index 7a41be944..f7ad10aa7 100644 --- a/src/renderer/features/shared/hooks/use-search-term-filter.ts +++ b/src/renderer/features/shared/hooks/use-search-term-filter.ts @@ -9,10 +9,14 @@ export const useSearchTermFilter = (defaultValue?: string) => { defaultValue ? parseAsString.withDefault(defaultValue) : parseAsString, ); - const debouncedSetSearchTerm = useDebouncedCallback(setSearchTerm, 300); + const handleSetSearchTerm = (value: null | string) => { + setSearchTerm(value === '' ? null : value); + }; + + const debouncedSetSearchTerm = useDebouncedCallback(handleSetSearchTerm, 300); return { - [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, + [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm || undefined, setSearchTerm: debouncedSetSearchTerm, }; }; diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index ce35e7b72..09a2a1c85 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -4,7 +4,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; -import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; +import { genresQueries, useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; import { SongListFilter, useCurrentServer } from '/@/renderer/store'; @@ -29,20 +29,10 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps) // Despite the fact that getTags returns genres, it only returns genre names. // We prefer using IDs, hence the double query - const genreListQuery = useQuery( - genresQueries.list({ - query: { - musicFolderId: query.musicFolderId, - sortBy: GenreListSort.NAME, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId: server.id, - }), - ); + const genreListQuery = useGenreList(); const genreList = useMemo(() => { - if (!genreListQuery?.data) return []; + if (!genreListQuery.data) return []; return genreListQuery.data.items.map((genre) => ({ label: genre.name, value: genre.id, @@ -52,7 +42,6 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps) const tagsQuery = useQuery( sharedQueries.tags({ query: { - folder: query.musicFolderId, type: LibraryItem.SONG, }, serverId: server.id, diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index 71929dce7..bc03269f7 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -7,7 +7,7 @@ import { MultiSelectWithInvalidData, SelectWithInvalidData, } from '/@/renderer/components/select-with-invalid-data'; -import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; +import { genresQueries, useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; import { SongListFilter, @@ -46,16 +46,7 @@ export const NavidromeSongFilters = ({ const isGenrePage = customFilters?.genreIds !== undefined; - const genreListQuery = useQuery( - genresQueries.list({ - query: { - sortBy: GenreListSort.NAME, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId, - }), - ); + const genreListQuery = useGenreList(); const tagsQuery = useQuery( sharedQueries.tags({ diff --git a/src/renderer/features/songs/components/song-list-content.tsx b/src/renderer/features/songs/components/song-list-content.tsx index 191b7e400..2ea57a2a0 100644 --- a/src/renderer/features/songs/components/song-list-content.tsx +++ b/src/renderer/features/songs/components/song-list-content.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useMemo } from 'react'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store'; @@ -43,7 +43,7 @@ export const SongListContent = () => { ); }; -export type OverrideSongListQuery = Omit; +export type OverrideSongListQuery = Omit, 'limit' | 'startIndex'>; export const SongListView = ({ display, @@ -57,6 +57,18 @@ export const SongListView = ({ const { query } = useSongListFilters(); + const mergedQuery = useMemo(() => { + if (!overrideQuery) { + return query; + } + + return { + ...overrideQuery, + sortBy: overrideQuery.sortBy || query.sortBy, + sortOrder: overrideQuery.sortOrder || query.sortOrder, + }; + }, [query, overrideQuery]); + switch (display) { case ListDisplayType.GRID: { switch (pagination) { @@ -66,7 +78,7 @@ export const SongListView = ({ gap={grid.itemGap} itemsPerPage={itemsPerPage} itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined} - query={overrideQuery ?? query} + query={mergedQuery} serverId={server.id} /> ); @@ -76,7 +88,7 @@ export const SongListView = ({ gap={grid.itemGap} itemsPerPage={itemsPerPage} itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined} - query={overrideQuery ?? query} + query={mergedQuery} serverId={server.id} /> ); @@ -96,7 +108,7 @@ export const SongListView = ({ enableRowHoverHighlight={table.enableRowHoverHighlight} enableVerticalBorders={table.enableVerticalBorders} itemsPerPage={itemsPerPage} - query={overrideQuery ?? query} + query={mergedQuery} serverId={server.id} size={table.size} /> @@ -111,7 +123,7 @@ export const SongListView = ({ enableRowHoverHighlight={table.enableRowHoverHighlight} enableVerticalBorders={table.enableVerticalBorders} itemsPerPage={itemsPerPage} - query={overrideQuery ?? query} + query={mergedQuery} serverId={server.id} size={table.size} /> diff --git a/src/renderer/features/songs/components/song-list-header-filters.tsx b/src/renderer/features/songs/components/song-list-header-filters.tsx index d90c08bd4..216275290 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -1,19 +1,51 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; +import { GenreTarget, useGenreTarget, useSettingsStoreActions } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; import { LibraryItem, SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; -export const SongListHeaderFilters = () => { +export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget?: boolean }) => { + const { t } = useTranslation(); + const target = useGenreTarget(); + const { setGenreBehavior } = useSettingsStoreActions(); + + const handleToggleGenreTarget = () => { + setGenreBehavior(target === GenreTarget.ALBUM ? GenreTarget.TRACK : GenreTarget.ALBUM); + }; + + const choice = useMemo(() => { + return target === GenreTarget.ALBUM + ? t('entity.album_other', { postProcess: 'titleCase' }) + : t('entity.track_other', { postProcess: 'titleCase' }); + }, [target, t]); + return ( + {toggleGenreTarget && ( + <> + + + + )} ; } export const SubsonicSongFilters = ({ customFilters }: SubsonicSongFiltersProps) => { - const server = useCurrentServer(); const { t } = useTranslation(); const { query, setFavorite, setGenreId } = useSongListFilters(); const isGenrePage = customFilters?.genreIds !== undefined; - const genreListQuery = useQuery( - genresQueries.list({ - query: { - sortBy: GenreListSort.NAME, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId: server.id, - }), - ); + const genreListQuery = useGenreList(); const genreList = useMemo(() => { - if (!genreListQuery?.data) return []; + if (!genreListQuery.data) return []; return genreListQuery.data.items.map((genre) => ({ label: genre.name, value: genre.id, diff --git a/src/renderer/features/songs/routes/song-list-route.tsx b/src/renderer/features/songs/routes/song-list-route.tsx index 91ce065b1..0157beb0c 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -1,82 +1,31 @@ -import { useQuery } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; -import { useParams, useSearchParams } from 'react-router'; import { ListContext } from '/@/renderer/context/list-context'; -import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { LibraryContainer } from '/@/renderer/features/shared/components/library-container'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { SongListContent } from '/@/renderer/features/songs/components/song-list-content'; import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header'; -import { useCurrentServer } from '/@/renderer/store'; -import { GenreListSort, SortOrder } from '/@/shared/types/domain-types'; const SongListRoute = () => { - const server = useCurrentServer(); - const [searchParams] = useSearchParams(); - const { albumArtistId, genreId } = useParams(); - - const pageKey = albumArtistId ? `albumArtistSong` : 'song'; - - // const customFilters = useMemo(() => { - // const value = { - // ...(albumArtistId && { artistIds: [albumArtistId] }), - // ...(genreId && { - // genreIds: [genreId], - // }), - // }; - - // if (isEmpty(value)) { - // return undefined; - // } - - // return value; - // }, [albumArtistId, genreId]); - - const genreList = useQuery( - genresQueries.list({ - options: { - enabled: !!genreId, - gcTime: 1000 * 60 * 60, - }, - query: { - sortBy: GenreListSort.NAME, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId: server?.id, - }), - ); - - const genreTitle = useMemo(() => { - if (!genreList.data) return ''; - const genre = genreList.data.items.find((g) => g.id === genreId); - - if (!genre) return 'Unknown'; - - return genre?.name; - }, [genreId, genreList.data]); + const pageKey = 'song'; const [itemCount, setItemCount] = useState(undefined); const providerValue = useMemo(() => { return { - id: albumArtistId ?? genreId, + id: undefined, itemCount, pageKey, setItemCount, }; - }, [albumArtistId, genreId, itemCount, pageKey, setItemCount]); - - const artist = searchParams.get('artistName'); - const title = artist ? artist : genreId ? genreTitle : undefined; + }, [itemCount, pageKey, setItemCount]); return ( - + diff --git a/src/renderer/hooks/use-genre-route.ts b/src/renderer/hooks/use-genre-route.ts index ba9496cb5..fb47d4f86 100644 --- a/src/renderer/hooks/use-genre-route.ts +++ b/src/renderer/hooks/use-genre-route.ts @@ -2,7 +2,6 @@ import { useMemo } from 'react'; import { useLocation } from 'react-router'; import { AppRoute } from '/@/renderer/router/routes'; -import { GenreTarget, useSettingsStore } from '/@/renderer/store'; const ALBUM_REGEX = /albums$/; const SONG_REGEX = /songs$/; @@ -12,18 +11,14 @@ export const useGenreRoute = () => { const matchAlbum = ALBUM_REGEX.test(pathname); const matchSongs = SONG_REGEX.test(pathname); - const baseState = useSettingsStore((state) => - state.general.genreTarget === GenreTarget.ALBUM - ? AppRoute.LIBRARY_GENRES_ALBUMS - : AppRoute.LIBRARY_GENRES_SONGS, - ); + const baseState = AppRoute.LIBRARY_GENRES_DETAIL; return useMemo(() => { if (matchAlbum) { - return AppRoute.LIBRARY_GENRES_ALBUMS; + return AppRoute.LIBRARY_GENRES_DETAIL; } if (matchSongs) { - return AppRoute.LIBRARY_GENRES_SONGS; + return AppRoute.LIBRARY_GENRES_DETAIL; } return baseState; }, [baseState, matchAlbum, matchSongs]); diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 1487c72e0..1ae57fc7c 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -63,6 +63,10 @@ const DummyAlbumDetailRoute = lazy( const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route')); +const GenreDetailRoute = lazy( + () => import('/@/renderer/features/genres/routes/genre-detail-route'), +); + const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route')); export const AppRouter = () => { @@ -91,12 +95,8 @@ export const AppRouter = () => { } index /> } - path={AppRoute.LIBRARY_GENRES_ALBUMS} - /> - } - path={AppRoute.LIBRARY_GENRES_SONGS} + element={} + path={AppRoute.LIBRARY_GENRES_DETAIL} /> export const usePrimaryColor = () => useSettingsStore((store) => store.general.accent); export const usePlayerbarSlider = () => useSettingsStore((store) => store.general.playerbarSlider); + +export const useGenreTarget = () => useSettingsStore((store) => store.general.genreTarget); diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx index 6a112c4af..e1f9b0c2b 100644 --- a/src/shared/components/icon/icon.tsx +++ b/src/shared/components/icon/icon.tsx @@ -12,6 +12,7 @@ import { LuArrowDownToLine, LuArrowDownWideNarrow, LuArrowLeft, + LuArrowLeftRight, LuArrowLeftToLine, LuArrowRight, LuArrowRightToLine, @@ -126,6 +127,7 @@ export const AppIcon = { arrowDownS: LuChevronDown, arrowDownToLine: LuArrowDownToLine, arrowLeft: LuArrowLeft, + arrowLeftRight: LuArrowLeftRight, arrowLeftS: LuChevronLeft, arrowLeftToLine: LuArrowLeftToLine, arrowRight: LuArrowRight, diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index ccf6b7ce9..6a301c52e 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -431,7 +431,7 @@ export interface AlbumListQuery extends AlbumListNavidromeQuery, BaseQuery { } // Artist List -export type ArtistListResponse = BasePaginatedResponse; +export type ArtistListResponse = BasePaginatedResponse; type ArtistListSortMap = { jellyfin: Record;