add additional album group metadata rows

This commit is contained in:
jeffvli
2026-07-10 19:10:15 -07:00
parent 857c8187b5
commit d40530b7f6
8 changed files with 393 additions and 23 deletions
+2
View File
@@ -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",
@@ -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;
@@ -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<number | undefined>();
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 ?? '')
)}
</div>
<div className={styles.artistName}>
<JoinedArtists
artistName={song?.albumArtistName ?? ''}
artists={song?.albumArtists ?? []}
linkProps={{ fw: 400 }}
rootTextProps={{ fw: 400, size: 'xs' }}
/>
</div>
<div className={styles.controlsRow}>
<AlbumGroupControls
albumId={song?.albumId}
isGroupHovered={isHovered}
serverId={song?._serverId}
serverType={song?._serverType}
/>
</div>
{metadataRows.map((row) => (
<div className={styles.metadataRow} key={row.id}>
{row.content}
</div>
))}
{showFavoriteRating && (
<div className={styles.controlsRow}>
<AlbumGroupControls
albumId={song?.albumId}
isGroupHovered={isHovered}
serverId={song?._serverId}
serverType={song?._serverType}
/>
</div>
)}
</div>
</div>
);
@@ -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<string, Genre>();
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 (
<JoinedArtists
artistName={song?.albumArtistName ?? ''}
artists={song?.albumArtists ?? []}
linkProps={{ fw: 400 }}
rootTextProps={{ fw: 400, size: 'xs' }}
/>
);
case AlbumGroupItem.DURATION:
return metadata.duration > 0 ? (
<Text size="xs">{formatDurationString(metadata.duration)}</Text>
) : null;
case AlbumGroupItem.GENRES:
if (metadata.genres.length === 0) {
return null;
}
return (
<>
{metadata.genres.map((genre, index) => (
<Fragment key={genre.id}>
<Text
component={Link}
fw={400}
isLink
size="xs"
state={{ item: genre }}
to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {
genreId: genre.id,
})}
>
{genre.name}
</Text>
{index < metadata.genres.length - 1 && ', '}
</Fragment>
))}
</>
);
case AlbumGroupItem.RELEASE_DATE: {
const releaseDate = formatReleaseDate(metadata);
return releaseDate ? <Text size="xs">{releaseDate}</Text> : null;
}
case AlbumGroupItem.RELEASE_TYPE:
return metadata.releaseType ? <Text size="xs">{metadata.releaseType}</Text> : null;
case AlbumGroupItem.RELEASE_YEAR:
return metadata.releaseYear != null && metadata.releaseYear > 0 ? (
<Text size="xs">{metadata.releaseYear}</Text>
) : null;
case AlbumGroupItem.SIZE:
return metadata.size > 0 ? (
<Text size="xs">{formatSizeString(metadata.size)}</Text>
) : null;
case AlbumGroupItem.SONG_COUNT:
return metadata.songCount > 0 ? (
<Text size="xs">{t('entity.trackWithCount', { count: metadata.songCount })}</Text>
) : null;
default:
return null;
}
};
@@ -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 (
<TableColumnContainer
{...props}
@@ -92,6 +99,7 @@ export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => {
>
<AlbumGroupHeader
groupRowCount={groupRowCount}
metadata={metadata}
onPlay={handlePlay}
rowIndex={props.rowIndex}
setAlbumGroupContentHeight={props.setAlbumGroupContentHeight}
@@ -0,0 +1,116 @@
import { Reorder } from 'motion/react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { DraggableItem } from '/@/renderer/features/settings/components/general/draggable-item';
import {
AlbumGroupItem,
SortableItem,
useAlbumGroupItems,
useSettingsStoreActions,
} from '/@/renderer/store';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
const ALBUM_GROUP_ITEM_LABELS: Array<[AlbumGroupItem, string]> = [
[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<AlbumGroupItem>[],
itemLabels: Array<[string, string]>,
): SortableItem<AlbumGroupItem>[] => {
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<AlbumGroupItem>[]),
];
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<AlbumGroupItem, string>,
[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<AlbumGroupItem>[]) => {
setAlbumGroupItems(reorderedItems);
},
[setAlbumGroupItems],
);
return (
<Stack gap="sm" pb="md" pl="md" pr="md">
<Stack gap={0}>
<Text fw={500} size="sm">
{t('table.config.general.albumGroupMetadata')}
</Text>
</Stack>
<Reorder.Group
axis="y"
onReorder={handleReorder}
style={{ userSelect: 'none' }}
values={items}
>
{items.map((item) => (
<DraggableItem
handleChangeDisabled={handleChangeDisabled}
item={item}
key={item.id}
value={translatedItemMap[item.id]}
/>
))}
</Reorder.Group>
</Stack>
);
});
@@ -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: (
<Text pl="md">
<Text fw={500} pl="md" size="sm">
{t('table.config.general.albumImageSize')}
</Text>
),
},
{
component: (
<ListConfigBooleanControl
onChange={(value) =>
setSettings({
general: {
albumGroupShowFavoriteRating: value,
},
})
}
value={albumGroupShowFavoriteRating}
/>
),
id: 'albumGroupShowFavoriteRating',
label: (
<Text fw={500} pl="md" size="sm">
{t('table.config.general.albumGroupShowFavoriteRating')}
</Text>
),
},
]
: []),
]
@@ -342,6 +366,7 @@ export const TableConfig = ({
hasAlbumGroupColumn,
albumGroupOpen,
albumGroupImageSize,
albumGroupShowFavoriteRating,
imageResTable,
setSettings,
]);
@@ -349,6 +374,9 @@ export const TableConfig = ({
return (
<>
<ListConfigTable options={advancedSettings} />
{hasAlbumGroupColumn && tableKey === 'main' && albumGroupOpen && (
<AlbumGroupMetadataConfig />
)}
<Divider />
<TableColumnConfig
data={tableColumnsData}
+49
View File
@@ -76,6 +76,17 @@ const HomeItemSchema = z.enum([
'recentlyReleased',
]);
const AlbumGroupItemSchema = z.enum([
'albumArtists',
'duration',
'genres',
'releaseDate',
'releaseYear',
'releaseType',
'size',
'songCount',
]);
const PlayerItemSchema = z.enum([
'bit_depth',
'bit_rate',
@@ -475,6 +486,8 @@ export const GeneralSettingsSchema = z.object({
albumBackground: z.boolean(),
albumBackgroundBlur: z.number(),
albumGroupImageSize: z.number(),
albumGroupItems: z.array(SortableItemSchema(AlbumGroupItemSchema)),
albumGroupShowFavoriteRating: z.boolean(),
artistBackground: z.boolean(),
artistBackgroundBlur: z.number(),
artistItems: z.array(SortableItemSchema(ArtistItemSchema)),
@@ -767,6 +780,17 @@ export const SettingsStateSchema = ValidationSettingsStateSchema.merge(
NonValidatedSettingsStateSchema,
);
export enum AlbumGroupItem {
ALBUM_ARTISTS = 'albumArtists',
DURATION = 'duration',
GENRES = 'genres',
RELEASE_DATE = 'releaseDate',
RELEASE_TYPE = 'releaseType',
RELEASE_YEAR = 'releaseYear',
SIZE = 'size',
SONG_COUNT = 'songCount',
}
export enum ArtistItem {
BIOGRAPHY = 'biography',
FAVORITE_SONGS = 'favoriteSongs',
@@ -944,6 +968,7 @@ export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
removeCollection: (id: string) => void;
reset: () => void;
resetSampleRate: () => void;
setAlbumGroupItems: (items: SortableItem<AlbumGroupItem>[]) => void;
setArtistItems: (item: SortableItem<ArtistItem>[]) => void;
setArtistReleaseTypeItems: (item: SortableItem<ArtistReleaseTypeItem>[]) => void;
setGenreBehavior: (target: GenreTarget) => void;
@@ -1120,6 +1145,17 @@ const artistReleaseTypeItems = Object.values(ArtistReleaseTypeItem).map((item) =
id: item,
}));
const albumGroupItems: SortableItem<AlbumGroupItem>[] = [
{ 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<SettingsSlice>()(
state.playback.mpvProperties.audioSampleRateHz = 0;
});
},
setAlbumGroupItems: (items: SortableItem<AlbumGroupItem>[]) => {
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 = () =>