'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 };