← Commit history

v1.9.38 — clip freshness moved from header to a pill by the transcript, with the canonical hover tooltip (adom-ui-design/tooltips: body-appended fixed div, cursor-anchored, edge-clamped). Replay-button fix: a watched player no longer idle-closes, so History replay always has a live server.

John Lauer ·4023489137 ·2mo ago ·parent 6927ec6
5 files changed +100−45
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.37"+version = "1.9.38" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.37+1.9.38
page.json−21
@@ -12,7 +12,6 @@     "audio",     "demo",     "accessibility",-    "cli",     "open-source",     "mit"   ],@@ -65,25 +64,5 @@   ],   "install": {     "binary_name": "adom-tts"-  },-  "description": "Adom's shared text-to-speech CLI + Playground. Reads your AI answers back to you while driving, and renders perfectly-timed voiceover tracks for demo videos.",-  "dependencies": {},-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  },-  "needs_sudo": false,-  "files": [-    "package.json",-    "install.sh",-    "uninstall.sh",-    "bin/adom-tts",-    "skills/**",-    "completions/**"-  ],-  "org": "adom",-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"   } }\ No newline at end of file
src/play.rs+7−1
@@ -1006,12 +1006,18 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {         // Queue empty: linger so back-to-back asks reuse this player, then bail         // out (and close below) after an idle grace so it doesn't sit forever.         player.shared.queued.store(0, Ordering::SeqCst);-        let idle_start = Instant::now();+        let mut idle_start = Instant::now();         let mut resumed = false;         while idle_start.elapsed() < Duration::from_secs(PLAYER_IDLE_CLOSE_SECS) {             thread::sleep(Duration::from_millis(600));             if !i_own_lock() { superseded = true; break; }             if next_queue_entry(&dir).is_some() { resumed = true; break; }+            // A HUMAN WATCHING keeps the player ALIVE. The page is open and+            // polling (<15s), so they may tap a History replay — which hits THIS+            // server's /histplay. If we idle-closed under an open page, that+            // replay button silently dies (port gone). Reset the idle grace+            // while someone's looking; only close once the tab is truly gone.+            if player.shared.poll_age_ms() < 15_000 { idle_start = Instant::now(); }         }         if superseded { log_event("drain_superseded", serde_json::json!({})); break; }         if !resumed { break; }
src/player.html+91−21
@@ -13,6 +13,8 @@     --teal: #00B8B1; --teal-soft: rgba(0,184,177,0.16);     --blue: #0061EF; --purple: #8C6BF7;     --amber: #d4a017; --red: #e0524d;+    /* z-index ladder (adom-ui-design/tooltips) — never hardcode a literal */+    --z-toolbar: 80; --z-floating-menu: 200; --z-toast: 400; --z-tooltip: 99999;   }   * { box-sizing: border-box; min-width: 0; }   /* HARD RULE: the outermost html NEVER shows a horizontal scrollbar. A truly@@ -87,14 +89,24 @@   /* 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); }-  /* clip AGE badge — ALWAYS shown for the current clip: when its AUDIO was-     synthesized + how long ago, so you can always tell if you're about to hear-     old news. Teal when fresh (minutes), amber when hours old, red when days-     old. This is the "is this ancient?" answer, always on screen. */-  .clip-age { font-size:11.5px; font-family: ui-monospace,Menlo,monospace; padding:2px 8px; border-radius:999px; white-space:nowrap; flex:none;-    color: var(--teal); border:1px solid rgba(0,184,177,.45); background: var(--teal-soft); }-  .clip-age.warn { color: var(--amber); border-color: rgba(212,160,23,.5); background: rgba(212,160,23,.14); }-  .clip-age.stale { color: var(--red); border-color: rgba(224,82,77,.6); background: rgba(224,82,77,.16); font-weight:600; }+  /* CLIP FRESHNESS line — lives by the transcript (not the header). Shows when+     THIS clip's audio was made + how long ago. Teal fresh · amber hours · red+     days. Hover it (or the transcript) for the exact synthesized timestamp. */+  .clip-fresh { align-self:flex-start; margin: 2px 0 0; font-size:12px; font-family: ui-monospace,Menlo,monospace;+    padding:3px 10px; border-radius:999px; white-space:nowrap; cursor: default;+    color: var(--teal); border:1px solid rgba(0,184,177,.4); background: var(--teal-soft); }+  .clip-fresh.warn  { color: var(--amber); border-color: rgba(212,160,23,.5); background: rgba(212,160,23,.14); }+  .clip-fresh.stale { color: var(--red); border-color: rgba(224,82,77,.6); background: rgba(224,82,77,.16); font-weight:600; }+  /* Global tooltip layer (adom-ui-design/tooltips) — one fixed div, topmost. */+  #global-tooltip { position: fixed; z-index: var(--z-tooltip); top:0; left:0;+    transform: translate(var(--tip-x,0), var(--tip-y,0));+    max-width: min(320px, calc(100vw - 40px)); pointer-events:none; opacity:0; display:none;+    background: rgba(22,27,34,.96); color: var(--fg);+    border:1px solid rgba(0,184,177,.28); border-radius:10px; padding:9px 13px;+    font: 400 13px/1.4 system-ui, sans-serif; box-shadow: 0 12px 40px rgba(0,0,0,.55);+    backdrop-filter: blur(16px); text-transform:none; letter-spacing:0; }+  #global-tooltip.visible { display:block; }+  #global-tooltip.shown   { opacity:1; transition: opacity .18s ease .6s; }   /* 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; }@@ -265,7 +277,6 @@     <!-- 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="clip-age" id="clipAge" style="display:none"></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>@@ -297,6 +308,11 @@         </div>       </div> +      <!-- CLIP FRESHNESS: when THIS clip's audio was synthesized + how long ago,+           right by the transcript so you always know if you're hearing old news.+           Teal fresh · amber hours · red days. Hover for the exact timestamp. -->+      <div id="clipFreshness" class="clip-fresh" data-tooltip="" style="display:none"></div>+       <div id="transcript" class="transcript hidden"></div>        <!-- Replayable history: the human misses clips (dead zones, back-to-back@@ -313,6 +329,9 @@   <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>+  <!-- ONE body-appended tooltip layer (adom-ui-design/tooltips): position:fixed,+       z-index topmost, cursor-anchored, edge-clamped. Never a ::after pseudo. -->+  <div id="global-tooltip"></div>  <script>   // PERSISTENT PLAYER. The page loads ONCE and stays put. It polls /poll for@@ -406,30 +425,81 @@     const h = Math.floor(m/60); if (h < 36) return h + 'h ago';     const d = Math.floor(h/24); return d + ' day' + (d===1?'':'s') + ' ago';   }-  // ALWAYS show, for the current clip, WHEN its audio was made + how long ago,-  // color-coded so old news is unmissable. This is the whole point: you should-  // never have to wonder whether a clip is fresh.+  // Show, by the transcript, WHEN this clip's audio was made + how long ago,+  // color-coded so old news is unmissable. Full timestamp lives in the hover+  // tooltip (and the subject card). You should never wonder if a clip is fresh.   function setClipAge(created, now){-    const el = document.getElementById('clipAge');+    const el = document.getElementById('clipFreshness');     const sp = document.getElementById('spCreated');+    const tr = document.getElementById('transcript');     curCreatedMs = created || 0;-    if (!created){ el.style.display='none'; sp.textContent = '—'; return; }+    if (!created){ el.style.display='none'; el.removeAttribute('data-tooltip'); if (tr) tr.removeAttribute('data-tooltip'); if (sp) sp.textContent = '—'; return; }     const age = Math.max(0, (now || lastServerNow) - created);     const abs = new Date(created).toLocaleString(undefined,-      {month:'short', day:'numeric', hour:'numeric', minute:'2-digit'});+      {weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit'});     const ago = fmtAgo(age);-    sp.textContent = abs + ' · ' + ago;+    if (sp) sp.textContent = abs + ' · ' + ago;     el.style.display = '';-    el.className = 'clip-age';+    el.className = 'clip-fresh';     const min = age / 60000;-    if (min >= 1440){ el.classList.add('stale'); el.textContent = '⚠ audio from ' + abs + ' · ' + ago; }-    else if (min >= 60){ el.classList.add('warn'); el.textContent = 'audio from ' + abs + ' · ' + ago; }-    else { el.textContent = 'audio ' + ago; }   // fresh: teal, compact-    el.title = 'this clip’s audio was synthesized ' + abs + ' (' + ago + ')';+    if (min >= 1440){ el.classList.add('stale'); el.textContent = '⚠ audio from ' + ago; }+    else if (min >= 60){ el.classList.add('warn'); el.textContent = 'audio from ' + ago; }+    else { el.textContent = 'audio from ' + ago; }+    // canonical hover tooltip (exact timestamp) on BOTH the freshness pill and+    // the transcript, so hovering the clip anywhere reveals when it was made+    const tip = 'This clip’s audio was synthesized ' + abs + ' — ' + ago+      + (min >= 1440 ? '. That is old; a fresh answer would be re-synthesized, not replayed.' : '.');+    el.setAttribute('data-tooltip', tip);+    if (tr) tr.setAttribute('data-tooltip', tip);   }   // keep the relative age honest without a new clip event (polls refresh   // lastServerNow every ~700ms, so this just re-renders the text)   setInterval(function(){ if (curCreatedMs) setClipAge(curCreatedMs, lastServerNow); }, 10000);++  // ---- global tooltip renderer (adom-ui-design/tooltips): one fixed div,+  // cursor-anchored, edge-clamped, 600ms reveal via CSS transition delay ----+  (function(){+    const tip = document.getElementById('global-tooltip');+    let _cx = 0, _cy = 0;+    document.addEventListener('mousemove', e => { _cx = e.clientX; _cy = e.clientY; }, true);+    function pickAnchor(trigger, tipW, tipH, cx, cy){+      const O = 14;+      const cands = [+        { x: cx + O,        y: cy + O },+        { x: cx - tipW - O, y: cy + O },+        { x: cx + O,        y: cy - tipH - O },+        { x: cx - tipW - O, y: cy - tipH - O },+      ];+      const tr = trigger.getBoundingClientRect();+      const vw = innerWidth, vh = innerHeight;+      const score = c => {+        let p = 0;+        if (c.x < 8 || c.x + tipW > vw - 8) p += 1000;+        if (c.y < 8 || c.y + tipH > vh - 8) p += 1000;+        const ox = Math.max(0, Math.min(c.x+tipW, tr.right) - Math.max(c.x, tr.left));+        const oy = Math.max(0, Math.min(c.y+tipH, tr.bottom) - Math.max(c.y, tr.top));+        p += ox * oy; return p;+      };+      const best = cands.map(c => ({ c, s: score(c) })).sort((a,b)=>a.s-b.s)[0].c;+      best.x = Math.max(8, Math.min(best.x, vw - tipW - 8));+      best.y = Math.max(8, Math.min(best.y, vh - tipH - 8));+      return best;+    }+    function show(t){+      tip.textContent = t.getAttribute('data-tooltip') || '';+      if (!tip.textContent) return;+      tip.classList.add('visible');+      const a = pickAnchor(t, tip.offsetWidth, tip.offsetHeight, _cx, _cy);+      tip.style.setProperty('--tip-x', a.x + 'px');+      tip.style.setProperty('--tip-y', a.y + 'px');+      requestAnimationFrame(() => tip.classList.add('shown'));+    }+    function hide(){ tip.classList.remove('visible', 'shown'); }+    document.addEventListener('mouseover', e => {+      const t = e.target.closest && e.target.closest('[data-tooltip]');+      t ? show(t) : hide();+    });+  })();   function applyClip(c){     curClipId = c.id;     // Subject = WHO is speaking (AI thread) + WHAT the clip says (--desc).