re-add similar artists carousel to artist page

This commit is contained in:
jeffvli
2025-12-27 01:13:28 -08:00
parent f0d22267c3
commit 3db229ef68
5 changed files with 194 additions and 63 deletions
@@ -157,45 +157,65 @@ function BaseGridCarousel(props: GridCarouselProps) {
{cq.isCalculated && ( {cq.isCalculated && (
<> <>
<div className={styles.navigation}> <div className={styles.navigation}>
<Group gap="xs" justify="space-between" w="100%"> {typeof title === 'string' ? (
<Group gap="xs"> <Group gap="xs" justify="space-between" w="100%">
{typeof title === 'string' ? ( <Group gap="xs">
<TextTitle fw={700} isNoSelect order={3}> <TextTitle fw={700} isNoSelect order={3}>
{title} {title}
</TextTitle> </TextTitle>
) : ( {enableRefresh && onRefresh && (
title <ActionIcon
)} icon="refresh"
{enableRefresh && onRefresh && ( iconProps={{ size: 'xs' }}
onClick={onRefresh}
size="xs"
tooltip={{ label: 'Refresh' }}
variant="transparent"
/>
)}
</Group>
<Group gap="xs" justify="end">
<ActionIcon <ActionIcon
icon="refresh" disabled={isPrevDisabled}
iconProps={{ size: 'xs' }} icon="arrowLeftS"
onClick={onRefresh} iconProps={{ size: 'lg' }}
onClick={handlePrevPage}
size="xs" size="xs"
tooltip={{ label: 'Refresh' }} variant="subtle"
variant="transparent"
/> />
)} <ActionIcon
disabled={isNextDisabled}
icon="arrowRightS"
iconProps={{ size: 'lg' }}
onClick={handleNextPage}
size="xs"
variant="subtle"
/>
</Group>
</Group> </Group>
<Group gap="xs" justify="end"> ) : (
<ActionIcon <div className={styles.customTitleContainer}>
disabled={isPrevDisabled} <div className={styles.customTitleContent}>{title}</div>
icon="arrowLeftS" <Group gap="xs" justify="end">
iconProps={{ size: 'lg' }} <ActionIcon
onClick={handlePrevPage} disabled={isPrevDisabled}
size="xs" icon="arrowLeftS"
variant="subtle" iconProps={{ size: 'lg' }}
/> onClick={handlePrevPage}
<ActionIcon size="xs"
disabled={isNextDisabled} variant="subtle"
icon="arrowRightS" />
iconProps={{ size: 'lg' }} <ActionIcon
onClick={handleNextPage} disabled={isNextDisabled}
size="xs" icon="arrowRightS"
variant="subtle" iconProps={{ size: 'lg' }}
/> onClick={handleNextPage}
</Group> size="xs"
</Group> variant="subtle"
/>
</Group>
</div>
)}
</div> </div>
<AnimatePresence custom={currentPage} initial={false} mode="wait"> <AnimatePresence custom={currentPage} initial={false} mode="wait">
<motion.div <motion.div
@@ -14,6 +14,19 @@
justify-content: space-between; justify-content: space-between;
} }
.custom-title-container {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--theme-spacing-sm);
}
.custom-title-content {
flex: 1;
min-width: 0;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr)); grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));
@@ -30,11 +30,22 @@
.album-section-divider-container { .album-section-divider-container {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--theme-spacing-md);
width: 100%; width: 100%;
} }
.album-section-divider { .album-section-divider {
width: 100%; flex: 1;
height: 2px; height: 2px;
background: var(--theme-colors-border); background: var(--theme-colors-border);
} }
.similar-artists-title {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--theme-spacing-md);
align-items: center;
margin-bottom: var(--theme-spacing-md);
width: 100%;
min-width: 0;
}
@@ -16,6 +16,7 @@ import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table
import { ItemControls } from '/@/renderer/components/item-list/types'; import { ItemControls } from '/@/renderer/components/item-list/types';
import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-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 { usePlayer } from '/@/renderer/features/player/context/player-context';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { import {
@@ -51,9 +52,12 @@ import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import { useHotkeys } from '/@/shared/hooks/use-hotkeys'; import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
import { import {
Album, Album,
AlbumArtist,
AlbumArtistDetailResponse, AlbumArtistDetailResponse,
AlbumListSort, AlbumListSort,
LibraryItem, LibraryItem,
RelatedArtist,
ServerType,
Song, Song,
SortOrder, SortOrder,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
@@ -237,13 +241,14 @@ const AlbumArtistMetadataTopSongs = ({
if (!tableConfig || columns.length === 0) { if (!tableConfig || columns.length === 0) {
return ( return (
<section> <section>
<Group justify="space-between" wrap="nowrap"> <div className={styles.albumSectionTitle}>
<Group align="flex-end" wrap="nowrap"> <TextTitle fw={700} order={3}>
<TextTitle fw={700} order={3}> {t('page.albumArtistDetail.topSongs', {
{t('page.albumArtistDetail.topSongs', { postProcess: 'sentenceCase',
postProcess: 'sentenceCase', })}
})} </TextTitle>
</TextTitle> <div className={styles.albumSectionDividerContainer}>
<div className={styles.albumSectionDivider} />
<Button <Button
component={Link} component={Link}
size="compact-md" size="compact-md"
@@ -257,8 +262,8 @@ const AlbumArtistMetadataTopSongs = ({
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
})} })}
</Button> </Button>
</Group> </div>
</Group> </div>
</section> </section>
); );
} }
@@ -268,13 +273,14 @@ const AlbumArtistMetadataTopSongs = ({
return ( return (
<section> <section>
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between" wrap="nowrap"> <div className={styles.albumSectionTitle}>
<Group align="flex-end" wrap="nowrap"> <TextTitle fw={700} order={3}>
<TextTitle fw={700} order={3}> {t('page.albumArtistDetail.topSongs', {
{t('page.albumArtistDetail.topSongs', { postProcess: 'sentenceCase',
postProcess: 'sentenceCase', })}
})} </TextTitle>
</TextTitle> <div className={styles.albumSectionDividerContainer}>
<div className={styles.albumSectionDivider} />
<Button <Button
component={Link} component={Link}
size="compact-md" size="compact-md"
@@ -288,8 +294,8 @@ const AlbumArtistMetadataTopSongs = ({
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
})} })}
</Button> </Button>
</Group> </div>
</Group> </div>
<Group gap="sm" w="100%"> <Group gap="sm" w="100%">
<TextInput <TextInput
flex={1} flex={1}
@@ -426,6 +432,79 @@ const AlbumArtistMetadataExternalLinks = ({
); );
}; };
interface AlbumArtistMetadataSimilarArtistsProps {
detailQuery: ReturnType<typeof useSuspenseQuery<AlbumArtistDetailResponse>>;
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(
() => (
<div className={styles.similarArtistsTitle}>
<TextTitle fw={700} order={3}>
{t('page.albumArtistDetail.relatedArtists', {
postProcess: 'sentenceCase',
})}
</TextTitle>
<div className={styles.albumSectionDividerContainer}>
<div className={styles.albumSectionDivider} />
</div>
</div>
),
[t],
);
if (similarArtists.length === 0) {
return null;
}
return (
<AlbumArtistGridCarousel
data={similarArtists}
excludeIds={[routeId]}
rowCount={1}
title={carouselTitle}
/>
);
};
export const AlbumArtistDetailContent = () => { export const AlbumArtistDetailContent = () => {
const { artistItems, externalLinks, lastFM, musicBrainz } = useGeneralSettings(); const { artistItems, externalLinks, lastFM, musicBrainz } = useGeneralSettings();
const { albumArtistId, artistId } = useParams() as { const { albumArtistId, artistId } = useParams() as {
@@ -493,7 +572,7 @@ export const AlbumArtistDetailContent = () => {
artistDiscographyLink={artistDiscographyLink} artistDiscographyLink={artistDiscographyLink}
artistSongsLink={artistSongsLink} artistSongsLink={artistSongsLink}
/> />
<Grid gutter="xl"> <Grid gutter="2xl">
{showGenres && ( {showGenres && (
<Grid.Col order={genresOrder} span={12}> <Grid.Col order={genresOrder} span={12}>
<AlbumArtistMetadataGenres genres={detailQuery.data?.genres} /> <AlbumArtistMetadataGenres genres={detailQuery.data?.genres} />
@@ -521,6 +600,14 @@ export const AlbumArtistDetailContent = () => {
<Grid.Col order={itemOrder.recentAlbums} span={12}> <Grid.Col order={itemOrder.recentAlbums} span={12}>
<ArtistAlbums /> <ArtistAlbums />
</Grid.Col> </Grid.Col>
{enabledItem.similarArtists && (
<Grid.Col order={itemOrder.similarArtists} span={12}>
<AlbumArtistMetadataSimilarArtists
detailQuery={detailQuery}
routeId={routeId}
/>
</Grid.Col>
)}
{enabledItem.topSongs && ( {enabledItem.topSongs && (
<Grid.Col order={itemOrder.topSongs} span={12}> <Grid.Col order={itemOrder.topSongs} span={12}>
<AlbumArtistMetadataTopSongs <AlbumArtistMetadataTopSongs
@@ -765,16 +852,17 @@ const ArtistAlbums = () => {
/> />
</Group> </Group>
<div className={styles.albumSectionContainer} ref={cq.ref}> <div className={styles.albumSectionContainer} ref={cq.ref}>
{releaseTypeEntries.map(({ albums, displayName, releaseType }) => ( {cq.isCalculated &&
<AlbumSection releaseTypeEntries.map(({ albums, displayName, releaseType }) => (
albums={albums} <AlbumSection
controls={controls} albums={albums}
cq={cq} controls={controls}
key={releaseType} cq={cq}
rows={rows} key={releaseType}
title={displayName} rows={rows}
/> title={displayName}
))} />
))}
</div> </div>
</Stack> </Stack>
); );
-1
View File
@@ -578,7 +578,6 @@ export const SettingsStateSchema = ValidationSettingsStateSchema.merge(
export enum ArtistItem { export enum ArtistItem {
BIOGRAPHY = 'biography', BIOGRAPHY = 'biography',
COMPILATIONS = 'compilations',
RECENT_ALBUMS = 'recentAlbums', RECENT_ALBUMS = 'recentAlbums',
SIMILAR_ARTISTS = 'similarArtists', SIMILAR_ARTISTS = 'similarArtists',
TOP_SONGS = 'topSongs', TOP_SONGS = 'topSongs',