import { attachClosestEdge, type Edge, extractClosestEdge, } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements, } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import clsx from 'clsx'; import throttle from 'lodash/throttle'; import { AnimatePresence } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { Fragment, memo, type ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, Link } from 'react-router'; import { List, RowComponentProps, useDynamicRowHeight, useListRef } from 'react-window-v2'; import styles from './item-detail-list.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 { getTrackColumnFixed, isNoHorizontalPaddingColumn, shouldShowHoverOnlyColumnContent, } from '/@/renderer/components/item-list/item-detail-list/utils'; 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 { columnLabelMap } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types'; import { JOINED_ARTISTS_MUTED_PROPS, JoinedArtists, } from '/@/renderer/features/albums/components/joined-artists'; 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 { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { AppRoute } from '/@/renderer/router/routes'; import { useSettingsStore, useShowRatings } from '/@/renderer/store'; import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils'; import { SEPARATOR_STRING } from '/@/shared/api/utils'; import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { useDoubleClick } from '/@/shared/hooks/use-double-click'; import { Album, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; import { ItemListKey, Play, TableColumn } from '/@/shared/types/types'; const DEFAULT_ROW_HEIGHT = 300; const SKELETON_TRACK_ROW_COUNT = 6; interface ItemDetailListProps { currentPage?: number; data?: unknown[]; enableHeader?: boolean; getItem?: (index: number) => unknown; internalState?: ItemListStateActions; itemCount?: number; items?: unknown[]; listKey?: ItemListKey; onColumnReordered?: ( columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: 'bottom' | 'left' | 'right' | 'top' | null, ) => void; onColumnResized?: (columnId: TableColumn, width: number) => void; onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise | void; onScrollEnd?: (rowIndex: number) => void; onSongRowDoubleClick?: (params: { index: number; internalState: ItemListStateActions; item: Song; }) => void; overrideControls?: Partial; rowHeight?: number; scrollOffset?: number; songsByAlbumId?: Record; tableId?: string; } interface RowData { columnWidthPercents: number[]; controls?: ItemControls; data: unknown[]; defaultRowHeight: number; enableAlternateRowColors: boolean; enableHorizontalBorders: boolean; enableRowHoverHighlight: boolean; enableVerticalBorders: boolean; getItem?: (index: number) => unknown; internalState: ItemListStateActions; isMutatingFavorite: boolean; onSongRowDoubleClick?: (params: { index: number; internalState: ItemListStateActions; item: Song; }) => void; registerSongs: (albumId: string, songs: Song[]) => void; songsByAlbumId?: Record; trackColumns: ItemTableListColumnConfig[]; trackTableSize: 'compact' | 'default' | 'large'; } interface TrackRowProps { albumSongs: Song[]; columns: ItemTableListColumnConfig[]; columnWidthPercents: number[]; controls?: ItemControls; enableAlternateRowColors: boolean; enableHorizontalBorders: boolean; enableRowHoverHighlight: boolean; enableVerticalBorders: boolean; internalState: ItemListStateActions; isMutatingFavorite: boolean; isSongsLoading?: boolean; onSongRowDoubleClick?: (params: { index: number; internalState: ItemListStateActions; item: Song; }) => void; rowIndex: number; size: 'compact' | 'default' | 'large'; song: Song; } const textAlignFromAlign = (align: ItemTableListColumnConfig['align']) => align === 'start' ? 'left' : align === 'end' ? 'right' : 'center'; const TrackRow = memo( ({ albumSongs, columns, columnWidthPercents, controls, enableAlternateRowColors, enableHorizontalBorders, enableRowHoverHighlight, enableVerticalBorders, internalState, isMutatingFavorite, isSongsLoading, onSongRowDoubleClick, rowIndex, size, song, }: TrackRowProps) => { const playerContext = usePlayer(); const { dragRef, isDragging } = useItemDragDropState({ enableDrag: true, internalState, isDataRow: true, item: song, itemType: LibraryItem.SONG, playerContext, }); const [isRowHovered, setIsRowHovered] = useState(false); const isSelected = useItemSelectionState(internalState, song.id); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (onSongRowDoubleClick) { onSongRowDoubleClick({ index: internalState.findItemIndex(song.id), internalState, item: song, }); return; } if (controls?.onDoubleClick) { controls.onDoubleClick({ event: e, index: internalState.findItemIndex(song.id), internalState, item: song, itemType: LibraryItem.SONG, }); return; } if (isSongsLoading || albumSongs.length === 0) return; internalState.setSelected([song]); playerContext.addToQueueByData(albumSongs, Play.NOW, song.id); }, [ albumSongs, controls, internalState, isSongsLoading, onSongRowDoubleClick, playerContext, song, ], ); 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 { const selected = internalState.getSelected(); const onlyThisSelected = selected.length === 1 && internalState.extractRowId(selected[0]) === song.id; internalState.setSelected(onlyThisSelected ? [] : [song]); } }, [internalState, song], ); const handleClick = useDoubleClick({ onDoubleClick: handleDoubleClick, onSingleClick: handleRowClick, }); const handleContextMenu = useCallback( (event: React.MouseEvent) => { if (isSongsLoading || !controls?.onMore) return; event.preventDefault(); const index = internalState.findItemIndex(song.id); controls.onMore({ event, index, internalState, item: song, itemType: LibraryItem.SONG, }); }, [controls, internalState, isSongsLoading, song], ); return (
0, [styles.trackRowHoverHighlightEnabled]: enableRowHoverHighlight, [styles.trackRowSelected]: isSelected, [styles.trackRowSizeCompact]: size === 'compact', [styles.trackRowSizeDefault]: size === 'default', [styles.trackRowSizeLarge]: size === 'large', [styles.trackRowWithHorizontalBorder]: rowIndex > 0, })} onClick={handleClick} onContextMenu={handleContextMenu} 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`, minWidth: isFixedColumn ? fixedWidth : 0, textAlign: textAlignFromAlign(col.align), }; const CellComponent = getDetailListCellComponent(col.id); const isTitleColumn = col.id === TableColumn.TITLE; const isImageColumn = col.id === TableColumn.IMAGE; const isIconActionColumn = isNoHorizontalPaddingColumn(col.id); const showHoverContent = shouldShowHoverOnlyColumnContent( col.id, isRowHovered, song, ); const content = isSongsLoading ? null : showHoverContent ? ( ) : ( '\u00A0' ); const isLastColumn = colIndex === columns.length - 1; return (
{content}
); })}
); }, ); TrackRow.displayName = 'TrackRow'; interface MetadataSectionProps { controls?: ItemControls; internalState: ItemListStateActions; item: Album; } const MetadataSection = memo( ({ controls, internalState, item }: MetadataSectionProps) => { const { t } = useTranslation(); const showRatings = useShowRatings(); const [isImageHovered, setIsImageHovered] = useState(false); const [isMetadataHovered, setIsMetadataHovered] = useState(false); const isFavorite = item.userFavorite ?? false; const userRating = item.userRating ?? null; const hasRating = showRatings && userRating !== null && userRating > 0; const metadataExtra = useMemo(() => { const parts: Array<{ content: React.ReactNode; key: string }> = []; let releaseStr = ''; if (item.releaseDate) { if (item.originalDate && item.originalDate !== item.releaseDate) { releaseStr = `${formatDateAbsoluteUTC(item.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(item.releaseDate)}`; } else { releaseStr = formatDateAbsoluteUTC(item.releaseDate); } } else if (item.releaseYear != null) { releaseStr = String(item.releaseYear); } if (releaseStr) parts.push({ content: releaseStr, key: 'release' }); const songCount = item.songCount ?? 0; const duration = item.duration ?? 0; const tracksAndDurationParts: string[] = []; if (songCount > 0) { tracksAndDurationParts.push(t('entity.trackWithCount', { count: songCount })); } if (duration > 0) { tracksAndDurationParts.push(formatDurationString(duration)); } const tracksAndDuration = tracksAndDurationParts.join(SEPARATOR_STRING); if (tracksAndDuration) { parts.push({ content: tracksAndDuration, key: 'tracks' }); } const genres = item.genres?.filter((g) => g.name) ?? []; if (genres.length > 0) { parts.push({ content: genres.map((genre, i) => ( {i > 0 && ', '} {genre.name} )), key: 'genres', }); } return parts.length > 0 ? parts : null; }, [item, t]); const hasArtist = (item.albumArtistName?.trim()?.length ?? 0) > 0 || (item.albumArtists?.length ?? 0) > 0; return (
setIsMetadataHovered(true)} onMouseLeave={() => setIsMetadataHovered(false)} > setIsImageHovered(true)} onMouseLeave={() => setIsImageHovered(false)} state={{ item }} to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: item.id, })} > {isFavorite &&
} {hasRating &&
{userRating}
} {controls && isImageHovered && ( )} {item.name}
{!hasArtist ? ( <>  ) : ( )}
{metadataExtra && metadataExtra.length > 0 && (
{metadataExtra.map((part) => (
{part.content}
))}
)}
); }, (prev, next) => prev.item === next.item, ); MetadataSection.displayName = 'MetadataSection'; interface ItemDetailSkeletonRowProps { defaultRowHeight: number; enableAlternateRowColors: boolean; enableHorizontalBorders: boolean; enableVerticalBorders: boolean; trackTableSize: 'compact' | 'default' | 'large'; } const ItemDetailSkeletonRow = memo( ({ defaultRowHeight, enableAlternateRowColors, enableHorizontalBorders, enableVerticalBorders, trackTableSize, }: ItemDetailSkeletonRowProps) => { const heightStyle = { height: defaultRowHeight, minHeight: defaultRowHeight, overflow: 'hidden' as const, }; return ( <>
{Array.from({ length: SKELETON_TRACK_ROW_COUNT }).map((_, i) => (
0, [styles.trackRowSizeCompact]: trackTableSize === 'compact', [styles.trackRowSizeDefault]: trackTableSize === 'default', [styles.trackRowSizeLarge]: trackTableSize === 'large', [styles.trackRowWithHorizontalBorder]: i > 0, })} key={i} role="row" >
))}
); }, ); ItemDetailSkeletonRow.displayName = 'ItemDetailSkeletonRow'; type RowContentProps = Omit, 'style'>; const RowContent = memo( ({ columnWidthPercents, controls, data, defaultRowHeight, enableAlternateRowColors, enableHorizontalBorders, enableRowHoverHighlight, enableVerticalBorders, getItem, index, internalState, isMutatingFavorite, onSongRowDoubleClick, registerSongs, songsByAlbumId, trackColumns, trackTableSize, }: RowContentProps) => { const item = useMemo(() => { if (getItem) { return getItem(index) as Album | undefined; } return (data?.[index] as Album | undefined) || undefined; }, [data, getItem, index]); const useClientSideSongs = Boolean(songsByAlbumId); const songListQuery = useMemo(() => { if (useClientSideSongs || !item?.id || !item?._serverId) return null; return { query: { albumIds: [item.id], limit: -1, sortBy: SongListSort.ALBUM, sortOrder: SortOrder.ASC, startIndex: 0, }, serverId: item?._serverId || '', }; }, [item, useClientSideSongs]); const { data: songListData, isLoading: isSongsQueryLoading } = useQuery({ enabled: !!songListQuery, ...(songListQuery ? songsQueries.list(songListQuery) : { queryFn: async () => ({ items: [], startIndex: 0, totalRecordCount: 0 }), queryKey: ['item-detail', 'list', 'disabled'], }), }); const songItemsFromQuery = songListData?.items; const songItemsFromClient = useMemo(() => { const rowSongs = (item as { _playlistSongs?: Song[] })?._playlistSongs; if (rowSongs?.length) return rowSongs; if (!songsByAlbumId || !item?.id) return undefined; return songsByAlbumId[item.id]; }, [item, songsByAlbumId]); const songItems = useClientSideSongs ? songItemsFromClient : songItemsFromQuery; const isSongsLoading = !useClientSideSongs && !!item && isSongsQueryLoading && !songItemsFromQuery?.length; const songs = useMemo(() => { return ( songItems || Array.from({ length: item?.songCount || 0 }, (_, i) => ({ duration: 0, id: `${item?.id}-${i}`, name: '', trackNumber: i + 1, })) ); }, [songItems, item?.id, item?.songCount]); useEffect(() => { if (item?.id && songItems?.length) { registerSongs(item.id, songItems as Song[]); } }, [item?.id, registerSongs, songItems]); if (!item) { return ( ); } return ( <>
{songs.map((song, rowIndex) => ( ))}
); }, (prev, next) => prev.index === next.index && prev.data === next.data && prev.columnWidthPercents === next.columnWidthPercents && prev.defaultRowHeight === next.defaultRowHeight && prev.enableAlternateRowColors === next.enableAlternateRowColors && prev.enableHorizontalBorders === next.enableHorizontalBorders && prev.enableRowHoverHighlight === next.enableRowHoverHighlight && prev.enableVerticalBorders === next.enableVerticalBorders && prev.getItem === next.getItem && prev.internalState === next.internalState && prev.isMutatingFavorite === next.isMutatingFavorite && prev.controls === next.controls && prev.registerSongs === next.registerSongs && prev.songsByAlbumId === next.songsByAlbumId && prev.trackColumns === next.trackColumns && prev.trackTableSize === next.trackTableSize, ); RowContent.displayName = 'RowContent'; const RowComponent = memo((props: RowComponentProps): ReactElement => { const { style, ...rowContentProps } = props; return (
); }); RowComponent.displayName = 'ItemDetailRow'; interface DetailListHeaderCellProps { columnId: TableColumn; columnWidthPercents: number[]; enableColumnResize?: boolean; enableVerticalBorders: boolean; isLastColumn: boolean; onColumnReordered?: (args: { columnIdFrom: TableColumn; columnIdTo: TableColumn; edge: Edge | null; }) => void; onColumnResized?: (columnId: TableColumn, width: number) => void; tableId: string; trackColumns: ItemTableListColumnConfig[]; } const DetailListHeaderCell = memo( ({ columnId, columnWidthPercents, enableColumnResize, onColumnReordered, onColumnResized, tableId, trackColumns, }: DetailListHeaderCellProps) => { const containerRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [isDraggedOver, setIsDraggedOver] = useState(null); const colIndex = trackColumns.findIndex((c) => c.id === columnId); const col = colIndex >= 0 ? trackColumns[colIndex] : null; const percent = col ? (columnWidthPercents[colIndex] ?? 0) : 0; const { fixedWidth, isFixedColumn } = getTrackColumnFixed(columnId); const currentWidth = col?.width ?? (fixedWidth || 100); const showResizeHandle = enableColumnResize && !isFixedColumn && !col?.autoSize && onColumnResized; useEffect(() => { if (!containerRef.current || !onColumnReordered) { return; } const handleReorder = ( columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null, ) => { onColumnReordered({ columnIdFrom, columnIdTo, edge }); }; return combine( draggable({ element: containerRef.current, getInitialData: () => { const data = dndUtils.generateDragData( { id: [columnId], operation: [DragOperation.REORDER], type: DragTarget.TABLE_COLUMN, }, { tableId }, ); return data; }, onDragStart: () => setIsDragging(true), onDrop: () => setIsDragging(false), onGenerateDragPreview: (data) => { disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage }); }, }), dropTargetForElements({ canDrop: (args) => { const data = args.source.data as unknown as DragData; const sourceTableId = (data.metadata as { tableId?: string })?.tableId; const isSelf = (args.source.data.id as string[])[0] === columnId; const isSameTable = sourceTableId === tableId; return ( dndUtils.isDropTarget(data.type, [DragTarget.TABLE_COLUMN]) && !isSelf && isSameTable ); }, element: containerRef.current, getData: ({ element, input }) => { const data = dndUtils.generateDragData( { id: [columnId], operation: [DragOperation.REORDER], type: DragTarget.TABLE_COLUMN, }, { tableId }, ); return attachClosestEdge(data, { allowedEdges: ['left', 'right'], element, input, }); }, onDrag: (args) => { const closestEdgeOfTarget = extractClosestEdge(args.self.data); setIsDraggedOver(closestEdgeOfTarget); }, onDragLeave: () => setIsDraggedOver(null), onDrop: (args) => { const closestEdgeOfTarget = extractClosestEdge(args.self.data); const from = args.source.data.id as string[]; const to = args.self.data.id as string[]; handleReorder( from[0] as TableColumn, to[0] as TableColumn, closestEdgeOfTarget, ); setIsDraggedOver(null); }, }), ); }, [columnId, onColumnReordered, tableId]); const style: React.CSSProperties = { flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`, justifyContent: colTypeToJustifyContentMap[col?.align ?? 'start'], minWidth: isFixedColumn ? fixedWidth : 0, textAlign: colTypeToAlignMap[col?.align ?? 'start'] as 'center' | 'left' | 'right', }; const handleResize = useCallback( (id: TableColumn, width: number) => { onColumnResized?.(id, width); }, [onColumnResized], ); return (
{columnLabelMap[columnId] ?? ''} {showResizeHandle && ( )}
); }, ); DetailListHeaderCell.displayName = 'DetailListHeaderCell'; interface DetailListColumnResizeHandleProps { columnId: TableColumn; initialWidth: number; onResize: (columnId: TableColumn, width: number) => void; side: 'left' | 'right'; } const DetailListColumnResizeHandle = ({ columnId, initialWidth, onResize, side, }: DetailListColumnResizeHandleProps) => { const [isDragging, setIsDragging] = useState(false); const handleRef = useRef(null); const startWidthRef = useRef(initialWidth); const startXRef = useRef(0); const finalWidthRef = useRef(initialWidth); useEffect(() => { if (!isDragging) { startWidthRef.current = initialWidth; } }, [initialWidth, isDragging]); useEffect(() => { if (!isDragging) return; const handleMouseMove = (event: MouseEvent) => { const deltaX = event.clientX - startXRef.current; const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000); finalWidthRef.current = newWidth; }; const handleMouseUp = () => { setIsDragging(false); document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); onResize(columnId, finalWidthRef.current); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isDragging, columnId, onResize]); const handleMouseDown = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); setIsDragging(true); startWidthRef.current = initialWidth; startXRef.current = event.clientX; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }; return (
); }; interface DetailListHeaderProps { columnWidthPercents: number[]; enableColumnReorder?: boolean; enableColumnResize?: boolean; enableVerticalBorders: boolean; headerLeftRef: React.RefObject; onColumnReordered?: (args: { columnIdFrom: TableColumn; columnIdTo: TableColumn; edge: Edge | null; }) => void; onColumnResized?: (columnId: TableColumn, width: number) => void; tableId: string; trackColumns: ItemTableListColumnConfig[]; trackTableSize: 'compact' | 'default' | 'large'; } const colTypeToAlignMap = { center: 'center', end: 'right', start: 'left', }; const colTypeToJustifyContentMap = { center: 'center', end: 'flex-end', start: 'flex-start', }; const DetailListHeader = memo( ({ columnWidthPercents, enableColumnReorder, enableColumnResize, enableVerticalBorders, headerLeftRef, onColumnReordered, onColumnResized, tableId, trackColumns, trackTableSize, }: DetailListHeaderProps) => { return (
{trackColumns.map((col, colIndex) => { const isLastColumn = colIndex === trackColumns.length - 1; if ( (enableColumnResize && onColumnResized) || (enableColumnReorder && onColumnReordered) ) { return ( ); } const percent = columnWidthPercents[colIndex] ?? 0; const { fixedWidth, isFixedColumn } = getTrackColumnFixed(col.id); const style: React.CSSProperties = { flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`, justifyContent: colTypeToJustifyContentMap[col.align], minWidth: isFixedColumn ? fixedWidth : 0, textAlign: colTypeToAlignMap[col.align] as | 'center' | 'left' | 'right', }; return (
{columnLabelMap[col.id] ?? ''}
); })}
); }, ); DetailListHeader.displayName = 'DetailListHeader'; const SCROLL_END_DEBOUNCE_MS = 150; const DEFAULT_DETAIL_TABLE_ID = 'album-detail'; export const ItemDetailList = ({ currentPage, data, enableHeader = true, getItem, itemCount: externalItemCount, items, listKey = ItemListKey.ALBUM, onColumnReordered, onColumnResized, onRangeChanged, onScrollEnd, onSongRowDoubleClick, overrideControls, songsByAlbumId, tableId = DEFAULT_DETAIL_TABLE_ID, }: ItemDetailListProps) => { const containerRef = useRef(null); const listRef = useListRef(null); const lastVisibleStartIndexRef = useRef(0); const queryClient = useQueryClient(); const controls = useDefaultItemListControls({ onColumnReordered, onColumnResized, overrides: overrideControls, }); const isMutatingCreateFavorite = useIsMutatingCreateFavorite(); const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite(); const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite; const rowHeight = useDynamicRowHeight({ defaultRowHeight: DEFAULT_ROW_HEIGHT, }); 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>(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[listKey]?.detail); 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 trackTableSize = tableConfig?.size ?? 'default'; const enableRowHoverHighlight = tableConfig?.enableRowHoverHighlight ?? true; const enableAlternateRowColors = tableConfig?.enableAlternateRowColors ?? false; const enableHorizontalBorders = tableConfig?.enableHorizontalBorders ?? false; const enableVerticalBorders = tableConfig?.enableVerticalBorders ?? false; const columnWidthPercents = useMemo(() => { const total = trackColumns.reduce((sum, c) => sum + c.width, 0); if (total <= 0) { return trackColumns.map(() => 100 / Math.max(1, trackColumns.length)); } return trackColumns.map((c) => (c.width / total) * 100); }, [trackColumns]); const headerLeftRef = useRef(null); const dataSourceRef = useRef(dataSource); dataSourceRef.current = dataSource; const lastHeaderNameRef = useRef(''); const handleRowsRendered = useCallback( (range: { startIndex: number; stopIndex: number }) => { lastVisibleStartIndexRef.current = range.startIndex; const el = headerLeftRef.current; if (el) { const album = ( getItem ? getItem(range.startIndex) : dataSourceRef.current[range.startIndex] ) as Album | undefined; const name = album?.name ?? ''; if (name) { lastHeaderNameRef.current = name; el.textContent = name; el.setAttribute('data-title', name); el.title = name; } else { el.textContent = lastHeaderNameRef.current; el.setAttribute('data-title', lastHeaderNameRef.current); el.title = lastHeaderNameRef.current; } } if (onRangeChanged) { onRangeChanged(range); } }, [getItem, onRangeChanged], ); const throttledHandleRowsRendered = useMemo( () => throttle(handleRowsRendered, 150, { leading: true, trailing: true, }), [handleRowsRendered], ); useEffect(() => { return () => { throttledHandleRowsRendered.cancel(); }; }, [throttledHandleRowsRendered]); const rowProps = useMemo( () => ({ columnWidthPercents, controls, data: dataSource, defaultRowHeight: DEFAULT_ROW_HEIGHT, enableAlternateRowColors, enableHorizontalBorders, enableRowHoverHighlight, enableVerticalBorders, getItem, internalState, isMutatingFavorite, onSongRowDoubleClick, queryClient, registerSongs, songsByAlbumId, trackColumns, trackTableSize, }), [ columnWidthPercents, controls, dataSource, enableAlternateRowColors, enableHorizontalBorders, enableRowHoverHighlight, enableVerticalBorders, getItem, internalState, isMutatingFavorite, onSongRowDoubleClick, queryClient, registerSongs, songsByAlbumId, trackColumns, trackTableSize, ], ); 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, }); let scrollEndTimeoutId: null | ReturnType = null; const handleScroll = () => { if (scrollEndTimeoutId) clearTimeout(scrollEndTimeoutId); scrollEndTimeoutId = setTimeout(() => { scrollEndTimeoutId = null; onScrollEnd?.(lastVisibleStartIndexRef.current); }, SCROLL_END_DEBOUNCE_MS); }; if (onScrollEnd) { viewport.addEventListener('scroll', handleScroll, { passive: true }); } return () => { if (onScrollEnd) { viewport.removeEventListener('scroll', handleScroll); if (scrollEndTimeoutId) clearTimeout(scrollEndTimeoutId); } osInstance()?.destroy(); }; }, [initialize, onScrollEnd, osInstance]); return (
{enableHeader && ( controls.onColumnResized?.({ columnId, width }) : undefined } tableId={tableId} trackColumns={trackColumns} trackTableSize={trackTableSize} /> )}
) => ReactElement } rowCount={itemCount} rowHeight={rowHeight} rowProps={rowProps} />
); };