Zum Inhalt

src/player/PhysarumPlayer.ts

// src/player/PhysarumPlayer.ts
import { DEFAULT_PRESET } from '~/presets/builtIn';
import { ControlsOverlay } from './ControlsOverlay';
import { PlayerCore } from './PlayerCore';
import { CANONICAL_HOST, builtinPosterUrl } from './constants';
import { parseSource } from './resolvePreset';
import type { ControlsMode } from './types';

const SHADOW_STYLE = `
  :host { display: block; position: relative; width: 100%; height: 100%; min-height: 120px; background: #060709; overflow: hidden; }
  canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
  .poster { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: none; }
  :host([data-fallback]) canvas { display: none; }
  :host([data-fallback]) .poster { display: block; }
  .phys-controls { position: absolute; left: 0; right: 0; bottom: 0; display: flex; gap: 8px; justify-content: center; padding: 10px; opacity: 0; transition: opacity .2s; pointer-events: none; }
  .phys-controls--always { opacity: 1; pointer-events: auto; }
  .phys-controls--none { display: none; }
  :host(:hover) .phys-controls--auto { opacity: 1; pointer-events: auto; }
  .phys-controls button { width: 40px; height: 40px; border-radius: 8px; border: 1px solid rgba(255,255,255,.2); background: rgba(0,0,0,.45); color: #fff; font-size: 18px; line-height: 1; cursor: pointer; }
  .phys-controls button[hidden] { display: none; }
`;

export class PhysarumPlayer extends HTMLElement {
  static get observedAttributes(): string[] {
    return [
      'preset',
      'playlist',
      'interval',
      'controls',
      'poster',
      'asset-base',
      'autoplay',
      'loop',
    ];
  }

  private core: PlayerCore | null = null;
  private controlsOverlay: ControlsOverlay | null = null;
  private canvasEl!: HTMLCanvasElement;
  private posterEl!: HTMLImageElement;
  private connectedFlag = false;

  connectedCallback(): void {
    if (!this.shadowRoot) this.buildShadow();
    this.connectedFlag = true;
    void this.boot();
  }

  disconnectedCallback(): void {
    this.connectedFlag = false;
    this.core?.dispose();
    this.core = null;
  }

  attributeChangedCallback(): void {
    if (this.connectedFlag) void this.boot();
  }

  // --- properties reflecting attributes (1:1 with attributes) ---
  private reflect(name: string, v: string | null): void {
    if (v == null) this.removeAttribute(name);
    else this.setAttribute(name, v);
  }
  get preset(): string | null {
    return this.getAttribute('preset');
  }
  set preset(v: string | null) {
    this.reflect('preset', v);
  }
  get playlist(): string | null {
    return this.getAttribute('playlist');
  }
  set playlist(v: string | null) {
    this.reflect('playlist', v);
  }
  get interval(): string | null {
    return this.getAttribute('interval');
  }
  set interval(v: string | null) {
    this.reflect('interval', v);
  }
  get controls(): string | null {
    return this.getAttribute('controls');
  }
  set controls(v: string | null) {
    this.reflect('controls', v);
  }
  get poster(): string | null {
    return this.getAttribute('poster');
  }
  set poster(v: string | null) {
    this.reflect('poster', v);
  }
  get assetBase(): string | null {
    return this.getAttribute('asset-base');
  }
  set assetBase(v: string | null) {
    this.reflect('asset-base', v);
  }

  // --- JS control API ---
  play(): void {
    this.core?.play();
    this.controlsOverlay?.setPaused(false);
  }
  pause(): void {
    this.core?.pause();
    this.controlsOverlay?.setPaused(true);
  }
  next(): void {
    this.core?.next();
  }
  prev(): void {
    this.core?.prev();
  }
  load(source: string): void {
    this.removeAttribute('playlist');
    this.setAttribute('preset', source);
  }

  private buildShadow(): void {
    const root = this.attachShadow({ mode: 'open' });
    const style = document.createElement('style');
    style.textContent = SHADOW_STYLE;
    this.canvasEl = document.createElement('canvas');
    this.posterEl = document.createElement('img');
    this.posterEl.className = 'poster';
    this.posterEl.alt = '';
    root.append(style, this.posterEl, this.canvasEl);
  }

  private get sources(): string[] {
    const playlist = this.getAttribute('playlist');
    if (playlist?.trim()) {
      return playlist
        .split(',')
        .map((s) => s.trim())
        .filter(Boolean);
    }
    const preset = this.getAttribute('preset');
    return preset?.trim() ? [preset.trim()] : [`builtin:${DEFAULT_PRESET.id}`];
  }

  private boolAttr(name: string, dflt: boolean): boolean {
    if (!this.hasAttribute(name)) return dflt;
    return this.getAttribute(name) !== 'false';
  }

  private dispatch(type: string, detail?: unknown): void {
    this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
  }

  private toggleFullscreen(): void {
    if (document.fullscreenElement) void document.exitFullscreen();
    else void this.requestFullscreen();
  }

  private showPoster(firstSource: string | undefined): void {
    this.setAttribute('data-fallback', '');
    const override = this.getAttribute('poster');
    let url = override ?? '';
    if (!url && firstSource) {
      const parsed = parseSource(firstSource);
      if (parsed.kind === 'builtin') {
        const base = this.getAttribute('asset-base') ?? CANONICAL_HOST;
        url = builtinPosterUrl(parsed.value, base);
      }
    }
    if (url) this.posterEl.src = url;
  }

  private async boot(): Promise<void> {
    this.core?.dispose();
    this.removeAttribute('data-fallback');

    const mode = (this.getAttribute('controls') ?? 'auto') as ControlsMode;
    const intervalSec = Number(this.getAttribute('interval') ?? '30');
    const intervalMs =
      Number.isFinite(intervalSec) && intervalSec > 0 ? intervalSec * 1000 : 30_000;
    const loop = this.boolAttr('loop', true);
    const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
    const autoplay = this.boolAttr('autoplay', !reduced);
    const sources = this.sources;
    const hasPlaylist = sources.length > 1;

    // (re)build controls
    this.shadowRoot?.querySelector('.phys-controls')?.remove();
    if (mode !== 'none') {
      this.controlsOverlay = new ControlsOverlay(document, mode, hasPlaylist, {
        onPlayPause: () => (this.core?.paused ? this.play() : this.pause()),
        onPrev: () => this.prev(),
        onNext: () => this.next(),
        onFullscreen: () => this.toggleFullscreen(),
      });
      this.shadowRoot?.appendChild(this.controlsOverlay.root);
    } else {
      this.controlsOverlay = null;
    }

    this.core = new PlayerCore(this.canvasEl, {
      onReady: () => {
        this.controlsOverlay?.setPaused(this.core?.paused ?? false);
        this.dispatch('physarum:ready');
      },
      onPresetChange: (d) => this.dispatch('physarum:presetchange', d),
      onError: (d) => {
        this.showPoster(sources[0]);
        this.dispatch('physarum:error', d);
      },
    });

    const ok = await this.core.init({ sources, intervalMs, loop, autoplay });
    if (ok) this.core.observe(this);
  }
}