diff --git a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts index e439fa35f..c64ab9e60 100644 --- a/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts +++ b/src/renderer/components/item-list/helpers/item-list-infinite-loader.ts @@ -32,6 +32,13 @@ export const getListQueryKeyName = (itemType: LibraryItem): string => { } }; +type InfiniteLoaderCacheData = { + dataMap: Map; + idToIndexMap: Map; + pagesLoaded: Record; + version: number; +}; + interface UseItemListInfiniteLoaderProps { eventKey: string; fetchThreshold?: number; @@ -43,10 +50,12 @@ interface UseItemListInfiniteLoaderProps { serverId: string; } -function getInitialData(itemCount: number) { +function getInitialData(): InfiniteLoaderCacheData { return { - data: Array.from({ length: itemCount }, () => undefined), + dataMap: new Map(), + idToIndexMap: new Map(), pagesLoaded: {}, + version: 0, }; } @@ -118,28 +127,27 @@ export const useItemListInfiniteLoader = ({ queryKey: queryKeys[getListQueryKeyName(itemType)].list(serverId, queryParams), }); - const endIndex = startIndex + itemsPerPage; - // Update the query data with the fetched page - queryClient.setQueryData( - dataQueryKey, - (oldData: { data: unknown[]; pagesLoaded: Record }) => { - const newData = [ - ...oldData.data.slice(0, startIndex), - ...result.items, - ...oldData.data.slice(endIndex), - ]; - const newPagesLoaded = { - ...oldData.pagesLoaded, - [pageNumber]: true, - }; + queryClient.setQueryData(dataQueryKey, (oldData: InfiniteLoaderCacheData) => { + const nextDataMap = new Map(oldData.dataMap); + const nextIdToIndexMap = new Map(oldData.idToIndexMap); - return { - data: newData, - pagesLoaded: newPagesLoaded, - }; - }, - ); + result.items.forEach((item, offset) => { + const index = startIndex + offset; + nextDataMap.set(index, item); + if (item && typeof item === 'object' && 'id' in (item as any)) { + const id = String((item as any).id); + nextIdToIndexMap.set(id, index); + } + }); + + return { + dataMap: nextDataMap, + idToIndexMap: nextIdToIndexMap, + pagesLoaded: { ...oldData.pagesLoaded, [pageNumber]: true }, + version: oldData.version + 1, + }; + }); // Track the last fetched page lastFetchedPageRef.current = Math.max(lastFetchedPageRef.current, pageNumber); @@ -179,7 +187,10 @@ export const useItemListInfiniteLoader = ({ if (!oldData) return oldData; return { ...oldData, + dataMap: new Map(), + idToIndexMap: new Map(), pagesLoaded: {}, + version: (oldData?.version ?? 0) + 1, }; }); @@ -211,11 +222,11 @@ export const useItemListInfiniteLoader = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [dataQueryKey, queryClient, fetchPage, itemsPerPage]); - const { data } = useQuery<{ data: unknown[]; pagesLoaded: Record }>({ + const { data } = useQuery({ enabled: false, - initialData: getInitialData(totalItemCount), + initialData: getInitialData(), queryFn: () => { - return getInitialData(totalItemCount); + return getInitialData(); }, queryKey: dataQueryKey, }); @@ -233,7 +244,7 @@ export const useItemListInfiniteLoader = ({ const pageNumber = Math.floor(range.startIndex / itemsPerPage); const currentData = queryClient.getQueryData<{ - data: unknown[]; + dataMap: Map; pagesLoaded: Record; }>(dataQueryKey); @@ -289,18 +300,20 @@ export const useItemListInfiniteLoader = ({ // Reset the infinite list data const currentData = queryClient.getQueryData<{ - data: unknown[]; + dataMap: Map; pagesLoaded: Record; }>(dataQueryKey); if (force || currentData) { // Reset data to initial state and clear all loaded pages await queryClient.setQueryData(dataQueryKey, (oldData: any) => { - if (!oldData) return getInitialData(totalItemCount); + if (!oldData) return getInitialData(); return { ...oldData, - data: Array.from({ length: totalItemCount }, () => undefined), + dataMap: new Map(), + idToIndexMap: new Map(), pagesLoaded: {}, + version: (oldData?.version ?? 0) + 1, }; }); lastFetchedPageRef.current = -1; @@ -336,28 +349,23 @@ export const useItemListInfiniteLoader = ({ const updateItems = useCallback( (indexes: number[], value: object) => { - queryClient.setQueryData( - dataQueryKey, - (prev: { data: unknown[]; pagesLoaded: Record }) => { - return { - ...prev, - data: prev.data.map((item: any, index) => { - if (!item) { - return item; - } + queryClient.setQueryData(dataQueryKey, (prev: InfiniteLoaderCacheData) => { + const nextDataMap = new Map(prev.dataMap); - if (!indexes.includes(index)) { - return item; - } + indexes.forEach((index) => { + const existing = nextDataMap.get(index); + if (!existing || typeof existing !== 'object') { + return; + } + nextDataMap.set(index, { ...(existing as any), ...(value as any) }); + }); - return { - ...item, - ...value, - }; - }), - }; - }, - ); + return { + ...prev, + dataMap: nextDataMap, + version: prev.version + 1, + }; + }); }, [queryClient, dataQueryKey], ); @@ -384,16 +392,9 @@ export const useItemListInfiniteLoader = ({ return; } - const idToIndexMap = data.data - .filter(Boolean) - .reduce((acc: Record, item: any, index: number) => { - acc[item.id] = index; - return acc; - }, {}); - const dataIndexes = payload.id - .map((id: string) => idToIndexMap[id]) - .filter((idx) => idx !== undefined); + .map((id: string) => (data as any).idToIndexMap?.get(id)) + .filter((idx): idx is number => typeof idx === 'number'); if (dataIndexes.length === 0) { return; @@ -407,16 +408,9 @@ export const useItemListInfiniteLoader = ({ return; } - const idToIndexMap = data.data - .filter(Boolean) - .reduce((acc: Record, item: any, index: number) => { - acc[item.id] = index; - return acc; - }, {}); - const dataIndexes = payload.id - .map((id: string) => idToIndexMap[id]) - .filter((idx) => idx !== undefined); + .map((id: string) => (data as any).idToIndexMap?.get(id)) + .filter((idx): idx is number => typeof idx === 'number'); if (dataIndexes.length === 0) { return; @@ -434,7 +428,40 @@ export const useItemListInfiniteLoader = ({ }; }, [data, eventKey, itemType, serverId, updateItems]); - return { data: data.data, onRangeChanged, refresh, updateItems }; + const itemCount = totalItemCount ?? 0; + + const getItem = useCallback( + (index: number) => { + return (data as any).dataMap?.get(index); + }, + [data], + ); + + const getItemIndex = useCallback( + (id: string) => { + return (data as any).idToIndexMap?.get(id); + }, + [data], + ); + + const loadedItems = useMemo(() => { + const map: Map | undefined = (data as any).dataMap; + if (!map || map.size === 0) return []; + return Array.from(map.entries()) + .sort(([a], [b]) => a - b) + .map(([, v]) => v); + }, [data]); + + return { + dataVersion: (data as any).version ?? 0, + getItem, + getItemIndex, + itemCount, + loadedItems, + onRangeChanged, + refresh, + updateItems, + }; }; export const parseListCountQuery = (query: any) => { diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx index 5736b83b6..8490b4ea9 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx @@ -53,14 +53,16 @@ interface VirtualizedGridListProps { _tableMetaVersion: number; // Used to trigger rerenders via React.memo comparison controls: ItemControls; currentPage?: number; - data: unknown[]; + dataVersion?: number; enableDrag?: boolean; enableExpansion: boolean; enableSelection: boolean; gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; + getItem?: (index: number) => ItemCardProps['data']; height: number; initialTop?: ItemGridListProps['initialTop']; internalState: ItemListStateActions; + itemCount: number; itemType: LibraryItem; onRangeChanged?: ItemGridListProps['onRangeChanged']; onScroll?: ItemGridListProps['onScroll']; @@ -81,14 +83,16 @@ const VirtualizedGridList = React.memo( ({ controls, currentPage, - data, + dataVersion, enableDrag, enableExpansion, enableSelection, gap, + getItem, height, initialTop, internalState, + itemCount, itemType, onRangeChanged, onScroll, @@ -107,12 +111,14 @@ const VirtualizedGridList = React.memo( return { columns: tableMeta?.columnCount || 0, controls, - data, + dataVersion, enableDrag, enableExpansion, enableSelection, gap, + getItem, internalState, + itemCount, itemType, rows, size, @@ -122,7 +128,9 @@ const VirtualizedGridList = React.memo( tableMeta, controls, rows, - data, + getItem, + itemCount, + dataVersion, enableDrag, enableExpansion, enableSelection, @@ -285,12 +293,14 @@ const createThrottledSetTableMeta = ( export interface GridItemProps { columns: number; controls: ItemCardProps['controls']; - data: any[]; + dataVersion?: number; enableDrag?: boolean; enableExpansion?: boolean; enableSelection?: boolean; gap: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; + getItem?: (index: number) => ItemCardProps['data']; internalState: ItemListStateActions; + itemCount: number; itemType: LibraryItem; rows?: ItemCardProps['rows']; size?: 'compact' | 'default' | 'large'; @@ -304,17 +314,21 @@ export interface GridItemProps { export interface ItemGridListProps { currentPage?: number; data: unknown[]; + dataVersion?: number; enableDrag?: boolean; enableEntranceAnimation?: boolean; enableExpansion?: boolean; enableSelection?: boolean; enableSelectionDialog?: boolean; gap?: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; + getItem?: (index: number) => ItemCardProps['data']; + getItemIndex?: (rowId: string) => number | undefined; getRowId?: ((item: unknown) => string) | string; initialTop?: { to: number; type: 'index' | 'offset'; }; + itemCount?: number; itemsPerRow?: number; itemType: LibraryItem; onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void; @@ -329,13 +343,17 @@ export interface ItemGridListProps { const BaseItemGridList = ({ currentPage, data, + dataVersion, enableDrag = true, enableEntranceAnimation = true, enableExpansion = false, enableSelection = true, gap = 'sm', + getItem, + getItemIndex, getRowId, initialTop, + itemCount, itemsPerRow, itemType, onRangeChanged, @@ -354,6 +372,14 @@ const BaseItemGridList = ({ const handleRef = useRef(null); const mergedContainerRef = useMergedRef(containerRef, rootRef, containerFocusRef); + const resolvedItemCount = itemCount ?? data.length; + const resolvedGetItem = useCallback<(index: number) => ItemCardProps['data']>( + (index: number) => { + return (getItem ? getItem(index) : (data as any[])[index]) as ItemCardProps['data']; + }, + [data, getItem], + ); + const getDataFn = useCallback(() => { return data; }, [data]); @@ -442,7 +468,7 @@ const BaseItemGridList = ({ const { current: container } = containerRef; if (!container) return; - throttledSetTableMeta(containerWidth, data.length, (meta) => { + throttledSetTableMeta(containerWidth, resolvedItemCount, (meta) => { if (!meta) return; const current = tableMetaRef.current; @@ -459,7 +485,7 @@ const BaseItemGridList = ({ setTableMetaVersion((v) => v + 1); } }); - }, [containerWidth, data.length, throttledSetTableMeta, containerRef]); + }, [containerWidth, resolvedItemCount, throttledSetTableMeta, containerRef]); const controls = useDefaultItemListControls({ overrides: overrideControls }); @@ -512,10 +538,12 @@ const BaseItemGridList = ({ const lastSelected = selected[selected.length - 1]; const lastRowId = internalState.extractRowId(lastSelected); if (lastRowId) { - currentIndex = data.findIndex((d: any) => { - const rowId = internalState.extractRowId(d); - return rowId === lastRowId; - }); + currentIndex = + getItemIndex?.(lastRowId) ?? + data.findIndex((d: any) => { + const rowId = internalState.extractRowId(d); + return rowId === lastRowId; + }); } } @@ -526,7 +554,7 @@ const BaseItemGridList = ({ : 0; const currentCol = currentIndex !== -1 ? currentIndex % tableMetaRef.current.columnCount : 0; - const totalRows = Math.ceil(data.length / tableMetaRef.current.columnCount); + const totalRows = Math.ceil(resolvedItemCount / tableMetaRef.current.columnCount); let newIndex = 0; if (currentIndex !== -1) { @@ -538,7 +566,7 @@ const BaseItemGridList = ({ const nextRowStart = nextRow * tableMetaRef.current.columnCount; const nextRowEnd = Math.min( nextRowStart + tableMetaRef.current.columnCount - 1, - data.length - 1, + resolvedItemCount - 1, ); // Keep same column position, or use last item in row if column doesn't exist newIndex = Math.min(nextRowStart + currentCol, nextRowEnd); @@ -559,7 +587,7 @@ const BaseItemGridList = ({ 1, 0, ); - newIndex = Math.min(newIndex, data.length - 1); + newIndex = Math.min(newIndex, resolvedItemCount - 1); } else { newIndex = currentIndex; } @@ -569,14 +597,14 @@ const BaseItemGridList = ({ // Move right, wrap to next row if at end of row if ( currentCol < tableMetaRef.current.columnCount - 1 && - currentIndex < data.length - 1 + currentIndex < resolvedItemCount - 1 ) { newIndex = currentIndex + 1; } else if (currentRow < totalRows - 1) { // Wrap to start of next row newIndex = Math.min( (currentRow + 1) * tableMetaRef.current.columnCount, - data.length - 1, + resolvedItemCount - 1, ); } else { newIndex = currentIndex; @@ -590,7 +618,7 @@ const BaseItemGridList = ({ const prevRowStart = prevRow * tableMetaRef.current.columnCount; const prevRowEnd = Math.min( prevRowStart + tableMetaRef.current.columnCount - 1, - data.length - 1, + resolvedItemCount - 1, ); // Keep same column position, or use last item in row if column doesn't exist newIndex = Math.min(prevRowStart + currentCol, prevRowEnd); @@ -605,7 +633,7 @@ const BaseItemGridList = ({ newIndex = 0; } - const newItem: any = data[newIndex]; + const newItem: any = resolvedGetItem(newIndex); if (!newItem) return; // Handle Shift + Arrow for incremental range selection (matches shift+click behavior) @@ -618,10 +646,12 @@ const BaseItemGridList = ({ const lastRowId = internalState.extractRowId(lastSelectedItem); if (!lastRowId) return; - const lastIndex = data.findIndex((d: any) => { - const rowId = internalState.extractRowId(d); - return rowId === lastRowId; - }); + const lastIndex = + getItemIndex?.(lastRowId) ?? + data.findIndex((d: any) => { + const rowId = internalState.extractRowId(d); + return rowId === lastRowId; + }); if (lastIndex !== -1 && newIndex !== -1) { // Create range selection from last selected to new position @@ -630,7 +660,7 @@ const BaseItemGridList = ({ const rangeItems: ItemListStateItemWithRequiredProperties[] = []; for (let i = startIndex; i <= stopIndex; i++) { - const rangeItem = data[i]; + const rangeItem = resolvedGetItem(i); if ( rangeItem && typeof rangeItem === 'object' && @@ -695,7 +725,15 @@ const BaseItemGridList = ({ scrollToIndex(newIndex); }, - [data, enableSelection, internalState, scrollToIndex], + [ + data, + enableSelection, + getItemIndex, + internalState, + resolvedGetItem, + resolvedItemCount, + scrollToIndex, + ], ); const imperativeHandle: ItemListHandle = useMemo(() => { @@ -740,14 +778,16 @@ const BaseItemGridList = ({ _tableMetaVersion={tableMetaVersion} controls={controls} currentPage={currentPage} - data={data} + dataVersion={dataVersion} enableDrag={enableDrag} enableExpansion={enableExpansion} enableSelection={enableSelection} gap={gap} + getItem={resolvedGetItem} height={height} initialTop={initialTop} internalState={internalState} + itemCount={resolvedItemCount} itemType={itemType} onRangeChanged={onRangeChanged} onScroll={onScroll ?? (() => {})} @@ -771,10 +811,10 @@ const BaseItemGridList = ({ const ListComponent = memo((props: ListChildComponentProps) => { const { index, style } = props; - const { columns, controls, data, enableDrag, gap, itemType, rows, size } = props.data; + const { columns, controls, enableDrag, gap, getItem, itemCount, itemType, rows, size } = + props.data; const items: ReactNode[] = []; - const itemCount = data.length; const startIndex = index * columns; const stopIndex = Math.min(itemCount - 1, startIndex + columns - 1); @@ -787,7 +827,8 @@ const ListComponent = memo((props: ListChildComponentProps) => { } for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) { - if (i < data.length) { + if (i < itemCount) { + const item = getItem ? getItem(i) : undefined; items.push(
) => { > { - const row: any = (props.data as (any | undefined)[])[props.rowIndex]; + const row: any = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; const handleActionClick = (event: React.MouseEvent) => { event.stopPropagation(); diff --git a/src/renderer/components/item-list/item-table-list/columns/album-artists-column.tsx b/src/renderer/components/item-list/item-table-list/columns/album-artists-column.tsx index 3fb0a4b27..1798788b8 100644 --- a/src/renderer/components/item-list/item-table-list/columns/album-artists-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/album-artists-column.tsx @@ -13,11 +13,10 @@ import { JoinedArtists } from '/@/renderer/features/albums/components/joined-art import { Album, RelatedAlbumArtist, Song } from '/@/shared/types/domain-types'; const AlbumArtistsColumn = (props: ItemTableListInnerColumn) => { - const row: RelatedAlbumArtist[] | undefined = ( - props.data as (RelatedAlbumArtist[] | undefined)[] - )[props.rowIndex]?.[props.columns[props.columnIndex].id]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: RelatedAlbumArtist[] | undefined = rowItem?.[props.columns[props.columnIndex].id]; - const item = props.data[props.rowIndex] as Album | Song | undefined; + const item = rowItem as Album | Song | undefined; const albumArtistString = item && 'albumArtistName' in item ? item.albumArtistName : ''; if (Array.isArray(row)) { diff --git a/src/renderer/components/item-list/item-table-list/columns/album-column.tsx b/src/renderer/components/item-list/item-table-list/columns/album-column.tsx index cb667754b..dcd727279 100644 --- a/src/renderer/components/item-list/item-table-list/columns/album-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/album-column.tsx @@ -15,11 +15,10 @@ import { Text } from '/@/shared/components/text/text'; import { Song } from '/@/shared/types/domain-types'; const AlbumColumn = (props: ItemTableListInnerColumn) => { - const row: null | string | undefined = (props.data as (null | string | undefined)[])[ - props.rowIndex - ]?.[props.columns[props.columnIndex].id]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: null | string | undefined = rowItem?.[props.columns[props.columnIndex].id]; - const song = props.data[props.rowIndex] as Song | undefined; + const song = rowItem as Song | undefined; const albumId = song?.albumId; const albumPath = useMemo(() => { diff --git a/src/renderer/components/item-list/item-table-list/columns/artists-column.tsx b/src/renderer/components/item-list/item-table-list/columns/artists-column.tsx index a5c3477bb..b94481d5f 100644 --- a/src/renderer/components/item-list/item-table-list/columns/artists-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/artists-column.tsx @@ -16,9 +16,8 @@ import { Text } from '/@/shared/components/text/text'; import { LibraryItem, RelatedAlbumArtist, Song } from '/@/shared/types/domain-types'; const AlbumArtistsColumn = (props: ItemTableListInnerColumn) => { - const row: RelatedAlbumArtist[] | undefined = ( - props.data as (RelatedAlbumArtist[] | undefined)[] - )[props.rowIndex]?.[props.columns[props.columnIndex].id]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: RelatedAlbumArtist[] | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; const artists = useMemo(() => { if (!row) return []; @@ -67,7 +66,8 @@ const AlbumArtistsColumn = (props: ItemTableListInnerColumn) => { }; const SongArtistsColumn = (props: ItemTableListInnerColumn) => { - const row: Song | undefined = (props.data as (Song | undefined)[])[props.rowIndex]; + const row: Song | undefined = + (props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]) as Song | undefined; if (row) { return ( diff --git a/src/renderer/components/item-list/item-table-list/columns/count-column.tsx b/src/renderer/components/item-list/item-table-list/columns/count-column.tsx index 703e483b0..9c5b4ddb8 100644 --- a/src/renderer/components/item-list/item-table-list/columns/count-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/count-column.tsx @@ -6,9 +6,8 @@ import { } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; export const CountColumn = (props: ItemTableListInnerColumn) => { - const row: number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (typeof row === 'number') { return ( diff --git a/src/renderer/components/item-list/item-table-list/columns/date-column.tsx b/src/renderer/components/item-list/item-table-list/columns/date-column.tsx index e0d662229..6c02a402e 100644 --- a/src/renderer/components/item-list/item-table-list/columns/date-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/date-column.tsx @@ -30,9 +30,8 @@ const getDateTooltipLabel = (utcString: string) => { }; export const DateColumn = (props: ItemTableListInnerColumn) => { - const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (typeof row === 'string' && row) { return ( @@ -52,12 +51,11 @@ export const DateColumn = (props: ItemTableListInnerColumn) => { }; export const AbsoluteDateColumn = (props: ItemTableListInnerColumn) => { - const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (props.type === TableColumn.RELEASE_DATE) { - const item = (props.data as (any | undefined)[])[props.rowIndex]; + const item = rowItem as any; if (item && 'releaseDate' in item && item.releaseDate) { const releaseDate = item.releaseDate; const originalDate = @@ -115,9 +113,8 @@ export const AbsoluteDateColumn = (props: ItemTableListInnerColumn) => { }; export const RelativeDateColumn = (props: ItemTableListInnerColumn) => { - const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (typeof row === 'string') { return ( diff --git a/src/renderer/components/item-list/item-table-list/columns/default-column.tsx b/src/renderer/components/item-list/item-table-list/columns/default-column.tsx index 0f552b5fb..cf5cbcb62 100644 --- a/src/renderer/components/item-list/item-table-list/columns/default-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/default-column.tsx @@ -6,9 +6,8 @@ import { } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; export const DefaultColumn = (props: ItemTableListInnerColumn) => { - const row: any | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: any | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (typeof row === 'string') { return {row}; diff --git a/src/renderer/components/item-list/item-table-list/columns/duration-column.tsx b/src/renderer/components/item-list/item-table-list/columns/duration-column.tsx index aa0bcb55b..452ca3666 100644 --- a/src/renderer/components/item-list/item-table-list/columns/duration-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/duration-column.tsx @@ -8,9 +8,8 @@ import { } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; export const DurationColumn = (props: ItemTableListInnerColumn) => { - const row: number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (typeof row === 'number') { return ( diff --git a/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx b/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx index a41a1e577..783a96414 100644 --- a/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/favorite-column.tsx @@ -8,9 +8,8 @@ import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutatio import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; export const FavoriteColumn = (props: ItemTableListInnerColumn) => { - const row: boolean | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: boolean | undefined = rowItem?.[props.columns[props.columnIndex].id]; const isMutatingCreateFavorite = useIsMutatingCreateFavorite(); const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite(); @@ -31,7 +30,7 @@ export const FavoriteColumn = (props: ItemTableListInnerColumn) => { onClick={(event) => { event.stopPropagation(); event.preventDefault(); - const item = props.data[props.rowIndex] as ItemListItem; + const item = rowItem as ItemListItem; const rowId = props.internalState.extractRowId(item); const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onFavorite?.({ diff --git a/src/renderer/components/item-list/item-table-list/columns/genre-badge-column.tsx b/src/renderer/components/item-list/item-table-list/columns/genre-badge-column.tsx index b4229e95e..6d21d9302 100644 --- a/src/renderer/components/item-list/item-table-list/columns/genre-badge-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/genre-badge-column.tsx @@ -18,9 +18,8 @@ import { stringToColor } from '/@/shared/utils/string-to-color'; const MAX_GENRES = 4; const GenreBadgeColumn = (props: ItemTableListInnerColumn) => { - const row: Genre[] | undefined = (props.data as (Genre[] | undefined)[])[props.rowIndex]?.[ - 'genres' - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: Genre[] | undefined = (rowItem as any)?.genres; const genres = useMemo(() => { if (!row) return []; diff --git a/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx b/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx index 090eb15c4..6cd1cb9d4 100644 --- a/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/genre-column.tsx @@ -15,9 +15,8 @@ import { Text } from '/@/shared/components/text/text'; import { Genre } from '/@/shared/types/domain-types'; const GenreColumn = (props: ItemTableListInnerColumn) => { - const row: Genre[] | undefined = (props.data as (Genre[] | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: Genre[] | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; const genres = useMemo(() => { if (!row) return []; diff --git a/src/renderer/components/item-list/item-table-list/columns/image-column.tsx b/src/renderer/components/item-list/item-table-list/columns/image-column.tsx index 68228d71f..8eead5878 100644 --- a/src/renderer/components/item-list/item-table-list/columns/image-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/image-column.tsx @@ -20,8 +20,9 @@ import { Folder, LibraryItem } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; export const ImageColumn = (props: ItemTableListInnerColumn) => { - const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.id; - const item = props.data[props.rowIndex] as any; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: string | undefined = rowItem?.id; + const item = rowItem as any; const playButtonBehavior = usePlayButtonBehavior(); const internalState = (props as any).internalState; const [isHovered, setIsHovered] = useState(false); @@ -113,7 +114,7 @@ export const ImageColumn = (props: ItemTableListInnerColumn) => { ); } - if ((props.data[props.rowIndex] as unknown as Folder)?._itemType === LibraryItem.FOLDER) { + if ((rowItem as unknown as Folder)?._itemType === LibraryItem.FOLDER) { return ( diff --git a/src/renderer/components/item-list/item-table-list/columns/numeric-column.tsx b/src/renderer/components/item-list/item-table-list/columns/numeric-column.tsx index a029c1026..b8c6b601c 100644 --- a/src/renderer/components/item-list/item-table-list/columns/numeric-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/numeric-column.tsx @@ -6,9 +6,8 @@ import { } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; export const NumericColumn = (props: ItemTableListInnerColumn) => { - const row: number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (typeof row === 'number') { return {row}; diff --git a/src/renderer/components/item-list/item-table-list/columns/path-column.tsx b/src/renderer/components/item-list/item-table-list/columns/path-column.tsx index 4e5ff6a65..52f5ef289 100644 --- a/src/renderer/components/item-list/item-table-list/columns/path-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/path-column.tsx @@ -6,9 +6,8 @@ import { } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; export const PathColumn = (props: ItemTableListInnerColumn) => { - const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (typeof row === 'string' && row) { return ( diff --git a/src/renderer/components/item-list/item-table-list/columns/playlist-reorder-column.tsx b/src/renderer/components/item-list/item-table-list/columns/playlist-reorder-column.tsx index 6f098fe69..40189c8ea 100644 --- a/src/renderer/components/item-list/item-table-list/columns/playlist-reorder-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/playlist-reorder-column.tsx @@ -24,7 +24,9 @@ export const PlaylistReorderColumn = (props: ItemTableListInnerColumn) => { const { playlistId } = useParams() as { playlistId?: string }; const isHeaderEnabled = !!props.enableHeader; const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true; - const item = isDataRow ? props.data[props.rowIndex] : null; + const item = isDataRow + ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) + : null; const isPlaylistSong = props.itemType === LibraryItem.PLAYLIST_SONG; @@ -153,8 +155,8 @@ export const PlaylistReorderColumn = (props: ItemTableListInnerColumn) => { const isDragging = props.internalState ? isDraggingState : isDraggingLocal; const getValidDataItems = useCallback(() => { - return props.data.filter((d) => d !== null && (d as any).id); - }, [props.data]); + return props.internalState.getData().filter((d) => d !== null && (d as any).id); + }, [props.internalState]); const handleMoveUp = useCallback(() => { if (!item || !isDataRow || !isPlaylistSong || !playlistId) { diff --git a/src/renderer/components/item-list/item-table-list/columns/rating-column.tsx b/src/renderer/components/item-list/item-table-list/columns/rating-column.tsx index c95cc523b..c03a7cb94 100644 --- a/src/renderer/components/item-list/item-table-list/columns/rating-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/rating-column.tsx @@ -7,9 +7,8 @@ import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-r import { Rating } from '/@/shared/components/rating/rating'; export const RatingColumn = (props: ItemTableListInnerColumn) => { - const row: null | number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: null | number | undefined = rowItem?.[props.columns[props.columnIndex].id]; const isMutatingRating = useIsMutatingRating(); @@ -19,7 +18,7 @@ export const RatingColumn = (props: ItemTableListInnerColumn) => { { - const item = props.data[props.rowIndex] as ItemListItem; + const item = rowItem as ItemListItem; const rowId = props.internalState.extractRowId(item); const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onRating?.({ diff --git a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx index c364a6a8a..930c9bf2e 100644 --- a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx @@ -61,7 +61,7 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => { icon="arrowDownS" iconProps={{ color: 'muted', size: 'md' }} onClick={(e) => { - const item = data[rowIndex] as ItemListItem; + const item = (props.getRowItem?.(rowIndex) ?? data[rowIndex]) as ItemListItem; const rowId = internalState.extractRowId(item); const index = rowId ? internalState.findItemIndex(rowId) : -1; controls.onExpand?.({ @@ -87,7 +87,7 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => { const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => { const status = usePlayerStatus(); - const song = props.data[props.rowIndex] as QueueSong; + const song = (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) as QueueSong; const isActive = useIsActiveRow(song?.id, song?._uniqueId); const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING; diff --git a/src/renderer/components/item-list/item-table-list/columns/size-column.tsx b/src/renderer/components/item-list/item-table-list/columns/size-column.tsx index 81f5937d1..fb77d6005 100644 --- a/src/renderer/components/item-list/item-table-list/columns/size-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/size-column.tsx @@ -7,9 +7,8 @@ import { import { formatSizeString } from '/@/renderer/utils/format'; export const SizeColumn = (props: ItemTableListInnerColumn) => { - const row: number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (typeof row === 'number') { return ( diff --git a/src/renderer/components/item-list/item-table-list/columns/text-column.tsx b/src/renderer/components/item-list/item-table-list/columns/text-column.tsx index 595f15e21..fc2cc35ff 100644 --- a/src/renderer/components/item-list/item-table-list/columns/text-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/text-column.tsx @@ -10,9 +10,8 @@ import { } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; export const TextColumn = (props: ItemTableListInnerColumn) => { - const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (typeof row === 'string' && row) { return ( diff --git a/src/renderer/components/item-list/item-table-list/columns/title-column.tsx b/src/renderer/components/item-list/item-table-list/columns/title-column.tsx index 63082918b..4647294e4 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/title-column.tsx @@ -29,13 +29,12 @@ export const TitleColumn = (props: ItemTableListInnerColumn) => { }; function DefaultTitleColumn(props: ItemTableListInnerColumn) { - const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id]; if (typeof row === 'string') { - const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string); - const item = props.data[props.rowIndex] as any; + const path = getTitlePath(props.itemType, (rowItem as any).id as string); + const item = rowItem as any; const titleLinkProps = path ? { @@ -71,16 +70,15 @@ function DefaultTitleColumn(props: ItemTableListInnerColumn) { } function QueueSongTitleColumn(props: ItemTableListInnerColumn) { - const row: string | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id]; - const song = props.data[props.rowIndex] as QueueSong; + const song = rowItem as QueueSong; const isActive = useIsActiveRow(song?.id, song?._uniqueId); if (typeof row === 'string') { - const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string); - const item = props.data[props.rowIndex] as any; + const path = getTitlePath(props.itemType, (rowItem as any).id as string); + const item = rowItem as any; const titleLinkProps = path ? { diff --git a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx index 4dfb8164c..488c9a0a9 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx @@ -26,8 +26,9 @@ import { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => { - const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.id; - const item = props.data[props.rowIndex] as any; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: object | undefined = (rowItem as any)?.id; + const item = rowItem as any; const internalState = (props as any).internalState; const playButtonBehavior = usePlayButtonBehavior(); const [isHovered, setIsHovered] = useState(false); @@ -76,9 +77,9 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => { if (item && 'name' in item && 'imageUrl' in item && 'artists' in item) { const rowHeight = props.getRowHeight(props.rowIndex, props); - const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string); + const path = getTitlePath(props.itemType, (rowItem as any).id as string); - const item = props.data[props.rowIndex] as any; + const item = rowItem as any; const titleLinkProps = path ? { component: Link, @@ -156,10 +157,11 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => { }; export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => { - const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const row: object | undefined = rowItem as any; - const song = props.data[props.rowIndex] as QueueSong; - const item = props.data[props.rowIndex] as any; + const song = rowItem as QueueSong; + const item = rowItem as any; const internalState = (props as any).internalState; const playButtonBehavior = usePlayButtonBehavior(); const [isHovered, setIsHovered] = useState(false); @@ -209,9 +211,9 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => if (row && 'name' in row && 'imageUrl' in row && 'artists' in row) { const rowHeight = props.getRowHeight(props.rowIndex, props); - const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string); + const path = getTitlePath(props.itemType, (rowItem as any).id as string); - const item = props.data[props.rowIndex] as any; + const item = rowItem as any; const titleLinkProps = path ? { @@ -306,11 +308,11 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => ); } - if ((props.data[props.rowIndex] as unknown as Folder)?._itemType === LibraryItem.FOLDER) { + if ((rowItem as unknown as Folder)?._itemType === LibraryItem.FOLDER) { const rowHeight = props.getRowHeight(props.rowIndex, props); - const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string); + const path = getTitlePath(props.itemType, (rowItem as any).id as string); - const item = props.data[props.rowIndex] as any; + const item = rowItem as any; const textStyles = isActive ? { color: 'var(--theme-colors-primary)' } : {}; const titleLinkProps = path @@ -322,7 +324,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => } : {}; - const title = (props.data[props.rowIndex] as unknown as Folder)?.name; + const title = (rowItem as unknown as Folder)?.name; return ( { - const item = (props.data as (any | undefined)[])[props.rowIndex]; + const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex]; + const item = rowItem as any; if (item && 'releaseYear' in item && item.releaseYear !== null) { const releaseYear = item.releaseYear; @@ -29,9 +30,7 @@ export const YearColumn = (props: ItemTableListInnerColumn) => { } } - const row: number | undefined = (props.data as (any | undefined)[])[props.rowIndex]?.[ - props.columns[props.columnIndex].id - ]; + const row: number | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id]; if (row === null) { return ; diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation.ts b/src/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation.ts index bb2c5f2a7..a543339a8 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation.ts +++ b/src/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation.ts @@ -17,11 +17,14 @@ interface UseTableKeyboardNavigationProps { enableHeader: boolean; enableSelection: boolean; extractRowId: (item: unknown) => string | undefined; + getItem?: (index: number) => undefined | unknown; + getItemIndex?: (rowId: string) => number | undefined; getStateItem: (item: any) => ItemListStateItemWithRequiredProperties | null; hasRequiredStateItemProperties: ( item: unknown, ) => item is ItemListStateItemWithRequiredProperties; internalState: ItemListStateActions; + itemCount?: number; itemType: LibraryItem; parsedColumns: TableItemProps['columns']; pinnedRightColumnCount: number; @@ -45,9 +48,12 @@ export const useTableKeyboardNavigation = ({ enableHeader, enableSelection, extractRowId, + getItem, + getItemIndex, getStateItem, hasRequiredStateItemProperties, internalState, + itemCount, itemType, parsedColumns, pinnedRightColumnCount, @@ -69,23 +75,26 @@ export const useTableKeyboardNavigation = ({ const selected = internalState.getSelected(); const validSelected = selected.filter(hasRequiredStateItemProperties); let currentIndex = -1; + const totalCount = itemCount ?? data.length; if (validSelected.length > 0) { const lastSelected = validSelected[validSelected.length - 1]; - currentIndex = data.findIndex( - (d) => extractRowId(d) === extractRowId(lastSelected), - ); + const rowId = extractRowId(lastSelected); + if (rowId) { + currentIndex = + getItemIndex?.(rowId) ?? data.findIndex((d) => extractRowId(d) === rowId); + } } let newIndex = 0; if (currentIndex !== -1) { newIndex = e.key === 'ArrowDown' - ? Math.min(currentIndex + 1, data.length - 1) + ? Math.min(currentIndex + 1, totalCount - 1) : Math.max(currentIndex - 1, 0); } - const newItem: any = data[newIndex]; + const newItem: any = getItem ? getItem(newIndex) : data[newIndex]; if (!newItem) return; const newItemListItem = getStateItem(newItem); @@ -118,7 +127,7 @@ export const useTableKeyboardNavigation = ({ cellPadding, columns: parsedColumns, controls: {} as ItemControls, - data: enableHeader ? [null, ...data] : data, + data: enableHeader ? [null] : [], enableAlternateRowColors: false, enableExpansion: false, enableHeader, @@ -127,6 +136,12 @@ export const useTableKeyboardNavigation = ({ enableSelection, enableVerticalBorders: false, getRowHeight: () => DEFAULT_ROW_HEIGHT, + getRowItem: (rowIndex: number) => { + if (!getItem) return undefined; + if (enableHeader && rowIndex === 0) return null; + const dataIndex = enableHeader ? rowIndex - 1 : rowIndex; + return getItem(dataIndex); + }, internalState: {} as ItemListStateActions, itemType, playerContext, @@ -174,6 +189,8 @@ export const useTableKeyboardNavigation = ({ calculateScrollTopForIndex, cellPadding, data, + getItem, + getItemIndex, DEFAULT_ROW_HEIGHT, enableHeader, enableSelection, @@ -181,6 +198,7 @@ export const useTableKeyboardNavigation = ({ getStateItem, hasRequiredStateItemProperties, internalState, + itemCount, itemType, parsedColumns, pinnedRightColumnCount, diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index aef62a76f..28a541072 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -84,7 +84,9 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => { const isHeaderEnabled = !!props.enableHeader; const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true; - const item = isDataRow ? props.data[props.rowIndex] : null; + const item = isDataRow + ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) + : null; const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item; const itemType = (item as unknown as { _itemType?: LibraryItem })?._itemType || props.itemType; @@ -585,7 +587,9 @@ export const TableColumnTextContainer = ( const containerRef = useRef(null); const isDataRow = props.enableHeader ? props.rowIndex > 0 : true; const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex; - const item = isDataRow ? props.data[props.rowIndex] : null; + const item = isDataRow + ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) + : null; const itemRowId = item && typeof item === 'object' && 'id' in item ? props.internalState.extractRowId(item) @@ -736,7 +740,9 @@ export const TableColumnContainer = ( const containerRef = useRef(null); const isDataRow = props.enableHeader ? props.rowIndex > 0 : true; const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex; - const item = isDataRow ? props.data[props.rowIndex] : null; + const item = isDataRow + ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) + : null; const itemRowId = item && typeof item === 'object' && 'id' in item ? props.internalState.extractRowId(item) diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index c0304434d..8b26c17f8 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -116,6 +116,7 @@ interface VirtualizedTableGridProps { enableScrollShadow: boolean; enableSelection: boolean; enableVerticalBorders: boolean; + getItem?: (index: number) => undefined | unknown; getRowHeight: (index: number, cellProps: TableItemProps) => number; groups?: TableGroupHeader[]; headerHeight: number; @@ -159,6 +160,7 @@ const VirtualizedTableGrid = ({ enableScrollShadow, enableSelection, enableVerticalBorders, + getItem, getRowHeight, groups, headerHeight, @@ -284,6 +286,44 @@ const VirtualizedTableGrid = ({ [enableHeader, groupHeaderInfoByRowIndex, groupHeaderRowIndexes, groups], ); + const getRowItem = useCallback( + (rowIndex: number): null | undefined | unknown => { + // Header row + if (enableHeader && rowIndex === 0) return null; + // Group header rows are represented as null in the row model + if (groupHeaderInfoByRowIndex?.has(rowIndex)) return null; + + if (!groups || groups.length === 0) { + const dataIndex = enableHeader ? rowIndex - 1 : rowIndex; + return getItem ? getItem(dataIndex) : dataWithGroups[rowIndex]; + } + + const headerOffset = enableHeader ? 1 : 0; + + // Count group header rows strictly before this rowIndex (upperBound on groupHeaderRowIndexes) + let lo = 0; + let hi = groupHeaderRowIndexes.length; + const target = rowIndex - 1; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (groupHeaderRowIndexes[mid] <= target) lo = mid + 1; + else hi = mid; + } + const groupHeadersBefore = lo; + + const dataIndex = rowIndex - headerOffset - groupHeadersBefore; + return getItem ? getItem(dataIndex) : undefined; + }, + [ + dataWithGroups, + enableHeader, + getItem, + groupHeaderInfoByRowIndex, + groupHeaderRowIndexes, + groups, + ], + ); + const stableConfigProps = useMemo( () => ({ cellPadding, @@ -317,6 +357,7 @@ const VirtualizedTableGrid = ({ data: dataWithGroups, getAdjustedRowIndex, getGroupRenderData, + getRowItem, groupHeaderInfoByRowIndex, pinnedLeftColumnCount, pinnedLeftColumnWidths, @@ -327,6 +368,7 @@ const VirtualizedTableGrid = ({ [ calculatedColumnWidths, dataWithGroups, + getRowItem, getAdjustedRowIndex, getGroupRenderData, groupHeaderInfoByRowIndex, @@ -724,6 +766,7 @@ export interface TableItemProps { getAdjustedRowIndex?: (rowIndex: number) => number; getGroupRenderData?: () => unknown[]; getRowHeight: (index: number, cellProps: TableItemProps) => number; + getRowItem?: (rowIndex: number) => null | undefined | unknown; groupHeaderInfoByRowIndex?: Map; groups?: TableGroupHeader[]; internalState: ItemListStateActions; @@ -759,6 +802,8 @@ interface ItemTableListProps { enableStickyGroupRows?: boolean; enableStickyHeader?: boolean; enableVerticalBorders?: boolean; + getItem?: (index: number) => undefined | unknown; + getItemIndex?: (rowId: string) => number | undefined; getRowId?: ((item: unknown) => string) | string; groups?: TableGroupHeader[]; headerHeight?: number; @@ -767,6 +812,7 @@ interface ItemTableListProps { to: number; type: 'index' | 'offset'; }; + itemCount?: number; itemType: LibraryItem; onColumnReordered?: ( columnIdFrom: TableColumn, @@ -802,10 +848,13 @@ const BaseItemTableList = ({ enableStickyGroupRows = false, enableStickyHeader = false, enableVerticalBorders = false, + getItem, + getItemIndex, getRowId, groups, headerHeight = 40, initialTop, + itemCount, itemType, onColumnReordered, onColumnResized, @@ -818,7 +867,8 @@ const BaseItemTableList = ({ startRowIndex, }: ItemTableListProps) => { const tableId = useId(); - const totalItemCount = enableHeader ? data.length + 1 : data.length; + const baseItemCount = itemCount ?? data.length; + const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount; const [centerContainerWidth, setCenterContainerWidth] = useState(0); const [totalContainerWidth, setTotalContainerWidth] = useState(0); @@ -836,12 +886,29 @@ const BaseItemTableList = ({ }); const playerContext = usePlayer(); - const { dataWithGroups, groupHeaderRowCount } = useTableRowModel({ + const { + dataWithGroups: dataWithGroupsFromModel, + groupHeaderRowCount: groupHeaderRowCountFromModel, + } = useTableRowModel({ data, enableHeader, groups, }); + const shouldUseAccessor = typeof getItem === 'function' && typeof itemCount === 'number'; + + // Avoid constructing a massive row-model array for infinite lists. + // Cell renderers use `getRowItem` accessor when provided. + const dataWithGroups = useMemo<(null | unknown)[]>(() => { + if (!shouldUseAccessor) return dataWithGroupsFromModel; + return enableHeader ? [null] : []; + }, [dataWithGroupsFromModel, enableHeader, shouldUseAccessor]); + + const groupHeaderRowCount = useMemo(() => { + if (!shouldUseAccessor) return groupHeaderRowCountFromModel; + return groups?.length ? groups.length : 0; + }, [groupHeaderRowCountFromModel, groups, shouldUseAccessor]); + const pinnedRowCount = enableHeader ? 1 : 0; // Group headers are inserted at specific indexes, so they add to the total row count @@ -1007,8 +1074,9 @@ const BaseItemTableList = ({ }); const getDataFn = useCallback(() => { - return dataWithGroups; - }, [dataWithGroups]); + // For infinite lists, callers should pass `data` as the currently loaded items only. + return data; + }, [data]); const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]); @@ -1041,9 +1109,12 @@ const BaseItemTableList = ({ enableHeader, enableSelection, extractRowId, + getItem, + getItemIndex, getStateItem, hasRequiredStateItemProperties, internalState, + itemCount: baseItemCount, itemType, parsedColumns, pinnedRightColumnCount, @@ -1499,6 +1570,7 @@ const BaseItemTableList = ({ enableScrollShadow={enableScrollShadow} enableSelection={enableSelection} enableVerticalBorders={enableVerticalBorders} + getItem={getItem} getRowHeight={getRowHeight} groups={groups} headerHeight={headerHeight} diff --git a/src/renderer/features/albums/components/album-list-infinite-grid.tsx b/src/renderer/features/albums/components/album-list-infinite-grid.tsx index bd8ef4d67..db50474b7 100644 --- a/src/renderer/features/albums/components/album-list-infinite-grid.tsx +++ b/src/renderer/features/albums/components/album-list-infinite-grid.tsx @@ -36,15 +36,16 @@ export const AlbumListInfiniteGrid = ({ const listQueryFn = api.controller.getAlbumList; - const { data, onRangeChanged } = useItemListInfiniteLoader({ - eventKey: ItemListKey.ALBUM, - itemsPerPage, - itemType: LibraryItem.ALBUM, - listCountQuery, - listQueryFn, - query, - serverId, - }); + const { dataVersion, getItem, getItemIndex, itemCount, loadedItems, onRangeChanged } = + useItemListInfiniteLoader({ + eventKey: ItemListKey.ALBUM, + itemsPerPage, + itemType: LibraryItem.ALBUM, + listCountQuery, + listQueryFn, + query, + serverId, + }); const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: saveScrollOffset, @@ -54,13 +55,17 @@ export const AlbumListInfiniteGrid = ({ return (