mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Add album grouping column (#1722)
* Add album grouping column --------- Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Executable → Regular
+1
@@ -1131,6 +1131,7 @@
|
||||
"label": {
|
||||
"actions": "$t(common.action, {\"count\": 2})",
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"albumGroup": "album group",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
|
||||
@@ -49,6 +49,7 @@ const getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => {
|
||||
[TableColumn.ALBUM]: 'album',
|
||||
[TableColumn.ALBUM_ARTIST]: 'albumArtists',
|
||||
[TableColumn.ALBUM_COUNT]: 'albumCount',
|
||||
[TableColumn.ALBUM_GROUP]: null,
|
||||
[TableColumn.ARTIST]: 'artists',
|
||||
[TableColumn.BIOGRAPHY]: null,
|
||||
[TableColumn.BIT_DEPTH]: 'bitDepth',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.container {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-sm);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 var(--theme-spacing-xs);
|
||||
}
|
||||
|
||||
.image-container {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
padding-top: calc(var(--theme-spacing-xs) * 0.5);
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.album-name {
|
||||
font-size: var(--theme-font-size-sm);
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: var(--theme-font-size-xs);
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ReactElement, useState } from 'react';
|
||||
|
||||
import imageColumnStyles from '../item-detail-list/columns/image-column.module.css';
|
||||
import styles from './album-group-header.module.css';
|
||||
import { TableItemSize } from './item-table-list';
|
||||
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
||||
import {
|
||||
LONG_PRESS_PLAY_BEHAVIOR,
|
||||
PlayTooltip,
|
||||
} from '/@/renderer/features/shared/components/play-button-group';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface AlbumGroupHeaderProps {
|
||||
groupRowCount?: number;
|
||||
onPlay?: (playType: Play) => void;
|
||||
size?: 'compact' | 'large' | 'normal';
|
||||
song: Song | undefined;
|
||||
}
|
||||
|
||||
export const AlbumGroupHeader = ({
|
||||
groupRowCount,
|
||||
onPlay,
|
||||
size = 'normal',
|
||||
song,
|
||||
}: AlbumGroupHeaderProps): ReactElement => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const rowHeight = {
|
||||
compact: TableItemSize.COMPACT,
|
||||
large: TableItemSize.LARGE,
|
||||
normal: TableItemSize.DEFAULT,
|
||||
}[size];
|
||||
const infoHeight = groupRowCount !== undefined ? groupRowCount * rowHeight : undefined;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={styles.imageContainer}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<ItemImage
|
||||
className={imageColumnStyles.compactImage}
|
||||
enableDebounce
|
||||
enableViewport={false}
|
||||
id={song?.imageId}
|
||||
itemType={LibraryItem.SONG}
|
||||
src={song?.imageUrl}
|
||||
type="table"
|
||||
/>
|
||||
{isHovered && onPlay && (
|
||||
<div className={imageColumnStyles.playButtonOverlay}>
|
||||
<PlayTooltip type={playButtonBehavior}>
|
||||
<PlayButton
|
||||
fill
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlay(playButtonBehavior);
|
||||
}}
|
||||
onLongPress={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior]);
|
||||
}}
|
||||
/>
|
||||
</PlayTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info} style={{ height: infoHeight }}>
|
||||
<div className={styles.albumName}>{song?.album ?? ''}</div>
|
||||
<div className={styles.artistName}>{song?.albumArtistName ?? ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+4
@@ -6,6 +6,10 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.title-combined.no-image {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
|
||||
+87
-71
@@ -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,
|
||||
|
||||
@@ -13,6 +13,15 @@ export type DefaultTableColumn = {
|
||||
};
|
||||
|
||||
export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
|
||||
{
|
||||
align: 'start',
|
||||
autoSize: false,
|
||||
isEnabled: false,
|
||||
label: i18n.t('table.config.label.albumGroup', { postProcess: 'titleCase' }),
|
||||
pinned: 'left',
|
||||
value: TableColumn.ALBUM_GROUP,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
autoSize: false,
|
||||
|
||||
@@ -19,24 +19,45 @@ export const useTableColumnModel = ({
|
||||
const calculatedColumnWidths = useMemo(() => {
|
||||
const baseWidths = parsedColumns.map((c) => c.width);
|
||||
|
||||
// When autoSizeColumns is enabled, treat all widths as proportions and scale to fit container
|
||||
// When autoSizeColumns is enabled, treat unpinned widths as proportions and scale to fit container.
|
||||
// Pinned columns keep their base width so they don't get squeezed.
|
||||
if (autoFitColumns) {
|
||||
const totalReferenceWidth = baseWidths.reduce((sum, width) => sum + width, 0);
|
||||
const pinnedWidth = parsedColumns.reduce(
|
||||
(sum, col, idx) => (col.pinned !== null ? sum + baseWidths[idx] : sum),
|
||||
0,
|
||||
);
|
||||
const unpinnedIndices: number[] = [];
|
||||
parsedColumns.forEach((col, idx) => {
|
||||
if (col.pinned === null) {
|
||||
unpinnedIndices.push(idx);
|
||||
}
|
||||
});
|
||||
|
||||
if (totalReferenceWidth === 0 || totalContainerWidth === 0) {
|
||||
const unpinnedReferenceWidth = unpinnedIndices.reduce(
|
||||
(sum, idx) => sum + baseWidths[idx],
|
||||
0,
|
||||
);
|
||||
const availableForUnpinned = totalContainerWidth - pinnedWidth;
|
||||
|
||||
if (unpinnedReferenceWidth === 0 || availableForUnpinned <= 0) {
|
||||
return baseWidths.map((width) => Math.round(width));
|
||||
}
|
||||
|
||||
const scaleFactor = totalContainerWidth / totalReferenceWidth;
|
||||
const scaledWidths = baseWidths.map((width) => Math.round(width * scaleFactor));
|
||||
const scaleFactor = availableForUnpinned / unpinnedReferenceWidth;
|
||||
const scaledWidths = baseWidths.map((width, idx) => {
|
||||
if (parsedColumns[idx].pinned !== null) {
|
||||
return Math.round(width);
|
||||
}
|
||||
return Math.round(width * scaleFactor);
|
||||
});
|
||||
|
||||
// Adjust for rounding errors: ensure total equals totalContainerWidth
|
||||
// Adjust for rounding errors on unpinned columns only
|
||||
const totalScaled = scaledWidths.reduce((sum, width) => sum + width, 0);
|
||||
const difference = totalContainerWidth - totalScaled;
|
||||
|
||||
if (difference !== 0 && scaledWidths.length > 0) {
|
||||
const sortedIndices = scaledWidths
|
||||
.map((width, idx) => ({ idx, width }))
|
||||
if (difference !== 0 && unpinnedIndices.length > 0) {
|
||||
const sortedIndices = unpinnedIndices
|
||||
.map((idx) => ({ idx, width: scaledWidths[idx] }))
|
||||
.sort((a, b) => b.width - a.width);
|
||||
|
||||
const adjustmentPerColumn = Math.sign(difference);
|
||||
|
||||
@@ -30,6 +30,7 @@ import { isNoHorizontalPaddingColumn } from '/@/renderer/components/item-list/it
|
||||
import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column';
|
||||
import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column';
|
||||
import { AlbumColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-column';
|
||||
import { AlbumGroupColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-group-column';
|
||||
import { ArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/artists-column';
|
||||
import { ComposerColumn } from '/@/renderer/components/item-list/item-table-list/columns/composer-column';
|
||||
import { CountColumn } from '/@/renderer/components/item-list/item-table-list/columns/count-column';
|
||||
@@ -213,6 +214,11 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
|
||||
case TableColumn.SONG_COUNT:
|
||||
return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
case TableColumn.ALBUM_GROUP:
|
||||
return (
|
||||
<AlbumGroupColumn {...props} {...dragProps} controls={controls} type={type} />
|
||||
);
|
||||
|
||||
case TableColumn.ARTIST:
|
||||
return <ArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||
|
||||
@@ -362,6 +368,27 @@ export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nex
|
||||
|
||||
const NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_ARTIST, TableColumn.TITLE_COMBINED];
|
||||
|
||||
export function isAlbumGroupingActive(columns: { id: string; isEnabled?: boolean }[]): boolean {
|
||||
return columns.some((col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled);
|
||||
}
|
||||
|
||||
export function isLastInAlbumGroup(
|
||||
rowIndex: number,
|
||||
getRowItem: ((index: number) => unknown) | undefined,
|
||||
enableHeader: boolean | undefined,
|
||||
dataLength: number,
|
||||
): boolean {
|
||||
const item = getRowItem?.(rowIndex) as null | undefined | { album?: string };
|
||||
if (!item?.album) return true;
|
||||
|
||||
const nextRowIndex = rowIndex + 1;
|
||||
const maxRow = enableHeader ? dataLength + 1 : dataLength;
|
||||
if (nextRowIndex >= maxRow) return true;
|
||||
|
||||
const nextItem = getRowItem?.(nextRowIndex) as null | undefined | { album?: string };
|
||||
return !nextItem || nextItem.album !== item.album;
|
||||
}
|
||||
|
||||
export const TableColumnTextContainer = (
|
||||
props: ItemTableListColumn & {
|
||||
children: React.ReactNode;
|
||||
@@ -493,7 +520,14 @@ export const TableColumnTextContainer = (
|
||||
props.enableHorizontalBorders &&
|
||||
props.enableHeader &&
|
||||
props.rowIndex > 0 &&
|
||||
(props.rowIndex === 1 || !isLastRow),
|
||||
(isAlbumGroupingActive(props.columns)
|
||||
? isLastInAlbumGroup(
|
||||
props.rowIndex,
|
||||
props.getRowItem,
|
||||
!!props.enableHeader,
|
||||
props.data.length,
|
||||
)
|
||||
: props.rowIndex === 1 || !isLastRow),
|
||||
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
|
||||
})}
|
||||
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
|
||||
@@ -641,13 +675,24 @@ export const TableColumnContainer = (
|
||||
[styles.paddingXl]: props.cellPadding === 'xl',
|
||||
[styles.paddingXs]: props.cellPadding === 'xs',
|
||||
[styles.right]: props.columns[props.columnIndex].align === 'end',
|
||||
[styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight,
|
||||
[styles.rowSelected]: isDataRow && isSelected,
|
||||
[styles.rowHoverHighlightEnabled]:
|
||||
isDataRow &&
|
||||
props.enableRowHoverHighlight &&
|
||||
props.type !== TableColumn.ALBUM_GROUP,
|
||||
[styles.rowSelected]:
|
||||
isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP,
|
||||
[styles.withHorizontalBorder]:
|
||||
props.enableHorizontalBorders &&
|
||||
props.enableHeader &&
|
||||
props.rowIndex > 0 &&
|
||||
(props.rowIndex === 1 || !isLastRow),
|
||||
(isAlbumGroupingActive(props.columns)
|
||||
? isLastInAlbumGroup(
|
||||
props.rowIndex,
|
||||
props.getRowItem,
|
||||
!!props.enableHeader,
|
||||
props.data.length,
|
||||
)
|
||||
: props.rowIndex === 1 || !isLastRow),
|
||||
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
|
||||
})}
|
||||
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
|
||||
@@ -898,6 +943,9 @@ export const columnLabelMap: Record<TableColumn, ReactNode | string> = {
|
||||
[TableColumn.ALBUM_COUNT]: i18n.t('table.column.albumCount', {
|
||||
postProcess: 'upperCase',
|
||||
}) as string,
|
||||
[TableColumn.ALBUM_GROUP]: i18n.t('table.config.label.albumGroup', {
|
||||
postProcess: 'upperCase',
|
||||
}) as string,
|
||||
[TableColumn.ARTIST]: i18n.t('table.column.artist', { postProcess: 'upperCase' }) as string,
|
||||
[TableColumn.BIOGRAPHY]: i18n.t('table.column.biography', {
|
||||
postProcess: 'upperCase',
|
||||
|
||||
@@ -94,7 +94,7 @@ const hasRequiredStateItemProperties = (
|
||||
);
|
||||
};
|
||||
|
||||
enum TableItemSize {
|
||||
export enum TableItemSize {
|
||||
COMPACT = 40,
|
||||
DEFAULT = 64,
|
||||
LARGE = 88,
|
||||
@@ -204,17 +204,6 @@ const VirtualizedTableGrid = ({
|
||||
[columnWidth, pinnedLeftColumnCount],
|
||||
);
|
||||
|
||||
const rowHeightMemoized = useCallback(
|
||||
(index: number, cellProps: TableItemProps) =>
|
||||
getRowHeight(index + pinnedRowCount, cellProps),
|
||||
[getRowHeight, pinnedRowCount],
|
||||
);
|
||||
|
||||
const pinnedRightColumnWidthMemoized = useCallback(
|
||||
(index: number) => columnWidth(index + pinnedLeftColumnCount + totalColumnCount),
|
||||
[columnWidth, pinnedLeftColumnCount, totalColumnCount],
|
||||
);
|
||||
|
||||
const groupHeaderInfoByRowIndex = useMemo(() => {
|
||||
if (!groups || groups.length === 0) return undefined;
|
||||
|
||||
@@ -231,6 +220,19 @@ const VirtualizedTableGrid = ({
|
||||
return map;
|
||||
}, [groups, enableHeader]);
|
||||
|
||||
const rowHeightMemoized = useCallback(
|
||||
(index: number, cellProps: TableItemProps) => {
|
||||
const adjustedIndex = index + pinnedRowCount;
|
||||
return getRowHeight(adjustedIndex, cellProps);
|
||||
},
|
||||
[getRowHeight, pinnedRowCount],
|
||||
);
|
||||
|
||||
const pinnedRightColumnWidthMemoized = useCallback(
|
||||
(index: number) => columnWidth(index + pinnedLeftColumnCount + totalColumnCount),
|
||||
[columnWidth, pinnedLeftColumnCount, totalColumnCount],
|
||||
);
|
||||
|
||||
const getGroupRenderData = useCallback(() => data, [data]);
|
||||
|
||||
// Calculate pinned column widths for group header positioning
|
||||
@@ -349,6 +351,7 @@ const VirtualizedTableGrid = ({
|
||||
controls,
|
||||
enableHeader,
|
||||
getRowHeight,
|
||||
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
|
||||
internalState,
|
||||
itemType,
|
||||
playerContext,
|
||||
@@ -802,6 +805,7 @@ export interface TableItemProps {
|
||||
getRowItem?: (rowIndex: number) => null | undefined | unknown;
|
||||
groupHeaderInfoByRowIndex?: Map<number, { groupIndex: number; startDataIndex: number }>;
|
||||
groups?: TableGroupHeader[];
|
||||
hasAlbumGroupColumn?: boolean;
|
||||
internalState: ItemListStateActions;
|
||||
itemType: ItemTableListProps['itemType'];
|
||||
onRowClick?: (item: any, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
|
||||
Executable → Regular
@@ -21,7 +21,7 @@ import {
|
||||
PlaylistSongListResponse,
|
||||
Song,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
||||
import { ItemListKey, Play, TableColumn } from '/@/shared/types/types';
|
||||
|
||||
interface PlaylistDetailSongListTableProps
|
||||
extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {
|
||||
@@ -68,6 +68,10 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
|
||||
const { searchTerm } = useSearchTermFilter();
|
||||
const { query } = usePlaylistSongListFilters();
|
||||
|
||||
const albumGroupingEnabled = columns.some(
|
||||
(col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled,
|
||||
);
|
||||
|
||||
const songDataFromData = useMemo(() => {
|
||||
let list = data?.items || [];
|
||||
if (searchTerm) {
|
||||
@@ -117,6 +121,11 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
|
||||
};
|
||||
}, []);
|
||||
|
||||
const effectiveColumns = useMemo(() => {
|
||||
if (albumGroupingEnabled) return columns;
|
||||
return columns.filter((col) => col.id !== TableColumn.ALBUM_GROUP);
|
||||
}, [columns, albumGroupingEnabled]);
|
||||
|
||||
const isPaginated =
|
||||
typeof currentPage === 'number' &&
|
||||
typeof itemsPerPage === 'number' &&
|
||||
@@ -135,7 +144,7 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
|
||||
activeRowId={currentSong?.id}
|
||||
autoFitColumns={autoFitColumns}
|
||||
CellComponent={ItemTableListColumn}
|
||||
columns={columns}
|
||||
columns={effectiveColumns}
|
||||
data={dataToRender}
|
||||
enableAlternateRowColors={enableAlternateRowColors}
|
||||
enableExpansion={false}
|
||||
|
||||
@@ -262,7 +262,6 @@ export const TableConfig = ({
|
||||
id: 'autoFitColumns',
|
||||
label: t('table.config.general.autoFitColumns', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
|
||||
...(extraOptions || []),
|
||||
];
|
||||
|
||||
@@ -774,25 +773,18 @@ const TableColumnItem = memo(
|
||||
className={clsx(styles.group, styles.numberInput)}
|
||||
hideControls={false}
|
||||
leftSection={
|
||||
<>
|
||||
{item.pinned === null && (
|
||||
<Tooltip
|
||||
label={t('table.config.general.autosize', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
>
|
||||
<Checkbox
|
||||
checked={item.autoSize}
|
||||
disabled={item.pinned !== null}
|
||||
id={item.id}
|
||||
onChange={(e) =>
|
||||
handleAutoSize(item, e.currentTarget.checked)
|
||||
}
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
<Tooltip
|
||||
label={t('table.config.general.autosize', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
>
|
||||
<Checkbox
|
||||
checked={item.autoSize}
|
||||
id={item.id}
|
||||
onChange={(e) => handleAutoSize(item, e.currentTarget.checked)}
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
max={2000}
|
||||
min={0}
|
||||
|
||||
@@ -2239,10 +2239,44 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
||||
}
|
||||
}
|
||||
|
||||
if (version <= 26) {
|
||||
// Add ALBUM_GROUP column to the song table config
|
||||
const listKeysToUpdate: ItemListKey[] = [
|
||||
ItemListKey.SONG,
|
||||
ItemListKey.FOLDER,
|
||||
ItemListKey.PLAYLIST_SONG,
|
||||
ItemListKey.ALBUM_ARTIST_SONG,
|
||||
ItemListKey.GENRE_SONG,
|
||||
ItemListKey.QUEUE_SONG,
|
||||
ItemListKey.FULL_SCREEN,
|
||||
ItemListKey.SIDE_QUEUE,
|
||||
];
|
||||
|
||||
listKeysToUpdate.forEach((listKey) => {
|
||||
const listConfig = state.lists[listKey as keyof typeof state.lists];
|
||||
if (listConfig?.table?.columns) {
|
||||
const columns = listConfig.table.columns;
|
||||
const hasAlbumGroup = columns.some(
|
||||
(col) => col.id === TableColumn.ALBUM_GROUP,
|
||||
);
|
||||
if (!hasAlbumGroup) {
|
||||
columns.push({
|
||||
align: 'start',
|
||||
autoSize: false,
|
||||
id: TableColumn.ALBUM_GROUP,
|
||||
isEnabled: false,
|
||||
pinned: 'left',
|
||||
width: 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return persistedState;
|
||||
},
|
||||
name: 'store_settings',
|
||||
version: 25,
|
||||
version: 26,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -158,7 +158,12 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
|
||||
case SongListSort.ALBUM_ARTIST:
|
||||
results = orderBy(
|
||||
results,
|
||||
[(v) => v.albumArtists[0]?.name.toLowerCase(), 'discNumber', 'trackNumber'],
|
||||
[
|
||||
(v) => v.albumArtists[0]?.name.toLowerCase(),
|
||||
(v) => v.album?.toLowerCase(),
|
||||
'discNumber',
|
||||
'trackNumber',
|
||||
],
|
||||
[order, order, order, order],
|
||||
);
|
||||
break;
|
||||
|
||||
Executable → Regular
@@ -166,6 +166,7 @@ export enum TableColumn {
|
||||
ALBUM = 'album',
|
||||
ALBUM_ARTIST = 'albumArtists',
|
||||
ALBUM_COUNT = 'albumCount',
|
||||
ALBUM_GROUP = 'albumGroup',
|
||||
ARTIST = 'artists',
|
||||
BIOGRAPHY = 'biography',
|
||||
BIT_DEPTH = 'bitDepth',
|
||||
|
||||
Reference in New Issue
Block a user