Files
portal/src/static/js/api.js
T
jimmyandCursor 3d444d1cb5 Ship the portal FastAPI controller with presets, sequences, and live audio.
Move the app under src/, add ring-spanning patterns with shared fonts and
beat-driven motion, and wire global audio settings into the web UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 00:59:03 +12:00

380 lines
11 KiB
JavaScript

/** @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<string, string>, extras: Record<string, number|string>, frame: number }} PlayerState */
/** @typedef {{ id: string, name: string, pattern: string, brightness: number, speed: number, colors: Record<string, string>, extras: Record<string, number|string> }} 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<string, number>, defaultBrightness: number, defaultSpeed?: number, player?: PlayerState, presets?: Preset[], sequences?: Sequence[], sequence?: SequenceStatus, audio?: AudioStatus, settings?: PortalSettings, errors?: Record<string, string> }} PortalConfig */
/**
* @param {string} path
* @returns {Promise<PortalConfig>}
*/
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<PlayerState>}
*/
export async function fetchPlayer() {
const res = await fetch("/api/player");
if (!res.ok) throw new Error(`player ${res.status}`);
return res.json();
}
/**
* @param {Partial<PlayerState> & { blackout?: boolean }} body
* @returns {Promise<PlayerState>}
*/
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<Preset[]>}
*/
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<Preset>} body
* @returns {Promise<Preset>}
*/
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<Preset>} body
* @returns {Promise<Preset>}
*/
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<void>}
*/
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<Sequence[]>}
*/
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<Sequence>}
*/
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<Sequence>} body
* @returns {Promise<Sequence>}
*/
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<void>}
*/
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<PortalSettings>}
*/
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<PortalSettings>} body
* @returns {Promise<PortalSettings>}
*/
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<AudioDevice[]>}
*/
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<AudioStatus>}
*/
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<AudioStatus>}
*/
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<AudioStatus>}
*/
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<AudioStatus>}
*/
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;
}
}
}