- 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>
The base .modal had no max-height/overflow on desktop — only the mobile media
query capped it — so a tall modal (e.g. the directory-board widget editor with
many tenant entries) grew past the screen with no scroll, stranding the lower
entries and the Save button off-screen ("unusable").
Cap .modal to 90vh and lay it out as a flex column so .modal-body becomes the
scroll region (flex + min-height:0 + overflow-y:auto) while the header and footer
(Cancel / Save) stay pinned and always reachable. Moved the cap onto the base
rule and dropped the now-redundant overflow from the mobile override.
Shared across all modals (all use the header/body/footer structure); short modals
are unchanged since max-height is a ceiling, not a fixed height. Verified in real
Chrome at a 700px viewport: modal capped to 630px, body scrollable (overflow=auto),
Save footer on-screen. CSS-only, no JS.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The Tizen .wgt player black-flashed between IMAGE items on slow decode HW
(Samsung OM55B / SSSP). Root cause: playCurrent() called clearStage() before
renderImage() set img.src, so the stage was empty (black) until the new image
decoded. Images had no decode-gated double-buffer — video gained one in #167
(938a43a), which made the always-present image flash conspicuous by contrast.
renderImage has been byte-identical since the first Tizen commit, so there was
nothing to revert; the buffer had to be ADDED, mirroring the video path.
- preloadImage()/_takePreloadImage()/_releasePreloadImage(): one-ahead,
image-only decode-gated buffer modeled on preloadVideo()/_takePreload().
Detached <img>, src set, warmed via HTMLImageElement.decode() (feature-
detected — onload/complete fallback for Tizen 5.0 / SSSP6). Warmed when the
current image begins its dwell and from the group-sync boundary tick.
- renderImage now SWAPS: take the pre-decoded <img> (or decode a fresh one) and
only THEN clearStage()+append, in one synchronous block — the compositor
never sees an empty stage. Never clear-then-load on the image path.
- Scoped to images only: playCurrent() skips the up-front clearStage() solely
for image targets (same branch order as the dispatch); video/youtube/widget
keep their pre-dispatch clear untouched. onerror and decode() rejection route
to skipSoon(); stale-index guard blocks mounting a stale decode over the
current item after next()/gotoIndex/load(); one-ahead with stale release on
index move, load(), stop(), and group-sync exit. #A1 single-item heal intact.
Tests:
- server/test/tizen-image-blackflash.test.js (new, 4/4): loads the real
player.js in a vm context with a test-controllable decode() Promise and proves
the invariant — across image->image the #stage is NEVER without a mounted
<img> (old element held until the new image's decode()/onload resolves, then
swap). Covers decode() supported, decode() absent (onload fallback), and
broken-image (decode-reject / onerror) -> skipSoon. Proven to VIOLATE on the
old clear-then-load ordering and HOLD on the fix.
- server/test/pip-overlay.test.js: the decode-gate makes image mount async,
which broke its older shim (no decode()/onload/complete). Teach the shim
element complete/naturalWidth so renderImage takes its synchronous
complete-fallback branch and mounts. Test-only.
Full server suite 486/486. node -c clean. Headless proves the DOM ordering
invariant (the flash's precondition); final black-frame sign-off needs a real
OM55B panel (manual steps in the test-file header).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A fresh unclaimed player that reconnects (same fingerprint) INSIDE the server's
~5s deferred-offline grace hit a false 'active on another connection' reclaim
reject, then collided on UNIQUE(devices.pairing_code) on the fall-through INSERT
and wedged unclaimed with no content. Real trial customer (web player) hit it.
server/ws/deviceSocket.js:
- Fix A (guard): gate the liveConn reclaim reject on !inDeferredOffline
(pendingOfflines.has(id)). A device mid-deferred-offline is a zombie, not live,
so a same-fingerprint reconnect is a legit reconnect, not a hijack. A genuinely
live socket (never disconnected -> no pending-offline) still rejects a cloned
fingerprint -> anti-hijack boundary preserved (documented).
- Fix B (idempotency): when the unclaimed old row holds the SAME pairing_code the
reconnecting player presents, ADOPT/refresh it (mirror the claimed-reclaim path,
but no device:paired) instead of INSERT-colliding. Differing-code case unchanged.
- deferOffline is NOT shrunk (it exists to prevent transient-blip flapping).
server/player/index.html:
- The cold-boot flap source: an unfiltered pageshow handler ran verifyLivenessSoon()
on every load, opening+registering a socket early, which the boot connect() then
tore down and rebuilt (connect->register->disconnect->reconnect). Guard it with
ev.persisted (mirror the pagehide guard) so only real bfcache restores trigger it.
server/test/pairing-race.test.js:
- Forces the race against the real socket server (log-gated reconnect inside the
deferred-offline window), asserts no false reject / no UNIQUE collision / single
claimable row; + a hijack case asserting a cloned fingerprint on a genuinely-live
display is still rejected. Web- and android-shaped fingerprints. Fails 2/4 on
pre-fix code, 4/4 with the fix.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The hidden-settings PIN box (2x back) and the change-server URL dialog couldn't be
typed into on kiosk devices: a plain AlertDialog shown over the player's
SYSTEM_UI_FLAG_IMMERSIVE_STICKY activity never gains window focus, so the soft
keyboard doesn't attach and the EditText gets no cursor. On a panel/Fire TV with
no hardware keyboard that means the PIN can't be entered at all. Pre-existing
(showPinDialog unchanged since it was added; immersive flags since the initial
release) — surfaces on tier-0 kiosk devices where immersive is active.
Fix: shared showImeDialog() applies the standard immersive-dialog IME workaround —
mark the dialog NOT_FOCUSABLE before show() (so it doesn't steal focus and reset
the activity's immersive flags), mirror the immersive systemUiVisibility onto the
dialog, then clear NOT_FOCUSABLE after show() so the IME can attach, force
SOFT_INPUT_STATE_ALWAYS_VISIBLE, and requestFocus the field. Routed both the PIN
and change-server dialogs through it.
Compiles (:app:compileDebugKotlin). IME behavior needs a real tier-0 device to
confirm (can't be exercised headless).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Symptom 1's 'stuck on first load, fixed by toggling the playlist' is stale
download backoff. On a fresh device the first downloads fail while the link is
settling; DownloadCoordinator escalates an exponential backoff (15s..5min cap),
and ensure() then SKIPS those items. The 60s playlist refresh re-fires
onPlaylistUpdate but ensure() still skips them, and backoff was only ever cleared
by forget() (content-delete) — never by a re-assignment. So the item stays stuck
until the 5-min window happens to lapse; toggling the playlist is just a manual
way to wait it out.
Fix (storm-safe — neither reset fires on the routine same-playlist 60s refresh):
1. DownloadCoordinator.resetBackoff(id) / resetAllBackoff() — clear attempts +
nextAttemptAt but KEEP inFlight (single-flight preserved, no duplicate .part).
2. onPlaylistUpdate resets backoff for each item ONLY when the content-id
signature changed (first load, reassignment, toggle-back), then ensures — so a
genuine (re)assignment retries immediately. Same-signature 60s refresh -> no
reset -> the retry-storm guard stays intact.
3. Network onAvailable (was onLost-only) -> resetAllBackoff() + requestPlaylistRefresh,
so content that failed while the link was settling retries the moment real
connectivity arrives.
Tests: DownloadCoordinatorTest — resetBackoff/resetAllBackoff re-attempt a
backed-off item before the clock advances; existing backoff/single-flight tests
unchanged. :app:testDebugUnitTest green.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>