Author SHA1 Message Date
christianandClaude Opus 5 95d1a7b9ec Stop the stream seeking against itself, and stop the desk scrolling itself
Build 126. Two reports, unrelated to each other.

IPHONES REPEATING AND SKIPPING PARTS OF EVERY SENTENCE.

DRIFT_LAND was 0.6 s and one segment is 0.697 s, so the position the player
was being held at sat INSIDE the newest segment — a point it very often did
not have yet. It got dragged to the edge, ran out of audio and stalled. Then
'stalled' and 'waiting' were both wired to seekLive(), and a seek on a live
HLS stream makes the element re-buffer, which raises those same two events. So
the correction caused the stall that caused the correction. On Safari, where
HLS is native and a seek lands on a segment boundary, each turn of that loop
replays or drops a piece of a sentence — which is exactly what was described.

Three changes, all of them about not fighting the player: those two events no
longer seek, the target is three segments back (~2.1 s, what an HLS player
picks for itself on a live playlist), and a correction now has 5 s to settle
before another may run. Costs about 1.5 s of extra delay. A listener a second
further behind is following the service; one whose audio keeps jumping
backwards is not.

NOT YET CONFIRMED ON A REAL IPHONE — the mechanism is read off the code and
the measured segment length, and the next service is the test.

CANNOT SCROLL THE MONITOR WHILE LIVE.

Four boxes ended their update with box.scrollTop = box.scrollHeight,
unconditionally, so every sentence yanked the view back to the live edge and
reading back was impossible. They now follow the bottom only while the
operator is already at the bottom, and resume when they return — nothing to
press either way. Trimming the top is held off while they are scrolled away
too, since dropping old lines moves the text being read; the cap comes back
the moment they return, with a ceiling so nothing grows all service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 15:25:42 -04:00
christianandClaude Opus 5 acfca90836 The English switch did nothing, so English could not be turned off
Build 125. Reported from the desk: every language on the listener page can be
switched off except English.

Exactly right, and it was not subtle. renderFeatured() unshifted the SOURCE
language into the list on every render, whatever the operator had chosen:

    if (list.indexOf(app.cfg.sourceLanguage) === -1)
      list.unshift(app.cfg.sourceLanguage);

So English was not merely on, it was always FIRST — the leading button on the
page — on a desk whose saved list was ["spa_Latn","por_Latn","por_PT","spa_ES"]
with English deliberately off and allowAll false. The operator had turned it
off, the config recorded that it was off, and the page put it back anyway.

The reasoning behind it was sound: people do want the English captions for
accessibility rather than translation. But that is a choice for the desk, not
for this function, and the switch to make it is already there in Settings next
to every other language. English is in the catalogue (languages.js:19) and the
operator window reads that same file, so turning it back on is one click.

A control that silently refuses is worse than no control. The operator's list
is now the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 15:17:15 -04:00
christianandClaude Opus 5 7291ff48f7 Save QR image stranded the window on a picture of itself
Build 124. Reported from the booth: the button did not download anything and
there was no way back off the QR.

Both halves are the same fault. The window is served from the asset port and
the code comes off the congregation port, so the link the button built was
CROSS-ORIGIN — and a cross-origin <a download> has its download attribute
ignored and NAVIGATES instead. So the file never saved, and the window went to
the image. The app has no back button, no reload item and no address bar, so
that is a one-way trip: a headless booth Mac showing a full-screen QR with the
operator locked out of the desk. It fetches the code and downloads a blob now,
which is same-origin and actually saves.

The second change is the one that matters more. Nothing was stopping the app
window being replaced by any navigation at all, and the only reason this had
never bitten before is that nothing had tried. will-navigate now refuses to
replace the app page, logs what it refused, and sends anything external to the
browser. Our own pages are unaffected — the operator console and the poster
open as their own windows through setWindowOpenHandler, which is a different
path entirely.

