209 lines
7.6 KiB
TypeScript
Executable file
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>
|
|
)
|
|
}
|