@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/README.md +68 -28
- package/README.zh.md +290 -263
- package/cordis.patch.yml +1 -1
- package/docs/USER-GUIDE.md +72 -17
- package/docs/acceptance-checklist.md +4 -4
- package/docs/scroll-follow-v2.md +102 -0
- package/lib/client.js +244 -132
- package/lib/index.mjs +88 -10
- package/package.json +1 -1
- package/tests/client-load.mjs +33 -20
- package/tests/smoke.mjs +74 -1
package/lib/client.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// @
|
|
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: "@
|
|
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.
|
|
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": "
|
|
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) {
|
|
@@ -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
|
-
//
|
|
2716
|
-
|
|
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
|
-
|
|
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:
|
|
3498
|
-
//
|
|
3499
|
-
//
|
|
3500
|
-
//
|
|
3501
|
-
//
|
|
3502
|
-
//
|
|
3503
|
-
//
|
|
3504
|
-
//
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
const
|
|
3508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
-
//
|
|
3552
|
-
|
|
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
|
|
3555
|
-
|
|
3556
|
-
|
|
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
|
-
//
|
|
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) {}
|
|
3675
|
+
// Block locate + highlight happen below; the anchor itself is
|
|
3676
|
+
// never replaced by a paragraph hit (see "fragile" above).
|
|
3642
3677
|
|
|
3643
|
-
//
|
|
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
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
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(
|
|
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: (
|
|
3708
|
+
tag: (blockEl.tagName || "?") + "." + String(blockEl.className || "").slice(0, 60),
|
|
3665
3709
|
};
|
|
3666
3710
|
return false;
|
|
3667
3711
|
}
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
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
|
-
|
|
3680
|
-
|
|
3729
|
+
let msgTop0 = 0;
|
|
3730
|
+
let msgH0 = 0;
|
|
3681
3731
|
try {
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
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
|
-
//
|
|
3690
|
-
//
|
|
3691
|
-
//
|
|
3692
|
-
const
|
|
3693
|
-
if (
|
|
3694
|
-
mark("in-view");
|
|
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
|
-
|
|
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
|
-
|
|
3707
|
-
}
|
|
3708
|
-
|
|
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
|
-
|
|
3762
|
+
ttsFollowTick();
|
|
3716
3763
|
}, [speaking, readingIdx, cp && cp.index, shared.showScrollFollow]);
|
|
3717
|
-
//
|
|
3718
|
-
//
|
|
3719
|
-
//
|
|
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
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
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 (
|
|
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
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
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.
|