99 lines
5.7 KiB
Markdown
99 lines
5.7 KiB
Markdown
# 0005: Playback is absolute instants reconciled forward, never a countdown
|
||
|
||
- Status: Accepted
|
||
- Date: 2026-08-27
|
||
- Related: [0004](0004-channel-groups-and-one-clock.md),
|
||
[0006](0006-display-rendering-and-offline-continuation.md)
|
||
|
||
## 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:
|
||
|
||
1. **Absolute instants, not remaining time.** A slide has a `startedAt` and a `deadlineAt`; a timer is
|
||
only ever a hint about when to look again.
|
||
2. **Reconcile forward from the previous authoritative deadline**, never from "now". Otherwise a
|
||
delayed callback shortens nothing and lengthens everything, and drift accumulates all day.
|
||
3. **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.
|