import { useDeferredValue, useMemo, useSyncExternalStore } from 'react'; import { AlignCenterVertical, ChevronDown, ListMusic, ListOrdered } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { usePlayerStore } from '../../store/playerStore'; import { useAuthStore } from '../../store/authStore'; import type { QueueItemRef } from '../../store/playerStoreTypes'; import type { QueueDisplayMode } from '../../store/authStoreTypes'; import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers'; import { formatLongDuration } from '@/lib/format/formatDuration'; import { formatClockTime } from '@/lib/format/formatClockTime'; import { resolveQueueTrack } from '../../utils/library/queueTrackView'; import { getQueueResolverVersion, subscribeQueueResolver, } from '../../utils/library/queueTrackResolver'; interface Props { queue: QueueItemRef[]; queueIndex: number; activePlaylist: { id: string; name: string } | null; isNowPlayingCollapsed: boolean; setIsNowPlayingCollapsed: (v: boolean) => void; durationMode: DurationMode; setDurationMode: (m: DurationMode) => void; queueDisplayMode: QueueDisplayMode; setQueueDisplayMode: (v: QueueDisplayMode) => void; t: TFunction; } export function QueueHeader({ queue, queueIndex, activePlaylist, isNowPlayingCollapsed, setIsNowPlayingCollapsed, durationMode, setDurationMode, queueDisplayMode, setQueueDisplayMode, t, }: Props) { const currentTime = usePlayerStore((s) => Math.floor(s.currentTime / 30) * 30); const isPlaying = usePlayerStore((s) => s.isPlaying); const clockFormat = useAuthStore((s) => s.clockFormat); const { i18n } = useTranslation(); // Thin-state: durations come from the resolver cache. The totals re-derive as // the cache fills (version) and on queue change; tracks past the cache window // contribute 0 until they resolve. Pure read (no cache mutation) in the memo. // H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide) // bumps `version` dozens of times in one frame; useDeferredValue coalesces // the burst into a single low-priority commit so long queues do not block // the main thread on every cache tick. // // The O(n) walk is keyed on `queue`/`deferredVersion` only — NOT `queueIndex`. // A skip moves only `queueIndex`, so it must not re-walk the whole queue: that // synchronous O(n) pass on every track change froze the UI for seconds on very // large queues (#1072, and the device-switch fallback in #1090). Instead we // build a cumulative-duration prefix (`cumSecs[i]` = summed duration of tracks // [0, i)) once per queue/cache change, then derive the future total as an O(1) // lookup below. A 50k-track queue costs one walk per queue/cache change, zero // per track change. const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion); const deferredVersion = useDeferredValue(version); const { totalSecs, cumSecs } = useMemo(() => { const cum = new Float64Array(queue.length + 1); for (let i = 0; i < queue.length; i += 1) { cum[i + 1] = cum[i] + (resolveQueueTrack(queue[i]).duration || 0); } return { totalSecs: cum[queue.length], cumSecs: cum }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [queue, deferredVersion]); // Tracks strictly after the current index — O(1) per skip. const futureTracksDuration = Math.max( 0, totalSecs - cumSecs[Math.min(queueIndex + 1, queue.length)], ); const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0; const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration); let dur: string | null = null; if (queue.length > 0) { if (durationMode === 'total') dur = formatLongDuration(Math.floor(totalSecs)); else if (durationMode === 'remaining') dur = `-${formatLongDuration(Math.floor(remainingSecs))}`; // React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders. // eslint-disable-next-line react-hooks/purity else dur = formatClockTime(Date.now() + remainingSecs * 1000, clockFormat, i18n.language); } const nextMode: DurationMode = durationMode === 'total' ? 'remaining' : durationMode === 'remaining' ? 'eta' : 'total'; const nextTooltipKey = nextMode === 'total' ? 'queue.showTotal' : nextMode === 'remaining' ? 'queue.showRemaining' : 'queue.showEta'; const isEta = durationMode === 'eta'; // Cycle the panel through the three render modes; icon + title show the active // one, tooltip names the one a click switches to. const DISPLAY_MODE_CYCLE: QueueDisplayMode[] = ['queue', 'timeline', 'playlist']; const nextDisplayMode = DISPLAY_MODE_CYCLE[(DISPLAY_MODE_CYCLE.indexOf(queueDisplayMode) + 1) % DISPLAY_MODE_CYCLE.length]; const displayModeLabel = (m: QueueDisplayMode) => m === 'playlist' ? t('queue.modePlaylist') : m === 'timeline' ? t('queue.modeTimeline') : t('queue.title'); const displayModeIcon = (m: QueueDisplayMode) => m === 'playlist' ? : m === 'timeline' ? : ; return (
{/* Small icon button to flip the display mode without leaving the panel. Icon reflects the active mode; the title (next to it) and the tooltip name the modes. Mirrors the Settings toggle. */} {/* Title doubles as the mode indicator so the panel names the active mode rather than always reading "Queue". */}

{displayModeLabel(queueDisplayMode)}

{queue.length > 0 && ( ({queueIndex + 1}/{queue.length}) )} {dur !== null && ( setDurationMode(nextMode)} data-tooltip={t(nextTooltipKey)} style={{ fontSize: "13px", color: isEta ? (isPlaying ? "var(--accent)" : "var(--text-muted)") : "var(--accent)", opacity: isEta && !isPlaying ? 0.5 : 1, whiteSpace: "nowrap", cursor: "pointer", userSelect: "none", }} > · {dur} )}
{activePlaylist && (
{activePlaylist.name}
)}
); }