123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>adom-tts player</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&display=swap');
  :root {
    --bg: #0d1117; --bg2: #00142f;
    --surface: #161b22; --elevated: #1c2128; --border: #2b3a52;
    --fg: #e6edf3; --muted: #8b98a5;
    --teal: #00B8B1; --teal-soft: rgba(0,184,177,0.16);
    --blue: #0061EF; --purple: #8C6BF7;
    --amber: #d4a017; --red: #e0524d;
  }
  * { box-sizing: border-box; min-width: 0; }
  /* HARD RULE: the outermost html NEVER shows a horizontal scrollbar. A truly
     fluid layout never overflows sideways; `clip` guarantees a stray pixel
     (negative margin, long token) can't summon one either. */
  html, body { margin: 0; height: 100%; overflow-x: clip; }

  /* Pretty scrollbars everywhere (transcript, history, page) — slim, rounded,
     quiet until hovered. Never the chunky default. */
  * { scrollbar-width: thin; scrollbar-color: rgba(0,184,177,.35) transparent; }
  *::-webkit-scrollbar { width: 7px; height: 7px; }
  *::-webkit-scrollbar-track { background: transparent; }
  *::-webkit-scrollbar-thumb { background: rgba(0,184,177,.28); border-radius: 999px; }
  *::-webkit-scrollbar-thumb:hover { background: rgba(0,184,177,.55); }
  *::-webkit-scrollbar-corner { background: transparent; }
  body {
    background:
      radial-gradient(900px 420px at 85% -10%, rgba(140,107,247,0.12), transparent 60%),
      radial-gradient(900px 420px at 0% 0%, rgba(0,97,239,0.12), transparent 55%),
      linear-gradient(170deg, var(--bg2), var(--bg) 60%);
    color: var(--fg);
    font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;
    min-height: 100%;
    display: flex; flex-direction: column;
  }

  /* ---- canonical Adom app-header: single row, 44px, brand | subject | actions ---- */
  header {
    position: sticky; top: 0; z-index: 20; min-height: 44px;
    display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; padding: 6px 14px;
    background: rgba(13,17,23,0.82); backdrop-filter: blur(10px);
    border-bottom: 1px solid var(--border);
  }
  /* version badge — ALWAYS visible so you can tell which build is running and
     spot any thread still launching an old one. */
  .ver { font-family: ui-monospace,Menlo,monospace; font-size:11px; color: var(--teal);
    border:1px solid rgba(0,184,177,0.55); border-radius:6px; padding:1px 6px; flex:none; }
  header::after {
    content:""; position:absolute; left:0; right:0; bottom:-1px; height:2px;
    background: linear-gradient(90deg, var(--blue), var(--teal) 50%, var(--purple)); opacity:.85;
  }
  /* Zone 1: brand */
  .brand { display: flex; align-items: center; gap: 9px; position: relative; cursor: default; outline: none; flex: none; }
  .eq { display: flex; align-items: flex-end; gap: 3px; height: 17px; }
  .eq i { width: 3px; border-radius: 2px; background: var(--teal); transform-origin: bottom; animation: bob 1s ease-in-out infinite; }
  .eq i:nth-child(1){height:7px;animation-delay:0s}
  .eq i:nth-child(2){height:15px;animation-delay:.15s}
  .eq i:nth-child(3){height:10px;animation-delay:.3s}
  .eq i:nth-child(4){height:17px;animation-delay:.45s}
  body:not(.is-playing) .eq i { animation-play-state: paused; transform: scaleY(.55); }
  @keyframes bob { 0%,100%{transform:scaleY(.45)} 50%{transform:scaleY(1)} }
  .app-name { font-family:'Familjen Grotesk',system-ui,sans-serif; font-weight:600; font-size:15px; color: var(--teal); letter-spacing:-.2px; }
  .app-subtitle { font-family:'Familjen Grotesk',system-ui,sans-serif; font-weight:500; font-size:13px; color: var(--muted); }
  .sep { color: var(--border); margin: 0 1px; }
  .vsep { width:1px; height:20px; background: var(--border); flex: none; }
  /* Zone 2: subject — truncates when tight; hover/tap reveals the full story */
  .subject { display:flex; align-items:baseline; gap:8px; min-width:0; flex:1; overflow:visible; position:relative; cursor:default; outline:none; }
  .subject-name { font-size:14px; font-weight:600; color: var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:0 1 auto; min-width:4.5em; }
  /* rich tooltip: everything the truncated header hides, in one card */
  .subject-pop {
    position:absolute; top: calc(100% + 10px); left: 0; width: min(340px, 86vw); z-index: 30;
    background: var(--elevated); border:1px solid var(--border); border-radius:12px;
    padding:13px 15px; box-shadow: 0 14px 40px rgba(0,0,0,.5);
    opacity:0; transform: translateY(-4px); pointer-events:none; transition: opacity .14s, transform .14s;
  }
  .subject:hover .subject-pop, .subject:focus-within .subject-pop, .subject.open .subject-pop { opacity:1; transform:none; pointer-events:auto; }
  .sp-desc { font-size:13px; color: var(--fg); line-height:1.5; margin-bottom:10px; }
  .sp-row { display:flex; gap:10px; font-size:12px; margin:4px 0; }
  .sp-k { color: var(--muted); width:52px; flex:none; text-transform:uppercase; letter-spacing:.5px; font-size:10px; padding-top:1px; }
  .sp-v { color: var(--fg); min-width:0; overflow-wrap:anywhere; }
  .sp-v.mono { font-family: ui-monospace,Menlo,monospace; font-size:11.5px; }
  /* the clip DESCRIPTION (human sentence) — always visible, ellipsized when tight */
  .subject-meta { font-size:12.5px; color: var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex: 1 1 auto; }
  .subject-meta .sep { color: var(--border); }
  /* Zone 3: actions */
  .actions { display:flex; align-items:center; gap:8px; margin-left:auto; flex:none; }
  .queue-chip { font-size:11.5px; color: var(--blue); border:1px solid rgba(0,97,239,0.5); background: rgba(0,97,239,0.12); padding:3px 9px; border-radius:999px; white-space:nowrap; }
  .queue-chip.hidden { display:none; }
  .thread-chip { font-size:11.5px; color: var(--purple); border:1px solid rgba(140,107,247,0.5); background: rgba(140,107,247,0.12); padding:3px 10px; border-radius:999px; white-space:nowrap; }
  /* live-link LED — proves the front-end has a live pipe to the AI/back-end.
     green=connected, amber=degraded, red/gray=disconnected (dead/orphaned tab). */
  .led { width:11px; height:11px; border-radius:50%; flex:none; margin-left:3px; transition: background .4s, box-shadow .4s; }
  /* GENTLE liveness: a slow breathing glow, never a jumpy per-ping pulse. */
  .led.ok   { background:#3fb950; animation: ledbreathe 3.2s ease-in-out infinite; }
  .led.warn { background:var(--amber); box-shadow:0 0 7px rgba(212,160,23,.5); }
  .led.down { background:#e0524d; box-shadow:0 0 6px rgba(224,82,77,.55); }
  @keyframes ledbreathe {
    0%,100% { box-shadow: 0 0 4px rgba(63,185,80,.35); }
    50%     { box-shadow: 0 0 10px rgba(63,185,80,.75); }
  }
  .pill {
    display: inline-flex; align-items: center; gap: 7px;
    padding: 4px 11px; background: var(--elevated); border: 1px solid var(--border);
    border-radius: 999px; font-size: 12px; color: var(--muted);
  }
  .pill::before { content:""; width:7px; height:7px; border-radius:50%; background: currentColor; }
  .pill.playing { color: var(--teal); border-color: var(--teal); }
  .pill.buffering { color: var(--amber); border-color: var(--amber); }
  .pill.paused { color: var(--amber); }
  .pill.ended { color: var(--muted); }
  .pill.error { color: var(--red); border-color: var(--red); }
  /* app-info popover (hover/focus the brand) */
  .popover {
    position:absolute; top: calc(100% + 8px); left:0; width: 320px; z-index: 30;
    background: var(--elevated); border:1px solid var(--border); border-radius:12px;
    padding:14px 16px; box-shadow: 0 14px 40px rgba(0,0,0,.5);
    opacity:0; transform: translateY(-4px); pointer-events:none; transition: opacity .14s, transform .14s;
  }
  .brand:hover .popover, .brand:focus-within .popover, .popover:hover { opacity:1; transform:none; pointer-events:auto; }
  .po-title { font-family:'Familjen Grotesk',sans-serif; font-weight:700; font-size:15px; color:var(--fg); }
  .po-title b { color: var(--teal); }
  .po-ver { font-family: ui-monospace,Menlo,monospace; font-size:11px; color: var(--muted); margin-left:4px; }
  .po-desc { font-size:12.5px; color: var(--muted); margin:8px 0 12px; line-height:1.5; }
  .po-row { display:flex; gap:10px; font-size:12px; margin:5px 0; }
  .po-k { color: var(--muted); width:54px; flex:none; text-transform:uppercase; letter-spacing:.5px; font-size:10.5px; padding-top:1px; }
  .po-v { color: var(--fg); }
  .po-link { display:inline-block; margin-top:11px; font-size:12.5px; color: var(--teal); text-decoration:none; }
  .po-link:hover { text-decoration:underline; }

  /* ---- player: centered card on a wide screen, edge-to-edge fill in the
     skinny 20% webview (see the max-width:620px block below) ---- */
  main { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 24px 20px; }
  .player {
    width: min(620px, 94vw); display: flex; flex-direction: column; min-height: 0;
    background: linear-gradient(180deg, var(--surface), #0e131a);
    border: 1px solid var(--border); border-radius: 18px;
    box-shadow: 0 18px 50px rgba(0,0,0,0.5);
    padding: 26px 28px 22px;
  }

  .transport { display: flex; align-items: center; gap: 16px; }
  .play {
    width: 56px; height: 56px; flex: none; border-radius: 50%; border: 0;
    background: var(--teal); color: #04201f; cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    box-shadow: 0 4px 16px rgba(0,184,177,0.4); transition: transform .08s, filter .15s, box-shadow .3s;
  }
  .play:hover { filter: brightness(1.08); }
  .play:active { transform: scale(0.93); }
  .play svg { width: 24px; height: 24px; fill: #04201f; }
  .play.attention { animation: pulse 1.3s ease-in-out infinite; }
  @keyframes pulse { 0%,100%{box-shadow:0 4px 16px rgba(0,184,177,0.4)} 50%{box-shadow:0 4px 30px rgba(0,184,177,0.95)} }

  .seekwrap { flex: 1; min-width: 0; }
  .seek {
    -webkit-appearance: none; appearance: none; width: 100%; height: 6px;
    border-radius: 999px; background: var(--elevated); cursor: pointer; outline: none;
  }
  .seek::-webkit-slider-thumb {
    -webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;
    background: var(--teal); box-shadow: 0 0 0 4px var(--teal-soft); cursor: pointer;
  }
  .seek::-moz-range-thumb { width: 15px; height: 15px; border: 0; border-radius: 50%; background: var(--teal); cursor: pointer; }
  .times { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--muted); margin-top: 7px; font-variant-numeric: tabular-nums; }

  /* ---- transcript / captions ---- */
  .transcript {
    margin-top: 22px; padding: 16px 18px; border-radius: 12px;
    background: rgba(0,0,0,0.22); border: 1px solid var(--border);
    font-size: 16px; line-height: 1.75; max-height: 230px; overflow-y: auto;
    color: var(--muted);
  }
  .transcript .label { display:block; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--teal); margin-bottom: 8px; opacity:.9; }
  .transcript w {
    transition: color .12s, background .12s; border-radius: 5px; padding: 0 1px;
  }
  .transcript w.spoken { color: var(--fg); }
  .transcript w.active {
    color: #04201f; background: var(--teal); padding: 1px 4px; margin: 0 -3px;
    font-weight: 600;
  }
  .transcript:empty, .transcript.hidden { display: none; }

  /* ---- replayable history ---- */
  .history {
    margin-top: 16px; padding: 12px 14px; border-radius: 12px;
    background: rgba(0,0,0,0.22); border: 1px solid var(--border);
    max-height: 190px; overflow-y: auto;
  }
  .history.hidden { display: none; }
  .hist-label { font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--teal); margin-bottom: 8px; opacity:.9; }
  .hist-hint { text-transform: none; letter-spacing: 0; color: var(--muted); font-size: 10.5px; margin-left: 8px; }
  .hist-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-top: 1px solid rgba(43,58,82,.5); }
  .hist-row:first-of-type { border-top: 0; }
  .hist-thread { flex: none; font-size: 10.5px; color: var(--purple); border: 1px solid rgba(140,107,247,0.45); background: rgba(140,107,247,0.10); padding: 1px 7px; border-radius: 999px; white-space: nowrap; max-width: 34%; overflow: hidden; text-overflow: ellipsis; }
  .hist-desc { flex: 1; min-width: 0; font-size: 12.5px; color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .hist-desc.missed { color: var(--amber); }
  .hist-when { flex: none; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
  .hist-replay { flex: none; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--border); background: var(--elevated); color: var(--teal); cursor: pointer; font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: background .12s, transform .08s; }
  .hist-replay:hover { background: var(--teal-soft); }
  .hist-replay:active { transform: scale(.9); }

  .diag { margin-top: 14px; font-size: 10.5px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; opacity: .6; word-break: break-all; }
  footer { padding: 12px; text-align: center; font-size: 11px; color: var(--muted); opacity: .6; }

  /* ============================================================
     RESPONSIVE — kept LAST so these win over the base rules above.
     The webview is often a skinny 20% column, so below 620px we drop
     the tagline, let the filename take its own row, and — most importantly
     — stretch the player so the transcript FILLS the tall empty space
     instead of floating in a small centered card with wasted margin.
     ============================================================ */
  @media (max-width: 620px){
    .app-subtitle, .brand .sep { display: none; }        /* row 1: brand + version + status + LED */
    .subject { order: 3; flex-basis: 100%; }             /* filename drops to its own row if tight */
    .actions { margin-left: auto; }
    .vsep { display: none; }
    main { padding: 8px; align-items: stretch; }         /* fill width, don't center a tiny card */
    .player { width: 100%; border-radius: 12px; padding: 14px 15px 12px; box-shadow: none; }
    .transcript { flex: 1 1 auto; max-height: none; margin-top: 12px; padding: 14px 16px; font-size: 15.5px; line-height: 1.7; }
    footer { display: none; }                            /* reclaim the footer line for content */
  }
  @media (max-width: 400px){
    .actions { flex-basis: 100%; justify-content: flex-end; margin-left: 0; }
    .transport { gap: 12px; }
    .play { width: 48px; height: 48px; }
    .transcript { font-size: 15px; }
  }
</style>
</head>
<body>
  <header>
    <!-- Zone 1: BRAND (hover → app-info popover) -->
    <div class="brand" id="brand" tabindex="0">
      <span class="eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
      <span class="app-name">adom-tts</span>
      <span class="ver" title="running build">v{{VERSION}}</span>
      <span class="sep">·</span>
      <span class="app-subtitle">your AI's voice</span>
      <div class="popover" id="appinfo" role="dialog" aria-label="About adom-tts">
        <div class="po-title">adom<b>-tts</b> <span class="po-ver">v{{VERSION}}</span></div>
        <div class="po-desc">Adom's shared voice service — one player, one queue, shared by every AI thread. Reads your AI's answers aloud and renders demo voiceovers.</div>
        <div class="po-row"><span class="po-k">engine</span><span class="po-v">service-tts · edge-tts · en-US-AndrewNeural</span></div>
        <div class="po-row"><span class="po-k">license</span><span class="po-v">MIT · open source</span></div>
        <div class="po-row"><span class="po-k">author</span><span class="po-v">John Lauer · Adom Industries</span></div>
        <a class="po-link" href="https://wiki.adom.inc/adom/adom-tts" target="_blank" rel="noopener">View on the Adom Wiki ↗</a>
      </div>
    </div>

    <div class="vsep" aria-hidden="true"></div>

    <!-- Zone 2: SUBJECT (what's playing right now); hover/tap for the full card -->
    <div class="subject" id="subject" tabindex="0">
      <span class="subject-name" id="subjectName">waiting for a clip…</span>
      <span class="subject-meta" id="subjectMeta"></span>
      <div class="subject-pop" id="subjectPop" role="tooltip">
        <div class="sp-desc" id="spDesc">No clip yet — the next `say --play` from any thread lands here.</div>
        <div class="sp-row"><span class="sp-k">thread</span><span class="sp-v" id="spThread">—</span></div>
        <div class="sp-row"><span class="sp-k">file</span><span class="sp-v mono" id="spFile">—</span></div>
        <div class="sp-row"><span class="sp-k">voice</span><span class="sp-v mono" id="spVoice">—</span></div>
      </div>
    </div>

    <!-- Zone 3: ACTIONS / status + live-link LED (always visible, even skinny) -->
    <div class="actions">
      <span id="queueChip" class="queue-chip hidden"></span>
      <span id="threadChip" class="thread-chip" style="display:none"></span>
      <span id="status" class="pill">idle</span>
      <span id="live" class="led down" title="AI ↔ player link — green = live, red = disconnected"></span>
    </div>
  </header>

  <main>
    <div class="player">
      <div class="transport">
        <button id="play" class="play" aria-label="Play">
          <svg id="playicon" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
        </button>
        <div class="seekwrap">
          <input id="seek" class="seek" type="range" min="0" max="1000" value="0" step="1" aria-label="Seek">
          <div class="times"><span id="cur">0:00</span><span id="dur">0:00</span></div>
        </div>
      </div>

      <div id="transcript" class="transcript hidden"></div>

      <!-- Replayable history: the human misses clips (dead zones, back-to-back
           updates) — every played clip is listed with WHO sent it and WHAT it
           says, with one-tap replay. -->
      <div id="histwrap" class="history hidden">
        <div class="hist-label">History <span class="hist-hint">tap ↻ to replay</span></div>
        <div id="histlist"></div>
      </div>

      <div class="diag" id="diag"></div>
    </div>
  </main>
  <footer>adom-tts shared player · one queue across all AI threads · auto-closes after a couple minutes idle</footer>

  <audio id="a" preload="auto"></audio>

<script>
  // PERSISTENT PLAYER. The page loads ONCE and stays put. It polls /poll for
  // the current clip id; when a NEW clip appears it swaps <audio>.src to
  // /audio?id=<id> and plays — so clips NEVER trigger a tab re-navigation
  // (the old new-port-per-clip race that caused "opened but silent"). One
  // server owns this page + the queue and knows the true playback state.
  const APP_NAME = "adom-tts";
  const a = document.getElementById('a');
  a.loop = false;  // never auto-repeat a clip — one play, then stop
  const statusEl = document.getElementById('status');
  const subjectName = document.getElementById('subjectName');
  const meta = document.getElementById('subjectMeta');
  const threadChip = document.getElementById('threadChip');
  const queueChip = document.getElementById('queueChip');
  const diag = document.getElementById('diag');
  const playBtn = document.getElementById('play');
  // NOTE: the <path> inside the svg — setting `d` on the <svg> itself is a
  // silent no-op, which kept the button stuck on ▶ forever (real bug).
  const playIcon = document.querySelector('#playicon path');
  const seek = document.getElementById('seek');
  const curEl = document.getElementById('cur');
  const durEl = document.getElementById('dur');
  const transcript = document.getElementById('transcript');
  const ICON_PLAY = "M8 5v14l11-7z", ICON_PAUSE = "M6 5h4v14H6zM14 5h4v14h-4z", ICON_REPLAY = "M12 5V1L7 6l5 5V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z";
  const MEDIA_ERR = {1:"ABORTED",2:"NETWORK",3:"DECODE",4:"SRC_NOT_SUPPORTED"};
  let lastErr = "", seeking = false;

  // ---- current clip state ----
  let curClipId = 0;       // id of the clip <audio> is currently bound to
  let wantPlay = false;    // we just swapped src and intend to auto-play it
  let metaBase = "";       // clip meta line before we append duration

  function fmt(t){ if(!isFinite(t))return "0:00"; const m=Math.floor(t/60),s=String(Math.floor(t%60)).padStart(2,'0'); return m+':'+s; }
  function setStatus(label, cls){ statusEl.textContent = label; statusEl.className = 'pill ' + (cls||''); }
  function setIcon(kind){ playIcon.setAttribute('d', kind==='pause'?ICON_PAUSE:kind==='replay'?ICON_REPLAY:ICON_PLAY); playBtn.setAttribute('aria-label', kind==='pause'?'Pause':kind==='replay'?'Replay':'Play'); document.body.classList.toggle('is-playing', kind==='pause'); }
  function updateDiag(){ const e=a.error?`err=${MEDIA_ERR[a.error.code]||a.error.code}`:'err=none'; diag.textContent=`clip#${curClipId} ready=${a.readyState} net=${a.networkState} ${e}${lastErr?' · '+lastErr:''}`; }
  function beacon(p){ fetch(p,{cache:'no-store'}).catch(()=>{}); }
  function ev(name, extra){
    const buf = a.buffered.length ? a.buffered.end(a.buffered.length-1).toFixed(1) : '0';
    beacon(`ev?id=${curClipId}&n=${name}&rs=${a.readyState}&ns=${a.networkState}&t=${(a.currentTime||0).toFixed(1)}&buf=${buf}${extra||''}`);
  }
  ['loadstart','durationchange','loadeddata','canplay','canplaythrough','play',
   'waiting','stalled','suspend','abort','emptied'].forEach(n => a.addEventListener(n, () => ev(n)));

  // ---- karaoke transcript (rebuilt per clip) ----
  let spans = [], bounds = [], activeIdx = -1;
  function buildTranscript(words){
    spans = []; bounds = []; activeIdx = -1;
    transcript.innerHTML = '';
    if (Array.isArray(words) && words.length) {
      transcript.classList.remove('hidden');
      const lbl = document.createElement('span'); lbl.className='label'; lbl.textContent='Transcript'; transcript.appendChild(lbl);
      let weights = words.map(w => w.length + 1), total = weights.reduce((x,y)=>x+y,0) || 1, acc = 0;
      words.forEach((w, i) => {
        const start = acc/total; acc += weights[i]; const end = acc/total;
        bounds.push([start, end]);
        const el = document.createElement('w'); el.textContent = w; spans.push(el);
        transcript.appendChild(el); transcript.appendChild(document.createTextNode(' '));
      });
    } else {
      transcript.classList.add('hidden');
    }
  }
  function highlight(frac){
    if (!spans.length) return;
    let idx = bounds.findIndex(([s,e]) => frac >= s && frac < e);
    if (idx === -1) idx = frac >= 1 ? spans.length - 1 : activeIdx;
    if (idx === activeIdx) return;
    activeIdx = idx;
    spans.forEach((el,i) => { el.classList.toggle('active', i===idx); el.classList.toggle('spoken', i<idx); });
    if (spans[idx]) spans[idx].scrollIntoView({ block:'nearest', behavior:'smooth' });
  }

  // ---- apply a new clip (swap source, no page reload) ----
  // HOTSPOT-PROOF: download the ENTIRE mp3 first, then play from a local blob.
  // The listener is often on a cellular hotspot that drops for seconds at a
  // time (driving) — streaming used to stall clips mid-sentence; a local blob
  // keeps playing through any drop once the download lands.
  let blobUrl = null;
  function applyClip(c){
    curClipId = c.id;
    // Subject = WHO is speaking (AI thread) + WHAT the clip says (--desc).
    // The mp3 filename means nothing to a human — it lives in the tooltip only.
    subjectName.textContent = c.label || c.title || 'audio clip';
    metaBase = c.desc ? c.desc : (c.meta || '');
    meta.textContent = metaBase;
    // rich tooltip card: the untruncated story of the current clip
    document.getElementById('spDesc').textContent = c.desc || '(no description sent)';
    document.getElementById('spThread').textContent = c.label || '—';
    document.getElementById('spFile').textContent = c.title || '—';
    document.getElementById('spVoice').textContent = c.meta || '—';
    threadChip.style.display = 'none';  // thread now leads the subject zone
    buildTranscript(c.words || []);
    // reset transport UI for the fresh clip
    lastErr = ''; seek.value = 0; curEl.textContent = '0:00'; durEl.textContent = '0:00';
    _wpos = 0; _stalls = 0;
    setStatus('downloading', 'buffering'); setIcon('play');
    wantPlay = true;
    downloadAndPlay(c.id, 0);
    updateDiag();
  }
  function downloadAndPlay(id, attempt){
    if (id !== curClipId) return;                      // superseded while downloading
    fetch('audio?id=' + id, { cache: 'no-store' })
      .then(r => { if (!r.ok) throw new Error('http ' + r.status); return r.blob(); })
      .then(b => {
        if (id !== curClipId) return;
        if (blobUrl) { try { URL.revokeObjectURL(blobUrl); } catch(e){} }
        blobUrl = URL.createObjectURL(b);
        ev('blobready', '&bytes=' + b.size);
        a.src = blobUrl;
        try { a.load(); } catch(e) {}
        tryPlay();
      })
      .catch(() => {
        // hotspot blip mid-download — retry while this is still the clip
        if (id !== curClipId) return;
        ev('dlretry', '&att=' + (attempt + 1));
        if (attempt < 8) { setStatus('reconnecting…', 'buffering'); setTimeout(() => downloadAndPlay(id, attempt + 1), 1600); }
        else setStatus('download failed', 'error');
      });
  }

  // ---- single transport ----
  function tryPlay(){ const p=a.play(); if(p&&p.catch) p.then(()=>{lastErr='';playBtn.classList.remove('attention');}).catch(showPlayError); }
  function showPlayError(err){ lastErr=(err&&err.name||'PlayError')+(err&&err.message?': '+err.message:''); if(err&&err.name==='NotAllowedError'){setStatus('tap play ▶','paused');playBtn.classList.add('attention');setIcon('play');} else setStatus('play failed','error'); updateDiag(); ev('playreject','&msg='+encodeURIComponent(((err&&err.name)||'')+' '+((err&&err.message)||'').slice(0,60))); }
  playBtn.addEventListener('click', () => { if (a.ended) { a.currentTime=0; tryPlay(); } else if (a.paused) tryPlay(); else a.pause(); });

  // tap toggles the subject card (hover covers mouse; this covers touch)
  var subjEl = document.getElementById('subject');
  subjEl.addEventListener('click', function(e){ if (e.target.closest('.subject-pop')) return; subjEl.classList.toggle('open'); });
  document.addEventListener('click', function(e){ if (!subjEl.contains(e.target)) subjEl.classList.remove('open'); });

  seek.addEventListener('input', () => { seeking = true; if (isFinite(a.duration)) { a.currentTime = (seek.value/1000)*a.duration; curEl.textContent = fmt(a.currentTime); } });
  seek.addEventListener('change', () => { seeking = false; });

  // ---- audio events ----
  a.addEventListener('loadedmetadata', () => { durEl.textContent = fmt(a.duration); if(isFinite(a.duration)){ meta.textContent=(metaBase?metaBase+' · ':'')+fmt(a.duration); beacon('meta?id='+curClipId+'&dur='+a.duration);} updateDiag(); });
  a.addEventListener('canplay', () => { updateDiag(); if (wantPlay && a.paused && !a.ended && a.currentTime===0) tryPlay(); });
  let lastBeacon = 0;
  a.addEventListener('timeupdate', () => {
    if (!isFinite(a.duration)) return;
    const frac = a.duration ? a.currentTime/a.duration : 0;
    if (!seeking) seek.value = Math.round(frac*1000);
    curEl.textContent = fmt(a.currentTime);
    highlight(frac);
    const now = Date.now(); if (now-lastBeacon>1200){ lastBeacon=now; beacon('progress?id='+curClipId+'&t='+a.currentTime); }
  });
  a.addEventListener('playing', () => { lastErr=''; wantPlay=false; setStatus('playing','playing'); setIcon('pause'); playBtn.classList.remove('attention'); ev('playing'); });
  a.addEventListener('waiting', () => setStatus('buffering','buffering'));
  a.addEventListener('stalled', () => { setStatus('stalled','buffering'); updateDiag(); });
  // A pause is a HUMAN action: cancel any pending auto-play intent so nothing
  // (canplay retry, watchdog) un-pauses them. The server also sees paused=1 on
  // the poll and HOLDS the whole queue until they press play again.
  a.addEventListener('pause',   () => { if (!a.ended){ wantPlay = false; setStatus('paused','paused'); setIcon('play'); } });
  a.addEventListener('ended',   () => { setStatus('ended','ended'); setIcon('replay'); highlight(1); spans.forEach(el=>el.classList.add('spoken')); ev('ended'); beacon('done?id='+curClipId); });
  a.addEventListener('error',   () => {
    const code = a.error ? a.error.code : 0;
    const msg = a.error && a.error.message ? a.error.message : '';
    lastErr = a.error?(MEDIA_ERR[code]||('code '+code)):'unknown';
    setStatus('audio error','error'); updateDiag();
    ev('error', '&code='+code+'&msg='+encodeURIComponent(msg.slice(0,80)));
    beacon('err?id='+curClipId+'&code='+code);
  });
  updateDiag();

  // ---- stall self-heal watchdog ----
  let _wpos = 0, _stalls = 0;
  setInterval(function(){
    if (a.paused || a.ended || !isFinite(a.duration)) { _wpos = a.currentTime; return; }
    if (a.currentTime > _wpos + 0.15) { _wpos = a.currentTime; _stalls = 0; return; } // healthy
    if (a.readyState >= 3) return;                 // buffered enough; just a blip
    _stalls++;
    if (_stalls <= 3) {
      ev('selfheal', '&att=' + _stalls);
      setStatus('recovering…', 'buffering');
      var ct = a.currentTime;
      try { a.load(); a.currentTime = ct; } catch (e) {}
      a.play().catch(function(){});
    } else if (_stalls === 4) {
      setStatus('stalled — press play', 'error');
      ev('selfheal-giveup', '&t=' + a.currentTime.toFixed(1));
    }
  }, 2500);

  // ---- replayable history drawer ----
  const histwrap = document.getElementById('histwrap');
  const histlist = document.getElementById('histlist');
  let histKey = '';
  function ago(ms){ const s=Math.max(0,Math.round(ms/1000)); if(s<60)return s+'s'; const m=Math.round(s/60); if(m<60)return m+'m'; return Math.round(m/60)+'h'; }
  function renderHistory(items, nowMs){
    const key = items.map(e => e.seq).join(',');
    if (key === histKey) {  // entries unchanged → just refresh the ago labels
      const whens = histlist.querySelectorAll('.hist-when');
      items.forEach((e,i)=>{ if(whens[i]) whens[i].textContent = ago(nowMs - (e.ts_ms||nowMs)); });
      return;
    }
    histKey = key;
    histlist.innerHTML = '';
    if (!items.length) { histwrap.classList.add('hidden'); return; }
    histwrap.classList.remove('hidden');
    items.forEach(e => {
      const row = document.createElement('div'); row.className = 'hist-row';
      const th = document.createElement('span'); th.className = 'hist-thread'; th.textContent = e.label || '?'; row.appendChild(th);
      const d = document.createElement('span'); d.className = 'hist-desc';
      d.textContent = e.desc || e.name || 'clip';
      // a clip that never fully played is the one you MISSED — flag it
      if (e.verdict && e.verdict !== 'ok') { d.classList.add('missed'); d.textContent += '  (missed?)'; }
      d.title = (e.label?e.label+' — ':'') + (e.desc||'') + (e.dur_s?(' · '+e.dur_s+'s'):'');
      row.appendChild(d);
      const w = document.createElement('span'); w.className = 'hist-when'; w.textContent = ago(nowMs - (e.ts_ms||nowMs)); row.appendChild(w);
      const b = document.createElement('button'); b.className = 'hist-replay'; b.title = 'Play this again'; b.textContent = '↻';
      b.addEventListener('click', () => { b.textContent='…'; fetch('histplay?seq='+e.seq,{cache:'no-store'}).then(()=>{ b.textContent='✓'; setTimeout(()=>b.textContent='↻',1500); }).catch(()=>{ b.textContent='↻'; }); });
      row.appendChild(b);
      histlist.appendChild(row);
    });
  }

  // ---- live link + clip poll: one poll drives BOTH the LED heartbeat AND
  // the clip swap. Each successful poll proves the front-end↔back-end pipe. ----
  const led = document.getElementById('live');
  let lastCmdSeq = 0, lastPing = 0;
  function poll() {
    // Telemetry rides ON the poll: after a hotspot gap the first poll through
    // carries the true position/done state, so lost beacons can't produce a
    // false "partial/no-playback" verdict for a clip that actually played.
    const q = 'poll?id=' + curClipId
      + '&t=' + (a.currentTime || 0).toFixed(1)
      + '&dur=' + (isFinite(a.duration) ? a.duration.toFixed(1) : '0')
      + '&done=' + (a.ended ? 1 : 0)
      + '&paused=' + ((curClipId && a.paused && !a.ended) ? 1 : 0);
    fetch(q, { cache: 'no-store' }).then(r => r.json()).then(j => {
      lastPing = Date.now();
      if (!j) return;
      // queue depth chip
      if (typeof j.queued === 'number' && j.queued > 0) { queueChip.textContent = '+' + j.queued + ' queued'; queueChip.classList.remove('hidden'); }
      else queueChip.classList.add('hidden');
      renderHistory(j.history || [], j.now_ms || Date.now());
      // NEW clip? swap without reloading the page.
      if (j.id && j.id !== curClipId) applyClip(j);
      else if (!j.id && curClipId && a.ended) { subjectName.textContent = 'waiting for a clip…'; }
      // control commands (replay/pause/play/toggle)
      if (j.seq > lastCmdSeq) {
        lastCmdSeq = j.seq;
        switch ((j.cmd || '').toLowerCase()) {
          case 'replay': a.currentTime = 0; tryPlay(); break;
          case 'play':   tryPlay(); break;
          case 'pause':  a.pause(); break;
          case 'toggle': if (a.paused) tryPlay(); else a.pause(); break;
        }
      }
    }).catch(() => {}); // a failed poll leaves lastPing stale → LED ages to red
  }
  setInterval(poll, 700);
  poll();
  setInterval(() => {
    const age = Date.now() - lastPing;
    const s = age < 2500 ? 'ok' : age < 6000 ? 'warn' : 'down';
    led.className = 'led ' + s;
    led.title = 'AI ↔ player link — ' + (s === 'ok' ? 'live' : s === 'warn' ? 'degraded' : 'disconnected (dead tab)');
  }, 1000);
</script>
</body>
</html>