add more metadata to album header / side

This commit is contained in:
jeffvli
2025-12-30 02:33:23 -08:00
parent 62ab4b7a00
commit aa75aaaffb
7 changed files with 449 additions and 254 deletions
+1
View File
@@ -102,6 +102,7 @@
"minimize": "minimize",
"modified": "modified",
"mbid": "MusicBrainz ID",
"mood": "mood",
"name": "name",
"no": "no",
"none": "none",
@@ -27,7 +27,11 @@
.metadata-column {
display: flex;
flex-direction: column;
flex-wrap: wrap;
grid-area: metadata;
gap: var(--theme-spacing-xl);
align-items: center;
text-align: center;
}
.songs-column {
@@ -53,6 +57,47 @@
}
}
.external-links-group {
justify-content: center;
}
.metadata-pill-group {
align-items: center;
}
.pill-group-wrapper {
display: flex;
width: 100%;
& > div {
justify-content: center;
}
}
@container (min-width: $mantine-breakpoint-sm) {
.metadata-column {
flex-direction: row;
justify-content: flex-start;
text-align: left;
}
.external-links-group {
justify-content: flex-start;
}
.metadata-pill-group {
align-items: flex-start;
}
.pill-group-wrapper {
justify-content: flex-start;
& > div {
justify-content: flex-start;
}
}
}
@container (min-width: $mantine-breakpoint-lg) {
.content-layout {
grid-template-areas: 'songs metadata';
@@ -22,18 +22,12 @@ import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, usePlayerSong } from '/@/renderer/store';
import { useGeneralSettings, useSettingsStore } from '/@/renderer/store/settings.store';
import {
formatDateAbsoluteUTC,
formatDurationString,
formatSizeString,
titleCase,
} from '/@/renderer/utils';
import { titleCase } from '/@/renderer/utils';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
import { sortSongList } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Pill, PillLink } from '/@/shared/components/pill/pill';
@@ -55,89 +49,85 @@ import {
} from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
const MetadataPillGroup = ({
items,
title,
}: {
items: undefined | { id: string; value: ReactNode | string | undefined }[];
title: string;
}) => {
if (!items || items.length === 0) return null;
return (
<Stack align="center" className={styles.metadataPillGroup} gap="xs">
<Text fw={600} isNoSelect size="sm" tt="uppercase">
{title}
</Text>
<div className={styles['pill-group-wrapper']}>
<Pill.Group>
{items.map((tag, index) => (
<Pill key={`item-${tag.id}-${index}`} size="md">
{tag.value}
</Pill>
))}
</Pill.Group>
</div>
</Stack>
);
};
interface AlbumMetadataTagsProps {
album: Album | undefined;
}
const MOOD_TAG = 'mood';
const RELEASE_COUNTRY_TAG = 'releasecountry';
const RELEASE_STATUS_TAG = 'releasestatus';
const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
const { t } = useTranslation();
const metadataItems = useMemo(() => {
const defaultTagItems = useMemo(() => {
if (!album) return [];
const originalDifferentFromRelease =
album.originalDate && album.originalDate !== album.releaseDate;
const releasePrefix = originalDifferentFromRelease
? t('page.albumDetail.released', { postProcess: 'sentenceCase' })
: '♫';
const releaseTypes = normalizeReleaseTypes(album.releaseTypes ?? [], t).map((type) => ({
id: type,
value: titleCase(type),
}));
const releaseCountries =
album.tags?.[RELEASE_COUNTRY_TAG]?.map((country) => ({
id: country,
value: country,
})) || [];
const releaseStatuses =
album.tags?.[RELEASE_STATUS_TAG]?.map((status) => ({
id: status,
value: status,
})) || [];
const recordLabels =
album.recordLabels?.map((label) => ({
id: label,
value: label,
})) || [];
console.log('album', album);
const items: Array<{ id: string; value: ReactNode | string | undefined }> = [];
if (originalDifferentFromRelease && album.originalDate) {
items.push({
id: 'originalDate',
value: `${formatDateAbsoluteUTC(album.originalDate)}`,
});
}
items.push(...releaseTypes);
items.push(
...releaseTypes,
{
id: 'isCompilation',
value: album?.isCompilation
? t('filter.isCompilation', { postProcess: 'sentenceCase' })
: undefined,
},
{
id: 'releaseDate',
value: album.releaseDate
? `${releasePrefix} ${formatDateAbsoluteUTC(album.releaseDate)}`
: undefined,
},
{
id: 'releaseYear',
value: album.releaseDate
? undefined
: album.releaseYear
? album.releaseYear.toString()
: undefined,
},
{
id: 'songCount',
value: album.songCount
? t('entity.trackWithCount', {
count: album.songCount,
})
: undefined,
},
{
id: 'duration',
value: album.duration ? (
<Flex align="center" gap="xs">
<Icon icon="duration" size="md" /> {formatDurationString(album.duration)}
</Flex>
) : undefined,
},
{
id: 'size',
value: album.size ? formatSizeString(album.size) : undefined,
},
{
id: 'playCount',
value:
typeof album.playCount === 'number'
? t('entity.play', {
count: album.playCount,
})
: undefined,
},
...releaseCountries,
...releaseStatuses,
...recordLabels,
{
id: 'explicitStatus',
value:
@@ -147,31 +137,32 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
? t('common.clean', { postProcess: 'sentenceCase' })
: undefined,
},
{
id: 'version',
value: album.version || undefined,
},
);
return items.filter((item) => item.value);
}, [album, t]);
if (metadataItems.length === 0) return null;
const moodTagItems = useMemo(() => {
if (!album) return [];
return album.tags?.[MOOD_TAG]?.map((tag) => ({
id: tag,
value: tag,
}));
}, [album]);
return (
<Stack gap="xs">
<Text fw={600} isNoSelect size="sm" tt="uppercase">
{t('common.tags', { postProcess: 'sentenceCase' })}
</Text>
<Pill.Group>
{metadataItems.map((item, index) => (
<Pill key={`item-${item.id}-${index}`} size="md">
{item.value}
</Pill>
))}
</Pill.Group>
</Stack>
<>
<MetadataPillGroup
items={defaultTagItems}
title={t('common.tags', { postProcess: 'sentenceCase' })}
/>
<MetadataPillGroup
items={moodTagItems}
title={t('common.mood', { postProcess: 'sentenceCase' })}
/>
</>
);
};
@@ -208,38 +199,38 @@ const AlbumMetadataGenres = ({ genres }: AlbumMetadataGenresProps) => {
);
};
interface AlbumMetadataArtistsProps {
artists?: Array<{ id: string; name: string }>;
}
// interface AlbumMetadataArtistsProps {
// artists?: Array<{ id: string; name: string }>;
// }
const AlbumMetadataArtists = ({ artists }: AlbumMetadataArtistsProps) => {
const { t } = useTranslation();
// const AlbumMetadataArtists = ({ artists }: AlbumMetadataArtistsProps) => {
// const { t } = useTranslation();
if (!artists || artists.length === 0) return null;
// if (!artists || artists.length === 0) return null;
return (
<Stack gap="xs">
<Text fw={600} isNoSelect size="sm" tt="uppercase">
{t('entity.albumArtist', {
count: artists.length,
})}
</Text>
<Pill.Group>
{artists.map((artist) => (
<PillLink
key={`artist-${artist.id}`}
size="md"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</PillLink>
))}
</Pill.Group>
</Stack>
);
};
// return (
// <Stack gap="xs">
// <Text fw={600} isNoSelect size="sm" tt="uppercase">
// {t('entity.albumArtist', {
// count: artists.length,
// })}
// </Text>
// <Pill.Group>
// {artists.map((artist) => (
// <PillLink
// key={`artist-${artist.id}`}
// size="md"
// to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
// albumArtistId: artist.id,
// })}
// >
// {artist.name}
// </PillLink>
// ))}
// </Pill.Group>
// </Stack>
// );
// };
interface AlbumMetadataExternalLinksProps {
albumArtist?: string;
@@ -269,7 +260,7 @@ const AlbumMetadataExternalLinks = ({
postProcess: 'sentenceCase',
})}
</Text>
<Group gap="sm">
<Group className={styles.externalLinksGroup} gap="sm">
{lastFM && (
<ActionIcon
component="a"
@@ -382,19 +373,17 @@ export const AlbumDetailContent = () => {
)}
</div>
<div className={styles.metadataColumn}>
<Stack gap="2xl">
<AlbumMetadataArtists artists={detailQuery?.data?.albumArtists} />
<AlbumMetadataGenres genres={detailQuery?.data?.genres} />
<AlbumMetadataTags album={detailQuery?.data} />
<AlbumMetadataExternalLinks
albumArtist={detailQuery?.data?.albumArtist}
albumName={detailQuery?.data?.name}
externalLinks={externalLinks}
lastFM={lastFM}
mbzId={mbzId || undefined}
musicBrainz={musicBrainz}
/>
</Stack>
{/* <AlbumMetadataArtists artists={detailQuery?.data?.albumArtists} /> */}
<AlbumMetadataGenres genres={detailQuery?.data?.genres} />
<AlbumMetadataTags album={detailQuery?.data} />
<AlbumMetadataExternalLinks
albumArtist={detailQuery?.data?.albumArtist}
albumName={detailQuery?.data?.name}
externalLinks={externalLinks}
lastFM={lastFM}
mbzId={mbzId || undefined}
musicBrainz={musicBrainz}
/>
</div>
</div>
{labels && (
@@ -1,11 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { forwardRef } from 'react';
import { generatePath, Link, useParams } from 'react-router';
import { forwardRef, Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useParams } from 'react-router';
import styles from './album-detail-header.module.css';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { JoinedAlbumArtist } from '/@/renderer/features/albums/components/joined-album-artist';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
@@ -15,7 +17,10 @@ import {
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
import { Group } from '/@/shared/components/group/group';
import { Separator } from '/@/shared/components/separator/separator';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
@@ -23,6 +28,7 @@ import { Play } from '/@/shared/types/types';
export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
const { albumId } = useParams() as { albumId: string };
const { t } = useTranslation();
const server = useCurrentServer();
const { showRatings } = useGeneralSettings();
const detailQuery = useQuery(
@@ -82,8 +88,8 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
});
};
const firstAlbumArtist = detailQuery?.data?.albumArtists?.[0];
const releaseYear = detailQuery?.data?.releaseYear;
const releaseDate = detailQuery?.data?.releaseDate;
const imageUrl = useItemImageUrl({
id: detailQuery?.data?.imageId || undefined,
@@ -91,43 +97,128 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
type: 'header',
});
const releaseType = detailQuery?.data?.releaseType || undefined;
const metadataItems = useMemo(() => {
const items: Array<{ id: string; value: React.ReactNode | string | undefined }> = [];
const album = detailQuery?.data;
if (!album) return [];
const originalDifferentFromRelease =
album?.originalDate && album?.originalDate !== album?.releaseDate;
const playCount = album?.playCount;
const releasePrefix = originalDifferentFromRelease
? t('page.albumDetail.released', { postProcess: 'sentenceCase' })
: '♫';
if (originalDifferentFromRelease && album.originalDate) {
items.push({
id: 'originalDate',
value: `${formatDateAbsoluteUTC(album.originalDate)}`,
});
}
items.push(
...[
{
id: 'releaseDate',
value: releaseDate
? `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`
: releaseYear,
},
{
id: 'songCount',
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
},
{
id: 'duration',
value: formatDurationString(detailQuery?.data?.duration || 0),
},
{
id: 'explicitStatus',
value: detailQuery?.data?.explicitStatus,
},
{
id: 'playCount',
value: playCount ? t('entity.play', { count: playCount }) : undefined,
},
],
);
return items.filter((item) => !!item.value);
}, [detailQuery?.data, releaseDate, releaseYear, t]);
const headerItem = useMemo(() => {
const album = detailQuery?.data;
if (!album) return null;
const releaseTypes = album.releaseType
? normalizeReleaseTypes([album.releaseType], t)
: null;
const releaseTypeText = releaseTypes?.length ? releaseTypes[0] : null;
if (releaseTypeText) {
return (
<Group gap="sm">
<Text
component={Link}
fw={600}
isLink
size="md"
to={AppRoute.LIBRARY_ALBUMS}
tt="uppercase"
>
{releaseTypeText}
</Text>
{album.version && (
<>
<Text fw={600} isMuted>
<Separator />
</Text>
<Text>{album.version}</Text>
</>
)}
</Group>
);
}
return null;
}, [detailQuery?.data, t]);
return (
<Stack ref={ref}>
<LibraryHeader
imageUrl={imageUrl}
item={{ releaseType, route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
item={{
children: headerItem,
route: AppRoute.LIBRARY_ALBUMS,
type: LibraryItem.ALBUM,
}}
title={detailQuery?.data?.name || ''}
>
<Stack gap="md" w="100%">
{(firstAlbumArtist || releaseYear) && (
<Group className={styles.metadataGroup}>
{firstAlbumArtist && (
<Text
component={Link}
fw={600}
isLink
isNoSelect
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: firstAlbumArtist.id,
})}
>
{firstAlbumArtist.name}
</Text>
)}
{firstAlbumArtist && releaseYear && (
<Text fw={600} isNoSelect>
</Text>
)}
{releaseYear && (
<Text fw={600} isMuted isNoSelect>
{releaseYear}
</Text>
)}
</Group>
)}
<Group className={styles.metadataGroup} gap="xs">
{metadataItems.map((item, index) => (
<Fragment key={item.id}>
{index > 0 && (
<Text fw={400} isMuted isNoSelect>
</Text>
)}
<Text fw={400}>{item.value}</Text>
</Fragment>
))}
</Group>
<Group className={styles.metadataGroup}>
<JoinedAlbumArtist
albumArtist={detailQuery?.data?.albumArtist || ''}
albumArtists={detailQuery?.data?.albumArtists || []}
/>
</Group>
<LibraryHeaderMenu
favorite={detailQuery?.data?.userFavorite}
onFavorite={handleFavorite}
@@ -0,0 +1,145 @@
import { Fragment } from 'react';
import { generatePath, Link } from 'react-router';
import { AppRoute } from '/@/renderer/router/routes';
import { Group } from '/@/shared/components/group/group';
import { Separator } from '/@/shared/components/separator/separator';
import { Text } from '/@/shared/components/text/text';
import { AlbumArtist, RelatedArtist } from '/@/shared/types/domain-types';
interface JoinedAlbumArtistProps {
albumArtist: string;
albumArtists: AlbumArtist[] | RelatedArtist[];
}
export const JoinedAlbumArtist = ({ albumArtist, albumArtists }: JoinedAlbumArtistProps) => {
const parts: (
| string
| { artist: AlbumArtist | RelatedArtist; end: number; start: number; text: string }
)[] = [];
const matches: Array<{
artist: AlbumArtist | RelatedArtist;
end: number;
name: string;
start: number;
}> = [];
for (const artist of albumArtists) {
const name = artist.name;
const regex = new RegExp(escapeRegex(name), 'gi');
let match: null | RegExpExecArray = null;
while ((match = regex.exec(albumArtist)) !== null) {
matches.push({
artist,
end: match.index + match[0].length,
name: match[0],
start: match.index,
});
}
}
matches.sort((a, b) => {
const lengthDiff = b.end - b.start - (a.end - a.start);
if (lengthDiff !== 0) return lengthDiff;
return a.start - b.start;
});
const nonOverlappingMatches: typeof matches = [];
for (const match of matches) {
const overlaps = nonOverlappingMatches.some(
(existing) =>
(match.start >= existing.start && match.start < existing.end) ||
(match.end > existing.start && match.end <= existing.end) ||
(match.start <= existing.start && match.end >= existing.end),
);
if (!overlaps) {
nonOverlappingMatches.push(match);
}
}
nonOverlappingMatches.sort((a, b) => a.start - b.start);
let lastIndex = 0;
for (const match of nonOverlappingMatches) {
if (match.start > lastIndex) {
parts.push(albumArtist.substring(lastIndex, match.start));
}
parts.push({
artist: match.artist,
end: match.end,
start: match.start,
text: match.name,
});
lastIndex = match.end;
}
if (lastIndex < albumArtist.length) {
parts.push(albumArtist.substring(lastIndex));
}
const hasArtistMatches = parts.some((part) => typeof part !== 'string');
// If no matches found and there are album artists, return the album artists
if (!hasArtistMatches && albumArtists.length > 0) {
return (
<Group gap="xs">
{albumArtists.map((artist, index) => (
<Fragment key={artist.id}>
{index > 0 && <Separator />}
<Text
component={Link}
fw={600}
isLink
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Text>
</Fragment>
))}
</Group>
);
}
// If no matches found and no albumArtists, return the original string
if (!hasArtistMatches) {
return (
<Text fw={400} isNoSelect>
{albumArtist}
</Text>
);
}
return (
<Text component="span" fw={400}>
{parts.map((part, index) => {
if (typeof part === 'string') {
return <Fragment key={index}>{part}</Fragment>;
}
const { artist, text } = part;
return (
<Text
component={Link}
fw={600}
isLink
key={`${artist.id}-${index}`}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{text}
</Text>
);
})}
</Text>
);
};
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -97,7 +97,7 @@
.title {
display: flex;
margin: var(--theme-spacing-sm) 0;
margin: 0;
font-size: clamp(1.75rem, 3dvw, 2.75rem);
line-height: 1.2;
}
@@ -17,7 +17,6 @@ import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-b
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { titleCase } from '/@/renderer/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center';
@@ -35,7 +34,7 @@ interface LibraryHeaderProps {
containerClassName?: string;
imagePlaceholderUrl?: null | string;
imageUrl?: null | string;
item: { releaseType?: string; route: string; type?: LibraryItem };
item: { children?: ReactNode; route: string; type?: LibraryItem };
loading?: boolean;
title: string;
}
@@ -53,85 +52,6 @@ export const LibraryHeader = forwardRef(
};
const itemTypeString = (): string => {
if (item.releaseType) {
switch (item.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: 'sentenceCase',
});
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(item.releaseType);
}
}
switch (item.type) {
case LibraryItem.ALBUM:
return t('entity.album', { count: 1 });
@@ -203,18 +123,22 @@ export const LibraryHeader = forwardRef(
</div>
{title && (
<div className={styles.metadataSection}>
<Text
className={styles.itemType}
component={Link}
fw={600}
isLink
size="md"
style={{}}
to={item.route}
tt="uppercase"
>
{itemTypeString()}
</Text>
{item.children ? (
<div className={styles.itemType}>{item.children}</div>
) : (
<Text
className={styles.itemType}
component={Link}
fw={600}
isLink
size="md"
to={item.route}
tt="uppercase"
>
{itemTypeString()}
</Text>
)}
<h1
className={styles.title}
style={{