diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 516c9ed30..77dfba3a2 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -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",
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 9ca91f9f8..7036a7742 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
@@ -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 (
@@ -42,6 +63,7 @@ export const AlbumGroupHeader = ({
className={styles.imageContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
+ style={imageContainerStyle}
>
{
...(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 }
+ : {}),
}}
/>
);
diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css
index 01cef746e..782a49e12 100644
--- a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css
+++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css
@@ -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;
diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx
index e0b37fe31..37fe02cc5 100644
--- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx
+++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx
@@ -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 (
+
+ );
+}
+
+// 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 = (
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}
>
);
+
+ return (
+
+ );
};
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 = (
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}
);
+
+ return (
+
+ );
};
interface ColumnResizeHandleProps {
diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx
index fcb29d5b7..a46ed0b55 100644
--- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx
+++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx
@@ -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(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;
+ 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)
diff --git a/src/renderer/features/shared/components/table-config.tsx b/src/renderer/features/shared/components/table-config.tsx
index 8605dcf49..1b2e9c895 100644
--- a/src/renderer/features/shared/components/table-config.tsx
+++ b/src/renderer/features/shared/components/table-config.tsx
@@ -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) => {
if (tableKey === 'detail') {
@@ -90,6 +99,73 @@ export const TableConfig = ({
);
const advancedSettings = useMemo(() => {
+ const albumGroupOptions =
+ hasAlbumGroupColumn && tableKey === 'main'
+ ? [
+ {
+ component: (
+
+
+
+ ),
+ id: 'albumGroupConfig',
+ label: t('table.config.general.albumGroupConfig'),
+ },
+ ...(albumGroupOpen
+ ? [
+ {
+ component: (
+
+ {
+ 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={
+
+ px
+
+ }
+ value={albumGroupImageSize}
+ width={90}
+ />
+
+ ),
+ id: 'albumImageSize',
+ label: (
+
+ {t('table.config.general.albumImageSize')}
+
+ ),
+ },
+ ]
+ : []),
+ ]
+ : [];
+
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 (
diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts
index dcabe2cff..37b204956 100644
--- a/src/renderer/store/settings.store.ts
+++ b/src/renderer/store/settings.store.ts
@@ -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 = () =>