@memef1f1y/dsh-plugin-tts 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -18,7 +18,7 @@ window.__ModuleLoader__.load({
18
18
 
19
19
  // Bundle build tag: bump on every client fix so a console line proves
20
20
  // WHICH code is actually running (rules out stale-bundle confusion).
21
- const CLIENT_BUILD = "2026-09-26.01-prewarm-skip-toast";
21
+ const CLIENT_BUILD = "2026-09-26.05-follow-v2-fix";
22
22
  const apply = ctx => {
23
23
  const slots = ctx.slots ?? (typeof ctx.get === "function" ? ctx.get("slots") : undefined);
24
24
  if (!slots) throw new Error("[dsh-plugin-tts] slots service unavailable");
@@ -361,7 +361,8 @@ window.__ModuleLoader__.load({
361
361
  "overlay.showToggle": "显示全局朗读中按钮(实验性,默认关闭)",
362
362
  "overlay.showToggle.desc": "屏幕右侧显示朗读进度浮窗,可点击暂停/继续",
363
363
  "scrollFollow.label": "朗读高度跟随(实验性,默认关闭)",
364
- "scrollFollow.desc": "朗读时自动滚动对话区域,使当前朗读段落保持在屏幕垂直居中位置",
364
+ "scrollFollow.desc": "朗读时自动滚动对话区域,使正在朗读的段落保持可见;你一动手滚动就停下,可点浮条回到跟随",
365
+ "follow.resume": "回到跟随",
365
366
  "footnote": "由 Microsoft Edge TTS / 本地 RVC / 本地 Index-TTS2 / 本地 CosyVoice / Google Cloud TTS 驱动。",
366
367
  "report.hint.lead": "遇到问题?点上方诊断区的「导出诊断日志」,把 JSON 附到 ",
367
368
  "report.hint.tail": " 提 issue;不方便用 GitHub 时再通过其他方式发给开发者,本地服务报错另附服务终端里的红色报错。",
@@ -740,7 +741,8 @@ window.__ModuleLoader__.load({
740
741
  "overlay.showToggle": "Show global reading indicator (experimental, off by default)",
741
742
  "overlay.showToggle.desc": "Show a floating reading progress indicator on the right side; click to pause/resume",
742
743
  "scrollFollow.label": "Auto scroll while reading (experimental, off by default)",
743
- "scrollFollow.desc": "Automatically scroll the conversation so the currently reading paragraph stays vertically centred",
744
+ "scrollFollow.desc": "Auto-scroll the conversation to keep the spoken paragraph visible; stops the moment you scroll, resume from the floating bar",
745
+ "follow.resume": "Resume follow",
744
746
  "footnote": "Powered by Microsoft Edge TTS / local RVC / local Index-TTS2 / local CosyVoice / Google Cloud TTS.",
745
747
  "report.hint.lead": "Hit a bug? Export the diagnostic log in Diagnostics above and attach the JSON to ",
746
748
  "report.hint.tail": " ; if GitHub is inconvenient, send it to the developer another way. For local-service errors also paste the red traceback from the service terminal.",
@@ -980,7 +982,7 @@ window.__ModuleLoader__.load({
980
982
  seed: 0,
981
983
  },
982
984
  speaking: false,
983
- diagLog: [], // one-click diagnostic log: recent speak attempts + toasts (no message text, textLen only; cap 20)
985
+ diagLog: [], // one-click diagnostic log: recent speak attempts + toasts (no message text, textLen only; cap 30)
984
986
  currentText: null,
985
987
  speakSource: null,
986
988
  speakToken: 0,
@@ -991,7 +993,8 @@ window.__ModuleLoader__.load({
991
993
  readingSentences: null, // array of sentence texts split from currentText (for scroll-center)
992
994
  readingSentenceIdx: 0, // 0-based index of the sentence currently centered
993
995
  readingMsgEl: null, // WeakRef to the message DOM node being read (anchored from the read button)
994
- readingCenterOnce: false, // first follow tick centres the message (click feedback)
996
+ readingCenterOnce: false, // first follow tick centres the message (click-initiated reads only)
997
+ followHlEl: null, // element currently carrying .dsh-tts-sentence-active (v2 block highlight)
995
998
  showReadingOverlay: false, // show the global reading progress overlay on the right (default off: follow still experimental)
996
999
  showScrollFollow: false, // auto-scroll conversation to keep reading paragraph centred (default off: follow still experimental)
997
1000
  currentJobId: null, // active RVC chunked job, cancelled eagerly on stop
@@ -1459,7 +1462,7 @@ window.__ModuleLoader__.load({
1459
1462
  function logDiag(entry) {
1460
1463
  try {
1461
1464
  shared.diagLog.push(Object.assign({ at: Date.now() }, entry));
1462
- while (shared.diagLog.length > 20) shared.diagLog.shift();
1465
+ while (shared.diagLog.length > 30) shared.diagLog.shift();
1463
1466
  } catch (e) { /* logging must never break playback */ }
1464
1467
  }
1465
1468
  function showToast(text, kind, action) {
@@ -1487,6 +1490,13 @@ window.__ModuleLoader__.load({
1487
1490
  return shared.rvc.baseVoice || shared.voice;
1488
1491
  }
1489
1492
 
1493
+ // --- Scroll-follow v2 state: apply-scope singleton so BOTH speakText
1494
+ // (new-read re-latch) and TtsReadingOverlay (tick / takeover / resume)
1495
+ // share the SAME object. Must NOT live inside the component (each render
1496
+ // would recreate it: speakText would throw ReferenceError AND latch-off
1497
+ // would be lost on next render).
1498
+ const ttsFollow = { latched: true, touchActive: false, selfUntil: 0 };
1499
+
1490
1500
  // ---------- approval voice alerts (Agent-event broadcast) ----------
1491
1501
  // The Host ingests approval session events (approval/asked, approval/
1492
1502
  // decided) from the session/event firehose and serves them through
@@ -2395,6 +2405,18 @@ window.__ModuleLoader__.load({
2395
2405
  if (typeof onError === "function") onError(t("err.chunkFail") + r.error);
2396
2406
  } else {
2397
2407
  completed = true;
2408
+ // Silent early end (GC'd/unknown job, cancelled Host job):
2409
+ // without this the player spins on "synthesizing…" forever
2410
+ // with no trace. Only fires when indices never arrived — a
2411
+ // clean drain has every queue slot filled.
2412
+ let served = 0;
2413
+ for (let i = 0; i < total; i++) if (queue[i] !== undefined) served++;
2414
+ if (served < total) {
2415
+ try {
2416
+ logDiag({ kind: "chunk-early-done", text: "job " + jobId + " ended with " + served + "/" + total + " chunks served" });
2417
+ } catch (e) {}
2418
+ if (typeof onError === "function") onError(t("err.chunkFail") + " (" + served + "/" + total + ")");
2419
+ }
2398
2420
  }
2399
2421
  })
2400
2422
  .catch(() => {
@@ -2601,6 +2623,18 @@ window.__ModuleLoader__.load({
2601
2623
  if (typeof onError === "function") onError(t("err.chunkFail") + r.error);
2602
2624
  } else {
2603
2625
  completed = true;
2626
+ // Silent early end (GC'd/unknown job, cancelled Host job):
2627
+ // without this the player spins on "synthesizing…" forever
2628
+ // with no trace. Only fires when indices never arrived — a
2629
+ // clean drain has every queue slot filled.
2630
+ let served = 0;
2631
+ for (let i = 0; i < total; i++) if (queue[i] !== undefined) served++;
2632
+ if (served < total) {
2633
+ try {
2634
+ logDiag({ kind: "chunk-early-done", text: "job " + jobId + " ended with " + served + "/" + total + " chunks served" });
2635
+ } catch (e) {}
2636
+ if (typeof onError === "function") onError(t("err.chunkFail") + " (" + served + "/" + total + ")");
2637
+ }
2604
2638
  }
2605
2639
  })
2606
2640
  .catch(() => {
@@ -2710,10 +2744,14 @@ window.__ModuleLoader__.load({
2710
2744
  // the chunked branch overwrites this with the real total.
2711
2745
  shared.chunkProgress = { index: 1, total: 1 };
2712
2746
  shared.readingIndex = 1;
2747
+ shared.chunkSpans = null; // exact block spans arrive with chunked jobs (P1-3)
2713
2748
  // Playback-start timestamp for smooth scroll interpolation.
2714
2749
  shared.chunkStartedAt = Date.now();
2715
- // Centre the message on the first follow tick (instant feedback).
2716
- shared.readingCenterOnce = true;
2750
+ // Follow engaged fresh; centre-once only for click-initiated reads —
2751
+ // auto-read must never yank the view away from what the user reads.
2752
+ ttsFollow.latched = true;
2753
+ try { _ttsClearHighlight(); } catch (e) {}
2754
+ shared.readingCenterOnce = (source === "manual");
2717
2755
  // New utterance: drop the previous message anchor (button clicks
2718
2756
  // re-anchor below; auto-read resolves on first successful lookup).
2719
2757
  shared.readingMsgEl = null;
@@ -2773,6 +2811,7 @@ window.__ModuleLoader__.load({
2773
2811
  if (Array.isArray(result.chunks) && result.chunks.length) {
2774
2812
  const total = result.total || result.chunks.length;
2775
2813
  shared.currentJobId = result.jobId || null;
2814
+ shared.chunkSpans = Array.isArray(result.spans) ? result.spans : null;
2776
2815
  shared.chunkProgress = { index: 1, total };
2777
2816
  notify();
2778
2817
  // Prewarm skips happened during /speak (not at /rvc-next time),
@@ -3126,7 +3165,12 @@ window.__ModuleLoader__.load({
3126
3165
  ".dsh-tts-reading-overlay{position:fixed;right:16px;top:50%;transform:translateY(-50%);z-index:100;display:flex;flex-direction:column;align-items:flex-end;gap:4px;opacity:0;transition:opacity .45s cubic-bezier(.4,0,.2,1);pointer-events:none}" +
3127
3166
  ".dsh-tts-reading-overlay[data-visible]{opacity:1}" +
3128
3167
  ".dsh-tts-reading-bar{display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);box-shadow:0 3px 16px rgba(0,0,0,.22),0 0 30px rgba(0,0,0,.08);font-size:12px;line-height:1;color:var(--dsw-alias-label-primary);white-space:nowrap;transition:transform .35s cubic-bezier(.4,0,.2,1),opacity .35s ease;transform:translateX(12px);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);pointer-events:none;cursor:pointer}" +
3129
- ".dsh-tts-reading-overlay[data-visible] .dsh-tts-reading-bar{transform:translateX(0);pointer-events:auto}" +
3168
+ ".dsh-tts-reading-overlay[data-visible] .dsh-tts-reading-bar{transform:translateX(0);pointer-events:auto}" +
3169
+ // v2 block highlight (visual anchor for follow): soft background,
3170
+ // no box-shadow (never reintroduce the shadow style).
3171
+ ".dsh-tts-sentence-active{background:var(--dsw-alias-bg-layer-2);border-radius:6px;transition:background-color .3s ease}" +
3172
+ // Resume-follow pill: appears only after the user latches follow off.
3173
+ ".dsh-tts-follow-pill{margin-top:4px;padding:4px 12px;border-radius:10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);box-shadow:0 3px 16px rgba(0,0,0,.22);font-size:12px;line-height:1;color:var(--dsw-alias-label-primary);white-space:nowrap;cursor:pointer}" +
3130
3174
  ".dsh-tts-reading-dot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-brand-primary);flex:none;animation:dsh-tts-rpulse 1.6s ease-in-out infinite;box-shadow:0 0 6px var(--dsw-alias-brand-primary)}" +
3131
3175
  "@keyframes dsh-tts-rpulse{0%,100%{opacity:.35;transform:scale(.75)}50%{opacity:1;transform:scale(1.15)}}" +
3132
3176
  ".dsh-tts-reading-label{font-weight:600;font-variant-numeric:tabular-nums}" +
@@ -3441,6 +3485,12 @@ window.__ModuleLoader__.load({
3441
3485
  // classes left by older bundles.)
3442
3486
  let _ttsHighlightedEl = null;
3443
3487
  function _ttsClearHighlight() {
3488
+ try {
3489
+ if (shared.followHlEl) {
3490
+ try { shared.followHlEl.classList.remove("dsh-tts-sentence-active"); } catch (e) {}
3491
+ shared.followHlEl = null;
3492
+ }
3493
+ } catch (e) {}
3444
3494
  if (_ttsHighlightedEl) {
3445
3495
  try {
3446
3496
  _ttsHighlightedEl.classList.remove("dsh-tts-sentence-active");
@@ -3494,53 +3544,45 @@ window.__ModuleLoader__.load({
3494
3544
  const tip = shared.paused ? t("mini.resume")
3495
3545
  : (t("mini.pause") + (cp && cp.total > 1 ? " · " + (readingIdx || 0) + "/" + cp.total : ""));
3496
3546
 
3497
- // --- Scroll-follow: track live playback position ---
3498
- // Progress signal: single-file reads use audioEl.currentTime/duration
3499
- // (real position); chunked reads interpolate inside the current chunk
3500
- // by elapsed time (chunk length estimated from text length at ~3.6
3501
- // CJK chars/s, same heuristic as the server). Result is a continuous
3502
- // 0..1 ratio over the whole text, so the view glides instead of
3503
- // jumping per chunk. User scrolls yield: any manual scroll pauses
3504
- // follow for 5s so we never drag the view out of the user's hands.
3505
- const ttsUserScrollAt = { t: 0 };
3506
- // While true, scroll events come from our own scrollTo(), not the user.
3507
- const ttsSelfScroll = { on: false };
3508
- const ttsEstimateSec = (text) => {
3509
- let cjk = 0, latin = 0, other = 0;
3510
- const s = String(text || "");
3511
- for (const ch of s) {
3512
- const cp = ch.codePointAt(0);
3513
- if (cp >= 0x4e00 && cp <= 0x9fff) cjk++;
3514
- else if (/[A-Za-z0-9]/.test(ch)) latin++;
3515
- else other++;
3516
- }
3517
- return Math.max(1, cjk / 3.6 + latin / 12 + other / 4);
3547
+ // --- Scroll-follow v2: visibility-driven minimal scrolling ---
3548
+ // Design: docs/scroll-follow-v2.md. Block-index stepwise signal (no
3549
+ // intra-chunk interpolation — the old equal-slice timing estimate
3550
+ // drifted against variable-length chunks and snapped at boundaries);
3551
+ // whole-message anchor only; scroll only when the current block
3552
+ // leaves the viewport (block:"nearest"); user takeover latches off
3553
+ // with manual resume; reduced-motion respected. No polling loop:
3554
+ // evaluation runs per chunk-index change + on scroll/touch/viewport
3555
+ // events (the old 250ms full-DOM scan forced reflow at 4Hz).
3556
+ // NOTE: ttsFollow lives in apply scope (shared singleton) — do NOT
3557
+ // redeclare here, or speakText can't see it (0.6.3 ReferenceError).
3558
+ const ttsReducedMotion = () => {
3559
+ try { return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); } catch (e) { return false; }
3518
3560
  };
3519
- const ttsLiveRatio = () => {
3561
+ // Which chunk is sounding (1-based). Stepwise: no fake glide inside
3562
+ // a chunk, so pause/playback-rate can never desync the position.
3563
+ const ttsBlockIdx = () => {
3520
3564
  const progress = shared.chunkProgress;
3521
3565
  if (!shared.speaking || !progress || !progress.total) return null;
3522
- const total = progress.total;
3523
- const idx = Math.min(Math.max(shared.readingIndex || 1, 1), total);
3524
- // Single audio element with known duration: exact position.
3525
- try {
3526
- const el = shared.audioEl;
3527
- if (el && el.duration && isFinite(el.duration) && el.duration > 0 &&
3528
- total <= 1 && typeof el.currentTime === "number") {
3529
- const r = el.currentTime / el.duration;
3530
- if (isFinite(r) && r >= 0) return Math.min(1, r);
3531
- }
3532
- } catch (e) {}
3533
- // Chunked: completed chunks + elapsed fraction of current chunk.
3534
- const fullText = shared.currentText || "";
3535
- const perChunk = ttsEstimateSec(fullText.slice(
3536
- Math.floor(fullText.length * (idx - 1) / total),
3537
- Math.floor(fullText.length * idx / total))) ||
3538
- ttsEstimateSec(fullText) / total;
3539
- const elapsed = Math.max(0, (Date.now() - (shared.chunkStartedAt || Date.now())) / 1000);
3540
- const frac = Math.min(1, elapsed / Math.max(1, perChunk));
3541
- return Math.min(1, ((idx - 1) + frac) / total);
3566
+ return Math.min(Math.max(shared.readingIndex || 1, 1), progress.total);
3567
+ };
3568
+ // Approximate text sounding at block idx (equal division). Used ONLY
3569
+ // to locate the paragraph for scroll/highlight — never for timing.
3570
+ // Server spans (P1-3) make this exact; until then, advisory mapping.
3571
+ const ttsBlockHead = (idx, total) => {
3572
+ const full = shared.currentText || "";
3573
+ if (!full || !total || total <= 0) return "";
3574
+ // Exact server spans when present (P1-3), equal-division fallback.
3575
+ const sp = shared.chunkSpans;
3576
+ let slice = "";
3577
+ if (Array.isArray(sp) && sp[idx - 1] && sp[idx - 1].length === 2 &&
3578
+ Number.isFinite(sp[idx - 1][0]) && Number.isFinite(sp[idx - 1][1])) {
3579
+ slice = full.slice(Math.max(0, sp[idx - 1][0]), Math.max(0, sp[idx - 1][1]));
3580
+ } else {
3581
+ slice = full.slice(Math.floor(full.length * (idx - 1) / total), Math.floor(full.length * idx / total));
3582
+ }
3583
+ return slice.replace(/\s+/g, " ").trim().slice(0, 30);
3542
3584
  };
3543
- const keepVisible = () => {
3585
+ const ttsFollowTick = () => {
3544
3586
  const diag = window.__dshTtsFollowDiag || (window.__dshTtsFollowDiag = { calls: 0, last: "init", byReason: {} });
3545
3587
  const mark = (r) => {
3546
3588
  diag.calls++;
@@ -3548,12 +3590,16 @@ window.__ModuleLoader__.load({
3548
3590
  diag.byReason[r] = (diag.byReason[r] || 0) + 1;
3549
3591
  };
3550
3592
  if (!shared.showScrollFollow || !shared.speaking) { mark("off-or-idle"); return false; }
3551
- // Yield to the user for 5s after any manual scroll.
3552
- if (Date.now() - ttsUserScrollAt.t < 5000) { mark("user-yield"); return false; }
3593
+ // Latched off: the user took over — stay parked until they tap
3594
+ // "resume follow". No auto-recovery (the old 5s yield yanked the
3595
+ // view back mid-read, the #1 follow complaint).
3596
+ if (!ttsFollow.latched) { mark("latched-off"); return false; }
3597
+ if (ttsReducedMotion()) { mark("reduced-motion"); return false; }
3553
3598
  if (shared.paused) { mark("paused"); return false; } // paused: hold still, don't drift
3554
- const ratio = ttsLiveRatio();
3555
- if (ratio == null) { mark("no-ratio"); return false; }
3556
- diag.ratio = ratio;
3599
+ const idx = ttsBlockIdx();
3600
+ const total = (shared.chunkProgress && shared.chunkProgress.total) || 0;
3601
+ if (idx == null || !total) { mark("no-ratio"); return false; }
3602
+ diag.idx = idx;
3557
3603
  try {
3558
3604
  const scrollEl = _ttsFindScrollContainer();
3559
3605
  if (!scrollEl) { mark("no-container"); return false; }
@@ -3602,6 +3648,7 @@ window.__ModuleLoader__.load({
3602
3648
  // one forever (that froze follow at the initial spot).
3603
3649
  const fullNeedle = shared.currentText || "";
3604
3650
  if (!fullNeedle) { mark("no-needle"); return false; }
3651
+ const ratio = total ? (idx - 1) / total : 0;
3605
3652
  const pos = Math.floor(fullNeedle.length * Math.min(0.99, Math.max(0, ratio)));
3606
3653
  const needle = fullNeedle.slice(Math.max(0, pos - 50), pos + 50);
3607
3654
  if (!needle) { mark("no-needle"); return false; }
@@ -3631,108 +3678,155 @@ window.__ModuleLoader__.load({
3631
3678
  }
3632
3679
  }
3633
3680
  if (!msgEl) { mark("no-msgEl"); return false; }
3681
+ mark("fragile"); // paragraph fallback: usable for this tick only,
3682
+ // never cached as the anchor (caching it froze follow in §C).
3634
3683
  } else { mark("anchored"); }
3635
- // Cache text-resolved anchors: once a lookup finds a sane-size
3636
- // element, reuse it (covers auto-read which has no click anchor).
3637
- // Sanity: must be big enough to be a message, not a line.
3638
- try {
3639
- const h = msgEl.getBoundingClientRect ? msgEl.getBoundingClientRect().height : 0;
3640
- if (h > 60 && typeof WeakRef !== "undefined") shared.readingMsgEl = new WeakRef(msgEl);
3641
- } catch (e) {}
3684
+ // Block locate + highlight happen below; the anchor itself is
3685
+ // never replaced by a paragraph hit (see "fragile" above).
3642
3686
 
3643
- // Live proportional target: glides through the message as audio plays
3687
+ // Current block: the paragraph sounding now. Searched inside the
3688
+ // anchor by the block's head text; falls back to the anchor
3689
+ // itself (highlighting the whole message is still truthful).
3644
3690
  const containerRect = scrollEl.getBoundingClientRect();
3645
- const msgRect = msgEl.getBoundingClientRect();
3646
- const msgTop = msgRect.top - containerRect.top + scrollEl.scrollTop;
3647
- const msgHeight = msgRect.height;
3648
- // Wrong-element guard (screen space!): skip only when the element
3649
- // is clearly a fragment — shorter than two lines, or nowhere near
3650
- // the viewport at all. NOTE msgTop is document coords; compare via
3651
- // rect delta instead (comparing msgTop against scrollTop wrongly
3652
- // rejected every tall message and froze follow completely).
3653
- const relTop = msgRect.top - containerRect.top;
3654
- const relBottom = msgRect.bottom - containerRect.top;
3655
- if (msgHeight < 40 || relBottom < -containerRect.height * 3 ||
3656
- relTop > containerRect.height * 3) {
3691
+ const head = ttsBlockHead(idx, total);
3692
+ let blockEl = msgEl;
3693
+ if (head && head.length >= 10 && msgEl.querySelectorAll) {
3694
+ try {
3695
+ const cands = msgEl.querySelectorAll("p,li,div,section,blockquote,pre,td");
3696
+ for (const c of cands) {
3697
+ if (c === msgEl) continue;
3698
+ try {
3699
+ if (c.querySelector && c.querySelector("p,li,div,section,blockquote,pre,td")) continue; // leaf-ish only
3700
+ } catch (e) {}
3701
+ let t = "";
3702
+ try { t = (c.textContent || "").trim().replace(/\s+/g, " "); } catch (e) {}
3703
+ if (t && t.length > 10 && t.includes(head.slice(0, 20))) { blockEl = c; break; }
3704
+ }
3705
+ } catch (e) {}
3706
+ }
3707
+ // Absurd-target guard: no box at all, or nowhere near the viewport.
3708
+ let bRect = null;
3709
+ try { bRect = blockEl.getBoundingClientRect(); } catch (e) {}
3710
+ if (!bRect || !(bRect.height > 0) ||
3711
+ bRect.bottom < containerRect.top - containerRect.height * 3 ||
3712
+ bRect.top > containerRect.bottom + containerRect.height * 3) {
3657
3713
  mark("bad-target");
3658
3714
  diag.guard = {
3659
- h: Math.round(msgHeight),
3660
- scrollH: msgEl.scrollHeight || 0,
3661
- relTop: Math.round(relTop),
3662
- relBottom: Math.round(relBottom),
3715
+ h: bRect ? Math.round(bRect.height) : -1,
3663
3716
  contH: Math.round(containerRect.height),
3664
- tag: (msgEl.tagName || "?") + "." + String(msgEl.className || "").slice(0, 60),
3717
+ tag: (blockEl.tagName || "?") + "." + String(blockEl.className || "").slice(0, 60),
3665
3718
  };
3666
3719
  return false;
3667
3720
  }
3668
- const targetY = msgTop + msgHeight * ratio;
3669
- diag.targetY = Math.round(targetY);
3670
- diag.msgH = Math.round(msgHeight);
3671
- const viewTop = scrollEl.scrollTop;
3672
- const viewBottom = viewTop + containerRect.height;
3673
- diag.viewTop = Math.round(viewTop);
3674
- // First tick after (re)anchor: centre the message unconditionally
3675
- // so clicking read gives instant visible feedback. Afterwards the
3676
- // deadband takes over for smooth tracking.
3721
+ // Block highlight (the visual anchor follow was missing): move it
3722
+ // as chunks advance. Whole-message fallback still truthful.
3723
+ try {
3724
+ const prev = shared.followHlEl;
3725
+ if (prev && prev !== blockEl) {
3726
+ try { prev.classList.remove("dsh-tts-sentence-active"); } catch (e) {}
3727
+ }
3728
+ if (blockEl.classList) {
3729
+ try { blockEl.classList.add("dsh-tts-sentence-active"); } catch (e) {}
3730
+ shared.followHlEl = blockEl;
3731
+ }
3732
+ } catch (e) {}
3733
+ // First tick of a click-initiated read: centre once for instant
3734
+ // feedback. (readingCenterOnce is only ever set for manual reads;
3735
+ // auto-read never yanks the view.)
3677
3736
  if (shared.readingCenterOnce) {
3678
3737
  shared.readingCenterOnce = false;
3679
- const scrollTo = msgTop + msgHeight * Math.min(0.5, Math.max(0, ratio)) - containerRect.height / 2;
3680
- ttsSelfScroll.on = true;
3738
+ let msgTop0 = 0;
3739
+ let msgH0 = 0;
3681
3740
  try {
3682
- scrollEl.scrollTo({ top: Math.max(0, scrollTo), behavior: "auto" });
3683
- } finally {
3684
- setTimeout(() => { ttsSelfScroll.on = false; }, 120);
3685
- }
3741
+ const msgRect0 = msgEl.getBoundingClientRect();
3742
+ msgTop0 = msgRect0.top - containerRect.top + scrollEl.scrollTop;
3743
+ msgH0 = msgRect0.height;
3744
+ } catch (e) {}
3745
+ ttsFollow.selfUntil = Date.now() + 300;
3746
+ try {
3747
+ scrollEl.scrollTo({ top: Math.max(0, msgTop0 + msgH0 / 2 - containerRect.height / 2), behavior: "auto" });
3748
+ } catch (e) {}
3686
3749
  mark("centred");
3687
3750
  return true;
3688
3751
  }
3689
- // Deadband: nudge only when the live point leaves the central
3690
- // 50% band; small steps (no smooth-animation pile-up) so the
3691
- // view tracks instead of rubber-banding to a stale spot.
3692
- const margin = containerRect.height * 0.25;
3693
- if (targetY > viewTop + margin && targetY < viewBottom - margin) {
3694
- mark("in-view"); // comfortably visible, leave it alone
3752
+ // Minimal scroll: fully visible (24px grace) → touch nothing.
3753
+ // Otherwise scrollIntoView nearest = smallest move that reveals it.
3754
+ // (Reduced-motion users never reach here: gated above.)
3755
+ const pad = 24;
3756
+ if (bRect.top >= containerRect.top + pad && bRect.bottom <= containerRect.bottom - pad) {
3757
+ mark("in-view");
3695
3758
  return true;
3696
3759
  }
3697
- const scrollTo = targetY - containerRect.height / 2;
3698
- // Skip tiny corrections (<24px) to avoid jitter from estimate wobble
3699
- if (Math.abs(scrollTo - viewTop) < 24) { mark("tiny-skip"); return true; }
3700
-
3701
- // Mark self-induced scroll so onUserScroll doesn't treat our own
3702
- // scrollTo() as a manual scroll (that froze follow forever: every
3703
- // heartbeat refreshed the 5s yield window with its own scroll).
3704
- ttsSelfScroll.on = true;
3760
+ ttsFollow.selfUntil = Date.now() + 600;
3705
3761
  try {
3706
- scrollEl.scrollTo({ top: Math.max(0, scrollTo), behavior: "auto" });
3707
- } finally {
3708
- setTimeout(() => { ttsSelfScroll.on = false; }, 120);
3709
- }
3710
- mark("scrolled");
3762
+ blockEl.scrollIntoView({ block: "nearest", behavior: "smooth" });
3763
+ } catch (e) {}
3764
+ mark("nearest-scrolled");
3711
3765
  return true;
3712
3766
  } catch (e) { mark("threw:" + String((e && e.message) || e).slice(0, 60)); return false; }
3713
3767
  };
3768
+ // Stepwise evaluation: only chunk-index changes can move the target
3769
+ // (no intra-chunk glide), so no polling loop is needed.
3714
3770
  react.useEffect(() => {
3715
- keepVisible();
3771
+ ttsFollowTick();
3716
3772
  }, [speaking, readingIdx, cp && cp.index, shared.showScrollFollow]);
3717
- // Live heartbeat: 4x/sec while speaking so the view tracks the
3718
- // playback position continuously, not just on chunk changes.
3719
- // Also records manual scrolls (user takes over for 5s).
3773
+ // Takeover detection (event-driven, no polling):
3774
+ // - wheel with delta / touchmove gesture / pointer drag with buttons
3775
+ // = confirmed user intent → latch off (stays off until the resume
3776
+ // pill). Host autoscroll during streaming emits none of these, so
3777
+ // live-growing replies no longer freeze follow.
3778
+ // - our own smooth scrollIntoView is fenced by selfUntil (cleared
3779
+ // early by scrollend, 600ms fallback) so it never latches us off;
3780
+ // momentum tails after a user flick land post-latch (no-op).
3781
+ // - visualViewport resize (mobile URL bar) re-evaluates geometry.
3720
3782
  react.useEffect(() => {
3721
3783
  if (!speaking || !shared.showScrollFollow) return undefined;
3722
- const scrollEl = _ttsFindScrollContainer();
3723
- const onUserScroll = () => {
3724
- if (ttsSelfScroll.on) return; // our own scrollTo(), not the user
3725
- ttsUserScrollAt.t = Date.now();
3784
+ const scrollEl = _ttsFindScrollContainer() ||
3785
+ (typeof document !== "undefined" && document.scrollingElement) || null;
3786
+ const isSelf = () => Date.now() < ttsFollow.selfUntil;
3787
+ const latchOff = () => {
3788
+ if (ttsFollow.latched) {
3789
+ ttsFollow.latched = false;
3790
+ try { notify(); } catch (e) {}
3791
+ }
3792
+ };
3793
+ const onWheel = (e) => {
3794
+ if (isSelf()) return;
3795
+ try {
3796
+ if ((e.deltaY || e.deltaX) !== 0) latchOff();
3797
+ } catch (err) {}
3798
+ };
3799
+ const onTouchStart = () => { ttsFollow.touchActive = true; };
3800
+ const onTouchMove = () => { if (!isSelf()) latchOff(); };
3801
+ const onTouchEnd = () => { ttsFollow.touchActive = false; };
3802
+ const onPointerMove = (e) => {
3803
+ if (isSelf()) return;
3804
+ try { if (e.buttons && e.buttons !== 0) latchOff(); } catch (err) {}
3805
+ };
3806
+ const onScrollEnd = () => { ttsFollow.selfUntil = 0; };
3807
+ const onViewResize = () => { ttsFollowTick(); };
3808
+ const regs = [];
3809
+ const listen = (target, type, fn) => {
3810
+ try {
3811
+ if (target) { target.addEventListener(type, fn, { passive: true }); regs.push([target, type, fn]); }
3812
+ } catch (e) {}
3726
3813
  };
3814
+ listen(scrollEl, "wheel", onWheel);
3815
+ listen(scrollEl, "touchstart", onTouchStart);
3816
+ listen(scrollEl, "touchmove", onTouchMove);
3817
+ listen(scrollEl, "touchend", onTouchEnd);
3818
+ listen(scrollEl, "pointermove", onPointerMove);
3819
+ listen(scrollEl, "scrollend", onScrollEnd);
3727
3820
  try {
3728
- if (scrollEl) scrollEl.addEventListener("scroll", onUserScroll, { passive: true });
3821
+ if (window.visualViewport) {
3822
+ window.visualViewport.addEventListener("resize", onViewResize);
3823
+ regs.push([window.visualViewport, "resize", onViewResize]);
3824
+ }
3729
3825
  } catch (e) {}
3730
- const timer = setInterval(keepVisible, 250);
3731
3826
  return () => {
3732
- clearInterval(timer);
3733
- try {
3734
- if (scrollEl) scrollEl.removeEventListener("scroll", onUserScroll);
3735
- } catch (e) {}
3827
+ for (const r of regs) {
3828
+ try { r[0].removeEventListener(r[1], r[2]); } catch (e) {}
3829
+ }
3736
3830
  };
3737
3831
  }, [speaking, shared.showScrollFollow]);
3738
3832
 
@@ -3777,6 +3871,23 @@ window.__ModuleLoader__.load({
3777
3871
  shared.paused ? t("overlay.paused") : t("overlay.reading"),
3778
3872
  ),
3779
3873
  ),
3874
+ // Resume-follow pill (v2 latch-off): only after the user takes over.
3875
+ (shared.showScrollFollow && speaking && !ttsFollow.latched && !ttsReducedMotion()
3876
+ ? react.createElement(
3877
+ "button",
3878
+ {
3879
+ className: "dsh-tts-follow-pill",
3880
+ title: t("follow.resume"),
3881
+ onClick: (e) => {
3882
+ if (e) { try { if (typeof e.stopPropagation === "function") e.stopPropagation(); } catch (err) {} }
3883
+ ttsFollow.latched = true;
3884
+ try { ttsFollowTick(); } catch (err) {}
3885
+ try { notify(); } catch (err) {}
3886
+ },
3887
+ },
3888
+ t("follow.resume"),
3889
+ )
3890
+ : null),
3780
3891
  );
3781
3892
  }
3782
3893
  slots.inject("shell.overlay", () =>
@@ -4912,6 +5023,10 @@ window.__ModuleLoader__.load({
4912
5023
  textLen: e.textLen,
4913
5024
  stage: e.stage || "",
4914
5025
  ms: e.ms,
5026
+ // job-summary entries (stage "job") carry counts, not errors.
5027
+ total: e.total,
5028
+ served: e.served,
5029
+ skipped: e.skipped,
4915
5030
  error: e.error || "",
4916
5031
  }));
4917
5032
  const clientRecent = shared.diagLog.map(e => {
@@ -4951,6 +5066,12 @@ window.__ModuleLoader__.load({
4951
5066
  exportedAt: new Date().toISOString(),
4952
5067
  ua: (() => { try { return navigator.userAgent; } catch (e) { return ""; } })(),
4953
5068
  plugin: (payload && payload.plugin) || null,
5069
+ // Build tags pin down WHICH code produced the log: package
5070
+ // versions only move on releases, so two different builds are
5071
+ // otherwise indistinguishable (exactly what stalled the 1/3/2
5072
+ // swap triage — the failing runs predated the fix).
5073
+ clientBuild: CLIENT_BUILD,
5074
+ hostBuild: (payload && payload.hostBuild) || null,
4954
5075
  // True when the Host predates the extended diagnose response
4955
5076
  // (plugin updated but dsh web not restarted): checks are valid,
4956
5077
  // but worker/recent/plugin are missing — re-run after a restart.