import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import FavoriteSongRow, { type FavoriteSongRowCallbacks } from './FavoriteSongRow'; import { TracklistColumnPicker } from '../albumTrackList/TracklistColumnPicker'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import type { ColDef } from '../../utils/useTracklistColumns'; import type { SubsonicSong } from '../../api/subsonicTypes'; import { usePlayerStore } from '../../store/playerStore'; import { usePreviewStore } from '../../store/previewStore'; import { useSelectionStore } from '../../store/selectionStore'; import { useThemeStore } from '../../store/themeStore'; import { useDragDrop } from '../../contexts/DragDropContext'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { songToTrack } from '../../utils/playback/songToTrack'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll'; import { useElementClientHeightById } from '../../hooks/useResizeClientHeight'; const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration', 'playCount', 'lastPlayed', 'bpm']); interface Props { visibleSongs: SubsonicSong[]; selectedIds: Set; selectedCount: number; inSelectMode: boolean; toggleSelect: (id: string, idx: number, shift: boolean) => void; allColumns: readonly ColDef[]; visibleCols: ColDef[]; gridStyle: React.CSSProperties; colVisible: Set; toggleColumn: (key: string) => void; resetColumns: () => void; pickerOpen: boolean; setPickerOpen: React.Dispatch>; pickerRef: React.RefObject; tracklistRef: React.RefObject; startResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void; handleSortClick: (key: string) => void; getSortIndicator: (key: string) => React.ReactNode; ratings: Record; handleRate: (songId: string, rating: number) => void; removeSong: (id: string) => void; hasFilters: boolean; } export default function FavoritesSongsTracklist({ visibleSongs, selectedIds, selectedCount, inSelectMode, toggleSelect, allColumns, visibleCols, gridStyle, colVisible, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, tracklistRef, startResize, handleSortClick, getSortIndicator, ratings, handleRate, removeSong, hasFilters, }: 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 userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const previewingId = usePreviewStore(s => s.previewingId); const previewAudioStarted = usePreviewStore(s => s.audioStarted); const showBitrate = useThemeStore(s => s.showBitrate); const psyDrag = useDragDrop(); const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const visibleTracks = useMemo(() => visibleSongs.map(songToTrack), [visibleSongs]); const latestVals = { visibleSongs, visibleTracks, selectedIds, inSelectMode, orbitActive, toggleSelect, handleRate, removeSong, playTrack, openContextMenu, navigate, queueHint, addTrackToOrbit, psyDrag, }; 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.inSelectMode) L.toggleSelect(song.id, index, e.shiftKey); else if (L.orbitActive) L.queueHint(); else L.playTrack(L.visibleTracks[index], L.visibleTracks); }, dblOrbit: (songId, e) => { if ((e.target as HTMLElement).closest('button, a, input')) return; const L = latest.current; if (e.ctrlKey || e.metaKey || L.inSelectMode) return; L.addTrackToOrbit(songId); }, context: (song, e) => { e.preventDefault(); latest.current.openContextMenu(e.clientX, e.clientY, songToTrack(song), 'favorite-song'); }, mouseDownRow: (song, e) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest('button, a, input')) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; const track = songToTrack(song); const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); const L = latest.current; const { selectedIds: selIds } = useSelectionStore.getState(); if (selIds.has(song.id) && selIds.size > 1) { const bulkTracks = L.visibleSongs.filter(s => selIds.has(s.id)).map(songToTrack); L.psyDrag.startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY); } else { L.psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); } } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }, 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.visibleTracks[index], L.visibleTracks); }, startPreview: (song) => usePreviewStore.getState().startPreview( { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'favorites', ), rate: (songId, r) => latest.current.handleRate(songId, r), remove: (songId) => latest.current.removeSong(songId), 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); useLayoutEffect(() => { const sc = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); // scrollMargin must track height changes in sections above the list (filters, top artists). // Intentionally coupled to the Favorites page shell class — keep in sync with that layout. const root = tracklistRef.current?.closest('.content-body') 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(); // selectedIds.size > 0 (boolean): bulk bar show/hide shifts listWrapRef top — remeasure on that edge only. }, [tracklistRef, selectedIds.size > 0, pickerOpen, visibleSongs.length]); const rowVirtualizer = useVirtualizer({ count: visibleSongs.length, getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), estimateSize: () => 48, overscan: Math.max(8, Math.ceil(viewportH / 48)), scrollMargin, getItemKey: i => `${visibleSongs[i].id}:${i}`, }); const virtualItems = rowVirtualizer.getVirtualItems(); return ( <>
{ if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll(); }}>
{visibleCols.map((colDef, colIndex) => { const key = colDef.key; const isLastCol = colIndex === visibleCols.length - 1; const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : ''; if (key === 'num') { const allSelected = selectedCount === visibleSongs.length && visibleSongs.length > 0; return (
{ e.stopPropagation(); if (allSelected) { useSelectionStore.getState().clearAll(); } else { useSelectionStore.getState().setSelectedIds(() => new Set(visibleSongs.map(s => s.id))); } }} /> #
); } if (key === 'title') { const hasNextCol = colIndex + 1 < visibleCols.length; const canSort = SORTABLE_COLUMNS.has('title'); return (
handleSortClick('title')} > {label} {canSort && getSortIndicator('title')}
{hasNextCol &&
startResize(e, colIndex + 1, -1)} />}
); } if (key === 'remove') return
; const isCentered = key === 'duration' || key === 'rating'; const canSort = SORTABLE_COLUMNS.has(key); return (
canSort && handleSortClick(key)} > {label} {canSort && getSortIndicator(key)}
{!isLastCol &&
startResize(e, colIndex, 1)} />}
); })}
{virtualItems.map(vi => { const song = visibleSongs[vi.index]; const i = vi.index; return (
); })}
{/* Empty state when filters return no results */} {visibleSongs.length === 0 && hasFilters && (
{t('favorites.noFilterResults')}
)}
); }