112 lines
6.3 KiB
Markdown
112 lines
6.3 KiB
Markdown
# 0006: Two permanent layers, real readiness, and an offline continuation that never becomes authority
|
|
|
|
- Status: Accepted
|
|
- Date: 2026-08-27
|
|
- Related: [0005](0005-authoritative-playback.md), Ekklesia
|
|
[ADR 0043](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (display offline code)
|
|
|
|
## Context
|
|
|
|
The legacy `v1` renderer rebuilt image elements per slide and faded the outgoing frame to
|
|
transparent, which let the black page background show through the middle of every transition. `v2`
|
|
fixed it with two permanent layers and never fading the outgoing one, and that fix is the single most
|
|
visible quality difference on a real television.
|
|
|
|
Signage additionally has to transition between things that are not images at all: a provider's live
|
|
React renderer, mounted through the platform's contribution ABI.
|
|
|
|
## Decision
|
|
|
|
### Two permanent scene layers, and one direction of fade
|
|
|
|
The display owns exactly two full-screen layers, created once and never rebuilt. For every
|
|
transition:
|
|
|
|
1. Resolve and mount the incoming content in the **hidden** layer — a native image, or a contributed
|
|
renderer mounted through `context.contributions.mount(...)`.
|
|
2. Wait for **actual readiness**: `img.decode()` for an image, and the renderer's own `mount`
|
|
resolution for a contribution, which the contribution ABI defines as asynchronous precisely so a
|
|
renderer can say "not yet".
|
|
3. Discard completion belonging to a stale generation. A newer transition always wins.
|
|
4. Keep the outgoing layer **fully opaque** the entire time.
|
|
5. Raise the prepared incoming layer above it at opacity zero.
|
|
6. Fade **only the incoming layer** in.
|
|
7. Unmount and release the old layer after the fade completes.
|
|
|
|
Both layers are never faded at once, so the black root can never contribute to the composite.
|
|
|
|
### Failure keeps the last good frame
|
|
|
|
If incoming content cannot be decoded or mounted: keep the frame that is already on screen, record a
|
|
bounded structured error, mark **that selection** locally unusable, and continue. A broken item is
|
|
never retried in a tight loop, and one broken item never blanks a screen.
|
|
|
|
An individually empty provider slot is different, and is skipped immediately with no error at all —
|
|
that is the provider saying "nothing right now", which is ordinary.
|
|
|
|
Only when there is neither renderable timeline content nor a cached good frame does the display show
|
|
its built-in slate: restrained, non-flashing bars and the words *Content unavailable*, carrying no
|
|
exception, URL, payload, credential, database detail, or identifier. Detail belongs in Signage's
|
|
sanitized management telemetry, where an already-authorized operator can read it.
|
|
|
|
A deliberate blackout is not a failure and never reaches the slate: it is pure black.
|
|
|
|
### The management preview is the same code
|
|
|
|
The Dashboard's live preview mounts the identical renderer host, the identical native-image
|
|
presentation resolver, and the identical layer transition. It differs in exactly two ways: it does not
|
|
register a viewer, and it sends no heartbeat. A preview that reimplemented the transition would drift
|
|
from what the wall actually shows, which makes it worse than no preview.
|
|
|
|
### Offline continuation, and its honest limit
|
|
|
|
Once mounted, a display survives losing the host. Signage persists its own domain state — viewer
|
|
identity, the last authoritative snapshot, resolved selections and revisions, audience payloads, the
|
|
timing anchor, transport state, renderer contribution identity, and the most recent safe error — in
|
|
**IndexedDB**, and native image bytes in **Cache Storage**. Ordinary HTTP cache is not an asset
|
|
manager and is not used as one.
|
|
|
|
While offline the display continues deterministically from the last authoritative anchor, applying
|
|
cached scheduling and expiration, preserving pause, blackout, static hold, and remaining duration,
|
|
skipping explicitly expired or withdrawn content, keeping the last good frame when expected content
|
|
is unusable, and never inventing provider data. Catch-up is arithmetic, mirroring the server's own
|
|
rule, so a long outage does not replay linearly.
|
|
|
|
**A local cursor is never reported back as authority.** On reconnect the server's state wins
|
|
immediately; incoming artifacts and data are staged before the current frame is replaced, no missed
|
|
slides are replayed, and superseded payloads and image bytes are pruned only after the new snapshot
|
|
is usable.
|
|
|
|
### The limit this cannot fix, stated rather than papered over
|
|
|
|
**A cold browser start while Ekklesia is unreachable cannot mount Signage at all.** The platform's
|
|
display service worker caches the shell and the declared code, deliberately not the display
|
|
descriptor — a descriptor is authorization-bearing, and caching one would let a revoked screen keep
|
|
showing content. Signage cannot and must not work around that: everything above applies to a display
|
|
that is **already mounted** when its network fails, which is the overwhelmingly common case (a router
|
|
reboot, an uplink drop, a switch power-cycle).
|
|
|
|
True offline cold start requires something only Core can provide: an authorized offline display
|
|
descriptor or lease with its own expiry and revocation. That is tracked as an Ekklesia prerequisite,
|
|
not as Signage work, and this repository does not claim the capability it does not have.
|
|
|
|
## Consequences
|
|
|
|
- Transitions are visually identical for images and for live React renderers, because readiness is
|
|
awaited rather than assumed for both.
|
|
- A provider renderer that throws costs one slide, not the screen.
|
|
- The preview and the wall cannot disagree.
|
|
- A screen that reboots during an outage shows Ekklesia's own offline state and recovers by itself
|
|
when connectivity returns, but shows no Signage content until it can resolve its descriptor.
|
|
|
|
## Alternatives
|
|
|
|
- **Crossfade both layers.** Rejected: it is the `v1` bug, and it is visible on every transition.
|
|
- **Treat `onload`, or a fixed delay, as readiness.** Rejected: a large image paints mid-fade, and a
|
|
renderer that is still fetching a font shows a flash of the wrong thing.
|
|
- **Cache the display descriptor so a cold start works offline.** Rejected: it is a stored permission
|
|
decision, and a revoked screen would keep showing content. This is the platform's rule, and Signage
|
|
respecting it is the point.
|
|
- **Use the HTTP cache for image bytes.** Rejected: no eviction control, no way to prune superseded
|
|
renditions deliberately, and no way to know what is actually available offline.
|