394 lines
20 KiB
JavaScript
394 lines
20 KiB
JavaScript
/*
|
|||
|
|
* follower-view.js — the booth screen. Isolated + additive; loaded ONLY for index.html?follower=1.
|
||
|
|
*
|
||
|
|
* This page is the real app, not a lookalike. It receives state from the stage TV (follower-link.js)
|
||
|
|
* and drives its own renderer to the same passage, scroll and strokes, then hands the picture to
|
||
|
|
* ProPresenter over the machine's existing HDMI output.
|
||
|
|
*
|
||
|
|
* WHY THE REAL APP: the pen strokes are positioned against the text. A second implementation of the
|
||
|
|
* Bible view would wrap lines even slightly differently and every underline would sit under the wrong
|
||
|
|
* word. Running the same renderer at the same CSS geometry makes that impossible by construction.
|
||
|
|
*
|
||
|
|
* WHY THE TOOLBAR IS HIDDEN BUT NOT REMOVED: the operator wants only the content area on screen. The
|
||
|
|
* obvious `display:none` would give the content the toolbar's height back, reflowing the text — and
|
||
|
|
* every stroke would then land a line off. Instead the layout is kept EXACTLY as the stage TV's and
|
||
|
|
* the chrome is scrolled out of frame and clipped. Never "simplify" this by removing the toolbar.
|
||
|
|
*
|
||
|
|
* Geometry: the app is forced to the stage TV's CSS size (published in every snapshot), then scaled
|
||
|
|
* with one CSS transform to fill this display, letterboxed on black. Text, images and ink scale
|
||
|
|
* together, so a 1080p booth output and a 4K portrait stage stay pixel-faithful to each other.
|
||
|
|
*/
|
||
|
|
(function () {
|
||
|
|
'use strict';
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Hard gate. This file ships in the same index.html the stage TV loads, and everything below —
|
||
|
|
* hiding the toolbar, disabling input, driving the passage from the network — would wreck the live
|
||
|
|
* screen. Only ?follower=1 turns it on.
|
||
|
|
*/
|
||
|
|
function isFollower() {
|
||
|
|
try { return !!window.__followerMode || /(^|[?&])follower=1(&|$)/.test(window.location.search); }
|
||
|
|
catch (_) { return false; }
|
||
|
|
}
|
||
|
|
if (!isFollower()) return;
|
||
|
|
|
||
|
|
var RECONNECT_MS = 1500;
|
||
|
|
var S = {
|
||
|
|
ws: null, retry: null,
|
||
|
|
geom: null, // stage geometry, from the snapshot
|
||
|
|
tabKey: '', // active passage identity, so we only reload on a real change
|
||
|
|
pendingScroll: null,
|
||
|
|
passageKey: null,
|
||
|
|
splash: null, // mirrored from the stage, never decided locally
|
||
|
|
carousel: null, // the image the stage is showing
|
||
|
|
pendingCarousel: null,
|
||
|
|
lastSeq: -1,
|
||
|
|
connected: false,
|
||
|
|
stats: { snapshots: 0, commits: 0, live: 0, scrolls: 0, lastMsgAt: 0 }
|
||
|
|
};
|
||
|
|
|
||
|
|
function app() { return window.bibleApp || null; }
|
||
|
|
function bridge() { return window.__followerBridge || null; }
|
||
|
|
function log() { try { console.log.apply(console, ['[FOLLOWER]'].concat([].slice.call(arguments))); } catch (_) {} }
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ chrome + geometry
|
||
|
|
function styleOnce() {
|
||
|
|
if (document.getElementById('followerStyle')) return;
|
||
|
|
var css = [
|
||
|
|
/* Black everywhere the page does not reach. */
|
||
|
|
'html,body{background:#000!important;margin:0!important;padding:0!important;overflow:hidden!important;cursor:none!important;}',
|
||
|
|
'#followerStage{position:fixed;left:0;top:0;width:100vw;height:100vh;background:#000;overflow:hidden;z-index:0;}',
|
||
|
|
/* The app is laid out at the STAGE TV's size, then scaled as one piece. */
|
||
|
|
'#app{position:absolute!important;left:0!important;top:0!important;transform-origin:0 0!important;}',
|
||
|
|
/* Nothing on this screen is interactive — it is a picture going to a switcher. The descendant
|
||
|
|
rule matters: the annotation canvases set pointer-events:auto INLINE, which beats the value they
|
||
|
|
would inherit from #app, so a mouse on the booth machine could draw ink here that nothing could
|
||
|
|
erase. An !important author rule outranks the inline declaration. */
|
||
|
|
'#app,#app *{pointer-events:none!important;}',
|
||
|
|
/* Chrome stays in the layout (so text wraps identically) but must never be seen. */
|
||
|
|
'.toolbar{visibility:hidden!important;}',
|
||
|
|
/* The chapter header's navigation cluster (back, up/down, -5/+5): a program feed shows the title
|
||
|
|
only. visibility, not display — the header is a 3-column grid whose right spacer mirrors this
|
||
|
|
cluster's width to keep the title centred; collapsing it would shift the title. */
|
||
|
|
'.chapter-header .chapter-left-controls,.chapter-header .header-back-btn,.chapter-header .header-vertical-group,.chapter-header .chapter-right-controls{visibility:hidden!important;}',
|
||
|
|
/* Overlays that would be wrong on a program feed. */
|
||
|
|
'#offstageBadge,.touch-menu-overlay,.qd-dock,#toolRail,#toolIndicator{display:none!important;}',
|
||
|
|
/* The splash occupies EXACTLY the box an announcement card does: from the top of the content region,
|
||
|
|
100vh tall (the card runs on under the toolbar's height at the bottom, as announcements do), with
|
||
|
|
the stock contain fit — so a splash of ANY aspect lands where the same picture would land as an
|
||
|
|
announcement, and the shell fits ONE box always. (A 'cover' fit would have zoomed the church's 2:3
|
||
|
|
splash 15% and cropped its sides — a size change, the very thing this exists to remove.) The var
|
||
|
|
comes from the stage geometry (applyGeometry); until it arrives it falls back to the viewport. */
|
||
|
|
'.splash-overlay{top:var(--fw-content-top,0px)!important;height:100vh!important;bottom:auto!important;}',
|
||
|
|
/* The toolbar band's height is decided by CSS the two machines evaluate separately: @media
|
||
|
|
(pointer: coarse) makes the TV's tabs 44px (band ~56px) while a mouse-driven booth lays them out
|
||
|
|
at 38px (band 50px). The band stays in the layout here (see above), so its height must be the
|
||
|
|
STAGE's, or every line of text and every stroke sits 6px off and the crop takes the top of the
|
||
|
|
page. Same var as the splash box. */
|
||
|
|
'.toolbar{height:var(--fw-content-top,auto)!important;min-height:0!important;max-height:none!important;overflow:hidden!important;}',
|
||
|
|
/* Until the first snapshot has said what is on the TV, show nothing: init.js creates the splash
|
||
|
|
visible, and before the geometry arrives it would render at THIS window's aspect and then jump
|
||
|
|
into the portrait box (on every connect and every version-change reload). */
|
||
|
|
'html:not(.fw-ready) .splash-overlay{visibility:hidden!important;opacity:0!important;transition:none!important;}',
|
||
|
|
/* The Ask panel is a faintly blue near-black (#0c0e12) on the TV; against this screen's pure-black
|
||
|
|
letterbox that tint reads as a panel edge on the program feed. Black here, and only here. */
|
||
|
|
'.ask-reading-area{background:#000!important;}'
|
||
|
|
].join('\n');
|
||
|
|
var el = document.createElement('style');
|
||
|
|
el.id = 'followerStyle';
|
||
|
|
el.textContent = css;
|
||
|
|
document.head.appendChild(el);
|
||
|
|
|
||
|
|
var stage = document.createElement('div');
|
||
|
|
stage.id = 'followerStage';
|
||
|
|
document.body.insertBefore(stage, document.body.firstChild);
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Force this window to lay out exactly like the stage TV, then scale to fit and slide the chrome
|
||
|
|
* off the top. `content` is the stage's .tab-content rect, so what remains on screen is precisely
|
||
|
|
* the region the operator asked for — the Bible text, graphics and announcements, nothing else.
|
||
|
|
*/
|
||
|
|
/*
|
||
|
|
* FRAMED (the normal case — follower.html): this page runs inside an iframe that the shell sizes to
|
||
|
|
* the stage's exact viewport, so every viewport unit already resolves as on the TV and nothing here
|
||
|
|
* may be transformed. The geometry is simply reported up to the shell, which does the fit-and-crop.
|
||
|
|
* UNFRAMED (the page opened directly): fall back to transforming #app — layout parity is then only
|
||
|
|
* approximate (100vh is this window's, not the stage's), which is why the shell is the supported way.
|
||
|
|
*/
|
||
|
|
var framed = false;
|
||
|
|
try { framed = window.parent && window.parent !== window && /(^|[?&])framed=1(&|$)/.test(window.location.search); } catch (_) { framed = false; }
|
||
|
|
|
||
|
|
function applyGeometry(g) {
|
||
|
|
if (!g || !g.w || !g.h) return;
|
||
|
|
var content = g.content || { x: 0, y: 0, w: g.w, h: g.h };
|
||
|
|
if (framed) {
|
||
|
|
/*
|
||
|
|
* The splash is confined to the CONTENT box (CSS vars read by the rule in styleOnce), so it occupies
|
||
|
|
* exactly the region the announcements do and the shell fits ONE box always. Fitting the whole frame
|
||
|
|
* while the splash was up made the splash 2.6% smaller than every other tab on the program feed —
|
||
|
|
* and swapping boxes was itself a visible size change.
|
||
|
|
*/
|
||
|
|
try {
|
||
|
|
if (S.fwTop !== content.y) { // guarded: applyGeometry re-runs every 500ms
|
||
|
|
document.documentElement.style.setProperty('--fw-content-top', content.y + 'px');
|
||
|
|
S.fwTop = content.y;
|
||
|
|
}
|
||
|
|
} catch (_) {}
|
||
|
|
try { window.parent.postMessage({ type: 'follower-geom', geom: g, splash: !!S.splash }, window.location.origin); } catch (_) {}
|
||
|
|
S.appliedGeom = { w: g.w, h: g.h, cw: content.w, ch: content.h, framed: true, inner: [window.innerWidth, window.innerHeight] };
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
var appEl = document.getElementById('app');
|
||
|
|
if (!appEl) return;
|
||
|
|
appEl.style.width = g.w + 'px';
|
||
|
|
appEl.style.height = g.h + 'px';
|
||
|
|
|
||
|
|
var vw = window.innerWidth, vh = window.innerHeight;
|
||
|
|
var scale = Math.min(vw / content.w, vh / content.h); // fit the CONTENT region, not the whole app
|
||
|
|
var offsetX = (vw - content.w * scale) / 2 - content.x * scale;
|
||
|
|
var offsetY = (vh - content.h * scale) / 2 - content.y * scale;
|
||
|
|
appEl.style.transform = 'translate(' + offsetX + 'px,' + offsetY + 'px) scale(' + scale + ')';
|
||
|
|
S.appliedGeom = { w: g.w, h: g.h, cw: content.w, ch: content.h, scale: scale, framed: false };
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ state application
|
||
|
|
function tabIdentity(t) {
|
||
|
|
return [t.mode, t.ppsync ? 1 : 0, t.book, t.chapter, t.verse, t.verseEnd, t.translation].join('|');
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Move this app to the stage's tab and passage. The follower has the same startup tab set, so the
|
||
|
|
* matching tab is found by mode (and the ppsync flag), never by id — ids are timestamps and differ
|
||
|
|
* between machines.
|
||
|
|
*/
|
||
|
|
function applyTab(remote) {
|
||
|
|
var a = app();
|
||
|
|
if (!a || !a.tabs) return null;
|
||
|
|
var local = null;
|
||
|
|
for (var i = 0; i < a.tabs.length; i++) {
|
||
|
|
var t = a.tabs[i];
|
||
|
|
if (t.mode !== remote.mode) continue;
|
||
|
|
if (!!t.ppsync !== !!remote.ppsync) continue;
|
||
|
|
local = t; break;
|
||
|
|
}
|
||
|
|
if (!local) return null;
|
||
|
|
if (a.activeTabId !== local.id) { try { a.switchTab(local.id); } catch (_) {} }
|
||
|
|
|
||
|
|
var identity = tabIdentity(remote);
|
||
|
|
if (identity !== S.tabKey) {
|
||
|
|
S.tabKey = identity;
|
||
|
|
if (remote.mode === 'bible' && remote.book && remote.chapter) {
|
||
|
|
local.book = remote.book;
|
||
|
|
local.chapter = remote.chapter;
|
||
|
|
local.verse = remote.verse || 1;
|
||
|
|
local.verseEnd = remote.verseEnd || null;
|
||
|
|
local.translation = remote.translation || local.translation;
|
||
|
|
local.loaded = false;
|
||
|
|
local.savedText = null;
|
||
|
|
local.savedHeader = null;
|
||
|
|
local._scrollToVerse = null; // scroll is mirrored from the stage, never chosen here
|
||
|
|
try { a.loadPassage(local.id); } catch (e) { log('loadPassage failed', e); }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return local;
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyScroll(tabId, top) {
|
||
|
|
var ra = document.getElementById('readingArea' + tabId);
|
||
|
|
if (!ra) { S.pendingScroll = { tabId: tabId, top: top }; return; }
|
||
|
|
if (Math.abs(ra.scrollTop - top) > 0.5) ra.scrollTop = top;
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* The splash is mirrored, never decided here. init.js refuses every show/hide on the follower that does
|
||
|
|
* not carry the token below: in 1.4.29 the follower's own idle timer was still being armed by mouse
|
||
|
|
* movement on the booth Mac and by ProPresenter slides (propresenter-sync.js resets it on every slide),
|
||
|
|
* so the program feed went to a splash of its own — fitted to the content box, hence the two sizes —
|
||
|
|
* and came back on the next slide. The link client itself no longer runs on the follower either.
|
||
|
|
*/
|
||
|
|
function applySplash(on) {
|
||
|
|
try {
|
||
|
|
if (on === S.splash) return;
|
||
|
|
S.splash = on;
|
||
|
|
if (on) { if (typeof window.showSplash === 'function') window.showSplash('__follower-mirror'); }
|
||
|
|
else { if (typeof window.hideSplash === 'function') window.hideSplash('__follower-mirror'); }
|
||
|
|
} catch (_) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* The image carousels (Graphics / Maps / Announcements). Matched by URL first: both machines build
|
||
|
|
* their list independently from the same server, so if a picture is added or reordered mid-service,
|
||
|
|
* position 3 need not be the same image on each screen — and showing the wrong graphic on the
|
||
|
|
* program feed is exactly the failure this avoids. The index is the fallback.
|
||
|
|
*
|
||
|
|
* Re-asserted on EVERY snapshot rather than only on change: the carousel refuses a move while it is
|
||
|
|
* still animating, and the follower's own list can finish loading a beat after the stage's, so a
|
||
|
|
* single attempt can be dropped. Re-asserting makes it self-heal within 250ms, and it is a no-op
|
||
|
|
* once the two agree.
|
||
|
|
*/
|
||
|
|
function applyCarousel(tabId, want) {
|
||
|
|
if (!want) return;
|
||
|
|
var b = bridge();
|
||
|
|
var car = b && b.carousel(tabId);
|
||
|
|
if (!car || !car.count) { S.pendingCarousel = { tabId: tabId, want: want }; return; }
|
||
|
|
S.pendingCarousel = null;
|
||
|
|
var target = -1;
|
||
|
|
if (want.url) target = car.indexOfUrl(want.url);
|
||
|
|
if (target < 0 && typeof want.index === 'number') target = Math.min(Math.max(0, want.index), car.count - 1);
|
||
|
|
if (target >= 0 && target !== car.index) car.goTo(target);
|
||
|
|
// Pinch-zoom / pan of the current map, from the snapshot (late joiners); live changes arrive as 'zoom'.
|
||
|
|
if (want.zoom && car.setZoom) car.setZoom(want.zoom);
|
||
|
|
}
|
||
|
|
function onZoom(msg) {
|
||
|
|
var b = bridge(), id = activeLocalTabId();
|
||
|
|
var car = b && id && b.carousel(id);
|
||
|
|
if (car && car.setZoom) car.setZoom(msg.zoom || { s: 1, x: 0, y: 0 });
|
||
|
|
}
|
||
|
|
|
||
|
|
function onSnapshot(msg) {
|
||
|
|
S.stats.snapshots++;
|
||
|
|
if (typeof msg.splash === 'boolean') applySplash(msg.splash);
|
||
|
|
// First word from the stage: from here on the splash may be shown (only ever as mirrored).
|
||
|
|
try { if (!S.ready) { S.ready = true; document.documentElement.classList.add('fw-ready'); } } catch (_) {}
|
||
|
|
if (msg.geom) { S.geom = msg.geom; applyGeometry(msg.geom); }
|
||
|
|
var local = applyTab(msg.tab);
|
||
|
|
if (!local) return;
|
||
|
|
if (typeof msg.scrollTop === 'number') applyScroll(local.id, msg.scrollTop);
|
||
|
|
// `strokes` is present only when the stage's list changed (bandwidth), so absence means "keep".
|
||
|
|
if (msg.passageKey) S.passageKey = msg.passageKey;
|
||
|
|
var b = bridge();
|
||
|
|
if (msg.strokes && b) b.setStrokes(local.id, msg.passageKey, msg.strokes);
|
||
|
|
|
||
|
|
// Whatever else this tab shows.
|
||
|
|
S.carousel = msg.carousel || null;
|
||
|
|
if (msg.carousel) applyCarousel(local.id, msg.carousel);
|
||
|
|
if (typeof msg.prayer === 'string' && b) b.setPrayerHtml(local.id, msg.prayer);
|
||
|
|
if (msg.ask && b) b.setAskMessages(local.id, msg.ask);
|
||
|
|
}
|
||
|
|
|
||
|
|
function activeLocalTabId() {
|
||
|
|
var a = app();
|
||
|
|
return a ? a.activeTabId : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
var lastGeomCheck = 0;
|
||
|
|
function checkCanvases(force) {
|
||
|
|
var now = Date.now();
|
||
|
|
if (!force && now - lastGeomCheck < 250) return;
|
||
|
|
lastGeomCheck = now;
|
||
|
|
try { var b = bridge(), id = activeLocalTabId(); if (b && id && b.ensureCanvasGeometry) b.ensureCanvasGeometry(id); } catch (_) {}
|
||
|
|
}
|
||
|
|
function onLive(msg) {
|
||
|
|
S.stats.live++;
|
||
|
|
checkCanvases(false);
|
||
|
|
var b = bridge(), id = activeLocalTabId();
|
||
|
|
if (b && id) b.paintLive(id, msg.live);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Committed strokes and the overlay clear arrive together and are applied together — one paint,
|
||
|
|
// so the line is never on both canvases (doubled) or neither (a flash).
|
||
|
|
function onCommit(msg) {
|
||
|
|
S.stats.commits++;
|
||
|
|
var b = bridge(), id = activeLocalTabId();
|
||
|
|
if (!b || !id) return;
|
||
|
|
if (msg.passageKey) { S.passageKey = msg.passageKey; b.setStrokes(id, msg.passageKey, msg.strokes || []); }
|
||
|
|
b.paintLive(id, null);
|
||
|
|
}
|
||
|
|
|
||
|
|
// The stage wiped every passage's ink (trash held). Passages not on screen never get a snapshot, so
|
||
|
|
// their cached lists would have come back the next time the stage showed them — drop them all now.
|
||
|
|
function onClearAll() {
|
||
|
|
S.stats.clears = (S.stats.clears || 0) + 1;
|
||
|
|
var b = bridge(), id = activeLocalTabId();
|
||
|
|
if (b && b.clearAllInk) b.clearAllInk(id);
|
||
|
|
}
|
||
|
|
|
||
|
|
function onMessage(raw) {
|
||
|
|
var msg;
|
||
|
|
try { msg = JSON.parse(raw); } catch (_) { return; }
|
||
|
|
if (!msg || !msg.type) return;
|
||
|
|
S.stats.lastMsgAt = Date.now();
|
||
|
|
if (msg.type === 'clearAll') return onClearAll();
|
||
|
|
if (msg.type === 'snapshot') return onSnapshot(msg);
|
||
|
|
if (msg.type === 'live') return onLive(msg);
|
||
|
|
if (msg.type === 'commit') return onCommit(msg);
|
||
|
|
if (msg.type === 'scroll') { S.stats.scrolls++; return applyScroll(activeLocalTabId(), msg.scrollTop); }
|
||
|
|
if (msg.type === 'zoom') return onZoom(msg);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ link
|
||
|
|
function connect() {
|
||
|
|
if (S.ws) { try { S.ws.close(); } catch (_) {} S.ws = null; }
|
||
|
|
var url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/follower-ws?role=subscriber';
|
||
|
|
var ws;
|
||
|
|
try { ws = new WebSocket(url); } catch (_) { scheduleRetry(); return; }
|
||
|
|
S.ws = ws;
|
||
|
|
ws.onopen = function () { S.connected = true; S.tabKey = ''; log('linked to stage'); };
|
||
|
|
ws.onmessage = function (ev) { onMessage(ev.data); };
|
||
|
|
ws.onclose = function () { S.connected = false; scheduleRetry(); };
|
||
|
|
ws.onerror = function () { try { ws.close(); } catch (_) {} };
|
||
|
|
}
|
||
|
|
function scheduleRetry() {
|
||
|
|
if (S.retry) return;
|
||
|
|
S.retry = setTimeout(function () { S.retry = null; connect(); }, RECONNECT_MS);
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Belt and braces to the CSS above: swallow every kind of input at the window, capture phase, before
|
||
|
|
* any app handler sees it. Wheel and keys included — a scroll or an Escape on the booth machine would
|
||
|
|
* otherwise move the page or hide the mirrored splash. The shell reloads this page on version
|
||
|
|
* change, so nothing here ever needs a keypress.
|
||
|
|
*/
|
||
|
|
function blockAllInput() {
|
||
|
|
var kill = function (e) { try { e.stopImmediatePropagation(); if (e.cancelable) e.preventDefault(); } catch (_) {} };
|
||
|
|
['pointerdown', 'pointerup', 'pointermove', 'pointercancel', 'mousedown', 'mouseup', 'mousemove', 'click', 'dblclick',
|
||
|
|
'touchstart', 'touchmove', 'touchend', 'touchcancel', 'wheel', 'keydown', 'keyup', 'keypress', 'contextmenu', 'dragstart']
|
||
|
|
.forEach(function (type) { window.addEventListener(type, kill, { capture: true, passive: false }); });
|
||
|
|
}
|
||
|
|
|
||
|
|
function boot() {
|
||
|
|
styleOnce();
|
||
|
|
blockAllInput();
|
||
|
|
var a = app();
|
||
|
|
if (!a || !a.tabs || !a.tabs.length || !window.__followerBridge) { setTimeout(boot, 120); return; }
|
||
|
|
// A late-arriving passage render can land after the scroll we were told to apply.
|
||
|
|
setInterval(function () {
|
||
|
|
if (S.pendingScroll) {
|
||
|
|
var p = S.pendingScroll;
|
||
|
|
var ra = document.getElementById('readingArea' + p.tabId);
|
||
|
|
if (ra) { ra.scrollTop = p.top; S.pendingScroll = null; }
|
||
|
|
}
|
||
|
|
if (S.geom) applyGeometry(S.geom);
|
||
|
|
// Canvases built before the frame took the stage's size (or before a display-scaling change) draw
|
||
|
|
// every stroke scaled toward the top-left; rebuild them the moment their geometry is off.
|
||
|
|
checkCanvases(true);
|
||
|
|
// The stage's list is the only truth about ink: anything else on the canvas is replaced.
|
||
|
|
try { var bb = bridge(), bid = activeLocalTabId(); if (bb && bid && bb.reassert) bb.reassert(bid); } catch (_) {}
|
||
|
|
// Re-derive the ink if this display's pixel ratio changed under us (window moved to another
|
||
|
|
// monitor, OS scaling changed). Cheap no-op when it did not.
|
||
|
|
try {
|
||
|
|
var b = bridge(), id = activeLocalTabId();
|
||
|
|
if (b && id) b.reapply(id);
|
||
|
|
// The carousel element is built a beat after the tab switch, so a picture asked for during
|
||
|
|
// that gap has to be re-asserted once it exists.
|
||
|
|
if (S.pendingCarousel) applyCarousel(S.pendingCarousel.tabId, S.pendingCarousel.want);
|
||
|
|
else if (id && S.carousel) applyCarousel(id, S.carousel);
|
||
|
|
} catch (_) {}
|
||
|
|
}, 500);
|
||
|
|
window.addEventListener('resize', function () { if (S.geom) applyGeometry(S.geom); });
|
||
|
|
connect();
|
||
|
|
log('ready — waiting for the stage');
|
||
|
|
}
|
||
|
|
|
||
|
|
window.__follower = {
|
||
|
|
get connected() { return S.connected; },
|
||
|
|
get stats() { return S.stats; },
|
||
|
|
get geom() { return S.appliedGeom; },
|
||
|
|
_apply: onMessage
|
||
|
|
};
|
||
|
|
|
||
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
||
|
|
else boot();
|
||
|
|
})();
|