@memef1f1y/dsh-plugin-tts 0.6.1 → 0.6.3

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
@@ -1,8 +1,8 @@
1
- // @dsh-external/dsh-plugin-tts — Client half (browser bundle).
1
+ // @memef1f1y/dsh-plugin-tts — Client half (browser bundle).
2
2
  // Hand-written in the harness module-loader format; `require` answers the
3
3
  // platform externals (react), everything else is inlined.
4
4
  window.__ModuleLoader__.load({
5
- id: "@dsh-external/dsh-plugin-tts",
5
+ id: "@memef1f1y/dsh-plugin-tts",
6
6
  factory: require => {
7
7
  var module = { exports: {} };
8
8
  var exports = module.exports;
@@ -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.04-follow-v2";
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) {
@@ -2395,6 +2398,18 @@ window.__ModuleLoader__.load({
2395
2398
  if (typeof onError === "function") onError(t("err.chunkFail") + r.error);
2396
2399
  } else {
2397
2400
  completed = true;
2401
+ // Silent early end (GC'd/unknown job, cancelled Host job):
2402
+ // without this the player spins on "synthesizing…" forever
2403
+ // with no trace. Only fires when indices never arrived — a
2404
+ // clean drain has every queue slot filled.
2405
+ let served = 0;
2406
+ for (let i = 0; i < total; i++) if (queue[i] !== undefined) served++;
2407
+ if (served < total) {
2408
+ try {
2409
+ logDiag({ kind: "chunk-early-done", text: "job " + jobId + " ended with " + served + "/" + total + " chunks served" });
2410
+ } catch (e) {}
2411
+ if (typeof onError === "function") onError(t("err.chunkFail") + " (" + served + "/" + total + ")");
2412
+ }
2398
2413
  }
2399
2414
  })
2400
2415
  .catch(() => {
@@ -2601,6 +2616,18 @@ window.__ModuleLoader__.load({
2601
2616
  if (typeof onError === "function") onError(t("err.chunkFail") + r.error);
2602
2617
  } else {
2603
2618
  completed = true;
2619
+ // Silent early end (GC'd/unknown job, cancelled Host job):
2620
+ // without this the player spins on "synthesizing…" forever
2621
+ // with no trace. Only fires when indices never arrived — a
2622
+ // clean drain has every queue slot filled.
2623
+ let served = 0;
2624
+ for (let i = 0; i < total; i++) if (queue[i] !== undefined) served++;
2625
+ if (served < total) {
2626
+ try {
2627
+ logDiag({ kind: "chunk-early-done", text: "job " + jobId + " ended with " + served + "/" + total + " chunks served" });
2628
+ } catch (e) {}
2629
+ if (typeof onError === "function") onError(t("err.chunkFail") + " (" + served + "/" + total + ")");
2630
+ }
2604
2631
  }
2605
2632
  })
2606
2633
  .catch(() => {
@@ -2710,10 +2737,14 @@ window.__ModuleLoader__.load({
2710
2737
  // the chunked branch overwrites this with the real total.
2711
2738
  shared.chunkProgress = { index: 1, total: 1 };
2712
2739
  shared.readingIndex = 1;
2740
+ shared.chunkSpans = null; // exact block spans arrive with chunked jobs (P1-3)
2713
2741
  // Playback-start timestamp for smooth scroll interpolation.
2714
2742
  shared.chunkStartedAt = Date.now();
2715
- // Centre the message on the first follow tick (instant feedback).
2716
- shared.readingCenterOnce = true;
2743
+ // Follow engaged fresh; centre-once only for click-initiated reads —
2744
+ // auto-read must never yank the view away from what the user reads.
2745
+ ttsFollow.latched = true;
2746
+ try { _ttsClearHighlight(); } catch (e) {}
2747
+ shared.readingCenterOnce = (source === "manual");
2717
2748
  // New utterance: drop the previous message anchor (button clicks
2718
2749
  // re-anchor below; auto-read resolves on first successful lookup).
2719
2750
  shared.readingMsgEl = null;
@@ -2773,6 +2804,7 @@ window.__ModuleLoader__.load({
2773
2804
  if (Array.isArray(result.chunks) && result.chunks.length) {
2774
2805
  const total = result.total || result.chunks.length;
2775
2806
  shared.currentJobId = result.jobId || null;
2807
+ shared.chunkSpans = Array.isArray(result.spans) ? result.spans : null;
2776
2808
  shared.chunkProgress = { index: 1, total };
2777
2809
  notify();
2778
2810
  // Prewarm skips happened during /speak (not at /rvc-next time),
@@ -3126,7 +3158,12 @@ window.__ModuleLoader__.load({
3126
3158
  ".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
3159
  ".dsh-tts-reading-overlay[data-visible]{opacity:1}" +
3128
3160
  ".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}" +
3161
+ ".dsh-tts-reading-overlay[data-visible] .dsh-tts-reading-bar{transform:translateX(0);pointer-events:auto}" +
3162
+ // v2 block highlight (visual anchor for follow): soft background,
3163
+ // no box-shadow (never reintroduce the shadow style).
3164
+ ".dsh-tts-sentence-active{background:var(--dsw-alias-bg-layer-2);border-radius:6px;transition:background-color .3s ease}" +
3165
+ // Resume-follow pill: appears only after the user latches follow off.
3166
+ ".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
3167
  ".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
3168
  "@keyframes dsh-tts-rpulse{0%,100%{opacity:.35;transform:scale(.75)}50%{opacity:1;transform:scale(1.15)}}" +
3132
3169
  ".dsh-tts-reading-label{font-weight:600;font-variant-numeric:tabular-nums}" +
@@ -3441,6 +3478,12 @@ window.__ModuleLoader__.load({
3441
3478
  // classes left by older bundles.)
3442
3479
  let _ttsHighlightedEl = null;
3443
3480
  function _ttsClearHighlight() {
3481
+ try {
3482
+ if (shared.followHlEl) {
3483
+ try { shared.followHlEl.classList.remove("dsh-tts-sentence-active"); } catch (e) {}
3484
+ shared.followHlEl = null;
3485
+ }
3486
+ } catch (e) {}
3444
3487
  if (_ttsHighlightedEl) {
3445
3488
  try {
3446
3489
  _ttsHighlightedEl.classList.remove("dsh-tts-sentence-active");
@@ -3494,53 +3537,44 @@ window.__ModuleLoader__.load({
3494
3537
  const tip = shared.paused ? t("mini.resume")
3495
3538
  : (t("mini.pause") + (cp && cp.total > 1 ? " · " + (readingIdx || 0) + "/" + cp.total : ""));
3496
3539
 
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);
3540
+ // --- Scroll-follow v2: visibility-driven minimal scrolling ---
3541
+ // Design: docs/scroll-follow-v2.md. Block-index stepwise signal (no
3542
+ // intra-chunk interpolation — the old equal-slice timing estimate
3543
+ // drifted against variable-length chunks and snapped at boundaries);
3544
+ // whole-message anchor only; scroll only when the current block
3545
+ // leaves the viewport (block:"nearest"); user takeover latches off
3546
+ // with manual resume; reduced-motion respected. No polling loop:
3547
+ // evaluation runs per chunk-index change + on scroll/touch/viewport
3548
+ // events (the old 250ms full-DOM scan forced reflow at 4Hz).
3549
+ const ttsFollow = { latched: true, touchActive: false, selfUntil: 0 };
3550
+ const ttsReducedMotion = () => {
3551
+ try { return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); } catch (e) { return false; }
3518
3552
  };
3519
- const ttsLiveRatio = () => {
3553
+ // Which chunk is sounding (1-based). Stepwise: no fake glide inside
3554
+ // a chunk, so pause/playback-rate can never desync the position.
3555
+ const ttsBlockIdx = () => {
3520
3556
  const progress = shared.chunkProgress;
3521
3557
  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);
3558
+ return Math.min(Math.max(shared.readingIndex || 1, 1), progress.total);
3559
+ };
3560
+ // Approximate text sounding at block idx (equal division). Used ONLY
3561
+ // to locate the paragraph for scroll/highlight — never for timing.
3562
+ // Server spans (P1-3) make this exact; until then, advisory mapping.
3563
+ const ttsBlockHead = (idx, total) => {
3564
+ const full = shared.currentText || "";
3565
+ if (!full || !total || total <= 0) return "";
3566
+ // Exact server spans when present (P1-3), equal-division fallback.
3567
+ const sp = shared.chunkSpans;
3568
+ let slice = "";
3569
+ if (Array.isArray(sp) && sp[idx - 1] && sp[idx - 1].length === 2 &&
3570
+ Number.isFinite(sp[idx - 1][0]) && Number.isFinite(sp[idx - 1][1])) {
3571
+ slice = full.slice(Math.max(0, sp[idx - 1][0]), Math.max(0, sp[idx - 1][1]));
3572
+ } else {
3573
+ slice = full.slice(Math.floor(full.length * (idx - 1) / total), Math.floor(full.length * idx / total));
3574
+ }
3575
+ return slice.replace(/\s+/g, " ").trim().slice(0, 30);
3542
3576
  };
3543
- const keepVisible = () => {
3577
+ const ttsFollowTick = () => {
3544
3578
  const diag = window.__dshTtsFollowDiag || (window.__dshTtsFollowDiag = { calls: 0, last: "init", byReason: {} });
3545
3579
  const mark = (r) => {
3546
3580
  diag.calls++;
@@ -3548,12 +3582,16 @@ window.__ModuleLoader__.load({
3548
3582
  diag.byReason[r] = (diag.byReason[r] || 0) + 1;
3549
3583
  };
3550
3584
  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; }
3585
+ // Latched off: the user took over — stay parked until they tap
3586
+ // "resume follow". No auto-recovery (the old 5s yield yanked the
3587
+ // view back mid-read, the #1 follow complaint).
3588
+ if (!ttsFollow.latched) { mark("latched-off"); return false; }
3589
+ if (ttsReducedMotion()) { mark("reduced-motion"); return false; }
3553
3590
  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;
3591
+ const idx = ttsBlockIdx();
3592
+ const total = (shared.chunkProgress && shared.chunkProgress.total) || 0;
3593
+ if (idx == null || !total) { mark("no-ratio"); return false; }
3594
+ diag.idx = idx;
3557
3595
  try {
3558
3596
  const scrollEl = _ttsFindScrollContainer();
3559
3597
  if (!scrollEl) { mark("no-container"); return false; }
@@ -3631,108 +3669,155 @@ window.__ModuleLoader__.load({
3631
3669
  }
3632
3670
  }
3633
3671
  if (!msgEl) { mark("no-msgEl"); return false; }
3672
+ mark("fragile"); // paragraph fallback: usable for this tick only,
3673
+ // never cached as the anchor (caching it froze follow in §C).
3634
3674
  } 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) {}
3675
+ // Block locate + highlight happen below; the anchor itself is
3676
+ // never replaced by a paragraph hit (see "fragile" above).
3642
3677
 
3643
- // Live proportional target: glides through the message as audio plays
3678
+ // Current block: the paragraph sounding now. Searched inside the
3679
+ // anchor by the block's head text; falls back to the anchor
3680
+ // itself (highlighting the whole message is still truthful).
3644
3681
  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) {
3682
+ const head = ttsBlockHead(idx, total);
3683
+ let blockEl = msgEl;
3684
+ if (head && head.length >= 10 && msgEl.querySelectorAll) {
3685
+ try {
3686
+ const cands = msgEl.querySelectorAll("p,li,div,section,blockquote,pre,td");
3687
+ for (const c of cands) {
3688
+ if (c === msgEl) continue;
3689
+ try {
3690
+ if (c.querySelector && c.querySelector("p,li,div,section,blockquote,pre,td")) continue; // leaf-ish only
3691
+ } catch (e) {}
3692
+ let t = "";
3693
+ try { t = (c.textContent || "").trim().replace(/\s+/g, " "); } catch (e) {}
3694
+ if (t && t.length > 10 && t.includes(head.slice(0, 20))) { blockEl = c; break; }
3695
+ }
3696
+ } catch (e) {}
3697
+ }
3698
+ // Absurd-target guard: no box at all, or nowhere near the viewport.
3699
+ let bRect = null;
3700
+ try { bRect = blockEl.getBoundingClientRect(); } catch (e) {}
3701
+ if (!bRect || !(bRect.height > 0) ||
3702
+ bRect.bottom < containerRect.top - containerRect.height * 3 ||
3703
+ bRect.top > containerRect.bottom + containerRect.height * 3) {
3657
3704
  mark("bad-target");
3658
3705
  diag.guard = {
3659
- h: Math.round(msgHeight),
3660
- scrollH: msgEl.scrollHeight || 0,
3661
- relTop: Math.round(relTop),
3662
- relBottom: Math.round(relBottom),
3706
+ h: bRect ? Math.round(bRect.height) : -1,
3663
3707
  contH: Math.round(containerRect.height),
3664
- tag: (msgEl.tagName || "?") + "." + String(msgEl.className || "").slice(0, 60),
3708
+ tag: (blockEl.tagName || "?") + "." + String(blockEl.className || "").slice(0, 60),
3665
3709
  };
3666
3710
  return false;
3667
3711
  }
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.
3712
+ // Block highlight (the visual anchor follow was missing): move it
3713
+ // as chunks advance. Whole-message fallback still truthful.
3714
+ try {
3715
+ const prev = shared.followHlEl;
3716
+ if (prev && prev !== blockEl) {
3717
+ try { prev.classList.remove("dsh-tts-sentence-active"); } catch (e) {}
3718
+ }
3719
+ if (blockEl.classList) {
3720
+ try { blockEl.classList.add("dsh-tts-sentence-active"); } catch (e) {}
3721
+ shared.followHlEl = blockEl;
3722
+ }
3723
+ } catch (e) {}
3724
+ // First tick of a click-initiated read: centre once for instant
3725
+ // feedback. (readingCenterOnce is only ever set for manual reads;
3726
+ // auto-read never yanks the view.)
3677
3727
  if (shared.readingCenterOnce) {
3678
3728
  shared.readingCenterOnce = false;
3679
- const scrollTo = msgTop + msgHeight * Math.min(0.5, Math.max(0, ratio)) - containerRect.height / 2;
3680
- ttsSelfScroll.on = true;
3729
+ let msgTop0 = 0;
3730
+ let msgH0 = 0;
3681
3731
  try {
3682
- scrollEl.scrollTo({ top: Math.max(0, scrollTo), behavior: "auto" });
3683
- } finally {
3684
- setTimeout(() => { ttsSelfScroll.on = false; }, 120);
3685
- }
3732
+ const msgRect0 = msgEl.getBoundingClientRect();
3733
+ msgTop0 = msgRect0.top - containerRect.top + scrollEl.scrollTop;
3734
+ msgH0 = msgRect0.height;
3735
+ } catch (e) {}
3736
+ ttsFollow.selfUntil = Date.now() + 300;
3737
+ try {
3738
+ scrollEl.scrollTo({ top: Math.max(0, msgTop0 + msgH0 / 2 - containerRect.height / 2), behavior: "auto" });
3739
+ } catch (e) {}
3686
3740
  mark("centred");
3687
3741
  return true;
3688
3742
  }
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
3743
+ // Minimal scroll: fully visible (24px grace) → touch nothing.
3744
+ // Otherwise scrollIntoView nearest = smallest move that reveals it.
3745
+ // (Reduced-motion users never reach here: gated above.)
3746
+ const pad = 24;
3747
+ if (bRect.top >= containerRect.top + pad && bRect.bottom <= containerRect.bottom - pad) {
3748
+ mark("in-view");
3695
3749
  return true;
3696
3750
  }
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;
3751
+ ttsFollow.selfUntil = Date.now() + 600;
3705
3752
  try {
3706
- scrollEl.scrollTo({ top: Math.max(0, scrollTo), behavior: "auto" });
3707
- } finally {
3708
- setTimeout(() => { ttsSelfScroll.on = false; }, 120);
3709
- }
3710
- mark("scrolled");
3753
+ blockEl.scrollIntoView({ block: "nearest", behavior: "smooth" });
3754
+ } catch (e) {}
3755
+ mark("nearest-scrolled");
3711
3756
  return true;
3712
3757
  } catch (e) { mark("threw:" + String((e && e.message) || e).slice(0, 60)); return false; }
3713
3758
  };
3759
+ // Stepwise evaluation: only chunk-index changes can move the target
3760
+ // (no intra-chunk glide), so no polling loop is needed.
3714
3761
  react.useEffect(() => {
3715
- keepVisible();
3762
+ ttsFollowTick();
3716
3763
  }, [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).
3764
+ // Takeover detection (event-driven, no polling):
3765
+ // - wheel with delta / touchmove gesture / pointer drag with buttons
3766
+ // = confirmed user intent → latch off (stays off until the resume
3767
+ // pill). Host autoscroll during streaming emits none of these, so
3768
+ // live-growing replies no longer freeze follow.
3769
+ // - our own smooth scrollIntoView is fenced by selfUntil (cleared
3770
+ // early by scrollend, 600ms fallback) so it never latches us off;
3771
+ // momentum tails after a user flick land post-latch (no-op).
3772
+ // - visualViewport resize (mobile URL bar) re-evaluates geometry.
3720
3773
  react.useEffect(() => {
3721
3774
  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();
3775
+ const scrollEl = _ttsFindScrollContainer() ||
3776
+ (typeof document !== "undefined" && document.scrollingElement) || null;
3777
+ const isSelf = () => Date.now() < ttsFollow.selfUntil;
3778
+ const latchOff = () => {
3779
+ if (ttsFollow.latched) {
3780
+ ttsFollow.latched = false;
3781
+ try { notify(); } catch (e) {}
3782
+ }
3783
+ };
3784
+ const onWheel = (e) => {
3785
+ if (isSelf()) return;
3786
+ try {
3787
+ if ((e.deltaY || e.deltaX) !== 0) latchOff();
3788
+ } catch (err) {}
3726
3789
  };
3790
+ const onTouchStart = () => { ttsFollow.touchActive = true; };
3791
+ const onTouchMove = () => { if (!isSelf()) latchOff(); };
3792
+ const onTouchEnd = () => { ttsFollow.touchActive = false; };
3793
+ const onPointerMove = (e) => {
3794
+ if (isSelf()) return;
3795
+ try { if (e.buttons && e.buttons !== 0) latchOff(); } catch (err) {}
3796
+ };
3797
+ const onScrollEnd = () => { ttsFollow.selfUntil = 0; };
3798
+ const onViewResize = () => { ttsFollowTick(); };
3799
+ const regs = [];
3800
+ const listen = (target, type, fn) => {
3801
+ try {
3802
+ if (target) { target.addEventListener(type, fn, { passive: true }); regs.push([target, type, fn]); }
3803
+ } catch (e) {}
3804
+ };
3805
+ listen(scrollEl, "wheel", onWheel);
3806
+ listen(scrollEl, "touchstart", onTouchStart);
3807
+ listen(scrollEl, "touchmove", onTouchMove);
3808
+ listen(scrollEl, "touchend", onTouchEnd);
3809
+ listen(scrollEl, "pointermove", onPointerMove);
3810
+ listen(scrollEl, "scrollend", onScrollEnd);
3727
3811
  try {
3728
- if (scrollEl) scrollEl.addEventListener("scroll", onUserScroll, { passive: true });
3812
+ if (window.visualViewport) {
3813
+ window.visualViewport.addEventListener("resize", onViewResize);
3814
+ regs.push([window.visualViewport, "resize", onViewResize]);
3815
+ }
3729
3816
  } catch (e) {}
3730
- const timer = setInterval(keepVisible, 250);
3731
3817
  return () => {
3732
- clearInterval(timer);
3733
- try {
3734
- if (scrollEl) scrollEl.removeEventListener("scroll", onUserScroll);
3735
- } catch (e) {}
3818
+ for (const r of regs) {
3819
+ try { r[0].removeEventListener(r[1], r[2]); } catch (e) {}
3820
+ }
3736
3821
  };
3737
3822
  }, [speaking, shared.showScrollFollow]);
3738
3823
 
@@ -3777,6 +3862,23 @@ window.__ModuleLoader__.load({
3777
3862
  shared.paused ? t("overlay.paused") : t("overlay.reading"),
3778
3863
  ),
3779
3864
  ),
3865
+ // Resume-follow pill (v2 latch-off): only after the user takes over.
3866
+ (shared.showScrollFollow && speaking && !ttsFollow.latched && !ttsReducedMotion()
3867
+ ? react.createElement(
3868
+ "button",
3869
+ {
3870
+ className: "dsh-tts-follow-pill",
3871
+ title: t("follow.resume"),
3872
+ onClick: (e) => {
3873
+ if (e) { try { if (typeof e.stopPropagation === "function") e.stopPropagation(); } catch (err) {} }
3874
+ ttsFollow.latched = true;
3875
+ try { ttsFollowTick(); } catch (err) {}
3876
+ try { notify(); } catch (err) {}
3877
+ },
3878
+ },
3879
+ t("follow.resume"),
3880
+ )
3881
+ : null),
3780
3882
  );
3781
3883
  }
3782
3884
  slots.inject("shell.overlay", () =>
@@ -4912,6 +5014,10 @@ window.__ModuleLoader__.load({
4912
5014
  textLen: e.textLen,
4913
5015
  stage: e.stage || "",
4914
5016
  ms: e.ms,
5017
+ // job-summary entries (stage "job") carry counts, not errors.
5018
+ total: e.total,
5019
+ served: e.served,
5020
+ skipped: e.skipped,
4915
5021
  error: e.error || "",
4916
5022
  }));
4917
5023
  const clientRecent = shared.diagLog.map(e => {
@@ -4951,6 +5057,12 @@ window.__ModuleLoader__.load({
4951
5057
  exportedAt: new Date().toISOString(),
4952
5058
  ua: (() => { try { return navigator.userAgent; } catch (e) { return ""; } })(),
4953
5059
  plugin: (payload && payload.plugin) || null,
5060
+ // Build tags pin down WHICH code produced the log: package
5061
+ // versions only move on releases, so two different builds are
5062
+ // otherwise indistinguishable (exactly what stalled the 1/3/2
5063
+ // swap triage — the failing runs predated the fix).
5064
+ clientBuild: CLIENT_BUILD,
5065
+ hostBuild: (payload && payload.hostBuild) || null,
4954
5066
  // True when the Host predates the extended diagnose response
4955
5067
  // (plugin updated but dsh web not restarted): checks are valid,
4956
5068
  // but worker/recent/plugin are missing — re-run after a restart.