add vertical album group layout

This commit is contained in:
jeffvli
2026-07-14 15:50:08 -07:00
parent 14e66c3ec9
commit 76b7ac8cec
9 changed files with 131 additions and 50 deletions
+1
View File
@@ -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",
@@ -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;
}
@@ -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 (
<div className={styles.controls}>
<ActionIcon
className={album.userFavorite || isGroupHovered ? undefined : styles.hidden}
className={styles.favorite}
disabled={isMutatingFavorite}
icon="favorite"
iconProps={{
color: album.userFavorite ? 'primary' : 'muted',
fill: album.userFavorite ? 'primary' : undefined,
size: 'md',
size: 'xs',
}}
onClick={handleFavorite}
onDoubleClick={(event) => {
@@ -98,11 +92,11 @@ export const AlbumGroupControls = ({
event.preventDefault();
}}
size="xs"
variant="subtle"
variant="transparent"
/>
{showRating && (
<Rating
className={album.userRating || isGroupHovered ? undefined : styles.hidden}
className={styles.rating}
onChange={handleRating}
readOnly={isMutatingRating}
size="xs"
@@ -7,6 +7,11 @@
padding: 0 var(--theme-spacing-xs);
}
.container.vertical {
flex-direction: column;
align-items: center;
}
.image-container {
position: relative;
box-sizing: border-box;
@@ -50,8 +55,16 @@
padding: 0;
}
.info.enlarged-image {
padding-top: var(--theme-spacing-xs);
.container.vertical .image-container {
align-self: center;
width: auto;
height: auto;
}
.container.vertical .info {
align-items: center;
width: 100%;
text-align: center;
}
.album-title {
@@ -74,6 +87,6 @@
.controls-row {
flex-shrink: 0;
min-height: 22px;
margin-top: var(--theme-spacing-xxs);
min-height: calc(1.875rem * var(--mantine-scale, 1));
margin-top: var(--theme-spacing-xs);
}
@@ -24,6 +24,7 @@ import {
useAlbumGroupImageSize,
useAlbumGroupItems,
useAlbumGroupShowFavoriteRating,
useAlbumGroupVerticalLayout,
usePlayButtonBehavior,
} from '/@/renderer/store';
import { Text } from '/@/shared/components/text/text';
@@ -54,7 +55,8 @@ export const AlbumGroupHeader = ({
const { t } = useTranslation();
const albumGroupItems = useAlbumGroupItems();
const showFavoriteRating = useAlbumGroupShowFavoriteRating();
const [isHovered, setIsHovered] = useState(false);
const isVerticalLayout = useAlbumGroupVerticalLayout();
const [isImageHovered, setIsImageHovered] = useState(false);
const [resolved, setResolved] = useState<null | { forInfoHeight: number; height: number }>(
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 (
<div
className={styles.container}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className={styles.imageContainer} style={imageContainerStyle}>
<div className={clsx(styles.container, isVerticalLayout && styles.vertical)}>
<div
className={styles.imageContainer}
onMouseEnter={() => setIsImageHovered(true)}
onMouseLeave={() => setIsImageHovered(false)}
style={imageContainerStyle}
>
<ItemImage
className={imageColumnStyles.compactImage}
enableDebounce
@@ -166,7 +176,7 @@ export const AlbumGroupHeader = ({
src={song?.imageUrl}
type="table"
/>
{isHovered && onPlay && (
{isImageHovered && onPlay && (
<div className={imageColumnStyles.playButtonOverlay}>
<PlayTooltip type={playButtonBehavior}>
<PlayButton
@@ -185,7 +195,7 @@ export const AlbumGroupHeader = ({
)}
</div>
<div
className={clsx(styles.info, albumImageSize > 0 && styles.enlargedImage)}
className={styles.info}
ref={infoRef}
style={{ minHeight: resolvedInfoHeight ?? infoHeight }}
>
@@ -213,7 +223,6 @@ export const AlbumGroupHeader = ({
<div className={styles.controlsRow}>
<AlbumGroupControls
albumId={song?.albumId}
isGroupHovered={isHovered}
serverId={song?._serverId}
serverType={song?._serverType}
/>
@@ -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.
@@ -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<HTMLDivElement | null>(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<number, number>;
albumGroupContentHeights?: Map<string, number>;
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,
@@ -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 = ({
</Text>
),
},
{
component: (
<ListConfigBooleanControl
onChange={(value) =>
setSettings({
general: {
albumGroupVerticalLayout: value,
},
})
}
value={albumGroupVerticalLayout}
/>
),
id: 'albumGroupVerticalLayout',
label: (
<Text fw={500} pl="md" size="sm">
{t('table.config.general.albumGroupVerticalLayout')}
</Text>
),
},
]
: []),
]
@@ -367,6 +390,7 @@ export const TableConfig = ({
albumGroupOpen,
albumGroupImageSize,
albumGroupShowFavoriteRating,
albumGroupVerticalLayout,
imageResTable,
setSettings,
]);
+5
View File
@@ -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 = () =>