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:
- Echter Web Component —
<physarum-player>als eigenständiges JS-Bundle, per<script>auf beliebigen Fremdseiten einbettbar, gekapselt in Shadow DOM. - 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?rawinlined → 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), viadecodePreset().builtin:<id>— Lookup inBUILT_IN_PRESETSperid.url:<href>—fetcheiner JSON-Datei (ein serialisiertesPresetoderPresetParams), dannPresetSchema-Validierung.json:<inline-json>— Inline-JSON, dannPresetSchema-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)
ResizeObserveram Host →engine.resize(w, h).IntersectionObserveram Host → Loop pausieren wenn unsichtbar (GPU sparen), fortsetzen wenn wieder sichtbar (sofern nicht manuell pausiert).- Audio-Felder des Presets werden ignoriert (
setAudiowird nie aufgerufen). - Playlist: Timer (
intervals) ruftnext();next/prevlösen die nächste Quelle viaresolvePresetauf und rufenPlayerCore.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(Formates, ein selbstständiges File). WGSL ist inlined → keine externen Engine-Assets. Sourcemap an. Neues Scriptbuild:element. Realisierung: eigenevite.element.config.tsmitbuild.lib(entrysrc/player/element.ts, namePhysarumPlayer, fileNamephysarum-player), ohne PWA-Plugin. - App + Embed-Route:
index.htmlbleibt unverändert;player.htmlkommt als zweiter Rollup-Input (build.rollupOptions.input) in die bestehendevite.config.ts. Beide Seiten nutzen denselbensrc/player/-Code. - PWA/Workbox für die Haupt-App unangetastet.
BASE_PATH-Logik bleibt gültig (auch fürplayer.htmlund die Poster-Defaults).
Tests¶
resolvePreset(Vitest, env: node): alle vier Quelltypen; Round-tripencodePreset→token:→resolvePreset; ungültiger Token/JSON →null; unbekannte Built-in-ID →null; Auto-Erkennung präfixlos.playlist(Vitest): pure Index-Logik — advance, Wrap beiloop, Stop am Ende ohneloop, 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:readyfeuert; mitplaylist=+next()ändert sichphysarum: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 —
onShareinApp.tsx:97, UI vermutlich in der Topbar/Settings).