import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; import { LayoutGroup, motion } from 'motion/react'; import { useCallback, 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 { 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 { 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 { useIsPlayerFetching, 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 { 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, useAppStore, useCurrentServer, useCurrentServerId, usePlayerSong, } from '/@/renderer/store'; import { useGeneralSettings, useSettingsStore } from '/@/renderer/store/settings.store'; import { titleCase } from '/@/renderer/utils'; import { sanitize } from '/@/renderer/utils/sanitize'; import { 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 { 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 { Album, AlbumArtist, AlbumArtistDetailResponse, AlbumListSort, LibraryItem, RelatedArtist, ServerType, Song, SortOrder, } from '/@/shared/types/domain-types'; import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types'; 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 }>; } const AlbumArtistMetadataGenres = ({ genres }: 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; biography: null | string | undefined; } const AlbumArtistMetadataBiography = ({ artistName, biography, }: AlbumArtistMetadataBiographyProps) => { const { t } = useTranslation(); if (!biography) return null; const sanitizedBiography = sanitize(biography); return (
{t('page.albumArtistDetail.about', { artist: artistName, })}
); }; interface AlbumArtistMetadataTopSongsProps { detailQuery: ReturnType>; routeId: string; } const AlbumArtistMetadataTopSongs = ({ detailQuery, routeId, }: 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]); const columns = useMemo(() => { return tableConfig?.columns || []; }, [tableConfig?.columns]); const filteredSongs = useMemo(() => { const filtered = searchLibraryItems(songs, debouncedSearchTerm, LibraryItem.SONG); // When searching, show all results. Otherwise, limit to 5 if not showing all if (debouncedSearchTerm.trim() || showAll) { return filtered; } return filtered.slice(0, 5); }, [songs, debouncedSearchTerm, showAll]); 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]); if (!topSongsQuery?.data?.items?.length) return null; if (!tableConfig || columns.length === 0) { return (
{t('page.albumArtistDetail.topSongs', { postProcess: 'sentenceCase', })}
); } const currentSongId = currentSong?.id; return (
{t('page.albumArtistDetail.topSongs', { postProcess: 'sentenceCase', })}
} 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} /> {!searchTerm.trim() && songs.length > 5 && !showAll && ( )}
); }; interface AlbumArtistMetadataExternalLinksProps { artistName?: string; externalLinks: boolean; lastFM: boolean; mbzId?: null | string; musicBrainz: boolean; } const AlbumArtistMetadataExternalLinks = ({ artistName, externalLinks, lastFM, mbzId, musicBrainz, }: AlbumArtistMetadataExternalLinksProps) => { const { t } = useTranslation(); if (!externalLinks || (!lastFM && !musicBrainz)) return null; return ( {t('common.externalLinks', { postProcess: 'sentenceCase', })} {lastFM && ( )} {mbzId && musicBrainz ? ( ) : null} ); }; interface AlbumArtistMetadataSimilarArtistsProps { detailQuery: ReturnType>; routeId: string; } const AlbumArtistMetadataSimilarArtists = ({ detailQuery, routeId, }: AlbumArtistMetadataSimilarArtistsProps) => { const { t } = useTranslation(); const server = useCurrentServer(); const serverId = useCurrentServerId(); const similarArtists = useMemo(() => { const relatedArtists = detailQuery.data?.similarArtists; 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: false, userRating: null, }), ); }, [detailQuery.data?.similarArtists, server?.type, serverId]); const carouselTitle = useMemo( () => (
{t('page.albumArtistDetail.relatedArtists', { postProcess: 'sentenceCase', })}
), [t], ); if (similarArtists.length === 0) { return null; } return ( ); }; export const AlbumArtistDetailContent = () => { const { artistItems, artistRadioCount, externalLinks, lastFM, musicBrainz } = useGeneralSettings(); 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 detailQuery = useSuspenseQuery( artistsQueries.albumArtistDetail({ query: { id: routeId }, serverId: server?.id, }), ); 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 biography = detailQuery.data?.biography && enabledItem.biography ? detailQuery.data.biography : null; const showGenres = detailQuery.data?.genres ? detailQuery.data.genres.length !== 0 : false; 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 (
{showGenres && ( )} {externalLinks && (lastFM || musicBrainz) && ( )} {biography && ( )} {enabledItem.similarArtists && ( )} {enabledItem.topSongs && ( )}
); }; interface AlbumSectionProps { albums: Album[]; controls: ItemControls; cq: ReturnType; releaseType: string; rows: DataRow[] | undefined; title: React.ReactNode | string; } const MAX_SECTION_CARDS = 20; const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumSectionProps) => { const { t } = useTranslation(); const span = cq.isXl ? 3 : cq.isLg ? 4 : cq.isMd ? 6 : cq.isSm ? 8 : cq.isXs ? 12 : 12; 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 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) { releaseTypes.forEach((type) => { const normalizedType = type.toLowerCase(); if (!acc[normalizedType]) { acc[normalizedType] = []; } acc[normalizedType].push(album); }); } else { // If no release types, use "other" as fallback const otherKey = 'other'; if (!acc[otherKey]) { acc[otherKey] = []; } acc[otherKey].push(album); } return acc; }, {} as Record, ); return grouped; } // Primary grouping (original behavior) 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: EP const hasEPType = album.releaseTypes?.some((type) => type.toLowerCase() === 'ep'); if (hasEPType) { const epKey = 'ep'; if (!acc[epKey]) { acc[epKey] = []; } acc[epKey].push(album); return acc; } // Priority 4: Single (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 5: Album const albumKey = 'album'; if (!acc[albumKey]) { acc[albumKey] = []; } acc[albumKey].push(album); return acc; }, {} as Record, ); return grouped; }; 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 groupingType = albumArtistDetailSort.groupingType; 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(() => { return groupAlbumsByReleaseType(filteredAndSortedAlbums, routeId, groupingType); }, [filteredAndSortedAlbums, routeId, groupingType]); const releaseTypeEntries = useMemo(() => { const priorityOrder = ['album', 'ep', '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: React.ReactNode | 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 'ep': displayName = t('releaseType.primary.ep', { postProcess: 'sentenceCase', }); break; case 'single': displayName = t('releaseType.primary.single', { postProcess: 'sentenceCase', }); break; default: displayName = titleCase(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} />
{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', })} ); }