diff --git a/src/renderer/components/item-detail/item-detail.module.css b/src/renderer/components/item-detail/item-detail.module.css index 134edaa51..21ce8d3f9 100644 --- a/src/renderer/components/item-detail/item-detail.module.css +++ b/src/renderer/components/item-detail/item-detail.module.css @@ -95,7 +95,7 @@ overflow: hidden; text-overflow: ellipsis; color: var(--theme-colors-foreground-muted); - text-align: left; + text-align: center; white-space: nowrap; } @@ -108,13 +108,13 @@ } .row .track-col-duration { - width: 8rem; - min-width: 8rem; - max-width: 8rem; + width: 4rem; + min-width: 4rem; + max-width: 4rem; overflow: hidden; text-overflow: ellipsis; color: var(--theme-colors-foreground-muted); - text-align: right; + text-align: center; white-space: nowrap; } @@ -134,9 +134,24 @@ max-width: 5.5rem; overflow: hidden; text-overflow: ellipsis; + text-align: center; white-space: nowrap; } +.track-row-selected { + @mixin dark { + background-color: lighten(var(--theme-colors-surface), 5%); + } + + @mixin light { + background-color: darken(var(--theme-colors-surface), 5%); + } +} + +.track-row-dragging { + opacity: 0.5; +} + .skeleton-image { width: 100%; aspect-ratio: 1; diff --git a/src/renderer/components/item-detail/item-detail.tsx b/src/renderer/components/item-detail/item-detail.tsx index 8e33ed30d..619506987 100644 --- a/src/renderer/components/item-detail/item-detail.tsx +++ b/src/renderer/components/item-detail/item-detail.tsx @@ -11,21 +11,24 @@ import styles from './item-detail.module.css'; import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls'; import { ItemImage } from '/@/renderer/components/item-image/item-image'; -import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id'; import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; import { ItemListStateActions, + ItemListStateItemWithRequiredProperties, useItemListState, + useItemSelectionState, } from '/@/renderer/components/item-list/helpers/item-list-state'; +import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state'; import { ItemControls } from '/@/renderer/components/item-list/types'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { AppRoute } from '/@/renderer/router/routes'; import { Icon } from '/@/shared/components/icon/icon'; import { ReadOnlyRating } from '/@/shared/components/read-only-rating/read-only-rating'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; -import { Album, Song } from '/@/shared/types/domain-types'; +import { Album, LibraryItem, Song } from '/@/shared/types/domain-types'; interface ItemDetailListProps { currentPage?: number; @@ -46,59 +49,159 @@ interface RowData { internalState: ItemListStateActions; isMutatingFavorite: boolean; queryClient: ReturnType; + registerSongs: (albumId: string, songs: Song[]) => void; } interface TrackRowProps { + internalState: ItemListStateActions; isMutatingFavorite: boolean; onFavoriteClick: (song: Song) => void; song: Song; } -const TrackRow = memo(({ isMutatingFavorite, onFavoriteClick, song }: TrackRowProps) => { - const discAndCol = - `${song.discNumber ?? 1}` + ' - ' + song.trackNumber.toString().padStart(2, '0'); +const TrackRow = memo( + ({ internalState, isMutatingFavorite, onFavoriteClick, song }: TrackRowProps) => { + const playerContext = usePlayer(); + const { dragRef, isDragging } = useItemDragDropState({ + enableDrag: true, + internalState, + isDataRow: true, + item: song, + itemType: LibraryItem.SONG, + playerContext, + }); + const discAndCol = + `${song.discNumber ?? 1}` + ' - ' + song.trackNumber.toString().padStart(2, '0'); + const isSelected = useItemSelectionState(internalState, song.id); - return ( - - - {discAndCol} - - {song.name} - - {formatDuration(song.duration)} - - -
{ - event.stopPropagation(); - event.preventDefault(); - onFavoriteClick(song); - }} - onDoubleClick={(event) => { - event.stopPropagation(); - event.preventDefault(); - }} - role="button" - > - -
- - - - - - ); -}); + const handleRowClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.ctrlKey || e.metaKey) { + internalState.toggleSelected(song); + } else if (e.shiftKey) { + const selectedItems = internalState.getSelected(); + const lastSelectedItem = selectedItems[selectedItems.length - 1]; + + if ( + lastSelectedItem && + typeof lastSelectedItem === 'object' && + lastSelectedItem !== null + ) { + const data = internalState.getData(); + const validData = data.filter((d) => d && typeof d === 'object'); + const lastRowId = internalState.extractRowId(lastSelectedItem); + if (!lastRowId) { + internalState.setSelected([song]); + return; + } + const lastIndex = internalState.findItemIndex(lastRowId); + const currentIndex = internalState.findItemIndex(song.id); + + if (lastIndex !== -1 && currentIndex !== -1) { + const startIndex = Math.min(lastIndex, currentIndex); + const stopIndex = Math.max(lastIndex, currentIndex); + const rangeItems: ItemListStateItemWithRequiredProperties[] = []; + for (let i = startIndex; i <= stopIndex; i++) { + const rangeItem = validData[i]; + if ( + rangeItem && + typeof rangeItem === 'object' && + '_serverId' in rangeItem && + '_itemType' in rangeItem + ) { + const rangeRowId = internalState.extractRowId(rangeItem); + if (rangeRowId) { + rangeItems.push( + rangeItem as ItemListStateItemWithRequiredProperties, + ); + } + } + } + const currentSelected = internalState.getSelected(); + const newSelected = [ + ...currentSelected.filter( + ( + selectedItem, + ): selectedItem is ItemListStateItemWithRequiredProperties => + typeof selectedItem === 'object' && selectedItem !== null, + ), + ]; + rangeItems.forEach((rangeItem) => { + const rangeRowId = internalState.extractRowId(rangeItem); + if ( + rangeRowId && + !newSelected.some( + (selected) => + internalState.extractRowId(selected) === rangeRowId, + ) + ) { + newSelected.push(rangeItem); + } + }); + internalState.setSelected(newSelected); + } else { + internalState.setSelected([song]); + } + } else { + internalState.setSelected([song]); + } + } else { + internalState.setSelected([song]); + } + }, + [internalState, song], + ); + + return ( + + + {discAndCol} + + {song.name} + + {formatDuration(song.duration)} + + +
{ + event.stopPropagation(); + event.preventDefault(); + onFavoriteClick(song); + }} + onDoubleClick={(event) => { + event.stopPropagation(); + event.preventDefault(); + }} + role="button" + > + +
+ + + + + + ); + }, +); TrackRow.displayName = 'TrackRow'; type RowContentProps = Omit, 'style'>; -/** - * Inner row content – memoized with custom comparator so it does NOT re-render when only - * `style` or `ariaAttributes` change (e.g. on scroll). Only re-renders when data/index/mutation state change. - */ const RowContent = memo( ({ controls, @@ -108,6 +211,7 @@ const RowContent = memo( internalState, isMutatingFavorite, queryClient, + registerSongs, }: RowContentProps) => { const [showControls, setShowControls] = useState(false); const item = useMemo(() => { @@ -140,6 +244,12 @@ const RowContent = memo( ); }, [songData, item?.id, item?.songCount]); + useEffect(() => { + if (item?.id && songData?.songs?.length) { + registerSongs(item.id, songData.songs as Song[]); + } + }, [item?.id, registerSongs, songData?.songs]); + const onFavoriteClick = useCallback((song: Song) => { // TODO: toggle favorite for song void song; @@ -213,6 +323,7 @@ const RowContent = memo( {songs.map((song) => ( createExtractRowId(), []); + // Accumulate songs from each row for selection/drag state (keyed by album id) + const songsByAlbumRef = useRef>(new Map()); + const registerSongs = useCallback((albumId: string, songs: Song[]) => { + songsByAlbumRef.current.set(albumId, songs); + }, []); - // Create getData function - const getDataFn = useCallback(() => dataSource, [dataSource]); + // Flattened songs in album order for ItemListState (selection/drag are per-song) + const getDataFn = useCallback(() => { + const map = songsByAlbumRef.current; + return dataSource.flatMap((album) => map.get((album as Album).id) ?? []); + }, [dataSource]); - // Create internal state - const internalState = useItemListState(getDataFn, extractRowId); + const extractRowIdSong = useCallback((item: unknown) => (item as Song).id, []); + + const internalState = useItemListState(getDataFn, extractRowIdSong); const handleRowsRendered = useCallback( (range: { startIndex: number; stopIndex: number }) => { @@ -329,8 +448,17 @@ export const ItemDetailList = ({ internalState, isMutatingFavorite, queryClient, + registerSongs, }), - [controls, dataSource, getItem, internalState, isMutatingFavorite, queryClient], + [ + controls, + dataSource, + getItem, + internalState, + isMutatingFavorite, + queryClient, + registerSongs, + ], ); const [initialize, osInstance] = useOverlayScrollbars({ diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx b/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx index 3f42a6551..ee586cfb8 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx +++ b/src/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state.tsx @@ -7,8 +7,8 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types'; import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop'; -interface DragDropState { - dragRef: null | React.Ref; +interface DragDropState { + dragRef: null | React.Ref; isDraggedOver: 'bottom' | 'top' | null; isDragging: boolean; } @@ -23,7 +23,7 @@ interface UseItemDragDropStateProps { playlistId?: string; } -export const useItemDragDropState = ({ +export const useItemDragDropState = ({ enableDrag, internalState, isDataRow, @@ -31,14 +31,14 @@ export const useItemDragDropState = ({ itemType, playerContext, playlistId, -}: UseItemDragDropStateProps): DragDropState => { +}: UseItemDragDropStateProps): DragDropState => { const shouldEnableDrag = enableDrag && isDataRow && !!item; const { isDraggedOver, isDragging: isDraggingLocal, ref: dragRef, - } = useDragDrop({ + } = useDragDrop({ drag: { getId: () => { if (!item || !isDataRow) {