diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 8a47d0c3e..ba36ae9e6 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -42,6 +42,7 @@ import { Spoiler } from '/@/shared/components/spoiler/spoiler'; import { Stack } from '/@/shared/components/stack/stack'; import { TextInput } from '/@/shared/components/text-input/text-input'; import { Text } from '/@/shared/components/text/text'; +import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value'; import { useHotkeys } from '/@/shared/hooks/use-hotkeys'; import { Album, @@ -439,6 +440,7 @@ interface AlbumDetailSongsTableProps { const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => { const { t } = useTranslation(); const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300); const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM_DETAIL]?.table); const currentSong = usePlayerSong(); @@ -452,11 +454,11 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => { const filteredSongs = useMemo(() => { return sortSongList( - searchLibraryItems(songs, searchTerm, LibraryItem.SONG), + searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG), sortBy, sortOrder, ); - }, [songs, searchTerm, sortBy, sortOrder]); + }, [songs, debouncedSearchTerm, sortBy, sortOrder]); const { handleColumnReordered } = useItemListColumnReorder({ itemListKey: ItemListKey.ALBUM_DETAIL, @@ -504,7 +506,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => { const groups = useMemo(() => { // Remove groups when filtering - if (searchTerm.trim()) { + if (debouncedSearchTerm.trim()) { return undefined; } @@ -590,7 +592,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => { }, rowHeight: 40, })); - }, [searchTerm, sortBy, discGroups, t]); + }, [debouncedSearchTerm, sortBy, discGroups, t]); const player = usePlayer(); diff --git a/src/renderer/features/artists/components/album-artist-detail-content.module.css b/src/renderer/features/artists/components/album-artist-detail-content.module.css index 28002029f..2260051a1 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.module.css +++ b/src/renderer/features/artists/components/album-artist-detail-content.module.css @@ -10,3 +10,31 @@ gap: var(--theme-spacing-2xl); padding: 1rem 2rem 5rem; } + +.album-section-container { + display: flex; + flex-direction: column; + gap: var(--theme-spacing-4xl); + width: 100%; + min-width: 0; +} + +.album-section-title { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--theme-spacing-md); + align-items: center; + margin-bottom: var(--theme-spacing-md); +} + +.album-section-divider-container { + display: flex; + align-items: center; + width: 100%; +} + +.album-section-divider { + width: 100%; + height: 2px; + background: var(--theme-colors-border); +} diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index 113eb18cf..18ab94503 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -1,47 +1,61 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; -import { Suspense, useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createSearchParams, generatePath, Link, useParams } from 'react-router'; import styles from './album-artist-detail-content.module.css'; +import { DataRow, MemoizedItemCard } from '/@/renderer/components/item-card/item-card'; +import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; +import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows'; import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder'; import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; import { ItemControls } from '/@/renderer/components/item-list/types'; -import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel'; +import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; -import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; +import { + CLIENT_SIDE_ALBUM_FILTERS, + ListSortByDropdownControlled, +} from '/@/renderer/features/shared/components/list-sort-by-dropdown'; +import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { searchLibraryItems } from '/@/renderer/features/shared/utils'; import { useContainerQuery } from '/@/renderer/hooks'; import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; import { AppRoute } from '/@/renderer/router/routes'; -import { ArtistItem, useCurrentServer, usePlayerSong } from '/@/renderer/store'; +import { + ArtistItem, + useAppStore, + useCurrentServer, + useCurrentServerId, + usePlayerSong, +} from '/@/renderer/store'; import { useGeneralSettings, useSettingsStore } from '/@/renderer/store/settings.store'; import { sanitize } from '/@/renderer/utils/sanitize'; +import { sortAlbumList } from '/@/shared/api/utils'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Button } from '/@/shared/components/button/button'; import { Grid } from '/@/shared/components/grid/grid'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; -import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spoiler } from '/@/shared/components/spoiler/spoiler'; import { Stack } from '/@/shared/components/stack/stack'; import { TextInput } from '/@/shared/components/text-input/text-input'; import { TextTitle } from '/@/shared/components/text-title/text-title'; import { Text } from '/@/shared/components/text/text'; +import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value'; +import { useHotkeys } from '/@/shared/hooks/use-hotkeys'; import { - AlbumArtist, + Album, + AlbumArtistDetailResponse, AlbumListSort, LibraryItem, - ServerType, Song, SortOrder, - TopSongListResponse, } from '/@/shared/types/domain-types'; import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types'; @@ -141,7 +155,7 @@ const AlbumArtistMetadataBiography = ({ return (
- + {t('page.albumArtistDetail.about', { artist: artistName, })} @@ -154,20 +168,29 @@ const AlbumArtistMetadataBiography = ({ }; interface AlbumArtistMetadataTopSongsProps { + detailQuery: ReturnType>; routeId: string; - topSongsQuery: ReturnType>; } const AlbumArtistMetadataTopSongs = ({ + detailQuery, routeId, - topSongsQuery, }: AlbumArtistMetadataTopSongsProps) => { const { t } = useTranslation(); const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300); const [showAll, setShowAll] = useState(false); const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table); const currentSong = usePlayerSong(); const player = usePlayer(); + const serverId = useCurrentServerId(); + + const topSongsQuery = useQuery( + artistsQueries.topSongs({ + query: { artist: detailQuery.data?.name || '', artistId: routeId }, + serverId: serverId, + }), + ); const songs = useMemo(() => topSongsQuery?.data?.items || [], [topSongsQuery?.data?.items]); @@ -176,13 +199,13 @@ const AlbumArtistMetadataTopSongs = ({ }, [tableConfig?.columns]); const filteredSongs = useMemo(() => { - const filtered = searchLibraryItems(songs, searchTerm, LibraryItem.SONG); + const filtered = searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG); // When searching, show all results. Otherwise, limit to 5 if not showing all - if (searchTerm.trim() || showAll) { + if (debouncedSearchTerm.trim() || showAll) { return filtered; } return filtered.slice(0, 5); - }, [songs, searchTerm, showAll]); + }, [songs, debouncedSearchTerm, showAll]); const { handleColumnReordered } = useItemListColumnReorder({ itemListKey: ItemListKey.SONG, @@ -216,7 +239,7 @@ const AlbumArtistMetadataTopSongs = ({
- + {t('page.albumArtistDetail.topSongs', { postProcess: 'sentenceCase', })} @@ -247,7 +270,7 @@ const AlbumArtistMetadataTopSongs = ({ - + {t('page.albumArtistDetail.topSongs', { postProcess: 'sentenceCase', })} @@ -404,14 +427,12 @@ const AlbumArtistMetadataExternalLinks = ({ }; export const AlbumArtistDetailContent = () => { - const { t } = useTranslation(); const { artistItems, externalLinks, lastFM, musicBrainz } = useGeneralSettings(); const { albumArtistId, artistId } = useParams() as { albumArtistId?: string; artistId?: string; }; const routeId = (artistId || albumArtistId) as string; - const { ref, ...cq } = useContainerQuery(); const server = useCurrentServer(); const [enabledItem, itemOrder] = useMemo(() => { @@ -433,105 +454,27 @@ export const AlbumArtistDetailContent = () => { }), ); - const artistDiscographyLink = `${generatePath( - AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, - { - albumArtistId: routeId, - }, - )}?${createSearchParams({ - artistId: routeId, - artistName: detailQuery.data?.name || '', - })}`; - - const artistSongsLink = `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS, { - albumArtistId: routeId, - })}?${createSearchParams({ - artistId: routeId, - artistName: detailQuery.data?.name || '', - })}`; - - const topSongsQuery = useQuery( - artistsQueries.topSongs({ - options: { - enabled: !!detailQuery.data?.name && enabledItem.topSongs, - }, - query: { - artist: detailQuery.data?.name || '', + const artistDiscographyLink = useMemo( + () => + `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, { + albumArtistId: routeId, + })}?${createSearchParams({ artistId: routeId, - }, - serverId: server?.id, - }), + artistName: detailQuery.data?.name || '', + })}`, + [routeId, detailQuery.data?.name], ); - const carousels = useMemo(() => { - return [ - { - isHidden: !enabledItem.recentAlbums || !routeId, - itemType: LibraryItem.ALBUM, - order: itemOrder.recentAlbums, - query: { - artistIds: routeId ? [routeId] : undefined, - compilation: false, - }, - rowCount: 2, - sortBy: AlbumListSort.RELEASE_DATE, - sortOrder: SortOrder.DESC, - title: ( - - {t('page.albumArtistDetail.recentReleases', { - postProcess: 'sentenceCase', - })} - - ), - uniqueId: 'recentReleases', - }, - { - isHidden: - !enabledItem.compilations || server?.type === ServerType.SUBSONIC || !routeId, - itemType: LibraryItem.ALBUM, - order: itemOrder.compilations, - query: { - artistIds: routeId ? [routeId] : undefined, - compilation: true, - }, - rowCount: 1, - sortBy: AlbumListSort.RELEASE_DATE, - sortOrder: SortOrder.DESC, - title: ( - - {t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase' })} - - ), - uniqueId: 'compilationAlbums', - }, - { - data: (detailQuery.data?.similarArtists || []) as AlbumArtist[], - isHidden: !detailQuery.data?.similarArtists || !enabledItem.similarArtists, - itemType: LibraryItem.ALBUM_ARTIST, - order: itemOrder.similarArtists, - rowCount: 1, - title: ( - - {t('page.albumArtistDetail.relatedArtists', { - postProcess: 'sentenceCase', - })} - - ), - uniqueId: 'similarArtists', - }, - ]; - }, [ - detailQuery.data?.similarArtists, - enabledItem.compilations, - enabledItem.recentAlbums, - enabledItem.similarArtists, - itemOrder.compilations, - itemOrder.recentAlbums, - itemOrder.similarArtists, - routeId, - server?.type, - t, - ]); + const artistSongsLink = useMemo( + () => + `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS, { + albumArtistId: routeId, + })}?${createSearchParams({ + artistId: routeId, + artistName: detailQuery.data?.name || '', + })}`, + [routeId, detailQuery.data?.name], + ); const biography = detailQuery.data?.biography && enabledItem.biography ? detailQuery.data.biography : null; @@ -544,7 +487,7 @@ export const AlbumArtistDetailContent = () => { const externalLinksOrder = 0.5; return ( -
+
{ /> )} - {Boolean(topSongsQuery?.data?.items?.length) && enabledItem.topSongs && ( + + + + {enabledItem.topSongs && ( )} - {cq.height || cq.width - ? carousels - .filter((c) => !c.isHidden) - .map((carousel) => ( - - }> - {carousel.itemType === LibraryItem.ALBUM ? ( - 'query' in carousel && - carousel.query && - carousel.sortBy && - carousel.sortOrder ? ( - - ) : null - ) : carousel.itemType === LibraryItem.ALBUM_ARTIST ? ( - 'data' in carousel && carousel.data ? ( - - ) : null - ) : null} - - - )) - : null}
); }; + +interface AlbumSectionProps { + albums: Album[]; + controls: ItemControls; + cq: ReturnType; + rows: DataRow[] | undefined; + title: string; +} + +const AlbumSection = ({ albums, controls, cq, rows, title }: AlbumSectionProps) => { + const span = cq.isXl ? 3 : cq.isLg ? 4 : cq.isMd ? 6 : cq.isSm ? 8 : cq.isXs ? 12 : 12; + + return ( + +
+ + {title} + +
+
+
+
+ + {albums.map((album) => ( + + + + ))} + + + ); +}; + +const ArtistAlbums = () => { + const { t } = useTranslation(); + const serverId = useCurrentServerId(); + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300); + const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort); + const setAlbumArtistDetailSort = useAppStore((state) => state.actions.setAlbumArtistDetailSort); + const sortBy = albumArtistDetailSort.sortBy; + const sortOrder = albumArtistDetailSort.sortOrder; + + const { albumArtistId, artistId } = useParams() as { + albumArtistId?: string; + artistId?: string; + }; + const routeId = (artistId || albumArtistId) as string; + + const albumsQuery = useSuspenseQuery( + albumQueries.list({ + query: { + artistIds: [routeId], + limit: -1, + sortBy: AlbumListSort.RELEASE_DATE, + sortOrder: SortOrder.DESC, + startIndex: 0, + }, + serverId, + }), + ); + + const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM); + const controls = useDefaultItemListControls(); + + const filteredAndSortedAlbums = useMemo(() => { + const albums = albumsQuery.data?.items || []; + const searched = searchLibraryItems(albums, debouncedSearchTerm, LibraryItem.ALBUM); + return sortAlbumList(searched, sortBy, sortOrder); + }, [albumsQuery.data?.items, debouncedSearchTerm, sortBy, sortOrder]); + + const albumsByReleaseType = useMemo(() => { + const albums = filteredAndSortedAlbums; + + const grouped = albums.reduce( + (acc, album) => { + // Priority 1: Appears on - artist is not an album artist + const isAlbumArtist = album.albumArtists?.some((artist) => artist.id === routeId); + if (!isAlbumArtist) { + const appearsOnKey = 'appears-on'; + if (!acc[appearsOnKey]) { + acc[appearsOnKey] = []; + } + acc[appearsOnKey].push(album); + return acc; + } + + // Priority 2: Compilations + if (album.isCompilation) { + const compilationKey = 'compilation'; + if (!acc[compilationKey]) { + acc[compilationKey] = []; + } + acc[compilationKey].push(album); + return acc; + } + + // Priority 3: Single (includes EP and other non-album types) + const hasAlbumType = album.releaseTypes?.some( + (type) => type.toLowerCase() === 'album', + ); + if (!hasAlbumType) { + const singleKey = 'single'; + if (!acc[singleKey]) { + acc[singleKey] = []; + } + acc[singleKey].push(album); + return acc; + } + + // Priority 4: Album + const albumKey = 'album'; + if (!acc[albumKey]) { + acc[albumKey] = []; + } + acc[albumKey].push(album); + return acc; + }, + {} as Record, + ); + + return grouped; + }, [filteredAndSortedAlbums, routeId]); + + const releaseTypeEntries = useMemo(() => { + const priorityOrder = ['album', 'single', 'compilation', 'appears-on']; + const getPriority = (releaseType: string) => { + const index = priorityOrder.indexOf(releaseType); + return index === -1 ? 999 : index; + }; + + return Object.entries(albumsByReleaseType) + .map(([releaseType, albums]) => { + let displayName: string; + switch (releaseType) { + case 'album': + displayName = t('releaseType.primary.album', { + postProcess: 'sentenceCase', + }); + break; + case 'appears-on': + displayName = t('page.albumArtistDetail.appearsOn', { + postProcess: 'sentenceCase', + }); + break; + case 'compilation': + displayName = t('releaseType.secondary.compilation', { + postProcess: 'sentenceCase', + }); + break; + case 'single': + displayName = t('releaseType.primary.single', { + postProcess: 'sentenceCase', + }); + break; + default: + displayName = releaseType; + } + return { albums, displayName, releaseType }; + }) + .sort((a, b) => getPriority(a.releaseType) - getPriority(b.releaseType)); + }, [albumsByReleaseType, t]); + + const cq = useContainerQuery(); + + const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch); + const searchInputRef = useRef(null); + + useHotkeys([ + [ + binding.hotkey, + () => { + searchInputRef.current?.focus(); + }, + ], + ]); + + if (releaseTypeEntries.length === 0) { + return null; + } + + return ( + + + } + onChange={(e) => setSearchTerm(e.target.value)} + placeholder={t('common.search', { postProcess: 'sentenceCase' })} + radius="xl" + ref={searchInputRef} + rightSection={ + searchTerm ? ( + setSearchTerm('')} + size="sm" + variant="transparent" + /> + ) : null + } + styles={{ + input: { + background: 'transparent', + border: '1px solid rgba(255, 255, 255, 0.05)', + }, + }} + value={searchTerm} + /> + + setAlbumArtistDetailSort(value as AlbumListSort, sortOrder) + } + sortBy={sortBy} + /> + setAlbumArtistDetailSort(sortBy, value as SortOrder)} + sortOrder={sortOrder} + /> + +
+ {releaseTypeEntries.map(({ albums, displayName, releaseType }) => ( + + ))} +
+
+ ); +}; diff --git a/src/renderer/features/artists/components/album-artist-detail-header.tsx b/src/renderer/features/artists/components/album-artist-detail-header.tsx index cd3e4b3e2..fab142811 100644 --- a/src/renderer/features/artists/components/album-artist-detail-header.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-header.tsx @@ -1,5 +1,5 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { forwardRef, Fragment, Ref } from 'react'; +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router'; @@ -34,16 +34,16 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref { - if (!server?.id || !routeId) return; - addToQueueByFetch( - server.id, - [routeId], - LibraryItem.ALBUM_ARTIST, - type || playButtonBehavior, - ); - }; + const handlePlay = useCallback( + (type?: Play) => { + if (!server?.id || !routeId) return; + addToQueueByFetch( + server.id, + [routeId], + LibraryItem.ALBUM_ARTIST, + type || playButtonBehavior, + ); + }, + [addToQueueByFetch, playButtonBehavior, routeId, server.id], + ); - const handleFavorite = () => { - if (!detailQuery?.data) return; + const handleFavorite = useCallback(() => { + if (!detailQuery.data) return; setFavorite( detailQuery.data._serverId, [detailQuery.data.id], LibraryItem.ALBUM_ARTIST, !detailQuery.data.userFavorite, ); - }; + }, [detailQuery.data, setFavorite]); - const handleUpdateRating = (rating: number) => { - if (!detailQuery?.data) return; + const handleUpdateRating = useCallback( + (rating: number) => { + if (!detailQuery.data) return; + + if (detailQuery.data.userRating === rating) { + return setRating( + detailQuery.data._serverId, + [detailQuery.data.id], + LibraryItem.ALBUM_ARTIST, + 0, + ); + } - if (detailQuery.data.userRating === rating) { return setRating( detailQuery.data._serverId, [detailQuery.data.id], LibraryItem.ALBUM_ARTIST, - 0, + rating, ); - } + }, + [detailQuery.data, setRating], + ); - return setRating( - detailQuery.data._serverId, - [detailQuery.data.id], - LibraryItem.ALBUM_ARTIST, - rating, - ); - }; + const handleMoreOptions = useCallback( + (e: React.MouseEvent) => { + if (!detailQuery.data) return; + ContextMenuController.call({ + cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST }, + event: e, + }); + }, + [detailQuery.data], + ); - const handleMoreOptions = (e: React.MouseEvent) => { - if (!detailQuery?.data) return; - ContextMenuController.call({ - cmd: { items: [detailQuery.data], type: LibraryItem.ALBUM_ARTIST }, - event: e, - }); - }; - - const showRating = detailQuery?.data?._serverType === ServerType.NAVIDROME; + const showRating = detailQuery.data?._serverType === ServerType.NAVIDROME; const imageUrl = useItemImageUrl({ - id: detailQuery?.data?.imageId || undefined, + id: detailQuery.data?.imageId || undefined, itemType: LibraryItem.ALBUM_ARTIST, type: 'itemCard', }); + const selectedImageUrl = useMemo(() => { + return detailQuery.data?.imageUrl || imageUrl; + }, [detailQuery.data?.imageUrl, imageUrl]); + return ( @@ -168,14 +181,14 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref handlePlay(type)} onRating={showRating ? handleUpdateRating : undefined} onShuffle={() => handlePlay(Play.SHUFFLE)} - rating={detailQuery?.data?.userRating || 0} + rating={detailQuery.data?.userRating || 0} /> diff --git a/src/renderer/features/artists/routes/album-artist-detail-route.tsx b/src/renderer/features/artists/routes/album-artist-detail-route.tsx index 55a291888..6f276a9ec 100644 --- a/src/renderer/features/artists/routes/album-artist-detail-route.tsx +++ b/src/renderer/features/artists/routes/album-artist-detail-route.tsx @@ -1,7 +1,8 @@ import { useSuspenseQuery } from '@tanstack/react-query'; -import { useRef } from 'react'; +import { Suspense, useRef } from 'react'; import { useLocation, useParams } from 'react-router'; +import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content'; @@ -16,6 +17,7 @@ import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { useFastAverageColor, useWaitForColorCalculation } from '/@/renderer/hooks'; import { useCurrentServer, useGeneralSettings } from '/@/renderer/store'; +import { Spinner } from '/@/shared/components/spinner/spinner'; import { LibraryItem } from '/@/shared/types/domain-types'; const AlbumArtistDetailRoute = () => { @@ -39,9 +41,17 @@ const AlbumArtistDetailRoute = () => { staleTime: 0, }); + const imageUrl = useItemImageUrl({ + id: detailQuery?.data?.imageId || undefined, + itemType: LibraryItem.ALBUM_ARTIST, + type: 'header', + }); + + const selectedImageUrl = imageUrl || detailQuery.data?.imageUrl; + const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({ id: artistId, - src: detailQuery.data?.imageUrl, + src: selectedImageUrl, srcLoaded: !detailQuery.isLoading, }); @@ -50,7 +60,7 @@ const AlbumArtistDetailRoute = () => { const showBlurredImage = artistBackground; const { isReady } = useWaitForColorCalculation({ - hasImage: !!detailQuery.data?.imageUrl, + hasImage: !!selectedImageUrl, isLoading: isColorLoading, routeId, showBlurredImage, @@ -86,7 +96,7 @@ const AlbumArtistDetailRoute = () => { ) : ( @@ -103,7 +113,9 @@ const AlbumArtistDetailRoute = () => { const AlbumArtistDetailRouteWithBoundary = () => { return ( - + }> + + ); }; diff --git a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx index 0024a1f0b..fd384a043 100644 --- a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx +++ b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx @@ -78,6 +78,7 @@ export const ListSortByDropdown = ({ interface ListSortByDropdownControlledProps { disabled?: boolean; + filters?: Array<{ defaultOrder: SortOrder; name: string; value: string }>; itemType: LibraryItem; setSortBy: Dispatch>; sortBy: string; @@ -86,6 +87,7 @@ interface ListSortByDropdownControlledProps { export const ListSortByDropdownControlled = ({ disabled, + filters, itemType, setSortBy, sortBy, @@ -93,8 +95,9 @@ export const ListSortByDropdownControlled = ({ }: ListSortByDropdownControlledProps) => { const server = useCurrentServer(); - const sortByLabel = - (itemType && FILTERS[itemType][server.type].find((f) => f.value === sortBy)?.name) || '—'; + const availableFilters = filters || (itemType && FILTERS[itemType]?.[server.type]) || []; + + const sortByLabel = availableFilters.find((f) => f.value === sortBy)?.name || '—'; const handleSortByChange = (sortBy: string) => { setSortBy(sortBy); @@ -112,7 +115,7 @@ export const ListSortByDropdownControlled = ({ )} - {FILTERS[itemType][server.type].map((f) => ( + {availableFilters.map((f) => ( > > = { diff --git a/src/renderer/hooks/use-container-query.ts b/src/renderer/hooks/use-container-query.ts index e450d0eab..c305348b8 100644 --- a/src/renderer/hooks/use-container-query.ts +++ b/src/renderer/hooks/use-container-query.ts @@ -17,10 +17,10 @@ export const useContainerQuery = (props?: UseContainerQueryProps) => { const isXs = width >= (xs || 360); const isSm = width >= (sm || 600); const isMd = width >= (md || 768); - const isLg = width >= (lg || 1200); - const isXl = width >= (xl || 1500); - const is2xl = width >= (xxl || 1920); - const is3xl = width >= (xxxl || 2560); + const isLg = width >= (lg || 960); + const isXl = width >= (xl || 1200); + const is2xl = width >= (xxl || 1440); + const is3xl = width >= (xxxl || 1920); const isCalculated = width !== 0; diff --git a/src/renderer/store/app.store.ts b/src/renderer/store/app.store.ts index 6e0f5549f..ced970693 100644 --- a/src/renderer/store/app.store.ts +++ b/src/renderer/store/app.store.ts @@ -3,10 +3,12 @@ import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { createWithEqualityFn } from 'zustand/traditional'; +import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types'; import { Platform } from '/@/shared/types/types'; export interface AppSlice extends AppState { actions: { + setAlbumArtistDetailSort: (sortBy: AlbumListSort, sortOrder: SortOrder) => void; setAppStore: (data: Partial) => void; setPageSidebar: (key: string, value: boolean) => void; setPrivateMode: (enabled: boolean) => void; @@ -17,6 +19,10 @@ export interface AppSlice extends AppState { } export interface AppState { + albumArtistDetailSort: { + sortBy: AlbumListSort; + sortOrder: SortOrder; + }; commandPalette: CommandPaletteProps; isReorderingQueue: boolean; pageSidebar: Record; @@ -53,6 +59,11 @@ export const useAppStore = createWithEqualityFn()( devtools( immer((set, get) => ({ actions: { + setAlbumArtistDetailSort: (sortBy, sortOrder) => { + set((state) => { + state.albumArtistDetailSort = { sortBy, sortOrder }; + }); + }, setAppStore: (data) => { set({ ...get(), ...data }); }, @@ -86,6 +97,10 @@ export const useAppStore = createWithEqualityFn()( }); }, }, + albumArtistDetailSort: { + sortBy: AlbumListSort.RELEASE_DATE, + sortOrder: SortOrder.DESC, + }, commandPalette: { close: () => { set((state) => { diff --git a/src/renderer/utils/normalize-release-types.tsx b/src/renderer/utils/normalize-release-types.tsx index 365443701..1e47741fe 100644 --- a/src/renderer/utils/normalize-release-types.tsx +++ b/src/renderer/utils/normalize-release-types.tsx @@ -1,4 +1,4 @@ -import { TFunction } from 'react-i18next'; +import { TFunction } from 'i18next'; import { titleCase } from '/@/renderer/utils/title-case'; @@ -55,3 +55,42 @@ export const normalizeReleaseTypes = (types: string[], t: TFunction) => { return primary.concat(secondary, unknown); }; + +export const normalizeToPrimaryReleaseTypes = (types: string[], t: TFunction) => { + const primary: string[] = []; + for (const type of types) { + const lower = type.toLocaleLowerCase(); + if (lower in PRIMARY_MAPPING) { + primary.push( + t(`releaseType.primary.${PRIMARY_MAPPING[lower]}`, { postProcess: 'sentenceCase' }), + ); + } + } + + // If no primary types found, use "other" category + if (primary.length === 0) { + primary.push( + t(`releaseType.primary.${PRIMARY_MAPPING.other}`, { postProcess: 'sentenceCase' }), + ); + } + + return primary; +}; + +export const normalizeToSecondaryReleaseTypes = (types: string[], t: TFunction) => { + const secondary: string[] = []; + for (const type of types) { + const lower = type.toLocaleLowerCase(); + if (lower in SECONDARY_MAPPING) { + secondary.push( + t(`releaseType.secondary.${SECONDARY_MAPPING[lower]}`, { + postProcess: 'sentenceCase', + }), + ); + } + } + + secondary.sort(); + + return secondary; +}; diff --git a/src/shared/api/navidrome/navidrome-normalize.ts b/src/shared/api/navidrome/navidrome-normalize.ts index 37d5cd447..a0ce4140f 100644 --- a/src/shared/api/navidrome/navidrome-normalize.ts +++ b/src/shared/api/navidrome/navidrome-normalize.ts @@ -268,7 +268,7 @@ const normalizeAlbum = ( originalDate: item.originalDate ? new Date(item.originalDate).toISOString() : null, playCount: item.playCount || 0, releaseDate: item.releaseDate ? new Date(item.releaseDate).toISOString() : null, - releaseYear: item.minYear || null, + releaseYear: item.maxYear || null, size: item.size, songCount: item.songCount, songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined, @@ -285,7 +285,7 @@ const normalizeAlbumArtist = ( }, server?: null | ServerListItem, ): AlbumArtist => { - const imageUrl = getImageUrl({ url: item?.largeImageUrl || null }); + const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null }); let albumCount: number; let songCount: number; diff --git a/src/shared/api/utils.ts b/src/shared/api/utils.ts index 763bb3c2e..9f4aa2b92 100644 --- a/src/shared/api/utils.ts +++ b/src/shared/api/utils.ts @@ -414,6 +414,25 @@ export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: case AlbumListSort.RECENTLY_PLAYED: results = orderBy(results, ['lastPlayedAt'], [order]); break; + case AlbumListSort.RELEASE_DATE: + results = orderBy( + results, + [ + (v) => { + if (v.releaseDate) { + return new Date(v.releaseDate).getTime(); + } + + // Fallback to the first day of the release year + if (v.releaseYear) { + return new Date(v.releaseYear, 0, 1).getTime(); + } + return 0; + }, + ], + [order], + ); + break; case AlbumListSort.SONG_COUNT: results = orderBy(results, ['songCount'], [order]); break;