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