optimize detail columns

This commit is contained in:
jeffvli
2026-02-09 01:47:48 -08:00
parent d4c0754bd2
commit 332fc5f9f9
35 changed files with 320 additions and 106 deletions
@@ -1,3 +1,38 @@
import { ItemDetailListCellProps } from './types';
export const ActionsColumn = (_props: ItemDetailListCellProps) => null;
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { LibraryItem } from '/@/shared/types/domain-types';
export const ActionsColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
const index = internalState?.findItemIndex(song.id) ?? -1;
controls?.onMore?.({
event,
index,
internalState: internalState ?? undefined,
item: song,
itemType: LibraryItem.SONG,
});
};
const handleDoubleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
};
return (
<ActionIcon
icon="ellipsisHorizontal"
iconProps={{
color: 'muted',
size: 'xs',
}}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
size="xs"
variant="subtle"
/>
);
};
@@ -1,4 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const AlbumArtistColumn = ({ song }: ItemDetailListCellProps) =>
song.albumArtistName ?? '—';
song.albumArtistName ?? <>&nbsp;</>;
@@ -1,3 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const AlbumColumn = ({ song }: ItemDetailListCellProps) => song.album ?? '—';
export const AlbumColumn = ({ song }: ItemDetailListCellProps) => song.album ?? <>&nbsp;</>;
@@ -1,3 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const ArtistColumn = ({ song }: ItemDetailListCellProps) => song.artistName ?? '—';
export const ArtistColumn = ({ song }: ItemDetailListCellProps) => song.artistName ?? <>&nbsp;</>;
@@ -1,4 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const BitDepthColumn = ({ song }: ItemDetailListCellProps) =>
song.bitDepth != null ? String(song.bitDepth) : '—';
export const BitDepthColumn = ({ song }: ItemDetailListCellProps) => song.bitDepth;
@@ -1,4 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const BitRateColumn = ({ song }: ItemDetailListCellProps) =>
song.bitRate != null ? `${song.bitRate} kbps` : '—';
song.bitRate != null ? `${song.bitRate} kbps` : <>&nbsp;</>;
@@ -1,4 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const BpmColumn = ({ song }: ItemDetailListCellProps) =>
song.bpm != null ? String(song.bpm) : '—';
export const BpmColumn = ({ song }: ItemDetailListCellProps) => song.bpm ?? <>&nbsp;</>;
@@ -1,4 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const ChannelsColumn = ({ song }: ItemDetailListCellProps) =>
song.channels != null ? String(song.channels) : '—';
song.channels != null ? String(song.channels) : <>&nbsp;</>;
@@ -1,3 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const CodecColumn = ({ song }: ItemDetailListCellProps) => song.container ?? '—';
export const CodecColumn = ({ song }: ItemDetailListCellProps) => song.container ?? <>&nbsp;</>;
@@ -1,3 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const CommentColumn = ({ song }: ItemDetailListCellProps) => song.comment ?? '—';
export const CommentColumn = ({ song }: ItemDetailListCellProps) => song.comment ?? <>&nbsp;</>;
@@ -2,6 +2,6 @@ import { ItemDetailListCellProps } from './types';
export const ComposerColumn = ({ song }: ItemDetailListCellProps) => {
const composers = song.participants?.composer;
if (!composers?.length) return '—';
if (!composers?.length) return <>&nbsp;</>;
return composers.map((a) => a.name).join(', ');
};
@@ -1,5 +1,6 @@
import { ItemDetailListCellProps } from './types';
import { formatDateAbsolute } from '/@/renderer/utils/format';
export const DateAddedColumn = ({ song }: ItemDetailListCellProps) =>
song.createdAt ? formatDateAbsolute(song.createdAt) : '—';
song.createdAt ? formatDateAbsolute(song.createdAt) : <>&nbsp;</>;
@@ -1,5 +1,4 @@
import { ItemDetailListCellProps } from './types';
import { TableColumn } from '/@/shared/types/types';
interface DefaultColumnProps extends ItemDetailListCellProps {
columnId: string;
@@ -7,6 +6,6 @@ interface DefaultColumnProps extends ItemDetailListCellProps {
export const DefaultColumn = ({ columnId, song }: DefaultColumnProps) => {
const raw = (song as Record<string, unknown>)[columnId];
if (raw === undefined || raw === null || typeof raw === 'object') return '—';
if (raw === undefined || raw === null || typeof raw === 'object') return <>&nbsp;</>;
return String(raw);
};
@@ -1,4 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const DiscNumberColumn = ({ song }: ItemDetailListCellProps) =>
String(song.discNumber ?? 1);
export const DiscNumberColumn = ({ song }: ItemDetailListCellProps) => String(song.discNumber ?? 1);
@@ -1,24 +1,54 @@
import { ItemDetailListCellProps } from './types';
import { Icon } from '/@/shared/components/icon/icon';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { LibraryItem } from '/@/shared/types/domain-types';
export const FavoriteColumn = ({
controls,
internalState,
isMutatingFavorite,
onFavoriteClick,
song,
}: ItemDetailListCellProps) => (
<div
aria-disabled={isMutatingFavorite}
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
onFavoriteClick?.(song);
}}
onDoubleClick={(event) => {
event.stopPropagation();
event.preventDefault();
}}
role="button"
>
<Icon icon="favorite" size="xs" />
</div>
);
}: ItemDetailListCellProps) => {
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
const isMutating = isMutatingFavorite ?? (isMutatingCreateFavorite || isMutatingDeleteFavorite);
const isFavorite = song.userFavorite ?? false;
return (
<ActionIcon
disabled={isMutating}
icon="favorite"
iconProps={{
color: isFavorite ? 'primary' : 'muted',
fill: isFavorite ? 'primary' : undefined,
size: 'xs',
}}
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
const index = internalState?.findItemIndex(song.id) ?? -1;
if (controls?.onFavorite) {
controls.onFavorite({
event,
favorite: !isFavorite,
index,
internalState: internalState ?? undefined,
item: song,
itemType: LibraryItem.SONG,
});
} else {
onFavoriteClick?.(song);
}
}}
onDoubleClick={(event) => {
event.stopPropagation();
event.preventDefault();
}}
size="xs"
variant="subtle"
/>
);
};
@@ -1,4 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const GenreBadgeColumn = ({ song }: ItemDetailListCellProps) =>
song.genres?.length ? song.genres.map((g) => g.name).join(', ') : '—';
song.genres?.length ? song.genres.map((g) => g.name).join(', ') : <>&nbsp;</>;
@@ -1,4 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const GenreColumn = ({ song }: ItemDetailListCellProps) =>
song.genres?.length ? song.genres.map((g) => g.name).join(', ') : '—';
song.genres?.length ? song.genres.map((g) => g.name).join(', ') : <>&nbsp;</>;
@@ -0,0 +1,22 @@
.compact-container {
flex: 1 1 0;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
max-height: 100%;
aspect-ratio: unset;
padding-top: var(--theme-spacing-xs);
padding-bottom: var(--theme-spacing-xs);
overflow: hidden;
border-radius: var(--theme-radius-md);
}
.compact-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
border-radius: var(--theme-radius-md);
}
@@ -1,10 +1,15 @@
import styles from './image-column.module.css';
import { ItemDetailListCellProps } from './types';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { LibraryItem } from '/@/shared/types/domain-types';
export const ImageColumn = ({ song }: ItemDetailListCellProps) =>
song.imageId ? (
<ItemImage id={song.imageId} itemType={LibraryItem.SONG} type="itemCard" />
) : (
'—'
);
export const ImageColumn = ({ song }: ItemDetailListCellProps) => (
<ItemImage
className={styles.compactImage}
containerClassName={styles.compactContainer}
id={song.imageId}
itemType={LibraryItem.SONG}
type="table"
/>
);
@@ -1,5 +1,6 @@
import { ItemDetailListCellProps } from './types';
import { formatDateRelative } from '/@/renderer/utils/format';
export const LastPlayedColumn = ({ song }: ItemDetailListCellProps) =>
song.lastPlayedAt ? formatDateRelative(song.lastPlayedAt) : '—';
song.lastPlayedAt ? formatDateRelative(song.lastPlayedAt) : <>&nbsp;</>;
@@ -1,3 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? '—';
export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? <>&nbsp;</>;
@@ -1,4 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const PlayCountColumn = ({ song }: ItemDetailListCellProps) =>
String(song.playCount ?? 0);
song.playCount ? String(song.playCount) : <>&nbsp;</>;
@@ -1,6 +1,29 @@
import { ItemDetailListCellProps } from './types';
import { ReadOnlyRating } from '/@/shared/components/read-only-rating/read-only-rating';
export const RatingColumn = ({ song }: ItemDetailListCellProps) => (
<ReadOnlyRating size="md" value={song.userRating ?? undefined} />
);
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { Rating } from '/@/shared/components/rating/rating';
import { LibraryItem } from '/@/shared/types/domain-types';
export const RatingColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {
const isMutatingRating = useIsMutatingRating();
const value = song.userRating ?? 0;
return (
<Rating
onChange={(rating) => {
const index = internalState?.findItemIndex(song.id) ?? -1;
controls?.onRating?.({
event: null,
index,
internalState: internalState ?? undefined,
item: song,
itemType: LibraryItem.SONG,
rating,
});
}}
readOnly={isMutatingRating}
size="xs"
value={value}
/>
);
};
@@ -1,5 +1,6 @@
import { ItemDetailListCellProps } from './types';
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format';
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : '—';
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <>&nbsp;</>;
@@ -1,4 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const SampleRateColumn = ({ song }: ItemDetailListCellProps) =>
song.sampleRate != null ? `${song.sampleRate} Hz` : '—';
song.sampleRate ? `${song.sampleRate} Hz` : <>&nbsp;</>;
@@ -1,5 +1,6 @@
import { ItemDetailListCellProps } from './types';
import { formatSizeString } from '/@/renderer/utils/format';
export const SizeColumn = ({ song }: ItemDetailListCellProps) =>
song.size != null ? formatSizeString(song.size) : '—';
song.size ? formatSizeString(song.size) : <>&nbsp;</>;
@@ -1,4 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const TitleArtistColumn = ({ song }: ItemDetailListCellProps) =>
[song.name, song.artistName].filter(Boolean).join(' — ') || '—';
[song.name, song.artistName].filter(Boolean).join(' — ') ?? <>&nbsp;</>;
@@ -1,3 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const TitleColumn = ({ song }: ItemDetailListCellProps) => song.name ?? '—';
export const TitleColumn = ({ song }: ItemDetailListCellProps) => song.name ?? <>&nbsp;</>;
@@ -1,4 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const TitleCombinedColumn = ({ song }: ItemDetailListCellProps) =>
[song.name, song.artistName].filter(Boolean).join(' — ') || '—';
[song.name, song.artistName].filter(Boolean).join(' — ') ?? <>&nbsp;</>;
@@ -3,5 +3,5 @@ import { ItemDetailListCellProps } from './types';
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
const disc = song.discNumber ?? 1;
const track = song.trackNumber.toString().padStart(2, '0');
return `${disc} - ${track}`;
return `${disc}-${track}`;
};
@@ -1,8 +1,13 @@
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { Song } from '/@/shared/types/domain-types';
export interface ItemDetailListCellProps {
controls?: ItemControls;
internalState?: ItemListStateActions;
isMutatingFavorite?: boolean;
onFavoriteClick?: (song: Song) => void;
rowIndex?: number;
size?: 'compact' | 'default' | 'large';
song: Song;
}
@@ -1,4 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const YearColumn = ({ song }: ItemDetailListCellProps) =>
song.releaseYear != null ? String(song.releaseYear) : '—';
song.releaseYear ? String(song.releaseYear) : <>&nbsp;</>;
@@ -67,6 +67,7 @@
flex-direction: column;
gap: var(--theme-spacing-xs);
align-items: center;
padding: var(--theme-spacing-sm);
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--theme-font-size-md);
@@ -88,9 +89,18 @@
}
.row .tracks-table {
display: flex;
flex-direction: column;
width: 100%;
font-size: var(--theme-font-size-sm);
table-layout: fixed;
}
.row .track-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
min-width: 0;
overflow: hidden;
}
.row .track-header-cell {
@@ -100,6 +110,7 @@
}
.row .track-cell {
min-width: 0;
padding-right: var(--theme-spacing-sm);
padding-left: var(--theme-spacing-sm);
overflow: hidden;
@@ -107,25 +118,38 @@
white-space: nowrap;
}
.row .track-row-size-compact .track-cell {
padding-top: var(--theme-spacing-xs);
padding-bottom: var(--theme-spacing-xs);
.row .track-row-size-compact {
height: 32px;
min-height: 32px;
max-height: 32px;
}
.row .track-row-size-default .track-cell {
padding-top: var(--theme-spacing-sm);
padding-bottom: var(--theme-spacing-sm);
.row .track-row-size-default {
height: 40px;
min-height: 40px;
max-height: 40px;
}
.row .track-row-size-large .track-cell {
padding-top: var(--theme-spacing-md);
padding-bottom: var(--theme-spacing-md);
.row .track-row-size-large {
height: 48px;
min-height: 48px;
max-height: 48px;
}
.row .track-cell-muted {
color: var(--theme-colors-foreground-muted);
}
.row .track-cell-image {
display: flex;
align-self: stretch;
min-height: 0;
max-height: 100%;
padding-right: var(--theme-spacing-sm);
padding-left: var(--theme-spacing-sm);
overflow: hidden;
}
.track-row-selected {
@mixin dark {
background-color: lighten(var(--theme-colors-surface), 5%);
@@ -172,18 +196,21 @@
}
.skeleton-tracks-size-compact .skeleton-track-row {
padding-top: var(--theme-spacing-xs);
padding-bottom: var(--theme-spacing-xs);
height: 32px;
padding-top: 0;
padding-bottom: 0;
}
.skeleton-tracks-size-default .skeleton-track-row {
padding-top: var(--theme-spacing-sm);
padding-bottom: var(--theme-spacing-sm);
height: 40px;
padding-top: 0;
padding-bottom: 0;
}
.skeleton-tracks-size-large .skeleton-track-row {
padding-top: var(--theme-spacing-md);
padding-bottom: var(--theme-spacing-md);
height: 48px;
padding-top: 0;
padding-bottom: 0;
}
.skeleton-track-cell {
@@ -20,6 +20,10 @@ import {
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
import { getDetailListCellComponent } from '/@/renderer/components/item-list/item-detail-list/columns';
import {
getTrackColumnFixed,
shouldShowHoverOnlyColumnContent,
} from '/@/renderer/components/item-list/item-detail-list/utils';
import {
pickTableColumns,
SONG_TABLE_COLUMNS,
@@ -62,6 +66,7 @@ interface RowData {
interface TrackRowProps {
columns: ItemTableListColumnConfig[];
columnWidthPercents: number[];
controls?: ItemControls;
internalState: ItemListStateActions;
isMutatingFavorite: boolean;
onFavoriteClick: (song: Song) => void;
@@ -77,6 +82,7 @@ const TrackRow = memo(
({
columns,
columnWidthPercents,
controls,
internalState,
isMutatingFavorite,
onFavoriteClick,
@@ -85,7 +91,7 @@ const TrackRow = memo(
song,
}: TrackRowProps) => {
const playerContext = usePlayer();
const { dragRef, isDragging } = useItemDragDropState<HTMLTableRowElement>({
const { dragRef, isDragging } = useItemDragDropState<HTMLDivElement>({
enableDrag: true,
internalState,
isDataRow: true,
@@ -93,6 +99,7 @@ const TrackRow = memo(
itemType: LibraryItem.SONG,
playerContext,
});
const [isRowHovered, setIsRowHovered] = useState(false);
const isSelected = useItemSelectionState(internalState, song.id);
const handleRowClick = useCallback(
@@ -176,8 +183,8 @@ const TrackRow = memo(
);
return (
<tr
className={clsx({
<div
className={clsx(styles.trackRow, {
[styles.trackRowDragging]: isDragging,
[styles.trackRowSelected]: isSelected,
[styles.trackRowSizeCompact]: size === 'compact',
@@ -185,45 +192,62 @@ const TrackRow = memo(
[styles.trackRowSizeLarge]: size === 'large',
})}
onClick={handleRowClick}
onMouseEnter={() => setIsRowHovered(true)}
onMouseLeave={() => setIsRowHovered(false)}
ref={dragRef ?? undefined}
role="row"
>
{columns.map((col, colIndex) => {
const percent = columnWidthPercents[colIndex] ?? 0;
const { fixedWidth, isFixedColumn } = getTrackColumnFixed(col.id);
const style: React.CSSProperties = {
flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`,
fontFamily:
col.id === TableColumn.DURATION || col.id === TableColumn.TRACK_NUMBER
? 'monospace'
: undefined,
minWidth: 0,
minWidth: isFixedColumn ? fixedWidth : 0,
textAlign: textAlignFromAlign(col.align),
width: `${percent}%`,
};
const CellComponent = getDetailListCellComponent(col.id);
const content = (
const isTitleColumn = col.id === TableColumn.TITLE;
const isImageColumn = col.id === TableColumn.IMAGE;
const showHoverContent = shouldShowHoverOnlyColumnContent(
col.id,
isRowHovered,
song,
);
const content = showHoverContent ? (
<CellComponent
columnId={col.id}
controls={controls}
internalState={internalState}
isMutatingFavorite={isMutatingFavorite}
onFavoriteClick={onFavoriteClick}
rowIndex={rowIndex}
size={size}
song={song}
/>
) : (
'\u00A0'
);
const isTitleColumn = col.id === TableColumn.TITLE;
return (
<td
className={clsx(
styles.trackCell,
!isTitleColumn && styles.trackCellMuted,
)}
<div
className={clsx(styles.trackCell, {
[styles.trackCellImage]: isImageColumn,
[styles.trackCellMuted]: !isTitleColumn,
})}
key={col.id}
role="cell"
style={style}
>
{content}
</td>
</div>
);
})}
</tr>
</div>
);
},
);
@@ -357,23 +381,22 @@ const RowContent = memo(
</div>
<div className={styles.right}>
<table className={styles.tracksTable}>
<tbody>
{songs.map((song, rowIndex) => (
<TrackRow
columns={trackColumns}
columnWidthPercents={columnWidthPercents}
internalState={internalState}
isMutatingFavorite={isMutatingFavorite}
key={song.id}
onFavoriteClick={onFavoriteClick}
rowIndex={rowIndex}
size={trackTableSize}
song={song as Song}
/>
))}
</tbody>
</table>
<div className={styles.tracksTable} role="table">
{songs.map((song, rowIndex) => (
<TrackRow
columns={trackColumns}
columnWidthPercents={columnWidthPercents}
controls={controls}
internalState={internalState}
isMutatingFavorite={isMutatingFavorite}
key={song.id}
onFavoriteClick={onFavoriteClick}
rowIndex={rowIndex}
size={trackTableSize}
song={song as Song}
/>
))}
</div>
</div>
</>
);
@@ -0,0 +1,44 @@
import { TableColumn } from '/@/shared/types/types';
const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
[TableColumn.ACTIONS]: 48,
[TableColumn.TRACK_NUMBER]: 56,
[TableColumn.USER_FAVORITE]: 48,
[TableColumn.USER_RATING]: 76,
};
const HOVER_ONLY_COLUMNS: TableColumn[] = [
TableColumn.ACTIONS,
TableColumn.USER_FAVORITE,
TableColumn.USER_RATING,
];
export function getTrackColumnFixed(columnId: TableColumn): {
fixedWidth: number;
isFixedColumn: boolean;
} {
const width = FIXED_TRACK_COLUMN_WIDTHS[columnId];
return width !== undefined
? { fixedWidth: width, isFixedColumn: true }
: { fixedWidth: 0, isFixedColumn: false };
}
export function isTrackColumnHoverOnly(columnId: TableColumn): boolean {
return HOVER_ONLY_COLUMNS.includes(columnId);
}
export function shouldShowHoverOnlyColumnContent(
columnId: TableColumn,
isRowHovered: boolean,
song: { userFavorite?: boolean | null; userRating?: null | number },
): boolean {
if (!HOVER_ONLY_COLUMNS.includes(columnId)) {
return true;
}
return (
isRowHovered ||
(columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) ||
(columnId === TableColumn.USER_RATING && song.userRating != null)
);
}