import React, { useState, useEffect, useMemo } from 'react'; import { Play, Heart, ListPlus, X } from 'lucide-react'; import { SubsonicSong } from '../api/subsonic'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; import { AddToPlaylistSubmenu } from './ContextMenu'; function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, '0')}`; } function codecLabel(song: { suffix?: string; bitRate?: number }): string { const parts: string[] = []; if (song.suffix) parts.push(song.suffix.toUpperCase()); if (song.bitRate) parts.push(`${song.bitRate} kbps`); return parts.join(' · '); } function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) { const { t } = useTranslation(); const [hover, setHover] = React.useState(0); return (
{[1, 2, 3, 4, 5].map(n => ( ))}
); } interface AlbumTrackListProps { songs: SubsonicSong[]; hasVariousArtists: boolean; currentTrack: Track | null; isPlaying: boolean; ratings: 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; } export default function AlbumTrackList({ songs, hasVariousArtists, currentTrack, isPlaying, ratings, starredSongs, onPlaySong, onRate, onToggleSongStar, onContextMenu, }: AlbumTrackListProps) { const { t } = useTranslation(); const [hoveredSongId, setHoveredSongId] = useState(null); const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const psyDrag = useDragDrop(); // ── Bulk select ─────────────────────────────────────────────────── const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedIdx, setLastSelectedIdx] = useState(null); const [showPlPicker, setShowPlPicker] = useState(false); const toggleSelect = (id: string, globalIdx: number, shift: boolean) => { setSelectedIds(prev => { const next = new Set(prev); if (shift && lastSelectedIdx !== null) { const from = Math.min(lastSelectedIdx, globalIdx); const to = Math.max(lastSelectedIdx, globalIdx); songs.slice(from, to + 1).forEach(s => next.add(s.id)); } else { next.has(id) ? next.delete(id) : next.add(id); } return next; }); setLastSelectedIdx(globalIdx); }; const allSelected = selectedIds.size === songs.length && songs.length > 0; const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id))); useEffect(() => { if (!contextMenuOpen) setContextMenuSongId(null); }, [contextMenuOpen]); // Close playlist picker on outside click useEffect(() => { if (!showPlPicker) return; const handler = (e: MouseEvent) => { const target = e.target as HTMLElement; if (!target.closest('.bulk-pl-picker-wrap')) setShowPlPicker(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [showPlPicker]); const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0); const discs = new Map(); songs.forEach(song => { const disc = song.discNumber ?? 1; if (!discs.has(disc)) discs.set(disc, []); discs.get(disc)!.push(song); }); const discNums = Array.from(discs.keys()).sort((a, b) => a - b); const isMultiDisc = discNums.length > 1; const inSelectMode = selectedIds.size > 0; return (
{/* ── Bulk action bar ── */} {inSelectMode && (
{t('common.bulkSelected', { count: selectedIds.size })}
{showPlPicker && ( { setShowPlPicker(false); setSelectedIds(new Set()); }} dropDown /> )}
)}
{inSelectMode ? : '#'}
{t('albumDetail.trackTitle')}
{t('albumDetail.trackArtist')}
{t('albumDetail.trackFavorite')}
{t('albumDetail.trackRating')}
{t('albumDetail.trackDuration')}
{t('albumDetail.trackFormat')}
{discNums.map(discNum => (
{isMultiDisc && (
💿 CD {discNum}
)} {discs.get(discNum)!.map((song) => { const globalIdx = songs.indexOf(song); return (
setHoveredSongId(song.id)} onMouseLeave={() => setHoveredSongId(null)} onDoubleClick={() => onPlaySong(song)} onClick={e => { if (inSelectMode && !(e.target as HTMLElement).closest('button, input')) { toggleSelect(song.id, globalIdx, e.shiftKey); } }} 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); psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), 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); }} >
{ e.stopPropagation(); if (inSelectMode || hoveredSongId === song.id) { toggleSelect(song.id, globalIdx, e.shiftKey); } else { onPlaySong(song); } }} > { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }} /> {hoveredSongId === song.id && currentTrack?.id !== song.id && !inSelectMode ? : currentTrack?.id === song.id && isPlaying ?
: currentTrack?.id === song.id ? : (song.track ?? globalIdx + 1)}
{song.title}
{song.artist}
onRate(song.id, r)} />
{formatDuration(song.duration)}
{(song.suffix || song.bitRate) && ( {codecLabel(song)} )}
); })}
))}
{t('albumDetail.trackTotal')} {formatDuration(totalDuration)}
); }