import { type Component, Match, Show, Switch, createSignal, onCleanup, onMount } from 'solid-js';
import { audioCapture } from './audio/AudioCapture';
import type { Engine } from './engine/Engine';
import { isWebGPUAvailable } from './engine/adapter';
import { useAspect } from './hooks/useAspect';
import { useHotkeys } from './hooks/useHotkeys';
import { useViewport } from './hooks/useViewport';
import { downloadBlob } from './lib/download';
import { captureFilename } from './lib/filename';
import type { FpsMeter } from './lib/timing';
import { BUILT_IN_PRESETS } from './presets/builtIn';
import { videoRecorder } from './recording/VideoRecorder';
import { embedSnippet } from './share/embedCode';
import { clearPresetHash, readPresetFromHash, shareUrl } from './share/encodePreset';
import { presetState, selectPreset, updateEdited } from './stores/presetStore';
import {
flashShareNotice,
requestExport,
setAudioActive,
setAudioLevels,
setAudioPermission,
startRecording,
stopRecording,
uiState,
} from './stores/uiStore';
import { Canvas } from './ui/Canvas';
import { CinemaMode } from './ui/CinemaMode';
import { NoWebGPUScreen } from './ui/NoWebGPUScreen';
import { PresetEditor } from './ui/PresetEditor';
import { PresetLibrary } from './ui/PresetLibrary';
import { SaveDialog } from './ui/SaveDialog';
import { SettingsMenu } from './ui/SettingsMenu';
import { Topbar } from './ui/Topbar';
export const App: Component = () => {
const [gpuAvailable] = createSignal(isWebGPUAvailable());
const [engineFailed, setEngineFailed] = createSignal(false);
const [fpsMeter, setFpsMeter] = createSignal<FpsMeter | null>(null);
const [engine, setEngine] = createSignal<Engine | null>(null);
const [fpsTick, setFpsTick] = createSignal(0);
// `?bare=1` hides every UI overlay (Topbar, share-notice toast, panels).
// Used by the thumbnail-bake script so screenshots show only the canvas.
const bareMode = createSignal(
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('bare'),
)[0];
useAspect();
useHotkeys();
useViewport();
// Sample FPS at 4 Hz to avoid re-rendering the topbar every frame
setInterval(() => setFpsTick((n) => n + 1), 250);
const fps = () => {
fpsTick();
return fpsMeter()?.fps() ?? 0;
};
const agentCount = () => presetState.edited.agentCount;
const toggleAudio = async () => {
if (audioCapture.active) {
audioCapture.stop();
setAudioActive(false);
setAudioLevels(0, 0, 0);
return;
}
setAudioPermission('requesting');
try {
await audioCapture.start(uiState.audio.source);
setAudioPermission('granted');
setAudioActive(true);
} catch {
setAudioPermission('denied');
setAudioActive(false);
}
};
const toggleRecord = async () => {
if (videoRecorder.active) {
const blob = await videoRecorder.stop();
stopRecording();
if (blob) {
downloadBlob(blob, captureFilename(presetState.current.name, videoRecorder.fileExt()));
}
return;
}
const canvas = engine()?.canvas;
if (!canvas) return;
try {
videoRecorder.start(canvas);
startRecording();
} catch {
stopRecording();
}
};
const onShare = async () => {
if (!navigator.clipboard?.writeText) {
flashShareNotice('unsupported');
return;
}
try {
await navigator.clipboard.writeText(shareUrl(presetState.current));
flashShareNotice('copied');
} catch {
flashShareNotice('failed');
}
};
const onCopyEmbed = async () => {
if (!navigator.clipboard?.writeText) {
flashShareNotice('unsupported');
return;
}
try {
await navigator.clipboard.writeText(embedSnippet(presetState.current));
flashShareNotice('copied');
} catch {
flashShareNotice('failed');
}
};
// Hydrate from a shared link if present, then strip the fragment so a
// refresh after an edit doesn't keep re-loading the original. Also
// honour `?preset=<id>` for direct selection — used by the thumbnail
// bake script and as a lightweight "deep link" to a built-in/custom.
onMount(() => {
const incoming = readPresetFromHash();
if (incoming) {
selectPreset(incoming);
clearPresetHash();
return;
}
const params = new URLSearchParams(window.location.search);
const wanted = params.get('preset');
if (wanted) {
const all = [...BUILT_IN_PRESETS, ...presetState.customPresets];
const match = all.find((p) => p.id === wanted);
if (match) selectPreset(match);
}
// `?stress_agents=N` overrides agentCount after preset selection. Used
// by scripts/bench-headroom.ts to measure FPS at a specific agent load
// without requiring UI interaction. The Slider's UI cap is 500k; the
// engine clamps to the perf tier's budget at init time (currently
// 200k on desktop tier).
const stress = params.get('stress_agents');
if (stress) {
const n = Number.parseInt(stress, 10);
if (Number.isFinite(n) && n >= 100 && n <= 500_000) {
updateEdited('agentCount', n);
}
}
});
onCleanup(() => {
audioCapture.stop();
void videoRecorder.stop();
});
return (
<Show when={gpuAvailable() && !engineFailed()} fallback={<NoWebGPUScreen />}>
{/* position: fixed (not absolute) so the canvas tracks iOS Safari's
dynamic viewport — `inset: 0` on a fixed element follows the visual
viewport and grows when the URL bar collapses. Absolute would stay
stuck at the small viewport and leave a black strip at the bottom. */}
<div style={{ position: 'fixed', inset: '0', overflow: 'hidden' }}>
<Canvas
onEngineReady={(e, f) => {
setEngine(e);
setFpsMeter(f);
}}
onEngineFail={() => setEngineFailed(true)}
/>
<Show when={uiState.panel !== 'cinema' && !bareMode()}>
<Topbar
fps={fps}
agentCount={agentCount}
onToggleAudio={() => void toggleAudio()}
onToggleRecord={() => void toggleRecord()}
onShare={() => void onShare()}
onExport={() => requestExport()}
/>
</Show>
<Show when={uiState.shareNotice !== 'idle' ? uiState.shareNotice : null} keyed>
{(kind) => <ShareToast kind={kind} />}
</Show>
<Switch>
<Match when={uiState.panel === 'editor'}>
<PresetEditor />
</Match>
<Match when={uiState.panel === 'library'}>
<PresetLibrary />
</Match>
<Match when={uiState.panel === 'save'}>
<SaveDialog />
</Match>
<Match when={uiState.panel === 'settings'}>
<SettingsMenu
onToggleAudio={() => void toggleAudio()}
onToggleRecord={() => void toggleRecord()}
onShare={() => void onShare()}
onCopyEmbed={() => void onCopyEmbed()}
onExport={() => requestExport()}
/>
</Match>
<Match when={uiState.panel === 'cinema'}>
<CinemaMode />
</Match>
</Switch>
</div>
</Show>
);
};
const SHARE_TOAST_MESSAGE: Record<Exclude<typeof uiState.shareNotice, 'idle'>, string> = {
copied: 'Share link copied to clipboard',
failed: 'Could not copy link',
unsupported: 'Clipboard unavailable in this browser',
};
const ShareToast: Component<{ kind: Exclude<typeof uiState.shareNotice, 'idle'> }> = (p) => (
<div
class="phys-glass"
style={{
position: 'absolute',
bottom: 'calc(env(safe-area-inset-bottom, 0px) + 24px)',
left: '50%',
transform: 'translateX(-50%)',
padding: '10px 16px',
'border-radius': '999px',
'font-size': '12px',
color: p.kind === 'copied' ? 'var(--fg-primary)' : 'var(--phys-rec)',
'z-index': '20',
animation: 'phys-fade-in 160ms var(--ease-signal)',
}}
>
{SHARE_TOAST_MESSAGE[p.kind]}
</div>
);