- openapi.yaml: use the numeric entity < so Redoc renders
'read < write < full' in the scope-ladder nav + section header,
instead of the double-escaped 'read < write < full'
- README: add a centered dashboard hero image + quick-links row
(Live demo / API reference / Self-hosting guide / Discord) at the top,
and refresh the Support section
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile advanceTimer on mode enter/exit via reconcileAdvanceTimerForMode in applyWallMode/applyGroupSync — fixes the group-entry zombie timer and the solo-exit frozen image. Closes#200.
Designs round-trip for visual editing: store the design source in the widget config, reroute Edit to the designer, reconstruct legacy HTML-only designs, PUT the original in place.
Pre-release review follow-up to #204: fixes the Android superseded-wipe playlist wedge + GL leak, and adds the stale-item guard to web/Tizen renderVideoBuffered.
Compositor panel-ring board scroll (smooth on Blink+Gecko, no blank-on-refresh), a per-device frame-rate diagnostic widget + dashboard card, and web/Android/Tizen device-id passthrough to widget render URLs.
* fix(web-player): buffered widget swap + solo-board hold to end directory-board black flicker
A fullscreen widget (e.g. a solo directory board) re-rendered on the advance timer:
renderContent tore the container down to black (innerHTML='') BEFORE the replacement
iframe finished loading, and a single/only-active widget re-advanced to itself every
duration_sec — so the board cycled black every few seconds. That reload was ALSO the
only thing refreshing the board's static, server-rendered data, so simply holding it
in place would freeze the data.
- Buffered swap: build the new widget iframe hidden OVER the current content and reveal
it on 'load', then tear down the outgoing content — no black frame on any widget
transition. On a load timeout, keep the last-good board and discard the dead hidden
frame via a shared cleanup path (don't reveal a blank frame); a transient server blip
self-heals on the next refresh.
- Solo/held widget (nextActiveIndex === currentIndex): hold in place and refresh its
DATA on a decoupled interval (WIDGET_SOLO_REFRESH_MS = 60s) via the buffered swap,
instead of re-querying the DB + re-rendering full HTML every duration_sec, fleet-wide.
Scoped to non-wall fullscreen widgets; wall+widget keeps the legacy path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web-player): route held directory-board refresh through nextItem (schedule-aware)
Follow-up to the buffered widget swap: the solo/held board refreshed via a bespoke
self-rescheduling loop that never re-evaluated the schedule — so a board could outlive
its daypart, and a newly-active sibling item was never picked up (the player stuck on
the board). Delete the duplicate loop entirely and advance via nextItem in both the
held (WIDGET_SOLO_REFRESH_MS cadence) and rotating (duration) cases: nextItem
re-evaluates the schedule every cycle and re-renders the held board through the buffered
swap (still no flash), and drops the duplicate code path that caused the bug.
Verified: the timer-lifecycle harness (6 scenarios / 68 assertions) still passes,
including widget->video transition and the leak/timer-count checks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A duration_sec=0 assignment (especially a widget) made the player schedule a 0ms
auto-advance, self-looping and black-screening the TV. #198 fixed the Android
client; this hardens the source so a 0 can't be stored or served in the first
place. assignments.js accepted an explicit 0 on the POST/PUT/copy write paths —
the `= 10` destructure default only covers an ABSENT field, not an explicit 0.
- Add normalizeDuration() and apply it on all assignment write paths so any
missing/invalid/<1 duration is floored to the 10s default.
- Add an idempotent migration repairing existing playlist_items rows with
duration_sec IS NULL OR < 1 (fixes the live widget on existing DBs).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A solo fullscreen widget (or image) with duration_sec=0 hit an unclamped
scheduleAdvance(item.durationSec * 1000L) in PlaylistController.playCurrentItem,
scheduling a 0ms auto-advance. For a single-item playlist next() re-selects the
same item, so it re-played every looper tick (~20x/sec) — black-screening the TV
and locking the UI (couldn't even reach home). Triggered when a schedule collapses
the playlist to a single always-on duration-0 widget.
Use slotMs() (the max(1, duration||10) contract shared with the web/Tizen players)
so a zero/negative duration floors to 10s. Also floor scheduleAdvance() itself to
MIN_ADVANCE_MS (500ms) as a backstop so no future path can busy-loop the main thread.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The vertical auto-scroll is a CSS keyframe that translates the track by
cycleH = baseH + GAP_PX and loops linear infinite. GAP_PX was 100 but the actual
.gap element between the content and its seamless clone is 120px, so every cycle
the reset landed 20px off — a visible jump/stutter once per loop.
Set GAP_PX = 120 to match the .gap CSS, and drive each gap element's height from
GAP_PX inline so the scroll math and the rendered gap can never drift again.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to #195. The CORP fix there landed on routes/content.js `/:id/file`, but that
handler is SHADOWED: server.js registers a public `app.get('/api/content/:id/file')`
(and `/thumbnail`) BEFORE the auth-gated content router, and that public route (gated by
playlist/widget reference) is what actually serves widget logo/background images. So the
header never changed on the wire — origin still returned CORP: same-origin and the player's
sandboxed (opaque-origin) widget iframe kept getting NS_ERROR_DOM_CORP_FAILED / 0 bytes.
Set Access-Control-Allow-Origin: * + Cross-Origin-Resource-Policy: cross-origin on the real
public routes in server.js: /file, /thumbnail (local), and the remote-thumbnail proxy.
Revert the now-dead content.js edit so the fix lives only where the bytes are served.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(widgets): bulk import for the directory board (JSON / CSV / TSV / text)
Adds an "Import from JSON / CSV" button to the directory-board editor. Paste JSON
(the { company, tenantsByFloor, advertisements, backgroundImages } shape plus
categories[]/floors[]/flat-array/bare-floor-map variants), a CSV/TSV/pipe/semicolon
table (with or without a header — vacant/yes/1 => available, quoted fields), or a
sectioned "room name" text list, and it auto-fills title, footer, floors->categories,
rooms/names/details/availability, and background-image URLs. "Replace / append" toggle.
Tolerant key matching (room/suite/unit/id, name/tenant/company, details/subtitle, …);
warns on things it can't use (bare-filename background images, headerless columns).
parseDirectoryImport is pure and was unit-tested in node across every format.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(widgets): directory board — logo replaces title, and images load on the player
Two on-screen bugs on the directory board:
1. A logo did not remove the title text — both rendered, stacking the wordmark over
the name. renderDirectoryBoard (and the directory-search header) now gate the title
h1 behind !logoSrc, so a logo replaces the title. New render test guards it.
2. Logo + background images did not show on the player (NS_ERROR_DOM_CORP_FAILED,
0 bytes). The player embeds widgets in a sandbox="allow-scripts" (opaque-origin)
iframe, so /api/content image requests are cross-origin, and the helmet default
Cross-Origin-Resource-Policy: same-origin blocks them. Set CORP: cross-origin (+
ACAO:*) on the content file + thumbnail routes, matching the existing /uploads/content
static route. Content already serves publicly, so no new exposure. Verified in a real
sandboxed iframe: same-origin blocks, cross-origin loads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>