diff --git a/app/engine.js b/app/engine.js index 29d557d..0ad2d4d 100644 --- a/app/engine.js +++ b/app/engine.js @@ -195,6 +195,59 @@ const state = { degradedAt: 0 }; +/* + * FOLLOW THE BOTTOM ONLY WHILE THE OPERATOR IS AT THE BOTTOM. + * + * Every one of these boxes used to end its update with + * `box.scrollTop = box.scrollHeight`, unconditionally. While the service is + * running that fires on every sentence, so scrolling back to re-read what was + * just said was impossible: the box yanked itself to the live edge a fraction + * of a second later, every time. Reported from the desk as "can't scroll on + * what's being said when it's live", which is exactly what it was. + * + * A box follows the live edge until the operator scrolls away from it, and + * starts following again when they come back to it. Nothing has to be pressed + * to get out, and nothing has to be pressed to get back in. + * + * The slack matters. A programmatic jump to the bottom also raises a scroll + * event, and it lands AT the bottom, so sticking survives its own scrolling. + * Sub-pixel layout and a growing last line can leave a couple of pixels + * behind, which with a zero threshold would unstick the box on its own output. + */ +/* + * Trimming the TOP of a box the operator is reading moves the text under + * their eyes — the other half of "it keeps jumping". While they are scrolled + * away the cap is suspended, so the lines they are looking at stay put; when + * they return to the live edge the backlog is trimmed in one go. The ceiling + * keeps a box that is left scrolled up all service from growing without end. + */ +function trimTop(box, cap) { + if (!box) return; + const held = box.dataset.stick === '0'; + const limit = held ? cap * 6 : cap; + while (box.children.length > limit) box.removeChild(box.firstChild); +} + +const STICK_SLACK_PX = 48; +const stickWired = new WeakSet(); + +function followBottom(box) { + if (!box) return; + if (!stickWired.has(box)) { + stickWired.add(box); + box.dataset.stick = '1'; + box.addEventListener('scroll', () => { + const atBottom = + box.scrollHeight - box.scrollTop - box.clientHeight <= STICK_SLACK_PX; + box.dataset.stick = atBottom ? '1' : '0'; + }, { passive: true }); + } + /* Scrolled away: leave the view exactly where they put it. New content + grows BELOW the viewport, so not touching scrollTop is what holds the + reader's place — there is nothing else to do here. */ + if (box.dataset.stick !== '0') box.scrollTop = box.scrollHeight; +} + // ────────────────────────────────────────────────────────── logging function log(msg, kind) { @@ -208,8 +261,8 @@ function log(msg, kind) { if (kind) line.className = kind; line.textContent = new Date().toLocaleTimeString() + ' ' + msg; el.appendChild(line); - while (el.children.length > 200) el.removeChild(el.firstChild); - el.scrollTop = el.scrollHeight; + trimTop(el, 200); + followBottom(el); } function hero(stateName, title, body) { @@ -2471,8 +2524,8 @@ function monitorFinal(cid, text) { monRows.set(cid, el); /* Neither the map nor the box may grow for the length of a service. */ while (monRows.size > 60) monRows.delete(monRows.keys().next().value); - while (box.children.length > 24) box.removeChild(box.firstChild); - box.scrollTop = box.scrollHeight; + trimTop(box, 24); + followBottom(box); } /* @@ -2514,7 +2567,7 @@ function monitorTranslation(cid, lang, text) { row.classList.add('dropped'); } const box = $('monitor'); - if (box) box.scrollTop = box.scrollHeight; + followBottom(box); } /* @@ -2581,9 +2634,9 @@ function showMonitor(text, isPartial, langCount) { sub.textContent = langCount + ' language' + (langCount === 1 ? '' : 's') + ' · ' + state.lagMs + ' ms'; el.appendChild(sub); box.appendChild(el); - while (box.children.length > 30) box.removeChild(box.firstChild); + trimTop(box, 30); } - box.scrollTop = box.scrollHeight; + followBottom(box); } // ────────────────────────────────────────────────────────── church server diff --git a/app/package.json b/app/package.json index 0c4e598..ce1a8ad 100644 --- a/app/package.json +++ b/app/package.json @@ -2,7 +2,7 @@ "name": "church-translate-engine", "productName": "Church Translate", "version": "3.23.2", - "buildNumber": 125, + "buildNumber": 126, "private": true, "description": "Live sermon translation — self-contained: captures audio, transcribes, translates, and serves the congregation page itself", "main": "main.js", @@ -106,7 +106,7 @@ ] }, "asar": false, - "buildVersion": "125", + "buildVersion": "126", "win": { "target": [ { diff --git a/app/server/public/listen.js b/app/server/public/listen.js index 48c6007..f4abd17 100644 --- a/app/server/public/listen.js +++ b/app/server/public/listen.js @@ -519,7 +519,16 @@ /* After any interruption the player resumes where it left off and stays behind live forever — no error, no stall, just permanently late. Every one of these events is a chance to catch back up. */ - ['playing', 'stalled', 'waiting', 'canplay', 'loadedmetadata'].forEach(function (ev) { + /* + * 'stalled' and 'waiting' are deliberately NOT in this list. A seek on a + * live HLS stream makes the element re-buffer, which raises exactly those + * two events — so seeking from them is a loop that feeds itself: seek, + * stall, seek, stall. On Safari, where HLS is played natively and a seek + * lands on a segment boundary, each turn of that loop replays or skips + * part of a sentence. That is the "it repeats a bit two or three times + * then moves on" reported from iPhones. + */ + ['playing', 'canplay', 'loadedmetadata'].forEach(function (ev) { a.addEventListener(ev, function () { seekLive(); }); }); @@ -599,8 +608,28 @@ * and 4s of tolerance plus a 5s check interval meant real drift could reach * nine seconds before anyone looked. */ - var DRIFT_MAX = 1.5; - var DRIFT_LAND = 0.6; + /* + * DRIFT_LAND WAS 0.6 S, AND ONE SEGMENT IS 0.697 S. + * + * So the target sat INSIDE the newest segment — a point the player very + * often did not have yet. It would be dragged up to the edge, run out of + * audio, stall, and the stall would trigger another correction. A phone + * cannot hold a position closer to live than the segments arriving. + * + * Three segments back (~2.1 s) is what HLS players choose for themselves on + * a live playlist, and it is the smallest target with enough buffered audio + * to survive one late segment. The tolerance is wider than one segment too, + * so an ordinary arrival wobble is not read as drift. + * + * It costs about a second and a half of extra delay. A listener a second + * further behind is following the service; a listener whose audio keeps + * jumping backwards is not. + */ + var DRIFT_MAX = 3.0; + var DRIFT_LAND = 2.1; + /* A correction needs time to settle before its own result is judged. */ + var SEEK_COOLDOWN_MS = 5000; + var lastSeekAt = 0; /* * How far behind the live edge this listener is SUPPOSED to be. @@ -627,6 +656,11 @@ var want = driftTarget(); var behind = (edge - a.currentTime) - want; // + = too late, - = too early if (!force && Math.abs(behind) <= DRIFT_MAX) return; + /* One correction at a time. Without this, timeupdate (four times a + second) can fire a fresh seek while the last one is still buffering, + and the element never gets a chance to settle anywhere. */ + if (!force && Date.now() - lastSeekAt < SEEK_COOLDOWN_MS) return; + lastSeekAt = Date.now(); /* * Land just BEHIND the target, never on the edge. Seeking exactly to the * live edge leaves no buffered audio at all, so the phone stalls on the