Zum Inhalt

Physarum Web Component Player — Design

Date: 2026-05-28 Status: Approved (Ansatz A) Author: Johannes + Cowork

Ziel

Ein einbettbarer Player für Physarum-Simulationen, der ein Preset abspielt (rein visuell, kein Editor). Zwei Distributionen aus einer Codebasis:

  1. Echter Web Component — <physarum-player> als eigenständiges JS-Bundle, per <script> auf beliebigen Fremdseiten einbettbar, gekapselt in Shadow DOM.
  2. Embed-Route — eine schlanke player.html, die dasselbe Element aus URL-Query-Params instanziiert (iframe-fähig).

Der Kern-Workflow: In der Vollversion (Editor) ein Preset bauen → als Share-Token exportieren → Token in den Player einsetzen → eingebettet.

Entscheidungen (aus dem Brainstorming)

Thema Entscheidung
Distribution Beides — Web Component (primär) + Embed-Route (gehostete WC-Seite)
Controls Player mit Preset-Wechsler (Play/Pause, Prev/Next, Fullscreen)
Preset-Quellen Alle vier: Share-Token, Built-in-ID, Inline-/externe JSON, Playlist
Audio Kein Audio — rein visuell, Audio-Routing-Felder im Preset werden ignoriert
Fallback Poster-Bild (pre-baked Thumbnails), per Attribut überschreibbar
Architektur Ansatz A: framework-agnostischer PlayerCore, kein Solid im Embed-Bundle

Warum Ansatz A

  • Echtes Drop-in: Das Embed-Bundle enthält nur Engine + dünne Loop-/Control- Schicht — nicht die komplette Editor-App, PWA-Service-Worker, Solid-Reaktivität und Stores. Klein und kollisionsfrei (Shadow DOM).
  • Eine Codebasis, beide Distributionen: Die Embed-Route ist nur eine player.html, die das Element aus URL-Params instanziiert. Kein Drift.
  • Engine ist bereits UI-frei: Engine (tick/setPreset/respawn/resize/dispose) hat null UI-Kopplung, WGSL ist via ?raw inlined → das Bundle braucht keine externen Engine-Assets.

Verworfen: B (Solid via solid-element) zöge Solid+Reaktivität und App-Store-Kopplung ins Embed-Bundle; lohnt nur, falls der Player später zum Mini-Editor wächst. C (nur Embed-Route, "WC" bootet die ganze App) ist kein echtes Drop-in.

Module & Struktur

Neues Verzeichnis src/player/, getrennt von src/ui/ (Editor) und src/stores/. Keine Imports aus src/stores/ oder src/ui/ — nur aus src/engine/, src/presets/, src/share/.

Datei Verantwortung
src/player/types.ts PresetSource, PlayerOptions, Event-Typen
src/player/resolvePreset.ts Eine Preset-Quelle → Preset auflösen (4 Typen)
src/player/playlist.ts Pure Playlist-Index-Logik (advance, loop on/off)
src/player/PlayerCore.ts Engine-Lifecycle, rAF-Loop, Resize/Visibility, Playlist-Cycle
src/player/ControlsOverlay.ts Plain-DOM Overlay: Play/Pause, Prev/Next, Fullscreen
src/player/PhysarumPlayer.ts class extends HTMLElement, Shadow DOM, Attr/Prop-Mapping
src/player/element.ts Bundle-Entry: customElements.define('physarum-player', …)
src/player/embed-page.ts Embed-Route-Entry: URL-Query → <physarum-player>
player.html Zweiter Vite-Input für die Embed-Route

Ergänzung im Editor:

Datei Änderung
src/share/embedCode.ts (neu) Baut den <physarum-player preset="token:…">-Snippet aus einem Preset
src/ui/… (bestehend) "Embed-Code kopieren"-Button neben dem Share-Button

Preset-Quellen — Format

Eine Quelle ist ein String mit optionalem Typ-Präfix:

  • token:<base64url> — Share-Token (msgpack), via decodePreset().
  • builtin:<id> — Lookup in BUILT_IN_PRESETS per id.
  • url:<href> — fetch einer JSON-Datei (ein serialisiertes Preset oder PresetParams), dann PresetSchema-Validierung.
  • json:<inline-json> — Inline-JSON, dann PresetSchema-Validierung.
  • Präfixlos — Auto-Erkennung: matcht es eine Built-in-ID → built-in, sonst als Token behandeln.

resolvePreset(source: string): Promise<Preset | null> — alle Fehler werden gefangen, Fehlschlag → null. Validierung immer gegen PresetSchema (kein hand-gebautes Preset kann die Engine vergiften).

Public API

Attribute (kebab-case) ↔ JS-Properties (camelCase), 1:1 gespiegelt:

Attribut Property Default Bedeutung
preset preset builtin:<DEFAULT_PRESET.id> Eine Preset-Quelle
playlist playlist — Kommagetrennte Liste von Quellen; aktiviert Cycle
interval interval 30 Sekunden pro Preset bei Playlist
controls controls auto auto (hover) | always | none
poster poster — Override-URL fürs Fallback-Bild
asset-base assetBase https://pages.jkaindl.de/physarum Origin für Built-in-Poster
autoplay autoplay true Startet automatisch (sonst pausiert auf Poster)
loop loop true Playlist endlos

