From 646eb4a3b0597fc5fc6dedb9e62c43d93e35e8f6 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 22 Nov 2025 22:27:45 -0800 Subject: [PATCH] add double click play to album detail - add mediaPlayByIndex - add index property to item list controls args - add overrides to item list controls --- .../item-list/helpers/item-list-controls.ts | 7 +++-- .../item-list/helpers/item-list-state.ts | 4 ++- .../item-grid-list/item-grid-list.tsx | 4 ++- .../columns/actions-column.tsx | 6 +++- .../columns/favorite-column.tsx | 6 +++- .../item-table-list/columns/rating-column.tsx | 6 +++- .../columns/row-index-column.tsx | 16 +++++++--- .../item-table-list/columns/title-column.tsx | 4 ++- .../columns/title-combined-column.tsx | 4 ++- .../item-table-list-column.tsx | 18 +++++++++++ .../item-table-list/item-table-list.tsx | 3 ++ src/renderer/components/item-list/types.ts | 16 ++++++---- .../components/album-detail-content.tsx | 31 +++++++++++++++++-- .../player/context/player-context.tsx | 11 +++++++ src/renderer/store/player.store.ts | 17 ++++++++++ 15 files changed, 131 insertions(+), 22 deletions(-) diff --git a/src/renderer/components/item-list/helpers/item-list-controls.ts b/src/renderer/components/item-list/helpers/item-list-controls.ts index cea290af0..37e501f71 100644 --- a/src/renderer/components/item-list/helpers/item-list-controls.ts +++ b/src/renderer/components/item-list/helpers/item-list-controls.ts @@ -16,6 +16,7 @@ interface UseDefaultItemListControlsArgs { edge: 'bottom' | 'left' | 'right' | 'top' | null, ) => void; onColumnResized?: (columnId: TableColumn, width: number) => void; + overrides?: Partial; } const itemTypeMapping = { @@ -32,7 +33,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs const player = usePlayer(); const navigate = useNavigate(); - const { onColumnReordered, onColumnResized } = args || {}; + const { onColumnReordered, onColumnResized, overrides } = args || {}; const controls: ItemControls = useMemo(() => { return { @@ -326,8 +327,10 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs player.setRating(item._serverId, [item.id], apiItemType, newRating); }, + + ...overrides, }; - }, [onColumnReordered, onColumnResized, navigate, player]); + }, [onColumnReordered, onColumnResized, overrides, navigate, player]); return controls; }; diff --git a/src/renderer/components/item-list/helpers/item-list-state.ts b/src/renderer/components/item-list/helpers/item-list-state.ts index 1d3607bac..c1e08dde2 100644 --- a/src/renderer/components/item-list/helpers/item-list-state.ts +++ b/src/renderer/components/item-list/helpers/item-list-state.ts @@ -438,7 +438,9 @@ export const useItemListState = ( ); const getData = useCallback(() => { - return getDataFn ? getDataFn() : []; + const data = getDataFn ? getDataFn() : []; + // Filter out null/undefined values (e.g., group header rows) + return data.filter((d) => d != null); }, [getDataFn]); const findItemIndex = useCallback( 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 3c0d2aea6..6ac433800 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 @@ -255,6 +255,7 @@ export interface ItemGridListProps { onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void; onScroll?: (offset: number, direction: 'down' | 'up') => void; onScrollEnd?: (offset: number, direction: 'down' | 'up') => void; + overrideControls?: Partial; ref?: Ref; rows?: ItemCardProps['rows']; } @@ -272,6 +273,7 @@ export const ItemGridList = ({ onRangeChanged, onScroll, onScrollEnd, + overrideControls, ref, rows, }: ItemGridListProps) => { @@ -366,7 +368,7 @@ export const ItemGridList = ({ throttledSetTableMeta(containerWidth, data.length, setTableMeta); }, [containerWidth, data.length, throttledSetTableMeta]); - const controls = useDefaultItemListControls(); + const controls = useDefaultItemListControls({ overrides: overrideControls }); const scrollToIndex = useCallback( ( diff --git a/src/renderer/components/item-list/item-table-list/columns/actions-column.tsx b/src/renderer/components/item-list/item-table-list/columns/actions-column.tsx index 2256e2931..ebebc864c 100644 --- a/src/renderer/components/item-list/item-table-list/columns/actions-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/actions-column.tsx @@ -12,10 +12,14 @@ export const ActionsColumn = (props: ItemTableListInnerColumn) => { event.stopPropagation(); event.preventDefault(); if (row !== undefined) { + const item = row as ItemListItem; + const rowId = props.internalState.extractRowId(item); + const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onMore?.({ event, + index, internalState: props.internalState, - item: row as ItemListItem, + item, itemType: props.itemType, }); } 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 34e0cabb6..151197b3e 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 @@ -24,11 +24,15 @@ export const FavoriteColumn = (props: ItemTableListInnerColumn) => { onClick={(event) => { event.stopPropagation(); event.preventDefault(); + const item = props.data[props.rowIndex] as ItemListItem; + const rowId = props.internalState.extractRowId(item); + const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onFavorite?.({ event, favorite: !row, + index, internalState: props.internalState, - item: props.data[props.rowIndex] as ItemListItem, + item, itemType: props.itemType, }); }} 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 9e261215b..e9f4335fc 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 @@ -16,10 +16,14 @@ export const RatingColumn = (props: ItemTableListInnerColumn) => { { + const item = props.data[props.rowIndex] as ItemListItem; + const rowId = props.internalState.extractRowId(item); + const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onRating?.({ event: null, + index, internalState: props.internalState, - item: props.data[props.rowIndex] as ItemListItem, + item, itemType: props.itemType, rating, }); 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 af0518d18..f0094e736 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 @@ -20,7 +20,9 @@ export const RowIndexColumn = (props: ItemTableListInnerColumn) => { const { itemType } = props; switch (itemType) { + case LibraryItem.PLAYLIST_SONG: case LibraryItem.QUEUE_SONG: + case LibraryItem.SONG: return ; default: return ; @@ -54,14 +56,18 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => { className={clsx(styles.expand, 'hover-only')} icon="arrowDownS" iconProps={{ color: 'muted', size: 'md' }} - onClick={(e) => + onClick={(e) => { + const item = data[rowIndex] as ItemListItem; + const rowId = internalState.extractRowId(item); + const index = rowId ? internalState.findItemIndex(rowId) : -1; controls.onExpand?.({ event: e, + index, internalState, - item: data[rowIndex] as ItemListItem, + item, itemType, - }) - } + }); + }} size="xs" variant="subtle" /> @@ -78,7 +84,7 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => { const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => { const status = usePlayerStatus(); const song = props.data[props.rowIndex] as QueueSong; - const isActive = props.activeRowId === song?._uniqueId; + const isActive = props.activeRowId === song?.id || props.activeRowId === song?._uniqueId; let adjustedRowIndex = props.adjustedRowIndexMap?.get(props.rowIndex) ?? 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 c9d0d97de..a85f64e6a 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 @@ -17,7 +17,9 @@ export const TitleColumn = (props: ItemTableListInnerColumn) => { const { itemType } = props; switch (itemType) { + case LibraryItem.PLAYLIST_SONG: case LibraryItem.QUEUE_SONG: + case LibraryItem.SONG: return ; default: return ; @@ -72,7 +74,7 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) { ]; const song = props.data[props.rowIndex] as QueueSong; - const isActive = props.activeRowId === song?._uniqueId; + const isActive = props.activeRowId === song?.id || props.activeRowId === song?._uniqueId; if (typeof row === 'string') { const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string); 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 ad1794145..b6df72fa5 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 @@ -88,7 +88,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => const row: object | undefined = (props.data as (any | undefined)[])[props.rowIndex]; const song = props.data[props.rowIndex] as QueueSong; - const isActive = props.activeRowId === song?._uniqueId; + const isActive = props.activeRowId === song?.id || props.activeRowId === song?._uniqueId; const artists = useMemo(() => { if (row && 'artists' in row && Array.isArray(row.artists)) { @@ -167,7 +167,9 @@ export const TitleCombinedColumn = (props: ItemTableListInnerColumn) => { const { itemType } = props; switch (itemType) { + case LibraryItem.PLAYLIST_SONG: case LibraryItem.QUEUE_SONG: + case LibraryItem.SONG: return ; default: return ; 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 c81d79c82..9d0a28fe4 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 @@ -547,8 +547,11 @@ export const TableColumnTextContainer = ( const handleClick = useDoubleClick({ onDoubleClick: (event: React.MouseEvent) => { if (isDataRow && item) { + const rowId = props.internalState.extractRowId(item); + const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onDoubleClick?.({ event, + index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, @@ -567,8 +570,11 @@ export const TableColumnTextContainer = ( } if (isDataRow && item && props.enableSelection) { + const rowId = props.internalState.extractRowId(item); + const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onClick?.({ event, + index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, @@ -580,8 +586,11 @@ export const TableColumnTextContainer = ( const handleContextMenu = (event: React.MouseEvent) => { if (isDataRow && item) { event.preventDefault(); + const rowId = props.internalState.extractRowId(item); + const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onMore?.({ event, + index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, @@ -750,8 +759,11 @@ export const TableColumnContainer = ( const handleClick = useDoubleClick({ onDoubleClick: (event: React.MouseEvent) => { if (isDataRow && item) { + const rowId = props.internalState.extractRowId(item); + const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onDoubleClick?.({ event, + index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, @@ -770,8 +782,11 @@ export const TableColumnContainer = ( } if (isDataRow && item && props.enableSelection) { + const rowId = props.internalState.extractRowId(item); + const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onClick?.({ event, + index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, @@ -783,8 +798,11 @@ export const TableColumnContainer = ( const handleContextMenu = (event: React.MouseEvent) => { if (isDataRow && item) { event.preventDefault(); + const rowId = props.internalState.extractRowId(item); + const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onMore?.({ event, + index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, 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 4a917d053..5f50b8d55 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 @@ -697,6 +697,7 @@ interface ItemTableListProps { onColumnResized?: (columnId: TableColumn, width: number) => void; onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void; onScrollEnd?: (offset: number, internalState: ItemListStateActions) => void; + overrideControls?: Partial; ref?: Ref; rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number; size?: 'compact' | 'default' | 'large'; @@ -729,6 +730,7 @@ export const ItemTableList = ({ onColumnResized, onRangeChanged, onScrollEnd, + overrideControls, ref, rowHeight, size = 'default', @@ -1759,6 +1761,7 @@ export const ItemTableList = ({ const controls = useDefaultItemListControls({ onColumnReordered, onColumnResized, + overrides: overrideControls, }); // Create itemProps for sticky header diff --git a/src/renderer/components/item-list/types.ts b/src/renderer/components/item-list/types.ts index 61c957024..f80de8f83 100644 --- a/src/renderer/components/item-list/types.ts +++ b/src/renderer/components/item-list/types.ts @@ -11,13 +11,14 @@ import { Play, TableColumn } from '/@/shared/types/types'; export interface DefaultItemControlProps { event: null | React.MouseEvent; + index?: number; internalState?: ItemListStateActions; item: ItemListItem | undefined; itemType: LibraryItem; } export interface ItemControls { - onClick?: ({ internalState, item, itemType }: DefaultItemControlProps) => void; + onClick?: ({ index, internalState, item, itemType }: DefaultItemControlProps) => void; onColumnReordered?: ({ columnIdFrom, columnIdTo, @@ -25,24 +26,27 @@ export interface ItemControls { }: { columnIdFrom: TableColumn; columnIdTo: TableColumn; - edge: 'top' | 'bottom' | 'left' | 'right' | null; + edge: 'bottom' | 'left' | 'right' | 'top' | null; }) => void; onColumnResized?: ({ columnId, width }: { columnId: TableColumn; width: number }) => void; - onDoubleClick?: ({ internalState, item, itemType }: DefaultItemControlProps) => void; - onExpand?: ({ internalState, item, itemType }: DefaultItemControlProps) => void; + onDoubleClick?: ({ index, internalState, item, itemType }: DefaultItemControlProps) => void; + onExpand?: ({ index, internalState, item, itemType }: DefaultItemControlProps) => void; onFavorite?: ({ + index, internalState, item, itemType, }: DefaultItemControlProps & { favorite: boolean }) => void; - onMore?: ({ internalState, item, itemType }: DefaultItemControlProps) => void; + onMore?: ({ index, internalState, item, itemType }: DefaultItemControlProps) => void; onPlay?: ({ + index, internalState, item, itemType, playType, }: DefaultItemControlProps & { playType: Play }) => void; onRating?: ({ + index, internalState, item, itemType, @@ -66,7 +70,7 @@ export interface ItemListHandle { internalState: ItemListStateActions; scrollToIndex: ( index: number, - options?: { align?: 'top' | 'bottom' | 'center'; behavior?: 'auto' | 'smooth' }, + options?: { align?: 'bottom' | 'center' | 'top'; behavior?: 'auto' | 'smooth' }, ) => void; scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void; } diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 9a5872db0..aac77bfb6 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -10,14 +10,16 @@ import { useItemListColumnResize } from '/@/renderer/components/item-list/helper import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { ItemControls } from '/@/renderer/components/item-list/types'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel'; +import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { searchLibraryItems } from '/@/renderer/features/shared/utils'; import { useContainerQuery } from '/@/renderer/hooks'; import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; import { AppRoute } from '/@/renderer/router/routes'; -import { useCurrentServer } from '/@/renderer/store'; +import { useCurrentServer, usePlayerSong } from '/@/renderer/store'; import { useGeneralSettings, useSettingsStore } from '/@/renderer/store/settings.store'; import { formatDateAbsoluteUTC, @@ -46,7 +48,7 @@ import { Song, SortOrder, } from '/@/shared/types/domain-types'; -import { ItemListKey, ListDisplayType } from '/@/shared/types/types'; +import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types'; interface AlbumMetadataTagsProps { album: Album | undefined; @@ -423,6 +425,8 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => { const [searchTerm, setSearchTerm] = useState(''); const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM_DETAIL]?.table); + const currentSong = usePlayerSong(); + const columns = useMemo(() => { return tableConfig?.columns || []; }, [tableConfig?.columns]); @@ -560,10 +564,31 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => { })); }, [discGroups, t, searchTerm]); + const player = usePlayer(); + + const overrideControls: Partial = useMemo(() => { + return { + onDoubleClick: ({ index, internalState, item }) => { + if (!item) { + return; + } + + const items = internalState?.getData() as Song[]; + + if (index !== undefined) { + player.addToQueueByData(items, Play.NOW); + player.mediaPlayByIndex(index); + } + }, + }; + }, [player]); + if (!tableConfig || columns.length === 0) { return null; } + const currentSongId = currentSong?.id; + return ( @@ -604,6 +629,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => { /> { itemType={LibraryItem.SONG} onColumnReordered={handleColumnReordered} onColumnResized={handleColumnResized} + overrideControls={overrideControls} size={tableConfig.size} /> diff --git a/src/renderer/features/player/context/player-context.tsx b/src/renderer/features/player/context/player-context.tsx index d3bb7a95d..d1ab44133 100644 --- a/src/renderer/features/player/context/player-context.tsx +++ b/src/renderer/features/player/context/player-context.tsx @@ -54,6 +54,7 @@ export interface PlayerContext { mediaNext: () => void; mediaPause: () => void; mediaPlay: (id?: string) => void; + mediaPlayByIndex: (index: number) => void; mediaPrevious: () => void; mediaSeekToTimestamp: (timestamp: number) => void; mediaSkipBackward: () => void; @@ -94,6 +95,7 @@ export const PlayerContext = createContext({ mediaNext: () => {}, mediaPause: () => {}, mediaPlay: () => {}, + mediaPlayByIndex: () => {}, mediaPrevious: () => {}, mediaSeekToTimestamp: () => {}, mediaSkipBackward: () => {}, @@ -488,6 +490,13 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { [storeActions], ); + const mediaPlayByIndex = useCallback( + (index: number) => { + storeActions.mediaPlayByIndex(index); + }, + [storeActions], + ); + const mediaPrevious = useCallback(() => { storeActions.mediaPrevious(); }, [storeActions]); @@ -642,6 +651,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { mediaNext, mediaPause, mediaPlay, + mediaPlayByIndex, mediaPrevious, mediaSeekToTimestamp, mediaSkipBackward, @@ -677,6 +687,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { mediaNext, mediaPause, mediaPlay, + mediaPlayByIndex, mediaPrevious, mediaSeekToTimestamp, mediaSkipBackward, diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index 4554bce7d..431340698 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -45,6 +45,7 @@ interface Actions { mediaNext: () => void; mediaPause: () => void; mediaPlay: (id?: string) => void; + mediaPlayByIndex: (index: number) => void; mediaPrevious: () => void; mediaSeekToTimestamp: (timestamp: number) => void; mediaSkipBackward: (offset?: number) => void; @@ -682,6 +683,21 @@ export const usePlayerStoreBase = create()( state.player.status = PlayerStatus.PLAYING; }); }, + mediaPlayByIndex: (index: number) => { + set((state) => { + const queue = state.getQueue(); + + if (index === -1 || index >= queue.items.length) { + state.player.status = PlayerStatus.PAUSED; + return; + } + + state.player.index = index; + setTimestampStore(0); + + state.player.status = PlayerStatus.PLAYING; + }); + }, mediaPrevious: () => { const currentIndex = get().player.index; @@ -1266,6 +1282,7 @@ export const usePlayerActions = () => { mediaNext: state.mediaNext, mediaPause: state.mediaPause, mediaPlay: state.mediaPlay, + mediaPlayByIndex: state.mediaPlayByIndex, mediaPrevious: state.mediaPrevious, mediaSeekToTimestamp: state.mediaSeekToTimestamp, mediaSkipBackward: state.mediaSkipBackward,