From d40530b7f67432348af96c1d877f7518b81e1994 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 10 Jul 2026 19:10:15 -0700 Subject: [PATCH] add additional album group metadata rows --- src/i18n/locales/en.json | 2 + .../album-group-header.module.css | 6 +- .../item-table-list/album-group-header.tsx | 58 ++++--- .../item-table-list/album-group-metadata.tsx | 147 ++++++++++++++++++ .../columns/album-group-column.tsx | 8 + .../album-group-metadata-config.tsx | 116 ++++++++++++++ .../shared/components/table-config.tsx | 30 +++- src/renderer/store/settings.store.ts | 49 ++++++ 8 files changed, 393 insertions(+), 23 deletions(-) create mode 100644 src/renderer/components/item-list/item-table-list/album-group-metadata.tsx create mode 100644 src/renderer/features/shared/components/album-group-metadata-config.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 79358c089..ae64099ce 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1231,6 +1231,8 @@ "general": { "advancedSettings": "Advanced settings", "albumGroupConfig": "Album Group configuration", + "albumGroupMetadata": "Metadata fields", + "albumGroupShowFavoriteRating": "Show favorites and ratings", "albumImageSize": "Album image size", "autoFitColumns": "Auto fit columns", "autosize": "Autosize", diff --git a/src/renderer/components/item-list/item-table-list/album-group-header.module.css b/src/renderer/components/item-list/item-table-list/album-group-header.module.css index 7f0c08c9a..9c9694088 100644 --- a/src/renderer/components/item-list/item-table-list/album-group-header.module.css +++ b/src/renderer/components/item-list/item-table-list/album-group-header.module.css @@ -43,8 +43,8 @@ } .info { - display: grid; - grid-template-rows: auto auto auto; + display: flex; + flex-direction: column; gap: 0; align-content: start; min-width: 0; @@ -71,7 +71,7 @@ } } -.artist-name { +.metadata-row { font-size: var(--theme-font-size-xs); line-height: 1.25; overflow-wrap: anywhere; diff --git a/src/renderer/components/item-list/item-table-list/album-group-header.tsx b/src/renderer/components/item-list/item-table-list/album-group-header.tsx index eff8334e6..3df92bb15 100644 --- a/src/renderer/components/item-list/item-table-list/album-group-header.tsx +++ b/src/renderer/components/item-list/item-table-list/album-group-header.tsx @@ -1,26 +1,33 @@ import clsx from 'clsx'; -import { ReactElement, useLayoutEffect, useRef, useState } from 'react'; +import { ReactElement, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { generatePath, Link } from 'react-router'; import imageColumnStyles from '../item-detail-list/columns/image-column.module.css'; import { AlbumGroupControls } from './album-group-controls'; import styles from './album-group-header.module.css'; +import { AlbumGroupMetadata, renderAlbumGroupMetadataItem } from './album-group-metadata'; import { TableItemSize } from './item-table-list'; import { ItemImage } from '/@/renderer/components/item-image/item-image'; -import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; import { PlayButton } from '/@/renderer/features/shared/components/play-button'; import { LONG_PRESS_PLAY_BEHAVIOR, PlayTooltip, } from '/@/renderer/features/shared/components/play-button-group'; import { AppRoute } from '/@/renderer/router/routes'; -import { useAlbumGroupImageSize, usePlayButtonBehavior } from '/@/renderer/store'; +import { + useAlbumGroupImageSize, + useAlbumGroupItems, + useAlbumGroupShowFavoriteRating, + usePlayButtonBehavior, +} from '/@/renderer/store'; import { LibraryItem, Song } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; interface AlbumGroupHeaderProps { groupRowCount?: number; + metadata: AlbumGroupMetadata; onPlay?: (playType: Play) => void; rowIndex?: number; setAlbumGroupContentHeight?: (rowIndex: number, height: number) => void; @@ -30,12 +37,16 @@ interface AlbumGroupHeaderProps { export const AlbumGroupHeader = ({ groupRowCount, + metadata, onPlay, rowIndex, setAlbumGroupContentHeight, size = 'normal', song, }: AlbumGroupHeaderProps): ReactElement => { + const { t } = useTranslation(); + const albumGroupItems = useAlbumGroupItems(); + const showFavoriteRating = useAlbumGroupShowFavoriteRating(); const [isHovered, setIsHovered] = useState(false); const [resolvedInfoHeight, setResolvedInfoHeight] = useState(); const playButtonBehavior = usePlayButtonBehavior(); @@ -50,6 +61,16 @@ export const AlbumGroupHeader = ({ ? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: song.albumId }) : null; + const metadataRows = useMemo(() => { + return albumGroupItems + .filter((item) => !item.disabled) + .map((item) => ({ + content: renderAlbumGroupMetadataItem(item.id, song, metadata, t), + id: item.id, + })) + .filter((item) => item.content != null); + }, [albumGroupItems, metadata, song, t]); + // The album group spans the combined row height, but when the image is // enlarged the group's last row is grown so the total reaches the img size. const infoHeight = @@ -145,22 +166,21 @@ export const AlbumGroupHeader = ({ (song?.album ?? '') )} -
- -
-
- -
+ {metadataRows.map((row) => ( +
+ {row.content} +
+ ))} + {showFavoriteRating && ( +
+ +
+ )} ); diff --git a/src/renderer/components/item-list/item-table-list/album-group-metadata.tsx b/src/renderer/components/item-list/item-table-list/album-group-metadata.tsx new file mode 100644 index 000000000..5efb638ee --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/album-group-metadata.tsx @@ -0,0 +1,147 @@ +import { TFunction } from 'i18next'; +import { Fragment, ReactNode } from 'react'; +import { generatePath, Link } from 'react-router'; + +import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; +import { AppRoute } from '/@/renderer/router/routes'; +import { AlbumGroupItem } from '/@/renderer/store'; +import { formatDurationString, formatPartialIsoDateUTC, formatSizeString } from '/@/renderer/utils'; +import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types'; +import { Text } from '/@/shared/components/text/text'; +import { Genre, Song } from '/@/shared/types/domain-types'; + +export type AlbumGroupMetadata = { + duration: number; + genres: Genre[]; + releaseDate: Song['releaseDate']; + releaseType: null | string; + releaseYear: Song['releaseYear']; + size: number; + songCount: number; +}; + +export const computeAlbumGroupMetadata = ( + songs: Song[], + songCount: number, + t: TFunction, +): AlbumGroupMetadata => { + const genreMap = new Map(); + let duration = 0; + let size = 0; + + for (const song of songs) { + duration += song.duration ?? 0; + size += song.size ?? 0; + + for (const genre of song.genres ?? []) { + if (!genreMap.has(genre.id)) { + genreMap.set(genre.id, genre); + } + } + } + + const firstSong = songs[0]; + const releaseTypes = firstSong?.tags?.releasetype; + const releaseType = releaseTypes?.length + ? (normalizeReleaseTypes(releaseTypes, t)[0] ?? null) + : null; + + return { + duration, + genres: Array.from(genreMap.values()), + releaseDate: firstSong?.releaseDate ?? null, + releaseType, + releaseYear: firstSong?.releaseYear ?? null, + size, + songCount, + }; +}; + +const formatReleaseDate = (metadata: AlbumGroupMetadata): null | string => { + if (metadata.releaseDate) { + return formatPartialIsoDateUTC(metadata.releaseDate); + } + + return null; +}; + +export const renderAlbumGroupMetadataItem = ( + itemId: AlbumGroupItem, + song: Song | undefined, + metadata: AlbumGroupMetadata, + t: TFunction, +): null | ReactNode => { + switch (itemId) { + case AlbumGroupItem.ALBUM_ARTISTS: + if (!song?.albumArtistName && !(song?.albumArtists?.length ?? 0)) { + return null; + } + + return ( + + ); + + case AlbumGroupItem.DURATION: + return metadata.duration > 0 ? ( + {formatDurationString(metadata.duration)} + ) : null; + + case AlbumGroupItem.GENRES: + if (metadata.genres.length === 0) { + return null; + } + + return ( + <> + {metadata.genres.map((genre, index) => ( + + + {genre.name} + + {index < metadata.genres.length - 1 && ', '} + + ))} + + ); + + case AlbumGroupItem.RELEASE_DATE: { + const releaseDate = formatReleaseDate(metadata); + return releaseDate ? {releaseDate} : null; + } + + case AlbumGroupItem.RELEASE_TYPE: + return metadata.releaseType ? {metadata.releaseType} : null; + + case AlbumGroupItem.RELEASE_YEAR: + return metadata.releaseYear != null && metadata.releaseYear > 0 ? ( + {metadata.releaseYear} + ) : null; + + case AlbumGroupItem.SIZE: + return metadata.size > 0 ? ( + {formatSizeString(metadata.size)} + ) : null; + + case AlbumGroupItem.SONG_COUNT: + return metadata.songCount > 0 ? ( + {t('entity.trackWithCount', { count: metadata.songCount })} + ) : null; + + default: + return null; + } +}; diff --git a/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx b/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx index a8567c1cf..32b29c780 100644 --- a/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx @@ -1,6 +1,8 @@ import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { AlbumGroupHeader } from '/@/renderer/components/item-list/item-table-list/album-group-header'; +import { computeAlbumGroupMetadata } from '/@/renderer/components/item-list/item-table-list/album-group-metadata'; import { isLastInAlbumGroup, ItemTableListInnerColumn, @@ -10,6 +12,7 @@ import { Song } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => { + const { t } = useTranslation(); const firstDataRow = props.enableHeader ? 1 : 0; const item = props.getRowItem?.(props.rowIndex) as null | Song | undefined; @@ -76,13 +79,17 @@ export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => { } let groupRowCount = 1; + const groupSongs: Song[] = [item]; const totalDataRows = props.data.length + firstDataRow; for (let idx = props.rowIndex + 1; idx < totalDataRows; idx++) { const nextItem = props.getRowItem?.(idx) as null | Song | undefined; if (!nextItem || nextItem.album !== item.album) break; groupRowCount++; + groupSongs.push(nextItem); } + const metadata = computeAlbumGroupMetadata(groupSongs, groupRowCount, t); + return ( { > = [ + [AlbumGroupItem.ALBUM_ARTISTS, 'table.column.albumArtist'], + [AlbumGroupItem.RELEASE_DATE, 'table.column.releaseDate'], + [AlbumGroupItem.RELEASE_YEAR, 'table.column.releaseYear'], + [AlbumGroupItem.SONG_COUNT, 'table.column.songCount'], + [AlbumGroupItem.DURATION, 'common.duration'], + [AlbumGroupItem.RELEASE_TYPE, 'common.releaseType'], + [AlbumGroupItem.GENRES, 'table.column.genre'], + [AlbumGroupItem.SIZE, 'table.column.size'], +]; + +const mergeItems = ( + items: SortableItem[], + itemLabels: Array<[string, string]>, +): SortableItem[] => { + const allItemIds = itemLabels.map(([key]) => key); + + const missingItemIds = allItemIds.filter((id) => !items.some((item) => item.id === id)); + + const merged = [ + ...items, + ...(missingItemIds.map((id) => ({ + disabled: true, + id, + })) as SortableItem[]), + ]; + + const uniqueMerged = merged.filter( + (item, index, self) => index === self.findIndex((t) => t.id === item.id), + ); + + return uniqueMerged.filter((item) => itemLabels.some(([key]) => key === item.id)); +}; + +export const AlbumGroupMetadataConfig = memo(() => { + const { t } = useTranslation(); + const albumGroupItems = useAlbumGroupItems(); + const { setAlbumGroupItems } = useSettingsStoreActions(); + + const items = useMemo( + () => mergeItems(albumGroupItems, ALBUM_GROUP_ITEM_LABELS), + [albumGroupItems], + ); + + const translatedItemMap = useMemo( + () => + Object.fromEntries( + ALBUM_GROUP_ITEM_LABELS.map(([key, value]) => [key, t(value)]), + ) as Record, + [t], + ); + + const handleChangeDisabled = useCallback( + (id: string, enabled: boolean) => { + setAlbumGroupItems( + items.map((item) => { + if (item.id === id) { + return { + ...item, + disabled: !enabled, + }; + } + + return item; + }), + ); + }, + [items, setAlbumGroupItems], + ); + + const handleReorder = useCallback( + (reorderedItems: SortableItem[]) => { + setAlbumGroupItems(reorderedItems); + }, + [setAlbumGroupItems], + ); + + return ( + + + + {t('table.config.general.albumGroupMetadata')} + + + + {items.map((item) => ( + + ))} + + + ); +}); diff --git a/src/renderer/features/shared/components/table-config.tsx b/src/renderer/features/shared/components/table-config.tsx index 1b2e9c895..23eca1952 100644 --- a/src/renderer/features/shared/components/table-config.tsx +++ b/src/renderer/features/shared/components/table-config.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'; import styles from './table-config.module.css'; import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types'; +import { AlbumGroupMetadataConfig } from '/@/renderer/features/shared/components/album-group-metadata-config'; import { ListConfigBooleanControl, ListConfigTable, @@ -74,6 +75,9 @@ export const TableConfig = ({ const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings; const albumGroupImageSize = useSettingsStore((state) => state.general.albumGroupImageSize); + const albumGroupShowFavoriteRating = useSettingsStore( + (state) => state.general.albumGroupShowFavoriteRating, + ); const imageResTable = useSettingsStore((state) => state.general.imageRes.table); const { setList, setSettings } = useSettingsStoreActions(); const [albumGroupOpen, setAlbumGroupOpen] = useState(false); @@ -156,11 +160,31 @@ export const TableConfig = ({ ), id: 'albumImageSize', label: ( - + {t('table.config.general.albumImageSize')} ), }, + { + component: ( + + setSettings({ + general: { + albumGroupShowFavoriteRating: value, + }, + }) + } + value={albumGroupShowFavoriteRating} + /> + ), + id: 'albumGroupShowFavoriteRating', + label: ( + + {t('table.config.general.albumGroupShowFavoriteRating')} + + ), + }, ] : []), ] @@ -342,6 +366,7 @@ export const TableConfig = ({ hasAlbumGroupColumn, albumGroupOpen, albumGroupImageSize, + albumGroupShowFavoriteRating, imageResTable, setSettings, ]); @@ -349,6 +374,9 @@ export const TableConfig = ({ return ( <> + {hasAlbumGroupColumn && tableKey === 'main' && albumGroupOpen && ( + + )} { removeCollection: (id: string) => void; reset: () => void; resetSampleRate: () => void; + setAlbumGroupItems: (items: SortableItem[]) => void; setArtistItems: (item: SortableItem[]) => void; setArtistReleaseTypeItems: (item: SortableItem[]) => void; setGenreBehavior: (target: GenreTarget) => void; @@ -1120,6 +1145,17 @@ const artistReleaseTypeItems = Object.values(ArtistReleaseTypeItem).map((item) = id: item, })); +const albumGroupItems: SortableItem[] = [ + { disabled: false, id: AlbumGroupItem.ALBUM_ARTISTS }, + { disabled: true, id: AlbumGroupItem.RELEASE_DATE }, + { disabled: true, id: AlbumGroupItem.RELEASE_YEAR }, + { disabled: true, id: AlbumGroupItem.SONG_COUNT }, + { disabled: true, id: AlbumGroupItem.DURATION }, + { disabled: true, id: AlbumGroupItem.RELEASE_TYPE }, + { disabled: true, id: AlbumGroupItem.GENRES }, + { disabled: true, id: AlbumGroupItem.SIZE }, +]; + // Determines the default/initial windowBarStyle value based on the current platform. const getPlatformDefaultWindowBarStyle = (): Platform => { if (utils?.isWindows()) { @@ -1173,6 +1209,8 @@ const initialState: SettingsState = { albumBackground: false, albumBackgroundBlur: 3, albumGroupImageSize: 0, + albumGroupItems, + albumGroupShowFavoriteRating: true, artistBackground: true, artistBackgroundBlur: 3, artistItems, @@ -2063,6 +2101,11 @@ export const useSettingsStore = createWithEqualityFn()( state.playback.mpvProperties.audioSampleRateHz = 0; }); }, + setAlbumGroupItems: (items: SortableItem[]) => { + set((state) => { + state.general.albumGroupItems = items; + }); + }, setArtistItems: (items) => { set((state) => { state.general.artistItems = items; @@ -2681,6 +2724,9 @@ export const useImageRes = () => useSettingsStore((state) => state.general.image export const useAlbumGroupImageSize = () => useSettingsStore((state) => state.general.albumGroupImageSize); +export const useAlbumGroupShowFavoriteRating = () => + useSettingsStore((state) => state.general.albumGroupShowFavoriteRating); + export const useVolumeWidth = () => useSettingsStore((state) => state.general.volumeWidth, shallow); export const useFollowCurrentSong = () => @@ -2800,6 +2846,9 @@ export const useHomeFeatureStyle = () => export const useHomeItems = () => useSettingsStore((state) => state.general.homeItems, shallow); +export const useAlbumGroupItems = () => + useSettingsStore((state) => state.general.albumGroupItems, shallow); + export const useArtistItems = () => useSettingsStore((state) => state.general.artistItems, shallow); export const useArtistReleaseTypeItems = () =>