Commit Graph
10 Commits
Author SHA1 Message Date
MediaandClaude Opus 5 423056e885 Implement Phase 6A visual management dashboard
Build the desktop management surface at / while leaving the verified
playback engine and display renderer untouched. Opening the dashboard goes
straight to the playlist that is actually active, with slide imagery
dominating rather than an admin table.

Playlist selection and activation are deliberately separate. Choosing a
playlist only changes what is being edited; an inactive playlist offers an
explicit Activate Playlist action. Activation remains the server's
decision: when playback queues the request to the current slide boundary
the header shows STARTING SOON and keeps the old playlist marked active
until authoritative state promotes it, so the UI never fakes activation
timing.

Entries render as large tiles in a responsive grid where position is the
sequence, so no ordinal numbers are stamped on slides. Names drop file
extensions and are uppercased in Roboto for presentation only; stored
display names keep their case. Status rings are green, gray, amber, or red
and are always accompanied by the same status as text, so nothing depends
on colour alone. Clicking a tile toggles that playlist entry only, never
the library asset or its use in other playlists. Tiles reorder by drag
through dnd-kit, persisting through the existing playlist API and snapping
back to the authoritative order if the server rejects the change. React
identity is always the stable PlaylistEntry UUID, never an index.

Uploads accept PNG, JPEG, and WebP from the picker or a drop anywhere on
the page, run sequentially so appended order matches selection order, and
report per-file state without blocking the dashboard. Content duplicates
and assets already in the playlist are surfaced rather than duplicated.
The Library panel browses and reuses existing assets, and permanent assets
are never shown as stale.

The live preview reuses the display's own presentation resolver and scene
stylesheet, which are extracted to a shared module so the miniature and
the signage output cannot drift apart. It takes on none of the kiosk's
authority: no worker, no offline rotation, no heartbeat, no identity.

Management is desktop-only below 768px, a restriction /display never
inherits.

Compose now attaches only the web service to the external tabernet network
as announcements-web, keeping the API and object storage private.

Verified with 200/200 backend, 20 client unit, and 44 Playwright tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 21:33:04 -05:00
MediaandClaude Opus 5 ba678d08d5 Complete Phase 5B offline display resilience
Make the /display kiosk survive losing its infrastructure, then surrender
authority cleanly when it returns. The Phase 5A renderer is unchanged: this
builds on its two permanent scene layers, explicit Cache Storage, IndexedDB,
Service Worker, timing anchors, and structured errors.

While connected the server is authoritative. While disconnected the display
becomes a deterministic local continuation of the last authoritative state
it persisted, and the moment the server speaks again the local cursor is
abandoned rather than reconciled or reported back.

A small client evaluator resolves offline playback from the persisted
manifest and clock anchor. Catch-up is arithmetic rather than iterative:
whole rotations are skipped between eligibility boundaries, so a month
offline resolves in milliseconds. Mixed durations, wraparound, [start, end)
schedules, and expirations are evaluated locally, and a null expiration
stays permanent. Mode off keeps the hidden cursor advancing, pause holds
its slide and remainder, static holds until the cached metadata says the
asset genuinely expired, and an activation queued for an unknown playlist
holds the last good frame instead of continuing into a playlist that is
being replaced. Contract tests run this evaluator and the real Phase 3B
engine over identical scenarios and assert the same slide, start, and
deadline, so the client tracks the server without refactoring it.

Cold start restores signage from the Service Worker shell, IndexedDB, and
Cache Storage with the entire stack down, keeping the same persistent
display identity.

Validated display error codes now become Home Assistant incidents through
the Phase 4 machinery, keyed per display and condition so a persisting
error reports once and recovers once. Arbitrary browser text stays
telemetry.

Corrects the deployment assumption: the kiosk runs on a separate machine,
so production requires a trusted HTTPS origin and localhost is development
only. Verifying on a real trusted certificate exposed two production bugs
that localhost had hidden — behind a reverse proxy an outage arrives as a
502 rather than a refused connection, and an unbounded shell fetch left a
restarting kiosk blank for the proxy's whole connect timeout. The worker
now treats a gateway error as an outage and bounds that wait.

Verified with 200/200 backend, 20 client unit, and 30 Playwright tests on
a trusted HTTPS origin with no browser security flags weakened.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 20:36:13 -05:00
MediaandClaude Opus 5 147e375702 Implement Phase 5A resilient display renderer
Build the real /display Chromium signage client as a React route on the
existing single-page build. The server remains authoritative; the display
only consumes the verified Phase 3B snapshot and reports telemetry.

The renderer owns two permanent scene layers that are never recreated. A
transition prepares the hidden layer, decodes the incoming image on the
element that will actually be composited, then raises that layer above the
current one at opacity 0 and fades it in while the outgoing layer stays
fully opaque underneath. This removes the v1 defect where two simultaneous
fades let the black root through the composite. Every asynchronous load
and transition carries a generation token, so work finishing after
authoritative state moved on is discarded rather than shown.

