Files
ProdDash/modules/propresenter-timers/phase.js
T

114 lines
5.0 KiB
JavaScript
Raw Normal View History

'use strict';
/**
* Phase lock for whole-second timer reports (the ProPresenter poller).
*
* ProPresenter reports a running timer as whole seconds — trunc(value):
* "12:34" means the true remaining time is anywhere in [754 s, 755 s). The
* old poller re-anchored a tile's local count at the bottom of that interval
* on every poll, so the displayed digit ran up to a second ahead and then
* got yanked back a step whenever a fresh poll happened to sample the next
* flip later than the last one did — the poll clock and ProPresenter's
* second boundaries drift through each other, so tiles flickered
* "…12:33 → 12:34 → 12:33…" about once a second.
*
* This tracker turns the quantized reports into ONE stable anchor
* { valueMs, at } — the continuous value at a fixed instant — which the
* client extrapolates from. Rules:
*
* - The only real information in a poll is a FLIP: the report stepping one
* second in the direction of travel. At the flip instant the true value
* equals the interval boundary just crossed, and the flip happened between
* the last two polls, so their midpoint dates it to within half a poll.
* - While a poll's report matches what the anchor predicts (within TOL_MS
* around the reported second's interval), the anchor HOLDS — the same
* numbers are republished, so consumers count along one steady clock
* instead of a freshly re-phased one per sample.
* - A report the anchor can't explain re-anchors: at the flip boundary when
* this poll saw a clean one-second flip, else mid-interval (first sight,
* an operator reset/seek, a resume, or clock drift past TOL_MS).
* - Before the first flip the anchor sits mid-interval: at most ±½ s off,
* and the first flip (≤1 s away) locks it — instead of the old guaranteed
* whole-second bias one way.
*
* trunc() semantics also cover overrun: a countdown at −12.4 s reports
* "-12", so a negative countdown's interval is (W−1 s, W] and its flip
* boundary is W itself, where a positive countdown's are [W, W+1 s) and
* W+1 s. Around zero ("00:00:00" covers (−1 s, 1 s)) the flip in from above
* crosses +1 s.
*/
/** How far a report may sit outside the anchor's prediction before it means
something real. A flip is dated to the midpoint of two polls, so its own
error bound is ±½ poll (~±300 ms at the 500 ms cadence, more when a poll
runs long); the tolerance must sit ABOVE that or consecutive flips
re-anchor each other forever, and safely below a full second so a real
one-second seek still registers. */
const TOL_MS = 450;
/** The continuous-value interval implied by a whole-second report. */
function intervalFor(kind, wholeMs) {
if (kind === 'countdown') {
if (wholeMs === 0) return { lo: -1000, hi: 1000 };
if (wholeMs < 0) return { lo: wholeMs - 1000, hi: wholeMs };
}
return { lo: wholeMs, hi: wholeMs + 1000 };
}
/** The continuous value at the instant a running timer's report flips TO wholeMs. */
function boundaryFor(kind, wholeMs) {
if (kind === 'countdown') return wholeMs <= 0 ? intervalFor(kind, wholeMs).hi : wholeMs + 1000;
return wholeMs;
}
/**
* One tracker per timer. Feed one observation per poll; get back what to
* publish: `{ valueMs, asOf }` — a continuous value at a fixed instant,
* stable across polls while the reports stay consistent with it. Not
* running (or no value): the report is returned verbatim, dated at the poll.
*/
function createTimerPhase() {
let anchor = null; // { valueMs, at }
let kind = '';
let lastWhole = null;
let lastAt = 0;
let wasRunning = false;
function observe({ kind: rawKind, running, wholeMs, pollAt }) {
const k = rawKind === 'countdown' ? 'countdown' : 'elapsed';
const dir = k === 'countdown' ? -1 : 1;
const flip = Boolean(running) && wasRunning && k === kind && lastAt > 0
&& lastWhole !== null && wholeMs !== null
&& wholeMs - lastWhole === dir * 1000;
const prevAt = lastAt;
lastWhole = wholeMs;
lastAt = pollAt;
wasRunning = Boolean(running);
if (k !== kind) anchor = null;
kind = k;
if (!running || wholeMs === null) {
anchor = null;
return { valueMs: wholeMs, asOf: pollAt };
}
const { lo, hi } = intervalFor(k, wholeMs);
// A mid-interval anchor is provisional — a guess up to ±½ s off that the
// tolerance check may fail to catch for a long time (its error only
// shows at a poll landing more than TOL_MS past a flip). The first
// clean flip is strictly better evidence (±½ poll), so it always wins.
if (anchor && !(anchor.provisional && flip)) {
const predicted = anchor.valueMs + dir * (pollAt - anchor.at);
if (predicted >= lo - TOL_MS && predicted <= hi + TOL_MS) {
return { valueMs: anchor.valueMs, asOf: anchor.at };
}
}
anchor = flip
? { valueMs: boundaryFor(k, wholeMs), at: (prevAt + pollAt) / 2, provisional: false }
: { valueMs: (lo + hi) / 2, at: pollAt, provisional: true };
return { valueMs: anchor.valueMs, asOf: anchor.at };
}
return { observe };
}
module.exports = { createTimerPhase, TOL_MS };