1592 lines
82 KiB
HTML
1592 lines
82 KiB
HTML
<!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 · scroll & underline</button>
|
|||
|
|
|
|||
|
|
<div class="card grow">
|
|||
|
|
<h2>Tabs · 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 · 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 · 1 finger draws · 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>
|