Files
bible-app/mirror-stream.js
T

153 lines
7.1 KiB
JavaScript
Raw Permalink Normal View History

2026-09-24 13:05:20 -04:00
/*
* mirror-stream.js — renderer-side frame source for the phone remote's live mirror.
*
* Main asks for a frame over IPC ('mirror:frame-request'); this module answers with a ready-encoded
* JPEG ('mirror:frame-response'). The picture comes from a getDisplayMedia stream of THIS window
* (main's setDisplayMediaRequestHandler grants it with no picker), which the compositor feeds
* directly — grabbing the latest video frame and encoding at ~860px takes ~20-40ms, where main's
* capturePage() has a fixed ~120ms GPU-readback floor on the 4K stage window. That difference is
* the phone mirror going from ~8fps to ~20fps.
*
* The stream is LAZY and self-stopping: it starts on the first request (so it costs nothing while
* no phone is mirroring) and stops after ~8s without requests. If getDisplayMedia cannot work here,
* one fatal reply tells main to stop asking and use capturePage permanently — the mirror keeps
* working either way, just slower.
*/
(function () {
'use strict';
var ipc = window.electron && window.electron.ipcRenderer;
if (!ipc || typeof ipc.on !== 'function' || typeof ipc.send !== 'function') return;
var video = null; // hidden <video> playing the self-capture stream (never added to the DOM)
var stream = null;
var starting = null; // in-flight getDisplayMedia promise, so bursts share one start
var fatal = false; // self-capture is impossible here — reported to main once per request
var canvas = null, ctx = null;
var lastUse = 0, stopTimer = 0;
/*
* Change detection: a static page paints nothing, so re-encoding and re-sending the identical
* JPEG twenty times a second would burn room Wi-Fi for zero pixels. requestVideoFrameCallback
* ticks only when the capture stream delivers a NEW frame; a request that arrives with no new
* tick (and the same size/quality as last time) is answered "unchanged" — a few bytes instead of
* ~100 KB. Scrolling and drawing repaint constantly, so live content still streams at full rate.
*/
var canDedup = typeof HTMLVideoElement !== 'undefined' && 'requestVideoFrameCallback' in HTMLVideoElement.prototype;
var frameTick = 0, sentTick = -1, sentW = 0, sentQ = 0;
/*
* Scroll position OF THE FRAME, sampled on each new capture frame (requestVideoFrameCallback fires
* as the compositor delivers it, so the reading is within one frame of what the picture shows).
* The phone gets it with every JPEG and can then place the frame's content EXACTLY — its old
* guess ("whatever I had scrolled when I asked") was off by the request's latency, which jittered
* frame-to-frame and made scrolled text visibly wobble. sMax rides along so the phone can stop
* scrolls dead at the real end of the chapter instead of rubber-banding.
*/
var tickScroll = null, tickScrollMax = null, tickInk = 0;
function sampleScroll() {
try { tickInk = window.__inkVersion || 0; } catch (_) { tickInk = 0; }
try {
var app = window.bibleApp;
var id = app && app.activeTabId;
var ra = id ? document.getElementById('readingArea' + id) : null;
if (ra && ra.clientHeight > 0 && window.innerHeight > 0) {
tickScroll = ra.scrollTop / window.innerHeight;
tickScrollMax = Math.max(0, ra.scrollHeight - ra.clientHeight) / window.innerHeight;
} else {
tickScroll = null; tickScrollMax = null;
}
} catch (_) { tickScroll = null; tickScrollMax = null; }
}
function watchFrames() {
if (!canDedup || !video) return;
var loop = function () {
frameTick++;
sampleScroll();
if (video && video.srcObject) video.requestVideoFrameCallback(loop);
};
video.requestVideoFrameCallback(loop);
}
function stopStream() {
try { if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); } catch (_) {}
stream = null;
if (video) { try { video.srcObject = null; } catch (_) {} }
}
function armStopTimer() {
lastUse = Date.now();
if (stopTimer) clearTimeout(stopTimer);
stopTimer = setTimeout(function () {
if (Date.now() - lastUse >= 7500) stopStream();
}, 8000);
}
function ensureStream() {
if (fatal) return Promise.reject(new Error('self-capture unavailable'));
if (stream) return Promise.resolve();
if (starting) return starting;
starting = navigator.mediaDevices.getDisplayMedia({
audio: false,
video: { frameRate: { ideal: 24, max: 30 } }
}).then(function (s) {
starting = null;
stream = s;
// If the track dies (display change, session teardown), drop it — the next request restarts.
try { s.getVideoTracks()[0].addEventListener('ended', stopStream); } catch (_) {}
if (!video) {
video = document.createElement('video');
video.muted = true;
video.playsInline = true;
}
video.srcObject = s;
sentTick = -1; // new stream: nothing sent from it yet
return video.play().then(watchFrames);
}).catch(function (e) {
starting = null;
stopStream();
fatal = true;
console.warn('[MIRROR] getDisplayMedia self-capture failed:', e && e.message);
throw e;
});
return starting;
}
ipc.on('mirror:frame-request', function (_evt, req) {
var id = req && req.id;
armStopTimer();
ensureStream().then(function () {
if (!video || !video.videoWidth) throw new Error('stream warming up');
var w = Math.max(240, Math.min(Number(req.width) || 540, video.videoWidth));
var h = Math.max(1, Math.round(video.videoHeight * (w / video.videoWidth)));
var q = Math.max(0.25, Math.min((Number(req.quality) || 55) / 100, 0.85));
if (canDedup && frameTick === sentTick && w === sentW && q === sentQ) {
ipc.send('mirror:frame-response', { id: id, unchanged: true });
return;
}
var tickNow = frameTick;
// Frame callbacks only fire when the picture changes; at rest nothing has sampled yet, and at
// rest "now" is exact anyway — so fall back to sampling at encode time.
if (!canDedup || tickScroll === null) sampleScroll();
var sc = tickScroll, sm = tickScrollMax, iv = tickInk;
if (!canvas) {
canvas = document.createElement('canvas');
ctx = canvas.getContext('2d', { alpha: false });
}
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
ctx.drawImage(video, 0, 0, w, h);
sentTick = tickNow; sentW = w; sentQ = q;
canvas.toBlob(function (blob) {
if (!blob) { ipc.send('mirror:frame-response', { id: id, error: 'encode failed' }); return; }
blob.arrayBuffer().then(function (ab) {
ipc.send('mirror:frame-response', { id: id, buf: new Uint8Array(ab), w: w, h: h, sc: sc, sm: sm, iv: iv });
}).catch(function () {
ipc.send('mirror:frame-response', { id: id, error: 'buffer read failed' });
});
}, 'image/jpeg', q);
}).catch(function (e) {
// "warming up" is a soft miss (main falls back for this frame); a fatal error stops the asking.
ipc.send('mirror:frame-response', { id: id, error: String(e && e.message || e), fatal: fatal });
});
});
})();