A true 16:9 slide fills the frame with no backdrop or shadow; anything
else is contained, centred, and uncropped over a blurred, solid, or black
background with a subtle shadow. Blur uses the global setting and is
overscanned; boolean darkening applies to the background only. Static
targets use the same renderer with the snapshot's global presentation
defaults, and mode off is a topmost gate while the renderer keeps
tracking underneath.

Slide bytes live in an explicit Cache Storage cache with reference
counted object URLs and conservative pruning, never in ordinary HTTP
caching. A new manifest is staged before promotion, so one unfetchable
asset marks only itself unavailable instead of rejecting a playlist. A
small IndexedDB store with explicit version handling holds the persistent
client ID, last known good manifest, timing anchor, and last structured
error, and discards malformed records on read. A narrow service worker
caches only the /display app shell, network-first for navigations, and
explicitly bypasses media, API, and socket traffic.

The display sends a real display:heartbeat every 12 seconds with its
actual state, and reports structured errors as bounded JSON that the
server's existing string validation already accepts; /api/displays parses
them back into an object, so no migration was required.

Verified against an isolated stack through Nginx with 20 Playwright
Chromium tests plus 9 client unit tests, and 187/187 backend tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 19:38:50 -05:00
MediaandClaude Opus 5 ae70bf1dd8 Implement Phase 4 integrations and display telemetry
Add MQTT playback control, durable Home Assistant incident notifications,
and display heartbeat telemetry on top of the verified Phase 3B playback
authority. No playback logic is duplicated: every MQTT command and every
observed condition goes through the existing playback service, and all
integration work happens after the authoritative SQLite commit.

MQTT (server/src/mqtt) subscribes the exact required command topics under
a configurable prefix that defaults to announcements. Durable commands use
QoS 1; next and previous stay at QoS 0 because they are not idempotent and
carry no command ID. Retained authoritative state is published only on the
separate availability, mode/state, and playback/state topics, never onto a
command topic, and only after a mutation commits. A registered last will
reports an ungraceful disappearance, a clean shutdown retracts it, and a
broker outage degrades status while playback, HTTP, and the display
protocol continue untouched.

Notifications (server/src/notifications) use the Phase 1 incident and
outbox tables. A failing condition opens one incident and queues exactly
one notification; continued failure only updates its observation time;
recovery queues exactly one recovery; a later failure is a new episode.
Delivery retries on a documented capped schedule with a stable notification
ID, survives restart, and never blocks playback. With no webhook URL
configured, incidents are still recorded but no delivery work is queued.
Conditions come from storage health transitions, committed playback state,
display heartbeat staleness, and newly failed uploads.

Displays (server/src/displays) ingest validated, bounded display:heartbeat
telemetry keyed by persistent client ID rather than socket ID. Telemetry is
read-only and never touches playback or playlist revisions. Offline is
decided by heartbeat staleness, not by a closed socket, and a startup grace
period keeps an API restart from manufacturing offline alerts. Adds
GET /api/displays and meaningful-change display:status events.

Health and status now report redacted storage, MQTT, notification,
playback, and display state. External integrations degrade status and never
affect readiness, so no integration outage can cause a restart loop. No
MQTT password, S3 credential, webhook URL, or webhook secret is exposed.

Uses the existing Phase 1 schema unchanged; no migration was required.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 22:52:59 -05:00
MediaandClaude Opus 5 993986c25e Complete Phase 3B authoritative playback
Audit, fix, and verify the Phase 3B playback engine under Node 22 with a
full backend suite, project lint/build checks, Docker builds, Compose
validation, and isolated runtime playback verification.

Two concrete correctness bugs were found and fixed, each with a
regression test that fails without the fix:

reconcileRotating() evaluated boundaries against a stale entry. After
fastForwardStable() skipped whole cycles it could move the stable cursor,
but the loop kept using the pre-skip entry, so that entry's schedule end
or expiration cut the successor's slide short and shifted every later
absolute deadline. Re-evaluate the loop when the cursor moves.

resumeHeldEntry() could reconstruct a timeline whose slide_started_at was
in the future. A remainder captured under a longer duration was replayed
verbatim after the effective duration shrank, publishing a snapshot whose
current slide had not started yet and whose remaining time exceeded its
own effective duration. Clamp the remainder to the effective duration.

Two Phase 3A tests built playback_state rows by raw SQL that the service
can never hold: an activation queued behind a playlist with no playable
entry, and a static hold that still carried a live absolute timeline.
Both now drive the authoritative service, keeping their original
assertions about deletion protection and revision bookkeeping.

Record the static-target deletion refusal as a known interim integration
point rather than final behavior; playback already tolerates a deleted
static asset, so Phase 3B does not depend on it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 22:22:03 -05:00
Media 68273d457f WIP: Phase 3B playback engine handoff 2026-08-16 21:50:19 -05:00
Media e2ecdc97e2 Implement Phase 3A playlist management 2026-08-16 20:34:36 -05:00
Media 722c6cf77d Implement Phase 2 asset storage pipeline 2026-08-16 19:59:10 -05:00
Media eb19675a5c Build Phase 1 application foundation 2026-08-16 19:16:45 -05:00
Media 9aab48643b Annoucements 1.5 2026-08-16 08:42:31 -05:00