Präzedenz: Ist playlist gesetzt, hat es Vorrang vor preset (das preset ist die Einzelquellen-Kurzform). playlist wird an Kommas gesplittet — daher sind json:-Inline-Quellen in einer Playlist nicht unterstützt (Kommas im JSON würden kollidieren); für eigene Presets in Playlists url: verwenden. Einzeln (via preset oder .load()) ist json: voll unterstützt.

JS-Methoden: .play(), .pause(), .next(), .prev(), .load(source: string).

Events (CustomEvent, bubbling, composed): physarum:ready, physarum:presetchange (detail: { index, id, name }), physarum:error (detail: { reason }).

observedAttributes reagiert auf Live-Änderungen aller Attribute.

Datenfluss

Attribut/Property
  → PhysarumPlayer (parse, Defaults)
  → resolvePreset(source)               → Preset | null
  → PlayerCore.load(preset)
  → Engine.setPreset(preset.params) + respawn()
  → rAF-Loop: engine.tick() × clamp(simSpeed, 1..8)
  • ResizeObserver am Host → engine.resize(w, h).
  • IntersectionObserver am Host → Loop pausieren wenn unsichtbar (GPU sparen), fortsetzen wenn wieder sichtbar (sofern nicht manuell pausiert).
  • Audio-Felder des Presets werden ignoriert (setAudio wird nie aufgerufen).
  • Playlist: Timer (interval s) ruft next(); next/prev lösen die nächste Quelle via resolvePreset auf und rufen PlayerCore.load.

Controls (Plain DOM)

On-hover eingeblendetes Overlay im Shadow DOM (CSS gekapselt): - Play/Pause-Toggle. - Prev/Next — nur sichtbar bei Playlist mit > 1 Eintrag. - Fullscreen-Toggle (requestFullscreen am Host-Element). - ARIA-Buttons mit aria-label; Tastatur: Leertaste = Play/Pause, ←/→ = Prev/Next.

controls="none" rendert kein Overlay; controls="always" zeigt es permanent.

Fallback & Robustheit

Poster-<img> (im Shadow DOM, hinter dem Canvas) wird gezeigt, Canvas verborgen, wenn eine der Bedingungen zutrifft: - WebGPU nicht verfügbar (isWebGPUAvailable() false) oder requestAdapter() → null. - Preset-Auflösung fehlgeschlagen (resolvePreset → null).

Poster-Quelle (Priorität): poster-Attribut → bei Built-in <assetBase>/preset-thumbs/<id>.png → sonst keins (leerer Container). Built-in-Poster: assetBase defaultet auf den kanonischen Codeberg-Pages-Host, damit Built-ins auf Fremdseiten ein Bild haben.

prefers-reduced-motion: reduce → Player startet pausiert auf dem Poster (per autoplay-Attribut überschreibbar). Bei jedem Fehlerpfad feuert physarum:error.

Build

  • Standalone-Element: separater Vite-Lib-Build → dist-player/physarum-player.js (Format es, ein selbstständiges File). WGSL ist inlined → keine externen Engine-Assets. Sourcemap an. Neues Script build:element. Realisierung: eigene vite.element.config.ts mit build.lib (entry src/player/element.ts, name PhysarumPlayer, fileName physarum-player), ohne PWA-Plugin.
  • App + Embed-Route: index.html bleibt unverändert; player.html kommt als zweiter Rollup-Input (build.rollupOptions.input) in die bestehende vite.config.ts. Beide Seiten nutzen denselben src/player/-Code.
  • PWA/Workbox für die Haupt-App unangetastet. BASE_PATH-Logik bleibt gültig (auch für player.html und die Poster-Defaults).

Tests

  • resolvePreset (Vitest, env: node): alle vier Quelltypen; Round-trip encodePreset → token: → resolvePreset; ungültiger Token/JSON → null; unbekannte Built-in-ID → null; Auto-Erkennung präfixlos.
  • playlist (Vitest): pure Index-Logik — advance, Wrap bei loop, Stop am Ende ohne loop, Single-Item-Edge-Case.
  • embedCode (Vitest): Snippet-String enthält gültigen Token, der round-trip-bar ist.
  • Playwright-Smoke (Chromium, --enable-unsafe-webgpu): player.html?preset=builtin:<id> lädt, Canvas zeichnet (nicht-leerer Snapshot), physarum:ready feuert; mit playlist= + next() ändert sich physarum:presetchange. WebGPU-abhängige Teile laufen nur im E2E, nicht in Vitest.

Nicht im Scope (YAGNI)

  • Audio-Reaktivität im Embed (bewusst raus).
  • Drag-and-drop Playlist-Editor / Preset-Editing im Player.
  • Eigenes npm-Package-Publishing (das Bundle wird als Datei ausgeliefert/gehostet; Registry-Publish kann später kommen).
  • Server-seitige Preset-Speicherung (Token ist selbst-enthalten).

Offene Detailfragen (in der Plan-Phase zu klären)

  • Genaue Stelle im Editor-UI für den "Embed-Code kopieren"-Button (neben dem bestehenden Share-Button — onShare in App.tsx:97, UI vermutlich in der Topbar/Settings).