// What's On, Chand? — app: state machine, backend brain, ElevenLabs TTS, remote UI, tweaks
//
// This is the Claude Design prototype rewired onto the real backend
// (routes/whats-on-chand.js). What changed from the prototype:
//   - window.claude.complete + local tape catalog  →  POST /whats-on-chand/api/chat
//     (Gemini turn, server-resolved playables: TMDB trailers / YouTube / Archive)
//   - browser speechSynthesis  →  real ElevenLabs audio (audioBase64 in responses)
//   - local canned text lines  →  GET /whats-on-chand/api/canned/:category
//     (pregenerated audio: greeting / thinking / channel-change / sign-off)
// Everything visual — scene, timings, title card, power spin — is the design as-is.

const API_BASE = "/whats-on-chand/api";

const TWEAK_DEFAULTS = {
  "chattiness": "normal",
  "crt": 0.45,
  "voice": true,
  "voiceRate": 0.98,
  "staticSound": true
};

const MOOD_CHIPS = [
  "something spooky",
  "make me laugh",
  "cartoons",
  "something WEIRD",
  "old hollywood",
  "surprise me, chandler"
];

// Local fallbacks, used only when the backend can't be reached.
const LOCAL_LINES = {
  coldOpens: [
    "Oh!! Hey. Hi. Okay. You have no idea how long I've been sitting here hoping you'd ask me what to watch. What are we feeling tonight?",
    "Hey hey hey, you made it. Couch is warm, tapes are rewound. Tell me a mood — any mood — and I will find us something."
  ],
  thinking: [
    "hold on, hold on, I know exactly the tape...",
    "okay give me one second, it's in the third tower...",
    "ooh. ooh ooh ooh. thinking..."
  ],
  errors: [
    "Okay so the dish is acting up. Channel sixty-three runs on love and one coat hanger, give it a second and ask me again.",
    "Huh. The transmitter hiccuped. That's showbiz! Try me one more time."
  ],
  signOffs: [
    "And that's our broadcast. From everyone here at Channel sixty-three — which is me, it's just me — goodnight, drive safe, rewind your tapes. I love you."
  ],
  channelChange: ["Okay okay — next tape!"],
  // LAST RESORT only: the voiced busted-tape canned category is the primary;
  // these fire solely when /api/canned itself is unreachable
  busted: [
    "Okay, friend, real talk: that tape has been loved so hard it finally gave out, and so did my backups. Pick me something else and I will make it up to you, promise.",
    "Channel sixty-three has suffered a tape emergency — the whole stack is chewed. The night is young though. What else are we feeling?"
  ]
};

const pickRand = (arr) => arr[Math.floor(Math.random() * arr.length)];

// ---------- mobile (phone-sized touch devices) ----------
// The stage scales to cover the viewport, so on a phone the in-stage remote
// shrinks below tappable; phones instead get a remote portaled into the
// unscaled #ui layer (real CSS px), plus a landscape lock/prompt.
const uiEl = document.getElementById("ui");
function detectMobile() {
  return window.matchMedia("(any-pointer: coarse)").matches &&
         Math.min(window.innerWidth, window.innerHeight) <= 540;
}
// Best effort: fullscreen + orientation lock works on Android from a user
// gesture; iOS Safari has neither API on phones, so the portrait rotate
// overlay is the fallback there. Must be called synchronously in the gesture.
function lockLandscape() {
  if (!detectMobile()) return;
  try {
    const p = document.documentElement.requestFullscreen
      ? document.documentElement.requestFullscreen()
      : Promise.reject(new Error("no fullscreen API"));
    Promise.resolve(p)
      .then(() => screen.orientation && screen.orientation.lock && screen.orientation.lock("landscape"))
      .catch(() => {});
  } catch (e) { /* not supported — the rotate prompt covers it */ }
}

