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 && (
) : (
)
}
onClick={onArtistRadio}
p={0}
size="compact-md"
variant="transparent"
>
{String(
t('player.artistRadio', {
postProcess: 'sentenceCase',
}),
).toUpperCase()}
)}
>
);
};
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 (
{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',
})}
);
}