openvisengine/src/components/ava/header.tsx
2026-10-22 03:29:51 +00:00

172 lines
5.8 KiB
TypeScript
Executable file

// AVA — sticky app header: logo, audio source controls (segmented), demo BPM
// slider, BPM readout, and the beat indicator + "Set Beat" button side by side
// (hard requirement: that pairing is always visible in the header).
'use client'
import { useRef } from 'react'
import { AudioWaveform, Disc3, FileAudio, Mic, Square, Timer } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { BeatDot } from '@/components/ava/beat-dot'
import { tapBeat, useAudioSource } from '@/components/ava/audio-source'
import { useAvaStore } from '@/lib/store'
import { cn } from '@/lib/utils'
function SourceSegmentButton({
active,
onClick,
icon: Icon,
label,
title,
tone = 'default',
}: {
active: boolean
onClick: () => void
icon: React.ComponentType<{ className?: string }>
label: string
title: string
tone?: 'default' | 'stop'
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
title={title}
className={cn(
'flex h-11 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors',
active
? 'bg-emerald-400/15 text-emerald-300 shadow-[inset_0_0_0_1px_rgba(52,211,153,0.35)]'
: tone === 'stop'
? 'text-rose-300/70 hover:bg-zinc-800 hover:text-rose-300'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100',
)}
>
<Icon className="size-3.5" />
{label}
</button>
)
}
function SourceControls() {
const source = useAvaStore((s) => s.source)
const setDemoBpm = useAvaStore((s) => s.setDemoBpm)
const demoBpm = useAvaStore((s) => s.demoBpm)
const { startDemo, startMic, startFile, stop } = useAudioSource()
const fileInputRef = useRef<HTMLInputElement>(null)
return (
<div className="flex flex-wrap items-center gap-2">
<div
role="group"
aria-label="Audio source"
className="flex items-center gap-0.5 rounded-lg border border-zinc-800 bg-zinc-900/60 p-0.5"
>
<SourceSegmentButton
active={source === 'demo'}
onClick={() => void startDemo()}
icon={Disc3}
label="Demo"
title="Built-in demo track (synthesized techno)"
/>
<SourceSegmentButton
active={source === 'mic'}
onClick={() => void startMic()}
icon={Mic}
label="Mic"
title="Live microphone / line-in"
/>
<SourceSegmentButton
active={source === 'file'}
onClick={() => fileInputRef.current?.click()}
icon={FileAudio}
label="File"
title="Play an audio file (loops)"
/>
<SourceSegmentButton active={false} onClick={stop} icon={Square} label="Stop" title="Stop audio" tone="stop" />
</div>
{source === 'demo' && (
<div className="flex h-11 items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/60 px-3">
<span className="font-mono text-[10px] tracking-widest text-emerald-400/80">DEMO</span>
<Slider
value={[demoBpm]}
min={60}
max={200}
step={1}
onValueChange={([v]) => setDemoBpm(v)}
className="w-24"
aria-label="Demo track tempo"
/>
<span className="w-8 text-right font-mono text-xs tabular-nums text-zinc-300">{demoBpm}</span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void startFile(file)
e.target.value = ''
}}
/>
</div>
)
}
function BpmReadout() {
const bpm = useAvaStore((s) => s.stats?.beat.bpm ?? 0)
return (
<div className="text-right leading-none">
<div className="font-mono text-xl font-bold tabular-nums text-emerald-400 sm:text-2xl">
{bpm > 0 ? bpm.toFixed(1) : '—'}
</div>
<div className="mt-0.5 font-mono text-[9px] tracking-[0.2em] text-zinc-500">BPM</div>
</div>
)
}
export function AppHeader() {
return (
<header className="sticky top-0 z-40 border-b border-zinc-800/80 bg-zinc-950/80 backdrop-blur supports-[backdrop-filter]:bg-zinc-950/60">
<div className="mx-auto flex max-w-[1800px] flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2 sm:px-4">
{/* logo */}
<div className="order-1 flex items-center gap-2.5">
<div className="grid size-9 place-items-center rounded-lg bg-emerald-400 shadow-[0_0_16px_rgba(52,211,153,0.35)]">
<AudioWaveform className="size-5 text-zinc-950" aria-hidden />
</div>
<div>
<h1 className="text-base font-bold leading-none tracking-wide">AVA</h1>
<p className="mt-1 hidden text-[10px] leading-none tracking-[0.14em] text-zinc-500 sm:block">
ADVANCED VISUALIZATION AGENT
</p>
</div>
</div>
{/* source controls — full row on mobile, after logo on desktop */}
<div className="order-3 basis-full md:order-2 md:basis-auto">
<SourceControls />
</div>
{/* BPM readout + beat indicator + Set Beat — always paired, always visible */}
<div className="order-2 ml-auto flex items-center gap-2.5 md:order-3 sm:gap-3">
<BpmReadout />
<BeatDot size={44} />
<Button
onClick={tapBeat}
className="h-11 gap-1.5 bg-emerald-400 px-3 font-semibold text-zinc-950 hover:bg-emerald-300 sm:px-4"
title="Tap to set the tempo (or press Space)"
>
<Timer className="size-4" />
<span className="hidden sm:inline">Set Beat</span>
<span className="sm:hidden">Beat</span>
</Button>
</div>
</div>
</header>
)
}