Zum Inhalt

src/App.tsx

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>
);