openvisengine/src/components/ava/header.tsx
2026-10-29 09:26:27 +00:00

209 lines
7.6 KiB
TypeScript
Executable file

// OVE — sticky app header: logo, audio source controls (segmented), the
// demo-track chip, What's New!, theme tick switch, BPM readout and the beat
// indicator. (v7.6.1: the Set Beat button moved out — tap-tempo lives in the
// Beat tab, and Space works anywhere.)
'use client'
import { useRef, useSyncExternalStore } from 'react'
import { AudioWaveform, Disc3, FileAudio, Link2, Mic, Moon, Music2, Square, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Switch } from '@/components/ui/switch'
import { BeatDot } from '@/components/ava/beat-dot'
import { LinkDialog } from '@/components/ava/link-dialog'
import { WhatsNew } from '@/components/ava/whats-new-dialog'
import { useAudioSource } from '@/components/ava/audio-source'
import { useAvaStore } from '@/lib/store'
import { getStoredDarkBase } from '@/lib/theme'
import { APP_NAME, APP_SHORT } from '@/lib/version'
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 max-[400px]:gap-0 max-[400px]:px-2.5',
active
? 'glow-segment bg-emerald-400/15 text-emerald-300'
: 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 shrink-0" />
{/* under 400px the five-button segment row exceeds the viewport — the
labels collapse to screen-reader-only (icon + tooltip carries the
meaning, same shortening pattern as What's New! → New) */}
<span className="max-[400px]:sr-only">{label}</span>
</button>
)
}
function SourceControls() {
const source = useAvaStore((s) => s.source)
const sourceTitle = useAvaStore((s) => s.sourceTitle)
const openLinkDialog = useAvaStore((s) => s.setLinkDialogOpen)
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 — Neural Mecha Pulse (mastered industrial EDM, plays once through)"
/>
<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={source === 'link'}
onClick={() => openLinkDialog(true)}
icon={Link2}
label="Link"
title="Stream from a link (YouTube, SoundCloud, direct MP3…)"
/>
<SourceSegmentButton active={false} onClick={stop} icon={Square} label="Stop" title="Stop audio" tone="stop" />
</div>
{source === 'demo' && (
<div
className="flex h-11 max-w-[240px] items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/60 px-3"
title="The demo track is a real mastered recording — its tempo is detected live, not synthesized. Watch the BPM readout lock on."
>
<Music2 className="size-3.5 shrink-0 text-emerald-400/80" aria-hidden />
<span className="truncate font-mono text-[11px] tracking-wide text-zinc-300">
{sourceTitle ?? 'Demo track'}
</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>
)
}
/**
* Light-switch tick: left position (unchecked) keeps the dark theme, flipping
* right (checked) switches the page to the white theme. The dark side is
* whichever dark base the visitor picked on the Settings tab (royal purple
* by default); both choices persist per visitor. The useSyncExternalStore
* mount guard returns false on the server / first client render and true
* after hydration, so the checkbox never causes a hydration mismatch.
*/
const emptySubscribe = () => () => {}
function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme()
const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false)
const isLight = mounted && resolvedTheme === 'light'
return (
<div
className="flex h-11 items-center gap-1.5 rounded-lg border border-zinc-800 bg-zinc-900/60 px-2.5"
title={isLight ? 'Theme: light — flip left for dark' : 'Theme: dark — flip right for light'}
>
<Moon className="size-3.5 shrink-0 text-zinc-500" aria-hidden />
<Switch
checked={isLight}
onCheckedChange={(v) => setTheme(v ? 'light' : getStoredDarkBase())}
aria-label={isLight ? 'Switch to dark theme' : 'Switch to light theme'}
className="data-[state=checked]:bg-emerald-400 data-[state=unchecked]:bg-zinc-600"
/>
<Sun className="size-3.5 shrink-0 text-zinc-500" aria-hidden />
</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="glow-logo grid size-9 place-items-center rounded-lg bg-emerald-400">
<AudioWaveform className="size-5 text-emerald-950" aria-hidden />
</div>
<div>
<h1 className="text-base font-bold leading-none tracking-wide">{APP_SHORT}</h1>
<p className="mt-1 hidden text-[10px] leading-none tracking-[0.14em] text-zinc-500 sm:block">
{APP_NAME.toUpperCase()}
</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>
{/* what's new + theme switch + BPM readout + beat indicator — always visible */}
<div className="order-2 ml-auto flex items-center gap-2.5 md:order-3 sm:gap-3">
<WhatsNew />
<ThemeToggle />
<BpmReadout />
<BeatDot size={44} />
</div>
</div>
<LinkDialog />
</header>
)
}