diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ae64099ce..c98a63e7c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1233,6 +1233,7 @@ "albumGroupConfig": "Album Group configuration", "albumGroupMetadata": "Metadata fields", "albumGroupShowFavoriteRating": "Show favorites and ratings", + "albumGroupVerticalLayout": "Vertical layout", "albumImageSize": "Album image size", "autoFitColumns": "Auto fit columns", "autosize": "Autosize", diff --git a/src/renderer/components/item-list/item-table-list/album-group-controls.module.css b/src/renderer/components/item-list/item-table-list/album-group-controls.module.css index a276bd72c..e8671b3bf 100644 --- a/src/renderer/components/item-list/item-table-list/album-group-controls.module.css +++ b/src/renderer/components/item-list/item-table-list/album-group-controls.module.css @@ -2,9 +2,22 @@ display: flex; gap: var(--theme-spacing-xs); align-items: center; - min-height: 22px; + min-height: calc(1.875rem * var(--mantine-scale, 1)); } -.hidden { - visibility: hidden; +.favorite { + width: auto !important; + min-width: 0 !important; + height: calc(1.875rem * var(--mantine-scale, 1)) !important; + min-height: calc(1.875rem * var(--mantine-scale, 1)) !important; + padding-inline: var(--theme-spacing-xs); +} + +.rating { + box-sizing: border-box; + display: flex; + align-items: center; + height: calc(1.875rem * var(--mantine-scale, 1)); + padding-block: var(--theme-spacing-xs); + padding-inline: var(--theme-spacing-xs) !important; } diff --git a/src/renderer/components/item-list/item-table-list/album-group-controls.tsx b/src/renderer/components/item-list/item-table-list/album-group-controls.tsx index e8c10d35e..8df2d9da5 100644 --- a/src/renderer/components/item-list/item-table-list/album-group-controls.tsx +++ b/src/renderer/components/item-list/item-table-list/album-group-controls.tsx @@ -29,17 +29,11 @@ const useAlbumGroupAlbum = (albumId: string | undefined, serverId: string | unde interface AlbumGroupControlsProps { albumId: string | undefined; - isGroupHovered: boolean; serverId: string | undefined; serverType: ServerType | undefined; } -export const AlbumGroupControls = ({ - albumId, - isGroupHovered, - serverId, - serverType, -}: AlbumGroupControlsProps) => { +export const AlbumGroupControls = ({ albumId, serverId, serverType }: AlbumGroupControlsProps) => { const showRatingsSetting = useShowRatings(); const detailQuery = useAlbumGroupAlbum(albumId, serverId); const setFavorite = useSetFavorite(); @@ -84,13 +78,13 @@ export const AlbumGroupControls = ({ return (
{ @@ -98,11 +92,11 @@ export const AlbumGroupControls = ({ event.preventDefault(); }} size="xs" - variant="subtle" + variant="transparent" /> {showRating && ( ( null, ); @@ -80,10 +82,12 @@ export const AlbumGroupHeader = ({ .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. + // Horizontal: info floor is max(image, row span) so metadata aligns with the image. + // Vertical: no minHeight floor — row-span minHeight made scrollHeight report the full + // track span (e.g. 15×40=600) instead of natural text height, then image+info overflowed + // the reserved group height and made the virtualizer thrash at the scroll bottom. const infoHeight = - groupRowCount !== undefined + groupRowCount !== undefined && !isVerticalLayout ? albumImageSize > 0 ? Math.max(albumImageSize, groupRowCount * rowHeight) : groupRowCount * rowHeight @@ -101,8 +105,6 @@ export const AlbumGroupHeader = ({ ? { aspectRatio: 'auto', height: `${albumImageSize}px`, - paddingBottom: 'var(--theme-spacing-xs)', - paddingTop: 'var(--theme-spacing-xs)', position: 'relative' as const, width: `${albumImageSize}px`, zIndex: 1, @@ -120,7 +122,12 @@ export const AlbumGroupHeader = ({ const resolvedHeight = Math.max(infoHeight ?? 0, contentHeight); if (infoHeight !== undefined) { - setResolved({ forInfoHeight: infoHeight, height: resolvedHeight }); + setResolved((prev) => { + if (prev?.forInfoHeight === infoHeight && prev.height === resolvedHeight) { + return prev; + } + return { forInfoHeight: infoHeight, height: resolvedHeight }; + }); } // Only persist heights that exceed the image/row floor. Equal values @@ -145,18 +152,21 @@ export const AlbumGroupHeader = ({ groupKey, groupRowCount, infoHeight, + isVerticalLayout, metadataRows.length, setAlbumGroupContentHeight, + showFavoriteRating, storedContentHeight, ]); return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
+
+
setIsImageHovered(true)} + onMouseLeave={() => setIsImageHovered(false)} + style={imageContainerStyle} + > - {isHovered && onPlay && ( + {isImageHovered && onPlay && (
0 && styles.enlargedImage)} + className={styles.info} ref={infoRef} style={{ minHeight: resolvedInfoHeight ?? infoHeight }} > @@ -213,7 +223,6 @@ export const AlbumGroupHeader = ({
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 339cd520e..3df04e6f5 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 @@ -391,22 +391,18 @@ const NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_ARTIST, TableColum * Keep in sync with album-group-header styles (title line-clamp, metadata xs, controls). */ export function estimateAlbumGroupContentHeight({ - hasEnlargedImage, metadataRowCount, showControls, }: { - hasEnlargedImage: boolean; metadataRowCount: number; showControls: boolean; }): number { const TITLE_LINE_HEIGHT = 20; const TITLE_MAX_LINES = 3; const METADATA_LINE_HEIGHT = 18; - const CONTROLS_HEIGHT = 26; - const PADDING_TOP = hasEnlargedImage ? 8 : 0; + const CONTROLS_HEIGHT = 38; return ( - PADDING_TOP + TITLE_LINE_HEIGHT * TITLE_MAX_LINES + Math.max(0, metadataRowCount) * METADATA_LINE_HEIGHT + (showControls ? CONTROLS_HEIGHT : 0) @@ -459,13 +455,23 @@ export function getAlbumGroupRowCount( return end - start + 1; } +export const ALBUM_GROUP_STACK_GAP = 12; + export function getAlbumGroupSpanHeight( groupRowCount: number, baseHeight: number, albumGroupImageSize: number, contentHeight = 0, + options?: { isVertical?: boolean }, ): number { const rowSpanHeight = groupRowCount * baseHeight; + const isVertical = options?.isVertical ?? false; + + if (isVertical) { + const imageSize = albumGroupImageSize > 0 ? albumGroupImageSize : 96; + return Math.max(rowSpanHeight, imageSize + ALBUM_GROUP_STACK_GAP + contentHeight); + } + const imageSpanHeight = albumGroupImageSize > 0 ? Math.max(albumGroupImageSize, rowSpanHeight) : rowSpanHeight; @@ -565,9 +571,6 @@ function ClampedCell({ // 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; @@ -583,6 +586,8 @@ function getAlbumGroupClampHeight(props: ItemTableListInnerColumn): null | numbe return null; } + const albumImageSize = props.albumGroupImageSize ?? 0; + const isVertical = props.albumGroupVerticalLayout ?? false; const baseHeight = baseRowHeightForSize(props.size); const groupRowCount = getAlbumGroupRowCount( props.rowIndex, @@ -600,12 +605,18 @@ function getAlbumGroupClampHeight(props: ItemTableListInnerColumn): null | numbe const measuredContentHeight = groupHeightKey ? props.albumGroupContentHeights?.get(groupHeightKey) : undefined; - const contentHeight = measuredContentHeight ?? props.estimatedAlbumGroupContentHeight ?? 0; + // Never reserve less than the estimate — early measure can miss the async + // favorites/ratings row (AlbumGroupControls returns null until album loads). + const contentHeight = Math.max( + measuredContentHeight ?? 0, + props.estimatedAlbumGroupContentHeight ?? 0, + ); const totalGroupHeight = getAlbumGroupSpanHeight( groupRowCount, baseHeight, albumImageSize, contentHeight, + { isVertical }, ); // Only clamp when the row was actually grown to fit the image or wrapped text. 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 6c00e75eb..47c6f98be 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 @@ -78,6 +78,7 @@ import { useAlbumGroupImageSize, useAlbumGroupItems, useAlbumGroupShowFavoriteRating, + useAlbumGroupVerticalLayout, usePlayerStore, } from '/@/renderer/store'; import { animationProps } from '/@/shared/components/animations/animation-props'; @@ -235,6 +236,7 @@ const VirtualizedTableGrid = ({ }: VirtualizedTableGridProps) => { const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig; const albumGroupImageSize = useAlbumGroupImageSize(); + const albumGroupVerticalLayout = useAlbumGroupVerticalLayout(); const hoverDelegateRef = useRef(null); useRowInteractionDelegate({ @@ -396,6 +398,7 @@ const VirtualizedTableGrid = ({ () => ({ albumGroupContentHeights, albumGroupImageSize, + albumGroupVerticalLayout, calculatedColumnWidths, cellPadding: tableConfig.cellPadding, columns: tableConfig.columns, @@ -435,6 +438,7 @@ const VirtualizedTableGrid = ({ [ albumGroupContentHeights, albumGroupImageSize, + albumGroupVerticalLayout, calculatedColumnWidths, dataWithGroups, estimatedAlbumGroupContentHeight, @@ -746,8 +750,7 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next nextProps.calculatedColumnWidths, ) && prevProps.albumGroupContentHeights === nextProps.albumGroupContentHeights && - prevProps.estimatedAlbumGroupContentHeight === - nextProps.estimatedAlbumGroupContentHeight && + prevProps.estimatedAlbumGroupContentHeight === nextProps.estimatedAlbumGroupContentHeight && prevProps.setAlbumGroupContentHeight === nextProps.setAlbumGroupContentHeight && prevProps.tableConfig === nextProps.tableConfig && prevProps.data === nextProps.data && @@ -788,7 +791,7 @@ export interface TableItemProps { adjustedRowIndexMap?: Map; albumGroupContentHeights?: Map; albumGroupImageSize?: number; - estimatedAlbumGroupContentHeight?: number; + albumGroupVerticalLayout?: boolean; calculatedColumnWidths?: number[]; cellPadding?: ItemTableListProps['cellPadding']; columns: ItemTableListColumnConfig[]; @@ -805,6 +808,7 @@ export interface TableItemProps { enableRowHoverHighlight?: ItemTableListProps['enableRowHoverHighlight']; enableSelection?: ItemTableListProps['enableSelection']; enableVerticalBorders?: ItemTableListProps['enableVerticalBorders']; + estimatedAlbumGroupContentHeight?: number; getAdjustedRowIndex?: (rowIndex: number) => number; getGroupRenderData?: () => unknown[]; getRowHeight: (index: number, cellProps: TableItemProps) => number; @@ -1312,6 +1316,7 @@ const BaseItemTableList = ({ const albumGroupImageSize = useAlbumGroupImageSize(); const albumGroupItems = useAlbumGroupItems(); const albumGroupShowFavoriteRating = useAlbumGroupShowFavoriteRating(); + const albumGroupVerticalLayout = useAlbumGroupVerticalLayout(); const albumGroupMetadataRowCount = useMemo( () => albumGroupItems.filter((item) => !item.disabled).length, [albumGroupItems], @@ -1319,11 +1324,10 @@ const BaseItemTableList = ({ const estimatedAlbumGroupContentHeight = useMemo( () => estimateAlbumGroupContentHeight({ - hasEnlargedImage: (albumGroupImageSize || 96) > 0, metadataRowCount: albumGroupMetadataRowCount, showControls: albumGroupShowFavoriteRating, }), - [albumGroupImageSize, albumGroupMetadataRowCount, albumGroupShowFavoriteRating], + [albumGroupMetadataRowCount, albumGroupShowFavoriteRating], ); const baseItemCount = itemCount ?? data.length; const [albumGroupContentHeights, setAlbumGroupContentHeights] = useState( @@ -1341,7 +1345,7 @@ const BaseItemTableList = ({ useEffect(() => { setAlbumGroupContentHeights(new Map()); - }, [baseItemCount]); + }, [albumGroupShowFavoriteRating, albumGroupVerticalLayout, baseItemCount]); const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount; const [centerContainerWidth, setCenterContainerWidth] = useState(0); @@ -1496,12 +1500,16 @@ const BaseItemTableList = ({ : undefined; // Prefer measured height when present; otherwise reserve with a stable // estimate so newly virtualized groups do not jump after mount measure. - const contentHeight = measuredContentHeight ?? estimatedAlbumGroupContentHeight; + const contentHeight = Math.max( + measuredContentHeight ?? 0, + estimatedAlbumGroupContentHeight, + ); const totalGroupHeight = getAlbumGroupSpanHeight( groupRowCount, baseHeight, albumGroupImageSize, contentHeight, + { isVertical: albumGroupVerticalLayout }, ); const lastRowHeight = totalGroupHeight - (groupRowCount - 1) * baseHeight; if (lastRowHeight > baseHeight) { @@ -1514,6 +1522,7 @@ const BaseItemTableList = ({ }, [ albumGroupImageSize, + albumGroupVerticalLayout, enableHeader, estimatedAlbumGroupContentHeight, headerHeight, @@ -1527,6 +1536,7 @@ const BaseItemTableList = ({ () => ({ albumGroupContentHeights, albumGroupImageSize, + albumGroupVerticalLayout, cellPadding, columns: parsedColumns, controls: {} as ItemControls, @@ -1562,6 +1572,7 @@ const BaseItemTableList = ({ [ albumGroupContentHeights, albumGroupImageSize, + albumGroupVerticalLayout, cellPadding, dataWithGroups, enableAlternateRowColors, diff --git a/src/renderer/features/shared/components/table-config.tsx b/src/renderer/features/shared/components/table-config.tsx index 23eca1952..9cd85fece 100644 --- a/src/renderer/features/shared/components/table-config.tsx +++ b/src/renderer/features/shared/components/table-config.tsx @@ -78,6 +78,9 @@ export const TableConfig = ({ const albumGroupShowFavoriteRating = useSettingsStore( (state) => state.general.albumGroupShowFavoriteRating, ); + const albumGroupVerticalLayout = useSettingsStore( + (state) => state.general.albumGroupVerticalLayout, + ); const imageResTable = useSettingsStore((state) => state.general.imageRes.table); const { setList, setSettings } = useSettingsStoreActions(); const [albumGroupOpen, setAlbumGroupOpen] = useState(false); @@ -185,6 +188,26 @@ export const TableConfig = ({ ), }, + { + component: ( + + setSettings({ + general: { + albumGroupVerticalLayout: value, + }, + }) + } + value={albumGroupVerticalLayout} + /> + ), + id: 'albumGroupVerticalLayout', + label: ( + + {t('table.config.general.albumGroupVerticalLayout')} + + ), + }, ] : []), ] @@ -367,6 +390,7 @@ export const TableConfig = ({ albumGroupOpen, albumGroupImageSize, albumGroupShowFavoriteRating, + albumGroupVerticalLayout, imageResTable, setSettings, ]); diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index e425b4d45..114cad413 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -488,6 +488,7 @@ export const GeneralSettingsSchema = z.object({ albumGroupImageSize: z.number(), albumGroupItems: z.array(SortableItemSchema(AlbumGroupItemSchema)), albumGroupShowFavoriteRating: z.boolean(), + albumGroupVerticalLayout: z.boolean(), artistBackground: z.boolean(), artistBackgroundBlur: z.number(), artistItems: z.array(SortableItemSchema(ArtistItemSchema)), @@ -1211,6 +1212,7 @@ const initialState: SettingsState = { albumGroupImageSize: 0, albumGroupItems, albumGroupShowFavoriteRating: true, + albumGroupVerticalLayout: true, artistBackground: true, artistBackgroundBlur: 3, artistItems, @@ -2727,6 +2729,9 @@ export const useAlbumGroupImageSize = () => export const useAlbumGroupShowFavoriteRating = () => useSettingsStore((state) => state.general.albumGroupShowFavoriteRating); +export const useAlbumGroupVerticalLayout = () => + useSettingsStore((state) => state.general.albumGroupVerticalLayout); + export const useVolumeWidth = () => useSettingsStore((state) => state.general.volumeWidth, shallow); export const useFollowCurrentSong = () =>