import { useQuery, useQueryClient, useSuspenseQuery, UseSuspenseQueryResult, } from '@tanstack/react-query'; import { LayoutGroup, motion } from 'motion/react'; import { Suspense, useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createSearchParams, generatePath, Link, useLocation, useParams } from 'react-router'; import styles from './album-artist-detail-content.module.css'; import { queryKeys } from '/@/renderer/api/query-keys'; 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 { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel'; import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context'; import { ListConfigMenu, SONG_DISPLAY_TYPES, } 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 { LONG_PRESS_PLAY_BEHAVIOR, PlayTooltip, } from '/@/renderer/features/shared/components/play-button-group'; import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click'; import { searchLibraryItems } from '/@/renderer/features/shared/utils'; import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { useContainerQuery } from '/@/renderer/hooks'; import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; import { AppRoute } from '/@/renderer/router/routes'; import { ArtistItem, ArtistReleaseTypeItem, useAppStore, useCurrentServer, useCurrentServerId, usePlayerSong, } from '/@/renderer/store'; import { useArtistItems, useArtistRadioCount, useArtistReleaseTypeItems, useExternalLinks, useSettingsStore, } from '/@/renderer/store/settings.store'; import { titleCase } from '/@/renderer/utils'; import { sanitize } from '/@/renderer/utils/sanitize'; import { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils'; import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { Badge } from '/@/shared/components/badge/badge'; import { Button } from '/@/shared/components/button/button'; import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { Grid } from '/@/shared/components/grid/grid'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; 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 { useLocalStorage } from '/@/shared/hooks/use-local-storage'; import { Album, AlbumArtist, AlbumArtistDetailResponse, AlbumListResponse, AlbumListSort, LibraryItem, RelatedArtist, ServerType, Song, SortOrder, } from '/@/shared/types/domain-types'; import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types'; const collator = new Intl.Collator(); interface AlbumArtistActionButtonsProps { artistDiscographyLink: string; artistSongsLink: string; onArtistRadio?: () => void; } const AlbumArtistActionButtons = ({ artistDiscographyLink, artistSongsLink, onArtistRadio, }: AlbumArtistActionButtonsProps) => { const { t } = useTranslation(); const isPlayerFetching = useIsPlayerFetching(); return ( <> {onArtistRadio && ( )} ); }; interface AlbumArtistMetadataGenresProps { genres?: Array<{ id: string; name: string }>; order?: number; } const AlbumArtistMetadataGenres = ({ genres, order }: AlbumArtistMetadataGenresProps) => { const { t } = useTranslation(); const genrePath = useGenreRoute(); if (!genres || genres.length === 0) return null; return ( {t('entity.genre', { count: genres.length, })} {genres.map((genre) => ( ))} ); }; interface AlbumArtistMetadataBiographyProps { artistName?: string; order?: number; routeId: string; } const AlbumArtistMetadataBiography = ({ artistName, order, routeId, }: AlbumArtistMetadataBiographyProps) => { const { t } = useTranslation(); const server = useCurrentServer(); const artistInfoQuery = useQuery({ ...artistsQueries.albumArtistInfo({ query: { id: routeId, limit: 10 }, serverId: server?.id, }), enabled: Boolean(server?.id && routeId), }); const detailQuery = useQuery({ ...artistsQueries.albumArtistDetail({ query: { id: routeId }, serverId: server?.id, }), enabled: Boolean(server?.id && routeId), }); const biography = artistInfoQuery.data?.biography || detailQuery.data?.biography; const isLoading = !biography && (artistInfoQuery.isLoading || detailQuery.isLoading); const sanitizedBiography = biography ? sanitize(biography) : ''; if (isLoading) { return (
{t('page.albumArtistDetail.about', { artist: artistName, })}
); } if (!biography) { return null; } return (
{t('page.albumArtistDetail.about', { artist: artistName, })}
); }; const TABLE_ROW_HEIGHT = { compact: 40, default: 64, large: 88, } as const; const TABLE_HEADER_HEIGHT = 40; interface SongTableListContainerProps { children: React.ReactNode; enableHeader?: boolean; itemCount: number; maxRows?: number; tableSize?: 'compact' | 'default' | 'large'; } function getTableRowHeight(size: 'compact' | 'default' | 'large' | undefined): number { return size ? TABLE_ROW_HEIGHT[size] : TABLE_ROW_HEIGHT.default; } const SongTableListContainer = ({ children, enableHeader = true, itemCount, maxRows = 5, tableSize = 'default', }: SongTableListContainerProps) => { const rowHeight = getTableRowHeight(tableSize); const headerOffset = enableHeader ? TABLE_HEADER_HEIGHT : 0; const height = headerOffset + rowHeight * Math.min(itemCount, maxRows); return
{children}
; }; interface AlbumArtistMetadataTopSongsProps { detailQuery: ReturnType>; order?: number; routeId: string; } const AlbumArtistMetadataTopSongsContent = ({ detailQuery, order, routeId, }: AlbumArtistMetadataTopSongsProps) => { const { t } = useTranslation(); const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300); const [topSongsQueryType, setTopSongsQueryType] = useLocalStorage<'community' | 'personal'>({ defaultValue: 'community', key: 'album-artist-top-songs-query-type', }); const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table); const currentSong = usePlayerSong(); const player = usePlayer(); const serverId = useCurrentServerId(); const server = useCurrentServer(); const canStartQuery = server?.type === ServerType.JELLYFIN || !!detailQuery.data?.name; const topSongsQuery = useQuery({ ...artistsQueries.topSongs({ query: { artist: detailQuery.data?.name || '', artistId: routeId, type: topSongsQueryType, }, serverId: serverId, }), enabled: canStartQuery, }); const songs = useMemo(() => topSongsQuery.data?.items || [], [topSongsQuery.data?.items]); const columns = useMemo(() => { return tableConfig?.columns || []; }, [tableConfig?.columns]); const filteredSongs = useMemo(() => { return searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG); }, [songs, debouncedSearchTerm]); const { handleColumnReordered } = useItemListColumnReorder({ itemListKey: ItemListKey.SONG, }); const { handleColumnResized } = useItemListColumnResize({ itemListKey: ItemListKey.SONG, }); const overrideControls: Partial = useMemo(() => { return { onDoubleClick: ({ index, internalState, item, meta }) => { if (!item) { return; } const playType = (meta?.playType as Play) || Play.NOW; const items = internalState?.getData() as Song[]; if (index !== undefined) { player.addToQueueByData(items, playType, item.id); } }, }; }, [player]); const handlePlay = useCallback( (playType: Play) => { if (songs.length === 0) return; player.addToQueueByData(songs, playType); }, [songs, player], ); const handlePlayNext = usePlayButtonClick({ onClick: () => handlePlay(Play.NEXT), onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]), }); const handlePlayNow = usePlayButtonClick({ onClick: () => handlePlay(Play.NOW), onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]), }); const handlePlayLast = usePlayButtonClick({ onClick: () => handlePlay(Play.LAST), onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]), }); const isLoading = topSongsQuery.isLoading || !topSongsQuery.data; if (!isLoading && !tableConfig) return null; if (!isLoading && songs.length === 0) return null; const currentSongId = currentSong?.id; return (
{t('page.albumArtistDetail.topSongs', { postProcess: 'sentenceCase', })} {!isLoading && {songs.length}}
{songs.length > 0 && ( )}
{isLoading ? ( ) : tableConfig ? ( <> } onChange={(e) => setSearchTerm(e.target.value)} placeholder={t('common.search', { postProcess: 'sentenceCase', })} radius="xl" rightSection={ searchTerm ? ( setSearchTerm('')} size="sm" variant="transparent" /> ) : null } styles={{ input: { background: 'transparent', border: '1px solid rgba(255, 255, 255, 0.05)', }, }} value={searchTerm} /> setTopSongsQueryType(value as 'community' | 'personal') } size="xs" value={topSongsQueryType} /> ) : null}
); }; const AlbumArtistMetadataTopSongs = ({ detailQuery, order, routeId, }: AlbumArtistMetadataTopSongsProps) => { const server = useCurrentServer(); const location = useLocation(); const artistName = location.state?.item?.name || detailQuery.data?.name; const canStartQuery = server?.type === ServerType.JELLYFIN || !!artistName; return ( {canStartQuery ? ( ) : null} ); }; interface AlbumArtistMetadataFavoriteSongsProps { order?: number; routeId: string; } const AlbumArtistMetadataFavoriteSongs = ({ order, routeId, }: AlbumArtistMetadataFavoriteSongsProps) => { const { t } = useTranslation(); const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300); const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table); const currentSong = usePlayerSong(); const player = usePlayer(); const serverId = useCurrentServerId(); const favoriteSongsQuery = useQuery({ ...artistsQueries.favoriteSongs({ query: { artistId: routeId, }, serverId: serverId, }), }); const songs = useMemo( () => favoriteSongsQuery.data?.items || [], [favoriteSongsQuery.data?.items], ); const columns = useMemo(() => { return tableConfig?.columns || []; }, [tableConfig?.columns]); const filteredSongs = useMemo(() => { return searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG); }, [songs, debouncedSearchTerm]); const { handleColumnReordered } = useItemListColumnReorder({ itemListKey: ItemListKey.SONG, }); const { handleColumnResized } = useItemListColumnResize({ itemListKey: ItemListKey.SONG, }); const overrideControls: Partial = useMemo(() => { return { onDoubleClick: ({ index, internalState, item, meta }) => { if (!item) { return; } const playType = (meta?.playType as Play) || Play.NOW; const items = internalState?.getData() as Song[]; if (index !== undefined) { player.addToQueueByData(items, playType, item.id); } }, }; }, [player]); const handlePlay = useCallback( (playType: Play) => { if (songs.length === 0) return; player.addToQueueByData(songs, playType); }, [songs, player], ); const handlePlayNext = usePlayButtonClick({ onClick: () => handlePlay(Play.NEXT), onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]), }); const handlePlayNow = usePlayButtonClick({ onClick: () => handlePlay(Play.NOW), onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]), }); const handlePlayLast = usePlayButtonClick({ onClick: () => handlePlay(Play.LAST), onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]), }); const isLoading = favoriteSongsQuery.isLoading || !favoriteSongsQuery.data; if (!isLoading && !tableConfig) return null; if (!isLoading && songs.length === 0) return null; const currentSongId = currentSong?.id; return (
{t('page.albumArtistDetail.favoriteSongs', { postProcess: 'sentenceCase', })} {!isLoading && {songs.length}}
{songs.length > 0 && ( )}
{isLoading ? ( ) : tableConfig ? ( <> } onChange={(e) => setSearchTerm(e.target.value)} placeholder={t('common.search', { postProcess: 'sentenceCase', })} radius="xl" rightSection={ searchTerm ? ( setSearchTerm('')} size="sm" variant="transparent" /> ) : null } styles={{ input: { background: 'transparent', border: '1px solid rgba(255, 255, 255, 0.05)', }, }} value={searchTerm} /> ) : null}
); }; interface AlbumArtistMetadataExternalLinksProps { artistName?: string; externalLinks: boolean; lastFM: boolean; mbzId?: null | string; musicBrainz: boolean; order?: number; } const AlbumArtistMetadataExternalLinks = ({ artistName, externalLinks, lastFM, mbzId, musicBrainz, order, }: AlbumArtistMetadataExternalLinksProps) => { const { t } = useTranslation(); if (!externalLinks || (!lastFM && !musicBrainz)) return null; return ( {t('common.externalLinks', { postProcess: 'sentenceCase', })} {lastFM && ( )} {mbzId && musicBrainz ? ( ) : null} ); }; interface AlbumArtistMetadataSimilarArtistsProps { order?: number; routeId: string; } const AlbumArtistMetadataSimilarArtists = ({ order, routeId, }: AlbumArtistMetadataSimilarArtistsProps) => { const { t } = useTranslation(); const server = useCurrentServer(); const serverId = useCurrentServerId(); const artistInfoQuery = useQuery({ ...artistsQueries.albumArtistInfo({ query: { id: routeId, limit: 10 }, serverId: server?.id, }), enabled: Boolean(server?.id && routeId), }); const relatedArtists = artistInfoQuery.data?.similarArtists ?? null; const similarArtists = useMemo(() => { if (!relatedArtists || relatedArtists.length === 0) { return []; } return relatedArtists.map( (relatedArtist: RelatedArtist): AlbumArtist => ({ _itemType: LibraryItem.ALBUM_ARTIST, _serverId: serverId || '', _serverType: (server?.type as ServerType) || ServerType.JELLYFIN, albumCount: null, biography: null, duration: null, genres: [], id: relatedArtist.id, imageId: relatedArtist.imageId, imageUrl: relatedArtist.imageUrl, lastPlayedAt: null, mbz: null, name: relatedArtist.name, playCount: null, similarArtists: null, songCount: null, userFavorite: relatedArtist.userFavorite, userRating: relatedArtist.userRating, }), ); }, [relatedArtists, server?.type, serverId]); const carouselTitle = useMemo( () => (
{t('page.albumArtistDetail.relatedArtists', { postProcess: 'sentenceCase', })}
), [t], ); if (!artistInfoQuery.isLoading && similarArtists.length === 0) { return null; } return ( ); }; interface AlbumArtistDetailContentProps { albumsQuery: UseSuspenseQueryResult; detailQuery: UseSuspenseQueryResult; } export const AlbumArtistDetailContent = ({ albumsQuery, detailQuery, }: AlbumArtistDetailContentProps) => { const artistItems = useArtistItems(); const artistRadioCount = useArtistRadioCount(); const { externalLinks, lastFM, musicBrainz } = useExternalLinks(); const { albumArtistId, artistId } = useParams() as { albumArtistId?: string; artistId?: string; }; const routeId = (artistId || albumArtistId) as string; const server = useCurrentServer(); const { addToQueueByData } = usePlayer(); const queryClient = useQueryClient(); const [enabledItem, itemOrder] = useMemo(() => { const enabled: { [key in ArtistItem]?: boolean } = {}; const order: { [key in ArtistItem]?: number } = {}; for (const [idx, item] of artistItems.entries()) { enabled[item.id] = !item.disabled; order[item.id] = idx + 1; } return [enabled, order]; }, [artistItems]); const artistDiscographyLink = useMemo( () => `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, { albumArtistId: routeId, })}?${createSearchParams({ artistId: routeId, artistName: detailQuery.data?.name || '', })}`, [routeId, detailQuery.data?.name], ); const artistSongsLink = useMemo( () => `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS, { albumArtistId: routeId, })}?${createSearchParams({ artistId: routeId, artistName: detailQuery.data?.name || '', })}`, [routeId, detailQuery.data?.name], ); const mbzId = detailQuery.data?.mbz; const handleArtistRadio = useCallback(async () => { if (!server?.id || !routeId) return; try { const artistRadioSongs = await queryClient.fetchQuery({ ...songsQueries.artistRadio({ query: { artistId: routeId, count: artistRadioCount, }, serverId: server.id, }), queryKey: queryKeys.player.fetch({ artistId: routeId }), }); if (artistRadioSongs && artistRadioSongs.length > 0) { addToQueueByData(artistRadioSongs, Play.NOW); } } catch (error) { console.error('Failed to load artist radio:', error); } }, [addToQueueByData, artistRadioCount, queryClient, routeId, server.id]); // Calculate order for genres and external links (show before other sections) // Use a very low order number to ensure they appear first const genresOrder = 0; const externalLinksOrder = 0.5; return (
{externalLinks && (lastFM || musicBrainz) && ( )} {enabledItem.biography && ( )} {enabledItem.similarArtists && ( )} {enabledItem.topSongs && ( )} {enabledItem.favoriteSongs && ( )}
); }; interface AlbumSectionProps { albums: Album[]; controls: ItemControls; cq: ReturnType; enableExpansion?: boolean; releaseType: string; rows: DataRow[] | undefined; title: React.ReactNode | string; } const MAX_SECTION_CARDS = 100; const getItemsPerRow = (cq: ReturnType) => { // Match grid carousel breakpoints: is3xl: 8, is2xl: 7, isXl: 6, isLg: 5, isMd: 4, isSm: 3, default: 2 if (cq.is3xl) return 8; if (cq.is2xl) return 7; if (cq.isXl) return 6; if (cq.isLg) return 5; if (cq.isMd) return 4; if (cq.isSm) return 3; if (cq.isXs) return 2; return 2; }; const AlbumSection = ({ albums, controls, cq, enableExpansion, releaseType, rows, title, }: AlbumSectionProps) => { const { t } = useTranslation(); const itemsPerRow = getItemsPerRow(cq); const albumCount = albums.length; const [showAll, setShowAll] = useState(false); const player = usePlayer(); const serverId = useCurrentServerId(); const displayedAlbums = showAll ? albums : albums.slice(0, MAX_SECTION_CARDS); const hasMoreAlbums = albums.length > MAX_SECTION_CARDS; const handlePlay = useCallback( (playType: Play) => { if (albums.length === 0) return; const albumIds = albums.map((album) => album.id); player.addToQueueByFetch(serverId, albumIds, LibraryItem.ALBUM, playType); }, [albums, player, serverId], ); const handlePlayNext = usePlayButtonClick({ onClick: () => { handlePlay(Play.NEXT); }, onLongPress: () => { handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]); }, }); const handlePlayNow = usePlayButtonClick({ onClick: () => { handlePlay(Play.NOW); }, onLongPress: () => { handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]); }, }); const handlePlayLast = usePlayButtonClick({ onClick: () => { handlePlay(Play.LAST); }, onLongPress: () => { handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]); }, }); return (
{title} {albumCount}
{albumCount > 0 && ( )}
{displayedAlbums.map((album) => ( ))}
{hasMoreAlbums && !showAll && ( )} ); }; type GroupingType = 'all' | 'primary'; const PRIMARY_RELEASE_TYPES = ['album', 'broadcast', 'ep', 'other', 'single']; const groupAlbumsByReleaseType = ( albums: Album[], routeId: string, groupingType: GroupingType = 'primary', ): Record => { if (groupingType === 'all') { // Group by all individual release types 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; } // Group by all release types const releaseTypes = album.releaseTypes || []; if (releaseTypes.length > 0) { // Sort release types: primaries first (alphabetically), then secondaries (alphabetically) const normalizedTypes = releaseTypes.map((type) => type.toLowerCase()); const primaryTypes = normalizedTypes .filter((type) => PRIMARY_RELEASE_TYPES.includes(type)) .sort(); const secondaryTypes = normalizedTypes .filter((type) => !PRIMARY_RELEASE_TYPES.includes(type)) .sort(); const sortedTypes = [...primaryTypes, ...secondaryTypes]; const combinedKey = sortedTypes.join('/'); if (!acc[combinedKey]) { acc[combinedKey] = []; } acc[combinedKey].push(album); } else { // If no release types, use "album" as fallback const albumKey = 'album'; if (!acc[albumKey]) { acc[albumKey] = []; } acc[albumKey].push(album); } return acc; }, {} as Record, ); return grouped; } // Group by primary release types 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; } const releaseTypes = album.releaseTypes || []; const normalizedTypes = releaseTypes.map((type) => type.toLowerCase()); let matchedType: null | string = null; if (normalizedTypes.includes('album')) { matchedType = 'album'; } else if (normalizedTypes.includes('single')) { matchedType = 'single'; } else if (normalizedTypes.includes('ep')) { matchedType = 'ep'; } else if (normalizedTypes.includes('broadcast')) { matchedType = 'broadcast'; } else if (normalizedTypes.includes('other')) { matchedType = 'other'; } else { matchedType = 'album'; } const releaseTypeKey = matchedType; if (!acc[releaseTypeKey]) { acc[releaseTypeKey] = []; } acc[releaseTypeKey].push(album); return acc; }, {} as Record, ); return grouped; }; const releaseTypeToEnumMap: Record = { album: ArtistReleaseTypeItem.RELEASE_TYPE_ALBUM, 'appears-on': ArtistReleaseTypeItem.APPEARS_ON, audiobook: ArtistReleaseTypeItem.RELEASE_TYPE_AUDIOBOOK, 'audio drama': ArtistReleaseTypeItem.RELEASE_TYPE_AUDIO_DRAMA, broadcast: ArtistReleaseTypeItem.RELEASE_TYPE_BROADCAST, compilation: ArtistReleaseTypeItem.RELEASE_TYPE_COMPILATION, demo: ArtistReleaseTypeItem.RELEASE_TYPE_DEMO, 'dj-mix': ArtistReleaseTypeItem.RELEASE_TYPE_DJ_MIX, ep: ArtistReleaseTypeItem.RELEASE_TYPE_EP, 'field recording': ArtistReleaseTypeItem.RELEASE_TYPE_FIELD_RECORDING, interview: ArtistReleaseTypeItem.RELEASE_TYPE_INTERVIEW, live: ArtistReleaseTypeItem.RELEASE_TYPE_LIVE, 'mixtape/street': ArtistReleaseTypeItem.RELEASE_TYPE_MIXTAPE_STREET, other: ArtistReleaseTypeItem.RELEASE_TYPE_OTHER, remix: ArtistReleaseTypeItem.RELEASE_TYPE_REMIX, single: ArtistReleaseTypeItem.RELEASE_TYPE_SINGLE, soundtrack: ArtistReleaseTypeItem.RELEASE_TYPE_SOUNDTRACK, spokenword: ArtistReleaseTypeItem.RELEASE_TYPE_SPOKENWORD, }; interface ArtistAlbumsProps { albumsQuery: UseSuspenseQueryResult; order?: number; } const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => { const { t } = useTranslation(); const artistReleaseTypeItems = useArtistReleaseTypeItems(); 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 groupingType = albumArtistDetailSort.groupingType; const { albumArtistId, artistId } = useParams() as { albumArtistId?: string; artistId?: string; }; const routeId = (artistId || albumArtistId) as string; const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM); 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 controls = useDefaultItemListControls(); const albumsByReleaseType = useMemo(() => { return groupAlbumsByReleaseType(filteredAndSortedAlbums, routeId, groupingType); }, [filteredAndSortedAlbums, routeId, groupingType]); const releaseTypeEntries = useMemo(() => { const enabledReleaseTypeEnums = new Set( artistReleaseTypeItems.filter((item) => !item.disabled).map((item) => item.id), ); const priorityMap = new Map(); artistReleaseTypeItems .filter((item) => !item.disabled) .forEach((item, index) => { const releaseTypeKey = Object.keys(releaseTypeToEnumMap).find( (key) => releaseTypeToEnumMap[key] === item.id, ); if (releaseTypeKey) { priorityMap.set(releaseTypeKey, index); } }); const getDisplayNameForType = (releaseType: string): string => { switch (releaseType) { case 'album': return t('releaseType.primary.album', { postProcess: 'sentenceCase', }); case 'appears-on': return t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase', }); case 'audiobook': return t('releaseType.secondary.audiobook', { postProcess: 'sentenceCase', }); case 'audio drama': return t('releaseType.secondary.audioDrama', { postProcess: 'sentenceCase', }); case 'broadcast': return t('releaseType.primary.broadcast', { postProcess: 'sentenceCase', }); case 'compilation': return t('releaseType.secondary.compilation', { postProcess: 'sentenceCase', }); case 'demo': return t('releaseType.secondary.demo', { postProcess: 'sentenceCase', }); case 'dj-mix': return t('releaseType.secondary.djMix', { postProcess: 'sentenceCase', }); case 'ep': return t('releaseType.primary.ep', { postProcess: 'upperCase', }); case 'field recording': return t('releaseType.secondary.fieldRecording', { postProcess: 'sentenceCase', }); case 'interview': return t('releaseType.secondary.interview', { postProcess: 'sentenceCase', }); case 'live': return t('releaseType.secondary.live', { postProcess: 'sentenceCase', }); case 'mixtape/street': return t('releaseType.secondary.mixtape', { postProcess: 'sentenceCase', }); case 'other': return t('releaseType.primary.other', { postProcess: 'sentenceCase', }); case 'remix': return t('releaseType.secondary.remix', { postProcess: 'sentenceCase', }); case 'single': return t('releaseType.primary.single', { postProcess: 'sentenceCase', }); case 'soundtrack': return t('releaseType.secondary.soundtrack', { postProcess: 'sentenceCase', }); case 'spokenword': return t('releaseType.secondary.spokenWord', { postProcess: 'sentenceCase', }); default: return titleCase(releaseType); } }; const getPriority = (releaseType: string) => { if (releaseType.includes('/')) { const types = releaseType.split('/'); // Check if there's a primary type in the joined types const primaryTypes = types.filter((type) => PRIMARY_RELEASE_TYPES.includes(type)); if (primaryTypes.length > 0) { // Use the primary type's priority (first primary if multiple) const primaryPriority = priorityMap.get(primaryTypes[0]) ?? 999; return primaryPriority; } else { // Only secondary types - use minimum priority from settings const priorities = types .map((type) => priorityMap.get(type) ?? 999) .filter((p) => p !== 999); return priorities.length > 0 ? Math.min(...priorities) : 999; } } return priorityMap.get(releaseType) ?? 999; }; const getSecondaryTypePriorityKey = (releaseType: string): string => { if (releaseType.includes('/')) { const types = releaseType.split('/'); const secondaryTypes = types.filter( (type) => !PRIMARY_RELEASE_TYPES.includes(type), ); if (secondaryTypes.length > 0) { const priorities = secondaryTypes .map((type) => priorityMap.get(type) ?? 999) .filter((p) => p !== 999) .sort((a, b) => a - b); // Create a comparison key from sorted priorities return priorities.map((p) => String(p).padStart(3, '0')).join(','); } } return ''; }; const isReleaseTypeEnabled = (releaseType: string): boolean => { if (releaseType.includes('/')) { const types = releaseType.split('/'); return types.some((type) => { const enumValue = releaseTypeToEnumMap[type]; return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false; }); } const enumValue = releaseTypeToEnumMap[releaseType]; return enumValue ? enabledReleaseTypeEnums.has(enumValue) : false; }; return Object.entries(albumsByReleaseType) .filter(([releaseType]) => isReleaseTypeEnabled(releaseType)) .map(([releaseType, albums]) => { let displayName: React.ReactNode | string; if (releaseType.includes('/')) { const types = releaseType.split('/'); const displayNames = types.map((type) => getDisplayNameForType(type)); displayName = displayNames.join(SEPARATOR_STRING); } else { displayName = getDisplayNameForType(releaseType); } return { albums, displayName, releaseType }; }) .sort((a, b) => { const priorityA = getPriority(a.releaseType); const priorityB = getPriority(b.releaseType); // First sort by priority if (priorityA !== priorityB) { return priorityA - priorityB; } // If priorities are equal, use weighted ordering for combined release types const isCombinedA = a.releaseType.includes('/'); const isCombinedB = b.releaseType.includes('/'); if (isCombinedA && isCombinedB) { const secondaryKeyA = getSecondaryTypePriorityKey(a.releaseType); const secondaryKeyB = getSecondaryTypePriorityKey(b.releaseType); if (secondaryKeyA && secondaryKeyB) { return collator.compare(secondaryKeyA, secondaryKeyB); } } // Fallback to alphabetical for non-combined types or if weighted comparison isn't applicable return collator.compare(a.releaseType, b.releaseType); }); }, [albumsByReleaseType, artistReleaseTypeItems, t]); const cq = useContainerQuery({ '2xl': 1280, '3xl': 1440, lg: 960, md: 720, sm: 520, xl: 1152, xs: 360, }); const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch); const searchInputRef = useRef(null); useHotkeys([ [ binding.hotkey, () => { searchInputRef.current?.focus(); }, ], ]); 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.length > 0 && (
{cq.isCalculated && ( {releaseTypeEntries.map(({ albums, displayName, releaseType }) => ( ))} )}
)}
); }; function GroupingTypeSelector() { const { t } = useTranslation(); const groupingType = useAppStore((state) => state.albumArtistDetailSort.groupingType); const setAlbumArtistDetailGroupingType = useAppStore( (state) => state.actions.setAlbumArtistDetailGroupingType, ); return ( setAlbumArtistDetailGroupingType('all')} > {t('page.albumArtistDetail.groupingTypeAll', { postProcess: 'sentenceCase', })} setAlbumArtistDetailGroupingType('primary')} > {t('page.albumArtistDetail.groupingTypePrimary', { postProcess: 'sentenceCase', })} ); }