122 lines
4.3 KiB
TypeScript
Executable file
122 lines
4.3 KiB
TypeScript
Executable file
// AVA — the green beat indicator.
|
|
//
|
|
// A circular component: emerald center dot + thin SVG phase ring that fills
|
|
// with beat.phase over each beat period. On every beat pulse the dot scales up
|
|
// and glows (~180 ms). When the pulse came from the *predictor* the flash is
|
|
// rendered as an expanding outline ring + "PRED" badge instead of a filled
|
|
// dot, so prediction is visible at a glance.
|
|
|
|
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { motion } from 'framer-motion'
|
|
import { useAvaStore } from '@/lib/store'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface BeatDotProps {
|
|
size?: number
|
|
className?: string
|
|
}
|
|
|
|
export function BeatDot({ size = 44, className }: BeatDotProps) {
|
|
const phase = useAvaStore((s) => s.stats?.beat.phase ?? 0)
|
|
const beatFlash = useAvaStore((s) => s.beatFlash)
|
|
const bpm = useAvaStore((s) => s.stats?.beat.bpm ?? 0)
|
|
const idle = useAvaStore((s) => s.source) === 'idle'
|
|
|
|
// flash state is derived: a flash is "on" until its seq is cleared by the
|
|
// hold timeout (setState only ever happens inside the timeout callback)
|
|
const [clearedSeq, setClearedSeq] = useState(-1)
|
|
|
|
useEffect(() => {
|
|
if (!beatFlash) return
|
|
const hold = beatFlash.source === 'predicted' ? 450 : 180
|
|
const t = setTimeout(() => setClearedSeq(beatFlash.seq), hold)
|
|
return () => clearTimeout(t)
|
|
}, [beatFlash])
|
|
|
|
const flashing = beatFlash !== null && beatFlash.seq > clearedSeq
|
|
const flashSource = flashing ? beatFlash.source : 'none'
|
|
|
|
const r = size / 2 - 3
|
|
const c = 2 * Math.PI * r
|
|
const predicted = flashing && flashSource === 'predicted'
|
|
const dotBase = Math.max(8, size * 0.3)
|
|
|
|
return (
|
|
<div
|
|
className={cn('relative shrink-0 select-none', className)}
|
|
style={{ width: size, height: size }}
|
|
role="img"
|
|
aria-label={
|
|
idle
|
|
? 'Beat indicator — paused, no audio playing'
|
|
: `Beat indicator — ${bpm > 0 ? `${bpm.toFixed(1)} BPM` : 'no tempo'}${flashSource === 'predicted' ? ', predicted' : ''}`
|
|
}
|
|
>
|
|
{/* phase progress ring */}
|
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 -rotate-90">
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={r}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={Math.max(2, size / 22)}
|
|
className="text-zinc-800"
|
|
/>
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={r}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={Math.max(2, size / 22)}
|
|
strokeLinecap="round"
|
|
strokeDasharray={c}
|
|
strokeDashoffset={c * (1 - Math.min(1, Math.max(0, phase)))}
|
|
className="text-emerald-400 transition-[stroke-dashoffset] duration-150 ease-linear"
|
|
/>
|
|
</svg>
|
|
|
|
{/* filled pulse dot (audio / tap beats) — dimmed while no source is
|
|
playing/loaded, since beat detection is suspended (v6.4) */}
|
|
<div
|
|
className={cn(
|
|
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-400 transition-all duration-150 ease-out',
|
|
flashing && !predicted
|
|
? 'glow-beat-flash opacity-100'
|
|
: idle
|
|
? 'opacity-35'
|
|
: 'glow-beat-idle opacity-80',
|
|
)}
|
|
style={{
|
|
width: dotBase,
|
|
height: dotBase,
|
|
transform: `translate(-50%, -50%) scale(${flashing && !predicted ? 1.9 : 1})`,
|
|
}}
|
|
/>
|
|
|
|
{/* predicted beats: expanding outline ring instead of the filled dot */}
|
|
{predicted && (
|
|
<motion.span
|
|
initial={{ scale: 0.45, opacity: 0.9 }}
|
|
animate={{ scale: 1.3, opacity: 0 }}
|
|
transition={{ duration: 0.45, ease: 'easeOut' }}
|
|
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-emerald-300"
|
|
style={{ width: size * 0.62, height: size * 0.62 }}
|
|
/>
|
|
)}
|
|
|
|
{/* PRED badge — visible while a predicted pulse is showing */}
|
|
{predicted && (
|
|
<span
|
|
className="absolute left-1/2 -translate-x-1/2 rounded border border-emerald-400/40 bg-zinc-950/90 px-1 py-px font-mono text-emerald-300"
|
|
style={{ bottom: size >= 64 ? -18 : -13, fontSize: size >= 64 ? 9 : 7, letterSpacing: '0.08em' }}
|
|
>
|
|
PRED
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|