316 lines
13 KiB
TypeScript
Executable file
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>
|
|
)
|
|
}
|