// ---------- voice (real ElevenLabs audio from the backend) ----------
let currentVoice = null;
function stopVoice() {
  if (currentVoice) {
    currentVoice.onended = null;
    currentVoice.onerror = null;
    currentVoice.pause();
    currentVoice = null;
  }
}
function playVoice(audioBase64, { rate = 1, enabled = true, onEnd, text = "" }) {
  stopVoice();
  // onEnd must fire EXACTLY once: onended, onerror, and a rejected play()
  // can all fire for one element, and downstream onDone callbacks start
  // state chains (tuneTo!) that must not run twice.
  let finished = false;
  const finish = () => {
    if (finished) return;
    finished = true;
    currentVoice = null;
    onEnd && onEnd();
  };
  if (!enabled || !audioBase64) {
    // silent mode (or no audio came back): estimate duration from text length
    const ms = Math.min(9000, 1400 + text.length * 42);
    const t = setTimeout(finish, ms);
    return () => clearTimeout(t);
  }
  const a = new Audio("data:audio/mpeg;base64," + audioBase64);
  a.playbackRate = rate;
  a.onended = finish;
  a.onerror = finish;
  currentVoice = a;
  a.play().catch(finish);
  return () => stopVoice();
}

// ---------- static audio burst ----------
let audioCtx = null;
function staticBurst(enabled) {
  if (!enabled) return;
  try {
    audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
    const len = Math.floor(audioCtx.sampleRate * 0.7);
    const buf = audioCtx.createBuffer(1, len, audioCtx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
    const src = audioCtx.createBufferSource();
    src.buffer = buf;
    const g = audioCtx.createGain();
    g.gain.setValueAtTime(0.055, audioCtx.currentTime);
    g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.68);
    src.connect(g).connect(audioCtx.destination);
    src.start();
  } catch (e) { /* no audio, no problem */ }
}

// ---------- backend ----------
async function chatApi({ history, message, chattiness }) {
  const res = await fetch(API_BASE + "/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ history, message, chattiness })
  });
  if (!res.ok) throw new Error("chat failed: " + res.status);
  return res.json(); // { dialogue, intent, optionChips, playables, audioBase64 }
}

async function fetchCanned(category) {
  try {
    const res = await fetch(API_BASE + "/canned/" + category);
    if (!res.ok) return null;
    return await res.json(); // { category, line, audioBase64 }
  } catch (e) { return null; }
}

// a playable is tunable when it carries an id for its provider
// (embed URLs are built by the players in scene.jsx — YTPlayer / ArchiveFrame)
function playableId(p) {
  return p.videoId || p.identifier || null;
}

// Archive metadata pre-check before tuning an archive candidate (CORS-enabled
// API; same trick the design prototype used for its self-validating catalog).
// The embed page loads "successfully" even for dead items, so a load timeout
// can't catch them — this can. Fails OPEN on our own network hiccups; the
// frame's load-timeout backup catches genuine hangs.
async function archiveAlive(identifier) {
  try {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), 5000);
    const res = await fetch("https://archive.org/metadata/" + encodeURIComponent(identifier), { signal: ctrl.signal });
    clearTimeout(timer);
    if (!res.ok) return false;
    const j = await res.json();
    return Boolean(j && j.metadata && Object.keys(j.metadata).length) && j.is_dark !== true;
  } catch (e) {
    return true;
  }
}

// LCD label, by kind — the PREVIEW/NOW PLAYING prefix and the stream card
// carry the trailer context now, so the label is just title (+ year).
function labelFor(p) {
  if (p.kind === "trailer" || p.kind === "full") {
    return p.title + (p.year ? " (" + p.year + ")" : "");
  }
  return p.title || "MYSTERY TAPE";
}

