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()} + + )} +