← Commit history
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "0.2.0"+version = "1.2.2" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-0.2.0+1.2.2
bin/adom-ttsadded
⋯ 1 unchanged line ⋯
docs/hero.png
⋯ 1 unchanged line ⋯
docs/hero.svg+35−42
@@ -1,4 +1,4 @@-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1280" height="720" viewBox="0 0 1280 720" font-family="Liberation Sans, DejaVu Sans, sans-serif">+<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" font-family="Liberation Sans, DejaVu Sans, sans-serif">   <defs>     <linearGradient id="bg" x1="0" y1="0" x2="0.6" y2="1">       <stop offset="0" stop-color="#001a3f"/>@@ -6,11 +6,11 @@       <stop offset="1" stop-color="#0a0e16"/>     </linearGradient>     <radialGradient id="glowL" cx="0.18" cy="-0.05" r="0.6">-      <stop offset="0" stop-color="#0061EF" stop-opacity="0.32"/>+      <stop offset="0" stop-color="#0061EF" stop-opacity="0.30"/>       <stop offset="1" stop-color="#0061EF" stop-opacity="0"/>     </radialGradient>     <radialGradient id="glowR" cx="1.0" cy="0" r="0.6">-      <stop offset="0" stop-color="#8C6BF7" stop-opacity="0.28"/>+      <stop offset="0" stop-color="#8C6BF7" stop-opacity="0.26"/>       <stop offset="1" stop-color="#8C6BF7" stop-opacity="0"/>     </radialGradient>     <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">@@ -26,7 +26,6 @@       <stop offset="0" stop-color="#00B8B1"/>       <stop offset="1" stop-color="#0061EF"/>     </linearGradient>-    <clipPath id="shotclip"><rect x="56" y="540" width="812" height="148" rx="12"/></clipPath>   </defs>    <rect width="1280" height="720" fill="url(#bg)"/>@@ -34,8 +33,10 @@   <rect width="1280" height="720" fill="url(#glowR)"/>   <rect width="1280" height="6" fill="url(#accent)"/> +  <!-- ===== SAFE ZONE: content lives within x:[72,1208], y:[72,648] (~5.6% margins) ===== -->+   <!-- header -->-  <g transform="translate(56,46)">+  <g transform="translate(72,74)">     <rect x="0"  y="22" width="6" height="16" rx="3" fill="url(#eqg)"/>     <rect x="11" y="4"  width="6" height="34" rx="3" fill="url(#eqg)"/>     <rect x="22" y="14" width="6" height="24" rx="3" fill="url(#eqg)"/>@@ -43,52 +44,44 @@     <rect x="44" y="24" width="6" height="14" rx="3" fill="url(#eqg)"/>     <text x="66" y="36" font-size="42" font-weight="700" letter-spacing="-1" fill="#ffffff">adom<tspan fill="#00B8B1">-tts</tspan></text>   </g>-  <g transform="translate(1224,46)" text-anchor="end">+  <g transform="translate(1208,74)" text-anchor="end">     <rect x="-372" y="6" width="372" height="40" rx="20" fill="#ffffff" fill-opacity="0.03" stroke="#2b3a52"/>     <text x="-22" y="32" font-size="17" fill="#9fb3c8"><tspan fill="#8FD3D4" font-weight="600">service-tts</tspan>  ·  one shared voice for all of Adom</text>   </g>    <!-- lede -->-  <text x="58" y="148" font-size="25" fill="#c4d2e3">The Adom voice service — <tspan fill="#ffffff" font-weight="700">it talks so you don't have to.</tspan></text>+  <text x="74" y="166" font-size="26" fill="#c4d2e3">The Adom voice service — <tspan fill="#ffffff" font-weight="700">it talks so you don't have to.</tspan></text> -  <!-- left card: DRIVING -->-  <rect x="56" y="178" width="571" height="338" rx="20" fill="url(#card)" stroke="#243049"/>-  <rect x="56" y="178" width="571" height="338" rx="20" fill="none" stroke="#00B8B1" stroke-opacity="0.18"/>+  <!-- left card: DRIVING  (box y 200..632; ~52px padding top & bottom inside) -->+  <rect x="72" y="200" width="548" height="432" rx="20" fill="url(#card)" stroke="#243049"/>+  <rect x="72" y="200" width="548" height="432" rx="20" fill="none" stroke="#00B8B1" stroke-opacity="0.18"/>   <g>-    <!-- speaker + sound waves icon -->-    <polygon points="92,238 104,238 116,228 116,260 104,250 92,250" fill="#00B8B1"/>-    <path d="M124,231 A16,16 0 0 1 124,257" fill="none" stroke="#00B8B1" stroke-width="3" stroke-linecap="round"/>-    <path d="M132,224 A25,25 0 0 1 132,264" fill="none" stroke="#00B8B1" stroke-width="3" stroke-opacity="0.7" stroke-linecap="round"/>+    <polygon points="110,260 122,260 134,250 134,282 122,272 110,272" fill="#00B8B1"/>+    <path d="M142,253 A16,16 0 0 1 142,279" fill="none" stroke="#00B8B1" stroke-width="3" stroke-linecap="round"/>+    <path d="M150,246 A25,25 0 0 1 150,286" fill="none" stroke="#00B8B1" stroke-width="3" stroke-opacity="0.7" stroke-linecap="round"/>   </g>-  <text x="92" y="305" font-size="15" letter-spacing="2.5" font-weight="700" fill="#00B8B1">WHEN YOU'RE DRIVING</text>-  <text x="92" y="342" font-size="29" font-weight="700" fill="#ffffff">It reads your AI</text>-  <text x="92" y="376" font-size="29" font-weight="700" fill="#ffffff">answers back to you.</text>-  <text x="92" y="416" font-size="18.5" fill="#aebed2">Hands on the wheel — just ask, and</text>-  <text x="92" y="443" font-size="18.5" fill="#aebed2"><tspan fill="#e6edf3" font-weight="600">adom-tts speaks the reply aloud.</tspan></text>-  <text x="92" y="470" font-size="18.5" fill="#aebed2">No screen, no typing.</text>-  <rect x="92" y="492" width="3" height="22" fill="#00B8B1"/>-  <text x="106" y="509" font-size="17" fill="#8FD3D4">"Read me your last answer, I'm driving."</text>+  <text x="110" y="338" font-size="15" letter-spacing="2.5" font-weight="700" fill="#00B8B1">WHEN YOU'RE DRIVING</text>+  <text x="110" y="378" font-size="30" font-weight="700" fill="#ffffff">It reads your AI</text>+  <text x="110" y="414" font-size="30" font-weight="700" fill="#ffffff">answers back to you.</text>+  <text x="110" y="458" font-size="19.5" fill="#aebed2">Hands on the wheel — just ask, and</text>+  <text x="110" y="486" font-size="19.5" fill="#aebed2"><tspan fill="#e6edf3" font-weight="600">adom-tts speaks the reply aloud.</tspan></text>+  <text x="110" y="514" font-size="19.5" fill="#aebed2">No screen, no typing.</text>+  <rect x="110" y="556" width="3" height="22" fill="#00B8B1"/>+  <text x="124" y="573" font-size="17" fill="#8FD3D4">"Read me your last answer, I'm driving."</text> -  <!-- right card: DEMO -->-  <rect x="653" y="178" width="571" height="338" rx="20" fill="url(#card)" stroke="#243049"/>-  <rect x="653" y="178" width="571" height="338" rx="20" fill="none" stroke="#8C6BF7" stroke-opacity="0.20"/>+  <!-- right card: DEMO  (box y 200..632; ~52px padding top & bottom inside) -->+  <rect x="660" y="200" width="548" height="432" rx="20" fill="url(#card)" stroke="#243049"/>+  <rect x="660" y="200" width="548" height="432" rx="20" fill="none" stroke="#8C6BF7" stroke-opacity="0.20"/>   <g>-    <!-- film/play icon -->-    <rect x="689" y="222" width="54" height="40" rx="8" fill="none" stroke="#8C6BF7" stroke-width="3"/>-    <polygon points="708,232 708,252 726,242" fill="#8C6BF7"/>+    <rect x="698" y="248" width="54" height="40" rx="8" fill="none" stroke="#8C6BF7" stroke-width="3"/>+    <polygon points="717,258 717,278 735,268" fill="#8C6BF7"/>   </g>-  <text x="689" y="305" font-size="15" letter-spacing="2.5" font-weight="700" fill="#b7a4ff">WHEN YOU'RE MAKING A DEMO</text>-  <text x="689" y="342" font-size="29" font-weight="700" fill="#ffffff">It voices the video —</text>-  <text x="689" y="376" font-size="29" font-weight="700" fill="#ffffff">with exact timing.</text>-  <text x="689" y="416" font-size="18.5" fill="#aebed2">Feed it your script; get a</text>-  <text x="689" y="443" font-size="18.5" fill="#aebed2"><tspan fill="#e6edf3" font-weight="600">perfectly-timed narration track.</tspan></text>-  <text x="689" y="470" font-size="18.5" fill="#aebed2">No recording booth, no retakes.</text>-  <rect x="689" y="492" width="3" height="22" fill="#8C6BF7"/>-  <text x="703" y="509" font-size="17" fill="#c5b3ff">Script in &#8594; finished voiceover out.</text>--  <!-- proof band: real Playground screenshot -->-  <rect x="55" y="539" width="814" height="150" rx="12" fill="none" stroke="#2b3a52"/>-  <text x="895" y="582" font-size="18" fill="#7e8ea3"><tspan fill="#00B8B1">&#9679;</tspan> the live <tspan fill="#8FD3D4" font-weight="600">TTS Playground</tspan></text>-  <text x="895" y="610" font-size="18" fill="#7e8ea3">Andrew Neural voice · Adom</text>-  <text x="895" y="634" font-size="18" fill="#7e8ea3">pronunciations · instant cache</text>+  <text x="698" y="338" font-size="15" letter-spacing="2.5" font-weight="700" fill="#b7a4ff">WHEN YOU'RE MAKING A DEMO</text>+  <text x="698" y="378" font-size="30" font-weight="700" fill="#ffffff">It voices the video —</text>+  <text x="698" y="414" font-size="30" font-weight="700" fill="#ffffff">with exact timing.</text>+  <text x="698" y="458" font-size="19.5" fill="#aebed2">Feed it your script; get a</text>+  <text x="698" y="486" font-size="19.5" fill="#aebed2"><tspan fill="#e6edf3" font-weight="600">perfectly-timed narration track.</tspan></text>+  <text x="698" y="514" font-size="19.5" fill="#aebed2">No recording booth, no retakes.</text>+  <rect x="698" y="556" width="3" height="22" fill="#8C6BF7"/>+  <text x="712" y="573" font-size="17" fill="#c5b3ff">Script in &#8594; finished voiceover out.</text> </svg>
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.0.5",+  "version": "1.2.2",   "type": "app",   "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.",   "license": "MIT",
page.json+23−22
@@ -1,27 +1,28 @@ {-  "schema_version": 1,-  "type": "app",   "slug": "adom-tts",-  "title": "adom-tts — your AI's voice",-  "brief": "Adom's shared voice service. Reads your AI's answers back to you while you drive, and renders perfectly-timed voiceover tracks for your demo videos — so you never record your own voice.",-  "version": "1.0.5",+  "version": "1.2.2",+  "type": "app",+  "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.",   "license": "MIT",-  "tags": ["tts", "voice", "audio", "demo", "accessibility", "open-source", "mit"],-  "hero": {-    "type": "image",-    "path": "screenshots/hero.png",-    "thumbnail": "screenshots/hero-thumb.png"-  },-  "discovery_pitch": "Hands-free in the car? Say 'read me your last answer, I'm driving' and adom-tts speaks it aloud. Making a demo video? Feed it your script and get a perfectly-timed narration track in the house voice — no recording booth, no retakes. One shared edge-tts service with Adom pronunciation overrides and an instant source-hash cache.",-  "discovery_triggers": ["adom-tts", "text to speech", "tts", "voiceover", "demo narration", "narration", "synthesize speech", "make audio from text", "read it to me", "read me your answer", "read that back", "read me the last prompt", "I'm driving", "hands-free", "drive mode", "narrate the answer", "play this audio"],-  "sample_prompts": [-    { "label": "Drive mode", "prompt": "Read me your last answer, I'm driving" },-    { "label": "Demo voiceover", "prompt": "Render this script as a demo voiceover track" },-    { "label": "Synthesize and play", "prompt": "Synthesize a 30-second walkthrough of this design and play it for me" },-    { "label": "Replay last", "prompt": "Play the last TTS clip again" },-    { "label": "Add pronunciation", "prompt": "Teach the TTS service that aci is pronounced A-C-I" }+  "tags": [+    "tts",+    "voice",+    "audio",+    "demo",+    "accessibility",+    "cli",+    "open-source",+    "mit"   ],-  "install": {-    "binary_name": "adom-tts"+  "dependencies": {},+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "needs_sudo": false,+  "org": "adom",+  "author": {+    "name": "John Lauer",+    "email": "[email protected]"   }-}+}\ No newline at end of file
src/main.rs+14
@@ -102,6 +102,14 @@ enum Cmd {         #[arg(long)]         wait: bool,     },+    /// Control the live player without reloading it: replay | play | pause | toggle.+    ///+    /// Sends the command into the most-recently-opened player tab via its+    /// command channel (e.g. `adom-tts control replay` re-plays the clip).+    Control {+        /// One of: replay, play, pause, toggle.+        action: String,+    },     /// GET /tts/voices — list edge-tts voices as JSON     Voices,     /// GET /tts/pronunciations — list the override table@@ -186,6 +194,7 @@ fn main() -> Result<()> {         Cmd::Serve { port, no_tab, dev } => cmd_serve(port, !no_tab, dev),         Cmd::Push { file, text, voice, rate } => cmd_push(&file, &text, &voice, &rate),         Cmd::Play { target, surface, wait } => play::run(&target, surface, wait),+        Cmd::Control { action } => play::control(&action),         Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"),         Cmd::Pron(PronCmd::Add { written, narration, reason }) =>             cmd_pron_add(&written, &narration, &reason),@@ -359,6 +368,11 @@ fn cmd_say(     }     let bytes = resp.bytes().context("read body")?;     std::fs::write(out, &bytes)?;+    // Write a `<out>.json` sidecar with the text + voice/rate/hash so the+    // player can show the transcript (and karaoke-highlight it) and a meta line.+    let sidecar = out.with_extension("json");+    let sc = serde_json::json!({ "text": text, "voice": voice, "rate": rate, "tts_hash": hash });+    let _ = std::fs::write(&sidecar, serde_json::to_vec_pretty(&sc).unwrap_or_default());     println!("→ {} ({} bytes, cache={}, hash={})", out.display(), bytes.len(), cache, &hash[..hash.len().min(12)]);     if register_history {         let clip = history::save(&bytes, &text, voice, rate, "cli", Some(hash.clone()))?;
src/play.rs+226−15
@@ -25,7 +25,7 @@ use anyhow::{anyhow, Context, Result}; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio};-use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant};@@ -232,9 +232,10 @@ fn serve_one_shot(path: &Path) -> Result<(String, u16, ShutdownHandle)> {     let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;     let title = path.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();     let meta = sidecar_meta_line(path);+    let words = sidecar_words_json(path);     // Relative path — proxy URLs end in `/proxy/PORT/`, so a leading-slash     // path would resolve to the bare domain and miss the proxy prefix.-    let html = build_html("audio.mp3", &title, &meta, "hydrogen");+    let html = build_html("audio.mp3", &title, &meta, "hydrogen", &words);      let server = Server::http("127.0.0.1:0")         .map_err(|e| anyhow!("bind 127.0.0.1:0: {e}"))?;@@ -242,14 +243,37 @@ fn serve_one_shot(path: &Path) -> Result<(String, u16, ShutdownHandle)> {         .ok_or_else(|| anyhow!("non-ip server addr"))?         .port();     let url = server::webview_url(port);+    // Publish the live player's local port so `adom-tts control <cmd>` can+    // find it and inject commands (replay/play/pause) WITHOUT reloading.+    write_player_portfile(port);      let server = Arc::new(server);     let stop = Arc::new(AtomicBool::new(false));     let done = Arc::new(AtomicBool::new(false));+    // Playback telemetry (milliseconds), reported by the player page so the+    // CLI can VERIFY a clip actually played end-to-end without a human watching.+    let dur_ms = Arc::new(AtomicU64::new(0));+    let reached_ms = Arc::new(AtomicU64::new(0));+    let last_ms = Arc::new(AtomicU64::new(0)); // live position (non-monotonic) — drops on replay+    let err_code = Arc::new(AtomicU64::new(0));+    // Command channel: CLI → /inject bumps cmd_seq + stores cmd; the page+    // polls /command and runs anything with a newer seq. Server→page control.+    let cmd_seq = Arc::new(AtomicU64::new(0));+    let cmd_str = Arc::new(std::sync::Mutex::new(String::new()));+    // Event timeline: the page beacons every significant <audio> event here so+    // the CLI can see EXACTLY where playback dies (stall, decode error, etc.).+    let events = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));+    let events_t = events.clone();      let server_t = server.clone();     let stop_t = stop.clone();     let done_t = done.clone();+    let dur_t = dur_ms.clone();+    let reached_t = reached_ms.clone();+    let last_t = last_ms.clone();+    let cmd_seq_t = cmd_seq.clone();+    let cmd_str_t = cmd_str.clone();+    let err_t = err_code.clone();     let bytes_arc = Arc::new(bytes);     let html_arc = Arc::new(html); @@ -258,30 +282,115 @@ fn serve_one_shot(path: &Path) -> Result<(String, u16, ShutdownHandle)> {             if stop_t.load(Ordering::SeqCst) {                 break;             }-            let url = request.url().split('?').next().unwrap_or("").to_string();+            let raw = request.url().to_string();+            let path = raw.split('?').next().unwrap_or("");+            let query = raw.split('?').nth(1).unwrap_or("").to_string();             let method = request.method().clone();             // Strip optional `/proxy/<port>/` prefix so the same routes             // match whether the request comes via 127.0.0.1 directly or             // through the Cloudflare proxy URL.-            let route = strip_proxy_prefix(&url);+            let route = strip_proxy_prefix(path);             let _ = match (&method, route) {                 (Method::Get, "/" | "/index.html" | "") =>                     respond_inline(request, 200, "text/html; charset=utf-8", html_arc.as_bytes().to_vec()),                 (Method::Get, "/audio.mp3" | "audio.mp3") => {-                    let len = bytes_arc.len();-                    let body = Cursor::new(bytes_arc.as_ref().clone());-                    let headers = vec![-                        Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),-                        Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),-                        Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),-                    ];-                    let resp = Response::new(StatusCode(200), headers, body, Some(len), None);-                    request.respond(resp)+                    let total = bytes_arc.len();+                    // Honor HTTP Range requests with a 206 Partial Content reply.+                    // Browsers stream <audio> via ranges; through the Cloudflare+                    // proxy a plain 200 (no range) leaves the element unable to+                    // read duration (shows 0:00) and truncates mid-playback.+                    let range = request.headers().iter()+                        .find(|h| h.field.equiv("Range"))+                        .and_then(|h| parse_byte_range(h.value.as_str(), total));+                    match range {+                        Some((start, end)) => {+                            let slice = bytes_arc[start..=end].to_vec();+                            let len = slice.len();+                            let headers = vec![+                                Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),+                                Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),+                                Header::from_bytes(&b"Content-Range"[..],+                                    format!("bytes {start}-{end}/{total}").into_bytes()).unwrap(),+                                Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),+                            ];+                            let resp = Response::new(StatusCode(206), headers, Cursor::new(slice), Some(len), None);+                            request.respond(resp)+                        }+                        None => {+                            let body = Cursor::new(bytes_arc.as_ref().clone());+                            let headers = vec![+                                Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),+                                Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),+                                Header::from_bytes(&b"Content-Length"[..], total.to_string().into_bytes()).unwrap(),+                                Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),+                            ];+                            let resp = Response::new(StatusCode(200), headers, body, Some(total), None);+                            request.respond(resp)+                        }+                    }+                }+                // Telemetry beacons from the player page.+                (Method::Get, "/meta" | "meta") => {+                    if let Some(d) = qnum(&query, "dur") { dur_t.store((d * 1000.0) as u64, Ordering::SeqCst); }+                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())+                }+                (Method::Get, "/progress" | "progress") => {+                    if let Some(t) = qnum(&query, "t") {+                        let ms = (t * 1000.0) as u64;+                        reached_t.fetch_max(ms, Ordering::SeqCst); // monotonic high-water (for "played to end")+                        last_t.store(ms, Ordering::SeqCst);        // live position (drops on replay)+                    }+                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())+                }+                (Method::Get, "/err" | "err") => {+                    if let Some(c) = qnum(&query, "code") { err_t.store(c as u64, Ordering::SeqCst); }+                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())                 }                 (Method::Get, "/done" | "done") => {+                    // Reaching the end means progress hit the full duration.+                    reached_t.fetch_max(dur_t.load(Ordering::SeqCst), Ordering::SeqCst);                     done_t.store(true, Ordering::SeqCst);                     respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())                 }+                // Command channel (server → page). The page polls /command and+                // executes any command whose seq is newer than the last it ran.+                (Method::Get, "/command" | "command") => {+                    let cmd = cmd_str_t.lock().map(|s| s.clone()).unwrap_or_default();+                    let body = format!("{{\"seq\":{},\"cmd\":\"{}\"}}",+                        cmd_seq_t.load(Ordering::SeqCst), cmd.replace('"', ""));+                    respond_inline(request, 200, "application/json", body.into_bytes())+                }+                // Event beacon: the page records each <audio> lifecycle event+                // (with readyState/networkState/error) into a capped timeline.+                (Method::Get, "/ev" | "ev") => {+                    if let Ok(mut log) = events_t.lock() {+                        if log.len() >= 60 { log.remove(0); }+                        log.push(query.clone());+                    }+                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())+                }+                // CLI injects a command here (local 127.0.0.1 only in practice).+                (Method::Get, "/inject" | "inject") => {+                    if let Some(c) = query.split('&').filter_map(|kv| kv.split_once('='))+                        .find(|(k,_)| *k == "cmd").map(|(_,v)| v.to_string()) {+                        if let Ok(mut s) = cmd_str_t.lock() { *s = c; }+                        cmd_seq_t.fetch_add(1, Ordering::SeqCst);+                    }+                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())+                }+                // Live telemetry snapshot — pollable from the CLI mid-playback+                // to confirm progress without waiting for the shutdown deadline.+                (Method::Get, "/state" | "state") => {+                    let timeline = events_t.lock().map(|l| l.join(" | ")).unwrap_or_default();+                    let body = format!(+                        "{{\"dur_ms\":{},\"reached_ms\":{},\"last_ms\":{},\"done\":{},\"err\":{},\"timeline\":\"{}\"}}",+                        dur_t.load(Ordering::SeqCst), reached_t.load(Ordering::SeqCst),+                        last_t.load(Ordering::SeqCst),+                        done_t.load(Ordering::SeqCst), err_t.load(Ordering::SeqCst),+                        timeline.replace('"', "'")+                    );+                    respond_inline(request, 200, "application/json", body.into_bytes())+                }                 _ => respond_inline(request, 404, "text/plain", b"not found".to_vec()),             };         }@@ -291,6 +400,10 @@ fn serve_one_shot(path: &Path) -> Result<(String, u16, ShutdownHandle)> {         server,         stop,         done,+        dur_ms,+        reached_ms,+        err_code,+        events,         worker: Some(worker),         deadline: Instant::now() + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS),         max_deadline: Instant::now() + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS),@@ -301,6 +414,10 @@ pub(crate) struct ShutdownHandle {     server: Arc<Server>,     stop: Arc<AtomicBool>,     done: Arc<AtomicBool>,+    dur_ms: Arc<AtomicU64>,+    reached_ms: Arc<AtomicU64>,+    err_code: Arc<AtomicU64>,+    events: Arc<std::sync::Mutex<Vec<String>>>,     worker: Option<thread::JoinHandle<()>>,     deadline: Instant,     max_deadline: Instant,@@ -337,6 +454,19 @@ impl ShutdownHandle {         if let Some(w) = self.worker.take() {             let _ = w.join();         }+        // Emit a machine-readable playback verdict so the CLI (and any AI+        // driving it) can confirm the clip actually played end-to-end —+        // no human needed to watch the tab.+        let dur = self.dur_ms.load(Ordering::SeqCst) as f64 / 1000.0;+        let reached = self.reached_ms.load(Ordering::SeqCst) as f64 / 1000.0;+        let err = self.err_code.load(Ordering::SeqCst);+        let full = ended_naturally || (dur > 0.0 && reached >= dur - 0.75);+        let verdict = if err != 0 { "error" } else if full { "ok" } else if reached > 0.0 { "partial" } else { "no-playback" };+        let timeline = self.events.lock().map(|l| l.join(" | ")).unwrap_or_default();+        eprintln!(+            "PLAYBACK verdict={verdict} reported_duration={dur:.1}s reached={reached:.1}s ended={ended_naturally} err_code={err}"+        );+        if !timeline.is_empty() { eprintln!("PLAYBACK timeline: {timeline}"); }         ended_naturally     } }@@ -346,6 +476,47 @@ impl ShutdownHandle { /// forwarding to localhost, but we strip defensively so the same routes /// work whether the request arrives directly on 127.0.0.1 or via a /// proxy that doesn't strip.+/// Record the live player's loopback port so `adom-tts control <cmd>` can+/// reach it. Best-effort: control is a convenience, not load-bearing.+fn write_player_portfile(port: u16) {+    if let Ok(home) = std::env::var("HOME") {+        let dir = std::path::Path::new(&home).join(".adom/tts");+        let _ = std::fs::create_dir_all(&dir);+        let _ = std::fs::write(dir.join("player.json"), format!("{{\"port\":{port}}}"));+    }+}++/// Read the live player's port from the portfile (for `control`).+pub(crate) fn read_player_port() -> Option<u16> {+    let home = std::env::var("HOME").ok()?;+    let raw = std::fs::read_to_string(std::path::Path::new(&home).join(".adom/tts/player.json")).ok()?;+    let v: serde_json::Value = serde_json::from_str(&raw).ok()?;+    v.get("port").and_then(|p| p.as_u64()).map(|p| p as u16)+}++/// Inject a control command (replay/play/pause/toggle) into the live player+/// by hitting its loopback /inject endpoint. No window reload.+pub fn control(action: &str) -> Result<()> {+    let port = read_player_port().ok_or_else(|| anyhow!("no live player (run `adom-tts play …` or `say --play` first)"))?;+    let url = format!("http://127.0.0.1:{port}/inject?cmd={action}");+    let resp = reqwest::blocking::Client::new().get(&url).send()+        .map_err(|e| anyhow!("player not reachable on :{port} ({e}) — it may have closed"))?;+    if resp.status().is_success() {+        println!("→ sent '{action}' to live player on :{port}");+        Ok(())+    } else {+        Err(anyhow!("player returned HTTP {}", resp.status()))+    }+}++/// Pull a numeric query param (`?key=12.3`) out of a raw query string.+fn qnum(query: &str, key: &str) -> Option<f64> {+    query.split('&')+        .filter_map(|kv| kv.split_once('='))+        .find(|(k, _)| *k == key)+        .and_then(|(_, v)| v.parse::<f64>().ok())+}+ fn strip_proxy_prefix(url: &str) -> &str {     let Some(s) = url.strip_prefix("/proxy/") else { return url };     match s.find('/') {@@ -354,6 +525,29 @@ fn strip_proxy_prefix(url: &str) -> &str {     } } +/// Parse a single-range HTTP `Range` header value (`bytes=START-END`,+/// `bytes=START-`, or suffix `bytes=-N`) into an inclusive `(start, end)`+/// byte span clamped to `total`. Returns None for absent/unsatisfiable/+/// multi-range values (caller then serves the full 200 body).+fn parse_byte_range(value: &str, total: usize) -> Option<(usize, usize)> {+    if total == 0 { return None; }+    let rest = value.trim().strip_prefix("bytes=")?;+    if rest.contains(',') { return None; } // multi-range: fall back to full body+    let (s, e) = rest.split_once('-')?;+    let (start, end) = if s.is_empty() {+        // suffix: last N bytes+        let n: usize = e.trim().parse().ok()?;+        if n == 0 { return None; }+        (total.saturating_sub(n), total - 1)+    } else {+        let start: usize = s.trim().parse().ok()?;+        let end: usize = if e.trim().is_empty() { total - 1 } else { e.trim().parse().ok()? };+        (start, end)+    };+    if start > end || start >= total { return None; }+    Some((start, end.min(total - 1)))+}+ fn respond_inline(req: tiny_http::Request, status: u16, content_type: &str, body: Vec<u8>) -> std::io::Result<()> {     let ct = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())         .expect("valid header");@@ -363,12 +557,15 @@ fn respond_inline(req: tiny_http::Request, status: u16, content_type: &str, body     req.respond(resp) } -fn build_html(audio_url: &str, title: &str, meta_line: &str, mode: &str) -> String {+fn build_html(audio_url: &str, title: &str, meta_line: &str, mode: &str, words_json: &str) -> String {     PLAYER_HTML         .replace("{{AUDIO_URL}}", audio_url)         .replace("{{TITLE}}", &html_escape(title))         .replace("{{META_LINE}}", &html_escape(meta_line))         .replace("{{MODE}}", mode)+        // Escape `<` so a word like "</script>" can't break out of the inline+        // <script> block. `<` is a valid JSON (and JS) escape.+        .replace("{{WORDS}}", &words_json.replace('<', "\\u003c")) }  fn html_escape(s: &str) -> String {@@ -389,6 +586,19 @@ fn html_escape(s: &str) -> String { /// If a `<file>.json` sidecar exists (history clip metadata), build a /// short pretty meta line: `voice · rate · hash`. Returns empty string /// when no sidecar.+/// Read the narration `text` from a `<file>.json` sidecar, as a JSON array+/// of words for the player's transcript + karaoke highlighting. Empty `[]`+/// when there's no sidecar or no text.+fn sidecar_words_json(mp3: &Path) -> String {+    let sidecar = mp3.with_extension("json");+    let text = std::fs::read_to_string(&sidecar).ok()+        .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())+        .and_then(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_string))+        .unwrap_or_default();+    let words: Vec<&str> = text.split_whitespace().collect();+    serde_json::to_string(&words).unwrap_or_else(|_| "[]".to_string())+}+ fn sidecar_meta_line(mp3: &Path) -> String {     let sidecar = mp3.with_extension("json");     let raw = match std::fs::read_to_string(&sidecar) {@@ -421,9 +631,10 @@ mod tests {      #[test]     fn build_html_substitutes_tokens() {-        let h = build_html("audio.mp3", "x", "voice", "hydrogen");+        let h = build_html("audio.mp3", "x", "voice", "hydrogen", "[\"hello\",\"world\"]");         assert!(h.contains("audio.mp3"));         assert!(h.contains("\"hydrogen\""));+        assert!(h.contains("\"hello\""));     }      #[test]
src/player.html+228−98
@@ -6,131 +6,261 @@ <title>{{TITLE}} — adom-tts player</title> <style>   :root {-    --bg: #0b0b0b;-    --fg: #e8e8e8;-    --muted: #888;-    --accent: #4ea3ff;-    --pill-bg: #1a1a1a;-    --pill-border: #2a2a2a;+    --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;   }-  html, body { background: var(--bg); color: var(--fg); margin: 0; padding: 0; height: 100%; }+  * { box-sizing: border-box; }+  html, body { margin: 0; height: 100%; }   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;-    display: flex; align-items: center; justify-content: center;+    min-height: 100%;+    display: flex; flex-direction: column;   }-  .card {-    width: min(640px, 90vw);-    padding: 28px 32px;-    border: 1px solid var(--pill-border);-    border-radius: 12px;-    background: #111;-    box-shadow: 0 8px 32px rgba(0,0,0,0.4);++  /* ---- header pinned to the very top ---- */+  header {+    position: sticky; top: 0; z-index: 10;+    display: flex; align-items: center; justify-content: space-between;+    padding: 14px 22px;+    background: rgba(13,17,23,0.72); backdrop-filter: blur(10px);+    border-bottom: 1px solid var(--border);   }-  .title {-    font-size: 18px; font-weight: 600;-    margin: 0 0 6px;-    word-break: break-all;+  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:.8;   }-  .meta {-    font-size: 13px; color: var(--muted);-    margin: 0 0 18px;+  .brand { display: flex; align-items: center; gap: 10px; }+  .eq { display: flex; align-items: flex-end; gap: 3px; height: 18px; }+  .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:16px;animation-delay:.15s}+  .eq i:nth-child(3){height:11px;animation-delay:.3s}+  .eq i:nth-child(4){height:18px;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)} }+  .wordmark { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }+  .wordmark b { color: var(--teal); }+  .pill {+    display: inline-flex; align-items: center; gap: 7px;+    padding: 5px 12px; background: var(--elevated); border: 1px solid var(--border);+    border-radius: 999px; font-size: 12px; color: var(--muted);   }-  audio {-    width: 100%;-    margin-bottom: 14px;-    filter: invert(1) hue-rotate(180deg);+  .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); }++  /* ---- centered player ---- */+  main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 28px 20px; }+  .player {+    width: min(620px, 94vw);+    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;   }-  .row {-    display: flex; align-items: center; gap: 10px;-    flex-wrap: wrap;+  .title { font-size: 19px; font-weight: 600; margin: 0 0 4px; word-break: break-word; }+  .meta { font-size: 12.5px; color: var(--muted); margin: 0 0 20px; }++  .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;   }-  button {-    background: var(--accent); color: #fff; border: 0;-    padding: 8px 14px; border-radius: 6px;-    font-size: 14px; font-weight: 500;-    cursor: pointer;+  .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;   }-  button:hover { filter: brightness(1.1); }-  button:active { filter: brightness(0.9); }-  .pill {-    display: inline-block;-    padding: 4px 10px;-    background: var(--pill-bg);-    border: 1px solid var(--pill-border);-    border-radius: 999px;-    font-size: 12px;-    color: var(--muted);+  .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;   }-  .pill.playing { color: var(--accent); border-color: var(--accent); }-  .pill.paused  { color: #d4a017; border-color: #d4a017; }-  .pill.ended   { color: #888; }-  .pill.error   { color: #e0524d; border-color: #e0524d; }-  .footer {-    margin-top: 18px;-    font-size: 11px;+  .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);-    text-align: center;-    opacity: 0.7;   }+  .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; }++  .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; } </style> </head> <body>-  <div class="card">-    <p class="title">{{TITLE}}</p>-    <p class="meta" id="meta">{{META_LINE}}</p>-    <audio id="a" controls autoplay preload="auto" src="{{AUDIO_URL}}"></audio>-    <div class="row">-      <button id="replay">Replay</button>-      <span id="status" class="pill">loading</span>+  <header>+    <div class="brand">+      <span class="eq"><i></i><i></i><i></i><i></i></span>+      <span class="wordmark">adom<b>-tts</b></span>     </div>-    <p class="footer">adom-tts auto-player · close this tab when done</p>-  </div>+    <span id="status" class="pill">loading</span>+  </header>++  <main>+    <div class="player">+      <p class="title">{{TITLE}}</p>+      <p class="meta" id="meta">{{META_LINE}}</p>++      <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>+      <div class="diag" id="diag"></div>+    </div>+  </main>+  <footer>adom-tts auto-player · closes itself when playback ends</footer>++  <audio id="a" preload="auto" src="{{AUDIO_URL}}"></audio>+ <script>-  const MODE = "{{MODE}}"; // "hydrogen" or "pup"+  const MODE = "{{MODE}}";+  const WORDS = {{WORDS}};   // array of words for the karaoke transcript   const a = document.getElementById('a');-  const status = document.getElementById('status');+  const statusEl = document.getElementById('status');   const meta = document.getElementById('meta');+  const diag = document.getElementById('diag');+  const playBtn = document.getElementById('play');+  const playIcon = document.getElementById('playicon');+  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; -  function setStatus(label, cls) {-    status.textContent = label;-    status.className = 'pill ' + cls;+  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=`ready=${a.readyState} net=${a.networkState} ${e}${lastErr?' · '+lastErr:''}`; }+  function beacon(p){ fetch(p,{cache:'no-store'}).catch(()=>{}); }+  // Rich diagnostics: beacon every significant <audio> lifecycle event with+  // readyState/networkState/buffered so the CLI can see EXACTLY where playback+  // dies — stall, decode error, never-started, etc.+  function ev(name, extra){+    const buf = a.buffered.length ? a.buffered.end(a.buffered.length-1).toFixed(1) : '0';+    beacon(`ev?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))); -  a.addEventListener('loadedmetadata', () => {-    if (a.duration && isFinite(a.duration)) {-      const mins = Math.floor(a.duration / 60);-      const secs = String(Math.floor(a.duration % 60)).padStart(2, '0');-      const dur = mins + ':' + secs;-      meta.textContent = (meta.textContent ? meta.textContent + ' · ' : '') + dur;-    }+  // ---- karaoke transcript ----+  let spans = [], bounds = [], activeIdx = -1;+  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), 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(' '));+    });+  }+  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' });+  }++  // ---- 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(); });++  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=(meta.textContent?meta.textContent+' · ':'')+fmt(a.duration); beacon('meta?dur='+a.duration);} updateDiag(); });+  a.addEventListener('canplay', updateDiag);+  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>1500){ lastBeacon=now; beacon('progress?t='+a.currentTime); }   });-  a.addEventListener('playing', () => setStatus('playing', 'playing'));-  a.addEventListener('pause',   () => { if (!a.ended) setStatus('paused', 'paused'); });-  a.addEventListener('ended',   () => {-    setStatus('ended', 'ended');-    if (MODE === 'hydrogen') {-      // Tell the one-shot server to shut down early.-      fetch('/done').catch(() => {});-    }+  a.addEventListener('playing', () => { lastErr=''; 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.addEventListener('pause',   () => { if (!a.ended){ 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'); });+  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?code='+code);   });-  a.addEventListener('error',   () => setStatus('audio error', 'error')); -  document.getElementById('replay').addEventListener('click', () => {-    a.currentTime = 0;-    a.play().catch(() => {});-  });+  // autoplay attempt; on block, surface a clear "tap play" instead of faking it+  a.addEventListener('canplay', () => { if (a.paused && !a.ended && a.currentTime===0) tryPlay(); }, { once:true });+  setTimeout(() => { if (a.paused && a.readyState>=2) tryPlay(); updateDiag(); }, 400);+  updateDiag(); -  // Belt-and-braces autoplay defeat: the browser may block the initial-  // autoplay attribute. Fire .play() once after a short delay; if it-  // throws (e.g. user-gesture-required), the user can hit Replay or the-  // <audio controls> play button.-  setTimeout(() => {-    if (a.paused) {-      a.play().catch(() => {-        setStatus('click play', 'paused');-      });-    }-  }, 250);+  // ---- command channel: poll for CLI-injected commands; no window reload ----+  let lastCmdSeq = 0;+  setInterval(() => {+    fetch('command', { cache: 'no-store' }).then(r => r.json()).then(j => {+      if (!j || j.seq <= lastCmdSeq) return;+      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(() => {});+  }, 1000); </script> </body> </html>
src/server.rs+4
@@ -502,6 +502,10 @@ pub(crate) fn open_webview_tab_named(url: &str, display_name: &str) -> Result<()                 "--display-name", display_name,                 "--display-icon", &icon_data,                 "--initial-state", &initial,+                // Auto-activate the tab AND blink it: a foregrounded webview+                // is required for WebView2 to actually render + autoplay the+                // audio (a backgrounded tab is deferred and never plays).+                "--alert",             ])             .output()?;         if !out.status.success() {