diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle.ts b/src/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle.ts index 9d304bec0..1f880ba93 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle.ts +++ b/src/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle.ts @@ -4,11 +4,15 @@ import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/i import { ItemListHandle } from '/@/renderer/components/item-list/types'; interface UseTableImperativeHandleProps { + autoScrollToActiveRow: boolean; enableHeader: boolean; handleRef: React.RefObject; internalState: ItemListStateActions; ref?: React.Ref; - scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void; + scrollToTableIndex: ( + index: number, + options?: { align?: 'bottom' | 'center' | 'top'; followActiveRow?: boolean }, + ) => void; scrollToTableOffset: (offset: number) => void; } @@ -16,6 +20,7 @@ interface UseTableImperativeHandleProps { * Hook to set up the imperative handle for ItemTableList, providing scroll methods and internal state. */ export const useTableImperativeHandle = ({ + autoScrollToActiveRow, enableHeader, handleRef, internalState, @@ -27,13 +32,22 @@ export const useTableImperativeHandle = ({ () => ({ internalState, scrollToIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => { - scrollToTableIndex(enableHeader ? index + 1 : index, options); + scrollToTableIndex(enableHeader ? index + 1 : index, { + ...options, + followActiveRow: autoScrollToActiveRow, + }); }, scrollToOffset: (offset: number) => { scrollToTableOffset(offset); }, }), - [enableHeader, internalState, scrollToTableIndex, scrollToTableOffset], + [ + autoScrollToActiveRow, + enableHeader, + internalState, + scrollToTableIndex, + scrollToTableOffset, + ], ); useImperativeHandle(ref, () => imperativeHandle); 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 a543339a8..de75e7375 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 @@ -4,21 +4,17 @@ import { ItemListStateActions, ItemListStateItemWithRequiredProperties, } from '/@/renderer/components/item-list/helpers/item-list-state'; -import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list'; -import { ItemControls } from '/@/renderer/components/item-list/types'; -import { PlayerContext } from '/@/renderer/features/player/context/player-context'; import { LibraryItem } from '/@/shared/types/domain-types'; interface UseTableKeyboardNavigationProps { calculateScrollTopForIndex: (index: number) => number; - cellPadding: TableItemProps['cellPadding']; data: unknown[]; - DEFAULT_ROW_HEIGHT: number; enableHeader: boolean; enableSelection: boolean; extractRowId: (item: unknown) => string | undefined; getItem?: (index: number) => undefined | unknown; getItemIndex?: (rowId: string) => number | undefined; + getRowHeightAtIndex: (index: number) => number; getStateItem: (item: any) => ItemListStateItemWithRequiredProperties | null; hasRequiredStateItemProperties: ( item: unknown, @@ -26,15 +22,10 @@ interface UseTableKeyboardNavigationProps { internalState: ItemListStateActions; itemCount?: number; itemType: LibraryItem; - parsedColumns: TableItemProps['columns']; pinnedRightColumnCount: number; pinnedRightColumnRef: React.RefObject; - playerContext: PlayerContext; - rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined; rowRef: React.RefObject; scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void; - size: TableItemProps['size']; - tableId: string; } /** @@ -42,28 +33,21 @@ interface UseTableKeyboardNavigationProps { */ export const useTableKeyboardNavigation = ({ calculateScrollTopForIndex, - cellPadding, data, - DEFAULT_ROW_HEIGHT, enableHeader, enableSelection, extractRowId, getItem, getItemIndex, + getRowHeightAtIndex, getStateItem, hasRequiredStateItemProperties, internalState, itemCount, - itemType, - parsedColumns, pinnedRightColumnCount, pinnedRightColumnRef, - playerContext, - rowHeight, rowRef, scrollToTableIndex, - size, - tableId, }: UseTableKeyboardNavigationProps) => { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -122,42 +106,7 @@ export const useTableKeyboardNavigation = ({ const viewportBottom = viewportTop + viewportHeight; const rowTop = calculateScrollTopForIndex(gridIndex); - const adjustedIndex = enableHeader ? Math.max(0, newIndex - 1) : newIndex; - const mockCellProps: TableItemProps = { - cellPadding, - columns: parsedColumns, - controls: {} as ItemControls, - data: enableHeader ? [null] : [], - enableAlternateRowColors: false, - enableExpansion: false, - enableHeader, - enableHorizontalBorders: false, - enableRowHoverHighlight: false, - 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, - size, - tableId, - }; - - let calculatedRowHeight: number; - if (typeof rowHeight === 'number') { - calculatedRowHeight = rowHeight; - } else if (typeof rowHeight === 'function') { - calculatedRowHeight = rowHeight(adjustedIndex, mockCellProps); - } else { - calculatedRowHeight = DEFAULT_ROW_HEIGHT; - } - + const calculatedRowHeight = getRowHeightAtIndex(gridIndex); const rowBottom = rowTop + calculatedRowHeight; // Check if row is fully visible within viewport @@ -187,28 +136,21 @@ export const useTableKeyboardNavigation = ({ }, [ calculateScrollTopForIndex, - cellPadding, data, getItem, getItemIndex, - DEFAULT_ROW_HEIGHT, enableHeader, enableSelection, extractRowId, + getRowHeightAtIndex, getStateItem, hasRequiredStateItemProperties, internalState, itemCount, - itemType, - parsedColumns, pinnedRightColumnCount, pinnedRightColumnRef, - playerContext, - rowHeight, rowRef, scrollToTableIndex, - size, - tableId, ], ); diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index.ts b/src/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index.ts index 6390cbecf..998322dce 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index.ts +++ b/src/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index.ts @@ -1,101 +1,45 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { TableItemProps } from '../item-table-list'; -import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state'; -import { ItemControls } from '/@/renderer/components/item-list/types'; -import { PlayerContext } from '/@/renderer/features/player/context/player-context'; -import { LibraryItem } from '/@/shared/types/domain-types'; +export type TableScrollToIndexOptions = { + align?: 'bottom' | 'center' | 'top'; + followActiveRow?: boolean; +}; export const useTableScrollToIndex = ({ - cellPadding, - columns, - data, - enableAlternateRowColors, - enableExpansion, + albumGroupContentHeights, + autoScrollToActiveRow, enableHeader, - enableHorizontalBorders, - enableRowHoverHighlight, - enableSelection, - enableVerticalBorders, - itemType, + getRowHeight, + hasAlbumGroupColumn, pinnedLeftColumnRef, pinnedRightColumnRef, - playerContext, - rowHeight, + pinnedRowCount, rowRef, - size, - tableId, + scrollCellProps, }: { - cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; - columns: TableItemProps['columns']; - data: unknown[]; - enableAlternateRowColors: boolean; - enableExpansion: boolean; + albumGroupContentHeights: Map; + autoScrollToActiveRow: boolean; enableHeader: boolean; - enableHorizontalBorders: boolean; - enableRowHoverHighlight: boolean; - enableSelection: boolean; - enableVerticalBorders: boolean; - itemType: LibraryItem; + getRowHeight: (index: number, cellProps: TableItemProps) => number; + hasAlbumGroupColumn: boolean; pinnedLeftColumnRef: React.RefObject; pinnedRightColumnRef: React.RefObject; - playerContext: PlayerContext; - rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined; + pinnedRowCount: number; rowRef: React.RefObject; - size: 'compact' | 'default' | 'large'; - tableId: string; + scrollCellProps: TableItemProps; }) => { - const DEFAULT_ROW_HEIGHT = useMemo(() => { - return size === 'compact' ? 40 : size === 'large' ? 88 : 64; - }, [size]); - - const mockCellPropsBase = useMemo( - () => ({ - cellPadding, - columns, - controls: {} as ItemControls, - data: enableHeader ? [null, ...data] : data, - enableAlternateRowColors, - enableExpansion, - enableHeader, - enableHorizontalBorders, - enableRowHoverHighlight, - enableSelection, - enableVerticalBorders, - getRowHeight: () => DEFAULT_ROW_HEIGHT, - internalState: {} as ItemListStateActions, - itemType, - playerContext, - size, - tableId, - }), - [ - DEFAULT_ROW_HEIGHT, - cellPadding, - columns, - data, - enableAlternateRowColors, - enableExpansion, - enableHeader, - enableHorizontalBorders, - enableRowHoverHighlight, - enableSelection, - enableVerticalBorders, - itemType, - playerContext, - size, - tableId, - ], - ); + const isProgrammaticScrollRef = useRef(false); + const userInterruptedFollowRef = useRef(false); + const pendingFollowScrollRef = useRef(null); const getRowHeightAtIndex = useCallback( - (index: number) => { - if (typeof rowHeight === 'number') return rowHeight; - if (typeof rowHeight === 'function') return rowHeight(index, mockCellPropsBase); - return DEFAULT_ROW_HEIGHT; - }, - [DEFAULT_ROW_HEIGHT, mockCellPropsBase, rowHeight], + (index: number) => getRowHeight(index, scrollCellProps), + [getRowHeight, scrollCellProps], ); const scrollToTableOffset = useCallback( @@ -110,6 +54,8 @@ export const useTableScrollToIndex = ({ const behavior = 'instant'; + isProgrammaticScrollRef.current = true; + if (mainContainer) { mainContainer.scrollTo({ behavior, top: offset }); } @@ -119,37 +65,38 @@ export const useTableScrollToIndex = ({ if (pinnedRightContainer) { pinnedRightContainer.scrollTo({ behavior, top: offset }); } + + requestAnimationFrame(() => { + isProgrammaticScrollRef.current = false; + }); }, [pinnedLeftColumnRef, pinnedRightColumnRef, rowRef], ); + const scrollRowOffset = enableHeader ? pinnedRowCount : 0; + const calculateScrollTopForIndex = useCallback( (index: number) => { - const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index; let scrollTop = 0; - for (let i = 0; i < adjustedIndex; i++) { + for (let i = scrollRowOffset; i < index; i++) { scrollTop += getRowHeightAtIndex(i); } return scrollTop; }, - [enableHeader, getRowHeightAtIndex], + [getRowHeightAtIndex, scrollRowOffset], ); - const scrollToTableIndex = useCallback( - (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => { + const applyScrollToIndex = useCallback( + (index: number, options?: TableScrollToIndexOptions) => { const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined; if (!mainContainer) return; const viewportHeight = mainContainer.clientHeight; const align = options?.align || 'top'; - // Calculate the base scroll offset (top of the row) let offset = calculateScrollTopForIndex(index); - - // Calculate row height for the target index - const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index; - const targetRowHeight = getRowHeightAtIndex(adjustedIndex); + const targetRowHeight = getRowHeightAtIndex(index); if (align === 'center') { offset = offset - viewportHeight / 2 + targetRowHeight / 2; @@ -160,22 +107,64 @@ export const useTableScrollToIndex = ({ offset = Math.max(0, offset); scrollToTableOffset(offset); }, - [ - calculateScrollTopForIndex, - enableHeader, - getRowHeightAtIndex, - rowRef, - scrollToTableOffset, - ], + [calculateScrollTopForIndex, getRowHeightAtIndex, rowRef, scrollToTableOffset], ); + const scrollToTableIndex = useCallback( + (index: number, options?: TableScrollToIndexOptions) => { + const shouldFollow = autoScrollToActiveRow && options?.followActiveRow; + if (shouldFollow) { + pendingFollowScrollRef.current = { index, options }; + userInterruptedFollowRef.current = false; + } + + applyScrollToIndex(index, options); + }, + [applyScrollToIndex, autoScrollToActiveRow], + ); + + useEffect(() => { + const viewport = rowRef.current?.childNodes[0] as HTMLDivElement | undefined; + if (!viewport) return; + + const handleUserScroll = () => { + if (isProgrammaticScrollRef.current) return; + + userInterruptedFollowRef.current = true; + pendingFollowScrollRef.current = null; + }; + + viewport.addEventListener('scroll', handleUserScroll, { passive: true }); + + return () => viewport.removeEventListener('scroll', handleUserScroll); + }, [rowRef]); + + useEffect(() => { + if (!autoScrollToActiveRow || !hasAlbumGroupColumn) return; + + const pending = pendingFollowScrollRef.current; + if (!pending || userInterruptedFollowRef.current) return; + if (albumGroupContentHeights.size === 0) return; + + const timeout = window.setTimeout(() => { + if (userInterruptedFollowRef.current) return; + + const currentPending = pendingFollowScrollRef.current; + if (!currentPending) return; + + applyScrollToIndex(currentPending.index, currentPending.options); + }, 50); + + return () => window.clearTimeout(timeout); + }, [albumGroupContentHeights, applyScrollToIndex, autoScrollToActiveRow, hasAlbumGroupColumn]); + return useMemo( () => ({ calculateScrollTopForIndex, - DEFAULT_ROW_HEIGHT, + getRowHeightAtIndex, scrollToTableIndex, scrollToTableOffset, }), - [calculateScrollTopForIndex, DEFAULT_ROW_HEIGHT, scrollToTableIndex, scrollToTableOffset], + [calculateScrollTopForIndex, getRowHeightAtIndex, scrollToTableIndex, scrollToTableOffset], ); }; 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 8842933c6..60c59f2f6 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 @@ -816,6 +816,7 @@ export interface TableItemProps { interface ItemTableListProps { activeRowId?: string; autoFitColumns?: boolean; + autoScrollToActiveRow?: boolean; CellComponent?: JSXElementConstructor>; cellPadding?: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; columns: ItemTableListColumnConfig[]; @@ -1255,6 +1256,7 @@ ItemTableListStickyUI.displayName = 'ItemTableListStickyUI'; const BaseItemTableList = ({ activeRowId, autoFitColumns = false, + autoScrollToActiveRow = false, CellComponent = ItemTableListColumn, cellPadding = 'sm', columns, @@ -1411,47 +1413,10 @@ const BaseItemTableList = ({ onScrollEndRef.current = onScrollEnd; }, [onScrollEnd]); - const { - calculateScrollTopForIndex, - DEFAULT_ROW_HEIGHT, - scrollToTableIndex, - scrollToTableOffset, - } = useTableScrollToIndex({ - cellPadding, - columns: parsedColumns, - data, - enableAlternateRowColors, - enableExpansion, - enableHeader, - enableHorizontalBorders, - enableRowHoverHighlight, - enableSelection, - enableVerticalBorders, - itemType, - pinnedLeftColumnRef, - pinnedRightColumnRef, - playerContext, - rowHeight, - rowRef, - size, - tableId, - }); - - useTablePaneSync({ - enableDrag, - enableDragScroll, - enableHeader, - handleRef, - onScrollEndRef, - pinnedLeftColumnCount, - pinnedLeftColumnRef, - pinnedRightColumnCount, - pinnedRightColumnRef, - pinnedRowRef, - rowRef, - scrollContainerRef, - scrollShadowStore, - }); + const hasAlbumGroupColumn = useMemo( + () => parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP), + [parsedColumns], + ); const getRowHeight = useCallback( (index: number, cellProps: TableItemProps) => { @@ -1515,6 +1480,99 @@ const BaseItemTableList = ({ [albumGroupImageSize, enableHeader, headerHeight, rowHeight, pinnedRowCount, size], ); + const scrollCellProps = useMemo( + () => ({ + albumGroupContentHeights, + albumGroupImageSize, + cellPadding, + columns: parsedColumns, + controls: {} as ItemControls, + data: dataWithGroups, + enableAlternateRowColors, + enableExpansion, + enableHeader, + enableHorizontalBorders, + enableRowHoverHighlight, + enableSelection, + enableVerticalBorders, + getRowHeight, + getRowItem: (rowIndex: number) => { + if (shouldUseAccessor && getItem) { + if (enableHeader && rowIndex === 0) { + return null; + } + + const dataIndex = enableHeader ? rowIndex - 1 : rowIndex; + return getItem(dataIndex); + } + + return dataWithGroups[rowIndex]; + }, + hasAlbumGroupColumn, + internalState: {} as ItemListStateActions, + itemType, + playerContext, + size, + tableId, + }), + [ + albumGroupContentHeights, + albumGroupImageSize, + cellPadding, + dataWithGroups, + enableAlternateRowColors, + enableExpansion, + enableHeader, + enableHorizontalBorders, + enableRowHoverHighlight, + enableSelection, + enableVerticalBorders, + getItem, + getRowHeight, + hasAlbumGroupColumn, + itemType, + parsedColumns, + playerContext, + shouldUseAccessor, + size, + tableId, + ], + ); + + const { + calculateScrollTopForIndex, + getRowHeightAtIndex, + scrollToTableIndex, + scrollToTableOffset, + } = useTableScrollToIndex({ + albumGroupContentHeights, + autoScrollToActiveRow, + enableHeader, + getRowHeight, + hasAlbumGroupColumn, + pinnedLeftColumnRef, + pinnedRightColumnRef, + pinnedRowCount, + rowRef, + scrollCellProps, + }); + + useTablePaneSync({ + enableDrag, + enableDragScroll, + enableHeader, + handleRef, + onScrollEndRef, + pinnedLeftColumnCount, + pinnedLeftColumnRef, + pinnedRightColumnCount, + pinnedRightColumnRef, + pinnedRowRef, + rowRef, + scrollContainerRef, + scrollShadowStore, + }); + // Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook) const getRowHeightWrapper = useCallback( (index: number) => { @@ -1574,28 +1632,21 @@ const BaseItemTableList = ({ const { handleKeyDown } = useTableKeyboardNavigation({ calculateScrollTopForIndex, - cellPadding, data, - DEFAULT_ROW_HEIGHT, enableHeader, enableSelection, extractRowId, getItem, getItemIndex, + getRowHeightAtIndex, getStateItem, hasRequiredStateItemProperties, internalState, itemCount: baseItemCount, - itemType, - parsedColumns, pinnedRightColumnCount, pinnedRightColumnRef, - playerContext, - rowHeight, rowRef, scrollToTableIndex, - size, - tableId, }); useTableInitialScroll({ @@ -1606,6 +1657,7 @@ const BaseItemTableList = ({ }); useTableImperativeHandle({ + autoScrollToActiveRow, enableHeader, handleRef, internalState, diff --git a/src/renderer/features/now-playing/components/play-queue.tsx b/src/renderer/features/now-playing/components/play-queue.tsx index 11cf3d090..dfa6ab0c4 100644 --- a/src/renderer/features/now-playing/components/play-queue.tsx +++ b/src/renderer/features/now-playing/components/play-queue.tsx @@ -179,6 +179,7 @@ export const PlayQueue = forwardRef(