Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d1a7b9ec | ||
|
|
acfca90836 | ||
|
|
7291ff48f7 | ||
|
|
826c471ed7 | ||
|
|
817fb743de |
+155
-11
@@ -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
|
||||
@@ -3508,14 +3561,50 @@ function shareChoice() {
|
||||
};
|
||||
}
|
||||
|
||||
function shareBase() {
|
||||
/* This Mac on the church network. The operator page lives here and NOWHERE
|
||||
else: it returns 404 to anything arriving through a proxy, by design. */
|
||||
function lanBase() {
|
||||
const c = shareChoice();
|
||||
if (!c || c.none) return '';
|
||||
return 'http://' + c.addr.address + ':' + state.local.port;
|
||||
}
|
||||
|
||||
/*
|
||||
* WHAT THE CONGREGATION OPENS.
|
||||
*
|
||||
* A church that has put this on a domain has said so in the operator page's
|
||||
* Public address, and from that moment the LAN address is the wrong thing to
|
||||
* print: it is not what is on the poster, not what the QR should encode, and
|
||||
* not what someone off the building's wifi can reach. The app window went on
|
||||
* showing the IP anyway, because this half of it never read that setting --
|
||||
* the operator page honoured it and the main window did not, so the two
|
||||
* disagreed about the one fact they both exist to display.
|
||||
*/
|
||||
function publicBaseConfigured() {
|
||||
const u = opConfig && typeof opConfig.publicUrl === 'string' ? opConfig.publicUrl.trim() : '';
|
||||
return u ? u.replace(/\/+$/, '') : '';
|
||||
}
|
||||
|
||||
function shareBase() {
|
||||
return publicBaseConfigured() || lanBase();
|
||||
}
|
||||
|
||||
function renderQrWarning() {
|
||||
const el = $('qrWarn');
|
||||
/*
|
||||
* On a domain the warnings below are about an address nobody is being
|
||||
* given any more. Saying "Wi-Fi is down, phones cannot reach the code"
|
||||
* while the code points at a proxy would send the desk after the wrong
|
||||
* thing entirely.
|
||||
*/
|
||||
const pub = publicBaseConfigured();
|
||||
if (pub) {
|
||||
el.hidden = false;
|
||||
el.textContent = 'The code points at ' + pub + ', set as the public address. ' +
|
||||
'This Mac still has to be reachable from there; its own address is ' +
|
||||
(lanBase() || 'unavailable') + '.';
|
||||
return;
|
||||
}
|
||||
const c = shareChoice();
|
||||
if (!c) { el.hidden = true; return; }
|
||||
|
||||
@@ -3566,6 +3655,26 @@ function renderShare() {
|
||||
const list = state.local.addresses || [];
|
||||
const box = $('addrList');
|
||||
box.textContent = '';
|
||||
/*
|
||||
* WHICH INTERFACE THE QR USES IS A QUESTION ONLY A LAN SETUP HAS.
|
||||
*
|
||||
* These rows let the operator pick the address the code points at. Once a
|
||||
* public address is configured the code points at the domain whatever they
|
||||
* pick, so the list is a row of numbers that do nothing — and the one
|
||||
* marked IN USE is not the address anyone is being given. Hide it.
|
||||
*/
|
||||
const addrBox = $('addrList');
|
||||
addrBox.hidden = !!publicBaseConfigured();
|
||||
if (addrBox.hidden) {
|
||||
/* Still draw the code itself — it is the only thing left in this panel. */
|
||||
const only = shareBase();
|
||||
$('qr').src = only
|
||||
? 'http://127.0.0.1:' + state.local.port + '/qr.svg?url=' + encodeURIComponent(only + '/')
|
||||
: '';
|
||||
renderQrWarning();
|
||||
renderOperatorUrl();
|
||||
return;
|
||||
}
|
||||
|
||||
/* Mark the row the QR is actually using, not the row that was saved — when
|
||||
a lease moves those are two different things and the saved one is gone. */
|
||||
@@ -3607,8 +3716,40 @@ $('copyAddr').addEventListener('click', () => {
|
||||
.catch((err) => log('could not copy: ' + err.message, 'err'));
|
||||
});
|
||||
|
||||
$('openPoster').addEventListener('click', () => {
|
||||
window.open('http://127.0.0.1:' + state.local.port + '/poster', '_blank');
|
||||
/*
|
||||
* The QR itself, saved as a file, because the poster people actually print
|
||||
* 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', async () => {
|
||||
const base = shareBase();
|
||||
if (!base) { log('no address to encode yet', 'err'); return; }
|
||||
/*
|
||||
* 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');
|
||||
@@ -3665,7 +3806,8 @@ function renderBuildTag() {
|
||||
}
|
||||
|
||||
function renderOperatorUrl() {
|
||||
const base = shareBase();
|
||||
/* Deliberately lanBase, not shareBase: over a proxy /operator is a 404. */
|
||||
const base = lanBase();
|
||||
$('opPageUrl').textContent = base ? base + '/operator' : 'no network';
|
||||
}
|
||||
|
||||
@@ -3968,6 +4110,7 @@ async function loadOperator() {
|
||||
try {
|
||||
const j = await opApi('/api/config');
|
||||
opConfig = j.config;
|
||||
renderShare(); /* publicUrl may have just arrived */
|
||||
clearTimeout(opLoadTimer); opLoadTimer = null; // it answered; stop retrying
|
||||
opRetryMs = 0;
|
||||
renderOpLanguages();
|
||||
@@ -4022,6 +4165,7 @@ async function opSave(patch, okMsg) {
|
||||
try {
|
||||
const j = await opApi('/api/config', { method: 'POST', body: JSON.stringify(patch) });
|
||||
opConfig = j.config;
|
||||
renderShare(); /* publicUrl may have just arrived */
|
||||
if (okMsg) log(okMsg, 'ok');
|
||||
return j;
|
||||
} catch (e) { log('could not save: ' + e.message, 'err'); throw e; }
|
||||
|
||||
+1
-1
@@ -433,7 +433,7 @@
|
||||
<div id="addrList" class="addrlist"></div>
|
||||
<div class="railbtns">
|
||||
<button class="btn btn-primary" id="copyAddr">Copy address</button>
|
||||
<button class="btn" id="openPoster">Printable poster</button>
|
||||
<button class="btn" id="saveQr">Save QR image</button>
|
||||
</div>
|
||||
|
||||
<h3 class="subhead">Operator page</h3>
|
||||
|
||||
+21
@@ -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
@@ -2,7 +2,7 @@
|
||||
"name": "church-translate-engine",
|
||||
"productName": "Church Translate",
|
||||
"version": "3.23.2",
|
||||
"buildNumber": 121,
|
||||
"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": "121",
|
||||
"buildVersion": "126",
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user