// OVE — playlist queue bar (v6.7). // // Slim strip directly under the header, visible while a playlist queue is // active. Shows what's playing, where we are in the list, lazy-load status, // prev/next/jump/stop controls and the expandable track list. The queue // itself (lib/ava/queue.ts) fetches ONE track at a time — this bar surfaces // that promise to the visitor. 'use client' import { useState } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { AlertTriangle, ChevronDown, ChevronUp, Clock, ListMusic, Loader2, PauseCircle, Play, RotateCcw, Settings2, SkipBack, SkipForward, X, } from 'lucide-react' import { Button } from '@/components/ui/button' import { queueJump, queueNext, queuePrev, replayQueue, stopQueue, useQueueStore, type QueueStatus, } from '@/lib/ava/queue' import { useAvaStore } from '@/lib/store' import { thinScrollbar } from '@/components/ava/shared' import { cn } from '@/lib/utils' function fmtDuration(sec: number | null): string { if (sec === null || !Number.isFinite(sec) || sec <= 0) return '' const m = Math.floor(sec / 60) const s = Math.floor(sec % 60) return `${m}:${String(s).padStart(2, '0')}` } function statusChip( status: QueueStatus, preloading: boolean, updating: boolean, ): { label: string; cls: string } | null { switch (status) { case 'loading': // v6.8: background yt-dlp self-update outranks the plain "fetching" return updating ? { label: 'updating engine…', cls: 'border-amber-400/40 bg-amber-400/10 text-amber-300' } : { label: 'fetching', cls: 'border-zinc-700 bg-zinc-800/60 text-zinc-300' } case 'playing': // v6.8: a look-ahead resolve stuck in the self-update loop outranks // the plain "next up ready" chip return updating ? { label: 'updating engine…', cls: 'border-amber-400/40 bg-amber-400/10 text-amber-300' } : preloading ? { label: 'next up ready', cls: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300' } : null case 'done': return { label: 'finished', cls: 'border-zinc-700 bg-zinc-800/60 text-zinc-300' } case 'blocked': return { label: 'paused', cls: 'border-amber-400/40 bg-amber-400/10 text-amber-300' } case 'error': return { label: 'stopped', cls: 'border-rose-400/40 bg-rose-400/10 text-rose-300' } default: return null } } /** Tiny icon button used for prev / next / expand / close. */ function IconButton({ onClick, disabled, title, children, }: { onClick: () => void disabled?: boolean title: string children: React.ReactNode }) { return ( {children} ) } export function QueueBar() { const status = useQueueStore((s) => s.status) const title = useQueueStore((s) => s.title) const entries = useQueueStore((s) => s.entries) const index = useQueueStore((s) => s.index) const trackTitle = useQueueStore((s) => s.trackTitle) const lastError = useQueueStore((s) => s.lastError) const lastErrorCode = useQueueStore((s) => s.lastErrorCode) const preloading = useQueueStore((s) => s.preloading) const updating = useQueueStore((s) => s.updating) const setTab = useAvaStore((s) => s.setTab) const [expanded, setExpanded] = useState(false) if (status === 'idle' || entries.length === 0) return null const chip = statusChip(status, preloading, updating) const current = index >= 0 && index < entries.length ? entries[index] : null return ( {/* playlist identity + position */} {title || 'Playlist'} {index + 1}/{entries.length} {chip && ( {status === 'loading' && } {chip.label} )} {/* now playing / state message */} {status === 'done' ? ( Queue finished — {entries.length} track{entries.length === 1 ? '' : 's'} played. ) : status === 'blocked' ? ( The browser blocked auto-advance — press Resume to keep the queue going. ) : status === 'error' ? ( {lastError ?? 'The queue stopped after repeated failures.'} ) : status === 'loading' && updating ? ( Updating in Background — the audio engine on the server is updating itself; your music resumes in a moment. ) : ( {status === 'loading' && } {status === 'playing' && preloading && } {current?.title ?? trackTitle} {status === 'playing' && preloading && ( · next up is ready )} )} {/* controls */} {status === 'done' ? ( Replay ) : status === 'blocked' ? ( queueJump(index)} className="mr-1 h-8 gap-1.5 bg-emerald-400 px-2.5 text-xs font-semibold text-emerald-950 hover:bg-emerald-300" > Resume ) : status === 'error' ? ( <> {lastErrorCode === 'bot-wall' && ( setTab('settings')} variant="outline" className="mr-1 h-8 gap-1.5 border-amber-400/40 px-2.5 text-xs font-medium text-amber-300 hover:bg-amber-400/10 hover:text-amber-200" > Open Settings )} queueJump(index)} className="mr-1 h-8 gap-1.5 bg-emerald-400 px-2.5 text-xs font-semibold text-emerald-950 hover:bg-emerald-300" > Retry > ) : ( <> = entries.length - 1} title="Next track" > > )} setExpanded((v) => !v)} title={expanded ? 'Hide the track list' : 'Show the track list'} > {expanded ? : } {/* expandable track list */} {expanded && ( QUEUE — CLICK A TRACK TO JUMP {entries.map((e, i) => { const isCurrent = i === index && status !== 'done' const dur = fmtDuration(e.durationSec) return ( queueJump(i)} role="option" aria-selected={isCurrent} disabled={status === 'loading'} title={e.title} className={cn( 'flex w-full items-center gap-2 rounded-md border px-2.5 py-1.5 text-left text-xs transition-colors disabled:opacity-50', isCurrent ? 'glow-segment border-emerald-400/50 bg-emerald-400/10 text-emerald-200' : 'border-zinc-800 bg-zinc-900/60 text-zinc-300 hover:border-emerald-500/40 hover:text-zinc-100', )} > {isCurrent ? : i + 1} {e.title} {dur && ( {dur} )} ) })} )} Lazy queue — each track is fetched from the source only right before it plays, one at a time, so playlist hosts are never hit with bulk requests. ) }
Queue finished — {entries.length} track{entries.length === 1 ? '' : 's'} played.
The browser blocked auto-advance — press Resume to keep the queue going.
{lastError ?? 'The queue stopped after repeated failures.'}
Updating in Background — the audio engine on the server is updating itself; your music resumes in a moment.
{status === 'loading' && } {status === 'playing' && preloading && } {current?.title ?? trackTitle} {status === 'playing' && preloading && ( · next up is ready )}
QUEUE — CLICK A TRACK TO JUMP
Lazy queue — each track is fetched from the source only right before it plays, one at a time, so playlist hosts are never hit with bulk requests.