// ---------- App ----------
function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const [phase, setPhase] = React.useState("title"); // title|coldopen|idle|thinking|talking|tuning|watching|signoff
  const [titleState, setTitleState] = React.useState("on"); // on|fading|off
  const [chandIn, setChandIn] = React.useState(false);
  const [pose, setPose] = React.useState("idle");
  const [spinning, setSpinning] = React.useState(false);
  const [bubble, setBubble] = React.useState({ text: "", mode: "speech", visible: false });
  const [tvVideo, setTvVideo] = React.useState(null); // { provider, id, key } | null
  const [staticOn, setStaticOn] = React.useState(false);
  const [offAir, setOffAir] = React.useState(true);
  const [osd, setOsd] = React.useState(false);
  const [freeText, setFreeText] = React.useState("");
  const [nowPlaying, setNowPlaying] = React.useState(null);
  const [chips, setChips] = React.useState(MOOD_CHIPS);
  const [mobile, setMobile] = React.useState(detectMobile);
  const [portrait, setPortrait] = React.useState(() => window.innerHeight > window.innerWidth);
  const [remoteOpen, setRemoteOpen] = React.useState(true);

  const historyRef = React.useRef([]); // [{role:'user'|'chand', text}]
  const queueRef = React.useRef([]);   // remaining pitch playables for "nah"
  const fallbackRef = React.useRef(null); // { cands: [{provider,id,title}], idx, playable }
  const tuneSeqRef = React.useRef(0);     // invalidates stale timers/errors across re-tunes
  const flapRef = React.useRef(null);
  const typeRef = React.useRef(null);
  const timersRef = React.useRef([]);
  const later = (fn, ms) => { timersRef.current.push(setTimeout(fn, ms)); };

  React.useEffect(() => () => { timersRef.current.forEach(clearTimeout); stopVoice(); }, []);

  const remember = (role, text) => {
    historyRef.current.push({ role, text });
    if (historyRef.current.length > 20) historyRef.current = historyRef.current.slice(-20);
  };

  // ----- bubble typewriter -----
  const bubbleSize = (len) =>
    len > 650 ? { font: 18, w: 700 } :
    len > 420 ? { font: 20, w: 670 } :
    len > 280 ? { font: 23, w: 610 } :
    len > 150 ? { font: 27, w: 530 } :
    { font: 31, w: 465 };

  const typeBubble = (text, mode) => {
    clearInterval(typeRef.current);
    const size = bubbleSize(text.length);
    setBubble({ text: "", mode, visible: true, size });
    let i = 0;
    typeRef.current = setInterval(() => {
      i += 2;
      setBubble({ text: text.slice(0, i), mode, visible: true, size });
      if (i >= text.length) clearInterval(typeRef.current);
    }, 26);
  };
  const hideBubble = () => {
    clearInterval(typeRef.current);
    setBubble(b => ({ ...b, visible: false }));
  };

  // ----- mouth flap -----
  const startFlap = () => {
    clearInterval(flapRef.current);
    let open = false;
    flapRef.current = setInterval(() => {
      open = Math.random() > 0.42 ? !open : open;
      setPose(open ? "talking" : "idle");
    }, 110);
  };
  const stopFlap = (endPose) => {
    clearInterval(flapRef.current);
    setPose(endPose || "idle");
  };

  // while watching, settle back into watching pose (e.g. after a stray state change)
  React.useEffect(() => {
    if (phase === "watching") setPose("watching");
  }, [phase]);

  // ----- mobile bookkeeping -----
  React.useEffect(() => {
    const onResize = () => {
      setMobile(detectMobile());
      setPortrait(window.innerHeight > window.innerWidth);
    };
    window.addEventListener("resize", onResize);
    window.addEventListener("orientationchange", onResize);
    return () => {
      window.removeEventListener("resize", onResize);
      window.removeEventListener("orientationchange", onResize);
    };
  }, []);
  React.useEffect(() => {
    document.body.classList.toggle("is-mobile", mobile);
  }, [mobile]);
  // the mobile remote ducks out of the way while a tape plays and comes back
  // when Chand is ready for the next ask; manual toggle covers the rest
  React.useEffect(() => {
    if (!mobile) return;
    if (phase === "watching") setRemoteOpen(false);
    else if (phase === "idle") setRemoteOpen(true);
  }, [phase, mobile]);

  // ----- core: Chand says a line aloud (real voice), then onDone -----
  const chandSays = (text, audioBase64, onDone) => {
    setPhase("talking");
    typeBubble(text, "speech");
    startFlap();
    playVoice(audioBase64, {
      rate: t.voiceRate, enabled: t.voice, text,
      onEnd: () => { stopFlap(); onDone && onDone(); }
    });
  };

  // ----- title → start: Chand slides in, then the cold open (spoken — we have a user gesture now) -----
  const startedRef = React.useRef(false);
  const start = () => {
    if (startedRef.current) return;
    startedRef.current = true;
    lockLandscape(); // synchronous in the start gesture; no-op on desktop
    setTitleState("fading");
    later(() => setTitleState("off"), 750);
    setChandIn(true);
    setPhase("coldopen");
    // fetch the canned greeting (real voice) while he slides in
    const greetingP = fetchCanned("greeting");
    later(() => {
      greetingP.then((c) => {
        const text = c ? c.line : pickRand(LOCAL_LINES.coldOpens);
        remember("chand", text);
        chandSays(text, c && c.audioBase64, () => setPhase("idle"));
      });
    }, 1250);
  };
  React.useEffect(() => {
    const h = (e) => { if (e.key === "Enter" || e.key === " ") start(); };
    window.addEventListener("keydown", h);
    return () => window.removeEventListener("keydown", h);
  }, []);

  // ----- channel change: static -> pre-check -> video -> react -> watching -----
  // tuneTo seeds the candidate cursor (put_on playables carry up to three
  // candidates, primary at index zero; pitch playables are their own single
  // candidate) and tunes the first one. Embed failures advance the cursor.
  const tuneTo = (p) => {
    const cands = (p.candidates && p.candidates.length ? p.candidates : [p])
      .map((c) => ({ provider: c.provider, id: c.videoId || c.identifier, title: c.title || p.title }))
      .filter((c) => c.id);
    fallbackRef.current = { cands, idx: 0, playable: p };
    tuneCandidate(0);
  };

  const tuneCandidate = async (idx) => {
    const fb = fallbackRef.current;
    if (!fb || !fb.cands[idx]) return;
    fb.idx = idx;
    const c = fb.cands[idx];
    const seq = ++tuneSeqRef.current;
    setPhase("tuning");
    hideBubble();
    setPose("point");
    setOffAir(false);
    setStaticOn(true);
    staticBurst(t.staticSound);
    if (c.provider === "archive") {
      const alive = await archiveAlive(c.id);
      if (seq !== tuneSeqRef.current) return; // superseded by a newer tune
      if (!alive) return embedFailed("archive metadata pre-check: " + c.id);
    }
    later(() => {
      if (seq !== tuneSeqRef.current) return;
      setTvVideo({ provider: c.provider, id: c.id, key: "tune" + seq });
      setStaticOn(false);
      setOsd(true);
      setPose("react");
      setNowPlaying({
        label: idx === 0 ? labelFor(fb.playable) : (c.title || "NEXT TAPE"),
        kind:  fb.playable.kind,
        // client-side display trim — the payload list may be long (the API
        // doc says trim here); same flatrate→free→ads order Chand speaks from
        providers: (fb.playable.providers || []).slice(0, 3).map((x) => x.name),
        providersLink: fb.playable.providersLink || null,
      });
      later(() => setOsd(false), 2600);
      later(() => { setPose("watching"); setPhase("watching"); }, 2800);
    }, 850);
  };

  // ----- dead-tape recovery: static -> channel-change quip -> next candidate;
  // all candidates dead -> stay on static, busted-tape line, chips re-offered.
  // The Magnavolt never shows a raw embed error: static covers it immediately.
  const embedFailed = (reason) => {
    const fb = fallbackRef.current;
    if (!fb) return; // stale error after sign-off / busted-out
    // dead YT players fire onError repeatedly (each play attempt re-errors) —
    // only the FIRST failure of a given candidate may start a recovery chain
    if (fb.handledIdx === fb.idx) return;
    fb.handledIdx = fb.idx;
    tuneSeqRef.current++; // invalidate pending tune timers + late errors
    console.warn("[chand] embed failed (" + reason + ") — candidate " + (fb.idx + 1) + "/" + fb.cands.length);
    setTvVideo(null);
    setNowPlaying(null);
    setStaticOn(true);
    staticBurst(t.staticSound);
    const nextIdx = fb.idx + 1;
    if (fb.cands[nextIdx]) {
      fetchCanned("channel-change").then((c) => {
        const text = c ? c.line : pickRand(LOCAL_LINES.channelChange);
        chandSays(text, c && c.audioBase64, () => tuneCandidate(nextIdx));
      });
    } else {
      fallbackRef.current = null;
      // voiced category ships with the backend cleanup stage; local line until then
      fetchCanned("busted-tape").then((c) => {
        const text = c ? c.line : pickRand(LOCAL_LINES.busted);
        remember("chand", text);
        chandSays(text, c && c.audioBase64, () => setPhase("idle")); // static stays on
      });
    }
  };

  // ----- ask flow: thinking filler (canned, real voice) while /chat is in flight -----
  const ask = async (text) => {
    if (!text.trim()) return;
    setPhase("thinking");
    typeBubble(pickRand(LOCAL_LINES.thinking), "thought");
    setPose("idle");
    remember("user", text);

    let settled = false;
    fetchCanned("thinking").then((c) => {
      if (settled || !c) return;
      typeBubble(c.line, "thought");
      startFlap();
      playVoice(c.audioBase64, {
        rate: t.voiceRate, enabled: t.voice, text: c.line,
        onEnd: () => { if (!settled) stopFlap(); }
      });
    });

    let resp;
    try {
      resp = await chatApi({
        history: historyRef.current.slice(0, -1), // current message goes in `message`
        message: text,
        chattiness: t.chattiness
      });
    } catch (e) {
      settled = true; stopVoice(); stopFlap();
      chandSays(pickRand(LOCAL_LINES.errors), null, () => setPhase("idle"));
      return;
    }
    settled = true; stopVoice(); stopFlap();

    remember("chand", resp.dialogue);
    if (resp.optionChips && resp.optionChips.length) setChips(resp.optionChips);

    let next = null;
    queueRef.current = [];
    if (resp.intent === "pitch") {
      const q = (resp.playables || []).filter(playableId);
      next = q[0] || null;
      queueRef.current = q.slice(1); // "nah" surfs to the next pitch for free
    } else if (resp.intent === "put_on") {
      const p = (resp.playables || [])[0];
      next = p && playableId(p) ? p : null;
    }

    chandSays(resp.dialogue, resp.audioBase64, () => {
      if (next) tuneTo(next);
      else setPhase("idle");
    });
  };

  // ----- nah: next queued trailer (with a channel-change quip), else ask again -----
  const nah = () => {
    if (queueRef.current.length) {
      const next = queueRef.current.shift();
      remember("user", "nah, what else?");
      fetchCanned("channel-change").then((c) => {
        const text = c ? c.line : pickRand(LOCAL_LINES.channelChange);
        remember("chand", text + " How about " + (next.title || "this one") + "?");
        chandSays(text, c && c.audioBase64, () => tuneTo(next));
      });
      return;
    }
    ask("nah, not feeling that one — what else you got?");
  };

  // ----- sign-off: canned line (real voice) -> power spin -> off air -----
  const signOff = () => {
    setPhase("signoff");
    fetchCanned("sign-off").then((c) => {
      const line = c ? c.line : pickRand(LOCAL_LINES.signOffs);
      chandSays(line, c && c.audioBase64, () => {
        setPose("react");
        setSpinning(true);
        later(() => {
          setStaticOn(true);
          staticBurst(t.staticSound);
        }, 500);
        later(() => {
          setStaticOn(false);
          setTvVideo(null);
          fallbackRef.current = null;
          tuneSeqRef.current++;
          setNowPlaying(null);
          setOffAir(true);
          setSpinning(false);
          setPose("idle");
        }, 1300);
        later(() => {
          hideBubble(); setPhase("idle");
          historyRef.current = []; queueRef.current = [];
          setChips(MOOD_CHIPS); // fresh broadcast day
        }, 1800);
      });
    });
  };

  const busy = phase === "title" || phase === "coldopen" || phase === "thinking" || phase === "talking" || phase === "tuning" || phase === "signoff";
  const watching = phase === "watching";

  const submitFree = (e) => {
    e.preventDefault();
    if (busy || !freeText.trim()) return;
    ask(freeText);
    setFreeText("");
  };

  const lcd =
    phase === "thinking" ? "CHAND IS THINKING" :
    phase === "talking" ? "CHAND IS TALKING" :
    phase === "tuning" ? "TUNING . . ." :
    phase === "signoff" ? "SIGNING OFF" :
    watching && nowPlaying
      ? (nowPlaying.kind === "trailer" ? "PREVIEW: " : "NOW PLAYING: ") + nowPlaying.label.toUpperCase() :
    offAir ? "OFF AIR" : "CH 63 — STANDING BY";

  const showStreamCard = watching && nowPlaying &&
    nowPlaying.kind === "trailer" && nowPlaying.providers.length > 0;

  return (
    <Room video={tvVideo} staticOn={staticOn} offAir={offAir} osd={osd}
          crtOpacity={t.crt} pose={pose} spinning={spinning} bubble={bubble} chandIn={chandIn}
          onEmbedError={embedFailed}>

      {titleState !== "off" ? (
        <div className={"title-overlay" + (titleState === "fading" ? " fade" : "")} onClick={start}>
          <div className="title-pre">PUBLIC ACCESS CHANNEL 63 PRESENTS</div>
          <div className="title-main">what&rsquo;s on, chand?</div>
          <div className="title-bars">
            <span style={{ background: "#c0c0c0" }}></span><span style={{ background: "#c0c000" }}></span>
            <span style={{ background: "#00c0c0" }}></span><span style={{ background: "#00c000" }}></span>
            <span style={{ background: "#c000c0" }}></span><span style={{ background: "#c00000" }}></span>
            <span style={{ background: "#0000c0" }}></span>
          </div>
          <div className="title-press">{mobile ? "TAP TO START" : "PRESS ENTER OR CLICK TO START"}</div>
        </div>
      ) : null}

      {/* remote-ish control panel — desktop draws it in stage coords (it scales
          with the scene); mobile portals it into the unscaled #ui layer so the
          buttons stay finger-sized at any stage scale. */}
      {(() => {
        // TMDB's per-movie watch page (JustWatch data, real provider links) —
        // when present the card itself is the tap-through
        const streamCard = showStreamCard ? (
          nowPlaying.providersLink ? (
            <a className="stream-card" href={nowPlaying.providersLink}
               target="_blank" rel="noopener noreferrer">
              <span className="stream-card-label">FULL MOVIE STREAMING ON</span>
              <span className="stream-card-names">{nowPlaying.providers.join(" · ")}</span>
              <span className="stream-card-go">WATCH ▸</span>
            </a>
          ) : (
            <div className="stream-card">
              <span className="stream-card-label">FULL MOVIE STREAMING ON</span>
              <span className="stream-card-names">{nowPlaying.providers.join(" · ")}</span>
            </div>
          )
        ) : null;
        const chipGrid = (
          <div className="remote-grid">
            {chips.map((label) => (
              <button key={label} className="mood-btn" disabled={busy}
                      onClick={() => ask(label)}>{label}</button>
            ))}
          </div>
        );
        const freeForm = (
          <form className="remote-free" onSubmit={submitFree}>
            <input
              type="text" value={freeText} placeholder="or just tell chand…"
              onChange={(e) => setFreeText(e.target.value)} disabled={busy}
            ></input>
            <button type="submit" disabled={busy || !freeText.trim()}>ask</button>
          </form>
        );
        const actionRow = (
          <div className={"remote-row" + (watching ? " show" : "")}>
            <button className="nah-btn" disabled={!watching} onClick={nah}>nah, what else?</button>
            <button className="off-btn" disabled={!watching} onClick={signOff}>sign off ch 63</button>
          </div>
        );
        if (!mobile) {
          return (
            <div className="remote">
              <div className="remote-tape">CHAND&rsquo;S</div>
              <div className="remote-lcd">{lcd}</div>
              {streamCard}{chipGrid}{freeForm}{actionRow}
            </div>
          );
        }
        if (titleState !== "off") return null; // the title card owns the screen
        return ReactDOM.createPortal(
          remoteOpen ? (
            <div className="m-remote">
              <div className="m-remote-hd">
                <div className="remote-lcd">{lcd}</div>
                <button className="m-toggle" aria-label="Hide remote"
                        onClick={() => setRemoteOpen(false)}>▼</button>
              </div>
              {streamCard}{chipGrid}{freeForm}{actionRow}
            </div>
          ) : (
            <button className="m-remote-pill" aria-label="Show remote"
                    onClick={() => setRemoteOpen(true)}>
              <span>▲</span><span className="pill-lcd">{lcd}</span>
            </button>
          ),
          uiEl
        );
      })()}

      {mobile && portrait ? ReactDOM.createPortal(
        <div className="rotate-overlay" onClick={lockLandscape}>
          <div className="rotate-bars">
            <span style={{ background: "#c0c0c0" }}></span><span style={{ background: "#c0c000" }}></span>
            <span style={{ background: "#00c0c0" }}></span><span style={{ background: "#00c000" }}></span>
            <span style={{ background: "#c000c0" }}></span><span style={{ background: "#c00000" }}></span>
            <span style={{ background: "#0000c0" }}></span>
          </div>
          <div className="rotate-icon">📺</div>
          <div className="rotate-main">rotate your set!</div>
          <div className="rotate-sub">CHANNEL 63 BROADCASTS IN LANDSCAPE</div>
        </div>,
        uiEl
      ) : null}

      <TweaksPanel>
        <TweakSection label="Chand" />
        <TweakRadio label="Chattiness" value={t.chattiness}
                    options={["terse", "normal", "rambling"]}
                    onChange={(v) => setTweak("chattiness", v)} />
        <TweakToggle label="Voice" value={t.voice} onChange={(v) => setTweak("voice", v)} />
        <TweakSlider label="Voice speed" value={t.voiceRate} min={0.6} max={1.4} step={0.02}
                     onChange={(v) => setTweak("voiceRate", v)} />
        <TweakSection label="Television" />
        <TweakSlider label="CRT glass" value={t.crt} min={0} max={1} step={0.05}
                     onChange={(v) => setTweak("crt", v)} />
        <TweakToggle label="Static sound" value={t.staticSound} onChange={(v) => setTweak("staticSound", v)} />
      </TweaksPanel>
    </Room>
  );
}

