import React, { useEffect, useSyncExternalStore } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Play } from 'lucide-react'; import type { TFunction } from 'i18next'; import OverlayScrollArea from '@/ui/OverlayScrollArea'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { useLuckyMixStore } from '@/features/randomMix'; import type { QueueItemRef } from '@/lib/media/trackTypes'; import type { PlayerState } from '@/features/playback/store/playerStoreTypes'; import type { QueueDisplayMode } from '@/store/authStoreTypes'; import { formatTrackTime } from '@/lib/format/formatDuration'; import { resolveQueueTrack } from '@/features/playback/store/queueTrackView'; import { getQueueResolverVersion, subscribeQueueResolver, resolveBatch, } from '@/features/playback/store/queueTrackResolver'; import { collectQueueResolveRefs } from '@/features/queue/utils/collectQueueResolveRefs'; import { findQueueItemRefIndex } from '@/features/playback/utils/playback/queueIdentity'; import type { TimelineDisplayRow } from '@/features/playback/utils/buildTimelineDisplayRows'; import { findTimelineScrollLocalIndex } from '@/features/playback/utils/buildTimelineDisplayRows'; import { playTimelineHistoryTrack } from '@/features/playback/utils/playTimelineHistoryTrack'; type StartDrag = ( payload: { data: string; label: string }, x: number, y: number, ) => void; interface Props { queue: QueueItemRef[]; /** Timeline virtual rows; when set, `queue` is ignored for rendering. */ timelineRows?: TimelineDisplayRow[]; /** Canonical queue for history-row play / context-menu index lookup. */ canonicalQueue?: QueueItemRef[]; queueIndex: number; displayBaseIndex: number; queueDisplayMode: QueueDisplayMode; emptyLabel: string; contextMenu: PlayerState['contextMenu']; playTrack: PlayerState['playTrack']; activeTab: string; queueListRef: React.RefObject; suppressNextAutoScrollRef: React.MutableRefObject; isQueueDrag: boolean; psyDragFromIdxRef: React.MutableRefObject; externalDropTarget: { idx: number; before: boolean } | null; startDrag: StartDrag; orbitAttributionLabel: (trackId: string) => string | null; luckyRolling: boolean; t: TFunction; } const INITIAL_RECT = { width: 0, height: 600 }; export function QueueList({ queue, timelineRows, canonicalQueue, queueIndex, displayBaseIndex, queueDisplayMode, emptyLabel, contextMenu, playTrack, activeTab, queueListRef, suppressNextAutoScrollRef, isQueueDrag, psyDragFromIdxRef, externalDropTarget, startDrag, orbitAttributionLabel, luckyRolling, t, }: Props) { useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion); const usingTimeline = queueDisplayMode === 'timeline' && timelineRows != null; const rowCount = usingTimeline ? timelineRows.length : queue.length; // eslint-disable-next-line react-hooks/incompatible-library const rowVirtualizer = useVirtualizer({ count: rowCount, getScrollElement: () => queueListRef.current, estimateSize: () => 52, overscan: 10, getItemKey: i => { if (usingTimeline) return timelineRows[i]!.key; return `${queue[i].trackId}:${i}`; }, initialRect: INITIAL_RECT, }); const virtualItems = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); // Resolve the rows the user is actually looking at (queue thin-state). The // queueResolverBridge only warms a window around the *playing* index; scrolling // the queue independently would otherwise leave rows stuck on the '…' // placeholder (cache miss / evicted from the bounded LRU). Drive resolution off // the virtualizer's visible range so any scrolled-to row fetches on demand. const firstVisible = virtualItems.length > 0 ? virtualItems[0]!.index : 0; const lastVisible = virtualItems.length > 0 ? virtualItems[virtualItems.length - 1]!.index : 0; useEffect(() => { if (rowCount === 0) return; const refs = collectQueueResolveRefs({ usingTimeline, timelineRows, queue, firstVisible, lastVisible }); if (refs.length > 0) void resolveBatch(refs); // Keyed on the visible range + mode; the resolver dedups against cache/in-flight, // so re-running on scroll is cheap. Queue-content changes are covered by the bridge. // eslint-disable-next-line react-hooks/exhaustive-deps }, [firstVisible, lastVisible, rowCount, usingTimeline]); useEffect(() => { if (suppressNextAutoScrollRef.current) { suppressNextAutoScrollRef.current = false; return; } if (activeTab !== 'queue' || rowCount === 0 || !usingTimeline || !timelineRows) return; const localIdx = findTimelineScrollLocalIndex(timelineRows); if (localIdx == null) return; const pinToTop = (index: number, scrollSelector: string) => { rowVirtualizer.scrollToIndex(index, { align: 'start' }); const id = requestAnimationFrame(() => { const el = queueListRef.current?.querySelector(scrollSelector); el?.scrollIntoView({ block: 'start', behavior: 'instant' }); }); return () => cancelAnimationFrame(id); }; return pinToTop(localIdx, `[data-timeline-local-idx="${localIdx}"]`); // eslint-disable-next-line react-hooks/exhaustive-deps }, [queueIndex, activeTab, queueDisplayMode, usingTimeline]); useEffect(() => { if (suppressNextAutoScrollRef.current) { suppressNextAutoScrollRef.current = false; return; } if (activeTab !== 'queue' || rowCount === 0 || usingTimeline) return; const pinToTop = (localIndex: number, scrollSelector: string) => { rowVirtualizer.scrollToIndex(localIndex, { align: 'start' }); const id = requestAnimationFrame(() => { const el = queueListRef.current?.querySelector(scrollSelector); el?.scrollIntoView({ block: 'start', behavior: 'instant' }); }); return () => cancelAnimationFrame(id); }; if (queueDisplayMode === 'queue') { if (queueIndex < 0) return; return pinToTop(0, `[data-queue-idx="${displayBaseIndex}"]`); } if (queueIndex < 0) return; const viewport = queueListRef.current; if (viewport) { const rowEl = viewport.querySelector(`[data-queue-idx="${queueIndex}"]`); if (rowEl) { const rowRect = rowEl.getBoundingClientRect(); const viewRect = viewport.getBoundingClientRect(); const fullyVisible = rowRect.top >= viewRect.top && rowRect.bottom <= viewRect.bottom; if (fullyVisible) return; } } return pinToTop(queueIndex, `[data-queue-idx="${queueIndex}"]`); // eslint-disable-next-line react-hooks/exhaustive-deps }, [queueIndex, activeTab, queueDisplayMode, rowCount, usingTimeline]); const playHistoryRow = (serverId: string, trackId: string) => { suppressNextAutoScrollRef.current = true; void playTimelineHistoryTrack(serverId, trackId, canonicalQueue); }; const renderTrackRow = (args: { track: ReturnType; absIdx: number | null; localIndex: number; isPlaying: boolean; isPast: boolean; isHistory: boolean; base?: QueueItemRef; dragStyle: React.CSSProperties; }) => { const { track, absIdx, localIndex, isPlaying, isPast, isHistory, base, dragStyle } = args; return (
{ if (isHistory) { playHistoryRow(base?.serverId ?? track.serverId ?? '', track.id); return; } if (absIdx == null) return; suppressNextAutoScrollRef.current = true; playTrack(track, undefined, undefined, undefined, absIdx); }} onContextMenu={(e) => { e.preventDefault(); if (isHistory && absIdx == null) { usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'song'); return; } if (absIdx == null) return; usePlayerStore.getState().openContextMenu(e.clientX, e.clientY, track, 'queue-item', absIdx); }} onMouseDown={(e) => { if (isHistory || absIdx == null) return; if (e.button !== 0) return; e.preventDefault(); const startX = e.clientX; const startY = e.clientY; const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); psyDragFromIdxRef.current = absIdx; startDrag({ data: JSON.stringify({ type: 'queue_reorder', index: absIdx }), label: track.title }, me.clientX, me.clientY); } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }} style={{ ...(isPast && !isPlaying ? { opacity: 0.5 } : null), ...dragStyle }} >
{isPlaying && } {track.title}
{track.artist}
{(() => { const label = orbitAttributionLabel(track.id); return label ?
{label}
: null; })()}
{formatTrackTime(track.duration)}
); }; return ( {rowCount === 0 ? ( emptyLabel ? (
{emptyLabel}
) : null ) : (
{virtualItems.map(vi => { const idx = vi.index; if (usingTimeline && timelineRows) { const row = timelineRows[idx]!; if (row.kind === 'divider') { return (
{t(row.labelKey)}
); } const base = row.kind === 'history' ? { serverId: row.ref.serverId, trackId: row.ref.trackId } : row.ref; const track = resolveQueueTrack(base); const absIdx = row.kind === 'history' ? findQueueItemRefIndex( canonicalQueue ?? usePlayerStore.getState().queueItems, row.ref, ) : row.queueIndex; const isPlaying = row.kind === 'current'; const isPast = row.kind === 'history'; const prevRow = idx > 0 ? timelineRows[idx - 1] : null; const isFirstAutoAdded = row.kind === 'upcoming' && row.ref.autoAdded && (prevRow?.kind !== 'upcoming' || !prevRow.ref.autoAdded); const isFirstRadioAdded = row.kind === 'upcoming' && row.ref.radioAdded && (prevRow?.kind !== 'upcoming' || !prevRow.ref.radioAdded); let dragStyle: React.CSSProperties = {}; if (row.kind !== 'history' && isQueueDrag && psyDragFromIdxRef.current === absIdx) { dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' }; } else if (row.kind !== 'history' && isQueueDrag && externalDropTarget?.idx === absIdx) { dragStyle = externalDropTarget.before ? { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' } : { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' }; } return (
{isFirstRadioAdded && (
{t('queue.radioAdded')}
)} {isFirstAutoAdded && (
{t('queue.autoAdded')}
)} {renderTrackRow({ track, absIdx: row.kind === 'history' ? (absIdx >= 0 ? absIdx : null) : absIdx, localIndex: row.localIndex, isPlaying, isPast, isHistory: row.kind === 'history', base, dragStyle, })} {luckyRolling && isPlaying && ( )}
); } const absIdx = displayBaseIndex + idx; const base = queue[idx]; const track = resolveQueueTrack(base); const isPlaying = absIdx === queueIndex; const isPast = false; const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded); const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded); let dragStyle: React.CSSProperties = {}; if (isQueueDrag && psyDragFromIdxRef.current === absIdx) { dragStyle = { opacity: 0.4, background: 'var(--bg-hover)' }; } else if (isQueueDrag && externalDropTarget?.idx === absIdx) { if (externalDropTarget.before) { dragStyle = { borderTop: '2px solid var(--accent)', paddingTop: '6px', marginTop: '-2px' }; } else { dragStyle = { borderBottom: '2px solid var(--accent)', paddingBottom: '6px', marginBottom: '-2px' }; } } return (
{isFirstRadioAdded && (
{t('queue.radioAdded')}
)} {isFirstAutoAdded && (
{t('queue.autoAdded')}
)} {renderTrackRow({ track, absIdx, localIndex: idx, isPlaying, isPast, isHistory: false, base, dragStyle, })} {luckyRolling && isPlaying && ( )}
); })}
)}
); }