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 ( +
+ {cell} +
+
+ ); +} + +// 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 = () =>