Add album grouping column (#1722)

* Add album grouping column

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Norman
2026-02-26 20:34:55 -08:00
committed by GitHub
parent 4918b412b2
commit eb8913479b
18 changed files with 467 additions and 120 deletions
@@ -0,0 +1,90 @@
import { useCallback } from 'react';
import { AlbumGroupHeader } from '/@/renderer/components/item-list/item-table-list/album-group-header';
import {
isLastInAlbumGroup,
ItemTableListInnerColumn,
TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => {
const firstDataRow = props.enableHeader ? 1 : 0;
const item = props.getRowItem?.(props.rowIndex) as null | Song | undefined;
const handlePlay = useCallback(
(playType: Play) => {
if (!item || !props.controls?.onDoubleClick) return;
const isHeaderEnabled = !!props.enableHeader;
const index = isHeaderEnabled ? props.rowIndex - 1 : props.rowIndex;
props.controls.onDoubleClick({
event: null,
index,
internalState: (props as any).internalState,
item,
itemType: props.itemType,
meta: { playType },
});
},
[item, props],
);
if (!item?.album) {
return <div style={props.style} />;
}
// Check if this is the first row of a new album group (by album name)
let isFirstInGroup = true;
if (props.rowIndex > firstDataRow) {
const prevItem = props.getRowItem?.(props.rowIndex - 1) as null | Song | undefined;
// If prevItem is undefined (not loaded yet), assume same group to avoid duplicates
if (prevItem === undefined || prevItem?.album === item.album) {
isFirstInGroup = false;
}
}
if (!isFirstInGroup) {
// For non-first rows, add border-bottom on the last row of the group
const needsBorder =
props.enableHorizontalBorders &&
isLastInAlbumGroup(
props.rowIndex,
props.getRowItem,
!!props.enableHeader,
props.data.length,
);
return (
<div
style={{
...props.style,
...(needsBorder
? { borderBottom: '1px solid var(--theme-colors-border)' }
: {}),
}}
/>
);
}
let groupRowCount = 1;
const totalDataRows = props.data.length + firstDataRow;
for (let idx = props.rowIndex + 1; idx < totalDataRows; idx++) {
const nextItem = props.getRowItem?.(idx) as null | Song | undefined;
if (!nextItem || nextItem.album !== item.album) break;
groupRowCount++;
}
return (
<TableColumnContainer {...props} enableAlternateRowColors={false}>
<AlbumGroupHeader
groupRowCount={groupRowCount}
onPlay={handlePlay}
size={props.size === 'default' ? 'normal' : props.size}
song={item}
/>
</TableColumnContainer>
);
};
@@ -6,6 +6,10 @@
height: 100%;
}
.title-combined.no-image {
grid-template-columns: 1fr;
}
.text-container {
display: grid;
grid-template-rows: 1fr 1fr;
@@ -81,6 +81,7 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const align = props.columns[props.columnIndex]?.align || 'start';
const hasAlbumGroupColumn = props.hasAlbumGroupColumn ?? false;
const item = rowItem as any;
const titleLinkProps = path
@@ -94,46 +95,53 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
return (
<TableColumnContainer
className={styles.titleCombined}
className={clsx(styles.titleCombined, {
[styles.noImage]: hasAlbumGroupColumn,
})}
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
{...props}
>
<div
className={styles.imageContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ItemImage
containerClassName={styles.image}
enableDebounce={true}
enableViewport={false}
explicitStatus={item?.explicitStatus}
id={item?.imageId}
itemType={item?._itemType}
src={item?.imageUrl}
type="table"
/>
{isHovered && (
<div
className={clsx(styles.playButtonOverlay, {
[styles.compactPlayButtonOverlay]: props.size === 'compact',
})}
>
<PlayTooltip
disabled={props.itemType === LibraryItem.QUEUE_SONG}
type={playButtonBehavior}
{!hasAlbumGroupColumn && (
<div
className={styles.imageContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ItemImage
containerClassName={styles.image}
enableDebounce={true}
enableViewport={false}
explicitStatus={item?.explicitStatus}
id={item?.imageId}
itemType={item?._itemType}
src={item?.imageUrl}
type="table"
/>
{isHovered && (
<div
className={clsx(styles.playButtonOverlay, {
[styles.compactPlayButtonOverlay]: props.size === 'compact',
})}
>
<PlayButton
fill
onClick={(e) => handlePlay(playButtonBehavior, e)}
onLongPress={(e) =>
handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior], e)
}
/>
</PlayTooltip>
</div>
)}
</div>
<PlayTooltip
disabled={props.itemType === LibraryItem.QUEUE_SONG}
type={playButtonBehavior}
>
<PlayButton
fill
onClick={(e) => handlePlay(playButtonBehavior, e)}
onLongPress={(e) =>
handlePlay(
LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior],
e,
)
}
/>
</PlayTooltip>
</div>
)}
</div>
)}
<div
className={clsx(styles.textContainer, {
[styles.alignCenter]: align === 'center',
@@ -224,6 +232,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
const rowHeight = props.getRowHeight(props.rowIndex, props);
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const align = props.columns[props.columnIndex]?.align || 'start';
const hasAlbumGroupColumn = props.hasAlbumGroupColumn ?? false;
const item = rowItem as any;
@@ -238,45 +247,52 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
return (
<TableColumnContainer
className={styles.titleCombined}
className={clsx(styles.titleCombined, {
[styles.noImage]: hasAlbumGroupColumn,
})}
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
{...props}
>
<div
className={styles.imageContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ItemImage
containerClassName={styles.image}
explicitStatus={item?.explicitStatus}
id={item?.imageId}
itemType={item?._itemType}
serverId={item?._serverId}
src={item?.imageUrl}
type="table"
/>
{isHovered && (
<div
className={clsx(styles.playButtonOverlay, {
[styles.compactPlayButtonOverlay]: props.size === 'compact',
})}
>
<PlayTooltip
disabled={props.itemType === LibraryItem.QUEUE_SONG}
type={playButtonBehavior}
{!hasAlbumGroupColumn && (
<div
className={styles.imageContainer}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ItemImage
containerClassName={styles.image}
explicitStatus={item?.explicitStatus}
id={item?.imageId}
itemType={item?._itemType}
serverId={item?._serverId}
src={item?.imageUrl}
type="table"
/>
{isHovered && (
<div
className={clsx(styles.playButtonOverlay, {
[styles.compactPlayButtonOverlay]: props.size === 'compact',
})}
>
<PlayButton
fill
onClick={(e) => handlePlay(playButtonBehavior, e)}
onLongPress={(e) =>
handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior], e)
}
/>
</PlayTooltip>
</div>
)}
</div>
<PlayTooltip
disabled={props.itemType === LibraryItem.QUEUE_SONG}
type={playButtonBehavior}
>
<PlayButton
fill
onClick={(e) => handlePlay(playButtonBehavior, e)}
onLongPress={(e) =>
handlePlay(
LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior],
e,
)
}
/>
</PlayTooltip>
</div>
)}
</div>
)}
<div
className={clsx(styles.textContainer, {
[styles.active]: isActive,