mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +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": {
|
"label": {
|
||||||
"actions": "$t(common.action, {\"count\": 2})",
|
"actions": "$t(common.action, {\"count\": 2})",
|
||||||
"album": "$t(entity.album, {\"count\": 1})",
|
"album": "$t(entity.album, {\"count\": 1})",
|
||||||
|
"albumGroup": "album group",
|
||||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => {
|
|||||||
[TableColumn.ALBUM]: 'album',
|
[TableColumn.ALBUM]: 'album',
|
||||||
[TableColumn.ALBUM_ARTIST]: 'albumArtists',
|
[TableColumn.ALBUM_ARTIST]: 'albumArtists',
|
||||||
[TableColumn.ALBUM_COUNT]: 'albumCount',
|
[TableColumn.ALBUM_COUNT]: 'albumCount',
|
||||||
|
[TableColumn.ALBUM_GROUP]: null,
|
||||||
[TableColumn.ARTIST]: 'artists',
|
[TableColumn.ARTIST]: 'artists',
|
||||||
[TableColumn.BIOGRAPHY]: null,
|
[TableColumn.BIOGRAPHY]: null,
|
||||||
[TableColumn.BIT_DEPTH]: 'bitDepth',
|
[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%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-combined.no-image {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.text-container {
|
.text-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: 1fr 1fr;
|
||||||
|
|||||||
+87
-71
@@ -81,6 +81,7 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
const rowHeight = props.getRowHeight(props.rowIndex, props);
|
const rowHeight = props.getRowHeight(props.rowIndex, props);
|
||||||
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
|
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
|
||||||
const align = props.columns[props.columnIndex]?.align || 'start';
|
const align = props.columns[props.columnIndex]?.align || 'start';
|
||||||
|
const hasAlbumGroupColumn = props.hasAlbumGroupColumn ?? false;
|
||||||
|
|
||||||
const item = rowItem as any;
|
const item = rowItem as any;
|
||||||
const titleLinkProps = path
|
const titleLinkProps = path
|
||||||
@@ -94,46 +95,53 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableColumnContainer
|
<TableColumnContainer
|
||||||
className={styles.titleCombined}
|
className={clsx(styles.titleCombined, {
|
||||||
|
[styles.noImage]: hasAlbumGroupColumn,
|
||||||
|
})}
|
||||||
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
|
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
{!hasAlbumGroupColumn && (
|
||||||
className={styles.imageContainer}
|
<div
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
className={styles.imageContainer}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
>
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
<ItemImage
|
>
|
||||||
containerClassName={styles.image}
|
<ItemImage
|
||||||
enableDebounce={true}
|
containerClassName={styles.image}
|
||||||
enableViewport={false}
|
enableDebounce={true}
|
||||||
explicitStatus={item?.explicitStatus}
|
enableViewport={false}
|
||||||
id={item?.imageId}
|
explicitStatus={item?.explicitStatus}
|
||||||
itemType={item?._itemType}
|
id={item?.imageId}
|
||||||
src={item?.imageUrl}
|
itemType={item?._itemType}
|
||||||
type="table"
|
src={item?.imageUrl}
|
||||||
/>
|
type="table"
|
||||||
{isHovered && (
|
/>
|
||||||
<div
|
{isHovered && (
|
||||||
className={clsx(styles.playButtonOverlay, {
|
<div
|
||||||
[styles.compactPlayButtonOverlay]: props.size === 'compact',
|
className={clsx(styles.playButtonOverlay, {
|
||||||
})}
|
[styles.compactPlayButtonOverlay]: props.size === 'compact',
|
||||||
>
|
})}
|
||||||
<PlayTooltip
|
|
||||||
disabled={props.itemType === LibraryItem.QUEUE_SONG}
|
|
||||||
type={playButtonBehavior}
|
|
||||||
>
|
>
|
||||||
<PlayButton
|
<PlayTooltip
|
||||||
fill
|
disabled={props.itemType === LibraryItem.QUEUE_SONG}
|
||||||
onClick={(e) => handlePlay(playButtonBehavior, e)}
|
type={playButtonBehavior}
|
||||||
onLongPress={(e) =>
|
>
|
||||||
handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior], e)
|
<PlayButton
|
||||||
}
|
fill
|
||||||
/>
|
onClick={(e) => handlePlay(playButtonBehavior, e)}
|
||||||
</PlayTooltip>
|
onLongPress={(e) =>
|
||||||
</div>
|
handlePlay(
|
||||||
)}
|
LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior],
|
||||||
</div>
|
e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PlayTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={clsx(styles.textContainer, {
|
className={clsx(styles.textContainer, {
|
||||||
[styles.alignCenter]: align === 'center',
|
[styles.alignCenter]: align === 'center',
|
||||||
@@ -224,6 +232,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
|||||||
const rowHeight = props.getRowHeight(props.rowIndex, props);
|
const rowHeight = props.getRowHeight(props.rowIndex, props);
|
||||||
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
|
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
|
||||||
const align = props.columns[props.columnIndex]?.align || 'start';
|
const align = props.columns[props.columnIndex]?.align || 'start';
|
||||||
|
const hasAlbumGroupColumn = props.hasAlbumGroupColumn ?? false;
|
||||||
|
|
||||||
const item = rowItem as any;
|
const item = rowItem as any;
|
||||||
|
|
||||||
@@ -238,45 +247,52 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableColumnContainer
|
<TableColumnContainer
|
||||||
className={styles.titleCombined}
|
className={clsx(styles.titleCombined, {
|
||||||
|
[styles.noImage]: hasAlbumGroupColumn,
|
||||||
|
})}
|
||||||
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
|
containerStyle={{ '--row-height': `${rowHeight}px` } as CSSProperties}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
{!hasAlbumGroupColumn && (
|
||||||
className={styles.imageContainer}
|
<div
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
className={styles.imageContainer}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
>
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
<ItemImage
|
>
|
||||||
containerClassName={styles.image}
|
<ItemImage
|
||||||
explicitStatus={item?.explicitStatus}
|
containerClassName={styles.image}
|
||||||
id={item?.imageId}
|
explicitStatus={item?.explicitStatus}
|
||||||
itemType={item?._itemType}
|
id={item?.imageId}
|
||||||
serverId={item?._serverId}
|
itemType={item?._itemType}
|
||||||
src={item?.imageUrl}
|
serverId={item?._serverId}
|
||||||
type="table"
|
src={item?.imageUrl}
|
||||||
/>
|
type="table"
|
||||||
{isHovered && (
|
/>
|
||||||
<div
|
{isHovered && (
|
||||||
className={clsx(styles.playButtonOverlay, {
|
<div
|
||||||
[styles.compactPlayButtonOverlay]: props.size === 'compact',
|
className={clsx(styles.playButtonOverlay, {
|
||||||
})}
|
[styles.compactPlayButtonOverlay]: props.size === 'compact',
|
||||||
>
|
})}
|
||||||
<PlayTooltip
|
|
||||||
disabled={props.itemType === LibraryItem.QUEUE_SONG}
|
|
||||||
type={playButtonBehavior}
|
|
||||||
>
|
>
|
||||||
<PlayButton
|
<PlayTooltip
|
||||||
fill
|
disabled={props.itemType === LibraryItem.QUEUE_SONG}
|
||||||
onClick={(e) => handlePlay(playButtonBehavior, e)}
|
type={playButtonBehavior}
|
||||||
onLongPress={(e) =>
|
>
|
||||||
handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior], e)
|
<PlayButton
|
||||||
}
|
fill
|
||||||
/>
|
onClick={(e) => handlePlay(playButtonBehavior, e)}
|
||||||
</PlayTooltip>
|
onLongPress={(e) =>
|
||||||
</div>
|
handlePlay(
|
||||||
)}
|
LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior],
|
||||||
</div>
|
e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PlayTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={clsx(styles.textContainer, {
|
className={clsx(styles.textContainer, {
|
||||||
[styles.active]: isActive,
|
[styles.active]: isActive,
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export type DefaultTableColumn = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SONG_TABLE_COLUMNS: 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',
|
align: 'center',
|
||||||
autoSize: false,
|
autoSize: false,
|
||||||
|
|||||||
@@ -19,24 +19,45 @@ export const useTableColumnModel = ({
|
|||||||
const calculatedColumnWidths = useMemo(() => {
|
const calculatedColumnWidths = useMemo(() => {
|
||||||
const baseWidths = parsedColumns.map((c) => c.width);
|
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) {
|
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));
|
return baseWidths.map((width) => Math.round(width));
|
||||||
}
|
}
|
||||||
|
|
||||||
const scaleFactor = totalContainerWidth / totalReferenceWidth;
|
const scaleFactor = availableForUnpinned / unpinnedReferenceWidth;
|
||||||
const scaledWidths = baseWidths.map((width) => Math.round(width * scaleFactor));
|
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 totalScaled = scaledWidths.reduce((sum, width) => sum + width, 0);
|
||||||
const difference = totalContainerWidth - totalScaled;
|
const difference = totalContainerWidth - totalScaled;
|
||||||
|
|
||||||
if (difference !== 0 && scaledWidths.length > 0) {
|
if (difference !== 0 && unpinnedIndices.length > 0) {
|
||||||
const sortedIndices = scaledWidths
|
const sortedIndices = unpinnedIndices
|
||||||
.map((width, idx) => ({ idx, width }))
|
.map((idx) => ({ idx, width: scaledWidths[idx] }))
|
||||||
.sort((a, b) => b.width - a.width);
|
.sort((a, b) => b.width - a.width);
|
||||||
|
|
||||||
const adjustmentPerColumn = Math.sign(difference);
|
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 { 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 { 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 { 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 { 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 { 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';
|
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:
|
case TableColumn.SONG_COUNT:
|
||||||
return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;
|
return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;
|
||||||
|
|
||||||
|
case TableColumn.ALBUM_GROUP:
|
||||||
|
return (
|
||||||
|
<AlbumGroupColumn {...props} {...dragProps} controls={controls} type={type} />
|
||||||
|
);
|
||||||
|
|
||||||
case TableColumn.ARTIST:
|
case TableColumn.ARTIST:
|
||||||
return <ArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
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];
|
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 = (
|
export const TableColumnTextContainer = (
|
||||||
props: ItemTableListColumn & {
|
props: ItemTableListColumn & {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -493,7 +520,14 @@ export const TableColumnTextContainer = (
|
|||||||
props.enableHorizontalBorders &&
|
props.enableHorizontalBorders &&
|
||||||
props.enableHeader &&
|
props.enableHeader &&
|
||||||
props.rowIndex > 0 &&
|
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,
|
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
|
||||||
})}
|
})}
|
||||||
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
|
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
|
||||||
@@ -641,13 +675,24 @@ export const TableColumnContainer = (
|
|||||||
[styles.paddingXl]: props.cellPadding === 'xl',
|
[styles.paddingXl]: props.cellPadding === 'xl',
|
||||||
[styles.paddingXs]: props.cellPadding === 'xs',
|
[styles.paddingXs]: props.cellPadding === 'xs',
|
||||||
[styles.right]: props.columns[props.columnIndex].align === 'end',
|
[styles.right]: props.columns[props.columnIndex].align === 'end',
|
||||||
[styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight,
|
[styles.rowHoverHighlightEnabled]:
|
||||||
[styles.rowSelected]: isDataRow && isSelected,
|
isDataRow &&
|
||||||
|
props.enableRowHoverHighlight &&
|
||||||
|
props.type !== TableColumn.ALBUM_GROUP,
|
||||||
|
[styles.rowSelected]:
|
||||||
|
isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP,
|
||||||
[styles.withHorizontalBorder]:
|
[styles.withHorizontalBorder]:
|
||||||
props.enableHorizontalBorders &&
|
props.enableHorizontalBorders &&
|
||||||
props.enableHeader &&
|
props.enableHeader &&
|
||||||
props.rowIndex > 0 &&
|
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,
|
[styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn,
|
||||||
})}
|
})}
|
||||||
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
|
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', {
|
[TableColumn.ALBUM_COUNT]: i18n.t('table.column.albumCount', {
|
||||||
postProcess: 'upperCase',
|
postProcess: 'upperCase',
|
||||||
}) as string,
|
}) 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.ARTIST]: i18n.t('table.column.artist', { postProcess: 'upperCase' }) as string,
|
||||||
[TableColumn.BIOGRAPHY]: i18n.t('table.column.biography', {
|
[TableColumn.BIOGRAPHY]: i18n.t('table.column.biography', {
|
||||||
postProcess: 'upperCase',
|
postProcess: 'upperCase',
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const hasRequiredStateItemProperties = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
enum TableItemSize {
|
export enum TableItemSize {
|
||||||
COMPACT = 40,
|
COMPACT = 40,
|
||||||
DEFAULT = 64,
|
DEFAULT = 64,
|
||||||
LARGE = 88,
|
LARGE = 88,
|
||||||
@@ -204,17 +204,6 @@ const VirtualizedTableGrid = ({
|
|||||||
[columnWidth, pinnedLeftColumnCount],
|
[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(() => {
|
const groupHeaderInfoByRowIndex = useMemo(() => {
|
||||||
if (!groups || groups.length === 0) return undefined;
|
if (!groups || groups.length === 0) return undefined;
|
||||||
|
|
||||||
@@ -231,6 +220,19 @@ const VirtualizedTableGrid = ({
|
|||||||
return map;
|
return map;
|
||||||
}, [groups, enableHeader]);
|
}, [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]);
|
const getGroupRenderData = useCallback(() => data, [data]);
|
||||||
|
|
||||||
// Calculate pinned column widths for group header positioning
|
// Calculate pinned column widths for group header positioning
|
||||||
@@ -349,6 +351,7 @@ const VirtualizedTableGrid = ({
|
|||||||
controls,
|
controls,
|
||||||
enableHeader,
|
enableHeader,
|
||||||
getRowHeight,
|
getRowHeight,
|
||||||
|
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
|
||||||
internalState,
|
internalState,
|
||||||
itemType,
|
itemType,
|
||||||
playerContext,
|
playerContext,
|
||||||
@@ -802,6 +805,7 @@ export interface TableItemProps {
|
|||||||
getRowItem?: (rowIndex: number) => null | undefined | unknown;
|
getRowItem?: (rowIndex: number) => null | undefined | unknown;
|
||||||
groupHeaderInfoByRowIndex?: Map<number, { groupIndex: number; startDataIndex: number }>;
|
groupHeaderInfoByRowIndex?: Map<number, { groupIndex: number; startDataIndex: number }>;
|
||||||
groups?: TableGroupHeader[];
|
groups?: TableGroupHeader[];
|
||||||
|
hasAlbumGroupColumn?: boolean;
|
||||||
internalState: ItemListStateActions;
|
internalState: ItemListStateActions;
|
||||||
itemType: ItemTableListProps['itemType'];
|
itemType: ItemTableListProps['itemType'];
|
||||||
onRowClick?: (item: any, event: React.MouseEvent<HTMLDivElement>) => void;
|
onRowClick?: (item: any, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
|||||||
Executable → Regular
@@ -21,7 +21,7 @@ import {
|
|||||||
PlaylistSongListResponse,
|
PlaylistSongListResponse,
|
||||||
Song,
|
Song,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, Play } from '/@/shared/types/types';
|
import { ItemListKey, Play, TableColumn } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface PlaylistDetailSongListTableProps
|
interface PlaylistDetailSongListTableProps
|
||||||
extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {
|
extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {
|
||||||
@@ -68,6 +68,10 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
|
|||||||
const { searchTerm } = useSearchTermFilter();
|
const { searchTerm } = useSearchTermFilter();
|
||||||
const { query } = usePlaylistSongListFilters();
|
const { query } = usePlaylistSongListFilters();
|
||||||
|
|
||||||
|
const albumGroupingEnabled = columns.some(
|
||||||
|
(col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
const songDataFromData = useMemo(() => {
|
const songDataFromData = useMemo(() => {
|
||||||
let list = data?.items || [];
|
let list = data?.items || [];
|
||||||
if (searchTerm) {
|
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 =
|
const isPaginated =
|
||||||
typeof currentPage === 'number' &&
|
typeof currentPage === 'number' &&
|
||||||
typeof itemsPerPage === 'number' &&
|
typeof itemsPerPage === 'number' &&
|
||||||
@@ -135,7 +144,7 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
|
|||||||
activeRowId={currentSong?.id}
|
activeRowId={currentSong?.id}
|
||||||
autoFitColumns={autoFitColumns}
|
autoFitColumns={autoFitColumns}
|
||||||
CellComponent={ItemTableListColumn}
|
CellComponent={ItemTableListColumn}
|
||||||
columns={columns}
|
columns={effectiveColumns}
|
||||||
data={dataToRender}
|
data={dataToRender}
|
||||||
enableAlternateRowColors={enableAlternateRowColors}
|
enableAlternateRowColors={enableAlternateRowColors}
|
||||||
enableExpansion={false}
|
enableExpansion={false}
|
||||||
|
|||||||
@@ -262,7 +262,6 @@ export const TableConfig = ({
|
|||||||
id: 'autoFitColumns',
|
id: 'autoFitColumns',
|
||||||
label: t('table.config.general.autoFitColumns', { postProcess: 'sentenceCase' }),
|
label: t('table.config.general.autoFitColumns', { postProcess: 'sentenceCase' }),
|
||||||
},
|
},
|
||||||
|
|
||||||
...(extraOptions || []),
|
...(extraOptions || []),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -774,25 +773,18 @@ const TableColumnItem = memo(
|
|||||||
className={clsx(styles.group, styles.numberInput)}
|
className={clsx(styles.group, styles.numberInput)}
|
||||||
hideControls={false}
|
hideControls={false}
|
||||||
leftSection={
|
leftSection={
|
||||||
<>
|
<Tooltip
|
||||||
{item.pinned === null && (
|
label={t('table.config.general.autosize', {
|
||||||
<Tooltip
|
postProcess: 'sentenceCase',
|
||||||
label={t('table.config.general.autosize', {
|
})}
|
||||||
postProcess: 'sentenceCase',
|
>
|
||||||
})}
|
<Checkbox
|
||||||
>
|
checked={item.autoSize}
|
||||||
<Checkbox
|
id={item.id}
|
||||||
checked={item.autoSize}
|
onChange={(e) => handleAutoSize(item, e.currentTarget.checked)}
|
||||||
disabled={item.pinned !== null}
|
size="xs"
|
||||||
id={item.id}
|
/>
|
||||||
onChange={(e) =>
|
</Tooltip>
|
||||||
handleAutoSize(item, e.currentTarget.checked)
|
|
||||||
}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
max={2000}
|
max={2000}
|
||||||
min={0}
|
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;
|
return persistedState;
|
||||||
},
|
},
|
||||||
name: 'store_settings',
|
name: 'store_settings',
|
||||||
version: 25,
|
version: 26,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -158,7 +158,12 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
|
|||||||
case SongListSort.ALBUM_ARTIST:
|
case SongListSort.ALBUM_ARTIST:
|
||||||
results = orderBy(
|
results = orderBy(
|
||||||
results,
|
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],
|
[order, order, order, order],
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
Executable → Regular
@@ -166,6 +166,7 @@ export enum TableColumn {
|
|||||||
ALBUM = 'album',
|
ALBUM = 'album',
|
||||||
ALBUM_ARTIST = 'albumArtists',
|
ALBUM_ARTIST = 'albumArtists',
|
||||||
ALBUM_COUNT = 'albumCount',
|
ALBUM_COUNT = 'albumCount',
|
||||||
|
ALBUM_GROUP = 'albumGroup',
|
||||||
ARTIST = 'artists',
|
ARTIST = 'artists',
|
||||||
BIOGRAPHY = 'biography',
|
BIOGRAPHY = 'biography',
|
||||||
BIT_DEPTH = 'bitDepth',
|
BIT_DEPTH = 'bitDepth',
|
||||||
|
|||||||
Reference in New Issue
Block a user