172 lines
5.8 KiB
TypeScript
Executable file
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>
|
|
)
|
|
}
|