import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { SubsonicSong } from '../api/subsonic'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useDragDrop } from '../contexts/DragDropContext'; import { AddToPlaylistSubmenu } from './ContextMenu'; import { useIsMobile } from '../hooks/useIsMobile'; import StarRating from './StarRating'; import { useSelectionStore } from '../store/selectionStore'; import { useThemeStore } from '../store/themeStore'; function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`; } function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string { const parts: string[] = []; if (song.suffix) parts.push(song.suffix.toUpperCase()); if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`); return parts.join(' · '); } // ── Column configuration ────────────────────────────────────────────────────── const COLUMNS: readonly ColDef[] = [ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true }, { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false }, { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false }, { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false }, { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false }, { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false }, ]; type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre'; const CENTERED_COLS = new Set(['favorite', 'rating', 'duration']); // ── Props ───────────────────────────────────────────────────────────────────── export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'; interface AlbumTrackListProps { songs: SubsonicSong[]; sorted?: boolean; hasVariousArtists: boolean; currentTrack: Track | null; isPlaying: boolean; ratings: Record; userRatingOverrides: Record; starredSongs: Set; onPlaySong: (song: SubsonicSong) => void; onRate: (songId: string, rating: number) => void; onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void; sortKey?: SortKey; sortDir?: 'asc' | 'desc'; onSort?: (key: SortKey) => void; } // ── TrackRow (memoised) ─────────────────────────────────────────────────────── // Subscribes only to its own boolean in the selection store → O(1) re-render on toggle. interface TrackRowProps { song: SubsonicSong; globalIdx: number; visibleCols: readonly ColDef[]; gridStyle: React.CSSProperties; currentTrackId: string | null; isPlaying: boolean; ratingValue: number; isStarred: boolean; inSelectMode: boolean; isContextMenuSong: boolean; onPlaySong: (song: SubsonicSong) => void; onRate: (songId: string, rating: number) => void; onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; onContextMenu: AlbumTrackListProps['onContextMenu']; onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void; onDragStart: (song: SubsonicSong, me: MouseEvent) => void; setContextMenuSongId: (id: string | null) => void; } const TrackRow = React.memo(function TrackRow({ song, globalIdx, visibleCols, gridStyle, currentTrackId, isPlaying, ratingValue, isStarred, inSelectMode, isContextMenuSong, onPlaySong, onRate, onToggleSongStar, onContextMenu, onToggleSelect, onDragStart, setContextMenuSongId, }: TrackRowProps) { const { t } = useTranslation(); const navigate = useNavigate(); const showBitrate = useThemeStore(s => s.showBitrate); // Fine-grained: only re-renders when THIS row's selection boolean flips. const isSelected = useSelectionStore(s => s.selectedIds.has(song.id)); const isActive = currentTrackId === song.id; const renderCell = (colDef: ColDef) => { const key = colDef.key as ColKey; switch (key) { case 'num': return (
{ e.stopPropagation(); onPlaySong(song); }} > { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }} /> {isActive && isPlaying && (
)} {song.track ?? '—'}
); case 'title': return (
{song.title}
); case 'artist': { const artistRefs = song.artists && song.artists.length > 0 ? song.artists : [{ id: song.artistId, name: song.artist }]; return (
{artistRefs.map((a, i) => ( {i > 0 &&  · } { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }} > {a.name ?? song.artist} ))}
); } case 'favorite': return (
); case 'rating': return ( onRate(song.id, r)} /> ); case 'duration': return (
{formatDuration(song.duration)}
); case 'format': return (
{(song.suffix || (showBitrate && song.bitRate)) && ( {codecLabel(song, showBitrate)} )}
); case 'genre': return (
{song.genre ?? '—'}
); default: return null; } }; return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; if (e.ctrlKey || e.metaKey) { onToggleSelect(song.id, globalIdx, false); } else if (inSelectMode) { onToggleSelect(song.id, globalIdx, e.shiftKey); } else { onPlaySong(song); } }} onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); }} role="row" onMouseDown={e => { if (e.button !== 0) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; 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); onDragStart(song, me); } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} > {visibleCols.map(colDef => renderCell(colDef))}
); }); // ── AlbumTrackList ──────────────────────────────────────────────────────────── export default function AlbumTrackList({ songs, sorted, hasVariousArtists: _hasVariousArtists, currentTrack, isPlaying, ratings, userRatingOverrides, starredSongs, onPlaySong, onRate, onToggleSongStar, onContextMenu, sortKey, sortDir, onSort, }: AlbumTrackListProps) { const { t } = useTranslation(); const isMobile = useIsMobile(); const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const psyDrag = useDragDrop(); // Selection state lives in selectionStore — only the toggled row re-renders (O(1)). const selectedCount = useSelectionStore(s => s.selectedIds.size); const inSelectMode = selectedCount > 0; const allSelected = selectedCount === songs.length && songs.length > 0; const lastSelectedIdxRef = useRef(null); const [showPlPicker, setShowPlPicker] = useState(false); // ── Column state ────────────────────────────────────────────────────────── const { colVisible, visibleCols, gridStyle, startResize, toggleColumn, pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns'); // Clear selection when the song list changes (different album / filter applied). useEffect(() => { useSelectionStore.getState().clearAll(); lastSelectedIdxRef.current = null; }, [songs]); useEffect(() => { if (!contextMenuOpen) setContextMenuSongId(null); }, [contextMenuOpen]); // Clear selection on click outside the tracklist (header, album art, etc.) useEffect(() => { if (!inSelectMode) return; const handler = (e: MouseEvent) => { if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) { useSelectionStore.getState().clearAll(); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [inSelectMode, tracklistRef]); useEffect(() => { if (!showPlPicker) return; const handler = (e: MouseEvent) => { if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowPlPicker(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [showPlPicker]); // ── Stable callbacks passed to memoised TrackRow ────────────────────────── const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => { useSelectionStore.getState().setSelectedIds(prev => { const next = new Set(prev); if (shift && lastSelectedIdxRef.current !== null) { const from = Math.min(lastSelectedIdxRef.current, globalIdx); const to = Math.max(lastSelectedIdxRef.current, globalIdx); songs.slice(from, to + 1).forEach(s => next.add(s.id)); } else { next.has(id) ? next.delete(id) : next.add(id); } lastSelectedIdxRef.current = globalIdx; return next; }); }, [songs]); // Drag: if the dragged song is part of the selection, drag all selected songs. const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => { const { selectedIds } = useSelectionStore.getState(); if (selectedIds.has(song.id) && selectedIds.size > 1) { const tracks = songs .filter(s => selectedIds.has(s.id)) .map(s => songToTrack(s)); psyDrag.startDrag( { data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` }, me.clientX, me.clientY, ); } else { psyDrag.startDrag( { data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY, ); } }, [songs, psyDrag]); const toggleAll = useCallback(() => { if (allSelected) { useSelectionStore.getState().clearAll(); } else { useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id))); } }, [allSelected, songs]); // ── Disc grouping ───────────────────────────────────────────────────────── const discs = new Map(); if (!sorted) { songs.forEach(song => { const disc = song.discNumber ?? 1; if (!discs.has(disc)) discs.set(disc, []); discs.get(disc)!.push(song); }); } else { discs.set(1, songs as SubsonicSong[]); } const discNums = sorted ? [1] : Array.from(discs.keys()).sort((a, b) => a - b); const isMultiDisc = !sorted && discNums.length > 1; const currentTrackId = currentTrack?.id ?? null; // ── Sortable columns ────────────────────────────────────────────────────── const SORTABLE_COLS = new Set(['title', 'artist', 'album', 'favorite', 'rating', 'duration']); const isSortable = (key: ColKey | string): key is SortKey => SORTABLE_COLS.has(key as ColKey); const handleHeaderClick = (key: ColKey | string) => { if (!isSortable(key) || !onSort) return; onSort(key); }; const renderSortIndicator = (key: SortKey) => { if (sortKey !== key) return null; return ( {sortDir === 'asc' ? '▲' : '▼'} ); }; // ── Header cell renderer ────────────────────────────────────────────────── const renderHeaderCell = (colDef: ColDef, colIndex: number) => { const key = colDef.key as ColKey; const isLastCol = colIndex === visibleCols.length - 1; const isCentered = CENTERED_COLS.has(key); const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : ''; const canSort = isSortable(key) && onSort; const isActive = canSort && sortKey === key; if (key === 'num') { return (
{ e.stopPropagation(); toggleAll(); }} style={{ cursor: 'pointer' }} /> #
); } if (key === 'title') { const hasNextCol = colIndex + 1 < visibleCols.length; return (
handleHeaderClick(key)} className={isActive ? 'tracklist-header-cell-active' : ''} >
{label} {canSort && renderSortIndicator(key as SortKey)}
{hasNextCol && (
startResize(e, colIndex + 1, -1)} /> )}
); } const isResizable = !isLastCol; return (
handleHeaderClick(key)} className={isActive ? 'tracklist-header-cell-active' : ''} >
{label} {canSort && isSortable(key) && renderSortIndicator(key as SortKey)}
{isResizable && (
startResize(e, colIndex, 1)} /> )}
); }; // ── Mobile tracklist ────────────────────────────────────────────────────── if (isMobile) { return (
{discNums.map(discNum => (
{isMultiDisc && (
💿 CD {discNum}
)} {discs.get(discNum)!.map(song => { const isActive = currentTrackId === song.id; return (
onPlaySong(song)} onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); }} >
{isActive && isPlaying ? (
) : ( {song.track ?? ''} )} {song.title}
{formatDuration(song.duration)}
); })}
))}
); } return (
{ if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll(); }} > {/* ── Bulk action bar ── */} {inSelectMode && (
{t('common.bulkSelected', { count: selectedCount })}
{showPlPicker && ( { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }} dropDown /> )}
)} {/* ── Header ── */}
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
{/* Column visibility picker */}
{pickerOpen && (
{t('albumDetail.columns')}
{COLUMNS.filter(c => !c.required).map(c => { const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key; const isOn = colVisible.has(c.key); return ( ); })}
)}
{/* ── Tracks ── */} {discNums.map(discNum => (
{isMultiDisc && (
💿 CD {discNum}
)} {discs.get(discNum)!.map(song => { const globalIdx = songs.indexOf(song); return ( ); })}
))}
); }