/** @typedef {{ index: number, width: number, height: number, position: string, label?: string }} PanelConfig */ /** @typedef {{ name: string, label: string, default: string }} ColorSpec */ /** @typedef {{ name: string, label: string, type?: 'float'|'text'|'choice'|'bool', default: number|string|boolean, min?: number, max?: number, step?: number, max_length?: number, placeholder?: string, choices?: string[] }} ParamSpec */ /** @typedef {{ id: string, label: string, fps: number, colors: ColorSpec[], params: ParamSpec[] }} PatternSchema */ /** @typedef {{ frame: number, animation?: string, pattern?: string, playing?: boolean, panels: Array<{ index: number, width: number, height: number, rgb: number[] }> }} FramePayload */ /** @typedef {{ pattern: string, playing: boolean, blackout?: boolean, brightness: number, global_brightness?: number, effective_brightness?: number, speed: number, colors: Record, extras: Record, frame: number }} PlayerState */ /** @typedef {{ id: string, name: string, pattern: string, brightness: number, speed: number, colors: Record, extras: Record }} Preset */ /** @typedef {{ preset_id: string, duration_s: number }} SequenceStep */ /** @typedef {{ id: string, name: string, loop: boolean, steps: SequenceStep[] }} Sequence */ /** @typedef {{ active: boolean, sequence_id: string | null, step_index: number }} SequenceStatus */ /** @typedef {{ running: boolean, bpm: number|null, last_beat_ts?: number|null, beat_seq?: number, beat_type?: string, beat_type_confidence?: number, bar_beat?: number, beats_per_bar?: number, is_downbeat?: boolean, phase_confidence?: number, bar_phase_readout?: string, error?: string|null, device?: string|number|null, input_level?: number, mode?: string, kick?: number, snare?: number, hat?: number, bass?: number, energy?: number, flux?: number, strength?: number }} AudioStatus */ /** @typedef {{ id: string|number, name?: string, display_name?: string, label?: string }} AudioDevice */ /** @typedef {{ global_brightness?: number, fallback_bpm?: number, audio_gain?: number }} PortalSettings */ /** @typedef {{ panels: PanelConfig[], patterns: PatternSchema[], animations?: string[], defaultFps?: Record, defaultBrightness: number, defaultSpeed?: number, player?: PlayerState, presets?: Preset[], sequences?: Sequence[], sequence?: SequenceStatus, audio?: AudioStatus, settings?: PortalSettings, errors?: Record }} PortalConfig */ /** * @param {string} path * @returns {Promise} */ export async function fetchConfig(path = "/api/config") { const res = await fetch(path); if (!res.ok) throw new Error(`config ${res.status}`); return res.json(); } /** * @returns {Promise} */ export async function fetchPlayer() { const res = await fetch("/api/player"); if (!res.ok) throw new Error(`player ${res.status}`); return res.json(); } /** * @param {Partial & { blackout?: boolean }} body * @returns {Promise} */ export async function putPlayer(body) { const res = await fetch("/api/player", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { let detail = `${res.status}`; try { const payload = await res.json(); if (payload.detail) detail = payload.detail; } catch { /* ignore */ } throw new Error(detail); } return res.json(); } /** * @returns {Promise} */ export async function fetchPresets() { const res = await fetch("/api/presets"); if (!res.ok) throw new Error(`presets ${res.status}`); return res.json(); } /** * @param {{ name?: string, from_player?: boolean } & Partial} body * @returns {Promise} */ export async function createPreset(body) { const res = await fetch("/api/presets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { let detail = `${res.status}`; try { const payload = await res.json(); if (payload.detail) detail = payload.detail; } catch { /* ignore */ } throw new Error(detail); } return res.json(); } /** * @param {string} id * @param {Partial} body * @returns {Promise} */ export async function updatePreset(id, body) { const res = await fetch(`/api/presets/${encodeURIComponent(id)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { let detail = `${res.status}`; try { const payload = await res.json(); if (payload.detail) detail = payload.detail; } catch { /* ignore */ } throw new Error(detail); } return res.json(); } /** * @param {string} id * @returns {Promise} */ export async function deletePreset(id) { const res = await fetch(`/api/presets/${encodeURIComponent(id)}`, { method: "DELETE" }); if (!res.ok) throw new Error(`delete preset ${res.status}`); } /** * @param {string} id * @returns {Promise<{ player: PlayerState, preset: Preset }>} */ export async function applyPreset(id) { const res = await fetch(`/api/presets/${encodeURIComponent(id)}/apply`, { method: "POST" }); if (!res.ok) { let detail = `${res.status}`; try { const payload = await res.json(); if (payload.detail) detail = payload.detail; } catch { /* ignore */ } throw new Error(detail); } return res.json(); } /** * @param {Response} res * @param {string} fallback */ async function _errorDetail(res, fallback) { let detail = fallback; try { const payload = await res.json(); if (payload.detail) detail = payload.detail; } catch { /* ignore */ } return detail; } /** * @returns {Promise} */ export async function fetchSequences() { const res = await fetch("/api/sequences"); if (!res.ok) throw new Error(`sequences ${res.status}`); return res.json(); } /** * @param {{ name?: string, loop?: boolean, steps: SequenceStep[] }} body * @returns {Promise} */ export async function createSequence(body) { const res = await fetch("/api/sequences", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await _errorDetail(res, `${res.status}`)); return res.json(); } /** * @param {string} id * @param {Partial} body * @returns {Promise} */ export async function updateSequence(id, body) { const res = await fetch(`/api/sequences/${encodeURIComponent(id)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await _errorDetail(res, `${res.status}`)); return res.json(); } /** * @param {string} id * @returns {Promise} */ export async function deleteSequence(id) { const res = await fetch(`/api/sequences/${encodeURIComponent(id)}`, { method: "DELETE" }); if (!res.ok) throw new Error(`delete sequence ${res.status}`); } /** * @param {string} id * @returns {Promise<{ sequence: SequenceStatus, item: Sequence }>} */ export async function playSequence(id) { const res = await fetch(`/api/sequences/${encodeURIComponent(id)}/play`, { method: "POST" }); if (!res.ok) throw new Error(await _errorDetail(res, `${res.status}`)); return res.json(); } /** * @returns {Promise<{ sequence: SequenceStatus }>} */ export async function stopSequence() { const res = await fetch("/api/sequences/stop", { method: "POST" }); if (!res.ok) throw new Error(await _errorDetail(res, `${res.status}`)); return res.json(); } /** * @returns {Promise} */ export async function fetchSettings() { const res = await fetch("/api/settings"); if (!res.ok) throw new Error(await _errorDetail(res, `settings ${res.status}`)); return res.json(); } /** * @param {Partial} body * @returns {Promise} */ export async function putSettings(body) { const res = await fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await _errorDetail(res, `settings ${res.status}`)); return res.json(); } /** * @returns {Promise} */ export async function fetchAudioDevices() { const res = await fetch("/api/audio/devices"); if (!res.ok) throw new Error(await _errorDetail(res, `audio devices ${res.status}`)); const payload = await res.json(); return payload.devices || []; } /** * @returns {Promise} */ export async function fetchAudioStatus() { const res = await fetch("/api/audio/status"); if (!res.ok) throw new Error(await _errorDetail(res, `audio status ${res.status}`)); const payload = await res.json(); return payload.status || payload; } /** * @param {string|number|null} [device] * @returns {Promise} */ export async function startAudio(device = null) { const res = await fetch("/api/audio/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(device == null || device === "" ? {} : { device }), }); if (!res.ok) throw new Error(await _errorDetail(res, `audio start ${res.status}`)); const payload = await res.json(); return payload.status || payload; } /** * @returns {Promise} */ export async function stopAudio() { const res = await fetch("/api/audio/stop", { method: "POST" }); if (!res.ok) throw new Error(await _errorDetail(res, `audio stop ${res.status}`)); const payload = await res.json(); return payload.status || payload; } /** * @returns {Promise} */ export async function resetAudio() { const res = await fetch("/api/audio/reset", { method: "POST" }); if (!res.ok) throw new Error(await _errorDetail(res, `audio reset ${res.status}`)); const payload = await res.json(); return payload.status || payload; } export class PreviewStream { /** @param {(payload: FramePayload) => void} onFrame */ constructor(onFrame) { this._onFrame = onFrame; this._source = null; this._retry = null; /** @type {(() => void) | null} */ this.onDisconnect = null; } start() { this.stop(); this._source = new EventSource("/api/preview"); this._source.onmessage = (event) => { this._onFrame(JSON.parse(event.data)); }; this._source.onerror = () => { this.onDisconnect?.(); this.stop(); this._retry = setTimeout(() => this.start(), 1500); }; } stop() { if (this._retry) { clearTimeout(this._retry); this._retry = null; } if (this._source) { this._source.close(); this._source = null; } } } export class EventStream { /** @param {(payload: object) => void} onEvent */ constructor(onEvent) { this._onEvent = onEvent; this._source = null; this._retry = null; } start() { this.stop(); this._source = new EventSource("/api/events"); this._source.onmessage = (event) => { this._onEvent(JSON.parse(event.data)); }; this._source.onerror = () => { this.stop(); this._retry = setTimeout(() => this.start(), 1500); }; } stop() { if (this._retry) { clearTimeout(this._retry); this._retry = null; } if (this._source) { this._source.close(); this._source = null; } } }