diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4fa44d13..0ed6838a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -97,6 +97,8 @@ "quit": "quit", "random": "random", "rating": "rating", + "recordLabel": "record label", + "releaseType": "release type", "refresh": "refresh", "reload": "reload", "reset": "reset", @@ -496,6 +498,29 @@ "pause": "pause", "viewQueue": "view queue" }, + "releaseType": { + "primary": { + "album": "$t(entity.album_one)", + "broadcast": "broadcast", + "ep": "ep", + "other": "other", + "single": "single" + }, + "secondary": { + "audiobook": "audiobook", + "audioDrama": "audio drama", + "compilation": "compilation", + "djMix": "dj mix", + "demo": "demo", + "fieldRecording": "field recording", + "interview": "interview", + "live": "live", + "mixtape": "mixtape", + "remix": "remix", + "soundtrack": "soundtrack", + "spokenWord": "spoken word" + } + }, "setting": { "accentColor_description": "sets the accent color for the application", "accentColor": "accent color", diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index 4c9a18cf..4fecc4b5 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react'; +import { forwardRef, Ref, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, useParams } from 'react-router'; import { Link } from 'react-router-dom'; @@ -13,8 +13,10 @@ import { useSongChange } from '/@/renderer/hooks/use-song-change'; import { queryClient } from '/@/renderer/lib/react-query'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer } from '/@/renderer/store'; -import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; +import { formatDateAbsoluteUTC, formatDurationString, titleCase } from '/@/renderer/utils'; +import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types'; import { Group } from '/@/shared/components/group/group'; +import { Pill } from '/@/shared/components/pill/pill'; import { Rating } from '/@/shared/components/rating/rating'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; @@ -38,7 +40,9 @@ export const AlbumDetailHeader = forwardRef( const cq = useContainerQuery(); const { t } = useTranslation(); - const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME; + const showRating = + detailQuery?.data?.serverType === ServerType.NAVIDROME || + detailQuery?.data?.serverType === ServerType.SUBSONIC; const originalDifferentFromRelease = detailQuery.data?.originalDate && @@ -78,7 +82,16 @@ export const AlbumDetailHeader = forwardRef( } }, detailQuery.data !== undefined); - const metadataItems = [ + const releaseTypes = useMemo( + () => + normalizeReleaseTypes(detailQuery.data?.releaseTypes ?? [], t).map((type) => ({ + id: type, + value: titleCase(type), + })) || [], + [detailQuery.data?.releaseTypes, t], + ); + + const metadataItems = releaseTypes.concat([ { id: 'releaseDate', value: @@ -98,11 +111,17 @@ export const AlbumDetailHeader = forwardRef( }, { id: 'playCount', - value: t('entity.play', { - count: detailQuery?.data?.playCount as number, - }), + value: + typeof detailQuery?.data?.playCount === 'number' && + t('entity.play', { + count: detailQuery?.data?.playCount, + }), }, - ]; + { + id: 'version', + value: detailQuery.data?.version, + }, + ]); if (originalDifferentFromRelease) { const formatted = `♫ ${formatDateAbsoluteUTC(detailQuery!.data!.originalDate)}`; @@ -135,28 +154,22 @@ export const AlbumDetailHeader = forwardRef( title={detailQuery?.data?.name || ''} {...background} > - - - {metadataItems.map((item, index) => ( - - {index > 0 && } - {item.value} - - ))} - {showRating && ( - <> - - - + + + {metadataItems.map( + (item, index) => + item.value && ( + {item.value} + ), )} - + + {showRating && ( + + )} const AlbumPropertyMapping: ItemDetailRow[] = [ { key: 'name', label: 'common.title' }, { label: 'entity.albumArtist_one', render: (item) => formatArtists(item.albumArtists) }, + { + label: 'common.releaseType', + render: (item, t) => normalizeReleaseTypes(item.releaseTypes, t).join(SEPARATOR_STRING), + }, { label: 'entity.genre_other', render: FormatGenre }, { label: 'common.duration', @@ -174,6 +179,8 @@ const AlbumPropertyMapping: ItemDetailRow[] = [ ) : null, }, { key: 'id', label: 'filter.id' }, + { key: 'version', label: 'common.version' }, + { label: 'common.recordLabel', render: (item) => item.recordLabels.join(SEPARATOR_STRING) }, ]; const AlbumArtistPropertyMapping: ItemDetailRow[] = [ diff --git a/src/renderer/utils/normalize-release-types.tsx b/src/renderer/utils/normalize-release-types.tsx new file mode 100644 index 00000000..36544370 --- /dev/null +++ b/src/renderer/utils/normalize-release-types.tsx @@ -0,0 +1,57 @@ +import { TFunction } from 'react-i18next'; + +import { titleCase } from '/@/renderer/utils/title-case'; + +// Release types derived from https://musicbrainz.org/doc/Release_Group/Type +const PRIMARY_MAPPING = { + album: 'album', + broadcast: 'broadcast', + ep: 'ep', + other: 'other', + single: 'single', +} as const; + +const SECONDARY_MAPPING = { + audiobook: 'audiobook', + 'audio drama': 'audioDrama', + compilation: 'compilation', + demo: 'demo', + 'dj-mix': 'djMix', + 'field recording': 'fieldRecording', + interview: 'interview', + live: 'live', + 'mixtape/street': 'mixtape', + remix: 'remix', + soundtrack: 'soundtrack', + spokenword: 'spokenWord', +} as const; + +export const normalizeReleaseTypes = (types: string[], t: TFunction) => { + const primary: string[] = []; + const secondary: string[] = []; + const unknown: string[] = []; + + for (const type of types) { + const lower = type.toLocaleLowerCase(); + + if (lower in PRIMARY_MAPPING) { + primary.push( + t(`releaseType.primary.${PRIMARY_MAPPING[lower]}`, { postProcess: 'sentenceCase' }), + ); + } else if (lower in SECONDARY_MAPPING) { + secondary.push( + t(`releaseType.secondary.${SECONDARY_MAPPING[lower]}`, { + postProcess: 'sentenceCase', + }), + ); + } else { + unknown.push(titleCase(type)); + } + } + + primary.sort(); + secondary.sort(); + unknown.sort(); + + return primary.concat(secondary, unknown); +}; diff --git a/src/shared/api/jellyfin/jellyfin-normalize.ts b/src/shared/api/jellyfin/jellyfin-normalize.ts index 5e6cb469..36a23842 100644 --- a/src/shared/api/jellyfin/jellyfin-normalize.ts +++ b/src/shared/api/jellyfin/jellyfin-normalize.ts @@ -340,7 +340,9 @@ const normalizeAlbum = ( originalDate: null, participants: getPeople(item), playCount: item.UserData?.PlayCount || 0, + recordLabels: [], releaseDate: item.PremiereDate?.split('T')[0] || null, + releaseTypes: [], releaseYear: item.ProductionYear || null, serverId: server?.id || '', serverType: ServerType.JELLYFIN, @@ -352,6 +354,7 @@ const normalizeAlbum = ( updatedAt: item?.DateLastMediaAdded || item.DateCreated, userFavorite: item.UserData?.IsFavorite || false, userRating: null, + version: null, }; }; diff --git a/src/shared/api/navidrome/navidrome-normalize.ts b/src/shared/api/navidrome/navidrome-normalize.ts index 742ee30e..bcfe0045 100644 --- a/src/shared/api/navidrome/navidrome-normalize.ts +++ b/src/shared/api/navidrome/navidrome-normalize.ts @@ -218,6 +218,47 @@ const normalizeSong = ( }; }; +const parseAlbumTags = ( + item: z.infer, +): Pick => { + if (!item.tags) { + return { + recordLabels: [], + releaseTypes: [], + tags: null, + version: null, + }; + } + + // We get the genre from elsewhere. We don't need genre twice + delete item.tags['genre']; + + let recordLabels: string[] = []; + if (item.tags['recordlabel']) { + recordLabels = item.tags['recordlabel']; + delete item.tags['recordlabel']; + } + + let releaseTypes: string[] = []; + if (item.tags['releasetype']) { + releaseTypes = item.tags['releasetype']; + delete item.tags['releasetype']; + } + + let version: null | string = null; + if (item.tags['albumversion']) { + version = item.tags['albumversion'].join(' · '); + delete item.tags['albumversion']; + } + + return { + recordLabels, + releaseTypes, + tags: item.tags, + version, + }; +}; + const normalizeAlbum = ( item: z.infer & { songs?: z.infer; @@ -238,8 +279,9 @@ const normalizeAlbum = ( const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null; return { - albumArtist: item.albumArtist, + ...parseAlbumTags(item), ...getArtists(item), + albumArtist: item.albumArtist, backdropImageUrl: imageBackdropUrl, comment: item.comment || null, createdAt: item.createdAt.split('T')[0], @@ -281,7 +323,6 @@ const normalizeAlbum = ( size: item.size, songCount: item.songCount, songs: item.songs ? item.songs.map((song) => normalizeSong(song, server)) : undefined, - tags: item.tags || null, uniqueId: nanoid(), updatedAt: item.updatedAt, userFavorite: item.starred, diff --git a/src/shared/api/subsonic/subsonic-normalize.ts b/src/shared/api/subsonic/subsonic-normalize.ts index 02d9afe8..58904124 100644 --- a/src/shared/api/subsonic/subsonic-normalize.ts +++ b/src/shared/api/subsonic/subsonic-normalize.ts @@ -272,7 +272,9 @@ const normalizeAlbum = ( originalDate: null, participants: getParticipants(item), playCount: null, + recordLabels: item.recordLabels?.map((item) => item.name) || [], releaseDate: item.year ? new Date(Date.UTC(item.year, 0, 1)).toISOString() : null, + releaseTypes: item.releaseTypes || [], releaseYear: item.year ? Number(item.year) : null, serverId: server?.id || 'unknown', serverType: ServerType.SUBSONIC, @@ -287,6 +289,7 @@ const normalizeAlbum = ( updatedAt: item.created, userFavorite: item.starred || false, userRating: item.userRating || null, + version: item.version || null, }; }; diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index 973385a6..4be3577c 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -117,6 +117,10 @@ const song = z.object({ year: z.number().optional(), }); +const recordLabel = z.object({ + name: z.string(), +}); + const album = z.object({ album: z.string(), artist: z.string(), @@ -135,11 +139,14 @@ const album = z.object({ isVideo: z.boolean(), name: z.string(), parent: z.string(), + recordLabels: z.array(recordLabel).optional(), + releaseTypes: z.array(z.string()).optional(), song: z.array(song), songCount: z.number(), starred: z.boolean().optional(), title: z.string(), userRating: z.number().optional(), + version: z.string().optional(), year: z.number().optional(), }); diff --git a/src/shared/components/pill/pill.module.css b/src/shared/components/pill/pill.module.css index 62bfe255..b8ed2743 100644 --- a/src/shared/components/pill/pill.module.css +++ b/src/shared/components/pill/pill.module.css @@ -1,28 +1,39 @@ +.root { + box-sizing: border-box; + user-select: auto; + background: alpha(var(--theme-colors-background), 0.5); + border: 1px solid var(--theme-colors-border); + + &[data-variant='outline'] { + background: transparent; + border: 1px solid var(--theme-colors-border); + } +} + .label { - font-family: var(--theme-content-font-family); + font-family: var(--theme-content-font-family); } .label.sm { - font-size: var(--theme-font-size-sm); + font-size: var(--theme-font-size-sm); } .label.md { - font-size: var(--theme-font-size-md); + font-size: var(--theme-font-size-md); } .label.lg { - font-size: var(--theme-font-size-lg); + font-size: var(--theme-font-size-lg); } .label.xl { - font-size: var(--theme-font-size-xl); + font-size: var(--theme-font-size-xl); } .label.xs { - font-size: var(--theme-font-size-xs); + font-size: var(--theme-font-size-xs); } - .remove { transition: color 0.1s ease-in-out; diff --git a/src/shared/components/pill/pill.tsx b/src/shared/components/pill/pill.tsx index 46e1949f..cc29fe9b 100644 --- a/src/shared/components/pill/pill.tsx +++ b/src/shared/components/pill/pill.tsx @@ -3,7 +3,9 @@ import clsx from 'clsx'; import styles from './pill.module.css'; -export const Pill = ({ children, size = 'md', ...props }: MantinePillProps) => { +interface PillProps extends MantinePillProps {} + +export const Pill = ({ children, classNames, radius = 'md', size = 'md', ...props }: PillProps) => { return ( { }), remove: styles.remove, root: styles.root, + ...classNames, }} - size="md" + radius={radius} + size={size} {...props} > {children} diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index c0a9e7ba..29a95e8b 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -182,7 +182,9 @@ export type Album = { originalDate: null | string; participants: null | Record; playCount: null | number; + recordLabels: string[]; releaseDate: null | string; + releaseTypes: string[]; releaseYear: null | number; serverId: string; serverType: ServerType; @@ -194,6 +196,7 @@ export type Album = { updatedAt: string; userFavorite: boolean; userRating: null | number; + version: null | string; } & { songs?: Song[] }; export type AlbumArtist = { diff --git a/src/types/mantine.d.ts b/src/types/mantine.d.ts new file mode 100644 index 00000000..d2625ecc --- /dev/null +++ b/src/types/mantine.d.ts @@ -0,0 +1,9 @@ +import { PillVariant } from '@mantine/core'; + +type ExtendedPillVariant = 'outline' | PillVariant; + +declare module '@mantine/core' { + export interface PillProps { + variant?: ExtendedPillVariant; + } +}