diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5988afa5..89f61fa8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -282,6 +282,8 @@ "moreFromGeneric": "more from {{item}}" }, "albumList": { + "artistAlbums": "Albums by {{artist}}", + "genreAlbums": "\"{{genre}}\" $t(entity.album_other)", "title": "$t(entity.album_other)" }, "appMenu": { @@ -336,6 +338,8 @@ "upNext": "up next" }, "genreList": { + "showAlbums": "show $t(entity.genre_one) $t(entity.album_other)", + "showTracks": "show $t(entity.genre_one) $t(entity.track_other)", "title": "$t(entity.genre_other)" }, "globalSearch": { @@ -474,6 +478,8 @@ "gaplessAudio": "gapless audio", "gaplessAudio_description": "sets the gapless audio setting for mpv", "gaplessAudio_optionWeak": "weak (recommended)", + "genreBehavior": "genre page default behavior", + "genreBehavior_description": "determines whether clicking on a genre opens by default in track or album list", "globalMediaHotkeys": "global media hotkeys", "globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback", "homeConfiguration": "home page configuration", diff --git a/src/renderer/components/virtual-table/cells/genre-cell.tsx b/src/renderer/components/virtual-table/cells/genre-cell.tsx index 9e24ab5e..9990f2e4 100644 --- a/src/renderer/components/virtual-table/cells/genre-cell.tsx +++ b/src/renderer/components/virtual-table/cells/genre-cell.tsx @@ -4,10 +4,11 @@ import { generatePath, Link } from 'react-router-dom'; import type { AlbumArtist, Artist } from '/@/renderer/api/types'; import { Text } from '/@/renderer/components/text'; import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell'; -import { AppRoute } from '/@/renderer/router/routes'; import { Separator } from '/@/renderer/components/separator'; +import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; export const GenreCell = ({ value, data }: ICellRendererParams) => { + const genrePath = useGenreRoute(); return ( { component={Link} overflow="hidden" size="md" - to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, { - genreId: item.id, - })} + to={generatePath(genrePath, { genreId: item.id })} > {item.name || '—'} diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index d75216ee..728e0608 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -45,6 +45,7 @@ import { } from '/@/renderer/store/settings.store'; import { Play } from '/@/renderer/types'; import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; +import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; const isFullWidthRow = (node: RowNode) => { return node.id?.startsWith('disc-'); @@ -81,6 +82,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP const isFocused = useAppFocus(); const currentSong = useCurrentSong(); const { externalLinks } = useGeneralSettings(); + const genreRoute = useGenreRoute(); const columnDefs = useMemo( () => getColumnDefs(tableConfig.columns, false, 'albumDetail'), @@ -389,7 +391,7 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP component={Link} radius={0} size="md" - to={generatePath(AppRoute.LIBRARY_GENRES_SONGS, { + to={generatePath(genreRoute, { genreId: genre.id, })} variant="outline" diff --git a/src/renderer/features/albums/components/album-list-header.tsx b/src/renderer/features/albums/components/album-list-header.tsx index 657802b0..9e503270 100644 --- a/src/renderer/features/albums/components/album-list-header.tsx +++ b/src/renderer/features/albums/components/album-list-header.tsx @@ -1,63 +1,59 @@ -import type { ChangeEvent, MutableRefObject } from 'react'; +import { useEffect, useRef, type ChangeEvent, type MutableRefObject } from 'react'; 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 { useTranslation } from 'react-i18next'; -import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh'; 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 { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters'; import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; import { useContainerQuery } from '/@/renderer/hooks'; -import { - AlbumListFilter, - useCurrentServer, - useListStoreActions, - useListStoreByKey, - usePlayButtonBehavior, -} from '/@/renderer/store'; -import { ListDisplayType } from '/@/renderer/types'; +import { AlbumListFilter, useCurrentServer, usePlayButtonBehavior } from '/@/renderer/store'; import { titleCase } from '/@/renderer/utils'; +import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; interface AlbumListHeaderProps { + genreId?: string; gridRef: MutableRefObject; itemCount?: number; tableRef: MutableRefObject; title?: string; } -export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumListHeaderProps) => { +export const AlbumListHeader = ({ + genreId, + itemCount, + gridRef, + tableRef, + title, +}: AlbumListHeaderProps) => { const { t } = useTranslation(); const server = useCurrentServer(); - const { setFilter, setTablePagination } = useListStoreActions(); const cq = useContainerQuery(); - const { pageKey, handlePlay } = useListContext(); - const { display, filter } = useListStoreByKey({ key: pageKey }); const playButtonBehavior = usePlayButtonBehavior(); - - const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + const genreRef = useRef(); + const { filter, handlePlay, refresh, search } = useDisplayRefresh({ + gridRef, itemType: LibraryItem.ALBUM, server, + tableRef, }); const handleSearch = debounce((e: ChangeEvent) => { - const searchTerm = e.target.value === '' ? undefined : e.target.value; - const updatedFilters = setFilter({ - data: { searchTerm }, - itemType: LibraryItem.ALBUM, - key: pageKey, - }) as AlbumListFilter; + const updatedFilters = search(e) as AlbumListFilter; - if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) { - handleRefreshTable(tableRef, updatedFilters); - setTablePagination({ data: { currentPage: 0 }, key: pageKey }); - } else { - handleRefreshGrid(gridRef, updatedFilters); - } + refresh(updatedFilters); }, 500); + useEffect(() => { + if (genreRef.current && genreRef.current !== genreId) { + refresh(filter); + } + + genreRef.current = genreId; + }, [filter, genreId, refresh, tableRef]); + return ( { + const { t } = useTranslation(); const gridRef = useRef(null); const tableRef = useRef(null); const server = useCurrentServer(); const [searchParams] = useSearchParams(); - const { albumArtistId } = useParams(); + const { albumArtistId, genreId } = useParams(); const pageKey = albumArtistId ? `albumArtistAlbum` : 'album'; const handlePlayQueueAdd = usePlayQueueAdd(); const customFilters = useMemo(() => { const value = { ...(albumArtistId && { artistIds: [albumArtistId] }), + ...(genreId && { + _custom: { + jellyfin: { + GenreIds: genreId, + }, + navidrome: { + genre_id: genreId, + }, + }, + }), }; if (isEmpty(value)) { @@ -35,13 +49,35 @@ const AlbumListRoute = () => { } return value; - }, [albumArtistId]); + }, [albumArtistId, genreId]); const albumListFilter = useListFilterByKey({ filter: customFilters, key: pageKey, }); + const genreList = useGenreList({ + options: { + cacheTime: 1000 * 60 * 60, + enabled: !!genreId, + }, + 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 itemCountCheck = useAlbumList({ options: { cacheTime: 1000 * 60, @@ -98,19 +134,27 @@ const AlbumListRoute = () => { return { customFilters, handlePlay, - id: albumArtistId ?? undefined, + id: albumArtistId ?? genreId, pageKey, }; - }, [albumArtistId, customFilters, handlePlay, pageKey]); + }, [albumArtistId, customFilters, genreId, handlePlay, pageKey]); + + const artist = searchParams.get('artistName'); + const title = artist + ? t('page.albumList.artistAlbums', { artist }) + : genreId + ? t('page.albumList.genreAlbums', { genre: titleCase(genreTitle) }) + : undefined; return ( ; @@ -29,30 +26,18 @@ export const AlbumArtistListHeader = ({ }: AlbumArtistListHeaderProps) => { const { t } = useTranslation(); const server = useCurrentServer(); - const { pageKey } = useListContext(); - const { display, filter } = useListStoreByKey({ key: pageKey }); - const { setFilter, setTablePagination } = useListStoreActions(); const cq = useContainerQuery(); - const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + const { filter, refresh, search } = useDisplayRefresh({ + gridRef, itemType: LibraryItem.ALBUM_ARTIST, server, + tableRef, }); const handleSearch = debounce((e: ChangeEvent) => { - const searchTerm = e.target.value === '' ? undefined : e.target.value; - const updatedFilters = setFilter({ - data: { searchTerm }, - itemType: LibraryItem.ALBUM_ARTIST, - key: pageKey, - }) as AlbumArtistListFilter; - - if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) { - handleRefreshTable(tableRef, updatedFilters); - setTablePagination({ data: { currentPage: 0 }, key: pageKey }); - } else { - handleRefreshGrid(gridRef, updatedFilters); - } + const updatedFilters = search(e) as AlbumArtistListFilter; + refresh(updatedFilters); }, 500); return ( diff --git a/src/renderer/features/genres/components/genre-list-grid-view.tsx b/src/renderer/features/genres/components/genre-list-grid-view.tsx index f261f882..37f92c49 100644 --- a/src/renderer/features/genres/components/genre-list-grid-view.tsx +++ b/src/renderer/features/genres/components/genre-list-grid-view.tsx @@ -13,9 +13,9 @@ import { } 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'; +import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; export const GenreListGridView = ({ gridRef, itemCount }: any) => { const queryClient = useQueryClient(); @@ -24,6 +24,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => { const { pageKey, id } = useListContext(); const { grid, display, filter } = useListStoreByKey({ key: pageKey }); const { setGrid } = useListStoreActions(); + const genrePath = useGenreRoute(); const [searchParams, setSearchParams] = useSearchParams(); const scrollOffset = searchParams.get('scrollOffset'); @@ -137,7 +138,7 @@ export const GenreListGridView = ({ gridRef, itemCount }: any) => { loading={itemCount === undefined || itemCount === null} minimumBatchSize={40} route={{ - route: AppRoute.LIBRARY_GENRES_SONGS, + route: genrePath, slugs: [{ idProperty: 'id', slugProperty: 'genreId' }], }} width={width} diff --git a/src/renderer/features/genres/components/genre-list-header-filters.tsx b/src/renderer/features/genres/components/genre-list-header-filters.tsx index 5c53bb73..794c8f7c 100644 --- a/src/renderer/features/genres/components/genre-list-header-filters.tsx +++ b/src/renderer/features/genres/components/genre-list-header-filters.tsx @@ -3,7 +3,14 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li import { Divider, Flex, Group, Stack } from '@mantine/core'; import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { RiFolder2Fill, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri'; +import { + RiAlbumLine, + RiFolder2Fill, + RiMoreFill, + RiMusic2Line, + RiRefreshLine, + RiSettings3Fill, +} from 'react-icons/ri'; import { queryKeys } from '/@/renderer/api/query-keys'; import { GenreListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types'; import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; @@ -15,9 +22,12 @@ import { useContainerQuery } from '/@/renderer/hooks'; import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; import { GenreListFilter, + GenreTarget, useCurrentServer, + useGeneralSettings, useListStoreActions, useListStoreByKey, + useSettingsStoreActions, } from '/@/renderer/store'; import { ListDisplayType, TableColumn } from '/@/renderer/types'; import i18n from '/@/i18n/i18n'; @@ -52,6 +62,8 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions(); const { display, filter, table, grid } = useListStoreByKey({ key: pageKey }); const cq = useContainerQuery(); + const { genreTarget } = useGeneralSettings(); + const { setGenreBehavior } = useSettingsStoreActions(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ itemType: LibraryItem.GENRE, @@ -208,6 +220,11 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil return filter.musicFolderId !== undefined; }, [filter.musicFolderId]); + const handleGenreToggle = useCallback(() => { + const newState = genreTarget === GenreTarget.ALBUM ? GenreTarget.TRACK : GenreTarget.ALBUM; + setGenreBehavior(newState); + }, [genreTarget, setGenreBehavior]); + return ( + + ; @@ -29,34 +22,18 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade const { t } = useTranslation(); const cq = useContainerQuery(); const server = useCurrentServer(); - const { pageKey } = useListContext(); - const { display, filter } = useListStoreByKey({ key: pageKey }); - const { setFilter, setTablePagination } = useListStoreActions(); - - const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + const { filter, refresh, search } = useDisplayRefresh({ + gridRef, itemType: LibraryItem.GENRE, server, + tableRef, }); const handleSearch = debounce((e: ChangeEvent) => { - 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); - } + const updatedFilters = search(e) as GenreListFilter; + refresh(updatedFilters); }, 500); + return ( const formatDate = (key: string | null) => (key ? dayjs(key).fromNow() : ''); -const formatGenre = (item: Album | AlbumArtist | Song) => - item.genres?.map((genre, index) => ( +const FormatGenre = (item: Album | AlbumArtist | Song) => { + const genreRoute = useGenreRoute(); + + return item.genres?.map((genre, index) => ( {index > 0 && } component={Link} overflow="visible" size="md" - to={ - genre.id - ? generatePath(AppRoute.LIBRARY_GENRES_SONGS, { - genreId: genre.id, - }) - : '' - } + to={genre.id ? generatePath(genreRoute, { genreId: genre.id }) : ''} weight={500} > {genre.name || '—'} )); +}; const formatRating = (item: Album | AlbumArtist | Song) => item.userRating !== null ? ( @@ -120,7 +118,7 @@ const BoolField = (key: boolean) => const AlbumPropertyMapping: ItemDetailRow[] = [ { key: 'name', label: 'common.title' }, { label: 'entity.albumArtist_one', render: formatArtists(true) }, - { label: 'entity.genre_other', render: formatGenre }, + { label: 'entity.genre_other', render: FormatGenre }, { label: 'common.duration', render: (album) => album.duration && formatDurationString(album.duration), @@ -166,7 +164,7 @@ const AlbumPropertyMapping: ItemDetailRow[] = [ const AlbumArtistPropertyMapping: ItemDetailRow[] = [ { key: 'name', label: 'common.name' }, - { label: 'entity.genre_other', render: formatGenre }, + { label: 'entity.genre_other', render: FormatGenre }, { label: 'common.duration', render: (artist) => artist.duration && formatDurationString(artist.duration), @@ -240,7 +238,7 @@ const SongPropertyMapping: ItemDetailRow[] = [ { key: 'discNumber', label: 'common.disc' }, { key: 'trackNumber', label: 'common.trackNumber' }, { key: 'releaseYear', label: 'filter.releaseYear' }, - { label: 'entity.genre_other', render: formatGenre }, + { label: 'entity.genre_other', render: FormatGenre }, { label: 'common.duration', render: (song) => formatDurationString(song.duration), diff --git a/src/renderer/features/playlists/components/playlist-list-header.tsx b/src/renderer/features/playlists/components/playlist-list-header.tsx index ec4f0138..faf1974e 100644 --- a/src/renderer/features/playlists/components/playlist-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header.tsx @@ -8,15 +8,12 @@ import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/cr import { PlaylistListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-list-header-filters'; import { LibraryHeaderBar } from '/@/renderer/features/shared'; import { useContainerQuery } from '/@/renderer/hooks'; -import { PlaylistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store'; -import { ListDisplayType } from '/@/renderer/types'; +import { PlaylistListFilter, useCurrentServer } from '/@/renderer/store'; import debounce from 'lodash/debounce'; import { useTranslation } from 'react-i18next'; import { RiFileAddFill } from 'react-icons/ri'; import { LibraryItem, ServerType } from '/@/renderer/api/types'; -import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh'; -import { useListContext } from '/@/renderer/context/list-context'; -import { useListStoreByKey } from '../../../store/list.store'; +import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; interface PlaylistListHeaderProps { gridRef: MutableRefObject; @@ -26,11 +23,8 @@ interface PlaylistListHeaderProps { export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistListHeaderProps) => { const { t } = useTranslation(); - const { pageKey } = useListContext(); const cq = useContainerQuery(); const server = useCurrentServer(); - const { setFilter, setTablePagination } = useListStoreActions(); - const { display, filter } = useListStoreByKey({ key: pageKey }); const handleCreatePlaylistModal = () => { openModal({ @@ -43,25 +37,16 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis }); }; - const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + const { filter, refresh, search } = useDisplayRefresh({ + gridRef, itemType: LibraryItem.PLAYLIST, server, + tableRef, }); const handleSearch = debounce((e: ChangeEvent) => { - const searchTerm = e.target.value === '' ? undefined : e.target.value; - const updatedFilters = setFilter({ - data: { searchTerm }, - itemType: LibraryItem.PLAYLIST, - key: pageKey, - }) as PlaylistListFilter; - - if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) { - handleRefreshTable(tableRef, updatedFilters); - setTablePagination({ data: { currentPage: 0 }, key: pageKey }); - } else { - handleRefreshGrid(gridRef, updatedFilters); - } + const updatedFilters = search(e) as PlaylistListFilter; + refresh(updatedFilters); }, 500); return ( diff --git a/src/renderer/features/settings/components/general/control-settings.tsx b/src/renderer/features/settings/components/general/control-settings.tsx index fb882d2a..57232129 100644 --- a/src/renderer/features/settings/components/general/control-settings.tsx +++ b/src/renderer/features/settings/components/general/control-settings.tsx @@ -4,6 +4,7 @@ import isElectron from 'is-electron'; import { Select, Tooltip, NumberInput, Switch, Slider } from '/@/renderer/components'; import { SettingsSection } from '/@/renderer/features/settings/components/settings-section'; import { + GenreTarget, SideQueueType, useGeneralSettings, useSettingsStoreActions, @@ -341,6 +342,41 @@ export const ControlSettings = () => { }), title: t('setting.externalLinks', { postProcess: 'sentenceCase' }), }, + { + control: ( +