add detail columns

This commit is contained in:
jeffvli
2026-02-08 20:06:55 -08:00
parent 3d67b02724
commit 5421182cc1
37 changed files with 326 additions and 54 deletions
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const ActionsColumn = (_props: ItemDetailListCellProps) => null;
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const AlbumArtistColumn = ({ song }: ItemDetailListCellProps) =>
song.albumArtistName ?? '—';
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const AlbumColumn = ({ song }: ItemDetailListCellProps) => song.album ?? '—';
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const ArtistColumn = ({ song }: ItemDetailListCellProps) => song.artistName ?? '—';
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const BitDepthColumn = ({ song }: ItemDetailListCellProps) =>
song.bitDepth != null ? String(song.bitDepth) : '—';
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const BitRateColumn = ({ song }: ItemDetailListCellProps) =>
song.bitRate != null ? `${song.bitRate} kbps` : '—';
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const BpmColumn = ({ song }: ItemDetailListCellProps) =>
song.bpm != null ? String(song.bpm) : '—';
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const ChannelsColumn = ({ song }: ItemDetailListCellProps) =>
song.channels != null ? String(song.channels) : '—';
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const CodecColumn = ({ song }: ItemDetailListCellProps) => song.container ?? '—';
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const CommentColumn = ({ song }: ItemDetailListCellProps) => song.comment ?? '—';
@@ -0,0 +1,7 @@
import { ItemDetailListCellProps } from './types';
export const ComposerColumn = ({ song }: ItemDetailListCellProps) => {
const composers = song.participants?.composer;
if (!composers?.length) return '—';
return composers.map((a) => a.name).join(', ');
};
@@ -0,0 +1,5 @@
import { ItemDetailListCellProps } from './types';
import { formatDateAbsolute } from '/@/renderer/utils/format';
export const DateAddedColumn = ({ song }: ItemDetailListCellProps) =>
song.createdAt ? formatDateAbsolute(song.createdAt) : '—';
@@ -0,0 +1,12 @@
import { ItemDetailListCellProps } from './types';
import { TableColumn } from '/@/shared/types/types';
interface DefaultColumnProps extends ItemDetailListCellProps {
columnId: string;
}
export const DefaultColumn = ({ columnId, song }: DefaultColumnProps) => {
const raw = (song as Record<string, unknown>)[columnId];
if (raw === undefined || raw === null || typeof raw === 'object') return '—';
return String(raw);
};
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const DiscNumberColumn = ({ song }: ItemDetailListCellProps) =>
String(song.discNumber ?? 1);
@@ -0,0 +1,5 @@
import formatDuration from 'format-duration';
import { ItemDetailListCellProps } from './types';
export const DurationColumn = ({ song }: ItemDetailListCellProps) => formatDuration(song.duration);
@@ -0,0 +1,24 @@
import { ItemDetailListCellProps } from './types';
import { Icon } from '/@/shared/components/icon/icon';
export const FavoriteColumn = ({
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>
);
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const GenreBadgeColumn = ({ song }: ItemDetailListCellProps) =>
song.genres?.length ? song.genres.map((g) => g.name).join(', ') : '—';
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const GenreColumn = ({ song }: ItemDetailListCellProps) =>
song.genres?.length ? song.genres.map((g) => g.name).join(', ') : '—';
@@ -0,0 +1,10 @@
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" />
) : (
'—'
);
@@ -0,0 +1,127 @@
import React, { type ReactNode } from 'react';
import type { ItemDetailListCellProps } from './types';
import { ActionsColumn } from './actions-column';
import { AlbumArtistColumn } from './album-artist-column';
import { AlbumColumn } from './album-column';
import { ArtistColumn } from './artist-column';
import { BitDepthColumn } from './bit-depth-column';
import { BitRateColumn } from './bit-rate-column';
import { BpmColumn } from './bpm-column';
import { ChannelsColumn } from './channels-column';
import { CodecColumn } from './codec-column';
import { CommentColumn } from './comment-column';
import { ComposerColumn } from './composer-column';
import { DateAddedColumn } from './date-added-column';
import { DefaultColumn } from './default-column';
import { DiscNumberColumn } from './disc-number-column';
import { DurationColumn } from './duration-column';
import { FavoriteColumn } from './favorite-column';
import { GenreBadgeColumn } from './genre-badge-column';
import { GenreColumn } from './genre-column';
import { ImageColumn } from './image-column';
import { LastPlayedColumn } from './last-played-column';
import { PathColumn } from './path-column';
import { PlayCountColumn } from './play-count-column';
import { RatingColumn } from './rating-column';
import { ReleaseDateColumn } from './release-date-column';
import { RowIndexColumn } from './row-index-column';
import { SampleRateColumn } from './sample-rate-column';
import { SizeColumn } from './size-column';
import { TitleArtistColumn } from './title-artist-column';
import { TitleColumn } from './title-column';
import { TitleCombinedColumn } from './title-combined-column';
import { TrackNumberColumn } from './track-number-column';
import { YearColumn } from './year-column';
import { TableColumn } from '/@/shared/types/types';
type CellComponent = (props: ItemDetailListCellProps) => ReactNode;
const COLUMN_MAP: Partial<Record<TableColumn, CellComponent>> = {
[TableColumn.ACTIONS]: ActionsColumn,
[TableColumn.ALBUM]: AlbumColumn,
[TableColumn.ALBUM_ARTIST]: AlbumArtistColumn,
[TableColumn.ARTIST]: ArtistColumn,
[TableColumn.BIT_DEPTH]: BitDepthColumn,
[TableColumn.BIT_RATE]: BitRateColumn,
[TableColumn.BPM]: BpmColumn,
[TableColumn.CHANNELS]: ChannelsColumn,
[TableColumn.CODEC]: CodecColumn,
[TableColumn.COMMENT]: CommentColumn,
[TableColumn.COMPOSER]: ComposerColumn,
[TableColumn.DATE_ADDED]: DateAddedColumn,
[TableColumn.DISC_NUMBER]: DiscNumberColumn,
[TableColumn.DURATION]: DurationColumn,
[TableColumn.GENRE]: GenreColumn,
[TableColumn.GENRE_BADGE]: GenreBadgeColumn,
[TableColumn.IMAGE]: ImageColumn,
[TableColumn.LAST_PLAYED]: LastPlayedColumn,
[TableColumn.PATH]: PathColumn,
[TableColumn.PLAY_COUNT]: PlayCountColumn,
[TableColumn.RELEASE_DATE]: ReleaseDateColumn,
[TableColumn.ROW_INDEX]: RowIndexColumn,
[TableColumn.SAMPLE_RATE]: SampleRateColumn,
[TableColumn.SIZE]: SizeColumn,
[TableColumn.TITLE]: TitleColumn,
[TableColumn.TITLE_ARTIST]: TitleArtistColumn,
[TableColumn.TITLE_COMBINED]: TitleCombinedColumn,
[TableColumn.TRACK_NUMBER]: TrackNumberColumn,
[TableColumn.USER_FAVORITE]: FavoriteColumn,
[TableColumn.USER_RATING]: RatingColumn,
[TableColumn.YEAR]: YearColumn,
};
export type DetailListCellComponentProps = ItemDetailListCellProps & { columnId?: string };
export function getDetailListCellComponent(
columnId: string | TableColumn,
): (props: DetailListCellComponentProps) => ReactNode {
const Component = COLUMN_MAP[columnId as TableColumn];
if (Component) {
return Component as (props: DetailListCellComponentProps) => ReactNode;
}
return (props: DetailListCellComponentProps) =>
React.createElement(DefaultColumn, {
columnId: props.columnId ?? (columnId as string),
song: props.song,
});
}
export type { ItemDetailListCellProps } from './types';
export {
ActionsColumn,
AlbumArtistColumn,
AlbumColumn,
ArtistColumn,
BitDepthColumn,
BitRateColumn,
BpmColumn,
ChannelsColumn,
CodecColumn,
CommentColumn,
ComposerColumn,
DateAddedColumn,
DefaultColumn,
DiscNumberColumn,
DurationColumn,
FavoriteColumn,
GenreBadgeColumn,
GenreColumn,
ImageColumn,
LastPlayedColumn,
PathColumn,
PlayCountColumn,
RatingColumn,
ReleaseDateColumn,
RowIndexColumn,
SampleRateColumn,
SizeColumn,
TitleArtistColumn,
TitleColumn,
TitleCombinedColumn,
TrackNumberColumn,
YearColumn,
};
@@ -0,0 +1,5 @@
import { ItemDetailListCellProps } from './types';
import { formatDateRelative } from '/@/renderer/utils/format';
export const LastPlayedColumn = ({ song }: ItemDetailListCellProps) =>
song.lastPlayedAt ? formatDateRelative(song.lastPlayedAt) : '—';
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? '—';
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const PlayCountColumn = ({ song }: ItemDetailListCellProps) =>
String(song.playCount ?? 0);
@@ -0,0 +1,6 @@
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} />
);
@@ -0,0 +1,5 @@
import { ItemDetailListCellProps } from './types';
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format';
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : '—';
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const RowIndexColumn = ({ rowIndex }: ItemDetailListCellProps) =>
String((rowIndex ?? 0) + 1);
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const SampleRateColumn = ({ song }: ItemDetailListCellProps) =>
song.sampleRate != null ? `${song.sampleRate} Hz` : '—';
@@ -0,0 +1,5 @@
import { ItemDetailListCellProps } from './types';
import { formatSizeString } from '/@/renderer/utils/format';
export const SizeColumn = ({ song }: ItemDetailListCellProps) =>
song.size != null ? formatSizeString(song.size) : '—';
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const TitleArtistColumn = ({ song }: ItemDetailListCellProps) =>
[song.name, song.artistName].filter(Boolean).join(' — ') || '—';
@@ -0,0 +1,3 @@
import { ItemDetailListCellProps } from './types';
export const TitleColumn = ({ song }: ItemDetailListCellProps) => song.name ?? '—';
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const TitleCombinedColumn = ({ song }: ItemDetailListCellProps) =>
[song.name, song.artistName].filter(Boolean).join(' — ') || '—';
@@ -0,0 +1,7 @@
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}`;
};
@@ -0,0 +1,8 @@
import { Song } from '/@/shared/types/domain-types';
export interface ItemDetailListCellProps {
isMutatingFavorite?: boolean;
onFavoriteClick?: (song: Song) => void;
rowIndex?: number;
song: Song;
}
@@ -0,0 +1,4 @@
import { ItemDetailListCellProps } from './types';
export const YearColumn = ({ song }: ItemDetailListCellProps) =>
song.releaseYear != null ? String(song.releaseYear) : '—';
@@ -0,0 +1,155 @@
.container {
position: relative;
width: 100%;
height: 100%;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.row {
display: grid;
grid-template-columns: 240px 1fr;
gap: var(--theme-spacing-md);
padding: 0 var(--theme-spacing-md) var(--theme-spacing-xl) var(--theme-spacing-md);
}
.image-wrapper {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
color: inherit;
text-decoration: none;
border-radius: var(--theme-radius-md);
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 5;
width: 100%;
height: 100%;
pointer-events: none;
content: '';
background-color: rgb(0 0 0);
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
&:hover {
@mixin dark {
&::before {
opacity: 0.7;
}
}
@mixin light {
&::before {
opacity: 0.5;
}
}
}
}
.row .image {
object-fit: var(--theme-image-fit);
border-radius: var(--theme-radius-md);
}
.row .metadata {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-xs);
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--theme-font-size-md);
text-align: center;
}
.row .title {
font-weight: 500;
}
.row .artist {
font-size: var(--theme-font-size-sm);
color: var(--theme-colors-foreground-muted);
}
.row .tracks-table {
width: 100%;
font-size: var(--theme-font-size-sm);
table-layout: fixed;
}
.row .track-header-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row .track-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-row-selected {
@mixin dark {
background-color: lighten(var(--theme-colors-surface), 5%);
}
@mixin light {
background-color: darken(var(--theme-colors-surface), 5%);
}
}
.track-row-dragging {
opacity: 0.5;
}
.skeleton-image {
width: 100%;
aspect-ratio: 1;
border-radius: var(--theme-radius-md);
}
.skeleton-title {
width: 75%;
height: 1.25rem;
}
.skeleton-artist {
width: 50%;
height: 1rem;
}
.skeleton-tracks {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-xs);
}
.skeleton-track-row {
display: grid;
grid-template-columns: 40px 1fr 8rem;
gap: var(--theme-spacing-sm);
align-items: center;
}
.skeleton-track-cell {
width: 100%;
height: 1rem;
}
.skeleton-track-cell-title {
width: 100%;
min-width: 0;
height: 1rem;
}
@@ -0,0 +1,565 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import throttle from 'lodash/throttle';
import { AnimatePresence } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { memo, type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { generatePath, Link } from 'react-router';
import { List, RowComponentProps, useDynamicRowHeight } from 'react-window-v2';
import styles from './item-detail.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
useItemListState,
useItemSelectionState,
} 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 {
pickTableColumns,
SONG_TABLE_COLUMNS,
} from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { AppRoute } from '/@/renderer/router/routes';
import { useSettingsStore } from '/@/renderer/store';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Album, LibraryItem, Song } from '/@/shared/types/domain-types';
import { ItemListKey, TableColumn } from '/@/shared/types/types';
interface ItemDetailListProps {
currentPage?: number;
data?: unknown[];
dataVersion?: number;
getItem?: (index: number) => unknown;
internalState?: ItemListStateActions;
itemCount?: number;
items?: unknown[];
onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise<void> | void;
rowHeight?: number;
}
interface RowData {
controls?: ItemControls;
data: unknown[];
enableTrackTableHeader: boolean;
getItem?: (index: number) => unknown;
internalState: ItemListStateActions;
isMutatingFavorite: boolean;
queryClient: ReturnType<typeof useQueryClient>;
registerSongs: (albumId: string, songs: Song[]) => void;
trackColumns: ItemTableListColumnConfig[];
}
interface TrackRowProps {
columns: ItemTableListColumnConfig[];
internalState: ItemListStateActions;
isMutatingFavorite: boolean;
onFavoriteClick: (song: Song) => void;
rowIndex: number;
song: Song;
}
const textAlignFromAlign = (align: ItemTableListColumnConfig['align']) =>
align === 'start' ? 'left' : align === 'end' ? 'right' : 'center';
const TrackRow = memo(
({
columns,
internalState,
isMutatingFavorite,
onFavoriteClick,
rowIndex,
song,
}: TrackRowProps) => {
const playerContext = usePlayer();
const { dragRef, isDragging } = useItemDragDropState<HTMLTableRowElement>({
enableDrag: true,
internalState,
isDataRow: true,
item: song,
itemType: LibraryItem.SONG,
playerContext,
});
const isSelected = useItemSelectionState(internalState, song.id);
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.ctrlKey || e.metaKey) {
internalState.toggleSelected(song);
} else if (e.shiftKey) {
const selectedItems = internalState.getSelected();
const lastSelectedItem = selectedItems[selectedItems.length - 1];
if (
lastSelectedItem &&
typeof lastSelectedItem === 'object' &&
lastSelectedItem !== null
) {
const data = internalState.getData();
const validData = data.filter((d) => d && typeof d === 'object');
const lastRowId = internalState.extractRowId(lastSelectedItem);
if (!lastRowId) {
internalState.setSelected([song]);
return;
}
const lastIndex = internalState.findItemIndex(lastRowId);
const currentIndex = internalState.findItemIndex(song.id);
if (lastIndex !== -1 && currentIndex !== -1) {
const startIndex = Math.min(lastIndex, currentIndex);
const stopIndex = Math.max(lastIndex, currentIndex);
const rangeItems: ItemListStateItemWithRequiredProperties[] = [];
for (let i = startIndex; i <= stopIndex; i++) {
const rangeItem = validData[i];
if (
rangeItem &&
typeof rangeItem === 'object' &&
'_serverId' in rangeItem &&
'_itemType' in rangeItem
) {
const rangeRowId = internalState.extractRowId(rangeItem);
if (rangeRowId) {
rangeItems.push(
rangeItem as ItemListStateItemWithRequiredProperties,
);
}
}
}
const currentSelected = internalState.getSelected();
const newSelected = [
...currentSelected.filter(
(
selectedItem,
): selectedItem is ItemListStateItemWithRequiredProperties =>
typeof selectedItem === 'object' && selectedItem !== null,
),
];
rangeItems.forEach((rangeItem) => {
const rangeRowId = internalState.extractRowId(rangeItem);
if (
rangeRowId &&
!newSelected.some(
(selected) =>
internalState.extractRowId(selected) === rangeRowId,
)
) {
newSelected.push(rangeItem);
}
});
internalState.setSelected(newSelected);
} else {
internalState.setSelected([song]);
}
} else {
internalState.setSelected([song]);
}
} else {
internalState.setSelected([song]);
}
},
[internalState, song],
);
return (
<tr
className={
isSelected
? styles.trackRowSelected
: isDragging
? styles.trackRowDragging
: undefined
}
onClick={handleRowClick}
ref={dragRef ?? undefined}
>
{columns.map((col) => {
const widthStyle = col.autoSize
? { minWidth: col.width }
: {
maxWidth: col.width,
minWidth: col.width,
width: col.width,
};
const style: React.CSSProperties = {
fontFamily:
col.id === TableColumn.DURATION || col.id === TableColumn.TRACK_NUMBER
? 'monospace'
: undefined,
textAlign: textAlignFromAlign(col.align),
...widthStyle,
};
const CellComponent = getDetailListCellComponent(col.id);
const content = (
<CellComponent
columnId={col.id}
isMutatingFavorite={isMutatingFavorite}
onFavoriteClick={onFavoriteClick}
rowIndex={rowIndex}
song={song}
/>
);
return (
<td className={styles.trackCell} key={col.id} style={style}>
{content}
</td>
);
})}
</tr>
);
},
);
TrackRow.displayName = 'TrackRow';
type RowContentProps = Omit<RowComponentProps<RowData>, 'style'>;
const RowContent = memo(
({
controls,
data,
enableTrackTableHeader,
getItem,
index,
internalState,
isMutatingFavorite,
queryClient,
registerSongs,
trackColumns,
}: RowContentProps) => {
const [showControls, setShowControls] = useState(false);
const item = useMemo(() => {
if (getItem) {
return getItem(index) as Album | undefined;
}
return (data?.[index] as Album | undefined) || undefined;
}, [data, getItem, index]);
const { data: songData } = useQuery({
enabled: !!item && !!item.id,
...albumQueries.detail({
query: {
id: item?.id || '',
},
serverId: item?._serverId || '',
}),
});
const songs = useMemo(() => {
return (
songData?.songs ||
Array.from({ length: item?.songCount || 0 }, (_, i) => ({
duration: 0,
id: `${item?.id}-${i}`,
name: '',
trackNumber: i + 1,
}))
);
}, [songData, item?.id, item?.songCount]);
useEffect(() => {
if (item?.id && songData?.songs?.length) {
registerSongs(item.id, songData.songs as Song[]);
}
}, [item?.id, registerSongs, songData?.songs]);
const onFavoriteClick = useCallback((song: Song) => {
// TODO: toggle favorite for song
void song;
}, []);
if (!item) {
return (
<>
<div className={styles.left}>
<div className={styles.metadata}>
<Skeleton className={styles.skeletonImage} />
<Skeleton className={styles.skeletonTitle} />
<Skeleton className={styles.skeletonArtist} />
</div>
</div>
<div className={styles.right}>
<div className={styles.skeletonTracks}>
{Array.from({ length: 10 }).map((_, i) => (
<div className={styles.skeletonTrackRow} key={i}>
<Skeleton className={styles.skeletonTrackCell} />
<Skeleton className={styles.skeletonTrackCellTitle} />
<Skeleton className={styles.skeletonTrackCell} />
</div>
))}
</div>
</div>
</>
);
}
return (
<>
<div className={styles.left}>
<div className={styles.metadata}>
<Link
className={styles.imageWrapper}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
state={{ item }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: item.id,
})}
>
<ItemImage
className={styles.image}
id={item.imageId}
itemType={item._itemType}
type="itemCard"
/>
<AnimatePresence>
{controls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={false}
internalState={internalState}
item={item}
itemType={item._itemType}
showRating={true}
type="compact"
/>
)}
</AnimatePresence>
</Link>
<div className={styles.title}>{item.name}</div>
<div className={styles.artist}>{item.albumArtistName}</div>
</div>
</div>
<div className={styles.right}>
<table className={styles.tracksTable}>
<tbody>
{songs.map((song, rowIndex) => (
<TrackRow
columns={trackColumns}
internalState={internalState}
isMutatingFavorite={isMutatingFavorite}
key={song.id}
onFavoriteClick={onFavoriteClick}
rowIndex={rowIndex}
song={song as Song}
/>
))}
</tbody>
</table>
</div>
</>
);
},
(prev, next) =>
prev.index === next.index &&
prev.data === next.data &&
prev.enableTrackTableHeader === next.enableTrackTableHeader &&
prev.getItem === next.getItem &&
prev.internalState === next.internalState &&
prev.queryClient === next.queryClient &&
prev.isMutatingFavorite === next.isMutatingFavorite &&
prev.controls === next.controls &&
prev.registerSongs === next.registerSongs &&
prev.trackColumns === next.trackColumns,
);
RowContent.displayName = 'RowContent';
const RowComponent = memo((props: RowComponentProps<RowData>): ReactElement => {
const { style, ...rowContentProps } = props;
return (
<div className={styles.row} style={style}>
<RowContent {...rowContentProps} />
</div>
);
});
RowComponent.displayName = 'ItemDetailRow';
export const ItemDetailList = ({
currentPage,
data,
dataVersion,
getItem,
itemCount: externalItemCount,
items,
onRangeChanged,
}: ItemDetailListProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const controls = useDefaultItemListControls();
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite;
const rowHeight = useDynamicRowHeight({
defaultRowHeight: 300,
});
const isInfinite = data !== undefined || getItem !== undefined;
const isPaginated = items !== undefined || currentPage !== undefined;
const dataSource = useMemo(() => {
if (isInfinite && data) {
return data;
}
if (isPaginated && items) {
return items;
}
return [];
}, [data, isInfinite, isPaginated, items]);
const itemCount = useMemo(() => {
if (externalItemCount !== undefined) {
return externalItemCount;
}
return dataSource.length;
}, [dataSource.length, externalItemCount]);
// Accumulate songs from each row for selection/drag state (keyed by album id)
const songsByAlbumRef = useRef<Map<string, Song[]>>(new Map());
const registerSongs = useCallback((albumId: string, songs: Song[]) => {
songsByAlbumRef.current.set(albumId, songs);
}, []);
// Flattened songs in album order for ItemListState (selection/drag are per-song)
const getDataFn = useCallback(() => {
const map = songsByAlbumRef.current;
return dataSource.flatMap((album) => map.get((album as Album).id) ?? []);
}, [dataSource]);
const extractRowIdSong = useCallback((item: unknown) => (item as Song).id, []);
const internalState = useItemListState(getDataFn, extractRowIdSong);
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM_DETAIL]?.table);
const trackColumns = useMemo((): ItemTableListColumnConfig[] => {
const raw = tableConfig?.columns;
if (raw && raw.length > 0) {
return parseTableColumns(raw);
}
return pickTableColumns({
columns: SONG_TABLE_COLUMNS,
enabledColumns: [
TableColumn.TRACK_NUMBER,
TableColumn.TITLE,
TableColumn.DURATION,
TableColumn.USER_FAVORITE,
TableColumn.USER_RATING,
],
});
}, [tableConfig?.columns]);
const enableTrackTableHeader = tableConfig?.enableHeader ?? false;
const handleRowsRendered = useCallback(
(range: { startIndex: number; stopIndex: number }) => {
if (onRangeChanged) {
onRangeChanged(range);
}
},
[onRangeChanged],
);
const throttledHandleRowsRendered = useMemo(
() =>
throttle(handleRowsRendered, 150, {
leading: true,
trailing: true,
}),
[handleRowsRendered],
);
useEffect(() => {
return () => {
throttledHandleRowsRendered.cancel();
};
}, [throttledHandleRowsRendered]);
const rowProps = useMemo<RowData>(
() => ({
controls,
data: dataSource,
enableTrackTableHeader,
getItem,
internalState,
isMutatingFavorite,
queryClient,
registerSongs,
trackColumns,
}),
[
controls,
dataSource,
enableTrackTableHeader,
getItem,
internalState,
isMutatingFavorite,
queryClient,
registerSongs,
trackColumns,
],
);
const [initialize, osInstance] = useOverlayScrollbars({
defer: false,
events: {
initialized(osInstance) {
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
},
},
options: {
overflow: { x: 'hidden', y: 'scroll' },
paddingAbsolute: true,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
visibility: 'visible',
},
},
});
useEffect(() => {
const { current: container } = containerRef;
if (!container || !container.firstElementChild) {
return;
}
const viewport = container.firstElementChild as HTMLElement;
initialize({
elements: { viewport },
target: container,
});
return () => osInstance()?.destroy();
}, [initialize, osInstance]);
return (
<div className={styles.container} ref={containerRef}>
<List
onRowsRendered={throttledHandleRowsRendered}
rowComponent={RowComponent as (props: RowComponentProps<RowData>) => ReactElement}
rowCount={itemCount}
rowHeight={rowHeight}
rowProps={rowProps}
/>
</div>
);
};