ReactDOM.createRoot(document.getElementById("stage")).render(<App />);

// stage scaler — COVER, not contain: the room always fills the viewport
// (no letterbox bars), bottom-anchored so the couch stays pinned and the
// wall crops at the top. --crop-x carries the per-side horizontal crop in
// stage px; CSS clamps the remote/bubble inward by it so UI never crops.
(function () {
  const stage = document.getElementById("stage");
  const fit = () => {
    const s = Math.max(window.innerWidth / 1920, window.innerHeight / 1080);
    const cropX = Math.max(0, (1920 - window.innerWidth / s) / 2);
    // Viewports WIDER than 16:9 (most phones in landscape) crop vertically;
    // pure bottom-anchoring would spend all of it on the top of the stage and
    // decapitate the TV (frame art starts at y=140). Spend the crop on the
    // wall above the TV first, then shift the stage down so the remainder
    // comes off the couch bottom instead.
    const cropY = Math.max(0, 1080 - window.innerHeight / s);
    const topCrop = Math.min(cropY, 130);
    stage.style.bottom = (-(cropY - topCrop) * s) + "px";
    stage.style.transform = "translateX(-50%) scale(" + s + ")";
    stage.style.setProperty("--crop-x", cropX.toFixed(1) + "px");
    // phones: counter-scale the speech bubble so its 31px stage text lands
    // near 20px CSS (capped so it doesn't swallow the TV while talking)
    const boost = detectMobile() ? Math.min(1.6, Math.max(1, 20 / (31 * s))) : 1;
    stage.style.setProperty("--ui-boost", boost.toFixed(3));
  };
  window.addEventListener("resize", fit);
  fit();
})();
