diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 6776983f5..0085b243d 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -102,6 +102,7 @@
"minimize": "minimize",
"modified": "modified",
"mbid": "MusicBrainz ID",
+ "mood": "mood",
"name": "name",
"no": "no",
"none": "none",
diff --git a/src/renderer/features/albums/components/album-detail-content.module.css b/src/renderer/features/albums/components/album-detail-content.module.css
index 149434de4..e7b6d8863 100644
--- a/src/renderer/features/albums/components/album-detail-content.module.css
+++ b/src/renderer/features/albums/components/album-detail-content.module.css
@@ -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';
diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx
index 939508361..55d028177 100644
--- a/src/renderer/features/albums/components/album-detail-content.tsx
+++ b/src/renderer/features/albums/components/album-detail-content.tsx
@@ -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 (
+
+
+ {title}
+
+
+
+ {items.map((tag, index) => (
+
+ {tag.value}
+
+ ))}
+
+
+
+ );
+};
+
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 ? (
-
- {formatDurationString(album.duration)}
-
- ) : 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 (
-
-
- {t('common.tags', { postProcess: 'sentenceCase' })}
-
-
- {metadataItems.map((item, index) => (
-
- {item.value}
-
- ))}
-
-
+ <>
+
+
+
+ >
);
};
@@ -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 (
-
-
- {t('entity.albumArtist', {
- count: artists.length,
- })}
-
-
- {artists.map((artist) => (
-
- {artist.name}
-
- ))}
-
-
- );
-};
+// return (
+//
+//
+// {t('entity.albumArtist', {
+// count: artists.length,
+// })}
+//
+//
+// {artists.map((artist) => (
+//
+// {artist.name}
+//
+// ))}
+//
+//
+// );
+// };
interface AlbumMetadataExternalLinksProps {
albumArtist?: string;
@@ -269,7 +260,7 @@ const AlbumMetadataExternalLinks = ({
postProcess: 'sentenceCase',
})}
-
+
{lastFM && (
{
)}
-
-
-
-
-
-
+ {/*
*/}
+
+
+
{labels && (
diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx
index c19a28601..f5e797450 100644
--- a/src/renderer/features/albums/components/album-detail-header.tsx
+++ b/src/renderer/features/albums/components/album-detail-header.tsx
@@ -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((_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((_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((_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 (
+
+
+ {releaseTypeText}
+
+ {album.version && (
+ <>
+
+
+
+ {album.version}
+ >
+ )}
+
+ );
+ }
+
+ return null;
+ }, [detailQuery?.data, t]);
return (
- {(firstAlbumArtist || releaseYear) && (
-
- {firstAlbumArtist && (
-
- {firstAlbumArtist.name}
-
- )}
- {firstAlbumArtist && releaseYear && (
-
- •
-
- )}
- {releaseYear && (
-
- {releaseYear}
-
- )}
-
- )}
+
+ {metadataItems.map((item, index) => (
+
+ {index > 0 && (
+
+ •
+
+ )}
+ {item.value}
+
+ ))}
+
+
+
+
{
+ 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 (
+
+ {albumArtists.map((artist, index) => (
+
+ {index > 0 && }
+
+ {artist.name}
+
+
+ ))}
+
+ );
+ }
+
+ // If no matches found and no albumArtists, return the original string
+ if (!hasArtistMatches) {
+ return (
+
+ {albumArtist}
+
+ );
+ }
+
+ return (
+
+ {parts.map((part, index) => {
+ if (typeof part === 'string') {
+ return {part};
+ }
+
+ const { artist, text } = part;
+ return (
+
+ {text}
+
+ );
+ })}
+
+ );
+};
+
+function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/src/renderer/features/shared/components/library-header.module.css b/src/renderer/features/shared/components/library-header.module.css
index fe59d5276..1afd44686 100644
--- a/src/renderer/features/shared/components/library-header.module.css
+++ b/src/renderer/features/shared/components/library-header.module.css
@@ -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;
}
diff --git a/src/renderer/features/shared/components/library-header.tsx b/src/renderer/features/shared/components/library-header.tsx
index 6a85c0615..6593768ad 100644
--- a/src/renderer/features/shared/components/library-header.tsx
+++ b/src/renderer/features/shared/components/library-header.tsx
@@ -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(
{title && (
-
- {itemTypeString()}
-
+ {item.children ? (
+
{item.children}
+ ) : (
+
+ {itemTypeString()}
+
+ )}
+