feat: album group has a config and can set the image size (#2153)

* Created a new album group configuration which includes (for now) an option to set the image size of the album group artwork.
This commit is contained in:
Norman
2026-06-29 19:00:20 -07:00
committed by GitHub
parent 751ec7f835
commit aa3c9251f5
8 changed files with 346 additions and 39 deletions
+2
View File
@@ -1218,6 +1218,8 @@
"config": {
"general": {
"advancedSettings": "Advanced settings",
"albumGroupConfig": "Album Group configuration",
"albumImageSize": "Album image size",
"autoFitColumns": "Auto fit columns",
"autosize": "Autosize",
"moveUp": "Move up",
@@ -10,7 +10,7 @@ import {
LONG_PRESS_PLAY_BEHAVIOR,
PlayTooltip,
} from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonBehavior } from '/@/renderer/store';
import { useAlbumGroupImageSize, usePlayButtonBehavior } from '/@/renderer/store';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
@@ -29,12 +29,33 @@ export const AlbumGroupHeader = ({
}: AlbumGroupHeaderProps): ReactElement => {
const [isHovered, setIsHovered] = useState(false);
const playButtonBehavior = usePlayButtonBehavior();
const albumImageSize = useAlbumGroupImageSize();
const rowHeight = {
compact: TableItemSize.COMPACT,
large: TableItemSize.LARGE,
normal: TableItemSize.DEFAULT,
}[size];
const infoHeight = groupRowCount !== undefined ? groupRowCount * rowHeight : undefined;
// 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 =
groupRowCount !== undefined
? albumImageSize > 0
? Math.max(albumImageSize, groupRowCount * rowHeight)
: groupRowCount * rowHeight
: undefined;
const imageContainerStyle =
albumImageSize > 0
? {
aspectRatio: 'auto',
height: `${albumImageSize}px`,
paddingBottom: 'var(--theme-spacing-xs)',
paddingTop: 'var(--theme-spacing-xs)',
position: 'relative' as const,
width: `${albumImageSize}px`,
zIndex: 1,
}
: undefined;
return (
<div className={styles.container}>
@@ -42,6 +63,7 @@ export const AlbumGroupHeader = ({
className={styles.imageContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={imageContainerStyle}
>
<ItemImage
className={imageColumnStyles.compactImage}
@@ -64,6 +64,12 @@ export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => {
...(needsBorder
? { borderBottom: '1px solid var(--theme-colors-border)' }
: {}),
// When the cover is enlarged it overflows down from the
// group's first row into these cells; let hover/click pass
// through to reach it.
...((props.albumGroupImageSize ?? 0) > 0
? { pointerEvents: 'none' as const }
: {}),
}}
/>
);
@@ -31,6 +31,11 @@
padding-left: 0;
}
.container.no-vertical-padding {
padding-top: 0;
padding-bottom: 0;
}
.container.center {
align-items: center;
text-align: center;
@@ -57,7 +57,10 @@ import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table
import { TrackNumberColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-number-column';
import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import {
TableItemProps,
TableItemSize,
} from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { useItemTableListColumnResizeLive } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types';
import { Flex } from '/@/shared/components/flex/flex';
@@ -381,6 +384,36 @@ export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nex
const NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_ARTIST, TableColumn.TITLE_COMBINED];
// Counts how many consecutive rows belong to the same album group as `rowIndex`.
export function getAlbumGroupRowCount(
rowIndex: number,
getRowItem: ((index: number) => unknown) | undefined,
enableHeader: boolean | undefined,
dataLength: number,
): number {
const item = getRowItem?.(rowIndex) as null | undefined | { album?: string };
if (!item?.album) return 1;
const firstDataRow = enableHeader ? 1 : 0;
const maxRow = enableHeader ? dataLength + 1 : dataLength;
let start = rowIndex;
while (start > firstDataRow) {
const prevItem = getRowItem?.(start - 1) as null | undefined | { album?: string };
if (!prevItem || prevItem.album !== item.album) break;
start--;
}
let end = rowIndex;
while (end + 1 < maxRow) {
const nextItem = getRowItem?.(end + 1) as null | undefined | { album?: string };
if (!nextItem || nextItem.album !== item.album) break;
end++;
}
return end - start + 1;
}
export function isAlbumGroupingActive(columns: { id: string; isEnabled?: boolean }[]): boolean {
return columns.some((col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled);
}
@@ -402,6 +435,106 @@ export function isLastInAlbumGroup(
return !nextItem || nextItem.album !== item.album;
}
function baseRowHeightForSize(size: ItemTableListColumn['size']): number {
if (size === 'compact') return TableItemSize.COMPACT;
if (size === 'large') return TableItemSize.LARGE;
return TableItemSize.DEFAULT;
}
// Wraps a clamped cell with the spacer that fills the reserved (grown) height
// below it. The spacer carries the group's bottom/right borders so they align
// across all columns.
function ClampedCell({
cell,
clampHeight,
outerStyle,
showHorizontalBorder,
showVerticalBorder,
}: {
cell: ReactElement;
clampHeight: null | number;
outerStyle?: CSSProperties;
showHorizontalBorder: boolean;
showVerticalBorder: boolean;
}): ReactElement {
const grownHeight = typeof outerStyle?.height === 'number' ? outerStyle.height : 0;
const spacerHeight = clampHeight !== null ? grownHeight - clampHeight : 0;
if (clampHeight === null || spacerHeight <= 0) return cell;
return (
<div style={outerStyle}>
{cell}
<div
aria-hidden
style={{
borderBottom: showHorizontalBorder
? '1px solid var(--theme-colors-border)'
: undefined,
borderRight: showVerticalBorder
? '1px solid var(--theme-colors-border)'
: undefined,
height: spacerHeight,
}}
/>
</div>
);
}
// When an enlarged album image extends past the album group's combined row
// height, the last row of the group is grown (in getRowHeight) to reserve the
// leftover space. This returns the standard (un-grown) height to clamp that
// row's non-album cells to, so the track content + hover/selection stay at
// standard height and the reserved space below is left empty (uniform
// background) for the overflowing album image.
function getAlbumGroupClampHeight(props: ItemTableListInnerColumn): null | number {
const albumImageSize = props.albumGroupImageSize ?? 0;
if (albumImageSize <= 0) return null;
if (props.type === TableColumn.ALBUM_GROUP) return null;
if (!isAlbumGroupingActive(props.columns)) return null;
const isDataRow = props.enableHeader ? props.rowIndex > 0 : true;
if (!isDataRow) return null;
const item = props.getRowItem?.(props.rowIndex) as null | undefined | { album?: string };
if (!item?.album) return null;
if (
!isLastInAlbumGroup(props.rowIndex, props.getRowItem, props.enableHeader, props.data.length)
) {
return null;
}
const baseHeight = baseRowHeightForSize(props.size);
const groupRowCount = getAlbumGroupRowCount(
props.rowIndex,
props.getRowItem,
props.enableHeader,
props.data.length,
);
// Only clamp when the row was actually grown to fit the image.
if (albumImageSize <= groupRowCount * baseHeight) return null;
return baseHeight;
}
function showHorizontalBorderFor(props: ItemTableListInnerColumn, isLastRow: boolean): boolean {
if (!props.enableHorizontalBorders || !props.enableHeader || props.rowIndex <= 0) {
return false;
}
if (isAlbumGroupingActive(props.columns)) {
return isLastInAlbumGroup(
props.rowIndex,
props.getRowItem,
!!props.enableHeader,
props.data.length,
);
}
return props.rowIndex === 1 || !isLastRow;
}
export const TableColumnTextContainer = (
props: ItemTableListColumn & {
children: React.ReactNode;
@@ -425,6 +558,7 @@ export const TableColumnTextContainer = (
? props.internalState.extractRowId(item)
: undefined;
const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined);
const clampHeight = getAlbumGroupClampHeight(props);
const isDragging = props.isDragging ?? false;
const mergedRef = useMergedRef(containerRef, props.dragRef ?? null);
@@ -507,7 +641,10 @@ export const TableColumnTextContainer = (
}
};
return (
const showHorizontalBorder = showHorizontalBorderFor(props, isLastRow);
const showVerticalBorder = !!props.enableVerticalBorders && !isLastColumn;
const cell = (
<div
className={clsx(styles.container, props.containerClassName, {
[styles.alternateRowEven]:
@@ -529,25 +666,16 @@ export const TableColumnTextContainer = (
[styles.right]: props.columns[props.columnIndex].align === 'end',
[styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight,
[styles.rowSelected]: isDataRow && isSelected,
[styles.withHorizontalBorder]:
props.enableHorizontalBorders &&
props.enableHeader &&
props.rowIndex > 0 &&
(isAlbumGroupingActive(props.columns)
? isLastInAlbumGroup(
props.rowIndex,
props.getRowItem,
!!props.enableHeader,
props.data.length,
)
: props.rowIndex === 1 || !isLastRow),
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
// When clamped, the bottom border is drawn on the spacer below
// instead.
[styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null,
[styles.withVerticalBorder]: showVerticalBorder,
})}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleClick}
onContextMenu={handleContextMenu}
ref={mergedRef}
style={props.style}
style={clampHeight !== null ? { height: clampHeight } : props.style}
>
<Text
className={clsx(styles.content, props.className, {
@@ -561,6 +689,16 @@ export const TableColumnTextContainer = (
</Text>
</div>
);
return (
<ClampedCell
cell={cell}
clampHeight={clampHeight}
outerStyle={props.style}
showHorizontalBorder={showHorizontalBorder}
showVerticalBorder={showVerticalBorder}
/>
);
};
export const TableColumnContainer = (
@@ -586,6 +724,7 @@ export const TableColumnContainer = (
? props.internalState.extractRowId(item)
: undefined;
const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined);
const clampHeight = getAlbumGroupClampHeight(props);
const isDragging = props.isDragging ?? false;
const mergedRef = useMergedRef(containerRef, props.dragRef ?? null);
@@ -668,7 +807,10 @@ export const TableColumnContainer = (
}
};
return (
const showHorizontalBorder = showHorizontalBorderFor(props, isLastRow);
const showVerticalBorder = !!props.enableVerticalBorders && !isLastColumn;
const cell = (
<div
className={clsx(styles.container, props.className, {
[styles.alternateRowEven]:
@@ -682,6 +824,8 @@ export const TableColumnContainer = (
[styles.large]: props.size === 'large',
[styles.left]: props.columns[props.columnIndex].align === 'start',
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
[styles.noVerticalPadding]:
props.type === TableColumn.ALBUM_GROUP && (props.albumGroupImageSize ?? 0) > 0,
[styles.paddingLg]: props.cellPadding === 'lg',
[styles.paddingMd]: props.cellPadding === 'md',
[styles.paddingSm]: props.cellPadding === 'sm',
@@ -694,29 +838,33 @@ export const TableColumnContainer = (
props.type !== TableColumn.ALBUM_GROUP,
[styles.rowSelected]:
isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP,
[styles.withHorizontalBorder]:
props.enableHorizontalBorders &&
props.enableHeader &&
props.rowIndex > 0 &&
(isAlbumGroupingActive(props.columns)
? isLastInAlbumGroup(
props.rowIndex,
props.getRowItem,
!!props.enableHeader,
props.data.length,
)
: props.rowIndex === 1 || !isLastRow),
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
// When clamped, the bottom border is drawn on the spacer below instead.
[styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null,
[styles.withVerticalBorder]: showVerticalBorder,
})}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleClick}
onContextMenu={handleContextMenu}
ref={mergedRef}
style={{ ...props.containerStyle, ...props.style }}
style={
clampHeight !== null
? { ...props.containerStyle, height: clampHeight }
: { ...props.containerStyle, ...props.style }
}
>
{props.children}
</div>
);
return (
<ClampedCell
cell={cell}
clampHeight={clampHeight}
outerStyle={props.style}
showHorizontalBorder={showHorizontalBorder}
showVerticalBorder={showVerticalBorder}
/>
);
};
interface ColumnResizeHandleProps {
@@ -44,7 +44,11 @@ import { useTableKeyboardNavigation } from '/@/renderer/components/item-list/ite
import { useTablePaneSync } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync';
import { useTableRowModel } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-row-model';
import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import {
getAlbumGroupRowCount,
isLastInAlbumGroup,
ItemTableListColumn,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import {
ItemTableListColumnResizeLiveProvider,
type ItemTableListConfig,
@@ -66,7 +70,7 @@ import {
ItemTableListColumnConfig,
} from '/@/renderer/components/item-list/types';
import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerStore } from '/@/renderer/store';
import { useAlbumGroupImageSize, usePlayerStore } from '/@/renderer/store';
import { animationProps } from '/@/shared/components/animations/animation-props';
import { useFocusWithin } from '/@/shared/hooks/use-focus-within';
import { useMergedRef } from '/@/shared/hooks/use-merged-ref';
@@ -215,6 +219,7 @@ const VirtualizedTableGrid = ({
totalRowCount,
}: VirtualizedTableGridProps) => {
const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig;
const albumGroupImageSize = useAlbumGroupImageSize();
const hoverDelegateRef = useRef<HTMLDivElement | null>(null);
useRowInteractionDelegate({
@@ -403,6 +408,7 @@ const VirtualizedTableGrid = ({
const itemProps: TableItemProps = useMemo(
() => ({
albumGroupImageSize,
cellPadding: tableConfig.cellPadding,
columns: tableConfig.columns,
controls: tableConfig.controls,
@@ -427,7 +433,7 @@ const VirtualizedTableGrid = ({
tableId: tableConfig.tableId,
...gridOnlyProps,
}),
[gridOnlyProps, tableConfig],
[albumGroupImageSize, gridOnlyProps, tableConfig],
);
const pinnedLeftGridMinWidthPx = useMemo(() => {
@@ -760,6 +766,7 @@ export interface TableGroupHeader {
export interface TableItemProps {
adjustedRowIndexMap?: Map<number, number>;
albumGroupImageSize?: number;
calculatedColumnWidths?: number[];
cellPadding?: ItemTableListProps['cellPadding'];
columns: ItemTableListColumnConfig[];
@@ -1275,6 +1282,7 @@ const BaseItemTableList = ({
}: ItemTableListProps) => {
const { playlistId: routePlaylistId } = useParams() as { playlistId?: string };
const tableId = useId();
const albumGroupImageSize = useAlbumGroupImageSize();
const baseItemCount = itemCount ?? data.length;
const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount;
const [centerContainerWidth, setCenterContainerWidth] = useState(0);
@@ -1434,9 +1442,38 @@ const BaseItemTableList = ({
return headerHeight;
}
// When an album image is enlarged beyond the album group's combined
// row height, grow the group's LAST row to reserve the leftover
// space (so the following album isn't clipped). Other rows keep
// their standard height.
if (
albumGroupImageSize > baseHeight &&
cellProps?.hasAlbumGroupColumn &&
isLastInAlbumGroup(
index,
cellProps.getRowItem,
cellProps.enableHeader,
cellProps.data.length,
)
) {
const item = cellProps.getRowItem?.(index) as null | undefined | { album?: string };
if (item?.album) {
const groupRowCount = getAlbumGroupRowCount(
index,
cellProps.getRowItem,
cellProps.enableHeader,
cellProps.data.length,
);
const lastRowHeight = albumGroupImageSize - (groupRowCount - 1) * baseHeight;
if (lastRowHeight > baseHeight) {
return lastRowHeight;
}
}
}
return baseHeight;
},
[enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
[albumGroupImageSize, enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
);
// Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook)
@@ -29,6 +29,7 @@ import {
} from '/@/renderer/store';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge';
import { Button } from '/@/shared/components/button/button';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
@@ -41,7 +42,7 @@ import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { useDebouncedState } from '/@/shared/hooks/use-debounced-state';
import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
import { ItemListKey, ListPaginationType } from '/@/shared/types/types';
import { ItemListKey, ListPaginationType, TableColumn } from '/@/shared/types/types';
interface TableConfigProps {
enablePinColumnButtons?: boolean;
@@ -72,10 +73,18 @@ export const TableConfig = ({
const { t } = useTranslation();
const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings;
const { setList } = useSettingsStoreActions();
const albumGroupImageSize = useSettingsStore((state) => state.general.albumGroupImageSize);
const imageResTable = useSettingsStore((state) => state.general.imageRes.table);
const { setList, setSettings } = useSettingsStoreActions();
const [albumGroupOpen, setAlbumGroupOpen] = useState(false);
const table = tableKey === 'detail' ? (list?.detail ?? list?.table) : list?.table;
const hasAlbumGroupColumn = useMemo(
() => table.columns.some((column) => column.id === TableColumn.ALBUM_GROUP),
[table.columns],
);
const setTableUpdate = useCallback(
(patch: Partial<DataTableProps>) => {
if (tableKey === 'detail') {
@@ -90,6 +99,73 @@ export const TableConfig = ({
);
const advancedSettings = useMemo(() => {
const albumGroupOptions =
hasAlbumGroupColumn && tableKey === 'main'
? [
{
component: (
<Group justify="flex-end" w="100%">
<Button
onClick={() => setAlbumGroupOpen((prev) => !prev)}
size="compact-md"
variant={albumGroupOpen ? 'subtle' : 'filled'}
>
{t(albumGroupOpen ? 'common.close' : 'common.edit')}
</Button>
</Group>
),
id: 'albumGroupConfig',
label: t('table.config.general.albumGroupConfig'),
},
...(albumGroupOpen
? [
{
component: (
<Group justify="flex-end" w="100%">
<NumberInput
max={2000}
min={0}
onChange={(value) => {
const size = Math.max(
0,
Math.min(
2000,
typeof value === 'number' ? value : 0,
),
);
setSettings({
general: {
albumGroupImageSize: size,
// Source table art must be at least as
// large as the displayed album image.
...(size >= imageResTable
? { imageRes: { table: size } }
: {}),
},
});
}}
rightSection={
<Text isMuted isNoSelect pr="lg" size="sm">
px
</Text>
}
value={albumGroupImageSize}
width={90}
/>
</Group>
),
id: 'albumImageSize',
label: (
<Text pl="md">
{t('table.config.general.albumImageSize')}
</Text>
),
},
]
: []),
]
: [];
const allOptions = [
{
component: (
@@ -238,6 +314,7 @@ export const TableConfig = ({
id: 'autoFitColumns',
label: t('table.config.general.autoFitColumns'),
},
...albumGroupOptions,
...(extraOptions || []),
];
@@ -262,6 +339,11 @@ export const TableConfig = ({
listKey,
setTableUpdate,
optionsConfig,
hasAlbumGroupColumn,
albumGroupOpen,
albumGroupImageSize,
imageResTable,
setSettings,
]);
return (
+5
View File
@@ -474,6 +474,7 @@ export const GeneralSettingsSchema = z.object({
),
albumBackground: z.boolean(),
albumBackgroundBlur: z.number(),
albumGroupImageSize: z.number(),
artistBackground: z.boolean(),
artistBackgroundBlur: z.number(),
artistItems: z.array(SortableItemSchema(ArtistItemSchema)),
@@ -1166,6 +1167,7 @@ const initialState: SettingsState = {
accent: 'rgb(53, 116, 252)',
albumBackground: false,
albumBackgroundBlur: 3,
albumGroupImageSize: 0,
artistBackground: true,
artistBackgroundBlur: 3,
artistItems,
@@ -2645,6 +2647,9 @@ export const useSkipButtons = () => useSettingsStore((state) => state.general.sk
export const useImageRes = () => useSettingsStore((state) => state.general.imageRes, shallow);
export const useAlbumGroupImageSize = () =>
useSettingsStore((state) => state.general.albumGroupImageSize);
export const useVolumeWidth = () => useSettingsStore((state) => state.general.volumeWidth, shallow);
export const useFollowCurrentSong = () =>