Zum Inhalt

src/player/PlayerCore.ts

import { Engine } from '~/engine/Engine';
// src/player/PlayerCore.ts
import { isWebGPUAvailable, requestAdapter } from '~/engine/adapter';
import type { Preset } from '~/engine/types';
import { nextIndex, prevIndex } from './playlist';
import { resolvePreset } from './resolvePreset';
import type { PlayerCallbacks } from './types';

export interface PlayerCoreOptions {
  sources: string[];
  intervalMs: number;
  loop: boolean;
  autoplay: boolean;
}

export class PlayerCore {
  private readonly canvas: HTMLCanvasElement;
  private readonly cb: PlayerCallbacks;
  private engine: Engine | null = null;

  private sources: string[] = [];
  private index = 0;
  private intervalMs = 30_000;
  private loop = true;
  private current: Preset | null = null;

  private rafId = 0;
  private running = false; // loop actively ticking
  private userPaused = false; // explicit pause() by user
  private visible = true; // IntersectionObserver state
  private cycleTimer = 0;

  private resizeObs: ResizeObserver | null = null;
  private interObs: IntersectionObserver | null = null;
  private disposed = false;

  constructor(canvas: HTMLCanvasElement, callbacks: PlayerCallbacks = {}) {
    this.canvas = canvas;
    this.cb = callbacks;
  }

  get paused(): boolean {
    return this.userPaused;
  }

  /** Boots the GPU adapter + engine and loads the first source. Returns false
   *  (and emits onError) if WebGPU is unavailable, the adapter request fails,
   *  or the first preset can't be resolved — the caller then shows the poster. */
  async init(opts: PlayerCoreOptions): Promise<boolean> {
    this.sources = [...opts.sources];
    this.intervalMs = opts.intervalMs;
    this.loop = opts.loop;
    this.index = 0;

    if (!isWebGPUAvailable()) {
      this.cb.onError?.({ reason: 'no-webgpu' });
      return false;
    }
    const adapter = await requestAdapter();
    if (this.disposed) return false;
    if (!adapter) {
      this.cb.onError?.({ reason: 'no-webgpu' });
      return false;
    }

    const first = await this.resolveAt(this.index);
    if (this.disposed) return false;
    if (!first) {
      this.cb.onError?.({ reason: 'preset-failed' });
      return false;
    }
    this.engine = new Engine({ adapter, canvas: this.canvas, initialPreset: first.params });
    this.current = first;
    this.cb.onReady?.();
    this.emitPresetChange();

    this.userPaused = !opts.autoplay;
    if (opts.autoplay) this.play();
    return true;
  }

  private async resolveAt(i: number): Promise<Preset | null> {
    const src = this.sources[i];
    if (src === undefined) return null;
    return resolvePreset(src);
  }

  private emitPresetChange(): void {
    if (!this.current) return;
    this.cb.onPresetChange?.({ index: this.index, id: this.current.id, name: this.current.name });
  }

  private async loadIndex(i: number): Promise<void> {
    if (!this.engine) return;
    const preset = await this.resolveAt(i);
    if (!preset || this.disposed || !this.engine) return;
    this.index = i;
    this.current = preset;
    this.engine.setPreset(preset.params);
    this.engine.respawn();
    this.emitPresetChange();
  }

  next(): void {
    const ni = nextIndex(this.index, this.sources.length, this.loop);
    if (ni === null) return;
    void this.loadIndex(ni);
    this.restartCycle();
  }

  prev(): void {
    const pi = prevIndex(this.index, this.sources.length, this.loop);
    if (pi === null) return;
    void this.loadIndex(pi);
    this.restartCycle();
  }

  play(): void {
    this.userPaused = false;
    this.startLoop();
    this.startCycle();
  }

  pause(): void {
    this.userPaused = true;
    this.stopLoop();
    this.stopCycle();
  }

  // Loop runs only when not user-paused AND visible.
  private startLoop(): void {
    if (this.running || !this.engine || this.userPaused || !this.visible) return;
    this.running = true;
    // Frame-rate-independent stepping: drain real elapsed time in fixed 60 Hz ticks, regardless of the
    // display's refresh rate. Stepping once per rAF instead ran the sim twice as fast on a 120 Hz /
    // ProMotion screen (Chrome) as on a 60 Hz one (Safari caps the rAF). `acc` is clamped so a tab
    // switch or stall can never produce a catch-up spiral.
    const TICK_MS = 1000 / 60;
    let last = performance.now();
    let acc = 0;
    const tick = (now: number): void => {
      if (!this.running || !this.engine) return;
      acc = Math.min(acc + (now - last), 250);
      last = now;
      const steps = Math.max(1, Math.min(8, Math.round(this.current?.params.simSpeed ?? 1)));
      while (acc >= TICK_MS) {
        for (let s = 0; s < steps; s += 1) this.engine.tick();
        acc -= TICK_MS;
      }
      this.rafId = requestAnimationFrame(tick);
    };
    this.rafId = requestAnimationFrame(tick);
  }

  private stopLoop(): void {
    this.running = false;
    cancelAnimationFrame(this.rafId);
  }

  private startCycle(): void {
    this.stopCycle();
    if (this.sources.length <= 1 || this.userPaused) return;
    this.cycleTimer = window.setInterval(() => {
      const ni = nextIndex(this.index, this.sources.length, this.loop);
      if (ni === null) {
        this.stopCycle();
        return;
      }
      void this.loadIndex(ni);
    }, this.intervalMs);
  }

  private stopCycle(): void {
    if (this.cycleTimer) {
      clearInterval(this.cycleTimer);
      this.cycleTimer = 0;
    }
  }

  private restartCycle(): void {
    if (!this.userPaused) this.startCycle();
  }

  observe(host: HTMLElement): void {
    this.resizeObs = new ResizeObserver((entries) => {
      const e = entries[0];
      if (!e || !this.engine) return;
      const { width, height } = e.contentRect;
      if (width > 0 && height > 0) this.engine.resize(width, height);
    });
    this.resizeObs.observe(host);

    this.interObs = new IntersectionObserver(
      (entries) => {
        const e = entries[0];
        if (!e) return;
        this.visible = e.isIntersecting;
        if (this.visible) this.startLoop();
        else this.stopLoop();
      },
      { threshold: 0 },
    );
    this.interObs.observe(host);
  }

  dispose(): void {
    this.disposed = true;
    this.stopLoop();
    this.stopCycle();
    this.resizeObs?.disconnect();
    this.interObs?.disconnect();
    this.engine?.dispose();
    this.engine = null;
  }
}