openvisengine/src/components/ava/queue-bar.tsx
2026-10-29 07:19:50 +00:00

316 lines
13 KiB
TypeScript
Executable file

// 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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
aria-label={title}
className="grid size-8 place-items-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent"
>
{children}
</button>
)
}
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 (
<motion.section
role="region"
aria-label="Playlist queue"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="shrink-0 border-b border-zinc-800/80 bg-zinc-900/50"
>
<div className="mx-auto max-w-[1800px] px-3 py-2 sm:px-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
{/* playlist identity + position */}
<div className="flex min-w-0 items-center gap-2">
<ListMusic className="size-4 shrink-0 text-emerald-400" aria-hidden />
<span className="max-w-[180px] truncate text-sm font-semibold sm:max-w-[280px]" title={title}>
{title || 'Playlist'}
</span>
<span className="shrink-0 rounded border border-zinc-700 bg-zinc-800/60 px-1.5 py-px font-mono text-[10px] tabular-nums text-zinc-300">
{index + 1}/{entries.length}
</span>
{chip && (
<span
className={cn(
'hidden shrink-0 items-center gap-1 rounded border px-1.5 py-px text-[10px] font-medium sm:inline-flex',
chip.cls,
)}
>
{status === 'loading' && <Loader2 className="size-3 animate-spin" aria-hidden />}
{chip.label}
</span>
)}
</div>
{/* now playing / state message */}
<div className="order-3 min-w-0 flex-1 basis-full sm:order-none sm:basis-auto">
{status === 'done' ? (
<p className="flex items-center gap-1.5 text-xs text-zinc-400">
<RotateCcw className="size-3.5 shrink-0 text-emerald-400" aria-hidden />
Queue finished — {entries.length} track{entries.length === 1 ? '' : 's'} played.
</p>
) : status === 'blocked' ? (
<p className="flex items-center gap-1.5 text-xs text-amber-300">
<PauseCircle className="size-3.5 shrink-0" aria-hidden />
The browser blocked auto-advance — press Resume to keep the queue going.
</p>
) : status === 'error' ? (
<p className="flex items-center gap-1.5 truncate text-xs text-rose-300" title={lastError ?? undefined}>
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
{lastError ?? 'The queue stopped after repeated failures.'}
</p>
) : status === 'loading' && updating ? (
<p className="flex items-center gap-1.5 text-xs text-amber-300">
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden />
Updating in Background — the audio engine on the server is updating itself; your
music resumes in a moment.
</p>
) : (
<p className="flex items-center gap-1.5 text-xs text-zinc-400">
{status === 'loading' && <Loader2 className="size-3.5 shrink-0 animate-spin text-emerald-400" aria-hidden />}
{status === 'playing' && preloading && <Clock className="size-3.5 shrink-0 text-emerald-400" aria-hidden />}
<span className="truncate" title={trackTitle ?? current?.title ?? undefined}>
{current?.title ?? trackTitle}
</span>
{status === 'playing' && preloading && (
<span className="hidden shrink-0 text-emerald-300/80 md:inline">· next up is ready</span>
)}
</p>
)}
</div>
{/* controls */}
<div className="order-2 ml-auto flex shrink-0 items-center gap-0.5 sm:order-none">
{status === 'done' ? (
<Button
onClick={replayQueue}
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"
>
<RotateCcw className="size-3.5" aria-hidden />
Replay
</Button>
) : status === 'blocked' ? (
<Button
onClick={() => 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"
>
<Play className="size-3.5" aria-hidden />
Resume
</Button>
) : status === 'error' ? (
<>
{lastErrorCode === 'bot-wall' && (
<Button
onClick={() => 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"
>
<Settings2 className="size-3.5" aria-hidden />
Open Settings
</Button>
)}
<Button
onClick={() => 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"
>
<RotateCcw className="size-3.5" aria-hidden />
Retry
</Button>
</>
) : (
<>
<IconButton onClick={queuePrev} disabled={index <= 0} title="Previous track">
<SkipBack className="size-4" aria-hidden />
</IconButton>
<IconButton
onClick={queueNext}
disabled={status === 'loading' || index >= entries.length - 1}
title="Next track"
>
<SkipForward className="size-4" aria-hidden />
</IconButton>
</>
)}
<IconButton
onClick={() => setExpanded((v) => !v)}
title={expanded ? 'Hide the track list' : 'Show the track list'}
>
{expanded ? <ChevronUp className="size-4" aria-hidden /> : <ChevronDown className="size-4" aria-hidden />}
</IconButton>
<IconButton onClick={stopQueue} title="Stop and clear the queue">
<X className="size-4" aria-hidden />
</IconButton>
</div>
</div>
{/* expandable track list */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
key="tracks"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="mt-2 border-t border-zinc-800/80 pt-2">
<p className="mb-1.5 font-mono text-[10px] tracking-[0.2em] text-zinc-500">
QUEUE — CLICK A TRACK TO JUMP
</p>
<ul className={cn('max-h-64 space-y-1 overflow-y-auto pr-1', thinScrollbar)} role="listbox" aria-label="Queue tracks">
{entries.map((e, i) => {
const isCurrent = i === index && status !== 'done'
const dur = fmtDuration(e.durationSec)
return (
<li key={`${e.url}-${i}`}>
<button
type="button"
onClick={() => 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',
)}
>
<span className="w-5 shrink-0 text-right font-mono text-[10px] tabular-nums text-zinc-500">
{isCurrent ? <Play className="ml-auto size-3 text-emerald-300" aria-hidden /> : i + 1}
</span>
<span className="min-w-0 flex-1 truncate">{e.title}</span>
{dur && (
<span className="shrink-0 font-mono text-[10px] tabular-nums text-zinc-500">{dur}</span>
)}
</button>
</li>
)
})}
</ul>
</div>
</motion.div>
)}
</AnimatePresence>
<p className="mt-1.5 hidden text-[10px] leading-snug text-zinc-600 sm:block">
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.
</p>
</div>
</motion.section>
)
}