← Commit history
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.2.2"+version = "1.3.1" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.2.2+1.3.1
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.2.2",+  "version": "1.3.1",   "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+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.2.2",+  "version": "1.3.1",   "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",
src/main.rs+22−3
@@ -87,6 +87,10 @@ enum Cmd {         /// With `--play`, block until playback completes (or 5-min cap).         #[arg(long)]         wait: bool,+        /// Label this clip with the originating Claude thread name, shown in+        /// the shared player so the listener knows which thread is speaking.+        #[arg(long)]+        thread: Option<String>,     },      /// Auto-play an mp3 (just-synthesized clip, history id, or `last`).@@ -110,6 +114,12 @@ enum Cmd {         /// One of: replay, play, pause, toggle.         action: String,     },+    /// Internal: the background queue drainer (spawned automatically; not for direct use).+    #[command(name = "__drain", hide = true)]+    Drain {+        #[arg(long, value_parser = parse_surface_arg)]+        surface: Option<Surface>,+    },     /// GET /tts/voices — list edge-tts voices as JSON     Voices,     /// GET /tts/pronunciations — list the override table@@ -188,13 +198,14 @@ fn main() -> Result<()> {         Cmd::Config { show } => cmd_config(show),         Cmd::Voices          => cmd_get("/tts/voices"),         Cmd::Pronunciations  => cmd_get("/tts/pronunciations"),-        Cmd::Say { text, out, voice, rate, no_pronunciations, history, play, surface, wait } =>-            cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait),+        Cmd::Say { text, out, voice, rate, no_pronunciations, history, play, surface, wait, thread } =>+            cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait, thread),         Cmd::Install         => cmd_install(),         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::Drain { surface } => play::drain(surface),         Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"),         Cmd::Pron(PronCmd::Add { written, narration, reason }) =>             cmd_pron_add(&written, &narration, &reason),@@ -334,6 +345,7 @@ fn cmd_say(     play_after: bool,     surface: Option<Surface>,     wait: bool,+    thread: Option<String>, ) -> Result<()> {     let text = if text == "-" {         let mut s = String::new();@@ -371,8 +383,15 @@ fn cmd_say(     // 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 sc = serde_json::json!({ "text": text, "voice": voice, "rate": rate, "tts_hash": hash, "label": thread.clone().unwrap_or_default() });     let _ = std::fs::write(&sidecar, serde_json::to_vec_pretty(&sc).unwrap_or_default());+    // Self-identify hint: clips from all the user's parallel Claude threads+    // share one player, so each should announce which thread it is out loud.+    if thread.is_none() && std::env::var("ADOM_TTS_NO_HINTS").is_err() {+        eprintln!("# hint: clips from ALL your Claude threads play through ONE shared player. Begin your");+        eprintln!("#       narration by saying which thread you are (e.g. \"This is the <thread> thread —\"),");+        eprintln!("#       and pass `--thread \"<name>\"` so the player shows the label too.");+    }     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+139−12
@@ -36,23 +36,136 @@ const PLAYER_TAB_NAME: &str = "TTS Player"; const SERVER_DEFAULT_TIMEOUT_SECS: u64 = 60; const SERVER_MAX_TIMEOUT_SECS: u64 = 300; -pub fn run(target: &str, surface_cli: Option<Surface>, wait: bool) -> Result<()> {+pub fn run(target: &str, surface_cli: Option<Surface>, _wait: bool) -> Result<()> {     let path = resolve_target(target)?;     let cfg = config::load();     let surface = config::resolve_surface(surface_cli, &cfg.play); -    let result = if wait {-        play_blocking(&path, surface, &cfg.play)-    } else {-        detach(&path, surface)-    };+    // SHARED QUEUE MODEL: every `play`/`say --play` from any thread enqueues+    // into one shared queue and a SINGLE background drainer plays them+    // sequentially in ONE reused tab — so parallel Claude threads never play+    // audio over each other.+    enqueue(&path)?;+    ensure_drainer(surface)?;+    Ok(())+}++const QUEUE_SUBDIR: &str = ".adom/tts/queue";+const LOCK_FILE: &str = ".adom/tts/drainer.lock"; +fn tts_home() -> Result<PathBuf> {+    Ok(PathBuf::from(std::env::var("HOME").context("no $HOME")?))+}+fn now_millis() -> u128 {+    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.as_millis()).unwrap_or(0)+}++/// Append a clip to the shared play queue (atomic temp+rename). Ordering is+/// by filename, which is `<millis>-<pid>` zero-padded → chronological.+fn enqueue(mp3: &Path) -> Result<()> {+    let dir = tts_home()?.join(QUEUE_SUBDIR);+    std::fs::create_dir_all(&dir)?;+    let abs = mp3.canonicalize().unwrap_or_else(|_| mp3.to_path_buf());+    let seq = format!("{:020}-{:07}", now_millis(), std::process::id());+    let body = serde_json::json!({ "mp3": abs.to_string_lossy() }).to_string();+    let tmp = dir.join(format!(".{seq}.tmp"));+    std::fs::write(&tmp, body)?;+    std::fs::rename(&tmp, dir.join(format!("{seq}.json")))?;     if std::env::var("ADOM_TTS_NO_HINTS").is_err() {-        eprintln!("# hint: tab/window auto-closes when audio ends (or after 5 min cap)");-        eprintln!("# hint: keep it open with ADOM_TTS_KEEP_OPEN=1; silence hints with ADOM_TTS_NO_HINTS=1");+        let depth = std::fs::read_dir(&dir).map(|rd| rd.filter_map(|e| e.ok())+            .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(1);+        if depth > 1 {+            eprintln!("# queued behind {} clip(s) — one shared player plays them in order, no overlap", depth - 1);+        }+        eprintln!("# hint: clips from ALL your Claude threads share this one player. START your narration by");+        eprintln!("#       saying which thread you are (e.g. \"This is the <thread name> thread —\") so the");+        eprintln!("#       listener knows who's speaking. Tag it with `--thread \"<name>\"` to label the player too.");+    }+    Ok(())+}++fn drainer_alive() -> bool {+    let Ok(home) = tts_home() else { return false };+    let Ok(raw) = std::fs::read_to_string(home.join(LOCK_FILE)) else { return false };+    match raw.trim().parse::<u32>() {+        Ok(pid) => Path::new(&format!("/proc/{pid}")).exists(),+        Err(_) => false,     }+} -    result+/// Spawn a detached background drainer if none is alive. Idempotent — extra+/// drainers lose the lock race and exit immediately.+fn ensure_drainer(surface: Surface) -> Result<()> {+    if drainer_alive() { return Ok(()); }+    let exe = std::env::current_exe().context("locate current exe")?;+    Command::new(&exe)+        .args(["__drain", "--surface", surface.as_str()])+        .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())+        .spawn().context("spawn detached drainer")?;+    Ok(())+}++/// Removes the lockfile on drop so the next enqueuer can become drainer.+struct LockGuard(PathBuf);+impl Drop for LockGuard { fn drop(&mut self) { let _ = std::fs::remove_file(&self.0); } }++fn acquire_drain_lock() -> Result<Option<LockGuard>> {+    let lock = tts_home()?.join(LOCK_FILE);+    if let Some(p) = lock.parent() { std::fs::create_dir_all(p)?; }+    for _ in 0..2 {+        match std::fs::OpenOptions::new().write(true).create_new(true).open(&lock) {+            Ok(mut f) => { use std::io::Write; let _ = write!(f, "{}", std::process::id()); return Ok(Some(LockGuard(lock))); }+            Err(_) => {+                if drainer_alive() { return Ok(None); }       // a live drainer holds it+                let _ = std::fs::remove_file(&lock);            // stale lock → clear + retry+            }+        }+    }+    Ok(None)+}++/// The detached drainer entrypoint: hold the lock, play queued clips oldest-+/// first in ONE reused tab, deleting each as it finishes, until the queue is+/// empty. Re-scans each loop so clips enqueued mid-drain still play.+pub fn drain(surface_cli: Option<Surface>) -> Result<()> {+    let cfg = config::load();+    let surface = config::resolve_surface(surface_cli, &cfg.play);+    let Some(_guard) = acquire_drain_lock()? else { return Ok(()); };+    let dir = tts_home()?.join(QUEUE_SUBDIR);+    loop {+        let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir).into_iter().flatten()+            .filter_map(|e| e.ok()).map(|e| e.path())+            .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))+            .collect();+        entries.sort();+        let Some(entry) = entries.into_iter().next() else { break };+        if let Ok(raw) = std::fs::read_to_string(&entry) {+            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {+                if let Some(mp3) = v.get("mp3").and_then(|m| m.as_str()).map(PathBuf::from) {+                    if mp3.exists() { let _ = play_one(&mp3, surface, &cfg.play); }+                }+            }+        }+        let _ = std::fs::remove_file(&entry);+    }+    Ok(())+}++/// Play exactly one clip in the single reused player tab, blocking until it+/// ends (or times out). Does NOT close the tab — the next clip reuses it.+fn play_one(path: &Path, surface: Surface, play_cfg: &config::PlayConfig) -> Result<()> {+    let (url, _port, handle) = serve_one_shot(path)?;+    match surface {+        Surface::Hydrogen => {+            if let Err(e) = server::open_webview_tab_named(&url, PLAYER_TAB_NAME) {+                eprintln!("Hint: could not open webview tab ({e}) — visit {url} manually");+            }+        }+        Surface::Pup => { let _ = open_pup_window(&url, &play_cfg.pup_session, &play_cfg.pup_profile); }+    }+    handle.wait();+    Ok(()) }  /// Resolve a CLI `<target>` to a concrete mp3 path.@@ -233,9 +346,10 @@ fn serve_one_shot(path: &Path) -> Result<(String, u16, ShutdownHandle)> {     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);+    let label = sidecar_label(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", &words);+    let html = build_html("audio.mp3", &title, &meta, "hydrogen", &words, &label);      let server = Server::http("127.0.0.1:0")         .map_err(|e| anyhow!("bind 127.0.0.1:0: {e}"))?;@@ -557,17 +671,29 @@ 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, words_json: &str) -> String {+fn build_html(audio_url: &str, title: &str, meta_line: &str, mode: &str, words_json: &str, label: &str) -> String {     PLAYER_HTML         .replace("{{AUDIO_URL}}", audio_url)         .replace("{{TITLE}}", &html_escape(title))         .replace("{{META_LINE}}", &html_escape(meta_line))         .replace("{{MODE}}", mode)+        .replace("{{LABEL}}", &html_escape(label))+        .replace("{{VERSION}}", env!("CARGO_PKG_VERSION"))         // 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")) } +/// Read the `label` (thread name) from a `<file>.json` sidecar, for the+/// player header. Empty when unset.+fn sidecar_label(mp3: &Path) -> String {+    let sidecar = mp3.with_extension("json");+    std::fs::read_to_string(&sidecar).ok()+        .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())+        .and_then(|v| v.get("label").and_then(|t| t.as_str()).map(str::to_string))+        .unwrap_or_default()+}+ fn html_escape(s: &str) -> String {     let mut out = String::with_capacity(s.len());     for c in s.chars() {@@ -631,10 +757,11 @@ mod tests {      #[test]     fn build_html_substitutes_tokens() {-        let h = build_html("audio.mp3", "x", "voice", "hydrogen", "[\"hello\",\"world\"]");+        let h = build_html("audio.mp3", "x", "voice", "hydrogen", "[\"hello\",\"world\"]", "MyThread");         assert!(h.contains("audio.mp3"));         assert!(h.contains("\"hydrogen\""));         assert!(h.contains("\"hello\""));+        assert!(h.contains("MyThread"));     }      #[test]
src/player.html+77−25
@@ -5,6 +5,7 @@ <meta name="viewport" content="width=device-width,initial-scale=1"> <title>{{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;@@ -26,33 +27,42 @@     display: flex; flex-direction: column;   } -  /* ---- header pinned to the very top ---- */+  /* ---- canonical Adom app-header: single row, 44px, brand | subject | actions ---- */   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);+    position: sticky; top: 0; z-index: 20; height: 44px;+    display: flex; align-items: center; gap: 12px; padding: 0 14px;+    background: rgba(13,17,23,0.82); backdrop-filter: blur(10px);     border-bottom: 1px solid var(--border);   }   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;+    background: linear-gradient(90deg, var(--blue), var(--teal) 50%, var(--purple)); opacity:.85;   }-  .brand { display: flex; align-items: center; gap: 10px; }-  .eq { display: flex; align-items: flex-end; gap: 3px; height: 18px; }+  /* 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:16px;animation-delay:.15s}-  .eq i:nth-child(3){height:11px;animation-delay:.3s}-  .eq i:nth-child(4){height:18px;animation-delay:.45s}+  .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)} }-  .wordmark { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }-  .wordmark b { color: var(--teal); }+  .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 */+  .subject { display:flex; align-items:baseline; gap:8px; min-width:0; flex:1; overflow:hidden; }+  .subject-name { font-size:14px; font-weight:600; color: var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }+  .subject-meta { font-family: ui-monospace,Menlo,monospace; font-size:11.5px; color: var(--muted); white-space:nowrap; }+  .subject-meta .sep { color: var(--border); }+  @media (max-width: 720px){ .subject-meta { display:none; } }+  /* Zone 3: actions */+  .actions { display:flex; align-items:center; gap:8px; margin-left:auto; flex:none; }   .pill {     display: inline-flex; align-items: center; gap: 7px;-    padding: 5px 12px; background: var(--elevated); border: 1px solid var(--border);+    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; }@@ -61,6 +71,23 @@   .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; }    /* ---- centered player ---- */   main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 28px 20px; }@@ -71,8 +98,6 @@     box-shadow: 0 18px 50px rgba(0,0,0,0.5);     padding: 26px 28px 22px;   }-  .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 {@@ -123,18 +148,38 @@ </head> <body>   <header>-    <div class="brand">-      <span class="eq"><i></i><i></i><i></i><i></i></span>-      <span class="wordmark">adom<b>-tts</b></span>+    <!-- 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="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 — 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) -->+    <div class="subject">+      <span class="subject-name" id="subjectName">—</span>+      <span class="subject-meta" id="subjectMeta"></span>+    </div>++    <!-- Zone 3: ACTIONS / status -->+    <div class="actions">+      <span id="status" class="pill">loading</span>     </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>@@ -156,9 +201,16 @@ <script>   const MODE = "{{MODE}}";   const WORDS = {{WORDS}};   // array of words for the karaoke transcript+  const LABEL = "{{LABEL}}"; // originating Claude thread+  const TITLE = "{{TITLE}}"; // clip basename   const a = document.getElementById('a');   const statusEl = document.getElementById('status');-  const meta = document.getElementById('meta');+  const subjectName = document.getElementById('subjectName');+  const meta = document.getElementById('subjectMeta');+  // Subject zone = what's playing: thread name (preferred) or clip; meta seeded+  // with voice/rate from the sidecar, duration appended on loadedmetadata.+  subjectName.textContent = LABEL ? ('🧵 ' + LABEL) : (TITLE || 'audio clip');+  meta.textContent = "{{META_LINE}}";   const diag = document.getElementById('diag');   const playBtn = document.getElementById('play');   const playIcon = document.getElementById('playicon');
src/server.rs+12−8
@@ -494,23 +494,27 @@ pub(crate) fn open_webview_tab_named(url: &str, display_name: &str) -> Result<()     let initial = serde_json::json!({"url": url}).to_string();      if let Some(leaf_id) = pick_non_vscode_leaf(&stdout) {+        // open-or-refresh REUSES the existing "TTS Player" tab (navigates ++        // reloads it) if one exists, else creates it in `leaf_id`. The stable+        // name means every clip from every thread lands in ONE tab — no pile.         let out = Command::new("adom-cli")             .args([-                "hydrogen", "workspace", "add-tab",+                "hydrogen", "webview", "open-or-refresh",+                "--name", display_name,+                "--url", url,                 "--panel-id", &leaf_id,                 "--panel-type", PANEL_TYPE,-                "--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() {-            anyhow::bail!("add-tab failed: {}", String::from_utf8_lossy(&out.stderr));+            anyhow::bail!("open-or-refresh failed: {}", String::from_utf8_lossy(&out.stderr));         }+        // Blink the tab so a foregrounded webview renders + autoplays (a+        // backgrounded tab is deferred and never plays) and the user notices.+        let _ = Command::new("adom-cli")+            .args(["hydrogen", "alert", "set", "--name", display_name])+            .output();         return Ok(());     }