lightloom / frontend /js /stage.js
Efradeca's picture
chore: deploy private lightloom build
14d6183 verified
Raw
History Blame Contribute Delete
30.1 kB
// frontend/js/stage.js — Lightloom "La Sala" · the CONTROLLER (LL.controller)
//
// IMPORTANT: this file does NOT define LL.stage. The WebGL renderer lives in
// frontend/js/stage-gl.js and owns window.LL.stage. Here we own LL.controller:
// the screen state machine + the SSE orchestration that drives stage / slate /
// subtitles / hud from the events streamed by LL.api.streamRecital.
//
// State machine (docs/01 §6.2):
// Lobby -> Listening/Processing -> Revealing -> Theater
// ...with a soft ErrorSoft branch when a beat is "fogged".
//
// Vanilla ES module. No frameworks, no build step. We read/write only the ids
// defined by the DOM contract in index.html and call sibling modules through
// the shared window.LL namespace.
import "./api.js"; // ensures LL.api exists before the controller wires up
const LL = (window.LL = window.LL || {});
// ---------------------------------------------------------------------------
// Bilingual micro-copy. Subtitles come from the poem itself; these are only the
// system/toast strings the controller emits. Kept here so the controller is
// self-contained even if a given data-es/data-en node isn't present.
// ---------------------------------------------------------------------------
const COPY = {
warming: { es: "Encendiendo el proyector…", en: "Warming up the projector…" },
ready: { es: "Rodando — te escucho.", en: "Rolling — I'm listening." },
directing: { es: "La directora decide el plano…", en: "The director calls the shot…" },
painting: { es: "Pintando el plano…", en: "Painting the shot…" },
fogged: { es: "El plano se veló; seguimos rodando.", en: "The shot got fogged; rolling on." },
empty: { es: "Pega un poema antes de rodar.", en: "Paste a poem before rolling." },
listening: { es: "Te escucho… habla y pulsa para parar.", en: "Listening… speak, then click to stop." },
transcribing: { es: "Transcribiendo tu voz…", en: "Transcribing your voice…" },
micDenied: { es: "No pude usar el micrófono. Escribe el poema y pulsa Rodar.", en: "Couldn't use the mic. Type your poem and hit Roll." },
noSpeech: { es: "No escuché nada. Inténtalo otra vez o escribe el poema.", en: "I didn't catch that. Try again or type the poem." },
connect: {
es: "No se pudo abrir la sala. Reintenta en un momento.",
en: "Couldn't open the theater. Try again in a moment.",
},
stream: {
es: "Se cortó la proyección. Reintenta.",
en: "The projection dropped. Try again.",
},
threeFogged: {
es: "Tres planos seguidos se velaron — prueba el Showcase.",
en: "Three shots fogged in a row — try the Showcase.",
},
};
// Micro-stage chip names (the #stage-bar lights these in order). Maps the
// backend stage -> the human label LL.slate.setStage shows (bilingual; the
// controller picks the current language). English-default for the hackathon.
const STAGE_CHIP = {
warming: { es: "OYENDO", en: "LISTENING" },
ready: { es: "OYENDO", en: "LISTENING" },
directing: { es: "DIRIGIENDO", en: "DIRECTING" },
directed: { es: "DIRIGIENDO", en: "DIRECTING" },
painting: { es: "PINTANDO EL MUNDO", en: "PAINTING THE WORLD" },
painted: { es: "REVELANDO", en: "REVEALING" },
depth: { es: "REVELANDO", en: "REVEALING" },
};
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
const controller = {
// --- runtime state ---
state: "lobby", // lobby | processing | revealing | theater | error
lang: "en", // current UI language (English-default for the hackathon); synced to <html lang> + localStorage
reducedMotion: false, // body.reduced-motion mirror
running: false, // a recital stream is live
_shotIndex: 0, // 1-based plano counter across the whole film
_sceneId: 0, // last scene id from a 'directed' event
_consecutiveFogged: 0, // for the "three in a row" hint
_pending: new Map(), // beat -> { image?, depth?, shot?, cameraMove?, transition? }
_revealChain: Promise.resolve(), // serializes revealBeat() so beats show in order
_toastTimer: null,
/**
* Wire the DOM and restore persisted preferences. Safe to call once on load.
* Tolerates missing optional nodes so a partial index.html still boots.
*/
init() {
this._restorePrefs();
this._wireLobby();
this._wireControls();
this._wireLangToggle();
this._initStageRenderer();
this._initRecorder();
this._applyLang(this.lang); // paint all data-es/data-en nodes once
this.show("lobby");
return this;
},
// ---- screen state machine -------------------------------------------------
/**
* Activate exactly one screen section (.is-active). Sections are #lobby/#set/
* #theater/#showcase/#about. #set hosts the live stage; we reveal it on start.
*/
show(screen) {
const ids = ["lobby", "set", "theater", "showcase", "about"];
for (const id of ids) {
const el = document.getElementById(id);
if (el) el.classList.toggle("is-active", id === screen);
}
},
// ---- lobby + controls wiring ---------------------------------------------
_wireLobby() {
on("btn-recital", "click", () => this.startRecital());
on("btn-recital-go", "click", () => this.startRecital());
on("btn-narrate", "click", () => {
// Live narration: capture the mic, transcribe (Cohere), then film the world.
// If the recorder isn't available/supported, fall back to the typed recital
// so the button is never dead.
if (LL.recorder && LL.recorder.supported && typeof LL.recorder.toggle === "function") {
LL.recorder.toggle();
} else {
const ta = document.getElementById("recital-text");
if (ta && ta.value.trim()) this.startRecital();
else { this.toast(t(COPY.micDenied, this.lang)); if (ta) ta.focus(); }
}
});
on("btn-showcase", "click", () => this.startShowcase());
},
_wireControls() {
// ⏹ Cut & watch — stop the stream and open the Theater with what we have.
on("ctl-cut", "click", () => this.cut());
// 🔇 bed on/off — toggle ambient bed if the audio module is present.
on("ctl-bed", "click", (e) => {
const btn = e.currentTarget;
const muted = btn.classList.toggle("is-muted");
btn.setAttribute("aria-pressed", String(!muted));
if (LL.audio && typeof LL.audio.setMuted === "function") LL.audio.setMuted(muted);
});
// ♿ reduce motion — manual toggle, persisted; OS preference also honored.
on("ctl-reduce-motion", "click", () => this.setReducedMotion(!this.reducedMotion));
const mq = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)");
if (mq) {
if (mq.matches) this.setReducedMotion(true, /*fromOS*/ true);
const onChange = (e) => this.setReducedMotion(e.matches, true);
if (mq.addEventListener) mq.addEventListener("change", onChange);
else if (mq.addListener) mq.addListener(onChange); // Safari < 14
}
},
_wireLangToggle() {
// Two possible entry points per the DOM contract: a lobby #lang-pill and an
// in-stage #lang-toggle. Both flip ES<->EN.
const flip = () => this.setLang(this.lang === "es" ? "en" : "es");
on("lang-toggle", "click", flip);
on("lang-pill", "click", flip);
},
_initStageRenderer() {
// The immersive PANORAMA world (stage-pano.js -> LL.pano) is the stage: an
// equirectangular sphere the camera lives inside. It owns #stage-canvas. If
// it isn't present we fall back to the beat renderer (LL.stage / stage-gl).
const canvas = document.getElementById("stage-canvas");
if (!canvas) return;
const r = this._renderer();
if (r && typeof r.init === "function") {
try {
r.init(canvas);
} catch (err) {
console.error("[LL.controller] stage renderer init failed", err);
}
}
},
/** The active stage renderer: prefer the immersive world (LL.pano), else the
* beat renderer (LL.stage). Looked up at call time so load order can't matter. */
_renderer() {
return LL.pano || LL.stage || null;
},
/** Wire the voice recorder (LL.recorder): mic -> Cohere Transcribe -> the
* transcript fills the recital and films the world. Mic state drives toasts +
* a recording class on the Narrate button. No-op if the recorder is absent. */
_initRecorder() {
if (!LL.recorder || typeof LL.recorder.init !== "function") return;
LL.recorder.init({
getLang: () => this.lang,
onState: (s) => {
const btn = document.getElementById("btn-narrate");
if (btn) {
btn.classList.toggle("is-recording", s === "recording");
btn.classList.toggle("is-busy", s === "transcribing");
btn.setAttribute("aria-pressed", String(s === "recording"));
}
if (s === "recording") this.toast(t(COPY.listening, this.lang));
else if (s === "transcribing") this.toast(t(COPY.transcribing, this.lang));
},
onTranscript: (text, error) => {
const ta = document.getElementById("recital-text");
const clean = (text || "").trim();
if (clean) {
if (ta) ta.value = clean;
this.startRecital();
} else {
const denied = error === "mic_denied" || error === "unsupported";
this.toast(t(denied ? COPY.micDenied : COPY.noSpeech, this.lang));
if (ta) ta.focus();
}
},
});
},
// ---- the main flow: start a recital ---------------------------------------
/**
* Read #recital-text, switch to the Set, and stream the recital. Orchestrates
* every SSE event into slate / subtitles / hud / stage. Idempotent guard:
* ignores re-entry while a stream is already running.
*/
async startRecital() {
if (this.running) return;
const textarea = document.getElementById("recital-text");
const text = textarea ? textarea.value : "";
if (!text || !text.trim()) {
this.toast(t(COPY.empty, this.lang));
// Nudge the user to the recital input if we have it.
if (textarea && typeof textarea.focus === "function") textarea.focus();
return;
}
this._resetFilmState();
this.running = true;
this.state = "processing";
this.show("set");
this.setStageBar("warming");
if (LL.subtitles) LL.subtitles.clear && LL.subtitles.clear();
try {
await LL.api.streamPanorama(text, this.lang, (ev) => this._onEvent(ev));
} catch (err) {
// streamPanorama itself converts most failures into {stage:"error"} events;
// this only catches truly unexpected throws.
console.error("[LL.controller] streamPanorama threw", err);
this.toast(t(COPY.stream, this.lang));
} finally {
this.running = false;
// The immersive world IS the destination — we stay in #set inhabiting it,
// no separate theater handoff. The world keeps drifting until a new Roll.
await this._revealChain.catch(() => {});
}
},
/** ⏹ Cut: stop the GPU stream. If a world is already up, stay inside it; if
* nothing has revealed yet, return to the lobby rather than a blank set. The
* Showcase reel exits to the lobby (it's a reel, not a generation to keep). */
cut() {
if (LL.api && typeof LL.api.cancel === "function") LL.api.cancel();
this._showcaseStop = true;
this.running = false;
this.setStageBar(null);
if (this._showcasePlaying || !this._worldShown) {
this._showcasePlaying = false;
this._hideBadge();
this.show("lobby");
}
},
// ---- the Showcase reel: an instant, GPU-free journey through pre-rendered
// worlds (one per stanza). Everything here was produced by this same app's
// /panorama endpoint — see scripts/build_showcase.py + the honest badge. -------
/**
* Play the pre-rendered immersive journey. Loads the manifest of worlds and
* cross-fades through them on the live sphere with each stanza as a caption,
* looping until Cut. Falls back to the static grid if no manifest is present.
*/
async startShowcase() {
if (this.running) return;
const manifest = await this._loadShowcaseManifest();
if (!manifest.length) {
this.show("showcase"); // graceful static-grid fallback
return;
}
this._resetFilmState();
this.running = true;
this._showcasePlaying = true;
this._showcaseStop = false;
this.show("set");
this.setStageBar(null);
this._setLiveChrome(false); // hide the live slate + pipeline bar for the reel
if (LL.subtitles && LL.subtitles.clear) LL.subtitles.clear();
this._showBadge(
t({ es: "✦ SHOWCASE · pre-renderizado con esta app", en: "✦ SHOWCASE · pre-rendered with this app" }, this.lang),
);
try {
const dwell = this.reducedMotion ? 3400 : 7000;
// Loop the reel so a judge who just watches sees the whole journey cycle.
while (!this._showcaseStop) {
for (const w of manifest) {
if (this._showcaseStop) break;
if (LL.subtitles && LL.subtitles.show) LL.subtitles.show(w.title || w.stanza);
if (LL.pano && typeof LL.pano.show === "function") {
await LL.pano.show({
panoramaUrl: absUrl(w.panorama),
depthUrl: w.depth ? absUrl(w.depth) : null,
});
}
this._worldShown = true;
await this._sleep(dwell);
}
if (this.reducedMotion) break; // don't auto-loop under reduced motion
}
} catch (err) {
console.error("[LL.controller] showcase failed", err);
} finally {
this.running = false;
}
},
async _loadShowcaseManifest() {
try {
const res = await fetch("/frontend/assets/showcase/manifest.json", { cache: "no-store" });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data.filter((w) => w && w.panorama) : [];
} catch (_) {
return [];
}
},
/** A cancellable sleep that wakes early if the showcase is stopped. */
_sleep(ms) {
return new Promise((resolve) => {
const start = performance.now();
const tick = () => {
if (this._showcaseStop || performance.now() - start >= ms) return resolve();
requestAnimationFrame(tick);
};
tick();
});
},
/** Small honest chip shown while the Showcase reel plays. */
_showBadge(text) {
let el = document.getElementById("ll-showcase-badge");
if (!el) {
el = document.createElement("div");
el.id = "ll-showcase-badge";
el.setAttribute("role", "note");
el.style.cssText =
"position:absolute;top:14px;left:50%;transform:translateX(-50%);z-index:7;" +
"font:600 12px/1.2 var(--font-ui,system-ui);letter-spacing:.08em;color:var(--ink,#f0e9da);" +
"background:rgba(10,10,14,.55);border:1px solid rgba(240,233,218,.18);border-radius:999px;" +
"padding:6px 14px;backdrop-filter:blur(6px);pointer-events:none;white-space:nowrap;";
const set = document.getElementById("set") || document.body;
set.appendChild(el);
}
el.textContent = text;
el.hidden = false;
},
_hideBadge() {
const el = document.getElementById("ll-showcase-badge");
if (el) el.hidden = true;
},
/** Show/hide the live-only chrome (the Director's slate, pipeline bar, scene/
* shot counter, GPU ledger). The pre-rendered Showcase has no live pipeline,
* so we hide them for a clean reel — only the brand, caption + honest badge. */
_setLiveChrome(visible) {
for (const id of ["slate", "stage-bar", "hud-counter", "hud-budget"]) {
const el = document.getElementById(id);
if (el) el.style.visibility = visible ? "" : "hidden";
}
},
// ---- SSE event orchestration ----------------------------------------------
_onEvent(ev) {
if (!ev || typeof ev !== "object") return;
const stage = ev.stage;
// Light the matching micro-stage chip for any stage we recognize.
this.setStageBar(stage);
switch (stage) {
case "warming":
this.state = "processing";
if (LL.hud && LL.hud.setBudget) LL.hud.setBudget(0);
break;
case "ready":
this.state = "processing";
break;
case "directing":
// Show the beat text as a subtitle the moment direction begins, so the
// room reads while the Director decides.
if (typeof ev.text === "string" && LL.subtitles && LL.subtitles.show) {
LL.subtitles.show(ev.text);
}
break;
case "directed":
this._onDirected(ev);
break;
case "painting":
// purely a micro-stage signal; chip already lit above.
break;
case "painted":
// The immersive world's equirectangular panorama is ready. Reveal it now
// (with a short grace for its depth map); never make the room wait blank.
this._worldPano = absUrl(ev.panorama || ev.image);
if (this._worldTimer) clearTimeout(this._worldTimer);
this._worldTimer = setTimeout(() => this._revealWorld(true), 1800);
this._revealWorld(false);
break;
case "depth":
// Its depth map drives the in-sphere relief/parallax. Reveal with both.
this._worldDepth = absUrl(ev.depth);
this._revealWorld(false);
break;
case "fogged":
this._onFogged(ev);
break;
case "done":
this._onDone(ev);
break;
case "error":
this._onError(ev);
break;
default:
// Unknown stage — ignore gracefully (forward-compatible).
break;
}
},
_onDirected(ev) {
const shot = ev.shot || {};
this._shotIndex += 1;
this._sceneId = typeof ev.scene_id === "number" ? ev.scene_id : this._sceneId;
// Stash the directing decision so revealBeat() (which fires later, once the
// image+depth pair arrives) gets the right camera move + refined transition.
// ev.transition is the *refined* transition (continuity hard_cut->crossfade).
this._stash(ev.beat, {
shot,
cameraMove: shot.camera_move || "static",
transition: ev.transition || shot.transition || "crossfade",
});
// The Slate is the hero: render the Director's decision now.
if (LL.slate && LL.slate.update) {
try {
LL.slate.update(shot, this._sceneId, this._shotIndex);
} catch (err) {
console.error("[LL.controller] slate.update failed", err);
}
}
if (LL.hud && LL.hud.setCounter) LL.hud.setCounter(this._sceneId, this._shotIndex);
},
_onFogged(ev) {
this._consecutiveFogged += 1;
// Drop any half-collected pieces for this beat so it can't reveal later.
if (typeof ev.beat === "number") this._pending.delete(ev.beat);
this.toast(t(COPY.fogged, this.lang));
if (this._consecutiveFogged >= 3) {
this.toast(t(COPY.threeFogged, this.lang));
}
},
_onDone() {
// The world is painted and we are inhabiting it on the #set canvas — no
// separate theater screen. Force the reveal in case depth never arrived.
this._revealWorld(true);
this.setStageBar(null);
},
_onError(ev) {
const reason = ev && ev.error ? String(ev.error) : "";
if (reason.startsWith("empty")) this.toast(t(COPY.empty, this.lang));
else if (reason.startsWith("connect")) this.toast(t(COPY.connect, this.lang));
else this.toast(t(COPY.stream, this.lang));
// A hard error ends the run; surface whatever beats we managed to reveal.
this.running = false;
if (this.state !== "theater") this._maybeOpenTheater();
},
// ---- beat reveal: pair painted+depth, then drive the renderer -------------
_stash(beat, patch) {
if (typeof beat !== "number") return;
const cur = this._pending.get(beat) || {};
this._pending.set(beat, Object.assign(cur, patch));
},
/**
* Reveal a beat once BOTH its image and depth maps are available. The two
* arrive as separate events ('painted' then 'depth'); we only fire once both
* are present. Reveals are serialized through _revealChain so they always play
* in beat order even if events interleave.
*/
_tryReveal(beat) {
const data = this._pending.get(beat);
if (!data || !data.image || !data.depth) return; // wait for the pair
this._pending.delete(beat);
this._consecutiveFogged = 0; // a successful reveal breaks the fogged streak
this.state = "revealing";
const job = {
imageUrl: data.image,
depthUrl: data.depth,
cameraMove: data.cameraMove || (data.shot && data.shot.camera_move) || "static",
transition: data.transition || (data.shot && data.shot.transition) || "crossfade",
reducedMotion: this.reducedMotion,
};
// Chain so beats reveal sequentially; swallow per-beat errors so one bad
// texture upload doesn't stall the whole film.
this._revealChain = this._revealChain
.then(() => {
if (LL.stage && typeof LL.stage.revealBeat === "function") {
return LL.stage.revealBeat(job);
}
})
.catch((err) => console.error("[LL.controller] revealBeat failed", err));
},
/**
* Reveal the immersive panorama WORLD into the sphere. Called from 'painted'
* (panorama ready) and 'depth' (relief ready). Shows once, as soon as the
* panorama exists and EITHER its depth has arrived OR the grace timer forced
* it — so the world never blocks on a missing/slow depth map.
*/
_revealWorld(force) {
if (this._worldShown || !this._worldPano) return;
if (!this._worldDepth && !force) return; // brief grace for the depth map
this._worldShown = true;
if (this._worldTimer) {
clearTimeout(this._worldTimer);
this._worldTimer = null;
}
this.state = "revealing";
const pano = LL.pano;
if (!pano || typeof pano.show !== "function") {
console.error("[LL.controller] LL.pano renderer unavailable");
return;
}
this._revealChain = this._revealChain
.then(() =>
pano.show({ panoramaUrl: this._worldPano, depthUrl: this._worldDepth || null }),
)
.then(() => {
this.state = "theater"; // inhabiting the world (still on the #set canvas)
})
.catch((err) => console.error("[LL.controller] pano.show failed", err));
},
// ---- theater handoff ------------------------------------------------------
_maybeOpenTheater() {
// Open the Theater only after every queued reveal has resolved, so the last
// frame is actually on screen before the curtain.
this._revealChain.then(() => this._openTheater()).catch(() => this._openTheater());
},
_openTheater() {
const theaterEl = document.getElementById("theater");
if (this.state === "theater" && theaterEl && theaterEl.classList.contains("is-active")) {
return; // already there
}
this.state = "theater";
this.setStageBar(null); // clear the micro-stage bar
// Hand off to the theater module if present; otherwise just show the screen.
if (LL.theater && typeof LL.theater.open === "function") {
try {
LL.theater.open();
} catch (err) {
console.error("[LL.controller] theater.open failed", err);
}
}
this.show("theater");
},
// ---- micro-stage bar ------------------------------------------------------
/** Light the chip for a backend stage (or clear when given a falsy stage). */
setStageBar(stage) {
if (!LL.slate || typeof LL.slate.setStage !== "function") return;
const pair = stage ? STAGE_CHIP[stage] : null;
const chip = pair ? t(pair, this.lang) : null;
if (chip || stage === null) {
try {
LL.slate.setStage(chip);
} catch (err) {
console.error("[LL.controller] slate.setStage failed", err);
}
}
},
// ---- language + reduced motion --------------------------------------------
setLang(lang) {
this.lang = lang === "en" ? "en" : "es";
try {
localStorage.setItem("ll.lang", this.lang);
} catch (_) {}
this._applyLang(this.lang);
},
/** Apply the language across BOTH i18n patterns used in the app and update pills.
* Pattern A (index.html shell): sibling spans `<span data-es>..</span><span data-en hidden>..</span>`
* — the attribute is empty, the text is the content -> show only the matching-lang span.
* Pattern B (hud.js / slate.js): a single element with valued `data-es="X" data-en="Y"`
* attributes -> swap textContent. */
_applyLang(lang) {
document.documentElement.setAttribute("lang", lang);
document.querySelectorAll("[data-es],[data-en]").forEach((node) => {
const esVal = node.getAttribute("data-es");
const enVal = node.getAttribute("data-en");
const valued = (esVal && esVal.length) || (enVal && enVal.length);
if (valued) {
const v = lang === "en" ? enVal : esVal;
if (v) node.textContent = v; // Pattern B
} else if (node.hasAttribute("data-en") && !node.hasAttribute("data-es")) {
node.hidden = lang !== "en"; // Pattern A (English span)
} else if (node.hasAttribute("data-es") && !node.hasAttribute("data-en")) {
node.hidden = lang !== "es"; // Pattern A (Spanish span)
}
});
// Reflect current language on the toggle controls (inner [data-current-lang] span).
const label = lang === "en" ? "EN" : "ES";
document.querySelectorAll("[data-current-lang]").forEach((el) => (el.textContent = label));
},
setReducedMotion(on, fromOS = false) {
this.reducedMotion = !!on;
document.body.classList.toggle("reduced-motion", this.reducedMotion);
const btn = document.getElementById("ctl-reduce-motion");
if (btn) {
btn.classList.toggle("is-active", this.reducedMotion);
btn.setAttribute("aria-pressed", String(this.reducedMotion));
}
// Persist only explicit user choices, not transient OS-driven ones.
if (!fromOS) {
try {
localStorage.setItem("ll.reducedMotion", this.reducedMotion ? "1" : "0");
} catch (_) {}
}
// Let the active renderer drop parallax/drift to 0 if it exposes a setter.
const r = this._renderer();
if (r && typeof r.setReducedMotion === "function") {
r.setReducedMotion(this.reducedMotion);
}
},
_restorePrefs() {
try {
const savedLang = localStorage.getItem("ll.lang");
if (savedLang === "es" || savedLang === "en") this.lang = savedLang;
else this.lang = (navigator.language || "es").toLowerCase().startsWith("en") ? "en" : "es";
} catch (_) {
this.lang = "es";
}
try {
const rm = localStorage.getItem("ll.reducedMotion");
if (rm === "1") this.setReducedMotion(true);
} catch (_) {}
},
// ---- misc -----------------------------------------------------------------
_resetFilmState() {
this._shotIndex = 0;
this._sceneId = 0;
this._consecutiveFogged = 0;
this._pending.clear();
this._revealChain = Promise.resolve();
// Immersive-world reveal state.
this._worldPano = null;
this._worldDepth = null;
this._worldShown = false;
if (this._worldTimer) clearTimeout(this._worldTimer);
this._worldTimer = null;
// Stop any running Showcase reel and clear its chrome before a new run.
this._showcaseStop = true;
this._showcasePlaying = false;
this._hideBadge();
this._setLiveChrome(true); // restore the live slate + pipeline bar
},
/** Soft amber toast (#toast if present; otherwise a created element). */
toast(message) {
let el = document.getElementById("toast");
if (!el) {
el = document.createElement("div");
el.id = "toast";
el.className = "ll-toast";
el.setAttribute("role", "status");
el.setAttribute("aria-live", "polite");
document.body.appendChild(el);
}
el.textContent = message;
el.classList.add("is-visible");
if (this._toastTimer) clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => el.classList.remove("is-visible"), 3200);
},
};
// ---------------------------------------------------------------------------
// Small DOM helpers (kept local; no extra globals).
// ---------------------------------------------------------------------------
function on(id, evt, handler) {
const el = document.getElementById(id);
if (el) el.addEventListener(evt, handler);
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function t(pair, lang) {
return (pair && pair[lang]) || (pair && pair.es) || "";
}
// Frame URLs from the backend are same-origin absolute paths ("/frames/..."),
// which need no auth. Normalize defensively in case a relative form arrives.
function absUrl(u) {
if (!u) return u;
try {
return new URL(u, window.location.origin).href;
} catch (_) {
return u;
}
}
LL.controller = controller;
// Boot once the DOM is parsed. The renderer module sets LL.stage on its own
// load; if it loads after us, init() still wires everything and a later reveal
// will use LL.stage if/when it appears (we look it up at call time).
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => controller.init(), { once: true });
} else {
controller.init();
}
export default controller;