Found because the build number went to the wrong field: buildNumber is the
source of truth and build.buildVersion is synced from it by predist, which
caught it and rebuilt correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 14:14:45 -04:00
4 changed files with 161 additions and 24 deletions
+87 -16
View File
@@ -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
@@ -3668,17 +3721,35 @@ $('copyAddr').addEventListener('click', () => {
* is made in their own design tool. The built-in poster page is still there
* at /poster for anyone who wants it; it is just not what this button does.
*/
$('saveQr').addEventListener('click', () => {
$('saveQr').addEventListener('click', async () => {
const base = shareBase();
if (!base) { log('no address to encode yet', 'err'); return; }
const a = document.createElement('a');
a.href = 'http://127.0.0.1:' + state.local.port + '/qr.svg?url=' +
encodeURIComponent(base + '/');
a.download = 'church-translate-qr.svg';
document.body.appendChild(a);
a.click();
a.remove();
log('saved the QR for ' + base + '/', 'ok');
/*
* Fetch the code and hand the browser a BLOB rather than linking straight at
* the server. This window is served from the asset port and the QR comes off
* the congregation port, so a plain <a download> is CROSS-ORIGIN — the
* download attribute is ignored and the window navigates to the image
* instead. With no back button, no reload item and no address bar, that
* strands the operator on a full-screen QR with no way out; build 123 did
* exactly that to a booth Mac. A blob: URL is same-origin, so it downloads.
*/
const url = 'http://127.0.0.1:' + state.local.port + '/qr.svg?url=' +
encodeURIComponent(base + '/');
try {
const r = await fetch(url, { cache: 'no-store' });
if (!r.ok) throw new Error('the server answered ' + r.status);
const href = URL.createObjectURL(await r.blob());
const a = document.createElement('a');
a.href = href;
a.download = 'church-translate-qr.svg';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(href), 10000);
log('saved the QR for ' + base + '/ to Downloads', 'ok');
} catch (err) {
log('could not save the QR: ' + err.message, 'err');
}
});
$('openOperator').addEventListener('click', () => {
window.open('http://127.0.0.1:' + state.local.port + '/operator', '_blank');
+21
View File
@@ -662,6 +662,27 @@ function createWindow() {
: '/index.html';
win.loadURL(assetOrigin + page);
/*
* NOTHING replaces the app in this window. There is no back button, no
* reload item and no address bar, so a window that leaves the app page is a
* window the operator cannot get back from — which is precisely how build
* 123 stranded a booth Mac on a full-screen QR image. Our own pages still
* open as their OWN windows through setWindowOpenHandler below; this only
* refuses to replace the app itself, and sends anything external to the
* browser where it belongs.
*/
win.webContents.on('will-navigate', (e, url) => {
if (url === assetOrigin + page) return; /* reloading ourselves is fine */
e.preventDefault();
console.error('[main] refused to navigate the app window to ' + url);
let local = false;
try {
const u = new URL(url);
local = (u.hostname === '127.0.0.1' || u.hostname === 'localhost');
} catch (_) { local = false; }
if (!local) shell.openExternal(url).catch(() => {});
});
win.webContents.on('did-finish-load', () => console.log('[main] page loaded'));
win.webContents.on('did-fail-load', (_e, code, desc, url) =>
console.error('[main] page FAILED to load: ' + code + ' ' + desc + ' ' + url));
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "church-translate-engine",
"productName": "Church Translate",
"version": "3.23.2",
"buildNumber": 123,
"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": "123",
"buildVersion": "126",
"win": {
"target": [
{
+51 -6
View File
@@ -163,10 +163,21 @@
function renderFeatured() {
var wrap = $('featured');
wrap.textContent = '';
/*
* THE OPERATOR'S LIST IS THE LIST. This used to unshift the source
* language in unconditionally, on the reasoning that plenty of people want
* the English captions for accessibility rather than translation — which
* is true, and still available, but it is not this code's call to make.
* The operator has a switch for every language including English, and for
* the source language alone that switch did nothing: English was put back
* on every render, so a desk that had deliberately turned it off still
* offered it as the first button on the page. A control that silently
* refuses is worse than no control.
*
* Wanting English captions is now one click in Settings, in the same list
* as every other language, and it stays where it is put.
*/
var list = (app.cfg.featured || []).slice();
/* The source language belongs in the list too — plenty of people want the
English captions for accessibility, not translation. */
if (list.indexOf(app.cfg.sourceLanguage) === -1) list.unshift(app.cfg.sourceLanguage);
list.forEach(function (code) {
var b = langButton(code);
if (b) wrap.appendChild(b);
@@ -508,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(); });
});
@@ -588,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.
@@ -616,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