import React from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { AudioLines, Check, ChevronDown, ChevronRight, Heart, ListPlus, Play, RotateCcw, Search, Square, Trash2, X, } from 'lucide-react'; import type { ColDef } from '../../utils/useTracklistColumns'; import type { SubsonicSong } from '../../api/subsonicTypes'; import type { Track } from '../../store/playerStoreTypes'; import { usePlayerStore } from '../../store/playerStore'; import { usePreviewStore } from '../../store/previewStore'; import { useThemeStore } from '../../store/themeStore'; import { useDragDrop } from '../../contexts/DragDropContext'; import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior'; import { songToTrack } from '../../utils/playback/songToTrack'; import { codecLabel, formatDuration } from '../../utils/componentHelpers/playlistDetailHelpers'; import type { PlaylistSortKey, PlaylistSortDir } from '../../utils/playlist/playlistDisplayedSongs'; import StarRating from '../StarRating'; import { AddToPlaylistSubmenu } from '../ContextMenu'; const PL_CENTERED = new Set(['favorite', 'rating', 'duration']); 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; 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, 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(); return (
{/* Bulk action bar */} {selectedIds.size > 0 && (
{t('common.bulkSelected', { count: selectedIds.size })}
{showBulkPlPicker && ( { setShowBulkPlPicker(false); setSelectedIds(new Set()); }} dropDown /> )}
)} {/* Column visibility picker */}
{pickerOpen && (
{t('albumDetail.columns')}
{allColumns.filter(c => !c.required).map(c => { const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key; const isOn = colVisible.has(c.key); return ( ); })}
)}
{/* 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')}
)} {displayedSongs.map((song, i) => { const realIdx = isFiltered ? songs.indexOf(song) : i; return ( {!isFiltered && isDragging && dropTargetIdx?.idx === i && dropTargetIdx.before && (
)}
!isFiltered && handleRowMouseEnter(i, e)} onMouseDown={e => handleRowMouseDown(e, realIdx)} onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (e.ctrlKey || e.metaKey) { toggleSelect(song.id, i, false); } else if (selectedIds.size > 0) { toggleSelect(song.id, i, e.shiftKey); } else if (orbitActive) { queueHint(); } else { playTrack(displayedTracks[i], displayedTracks); } }} onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (e.ctrlKey || e.metaKey || selectedIds.size > 0) return; addTrackToOrbit(song.id); } : undefined} onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, id, realIdx); }} > {visibleCols.map(colDef => { const inSelectMode = selectedIds.size > 0; switch (colDef.key) { case 'num': return (
{ e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> {currentTrack?.id === song.id && isPlaying ? ( ) : ( {i + 1} )}
); case 'title': return (
{song.title}
); case 'artist': return (
{ if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}
); case 'album': return (
{ if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}
); case 'favorite': return (
); case 'rating': return handleRate(song.id, r)} />; case 'duration': return
{formatDuration(song.duration ?? 0)}
; case 'format': return (
{(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}}
); case 'delete': return (
); default: return null; } })}
{!isFiltered && isDragging && dropTargetIdx?.idx === i && !dropTargetIdx.before && (
)} ); })}
); }