|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import "./api.js";
|
|
|
| const LL = (window.LL = window.LL || {});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.",
|
| },
|
| };
|
|
|
|
|
|
|
|
|
| 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" },
|
| };
|
|
|
|
|
|
|
|
|
| const controller = {
|
|
|
| state: "lobby",
|
| lang: "en",
|
| reducedMotion: false,
|
| running: false,
|
| _shotIndex: 0,
|
| _sceneId: 0,
|
| _consecutiveFogged: 0,
|
| _pending: new Map(),
|
| _revealChain: Promise.resolve(),
|
| _toastTimer: null,
|
|
|
| |
| |
| |
|
|
| init() {
|
| this._restorePrefs();
|
| this._wireLobby();
|
| this._wireControls();
|
| this._wireLangToggle();
|
| this._initStageRenderer();
|
| this._initRecorder();
|
| this._applyLang(this.lang);
|
| this.show("lobby");
|
| return this;
|
| },
|
|
|
|
|
|
|
| |
| |
| |
|
|
| 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);
|
| }
|
| },
|
|
|
|
|
|
|
| _wireLobby() {
|
| on("btn-recital", "click", () => this.startRecital());
|
| on("btn-recital-go", "click", () => this.startRecital());
|
| on("btn-narrate", "click", () => {
|
|
|
|
|
|
|
| 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() {
|
|
|
| on("ctl-cut", "click", () => this.cut());
|
|
|
|
|
| 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);
|
| });
|
|
|
|
|
| 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, true);
|
| const onChange = (e) => this.setReducedMotion(e.matches, true);
|
| if (mq.addEventListener) mq.addEventListener("change", onChange);
|
| else if (mq.addListener) mq.addListener(onChange);
|
| }
|
| },
|
|
|
| _wireLangToggle() {
|
|
|
|
|
| const flip = () => this.setLang(this.lang === "es" ? "en" : "es");
|
| on("lang-toggle", "click", flip);
|
| on("lang-pill", "click", flip);
|
| },
|
|
|
| _initStageRenderer() {
|
|
|
|
|
|
|
| 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);
|
| }
|
| }
|
| },
|
|
|
| |
|
|
| _renderer() {
|
| return LL.pano || LL.stage || null;
|
| },
|
|
|
| |
| |
|
|
| _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();
|
| }
|
| },
|
| });
|
| },
|
|
|
|
|
|
|
| |
| |
| |
| |
|
|
| 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));
|
|
|
| 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) {
|
|
|
|
|
| console.error("[LL.controller] streamPanorama threw", err);
|
| this.toast(t(COPY.stream, this.lang));
|
| } finally {
|
| this.running = false;
|
|
|
|
|
| await this._revealChain.catch(() => {});
|
| }
|
| },
|
|
|
| |
| |
|
|
| 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");
|
| }
|
| },
|
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
| |
|
|
| async startShowcase() {
|
| if (this.running) return;
|
| const manifest = await this._loadShowcaseManifest();
|
| if (!manifest.length) {
|
| this.show("showcase");
|
| return;
|
| }
|
| this._resetFilmState();
|
| this.running = true;
|
| this._showcasePlaying = true;
|
| this._showcaseStop = false;
|
| this.show("set");
|
| this.setStageBar(null);
|
| this._setLiveChrome(false);
|
| 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;
|
|
|
| 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;
|
| }
|
| } 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 [];
|
| }
|
| },
|
|
|
|
|
| _sleep(ms) {
|
| return new Promise((resolve) => {
|
| const start = performance.now();
|
| const tick = () => {
|
| if (this._showcaseStop || performance.now() - start >= ms) return resolve();
|
| requestAnimationFrame(tick);
|
| };
|
| tick();
|
| });
|
| },
|
|
|
|
|
| _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;
|
| },
|
|
|
| |
| |
|
|
| _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";
|
| }
|
| },
|
|
|
|
|
|
|
| _onEvent(ev) {
|
| if (!ev || typeof ev !== "object") return;
|
| const stage = ev.stage;
|
|
|
|
|
| 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":
|
|
|
|
|
| if (typeof ev.text === "string" && LL.subtitles && LL.subtitles.show) {
|
| LL.subtitles.show(ev.text);
|
| }
|
| break;
|
|
|
| case "directed":
|
| this._onDirected(ev);
|
| break;
|
|
|
| case "painting":
|
|
|
| break;
|
|
|
| case "painted":
|
|
|
|
|
| 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":
|
|
|
| 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:
|
|
|
| break;
|
| }
|
| },
|
|
|
| _onDirected(ev) {
|
| const shot = ev.shot || {};
|
| this._shotIndex += 1;
|
| this._sceneId = typeof ev.scene_id === "number" ? ev.scene_id : this._sceneId;
|
|
|
|
|
|
|
|
|
| this._stash(ev.beat, {
|
| shot,
|
| cameraMove: shot.camera_move || "static",
|
| transition: ev.transition || shot.transition || "crossfade",
|
| });
|
|
|
|
|
| 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;
|
|
|
| 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() {
|
|
|
|
|
| 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));
|
|
|
| this.running = false;
|
| if (this.state !== "theater") this._maybeOpenTheater();
|
| },
|
|
|
|
|
|
|
| _stash(beat, patch) {
|
| if (typeof beat !== "number") return;
|
| const cur = this._pending.get(beat) || {};
|
| this._pending.set(beat, Object.assign(cur, patch));
|
| },
|
|
|
| |
| |
| |
| |
| |
|
|
| _tryReveal(beat) {
|
| const data = this._pending.get(beat);
|
| if (!data || !data.image || !data.depth) return;
|
|
|
| this._pending.delete(beat);
|
| this._consecutiveFogged = 0;
|
| 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,
|
| };
|
|
|
|
|
|
|
| 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));
|
| },
|
|
|
| |
| |
| |
| |
| |
|
|
| _revealWorld(force) {
|
| if (this._worldShown || !this._worldPano) return;
|
| if (!this._worldDepth && !force) return;
|
| 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";
|
| })
|
| .catch((err) => console.error("[LL.controller] pano.show failed", err));
|
| },
|
|
|
|
|
|
|
| _maybeOpenTheater() {
|
|
|
|
|
| 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;
|
| }
|
| this.state = "theater";
|
| this.setStageBar(null);
|
|
|
| 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");
|
| },
|
|
|
|
|
|
|
|
|
| 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);
|
| }
|
| }
|
| },
|
|
|
|
|
|
|
| setLang(lang) {
|
| this.lang = lang === "en" ? "en" : "es";
|
| try {
|
| localStorage.setItem("ll.lang", this.lang);
|
| } catch (_) {}
|
| this._applyLang(this.lang);
|
| },
|
|
|
| |
| |
| |
| |
|
|
| _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;
|
| } else if (node.hasAttribute("data-en") && !node.hasAttribute("data-es")) {
|
| node.hidden = lang !== "en";
|
| } else if (node.hasAttribute("data-es") && !node.hasAttribute("data-en")) {
|
| node.hidden = lang !== "es";
|
| }
|
| });
|
|
|
| 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));
|
| }
|
|
|
| if (!fromOS) {
|
| try {
|
| localStorage.setItem("ll.reducedMotion", this.reducedMotion ? "1" : "0");
|
| } catch (_) {}
|
| }
|
|
|
| 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 (_) {}
|
| },
|
|
|
|
|
|
|
| _resetFilmState() {
|
| this._shotIndex = 0;
|
| this._sceneId = 0;
|
| this._consecutiveFogged = 0;
|
| this._pending.clear();
|
| this._revealChain = Promise.resolve();
|
|
|
| this._worldPano = null;
|
| this._worldDepth = null;
|
| this._worldShown = false;
|
| if (this._worldTimer) clearTimeout(this._worldTimer);
|
| this._worldTimer = null;
|
|
|
| this._showcaseStop = true;
|
| this._showcasePlaying = false;
|
| this._hideBadge();
|
| this._setLiveChrome(true);
|
| },
|
|
|
|
|
| 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);
|
| },
|
| };
|
|
|
|
|
|
|
|
|
| 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) || "";
|
| }
|
|
|
|
|
| function absUrl(u) {
|
| if (!u) return u;
|
| try {
|
| return new URL(u, window.location.origin).href;
|
| } catch (_) {
|
| return u;
|
| }
|
| }
|
|
|
| LL.controller = controller;
|
|
|
|
|
|
|
|
|
| if (document.readyState === "loading") {
|
| document.addEventListener("DOMContentLoaded", () => controller.init(), { once: true });
|
| } else {
|
| controller.init();
|
| }
|
|
|
| export default controller;
|
|
|