@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/README.md +67 -27
- package/README.zh.md +290 -263
- package/docs/USER-GUIDE.md +72 -17
- package/docs/acceptance-checklist.md +3 -3
- package/docs/scroll-follow-v2.md +102 -0
- package/lib/client.js +251 -130
- package/lib/index.mjs +86 -8
- package/package.json +1 -1
- package/tests/client-load.mjs +33 -20
- package/tests/smoke.mjs +55 -0
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.
|
|
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": "
|
|
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
|
|
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
|
|
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 >
|
|
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
|
-
//
|
|
2716
|
-
|
|
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
|
-
|
|
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:
|
|
3498
|
-
//
|
|
3499
|
-
//
|
|
3500
|
-
//
|
|
3501
|
-
//
|
|
3502
|
-
//
|
|
3503
|
-
//
|
|
3504
|
-
//
|
|
3505
|
-
|
|
3506
|
-
//
|
|
3507
|
-
|
|
3508
|
-
const
|
|
3509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
Math.floor(
|
|
3538
|
-
|
|
3539
|
-
|
|
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
|
|
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
|
-
//
|
|
3552
|
-
|
|
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
|
|
3555
|
-
|
|
3556
|
-
|
|
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
|
-
//
|
|
3636
|
-
//
|
|
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
|
-
//
|
|
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
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
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(
|
|
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: (
|
|
3717
|
+
tag: (blockEl.tagName || "?") + "." + String(blockEl.className || "").slice(0, 60),
|
|
3665
3718
|
};
|
|
3666
3719
|
return false;
|
|
3667
3720
|
}
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
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
|
-
|
|
3680
|
-
|
|
3738
|
+
let msgTop0 = 0;
|
|
3739
|
+
let msgH0 = 0;
|
|
3681
3740
|
try {
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
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
|
-
//
|
|
3690
|
-
//
|
|
3691
|
-
//
|
|
3692
|
-
const
|
|
3693
|
-
if (
|
|
3694
|
-
mark("in-view");
|
|
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
|
-
|
|
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
|
-
|
|
3707
|
-
}
|
|
3708
|
-
|
|
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
|
-
|
|
3771
|
+
ttsFollowTick();
|
|
3716
3772
|
}, [speaking, readingIdx, cp && cp.index, shared.showScrollFollow]);
|
|
3717
|
-
//
|
|
3718
|
-
//
|
|
3719
|
-
//
|
|
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
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
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 (
|
|
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
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
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.
|