Files
bible-app/remote.html
T

1592 lines
82 KiB
HTML
Raw Permalink Normal View History

2026-09-24 13:05:20 -04:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#0b0b0d">
<!-- Added to the Home Screen on an iPad this launches with no Safari chrome, which is the only way to
get a genuinely full screen there: iPadOS does not grant requestFullscreen to ordinary elements. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Bible Remote">
<title>Bible Remote</title>
<style>
:root { --bg:#0b0b0d; --card:#17181c; --btn:#1f2127; --btn2:#2a2d34; --line:#2c2f36; --text:#f3f4f6; --dim:#9aa0aa; --accent:#3b82f6; --ok:#22c55e; --warn:#ef4444; --gold:#eab308; }
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html, body { margin:0; height:100%; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
/* ---- Everything on one screen ----
The operator works on a tablet, so the whole remote has to fit with no scrolling: a fixed-height
flex column, cards that cannot overflow, and buttons that scale with the viewport height rather
than a fixed pixel size. A short phone in landscape falls back to scrolling (media query below),
because squeezing 8 controls into 400px would make them untappable. */
/* Fill the device. The old 900px cap left an iPad with dead bars down both sides and made the whole
remote look like a phone screen floating in the middle of the screen. Buttons already scale from
viewport height, so using the full width just makes them easier to hit. Safe-area padding keeps the
home indicator and notch off the controls now that viewport-fit=cover is set. */
body { height:100dvh; overflow:hidden; display:flex; flex-direction:column; gap:6px;
padding:calc(6px + env(safe-area-inset-top)) calc(8px + env(safe-area-inset-right))
calc(8px + env(safe-area-inset-bottom)) calc(8px + env(safe-area-inset-left));
max-width:min(100%, 1400px); margin:0 auto; }
header { display:flex; align-items:center; gap:8px; flex:0 0 auto; padding:2px 2px 0; }
header h1 { font-size:clamp(14px, 2.1vh, 17px); margin:0; font-weight:650; flex:1; }
#dot { width:10px; height:10px; border-radius:50%; background:var(--dim); flex:0 0 auto; }
#dot.on { background:var(--ok); } #dot.off { background:var(--warn); }
.muted { color:var(--dim); font-size:13px; }
.hidden { display:none !important; }
#controls { flex:1 1 auto; min-height:0; display:flex; flex-direction:column; gap:6px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:7px 8px 8px;
flex:0 0 auto; min-height:0; display:flex; flex-direction:column; }
.card.grow { flex:1 1 auto; }
.card h2 { font-size:clamp(9px, 1.25vh, 11px); text-transform:uppercase; letter-spacing:.7px; color:var(--dim);
margin:0 0 5px; font-weight:600; flex:0 0 auto; }
.grid { display:grid; gap:6px; flex:1 1 auto; min-height:0; }
.g2 { grid-template-columns:1fr 1fr; } .g3 { grid-template-columns:repeat(3,1fr); } .g4 { grid-template-columns:repeat(4,1fr); }
.rows2 { grid-template-rows:1fr 1fr; }
button.b { appearance:none; border:1px solid var(--line); background:var(--btn); color:var(--text);
font-size:clamp(12px, 1.9vh, 16px); font-weight:600; padding:4px 3px; border-radius:10px; cursor:pointer;
min-height:clamp(38px, 6vh, 60px); line-height:1.15; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
transition:background .08s ease, border-color .08s ease, color .08s ease; touch-action:manipulation; }
button.b:active { background:var(--btn2); }
button.b.accent { background:var(--accent); border-color:var(--accent); }
button.b.danger { color:#fca5a5; }
button.b.on { background:rgba(59,130,246,.22); border-color:var(--accent); color:#cfe0ff; } /* active tab */
button.b.ontool { background:rgba(234,179,8,.20); border-color:var(--gold); color:#fde68a; } /* active tool / toggles */
button.b.onwarn { background:rgba(239,68,68,.22); border-color:var(--warn); color:#fecaca; } /* splash ON */
input[type=text], input[type=tel] { width:100%; padding:9px 10px; font-size:16px; border-radius:10px;
border:1px solid var(--line); background:#0f1013; color:var(--text); outline:none; }
input:focus { border-color:var(--accent); }
.row { display:flex; gap:6px; flex:0 0 auto; }
/* Live echo of the typed reference, so an abbreviation is confirmed BEFORE tapping Go */
#refEcho { margin-top:4px; font-size:clamp(11px, 1.5vh, 13.5px); min-height:1.2em; font-weight:600; color:var(--dim); flex:0 0 auto; }
#refEcho.good { color:#86efac; }
#refEcho.bad { color:#fca5a5; font-weight:500; }
/* Hold-to-clear-everything: red bar inside the button, same 1.5s as the trash on the TV */
button.b.hold { position:relative; }
button.b.holding { border-color:var(--warn); box-shadow:0 0 0 2px rgba(59,130,246,.35); }
button.b.flash { background:rgba(239,68,68,.35); border-color:var(--warn); }
.hold-bar { position:absolute; left:0; bottom:0; height:3px; width:0; background:var(--warn); border-radius:0 3px 3px 0; pointer-events:none; }
.hint { color:var(--dim); font-size:clamp(9.5px, 1.2vh, 11.5px); margin-top:5px; text-align:center; flex:0 0 auto; }
/* z-index above the TV view (30), so feedback still shows while mirroring the stage screen */
#toast { position:fixed; left:50%; bottom:14px; transform:translateX(-50%) translateY(30px); background:#000;
border:1px solid var(--line); color:#fff; padding:9px 16px; border-radius:999px; font-size:14px; opacity:0;
transition:all .18s ease; pointer-events:none; max-width:92vw; z-index:40; }
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
#toast.err { border-color:var(--warn); color:#fca5a5; }
#gate { text-align:center; padding-top:8vh; }
#gate .big { font-size:44px; margin-bottom:8px; }
#pinRow { max-width:280px; margin:18px auto 0; }
/* ---- TV view ----
A live mirror of the stage screen with a transparent touch layer over it: one finger draws,
two fingers scroll, the same as the TV's own glass. Frames are requested ONE AT A TIME rather than
on a timer, so a slow phone or a busy LAN can only lower the frame rate — it can never queue
capture work onto the stage screen, which is the thing that must never stutter.
The frame is composited onto a canvas (not an <img>): during a scroll only the TEXT REGION of the
picture is shifted locally between frames, so the header and tab bar hold still the way they do on
the real screen instead of the whole picture wobbling. */
#tvBtn { flex:0 0 auto; }
#tv { position:fixed; inset:0; background:#000; z-index:30; display:flex; flex-direction:column; gap:6px;
padding:calc(6px + env(safe-area-inset-top)) calc(8px + env(safe-area-inset-right))
calc(8px + env(safe-area-inset-bottom)) calc(8px + env(safe-area-inset-left)); }
#tvTop { display:flex; align-items:center; gap:8px; flex:0 0 auto; }
#tvTop .b { min-height:40px; padding:4px 14px; flex:0 0 auto; font-size:14px; }
#tvStatus { font-size:12px; color:var(--dim); flex:1 1 auto; text-align:right; }
#tvStatus.live { color:var(--ok); }
#tvStatus.lost { color:var(--warn); }
#tvStage { flex:1 1 auto; min-height:0; position:relative; display:flex; align-items:center; justify-content:center;
background:#000; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
#tvView { display:block; user-select:none; -webkit-user-select:none; pointer-events:none; }
/* Touch layer and ink preview are sized in JS to sit EXACTLY over the picture, not over the
letterbox bars — a touch in the black must not be mistaken for a touch on the text. */
#tvTouch, #tvInk { position:absolute; }
#tvTouch { touch-action:none; z-index:3; }
#tvInk { pointer-events:none; z-index:2; }
/* The scrolling text layer. Sits exactly over the picture's text region and is moved with a
transform ONLY — a scroll tick costs one composited translate instead of redrawing megapixels
of canvas, which is what buys a genuinely smooth 60fps drag. */
#tvScrollClip { position:absolute; overflow:hidden; z-index:1; pointer-events:none; }
#tvScrollInner { position:absolute; left:0; top:0; }
#tvAtlas, #tvStrokes { position:absolute; left:0; top:0; display:block; }
#tvTools { display:grid; grid-template-columns:repeat(5,1fr); gap:6px; flex:0 0 auto; }
#tvTools .b { min-height:clamp(40px, 6vh, 56px); font-size:clamp(11px, 1.7vh, 15px); }
#tvHint { position:absolute; left:50%; bottom:8px; transform:translateX(-50%); background:rgba(0,0,0,.72);
border:1px solid var(--line); border-radius:999px; padding:5px 12px; font-size:12px; color:var(--dim);
pointer-events:none; transition:opacity .3s ease; z-index:3; }
/* Too short to fit everything at a tappable size (small phone in landscape): allow scrolling. */
@media (max-height: 620px) {
body { height:auto; min-height:100dvh; overflow:auto; }
.card.grow { flex:0 0 auto; }
}
</style>
</head>
<body>
<header>
<span id="dot"></span>
<h1>Bible Remote</h1>
<!-- The app version this page is talking to. If a support question is "did the phone update?",
this answers it at a glance — and the page reloads itself whenever this changes on the TV. -->
<span id="appVer" class="muted" style="font-size:11px;"></span>
<span id="splashBadge" class="muted hidden" style="color:#fca5a5; font-weight:600;">SPLASH ON</span>
</header>
<!-- Gate -->
<div id="gate">
<div class="big">🔒</div>
<div id="gateTitle" style="font-size:19px; font-weight:600;">Connecting…</div>
<p id="gateMsg" class="muted">Checking the remote.</p>
<div id="pinRow" class="hidden">
<input id="pinInput" type="tel" inputmode="numeric" autocomplete="off" placeholder="Enter PIN" />
<button class="b accent" style="margin-top:12px; width:100%;" id="pinBtn">Connect</button>
</div>
</div>
<!-- Controls -->
<main id="controls" class="hidden">
<button class="b accent" id="tvBtn">📺 TV View &middot; scroll &amp; underline</button>
<div class="card grow">
<h2>Tabs &middot; tap to show, double-tap to close</h2>
<div class="grid g4" id="tabGrid">
<button class="b" disabled>Loading…</button>
</div>
</div>
<div class="card">
<h2>Navigate</h2>
<div class="grid g4">
<button class="b" data-action="scroll_top">⤒ Top</button>
<button class="b" data-action="scroll_up_5">−5</button>
<button class="b" data-action="scroll_down_5">+5</button>
<button class="b" data-action="scroll_bottom">⤓ Bottom</button>
</div>
</div>
<div class="card">
<h2>Passage</h2>
<div class="row">
<input id="refInput" type="text" placeholder="Heb 11:4 · 1 Cor 13 · Ps 23" autocapitalize="words" autocomplete="off" spellcheck="false" />
<button class="b accent" style="flex:0 0 72px;" data-go>Go</button>
</div>
<div id="refEcho"></div>
<div class="grid g2" style="margin-top:5px;">
<button class="b" data-action="chapterPrev">◀ Prev chapter</button>
<button class="b" data-action="chapterNext">Next chapter ▶</button>
</div>
</div>
<div class="card grow">
<h2>Tools</h2>
<div class="grid g4 rows2">
<button class="b" data-tool-pick="pen" data-tool="pen">Pen</button>
<button class="b" data-tool-pick="highlight" data-tool="highlight">Highlight</button>
<button class="b" data-tool-pick="erase" data-tool="erase">Erase</button>
<button class="b" data-mode-pick="laser" data-tool="laser">Laser</button>
<button class="b" data-mode-pick="spotlight" data-tool="spotlight">Spotlight</button>
<button class="b" data-action="undo">Undo</button>
<button class="b" data-action="redo">Redo</button>
<button class="b danger hold" id="clearBtn">Clear<span class="hold-bar" id="clearHoldBar"></span></button>
</div>
<div class="hint">Tap Clear for this page &middot; hold it to clear every annotation</div>
</div>
<div class="card">
<h2>Present</h2>
<div class="grid g2">
<button class="b" id="splashBtn" data-action="splashToggle">Splash</button>
<button class="b" id="redLetterBtn" data-action="redLetterToggle">Red letters</button>
</div>
</div>
</main>
<!-- Live mirror of the stage screen. Same tool buttons as the main screen (same data-* attributes, so
they are wired and highlighted by the same code) — they are duplicated here so nothing forces the
operator out of this view mid-sermon. -->
<div id="tv" class="hidden">
<div id="tvTop">
<button class="b" id="tvClose">✕ Back</button>
<span id="tvStatus">connecting…</span>
</div>
<div id="tvStage">
<canvas id="tvView"></canvas>
<div id="tvScrollClip"><div id="tvScrollInner"><canvas id="tvAtlas"></canvas><canvas id="tvStrokes"></canvas></div></div>
<canvas id="tvInk"></canvas>
<div id="tvTouch"></div>
<div id="tvHint">tap to press &middot; 1 finger draws &middot; 2 fingers scroll</div>
</div>
<div id="tvTools">
<button class="b" data-tool-pick="pen" data-tool="pen">Pen</button>
<button class="b" data-tool-pick="highlight" data-tool="highlight">Highlight</button>
<button class="b" data-tool-pick="erase" data-tool="erase">Erase</button>
<button class="b" data-action="undo">Undo</button>
<button class="b danger" data-action="clear_current">Clear</button>
</div>
</div>
<div id="toast"></div>
<!-- Shared with the app: turns "Heb 11.4" into an exact reference, and refuses to guess when a name
could mean two books. Same file, same table, so phone and TV always agree. -->
<script src="book-aliases.js"></script>
<script>
var PIN_KEY = 'bibleRemotePin';
var pin = '';
try { pin = localStorage.getItem(PIN_KEY) || ''; } catch (e) {}
var gate = document.getElementById('gate');
var controls = document.getElementById('controls');
var dot = document.getElementById('dot');
var pollTimer = 0;
var lastTabIds = '';
/*
* ---- Never run stale ----
* A Home-Screen web app resumed from the app switcher keeps executing the JS it loaded WEEKS ago —
* no reload, no refetch — so after a TV update the phone silently kept every old bug. The page
* remembers the app version it booted against and hard-reloads the moment any response reports a
* different one (state is polled twice a second, so an update is picked up within ~1s).
*/
var loadedV = '';
function checkVersion(v) {
if (!v) return;
if (!loadedV) {
loadedV = v;
try { document.getElementById('appVer').textContent = 'v' + v; } catch (e) {}
return;
}
if (v !== loadedV) {
try { window.location.reload(); } catch (e) {}
}
}
/*
* What the operator just asked for, believed for a moment even before the TV confirms it. The phone
* used to wait for the round trip (up to ~2.5s), which felt broken and invited a second tap — and a
* second tap on a toggling tool turned it straight back off.
*/
var optimistic = { tool: null, tabId: null, until: 0 };
function believe(patch) {
if (patch.tool !== undefined) optimistic.tool = patch.tool;
if (patch.tabId !== undefined) optimistic.tabId = patch.tabId;
optimistic.until = Date.now() + 1200;
paint();
}
function optimisticLive() { return Date.now() < optimistic.until; }
function toast(msg, isErr) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'show' + (isErr ? ' err' : '');
clearTimeout(toast._t);
toast._t = setTimeout(function () { t.className = ''; }, 1300);
}
function showGate(title, msg, showPin) {
controls.classList.add('hidden');
gate.classList.remove('hidden');
document.getElementById('gateTitle').textContent = title;
document.getElementById('gateMsg').textContent = msg;
document.getElementById('pinRow').classList.toggle('hidden', !showPin);
dot.className = showPin ? '' : 'off';
stopPolling();
}
function showControls() {
gate.classList.add('hidden');
controls.classList.remove('hidden');
dot.className = 'on';
startPolling();
}
function api(path, opts) {
return fetch(path, opts).then(function (r) {
return r.json().then(function (j) { return { status: r.status, body: j }; }).catch(function () { return { status: r.status, body: {} }; });
});
}
function post(path, obj) { return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(obj || {}) }); }
function doAction(action, payload, quiet) {
if (!action) return Promise.resolve();
return post('/api/remote/action', { pin: pin, action: action, payload: payload || {} }).then(function (res) {
if (res.body && res.body.ok) { if (!quiet) toast('✓ ' + labelFor(action, payload)); refreshState(); }
else if (res.status === 401 || (res.body && res.body.reason === 'bad-pin')) requirePin('That PIN no longer works.');
else toast((res.body && res.body.error) || 'Failed', true);
}).catch(function () { toast('No connection', true); });
}
function labelFor(action, payload) {
if (action === 'tool') return (payload && payload.name) || 'tool';
if (action === 'switchTabId') return 'tab';
if (action === 'gotoPassage') return (payload && payload.ref) || 'passage';
if (action === 'splashToggle') return 'splash';
if (action === 'laser' || action === 'spotlight') return action + (payload && payload.on === false ? ' off' : '');
return action.replace(/([A-Z])/g, ' $1').replace(/_/g, ' ').toLowerCase().trim();
}
/*
* ---- Taps only ----
* Every control goes through this instead of a raw click listener. A click fires wherever the finger
* happens to be when it lifts, so scrolling the page or a layout shift under the thumb could trigger
* a button the operator never aimed at — which is how the tool kept switching to spotlight by itself.
* A tap here means: pressed and released on the SAME control, without dragging, within a second.
*/
function onTap(el, fn) {
var sx = 0, sy = 0, t0 = 0, live = false;
el.addEventListener('pointerdown', function (e) {
if (e.isPrimary === false) return;
sx = e.clientX; sy = e.clientY; t0 = Date.now(); live = true;
});
el.addEventListener('pointermove', function (e) {
if (!live) return;
if (Math.abs(e.clientX - sx) > 12 || Math.abs(e.clientY - sy) > 12) live = false; // a scroll, not a tap
});
el.addEventListener('pointercancel', function () { live = false; });
el.addEventListener('pointerup', function (e) {
if (!live) return;
live = false;
if (Date.now() - t0 > 1000) return; // a long press is not a tap
var r = el.getBoundingClientRect(); // released off the control? ignore
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return;
fn(e);
});
// Nothing acts on click, so a click synthesised after a drag or a reflow can never fire anything.
el.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); });
}
// ---- Live state: mirror the app's real tabs / tool / splash / red-letter ----
var state = null;
var tvOnState = null; // set by the TV view below; called on every state paint (splash hint etc.)
function renderState(s) {
if (!s) return;
state = s;
/*
* Update the tab buttons IN PLACE. Rebuilding the grid (innerHTML = '') on every title change
* reflowed everything under the operator's finger, so a tap in flight landed on a different
* control. Only a genuine add/remove touches the DOM structure now.
*/
var tabs = s.tabs || [];
var ids = tabs.map(function (t) { return t.id; }).join('|');
var grid = document.getElementById('tabGrid');
if (ids !== lastTabIds) {
lastTabIds = ids;
grid.innerHTML = '';
tabs.forEach(function (t) {
var b = document.createElement('button');
b.className = 'b';
b.textContent = t.title;
b.setAttribute('data-tabid', t.id);
wireTab(b, t.id);
grid.appendChild(b);
});
}
paint();
}
// Paint the highlights from the last known state, letting a fresh local action win briefly.
function paint() {
if (!state) return;
var tabs = state.tabs || [];
var activeId = (optimisticLive() && optimistic.tabId) ? optimistic.tabId : (tabs.find(function (t) { return t.active; }) || {}).id;
document.querySelectorAll('[data-tabid]').forEach(function (b) {
var t = tabs.find(function (x) { return x.id === b.getAttribute('data-tabid'); });
if (t && b.textContent !== t.title) b.textContent = t.title; // live titles, no rebuild
b.classList.toggle('on', b.getAttribute('data-tabid') === activeId);
});
var tool = (optimisticLive() && optimistic.tool) ? optimistic.tool : state.tool;
document.querySelectorAll('[data-tool]').forEach(function (b) {
b.classList.toggle('ontool', b.getAttribute('data-tool') === tool);
});
var sb = document.getElementById('splashBtn');
sb.classList.toggle('onwarn', !!state.splash);
sb.textContent = state.splash ? 'Splash ON' : 'Splash';
document.getElementById('splashBadge').classList.toggle('hidden', !state.splash);
var rb = document.getElementById('redLetterBtn');
rb.classList.toggle('ontool', !!state.redLetter);
if (tvOnState) tvOnState();
}
function refreshState() {
if (!pin) return;
post('/api/remote/state', { pin: pin }).then(function (res) {
if (res.body && res.body.ok) { checkVersion(res.body.v); renderState(res.body.state); dot.className = 'on'; }
else if (res.status === 401) requirePin('That PIN no longer works.');
}).catch(function () { dot.className = 'off'; });
}
// 500ms: fast enough that the highlight feels immediate even without the optimistic paint.
function startPolling() { stopPolling(); refreshState(); pollTimer = setInterval(refreshState, 500); }
function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = 0; } }
function requirePin(msg) {
pin = '';
try { localStorage.removeItem(PIN_KEY); } catch (e) {}
showGate('Enter PIN', msg || 'Enter the PIN shown in the app’s Settings.', true);
var el = document.getElementById('pinInput'); if (el) { el.value = ''; el.focus(); }
}
function tryAuth(candidate) {
post('/api/remote/auth', { pin: candidate }).then(function (res) {
if (res.body && res.body.ok && res.body.auth) {
pin = candidate; try { localStorage.setItem(PIN_KEY, pin); } catch (e) {}
showControls(); toast('Connected');
} else if (res.body && res.body.reason === 'disabled') {
showGate('Remote is off', 'Turn on the phone remote in the app’s Settings → Phone Remote.', false);
} else requirePin((res.body && res.body.error) || 'Wrong PIN.');
}).catch(function () { showGate('No connection', 'Could not reach the app. Check you are on the same Wi-Fi.', false); });
}
function boot() {
api('/api/remote/status').then(function (res) {
var b = res.body || {};
checkVersion(b.v);
if (!b.enabled) { showGate('Remote is off', 'Turn on the phone remote in the app’s Settings → Phone Remote.', false); return; }
if (pin) tryAuth(pin); else requirePin();
}).catch(function () { showGate('No connection', 'Could not reach the app. Check you are on the same Wi-Fi.', false); });
}
// A resumed standalone web app fires visibilitychange, not a page load: re-ask the version
// immediately so waking the phone onto a freshly updated TV reloads without waiting for a poll.
document.addEventListener('visibilitychange', function () {
if (document.hidden) return;
api('/api/remote/status').then(function (res) { checkVersion((res.body || {}).v); }).catch(function () {});
});
document.getElementById('pinBtn').addEventListener('click', function () {
var v = (document.getElementById('pinInput').value || '').trim();
if (v) tryAuth(v);
});
document.getElementById('pinInput').addEventListener('keydown', function (e) { if (e.key === 'Enter') document.getElementById('pinBtn').click(); });
// ---- Tabs: tap shows it, double-tap closes it ----
/*
* One tap shows the tab. Two quick taps close it — but ONLY if that tab was already the one on
* screen when the first tap landed. Without that condition, hurrying between tabs mid-service would
* close one by accident: the first tap makes the tab active, so the second would qualify instantly.
* In practice: double-tap the tab you are on to close it; for another tab, tap to open it, then
* double-tap. Losing a tab live is far more costly than one extra tap.
*/
function wireTab(btn, id) {
var lastTap = 0, wasActiveOnFirstTap = false;
onTap(btn, function () {
var now = Date.now();
var isActive = btn.classList.contains('on');
if (now - lastTap < 400 && wasActiveOnFirstTap) {
lastTap = 0; wasActiveOnFirstTap = false;
var title = btn.textContent;
doAction('close_tab', { id: id }, true).then(function () { toast('✕ closed ' + title); });
return;
}
lastTap = now;
wasActiveOnFirstTap = isActive;
believe({ tabId: id });
doAction('switchTabId', { id: id }, true);
});
}
// ---- Plain actions ----
document.querySelectorAll('[data-action]').forEach(function (btn) {
if (btn.id === 'clearBtn') return; // wired separately (tap vs hold)
onTap(btn, function () {
var payload = {};
if (btn.hasAttribute('data-name')) payload.name = btn.getAttribute('data-name');
doAction(btn.getAttribute('data-action'), payload);
});
});
// ---- Drawing tools: idempotent, so a repeat can never undo itself ----
document.querySelectorAll('[data-tool-pick]').forEach(function (btn) {
var name = btn.getAttribute('data-tool-pick');
onTap(btn, function () {
believe({ tool: name });
doAction('tool', { name: name }, true);
});
});
/*
* Laser + spotlight state ON THE TV IS A TOGGLE, so the phone always states what it wants rather
* than asking for a flip. Tap while off -> on; tap while on -> off. A duplicate request is then a
* no-op instead of switching the tool back.
*/
document.querySelectorAll('[data-mode-pick]').forEach(function (btn) {
var name = btn.getAttribute('data-mode-pick');
onTap(btn, function () {
var currently = (optimisticLive() && optimistic.tool) ? optimistic.tool : (state && state.tool);
var want = currently !== name;
believe({ tool: want ? name : 'pen' });
doAction(name, { on: want }, true);
});
});
/* ---- Passage box: resolve abbreviations on the phone, and show what will open ----
* The operator sees "Heb 11.4 → Hebrews 11:4" before tapping Go, and the CANONICAL name is what
* gets sent, so the app never has to interpret an abbreviation. An unresolvable or ambiguous name is
* refused here with a reason rather than opening the wrong book in front of the room.
*/
var refInput = document.getElementById('refInput');
var refEcho = document.getElementById('refEcho');
function resolveTyped() {
var raw = (refInput.value || '').trim();
if (!raw) return { raw: '', ref: null };
if (!window.BibleBooks) return { raw: raw, ref: raw, note: '' }; // resolver missing: send as typed
var parsed = window.BibleBooks.parseReference(raw);
if (parsed) return { raw: raw, ref: parsed.canonical, parsed: parsed };
var bookish = raw.replace(/[0-9:.\-\s]+$/, '').trim() || raw;
return { raw: raw, ref: null, note: window.BibleBooks.explain(bookish) };
}
function updateEcho() {
var r = resolveTyped();
if (!r.raw) { refEcho.textContent = ''; refEcho.className = ''; return; }
if (r.ref) { refEcho.textContent = '→ ' + r.ref; refEcho.className = 'good'; }
else { refEcho.textContent = r.note || 'not a reference'; refEcho.className = 'bad'; }
}
refInput.addEventListener('input', updateEcho);
var goBtn = document.querySelector('[data-go]');
onTap(goBtn, function () {
var r = resolveTyped();
if (!r.raw) return;
if (!r.ref) { toast(r.note || 'Not a reference', true); return; }
doAction('gotoPassage', { ref: r.ref, newTab: true });
});
refInput.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
var r = resolveTyped();
if (r.raw && r.ref) doAction('gotoPassage', { ref: r.ref, newTab: true });
else if (r.raw) toast(r.note || 'Not a reference', true);
});
/* ---- Clear: tap clears this page, hold 1.5s clears everything ----
* Same gesture and the same 1.5s as the trash button on the TV's tool rail. It lives on Clear, not
* on the eraser: the eraser is a tool you select, clearing is an action.
*/
(function wireClearHold() {
var btn = document.getElementById('clearBtn');
var bar = document.getElementById('clearHoldBar');
if (!btn || !bar) return;
var HOLD = 1500, PROGRESS_DELAY = 300;
var timer = 0, startT = 0, holding = false, fired = false, sx = 0, sy = 0;
function reset() {
holding = false;
if (timer) { clearInterval(timer); timer = 0; }
bar.style.width = '0';
btn.classList.remove('holding', 'flash');
}
// A timer, not requestAnimationFrame: rAF stops entirely when the page is not being painted, and
// a hold that silently does nothing is worse than a slightly less smooth bar.
function tick() {
if (!holding) return;
var dt = Date.now() - startT;
if (dt > PROGRESS_DELAY) {
btn.classList.add('holding');
bar.style.width = Math.min(100, ((dt - PROGRESS_DELAY) / (HOLD - PROGRESS_DELAY)) * 100) + '%';
}
if (dt >= HOLD) {
fired = true;
holding = false;
if (timer) { clearInterval(timer); timer = 0; }
btn.classList.add('flash');
doAction('clear_all', {}, true);
toast('✓ cleared everything');
setTimeout(reset, 260);
}
}
btn.addEventListener('pointerdown', function (e) {
if (e.isPrimary === false) return;
fired = false; holding = true; startT = Date.now(); sx = e.clientX; sy = e.clientY;
if (timer) clearInterval(timer);
timer = setInterval(tick, 30);
});
btn.addEventListener('pointermove', function (e) {
if (!holding) return;
if (Math.abs(e.clientX - sx) > 14 || Math.abs(e.clientY - sy) > 14) reset(); // scrolled away
});
btn.addEventListener('pointercancel', function () { if (!fired) reset(); });
btn.addEventListener('pointerup', function (e) {
var wasHolding = holding, elapsed = Date.now() - startT;
if (!fired) reset();
if (fired || !wasHolding) return;
var r = btn.getBoundingClientRect();
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return;
if (elapsed <= 1000) doAction('clear_current'); // a tap: this page only
});
btn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); });
})();
/* ---- TV view: mirror the stage screen, draw and scroll on it ----
*
* Coordinates leave this page NORMALISED (0..1 of the picture). The phone never learns the TV's pixel
* size, so the same gesture lands on the same word whether the stage screen is 2160x3840 or a windowed
* dev build — and it keeps working if the TV is ever replaced.
*
* Frames: one request at a time, the next only after the last one lands. That makes the round trip
* itself the pacer, so a weak signal degrades the frame rate and nothing else. FRAME_GAP_MS adds a
* small breather on top, because every frame is a GPU readback of a 4K window on the machine that is
* also running the service.
*/
(function wireTvView() {
/*
* Timing, measured rather than guessed (on this hardware):
* capturePage of the 4K stage window ....... ~120ms, and it is a FLOOR — asking for a 360px
* frame is no faster than 540px, so frame rate
* cannot be bought by shrinking the picture.
* a stroke/scroll action round trip ........ ~3ms, i.e. free.
* So the mirror is the slow part and input is not. The round trip is the frame pacer, and the
* phone never waits for frames to show a gesture: scrolling moves the picture locally and
* drawing inks locally, with each arriving frame correcting the guess.
*
* Ordering: stroke point batches go out as independent HTTP posts, and independent posts can
* OVERTAKE each other on Wi-Fi — the TV then joins points out of order, which is what drew
* random lines through the middle of a stroke. Every mirror_draw now goes through one send
* chain (next request only after the last answered), so batches arrive in the order the finger
* made them. Scroll packets stay parallel but carry a per-gesture running total + sequence
* number instead of deltas, so a lost or reordered packet self-heals on the next one.
*/
var FRAME_GAP_MS = 16; // just enough to yield; the round trip sets the real rate
var SEND_EVERY_MS = 33; // batch of stroke points per request while a finger is down
var SCROLL_EVERY_MS = 20;
/*
* Measured bytes per frame with a full-screen PHOTO on the stage screen (the worst case):
* 1024px @ q62 -> 322 KB (~21 Mbit/s at 8 fps — too much to ask of room Wi-Fi)
* 860px @ q55 -> ~200 KB
* 860px @ q34 -> ~145 KB
* and with scripture on screen, 860px costs 119 KB at q55 / 96 KB at q34 — text is dominated by
* sharp edges, so dropping quality saves far less on text than it does on a photo. Hence a
* moderate width cap rather than the largest the screen could take.
*/
var FRAME_MAX_WIDTH = 860;
/*
* Quality raised from 34/55 once idle frames became free (unchanged screens answer 204) and
* gesture frames became paced: the operator must be able to READ the text while it scrolls,
* and q34 text was too soft for that.
*/
var GESTURE_QUALITY = 48; // cheaper frames while a finger is down (saves Wi-Fi, not CPU)
var IDLE_QUALITY = 60;
var START_SLOP = 0.004; // fraction of the picture a finger must travel before it counts as a stroke
var view = document.getElementById('tv');
var openBtn = document.getElementById('tvBtn');
var closeBtn = document.getElementById('tvClose');
var stage = document.getElementById('tvStage');
var cv = document.getElementById('tvView');
var scrollClip = document.getElementById('tvScrollClip');
var scrollInner = document.getElementById('tvScrollInner');
var atlas = document.getElementById('tvAtlas');
var strokesCv = document.getElementById('tvStrokes');
var ink = document.getElementById('tvInk');
var pad = document.getElementById('tvTouch');
var statusEl = document.getElementById('tvStatus');
var hint = document.getElementById('tvHint');
if (!view || !openBtn || !cv || !pad) return;
var open = false, pending = false, nextTimer = 0, misses = 0;
// stroke.buf = points not yet sent; stroke.all = the whole stroke, for the local preview
var stroke = { down: false, sent: false, buf: [], all: [], lastSend: 0, last: null, tool: 'pen', style: null, blocked: false, warned: false };
var scroll = { active: false, lastY: 0, lastSend: 0, total: 0, gesture: 0, seq: 0, samples: [] };
var gestureCounter = Math.floor(Math.random() * 1e6);
var swallowGesture = false; // this touch already did its job (dismissed splash / stopped a fling)
var viewCtx = null, inkCtx = null;
/*
* Frame HISTORY, newest last: [{img, url, s}]. `s` is the EXACT scroll position the frame's
* text-region content was captured at (fraction of the window height, stamped by the TV in the
* X-Scroll-S header; null on the fallback capture path). Scrolling composites the text region
* from every frame that still covers part of it — so scrolling back over anything seen moments
* ago shows the REAL text. Idle replaces the newest entry in place (same s), so the cache only
* grows while actually scrolling.
*/
var frames = [];
var FRAME_KEEP = 6;
var frameW = 0, frameH = 0, frameReady = false;
/*
* ABSOLUTE scroll model. `wanted` is where the operator's finger says the page should be
* (same units as `s`); the finger updates it at 60fps and every frame is drawn shifted by
* exactly (frame.s - wanted). The old model GUESSED each frame's position from when it was
* requested — the request latency jittered frame to frame, so scrolled text visibly wobbled
* and could not be read in motion. With stamped positions the text glides; frames only refresh
* pixels. `scrollMaxN` (X-Scroll-M) is the page's true scroll limit: drags and momentum stop
* dead at the end of the chapter instead of rubber-banding.
*/
var wanted = null; // null until the first stamped frame arrives
var scrollMaxN = null;
var scrollAnchor = null; // `wanted` at the moment the current gesture began
/*
* Local ink preview. `live` is the stroke under the finger; `done` holds finished strokes until
* a frame containing the REAL line has landed (two frames: the one in flight, then the next).
* The old single-canvas version cleared EVERYTHING when that countdown expired — including a new
* stroke already in progress, which read as the pen "starting a new line" mid-stroke. Keeping
* strokes as data and repainting means a countdown can only ever remove its own stroke.
*/
var preview = { live: null, done: [] };
var eraserCursor = null; // normalised point while the eraser finger is down
var momentumRaf = 0;
var hintIntro = false;
var paintRaf = 0;
var regionBg = '#000'; // page background sampled from the frame, fills scroll reveals
/*
* ---- The scroll layer ("atlas") ----
* A tall canvas holding stitched text-region content across a RANGE of scroll positions, drawn
* once per arriving frame and then moved purely by transform. The previous design recomposited
* the whole picture on every finger tick (~8 megapixels of drawImage per tick), which no phone
* can sustain at 60fps — that was the real reason scrolling never felt smooth. Now a tick costs
* one GPU translate, and canvas work happens only ~20 times a second when frames land.
*
* atlasS0 is the scroll position at atlas y = 0 (null until the first stitch).
*/
var atlasCtx = null, strokesCtx = null;
var atlasS0 = null, atlasH = 0, atlasReady = false;
var atlasRect = null; // the text region in natural frame px: {rx, ry, rw, rh}
var strokesPrevBox = null; // last painted stroke bounds, so repaints clear only that area
/*
* Ink handshake. The TV bumps window.__inkVersion the instant a finished stroke is on its
* committed canvas; every frame carries the value current when it was captured. A frame whose
* counter has not moved past `pendingInk.base` may still be showing the gap between "live
* overlay cleared" and "committed canvas painted" — applying it made the line blink out and
* back at the end of every stroke. Such frames are dropped, so the local preview stays put
* until a frame that genuinely contains the line arrives, and the swap is a single paint.
*/
var lastInkV = 0;
var pendingInk = null; // { base, until, softUntil }
var scrollSpeed = 0; // smoothed px per touchmove, picks the capture quality below
/*
* While a pen/highlight stroke is being drawn, the mirror is FROZEN: the TV renders the stroke
* live for the room, so every frame pulled mid-stroke contained the TV's copy of the line — the
* phone then showed that AND its own preview, which read as "drawing a second stroke" that
* popped into one line at the end. Freezing costs nothing visible (the page content cannot
* change under a one-finger stroke) and also spares the TV a GPU readback per frame while the
* operator draws. The eraser keeps frames flowing — seeing ink disappear IS its feedback.
*/
function drawingHold() { return stroke.down && stroke.sent && stroke.tool !== 'erase'; }
function setStatus(text, cls) { statusEl.textContent = text; statusEl.className = cls || ''; }
// ---- live state the preview needs (pushed by the TV via remote-state.js) ----
function tvToolNow() { return (optimisticLive() && optimistic.tool) ? optimistic.tool : ((state && state.tool) || 'pen'); }
function tvInk() { return (state && state.ink) || null; }
function tvRect() { return (state && state.rect) || null; }
function splashOn() { return !!(state && state.splash); }
function activeTabMode() {
var tabs = (state && state.tabs) || [];
var t = tabs.find(function (x) { return x.active; });
return t ? t.mode : null;
}
/*
* The preview's pen/highlighter/eraser mirror the TV's REAL colours and widths (normalised by
* the TV). Stroke widths stay NORMALISED here — the preview is painted into the mirror itself
* at frame resolution, so a widthN becomes exactly the same pixels the TV's line occupies.
* Captured once per stroke so a mid-stroke settings change on the TV can't recolour the line
* under the finger.
*/
function toolStyle(tool) {
var info = tvInk() || {};
if (tool === 'highlight') {
return { color: (info.hl && info.hl.color) || '#ffff00',
widthN: (info.hl && info.hl.size) || 0.033, alpha: 0.3 };
}
if (tool === 'erase') {
return { radius: Math.max((((info.erase && info.erase.size) || 0.043) * (pad.clientHeight || 1)) / 2, 8) };
}
return { color: (info.pen && info.pen.color) || '#ffff00',
widthN: (info.pen && info.pen.size) || 0.004, alpha: 1 };
}
/* The picture is letterboxed inside the stage box, so the touch layer and the ink canvas are moved
onto the picture's exact rectangle every time a frame's size could have changed.
The canvas is given an exact width/height matching the frame's aspect ratio, so the element box
IS the picture — no letterboxing inside it. That keeps the touch-layer alignment and the 0..1
coordinate maths below exact. */
function fitPicture() {
if (!frameW || !frameH) return;
var sr = stage.getBoundingClientRect();
var ar = frameW / frameH;
var w = sr.width, h = w / ar;
if (h > sr.height) { h = sr.height; w = h * ar; }
cv.style.width = Math.round(w) + 'px';
cv.style.height = Math.round(h) + 'px';
}
function syncOverlays() {
if (!frameW) return;
var ir = cv.getBoundingClientRect(), sr = stage.getBoundingClientRect();
var left = Math.round(ir.left - sr.left), top = Math.round(ir.top - sr.top);
var w = Math.round(ir.width), h = Math.round(ir.height);
[pad, ink].forEach(function (el) {
el.style.left = left + 'px'; el.style.top = top + 'px';
el.style.width = w + 'px'; el.style.height = h + 'px';
});
var dpr = window.devicePixelRatio || 1;
if (ink.width !== Math.round(w * dpr) || ink.height !== Math.round(h * dpr)) {
ink.width = Math.round(w * dpr); ink.height = Math.round(h * dpr);
inkCtx = ink.getContext('2d');
inkCtx.scale(dpr, dpr);
paintPreview();
}
}
/*
* Ask for a frame sized to THIS device's screen. capturePage costs the same whatever width is
* requested (measured: 360px is no faster than 540px), so the only reason to hold back is bytes on
* the wire — hence the FRAME_MAX_WIDTH ceiling, which keeps an iPad-sized mirror sharp without turning every
* frame into a quarter-megabyte. A phone naturally asks for less.
*/
function frameWidth() {
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var cssWidth = stage.clientWidth || 360;
return Math.min(FRAME_MAX_WIDTH, Math.max(320, Math.round(cssWidth * dpr)));
}
function schedule(ms) {
if (nextTimer) clearTimeout(nextTimer);
if (!open) return;
nextTimer = setTimeout(pullFrame, ms);
}
/*
* ---- Compositing the mirror ----
*
* Three layers, each redrawn only when it actually changes:
* 1. #tvView — the newest frame drawn whole. Chrome (header, tab bar) lives here and never
* moves. Redrawn once per arriving frame.
* 2. #tvAtlas — a tall canvas of stitched TEXT-REGION content covering a range of scroll
* positions, moved by transform alone. A scroll tick touches nothing but that
* transform, which is what lets a drag hold 60fps.
* 3. #tvStrokes — local stroke previews, same size and scale as the atlas, so they scroll with
* the text and hand over to the TV's real line with no change in resolution.
*
* The previous design recomposited the entire picture on every finger tick — a full-frame
* drawImage plus up to six region blits, around eight megapixels per tick. No phone sustains
* that at 60fps, which is why scrolling stayed choppy however exact the maths got.
*
* Hard rules learned the glitchy way:
* - only ever draw a frame's REGION pixels into the region. Shifting the whole frame slid the
* chapter header's image down into the text, which read as a duplicate header.
* - the atlas is pre-filled with the page background, so a not-yet-stitched strip shows clean
* background instead of stale text.
* - stroke previews use the TV's exact path maths at the TV's resolution, so the handover to
* the real line changes nothing on screen.
*/
function regionNat() {
var r = tvRect();
if (!r || !frameW || !frameH) return null;
return { rx: Math.round(r.x * frameW), ry: Math.round(r.y * frameH),
rw: Math.round(r.w * frameW), rh: Math.round(r.h * frameH) };
}
function atlasActive() { return atlasReady && atlasS0 !== null && wanted !== null && !!atlasRect; }
// (Re)allocate the scroll layers when the frame size or the text region changes.
function setupAtlas() {
var rn = regionNat();
if (!rn || rn.rw <= 0 || rn.rh <= 0) { atlasReady = false; return false; }
var wantH = Math.min(Math.round(rn.rh * 3), 4600);
var changed = !atlasReady || atlas.width !== rn.rw || atlasH !== wantH || !atlasRect ||
atlasRect.rx !== rn.rx || atlasRect.ry !== rn.ry || atlasRect.rh !== rn.rh;
if (changed) {
atlas.width = rn.rw; atlas.height = wantH;
strokesCv.width = rn.rw; strokesCv.height = wantH;
atlasCtx = atlas.getContext('2d');
strokesCtx = strokesCv.getContext('2d');
atlasH = wantH;
atlasRect = rn;
atlasReady = true;
atlasS0 = null; // forces a rebase + restitch
strokesPrevBox = null;
}
return true;
}
// Re-centre the atlas on a scroll position and rebuild it from the cached frames.
function rebaseAtlas(s) {
if (!atlasReady || s === null || s === undefined) return;
atlasS0 = Math.max(0, s - ((atlasH - atlasRect.rh) / 2) / frameH);
atlasCtx.fillStyle = regionBg;
atlasCtx.fillRect(0, 0, atlas.width, atlas.height);
for (var i = 0; i < frames.length; i++) stitch(frames[i].img, frames[i].s);
strokesPrevBox = null;
if (strokesCtx) strokesCtx.clearRect(0, 0, strokesCv.width, strokesCv.height);
redrawStrokes();
}
function atlasCovers(s) {
if (!atlasReady || atlasS0 === null || s === null) return false;
var y = (s - atlasS0) * frameH;
return y >= 0 && y + atlasRect.rh <= atlasH;
}
// Place one frame's text region into the atlas at its true scroll position.
function stitch(img, s) {
if (!atlasReady || atlasS0 === null || s === null || s === undefined) return;
var y = Math.round((s - atlasS0) * frameH);
if (y + atlasRect.rh < 0 || y > atlasH) return;
atlasCtx.drawImage(img, atlasRect.rx, atlasRect.ry, atlasRect.rw, atlasRect.rh,
0, y, atlasRect.rw, atlasRect.rh);
}
// Base layer. The region is knocked back to background so the frame's own copy of the text can
// never peek out from behind the scroll layer at its edges.
function drawBase(img) {
if (!viewCtx || !img) return;
viewCtx.drawImage(img, 0, 0);
sampleRegionBg(); // read the page colour BEFORE the knockout below
if (atlasActive()) {
viewCtx.fillStyle = regionBg;
viewCtx.fillRect(atlasRect.rx, atlasRect.ry, atlasRect.rw, atlasRect.rh);
}
}
// Put the scroll layers exactly over the picture's text region, at the picture's scale.
function layoutAtlas() {
if (!atlasActive() || !cv.clientWidth) { scrollClip.style.display = 'none'; return; }
var ir = cv.getBoundingClientRect(), sr = stage.getBoundingClientRect();
var scale = cv.clientWidth / frameW;
scrollClip.style.display = '';
scrollClip.style.left = Math.round(ir.left - sr.left + atlasRect.rx * scale) + 'px';
scrollClip.style.top = Math.round(ir.top - sr.top + atlasRect.ry * scale) + 'px';
scrollClip.style.width = Math.round(atlasRect.rw * scale) + 'px';
scrollClip.style.height = Math.round(atlasRect.rh * scale) + 'px';
atlas.style.width = strokesCv.style.width = (atlasRect.rw * scale) + 'px';
atlas.style.height = strokesCv.style.height = (atlasH * scale) + 'px';
}
/*
* The entire cost of a scroll tick: one composited transform. Called straight from touchmove and
* from the momentum loop — deliberately NOT through requestAnimationFrame, so the picture moves
* in the very frame the finger did.
*/
function applyScrollTransform() {
if (!atlasActive() || !cv.clientWidth) return;
var scale = cv.clientWidth / frameW;
var ty = -((wanted - atlasS0) * frameH) * scale;
scrollInner.style.transform = 'translate3d(0,' + ty.toFixed(2) + 'px,0)';
}
// Full repaint of the static layers — only for frame arrivals, resizes and orientation changes.
function renderView() {
if (!frames.length) return;
drawBase(frames[frames.length - 1].img);
layoutAtlas();
applyScrollTransform();
}
// ---- stroke previews, in atlas coordinates ----
function strokeAtlasPath(pts, s) {
var r = tvRect();
var dy = (s !== null && s !== undefined && atlasS0 !== null) ? (s - atlasS0) * frameH : 0;
function P(p) {
var x = p.x, y = p.y;
if (r) {
x = Math.min(Math.max(x, r.x), r.x + r.w);
y = Math.min(Math.max(y, r.y), r.y + r.h);
}
return { x: x * frameW - atlasRect.rx, y: y * frameH - atlasRect.ry + dy };
}
var eff = [pts[0]].concat(pts); // the TV duplicates the first point — match it
var box = null;
function grow(q) {
if (!box) box = { x0: q.x, y0: q.y, x1: q.x, y1: q.y };
else {
box.x0 = Math.min(box.x0, q.x); box.y0 = Math.min(box.y0, q.y);
box.x1 = Math.max(box.x1, q.x); box.y1 = Math.max(box.y1, q.y);
}
}
strokesCtx.beginPath();
var p0 = P(eff[0]); grow(p0);
strokesCtx.moveTo(p0.x, p0.y);
if (eff.length < 3) { strokesCtx.lineTo(p0.x + 0.01, p0.y); return box; }
for (var i = 1; i < eff.length - 1; i++) {
var a = P(eff[i]), b = P(eff[i + 1]);
grow(a); grow(b);
strokesCtx.quadraticCurveTo(a.x, a.y, (a.x + b.x) / 2, (a.y + b.y) / 2);
}
var pl = P(eff[eff.length - 1]); grow(pl);
strokesCtx.lineTo(pl.x, pl.y);
return box;
}
/*
* Repaint the stroke layer. Only the area the strokes occupied last time plus the area they
* occupy now is touched, so a growing line costs a few thousand pixels rather than the whole
* tall canvas — the live preview keeps up with the finger.
*/
function redrawStrokes() {
if (!strokesCtx || !atlasRect) return;
var entries = preview.live ? preview.done.concat([preview.live]) : preview.done;
if (!entries.length) {
/*
* Nothing left to preview — the handover to the TV's own line. Clear the WHOLE layer, not
* just the last bounds: a bounds-only clear left a hairline of the old stroke behind, and a
* leftover translucent highlighter sitting on top of the real one is exactly the doubled
* "glow" this is meant to avoid. It costs one clear per stroke, never per frame.
*/
strokesCtx.clearRect(0, 0, strokesCv.width, strokesCv.height);
strokesPrevBox = null;
return;
}
if (strokesPrevBox) {
strokesCtx.clearRect(strokesPrevBox.x, strokesPrevBox.y, strokesPrevBox.w, strokesPrevBox.h);
}
var box = null;
for (var i = 0; i < entries.length; i++) {
var s = entries[i];
if (!s.pts.length || !s.style || s.style.widthN === undefined) continue; // eraser: no trail
var lw = Math.max(s.style.widthN * frameH, 0.75);
strokesCtx.save();
strokesCtx.globalAlpha = s.style.alpha;
strokesCtx.strokeStyle = s.style.color;
strokesCtx.lineWidth = lw;
strokesCtx.lineCap = 'round'; strokesCtx.lineJoin = 'round';
var b = strokeAtlasPath(s.pts, s.s);
strokesCtx.stroke();
strokesCtx.restore();
if (b) {
var m = lw + 2;
var nb = { x: b.x0 - m, y: b.y0 - m, w: (b.x1 - b.x0) + m * 2, h: (b.y1 - b.y0) + m * 2 };
if (!box) box = nb;
else {
var x0 = Math.min(box.x, nb.x), y0 = Math.min(box.y, nb.y);
box = { x: x0, y: y0,
w: Math.max(box.x + box.w, nb.x + nb.w) - x0,
h: Math.max(box.y + box.h, nb.y + nb.h) - y0 };
}
}
}
strokesPrevBox = box;
}
// The page background under the text, sampled from the frame itself (bottom-left margin of the
// text region), so the scroll reveal matches whatever theme the TV is showing.
function sampleRegionBg() {
try {
var r = tvRect();
if (!r || !viewCtx || !frameReady) return;
var x = Math.max(0, Math.min(frameW - 1, Math.round(r.x * frameW) + 4));
var y = Math.max(0, Math.min(frameH - 1, Math.round((r.y + r.h) * frameH) - 6));
var d = viewCtx.getImageData(x, y, 1, 1).data;
regionBg = 'rgb(' + d[0] + ',' + d[1] + ',' + d[2] + ')';
} catch (_) {}
}
// One rAF repaints both the mirror and the ink preview — never more than once per screen refresh.
function requestPaint() {
if (paintRaf) return;
paintRaf = requestAnimationFrame(function () {
paintRaf = 0;
renderView();
paintPreview();
});
}
function pullFrame() {
if (!open || pending) return;
if (document.hidden) { schedule(500); return; } // phone screen off / app backgrounded
if (drawingHold()) { schedule(120); return; } // mirror frozen while a line is drawn
pending = true;
var gesturing = stroke.down || scroll.active;
fetch('/api/remote/frame', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pin: pin,
// The WIDTH never changes with the gesture. Dropping resolution mid-gesture resized the
// picture under the operator's finger, which read as the screen "getting smaller" every
// time they touched it. Only the JPEG quality moves, which no one can see moving and which
// is where most of the bytes are anyway.
width: frameWidth(),
// Flying: cheap frames encode and travel faster, so new text streams in sooner — and
// nobody can read text at that speed anyway. Creeping or still: full quality, because
// that IS when the operator is reading.
quality: gesturing ? (scrollSpeed > 7 ? 34 : GESTURE_QUALITY) : IDLE_QUALITY
})
}).then(function (r) {
if (r.status === 401) { pending = false; closeTv(); requirePin('That PIN no longer works.'); return null; }
if (r.status === 204) {
// Nothing changed on the TV since our current frame — keep it, check again shortly.
pending = false;
misses = 0;
setStatus('live', 'live');
schedule(70);
return null;
}
if (!r.ok) throw new Error('frame ' + r.status);
var sH = parseFloat(r.headers.get('X-Scroll-S'));
var mH = parseFloat(r.headers.get('X-Scroll-M'));
var iH = parseFloat(r.headers.get('X-Ink-V'));
return r.blob().then(function (blob) {
return { blob: blob, s: isFinite(sH) ? sH : null, m: isFinite(mH) ? mH : null,
iv: isFinite(iH) ? iH : null };
});
}).then(function (pkt) {
pending = false;
if (!pkt || !pkt.blob || !open) return;
// A stroke began while this frame was in flight: drop it unseen. It may already contain the
// TV's copy of the line, and applying it would double the stroke under the finger.
if (drawingHold()) { schedule(120); return; }
var url = URL.createObjectURL(pkt.blob);
var im = new Image();
im.onload = function () {
if (!open) { URL.revokeObjectURL(url); return; }
if (drawingHold()) { URL.revokeObjectURL(url); return; }
/*
* The ink handshake. Until the TV's "ink settled" counter has moved past where it stood
* when this stroke began, a frame may still be showing the instant between the live
* overlay being cleared and the committed canvas being painted — the flash-and-return at
* the end of every stroke. Drop those frames; the local preview holds the line meanwhile.
*/
if (pendingInk) {
var nowH = Date.now();
var holdOn = (pkt.iv !== null)
? (pkt.iv <= pendingInk.base && nowH < pendingInk.until)
: (nowH < pendingInk.softUntil);
if (holdOn) { URL.revokeObjectURL(url); schedule(25); return; }
// Settled: this frame carries the real line, so the preview retires in the same paint.
pendingInk = null;
preview.done = [];
}
if (pkt.iv !== null) lastInkV = pkt.iv;
if (cv.width !== im.naturalWidth || cv.height !== im.naturalHeight) {
cv.width = im.naturalWidth;
cv.height = im.naturalHeight;
viewCtx = cv.getContext('2d');
// A size change (rotation, resize) makes older frames unusable for compositing.
frames.forEach(function (f) { URL.revokeObjectURL(f.url); });
frames.length = 0;
atlasReady = false; atlasS0 = null;
}
frameW = im.naturalWidth; frameH = im.naturalHeight;
frameReady = true;
var newest = frames[frames.length - 1];
if (newest && newest.s === pkt.s) {
// Same position as the last frame: replace in place, keep history for real offsets.
URL.revokeObjectURL(newest.url);
frames[frames.length - 1] = { img: im, s: pkt.s, url: url };
} else {
frames.push({ img: im, s: pkt.s, url: url });
while (frames.length > FRAME_KEEP) { var oldF = frames.shift(); URL.revokeObjectURL(oldF.url); }
}
if (pkt.m !== null) scrollMaxN = pkt.m;
if (pkt.s !== null) {
if (wanted === null) wanted = pkt.s;
else if (!scroll.active && !stroke.down && !momentumRaf) {
// At rest, ease the wanted position onto the frame's truth (protocol losses, clamp
// arithmetic) — during a gesture the finger alone is in charge.
var diff = pkt.s - wanted;
wanted += (Math.abs(diff) * frameH < 0.75) ? diff : diff * 0.5;
}
}
fitPicture();
drawBase(im); // also refreshes regionBg from this frame
// Stitch this frame's text into the scroll layer at its true position, rebasing the
// atlas first if the page has scrolled beyond what it currently covers.
if (setupAtlas()) {
if (atlasS0 === null || !atlasCovers(pkt.s)) rebaseAtlas(pkt.s);
else stitch(im, pkt.s);
}
layoutAtlas();
applyScrollTransform();
redrawStrokes();
syncOverlays();
paintPreview();
};
im.src = url;
misses = 0;
setStatus('live', 'live');
// While a gesture is streaming fresh frames, pace them (~20fps): the local shift is what
// makes motion smooth, frames only refresh the content — and this keeps the fast capture
// path from flooding room Wi-Fi at 30fps.
schedule((stroke.down || scroll.active) ? 33 : FRAME_GAP_MS);
}).catch(function () {
pending = false;
misses++;
// 503 also lands here: the app answers that while a capture is already running, so a single
// miss is normal traffic, not a lost connection.
setStatus(misses > 3 ? 'reconnecting…' : 'live', misses > 3 ? 'lost' : 'live');
schedule(misses > 3 ? 700 : 260);
});
}
// ---- gesture -> app ----
function norm(touch) {
var r = pad.getBoundingClientRect();
if (!r.width || !r.height) return null;
// The touch layer is sized to the picture, and the picture IS the whole app window, so 0..1
// across this layer is 0..1 across the stage screen.
return {
x: Math.min(1, Math.max(0, (touch.clientX - r.left) / r.width)),
y: Math.min(1, Math.max(0, (touch.clientY - r.top) / r.height))
};
}
function avgY(touches) {
var s = 0; for (var i = 0; i < touches.length; i++) s += touches[i].clientY;
return touches.length ? s / touches.length : 0;
}
/*
* All mirror_draw traffic goes through ONE chain: the next batch is posted only after the last
* one answered (~3ms on a healthy LAN), which is what guarantees the TV joins the points in the
* order the finger made them. A batch that hangs is abandoned after 1.5s so a Wi-Fi hiccup costs
* a gap in the line, never a frozen stroke; if the chain backs up the newest batches are shed
* rather than queued into the past.
*/
var drawChain = Promise.resolve();
var drawChainDepth = 0;
function queueDraw(phase, points) {
if (drawChainDepth > 12) return;
drawChainDepth++;
drawChain = drawChain.then(function () {
var ctrl = (typeof AbortController === 'function') ? new AbortController() : null;
var to = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (_) {} }, 1500) : 0;
return fetch('/api/remote/action', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: pin, action: 'mirror_draw', payload: { phase: phase, points: points || [] } }),
signal: ctrl ? ctrl.signal : undefined
}).catch(function () {}).then(function () { if (to) clearTimeout(to); drawChainDepth--; });
});
}
function flush(phase) {
var pts = stroke.buf; stroke.buf = [];
stroke.lastSend = Date.now();
queueDraw(phase, pts);
}
// Scroll packets restate the whole gesture (running total + sequence), so they can stay
// parallel: a lost or overtaken packet is corrected by the next one.
function sendScroll() {
scroll.lastSend = Date.now();
post('/api/remote/action', {
pin: pin, action: 'mirror_scroll',
payload: { t: scroll.total, seq: ++scroll.seq, gesture: scroll.gesture }
}).catch(function () {});
}
/*
* The ink overlay now carries ONLY the eraser cursor. Stroke previews live inside renderView
* (see drawStrokes): painting them on this crisp device-resolution overlay made them sharper
* than the JPEG they handed over to, so every finished stroke looked like it "settled" — and
* cross-fading the translucent highlighter over the frame's copy doubled its alpha into a glow.
*/
function paintPreview() {
if (!inkCtx) return;
inkCtx.save();
inkCtx.setTransform(1, 0, 0, 1, 0, 0);
inkCtx.clearRect(0, 0, ink.width, ink.height);
inkCtx.restore();
if (eraserCursor) {
var w = pad.clientWidth || 1, h = pad.clientHeight || 1;
var st = toolStyle('erase');
inkCtx.save();
inkCtx.strokeStyle = 'rgba(255,255,255,.9)';
inkCtx.fillStyle = 'rgba(255,255,255,.15)';
inkCtx.lineWidth = 1.5;
inkCtx.beginPath();
inkCtx.arc(eraserCursor.x * w, eraserCursor.y * h, st.radius, 0, Math.PI * 2);
inkCtx.fill();
inkCtx.stroke();
inkCtx.restore();
}
}
function resetStroke() {
stroke.down = false; stroke.sent = false; stroke.last = null;
stroke.buf = []; stroke.all = []; stroke.blocked = false; stroke.warned = false;
preview.live = null;
eraserCursor = null;
}
/*
* Integrate finger/momentum movement into the gesture total and the wanted position, clamped
* to the page's REAL scroll range (from the frames' X-Scroll-M stamp). Clamping the TOTAL —
* not just the display — means the TV is never asked to over-scroll, so reversing out of the
* end of a chapter responds instantly instead of unwinding a phantom overshoot.
* Returns true when the movement hit a bound (momentum uses that to stop dead).
*/
function scrollBy(movedPx) {
scroll.total += movedPx / (pad.clientHeight || 1);
var clamped = false;
if (scrollAnchor !== null) {
var hi = scrollAnchor; // wanted >= 0
var lo = (scrollMaxN !== null) ? scrollAnchor - scrollMaxN : -Infinity;
if (scroll.total > hi) { scroll.total = hi; clamped = true; }
if (scroll.total < lo) { scroll.total = lo; clamped = true; }
wanted = scrollAnchor - scroll.total;
}
return clamped;
}
/*
* Momentum: a flick keeps the page coasting after the fingers lift, like a native scroll view.
* It keeps feeding the SAME gesture (same running total), so to the TV a fling is just a drag
* that carries on — nothing new to get out of sync.
*/
function stopMomentum() {
if (momentumRaf) { cancelAnimationFrame(momentumRaf); momentumRaf = 0; scroll.active = false; }
}
function flingVelocity() {
var now = Date.now();
var fresh = scroll.samples.filter(function (s) { return now - s.t <= 120; });
if (fresh.length < 2 || now - fresh[fresh.length - 1].t > 90) return 0;
var a = fresh[0], b = fresh[fresh.length - 1];
if (b.t <= a.t) return 0;
var v = (b.y - a.y) / (b.t - a.t) * 1000; // px/s, sign follows the finger
if (Math.abs(v) < 250) return 0; // too slow to be a flick
return Math.max(-3800, Math.min(3800, v));
}
function startMomentum(v0) {
var v = v0, lastT = performance.now();
scroll.active = true;
function step(now) {
momentumRaf = 0;
if (!open) { scroll.active = false; return; }
var dt = Math.min(64, now - lastT); lastT = now;
var dy = v * dt / 1000;
v *= Math.exp(-dt / 325); // exponential friction, ~0.7s to rest
var hitEnd = scrollBy(dy);
applyScrollTransform(); // one composited translate, nothing redrawn
if (Date.now() - scroll.lastSend >= SCROLL_EVERY_MS) sendScroll();
if (hitEnd || Math.abs(v) < 40) { sendScroll(); scroll.active = false; return; }
momentumRaf = requestAnimationFrame(step);
}
momentumRaf = requestAnimationFrame(step);
}
pad.addEventListener('touchstart', function (e) {
e.preventDefault();
var wasCoasting = !!momentumRaf;
stopMomentum();
/*
* Splash up? ANY touch dismisses it — the same gesture as touching the TV glass — and does
* nothing else, so the operator can tap once and go straight back to drawing and scrolling.
* splash_hide is hide-only and visibility-guarded on the TV, so a stale snapshot here can
* never toggle the splash back on.
*/
if (splashOn()) {
if (!swallowGesture) {
if (state) state.splash = false; // believe it now; the poll confirms shortly
doAction('splash_hide', {}, true);
toast('✓ splash hidden');
updateTvHint();
}
swallowGesture = true;
return;
}
if (swallowGesture) return;
if (e.touches.length >= 2) {
// Second finger: this is a scroll. Throw away anything the first finger already started —
// the app discards it the same way it discards a two-finger-cancelled stroke on the TV.
if (stroke.sent) queueDraw('cancel', []);
resetStroke();
scroll.active = true;
scroll.lastY = avgY(e.touches);
scroll.total = 0;
scroll.seq = 0;
scroll.gesture = ++gestureCounter;
scrollAnchor = wanted; // the finger takes over from here (null until a stamped frame)
scrollSpeed = 0;
scroll.samples = [{ t: Date.now(), y: scroll.lastY }];
redrawStrokes(); // synchronous: wipe a cancelled live stroke off the mirror right now
paintPreview();
return;
}
if (scroll.active) return;
if (wasCoasting) { swallowGesture = true; return; } // this touch just stops the fling
var p = norm(e.touches[0]);
if (!p) return;
var mode = activeTabMode();
var tool = tvToolNow();
// Match the TV's own refusals up front, so the preview never shows a line the TV will not draw:
// no drawing on announcement/graphics/maps tabs, no drawing with a non-drawing tool, and a
// stroke must START inside the text area (the TV ignores ones that don't).
var blocked = (mode === 'announcements' || mode === 'graphics' || mode === 'maps')
|| !(tool === 'pen' || tool === 'highlight' || tool === 'erase');
var r = tvRect();
if (!blocked && r && (p.x < r.x || p.x > r.x + r.w || p.y < r.y || p.y > r.y + r.h)) blocked = true;
stroke.down = true; stroke.sent = false; stroke.buf = [p]; stroke.all = [p];
stroke.last = p; stroke.lastSend = 0; stroke.tool = tool; stroke.blocked = blocked; stroke.warned = false;
stroke.t0 = Date.now();
stroke.style = blocked ? null : toolStyle(tool);
if (!blocked && tool === 'erase') { eraserCursor = p; requestPaint(); }
}, { passive: false });
pad.addEventListener('touchmove', function (e) {
e.preventDefault();
if (swallowGesture) return;
var now = Date.now();
if (scroll.active) {
if (e.touches.length < 2) return;
var y = avgY(e.touches);
var movedPx = y - scroll.lastY;
scroll.lastY = y;
scroll.samples.push({ t: now, y: y });
if (scroll.samples.length > 8) scroll.samples.shift();
// Move the picture NOW. Waiting for the next frame is what made scrolling feel like mud:
// one visible step per frame instead of following the finger.
scrollSpeed = scrollSpeed * 0.6 + Math.abs(movedPx) * 0.4;
scrollBy(movedPx);
applyScrollTransform(); // one composited translate, nothing redrawn
if (now - scroll.lastSend >= SCROLL_EVERY_MS) sendScroll();
return;
}
if (!stroke.down || e.touches.length !== 1) return;
var p = norm(e.touches[0]);
if (!p) return;
if (stroke.blocked) {
// Tell the operator once WHY nothing is happening instead of silently eating the gesture.
if (!stroke.warned) {
stroke.warned = true;
var mode = activeTabMode();
if (mode === 'announcements' || mode === 'graphics' || mode === 'maps') toast('No drawing on this tab', true);
else if (!(stroke.tool === 'pen' || stroke.tool === 'highlight' || stroke.tool === 'erase')) toast('Pick pen, highlight or erase', true);
}
return;
}
stroke.buf.push(p);
stroke.all.push(p);
stroke.last = p;
if (stroke.tool === 'erase') eraserCursor = p;
if (!stroke.sent) {
// Wait for real travel before opening a stroke, so resting a thumb on the picture while
// finding your place cannot leave a mark.
var first = stroke.all[0];
if (Math.abs(p.x - first.x) < START_SLOP && Math.abs(p.y - first.y) < START_SLOP) {
if (eraserCursor) paintPreview();
return;
}
stroke.sent = true;
if (stroke.tool !== 'erase') preview.live = { pts: stroke.all, style: stroke.style, s: wanted };
flush('start');
} else if (now - stroke.lastSend >= SEND_EVERY_MS) {
flush('move');
}
redrawStrokes();
if (eraserCursor) paintPreview();
}, { passive: false });
function endGesture(e) {
if (e.touches && e.touches.length > 0) return; // a finger is still down
swallowGesture = false;
if (scroll.active && !momentumRaf) {
// Restate the final total straight away instead of waiting for the next throttle window,
// otherwise the last few pixels of a drag arrive late and the picture corrects at the end.
sendScroll();
var v = flingVelocity();
scroll.samples = [];
if (v !== 0) startMomentum(v);
else scroll.active = false;
}
if (stroke.down) {
if (stroke.sent) {
flush('end');
if (stroke.tool !== 'erase') {
// Hold the finished stroke on the local layer, and refuse arriving frames, until the
// TV's ink counter proves the committed line is really in the picture. That is what
// removes the flash-vanish-return at the end of a stroke: the preview is replaced in a
// single paint by a frame that definitely contains the line, never by an in-between one.
preview.done.push({ pts: stroke.all, style: stroke.style, s: wanted });
var nowE = Date.now();
pendingInk = { base: lastInkV, until: nowE + 1800, softUntil: nowE + 320 };
// Ask for the confirming frame as soon as the 'end' has actually reached the TV.
drawChain.then(function () { if (open) pullFrame(); });
}
} else if (Date.now() - (stroke.t0 || 0) < 600 && stroke.all.length) {
/*
* A tap, not a stroke: replay it on the TV at the same spot. This is what lets the operator
* tap tabs, the book/chapter/translation controls in the header, and verses straight on the
* mirror — the phone behaves like the glass, not just a picture of it.
*/
var tp = stroke.all[0];
post('/api/remote/action', { pin: pin, action: 'mirror_tap', payload: { x: tp.x, y: tp.y } }).catch(function () {});
try { if (navigator.vibrate) navigator.vibrate(8); } catch (err) {}
}
resetStroke();
redrawStrokes(); // synchronous: the stroke layer must settle on lift even if rAF is stalled
paintPreview();
}
}
pad.addEventListener('touchend', function (e) { e.preventDefault(); endGesture(e); }, { passive: false });
pad.addEventListener('touchcancel', function (e) { endGesture(e); }, { passive: false });
/*
* The pill at the bottom of the picture doubles as the splash notice: while the splash is up it
* stays visible saying how to clear it, because a mirror showing only the splash image gives no
* clue that one tap gets the text back. Driven from every state paint via tvOnState.
*/
function updateTvHint() {
if (!open) return;
if (splashOn()) {
hint.textContent = 'Splash is on — tap the screen to hide it';
hint.style.opacity = '1';
} else if (!hintIntro) {
hint.textContent = 'tap to press · 1 finger draws · 2 fingers scroll';
hint.style.opacity = '0';
}
}
tvOnState = updateTvHint;
function openTv() {
if (!pin) return;
open = true;
wanted = null; scrollMaxN = null; scrollAnchor = null;
frameReady = false; frameW = 0; frameH = 0;
frames.forEach(function (f) { URL.revokeObjectURL(f.url); });
frames.length = 0;
atlasReady = false; atlasS0 = null; atlasRect = null; strokesPrevBox = null;
lastInkV = 0; pendingInk = null;
scrollClip.style.display = 'none';
scrollInner.style.transform = '';
preview.done = []; preview.live = null; eraserCursor = null;
resetStroke();
// NO requestFullscreen here: the page already fills the phone screen, and real fullscreen
// meant the ✕ needed two taps (one to leave fullscreen, one to close) — annoying live.
view.classList.remove('hidden');
setStatus('connecting…', '');
hintIntro = true;
hint.textContent = 'tap to press · 1 finger draws · 2 fingers scroll';
hint.style.opacity = '1';
setTimeout(function () { hintIntro = false; updateTvHint(); }, 2600);
updateTvHint();
pullFrame();
}
function closeTv() {
open = false;
if (nextTimer) { clearTimeout(nextTimer); nextTimer = 0; }
stopMomentum();
if (stroke.sent) queueDraw('cancel', []);
resetStroke();
scroll.active = false;
preview.done = [];
if (inkCtx) { inkCtx.save(); inkCtx.setTransform(1, 0, 0, 1, 0, 0); inkCtx.clearRect(0, 0, ink.width, ink.height); inkCtx.restore(); }
if (viewCtx) viewCtx.clearRect(0, 0, cv.width, cv.height);
wanted = null; scrollMaxN = null; scrollAnchor = null;
frameReady = false;
atlasReady = false; atlasS0 = null; atlasRect = null; strokesPrevBox = null;
lastInkV = 0; pendingInk = null;
scrollClip.style.display = 'none';
scrollInner.style.transform = '';
cv.style.width = ''; cv.style.height = '';
view.classList.add('hidden');
frames.forEach(function (f) { URL.revokeObjectURL(f.url); });
frames.length = 0;
}
onTap(openBtn, openTv);
onTap(closeBtn, closeTv);
// Debug/support hook: state of the mirror compositor (safe to call from a console).
window.__tvDebug = function () {
return { frames: frames.map(function (f) { return f.s; }), wanted: wanted, max: scrollMaxN,
rect: tvRect(), size: [frameW, frameH], atlasS0: atlasS0, atlasH: atlasH,
inkV: lastInkV, pendingInk: pendingInk };
};
// Rotating an iPad changes the box, so refit and re-place the touch layer, and ask for a frame
// sized to the new width rather than keeping the portrait one stretched across landscape.
function refit() { if (!open) return; fitPicture(); syncOverlays(); layoutAtlas(); applyScrollTransform(); requestPaint(); }
window.addEventListener('resize', refit);
window.addEventListener('orientationchange', function () { setTimeout(refit, 250); });
// Stop pulling 4K captures the moment the phone is put down or the browser is backgrounded.
document.addEventListener('visibilitychange', function () {
if (!open) return;
if (document.hidden) { stopMomentum(); if (nextTimer) { clearTimeout(nextTimer); nextTimer = 0; } }
else pullFrame();
});
})();
boot();
</script>
</body>
</html>