5.7 KiB
0005: Playback is absolute instants reconciled forward, never a countdown
Context
The proven behaviour this preserves comes from the legacy Announcements v2 playback engine, which
ran unattended church screens through outages, restarts, and mid-service edits. Its three hard-won
properties are worth restating, because they are the ones a naive implementation loses:
- Absolute instants, not remaining time. A slide has a
startedAtand adeadlineAt; a timer is only ever a hint about when to look again. - Reconcile forward from the previous authoritative deadline, never from "now". Otherwise a delayed callback shortens nothing and lengthens everything, and drift accumulates all day.
- Catch up arithmetically. A screen that was off for a week must not replay a week of slides.
Decision
One serialized service per channel group
Each group has exactly one playback service owning its persisted state and its timers. Every mutation — a timer firing, an operator command, a provider invalidation — runs serialized against that group, inside one explicit transaction, and produces at most one revision increment. Two concurrent commands cannot interleave into a state neither asked for.
revision increases only on semantic change. An exact no-op — resuming something already
running, activating the timeline already active — commits nothing and broadcasts nothing.
Reconciliation, not simulation
reconcile(group, at) is the single entry point. It reads persisted state and walks it forward to
at:
- If the current slot's deadline is still in the future, nothing happens. This is what makes restart recovery show the same item it was showing, rather than choosing a new one.
- Otherwise the boundary is the previous deadline, not
at. The next slot starts exactly when the last one ended, so a callback that fired 40 ms late costs 40 ms once instead of 40 ms per slide forever. - Eligibility and expiration boundaries are honoured at their exact instants, and a currently showing slot can only be cut short by its own window, never by another slot's.
Catch-up is bounded by boundaries, not by missed slides
When the gap is long, Signage advances the slot cursor arithmetically: it computes the effective duration of each enabled, eligible slot from what it already knows — the slot override, or the provider's registered default, or the native-image default — sums one pass of the pattern, and skips whole passes with a division. Only the slot it actually lands on is resolved.
That is the property that keeps a provider's rotation sane: catching up a week does not call
resolveNext ten thousand times and does not consume a provider's no-repeat pool. Fast-forwarding
stops at the first eligibility boundary in the interval, exactly as v2 did, so a schedule change
inside the outage is still honoured.
Transport
| Control | Meaning |
|---|---|
| Pause | Freeze the current slot. Persist the remainder; clear start and deadline. |
| Resume | deadline = now + remainder; start = deadline − duration, so the reconstructed timeline stays consistent. |
| Static hold | Show one native image while preserving the normal cursor and remainder underneath. Releasing it resumes from that remainder. |
| Next / Previous | Move over actually resolved playback: previous re-shows the previous resolved selection from history, not "the slot before this one". |
| Blackout | Cover the output with black. The hidden timeline keeps running, keeps its deadlines, and keeps resolving. |
| Reload | Refresh provider availability and re-resolve upcoming selections without disturbing the current frame or its deadline. |
| Activate now / at next boundary | Immediate, or queued as pending and applied at the next safe slot boundary. |
A provider's internal React behaviour may change anything it likes during a slot; it may not extend that slot's authoritative deadline. The deadline was set by Signage before the renderer mounted.
Realtime carries revisions, not content
Signage publishes compact authoritative state — group id, revision, slot identity, absolute start/deadline, transport flags — and nothing large. Timelines, histories, and renderer payloads are fetched over authorized HTTP, keyed by revision, so a client that sees a revision it does not have refetches rather than waiting. That closes the GET-then-subscribe race without a second transport, and keeps every publication far inside the platform's payload ceiling.
Signage introduces no socket, no broker, and no second realtime transport. It publishes through the platform's module realtime contract and nothing else.
Consequences
- Server authority is unambiguous: on reconnect, the server's state wins immediately, and a client's local cursor is discarded rather than reconciled.
- Timers are never load-bearing. Losing one costs a late transition, not a wrong one.
- Two displays that connected minutes apart agree, because they both read the same absolute instants.
- A long outage costs one reconciliation, not one per missed slide.
Alternatives
- A countdown per slide. Rejected: it drifts, and it cannot be restored after a restart.
- Reconcile from
now. Rejected: every late callback permanently lengthens the loop. - Replay every missed slide to catch up. Rejected: it burns a provider's rotation and can take longer than the outage.
- Publish full state over realtime. Rejected: a large timeline exceeds the payload ceiling, and a screen that missed one message would be wrong until the next one.