import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import PlaylistRow, { type PlaylistRowCallbacks } from '@/features/playlist/components/PlaylistRow'; import { TracklistColumnPicker } from '@/ui/TracklistColumnPicker'; import { useTranslation } from 'react-i18next'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll'; import { useElementClientHeightById } from '@/lib/hooks/useResizeClientHeight'; import { useNavigate } from 'react-router-dom'; import { ListPlus, Search, Trash2, X, } from 'lucide-react'; import type { ColDef } from '@/hooks/useTracklistColumns'; import type { SubsonicSong } from '@/lib/api/subsonicTypes'; import type { Track } from '@/lib/media/trackTypes'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { previewInputFromSong, usePreviewStore } from '@/features/playback/store/previewStore'; import { useThemeStore } from '@/store/themeStore'; import { useDragDrop } from '@/lib/dnd/DragDropContext'; import { useOrbitSongRowBehavior } from '@/features/orbit'; import { songToTrack } from '@/lib/media/songToTrack'; import type { PlaylistSortKey, PlaylistSortDir } from '@/features/playlist/utils/playlistDisplayedSongs'; import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/ContextMenu'; const PL_CENTERED = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']); interface Props { // Column config / picker allColumns: readonly ColDef[]; visibleCols: ColDef[]; gridStyle: React.CSSProperties; colVisible: Set; toggleColumn: (key: string) => void; resetColumns: () => void; pickerOpen: boolean; setPickerOpen: React.Dispatch>; pickerRef: React.RefObject; startResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void; tracklistRef: React.RefObject; // Data songs: SubsonicSong[]; displayedSongs: SubsonicSong[]; displayedTracks: Track[]; isFiltered: boolean; /** True only while a filter text is active — distinct from `isFiltered`, * which also goes true while sorting (displayedSongs !== songs). Drives the * scroll-to-list effect so sorting doesn't snap the viewport (issue #840). */ hasActiveFilter: boolean; id: string | undefined; // Sort sortKey: PlaylistSortKey; setSortKey: React.Dispatch>; sortDir: PlaylistSortDir; setSortDir: React.Dispatch>; sortClickCount: number; setSortClickCount: React.Dispatch>; // Selection selectedIds: Set; setSelectedIds: React.Dispatch>>; allSelected: boolean; toggleAll: () => void; toggleSelect: (id: string, idx: number, shift: boolean) => void; showBulkPlPicker: boolean; setShowBulkPlPicker: React.Dispatch>; bulkRemove: () => void; // Context menu + DnD visual contextMenuSongId: string | null; setContextMenuSongId: React.Dispatch>; dropTargetIdx: { idx: number; before: boolean } | null; // Rating / star / row mouse / delete ratings: Record; starredSongs: Set; handleRate: (songId: string, rating: number) => void; handleToggleStar: (song: SubsonicSong, e: React.MouseEvent) => void; handleRowMouseDown: (e: React.MouseEvent, idx: number) => void; handleRowMouseEnter: (idx: number, e: React.MouseEvent) => void; removeSong: (idx: number) => void; // Empty state setSearchOpen: React.Dispatch>; } export default function PlaylistTracklist({ allColumns, visibleCols, gridStyle, colVisible, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, startResize, tracklistRef, songs, displayedSongs, displayedTracks, isFiltered, hasActiveFilter, id, sortKey, setSortKey, sortDir, setSortDir, sortClickCount, setSortClickCount, selectedIds, setSelectedIds, allSelected, toggleAll, toggleSelect, showBulkPlPicker, setShowBulkPlPicker, bulkRemove, contextMenuSongId, setContextMenuSongId, dropTargetIdx, ratings, starredSongs, handleRate, handleToggleStar, handleRowMouseDown, handleRowMouseEnter, removeSong, setSearchOpen, }: Props) { const { t } = useTranslation(); const navigate = useNavigate(); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); const playTrack = usePlayerStore(s => s.playTrack); const openContextMenu = usePlayerStore(s => s.openContextMenu); const starredOverrides = usePlayerStore(s => s.starredOverrides); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const previewingId = usePreviewStore(s => s.previewingId); const previewAudioStarted = usePreviewStore(s => s.audioStarted); const showBitrate = useThemeStore(s => s.showBitrate); const { isDragging } = useDragDrop(); const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const latestVals = { selectedIds, orbitActive, displayedTracks, isFiltered, id, toggleSelect, handleRowMouseDown, handleRowMouseEnter, handleToggleStar, handleRate, removeSong, playTrack, openContextMenu, setContextMenuSongId, navigate, queueHint, addTrackToOrbit, }; const latest = useRef(latestVals); latest.current = latestVals; const cb = useMemo(() => ({ activate: (song, index, e) => { if ((e.target as HTMLElement).closest('button, a, input')) return; const L = latest.current; if (e.ctrlKey || e.metaKey) L.toggleSelect(song.id, index, false); else if (L.selectedIds.size > 0) L.toggleSelect(song.id, index, e.shiftKey); else if (L.orbitActive) L.queueHint(); else L.playTrack(L.displayedTracks[index], L.displayedTracks); }, dblOrbit: (songId, e) => { if ((e.target as HTMLElement).closest('button, a, input')) return; const L = latest.current; if (e.ctrlKey || e.metaKey || L.selectedIds.size > 0) return; L.addTrackToOrbit(songId); }, context: (song, rIdx, e) => { e.preventDefault(); const L = latest.current; L.setContextMenuSongId(song.id); L.openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, L.id, rIdx); }, mouseDownRow: (rIdx, e) => latest.current.handleRowMouseDown(e, rIdx), mouseEnterRow: (index, e) => { const L = latest.current; if (!L.isFiltered) L.handleRowMouseEnter(index, e); }, toggleSelect: (songId, index, shift) => latest.current.toggleSelect(songId, index, shift), play: (index) => { const L = latest.current; if (L.orbitActive) { L.queueHint(); return; } L.playTrack(L.displayedTracks[index], L.displayedTracks); }, startPreview: (song) => usePreviewStore.getState().startPreview( previewInputFromSong(song), 'playlists', ), toggleStar: (song, e) => latest.current.handleToggleStar(song, e), rate: (songId, r) => latest.current.handleRate(songId, r), remove: (rIdx) => latest.current.removeSong(rIdx), navArtist: (artistId) => latest.current.navigate(`/artist/${artistId}`), navAlbum: (albumId) => latest.current.navigate(`/album/${albumId}`), }), []); const listWrapRef = useRef(null); const [scrollMargin, setScrollMargin] = useState(0); const viewportH = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); // Bulk bar show/hide shifts listWrapRef top — remeasure on that edge only. const bulkBarVisible = selectedIds.size > 0; useLayoutEffect(() => { const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); const root = tracklistRef.current?.closest('.album-detail') as HTMLElement | null; if (!sc) return; const measure = () => { const wrap = listWrapRef.current; if (!wrap) return; const m = wrap.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop; setScrollMargin(prev => (Math.abs(prev - m) > 0.5 ? m : prev)); }; const ro = new ResizeObserver(measure); ro.observe(sc); if (root) ro.observe(root); measure(); return () => ro.disconnect(); }, [tracklistRef, bulkBarVisible, pickerOpen, displayedSongs.length]); // React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct. // eslint-disable-next-line react-hooks/incompatible-library const rowVirtualizer = useVirtualizer({ count: displayedSongs.length, getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), estimateSize: () => 48, overscan: Math.max(8, Math.ceil(viewportH / 48)), scrollMargin, getItemKey: i => `${displayedSongs[i].id}:${i}`, }); const firstRender = useRef(true); useEffect(() => { if (firstRender.current) { firstRender.current = false; return; } const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); if (sc) sc.scrollTop = scrollMargin; // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, hasActiveFilter]); const autoScrollRef = useRef(0); const pointerYRef = useRef(0); const runAutoScroll = useCallback(() => { const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); if (!sc) { autoScrollRef.current = 0; return; } const r = sc.getBoundingClientRect(); const EDGE = 60; const MAX = 18; const y = pointerYRef.current; let dy = 0; if (y < r.top + EDGE) dy = -MAX * (1 - (y - r.top) / EDGE); else if (y > r.bottom - EDGE) dy = MAX * (1 - (r.bottom - y) / EDGE); if (dy !== 0) sc.scrollTop += dy; autoScrollRef.current = requestAnimationFrame(runAutoScroll); }, []); useEffect(() => { if (!isDragging) return; const onMove = (e: MouseEvent) => { pointerYRef.current = e.clientY; }; window.addEventListener('mousemove', onMove); autoScrollRef.current = requestAnimationFrame(runAutoScroll); return () => { window.removeEventListener('mousemove', onMove); if (autoScrollRef.current) cancelAnimationFrame(autoScrollRef.current); autoScrollRef.current = 0; }; }, [isDragging, runAutoScroll]); const virtualItems = rowVirtualizer.getVirtualItems(); let dropIndicatorY: number | null = null; if (isDragging && !isFiltered && dropTargetIdx) { const vi = virtualItems.find(v => v.index === dropTargetIdx.idx); const start = vi ? vi.start : dropTargetIdx.idx * 48 + scrollMargin; const size = vi ? vi.size : 48; dropIndicatorY = (dropTargetIdx.before ? start : start + size) - scrollMargin; } return ( <>
{/* Bulk action bar */} {selectedIds.size > 0 && (
{t('common.bulkSelected', { count: selectedIds.size })}
{showBulkPlPicker && ( { setShowBulkPlPicker(false); setSelectedIds(new Set()); }} dropDown /> )}
)} {/* Header */}
{visibleCols.map((colDef, colIndex) => { const key = colDef.key; const isLastCol = colIndex === visibleCols.length - 1; const isCentered = PL_CENTERED.has(key); const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : ''; const sortableCols = new Set(['title', 'artist', 'favorite', 'rating', 'duration', 'album']); const canSort = sortableCols.has(key); const isSortActive = canSort && sortKey === key; const handleSortClick = () => { if (!canSort) return; if (sortKey === key) { const nextCount = sortClickCount + 1; if (nextCount >= 3) { setSortKey('natural'); setSortDir('asc'); setSortClickCount(0); } else { setSortDir(d => d === 'asc' ? 'desc' : 'asc'); setSortClickCount(nextCount); } } else { setSortKey(key as PlaylistSortKey); setSortDir('asc'); setSortClickCount(1); } }; const renderSortIndicator = () => { if (!isSortActive) return null; return ( {sortDir === 'asc' ? '▲' : '▼'} ); }; if (key === 'num') return (
0 ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleAll(); }} style={{ cursor: 'pointer' }} /> #
); if (key === 'title') { const hasNextCol = colIndex + 1 < visibleCols.length; return (
{label} {canSort && renderSortIndicator()}
{hasNextCol &&
startResize(e, colIndex + 1, -1)} />}
); } if (key === 'delete') return
; return (
{label} {canSort && renderSortIndicator()}
{!isLastCol && key !== 'delete' && (
startResize(e, colIndex, 1)} /> )}
); })}
{songs.length === 0 && (
{t('playlists.emptyPlaylist')}
)}
{dropIndicatorY !== null && (
)} {virtualItems.map(vi => { const song = displayedSongs[vi.index]; const i = vi.index; const realIdx = isFiltered ? songs.indexOf(song) : i; return (
0} isStarred={song.id in starredOverrides ? !!starredOverrides[song.id] : starredSongs.has(song.id)} ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} isPreviewing={previewingId === song.id} previewStarted={previewingId === song.id && previewAudioStarted} orbitActive={orbitActive} cb={cb} />
); })}
); }