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