mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
21 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e4c25c39df |
Describe a portrait video wall as portrait, and stop a wall hiding its screens
#236: the wall canvas was secretly framebuffer space rather than the wall as the audience sees it. Invisible while every panel is the normal way up, and actively misleading the moment one isn't — two portrait-mounted panels standing side by side had to be STACKED VERTICALLY in the editor, with a pre-rotated copy of every video, before the output came out right. It worked, but only after trial and error, and it meant a portrait wall could never reuse content as-is. Each panel now carries a mounting rotation (0/90/180/270 clockwise, the same convention as the per-device orientation setting), the canvas means the physical wall, and the player works out the mapping. The geometry lives in one place, server/lib/wall-geometry.js, because four players have to agree on it to the pixel across a seam. Existing walls need no migration and do not move. Every wall in the field is rotation 0, and that case takes the original expression verbatim on all three players rather than the algebraically-equal centre-based one — the two differ in the last float bit, and a float's worth of disagreement between two panels is a hairline seam down a wall that was aligned yesterday. Pinned by the first test in wall-geometry.test.js and by wall-payload.test.js. While a display is in a wall its panel rotation replaces its own orientation: both describe the same physical fact, so honouring both turned the content twice. #235: a wall replaced its members' cards, so one dead panel of a four-panel wall was invisible from the dashboard, and inspecting a single screen meant pulling it out of the live wall and putting it back. The wall screen now lists its panels with live online state, a per-panel screenshot request, and a link to each device's page; the dashboard wall card carries per-member status chips that track socket updates. Tests: wall-geometry.test.js re-simulates the CSS box independently and asserts each panel's viewport maps onto exactly its own rect of wall space, for every rotation, plus a mixed wall and the Tizen player's hand-ported copy executed against the canonical rule. Full server suite green (1260). Not verified here: the Android and Tizen renders on real hardware. Kotlin compiles clean; the maths is shared/tested, the view plumbing is not. |
||
|
|
684e60fc55 |
Offline media on every player, and a revision so the cache can still be updated
Two halves of the same problem. A screen has to keep playing when the link is gone, and it must not keep playing the wrong thing once the link is back. CACHING FOR OFFLINE, on the players that could not: - Tizen cached nothing but the playlist, so a panel came back from a reboot knowing exactly what to show and fetched every frame of it from a server that was not there. tizen/js/media-cache.js caches the media itself to wgt-private (the store Tizen documents as surviving reboots), resumable via Range and If-Range, with the transfer async so a stalled chunk cannot freeze the player. offline.cache moves from "absent" to a runtime claim: a build with no writable private storage still says nothing. - The web player's worker stored only what a single fetch() happened to complete, which on a marginal link is nothing at all — a 200MB asset never finishes in one go and every retry starts from zero. It now accumulates in resumable chunks, driven by the player's playlist rather than by playback, so the prefetch is not competing with the video that is currently on screen for the same scarce bandwidth. BrightSign inherits this. STILL UPDATING, which caching quietly breaks: PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every cache keys on that id, so before this the new bytes could not reach a panel that already held the old ones — not until the next refresh, but never. Content now carries a revision, stamped onto each item at send time like widget revs, and every player keys its cache on it. The same send-time refresh fixes a second bug: a replace writes a new randomly-named file and unlinks the old one, so the filepath in a published snapshot pointed at a deleted file and web panels 404'd on the item until somebody republished the playlist. The route now also pushes to affected devices, which it never did. Bytes are kept only where they can be built upon: no validator means no safe resume, so the partial is discarded and the attempt backs off as the failure it is rather than re-fetching the same prefix forever. Server needed no new transfer support — res.sendFile already does Range, If-Range and 416. The Tizen cache and the service worker are both driven in Node against fakes, because neither can be exercised without hardware and "the chunks assemble correctly" is not something to discover from a panel showing a corrupt video. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
cf1124d687 |
Mute reaches YouTube items — it never did, and failed opposite ways per player
Muting was implemented three times and agreed nowhere. A YouTube item is a
cross-origin iframe, so `el.muted` reaches nothing; only the IFrame API can
touch it. Both browser-family players got this wrong, in opposite directions:
web playerVars.mute was `userHasInteracted ? 0 : 1` — autoplay policy and
NOTHING else. An item an operator muted in the admin console played
WITH SOUND, a wall follower blared alongside its leader, and the
real-time device:mute-changed toggle only ever touched `<video>`.
onReady then unmuted unconditionally, and the click-to-unmute overlay
appeared on deliberately-muted items and undid the operator's setting.
tizen the embed URL hardcoded `mute=1`, so YouTube there was PERMANENTLY
silent: the per-item flag was never read and nothing could unmute it.
device:mute-changed did nothing at all, because it dereferenced a
<video> that is null for a YouTube item.
Android was already correct and is unchanged — it is the reference here.
The rule now lives once, in server/lib/media-mute.js, served to the web player
from its single source the same way schedule-eval.js is, and mirrored in Tizen
(which ships inside the .wgt and cannot import it). The ORDER is the substance:
a wall follower is always silent (one wall, one audio source) > autoplay policy,
which is a hard constraint rather than a preference because unmuted playback
without a gesture is refused outright and costs the VIDEO > a live operator
toggle, who is looking at the screen > the item's stored flag.
shouldOfferUnmute() exists so the prompt only appears when a gesture is the ONLY
thing in the way. Prompting on a muted item trains viewers to click a button
that undoes an operator's decision.
Tizen gains enablejsapi + a postMessage bridge so a live toggle flips the embed
without reloading it — reloading would restart the video from zero every time
someone touched the control.
11 new tests pinning each precedence step separately; 1055 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
9458af95fd |
Show the idle card on a group-synced screen when nothing is scheduled
Give every item on a sync-group playlist a daypart — "menu boards 06:00-22:00" — and at 22:00 the whole group kept displaying, or looping, whatever had been in-window last. An identical ungrouped screen showed "Nothing scheduled right now" correctly. The group schedule tick filters items by the same scheduleAllows check as solo playback. With everything filtered out the period is zero, so the target is null and the tick simply returned. Nothing else was watching: group members are schedule-driven, so renderContent arms no advanceTimer, and a group-rendered video is created with loop = !!groupSync. Solo playback routes this exact condition into the idle card; group playback had no equivalent, on either player. Both ticks now tear down and show the idle card when the schedule has nothing live, and pick up again when the daypart re-opens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
47bda040a2 |
Re-render when a screen leaves a sync group or a video wall
Taking a display out of a sync group, or deleting the wall it belonged to, froze it on whatever was
playing. The clip looped forever and every later refresh took the "unchanged" branch, because the
element was attached, playing and un-errored — healthy by every check the player makes. Only a
reboot cleared it.
On the web player, reconcileAdvanceTimerForMode re-arms a solo timer for widgets and images but
skips video and YouTube, on the grounds that they "self-advance via their own end handlers". The
handler that is live at that moment, though, was built for the mode being left: a group-rendered
video was created with `loop = !!groupSync`, a wall-follower video with `isFollower` true, and both
are captured in the closure at render time. A looping element never fires `ended`, and a follower's
handler declines to advance — so nothing self-advances and nothing re-renders. It now re-renders
whenever the element on screen is still looping, rather than guessing which media types can look
after themselves.
Tizen had the same freeze by a different route. GroupSyncController.exit and WallController.exit
both call player.invalidate() for exactly this purpose, but invalidate only cleared the change
signature — and load() returns at the continuity check ("current item survives, just retarget the
index") before reaching any render, so the invalidate was a no-op. It now forces the next load to
re-render, which is what those call sites always intended. On Tizen this froze every item type, not
just video, because `single` skips the timer in all of the renderers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
f4d309a0d4 |
Apply a playlist change even when the outgoing item never advances
Replacing the single item of a one-item playlist did nothing. The old promo, board or clip kept playing while the dashboard showed the new playlist published and the device perfectly healthy — only a reboot or a manual refresh cleared it. #157 defers a rotation so a live item is not yanked mid-play, and applies it "on the next natural advance". For a one-item playlist there is no such thing, by design: single-item rendering deliberately never advances. A video gets `loop = (playlist.length === 1)` and so never fires `ended`; a YouTube embed loops for the same reason and skips its safety net; a solo widget is "held" on a self-re-arming refresh that never calls nextItem, because reloading it would reset a directory board's scroll. Tizen is worse still — `single` makes every renderer skip its timer, so images freeze too. Two guards, the same pair already applied to the Android controller: - A one-item playlist is never deferred. There is nothing to protect from being cut off, since nothing was going to advance anyway. - Any deferral that does happen gets a 60-second deadline. The deferral is a bet that an advance is coming; if the bet loses, the change must still land rather than strand the screen on content the operator has already replaced. Verified in headless Chrome: a one-item playlist holding a solo widget (the "held" case that never advances), its only item replaced with a different widget — the screen followed, with no reload and no restart. Before the change it stayed on the replaced item indefinitely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
5c6e0325b1 |
Widget edits reach the web and Tizen players too, and a pinned render can be cached offline
Same fault as Android, in both other players, and my earlier read of them was wrong: I assumed they rebuilt the iframe each cycle so could not go stale. They do rebuild — but only after the update survives a change check, and both change checks key on IDENTITY: web content_id|widget_id|remote_url|filepath|filename|schedules|transition tizen [content_id, widget_id, remote_url, mime_type, schedules, transition] A widget's identity does not change when it is edited, so an edit produced an identical signature, the update was discarded as "unchanged", and the old render stayed up. widget_rev now sits in both, alongside schedules and transition, which are there for exactly this reason. The render URL carries the rev on both players as well. In the zone path the web player was picking up `item.widget_rev` inside a loop whose variable is `a` — that would have been undefined on every zone; it now reads the zone assignment's own rev. Caching, which is the reason this is worth doing properly rather than just busting the URL: a URL carrying ?rev=<updated_at> is content-addressed, so those bytes cannot change without the URL changing. The render endpoint now returns immutable caching for a pinned URL and keeps no-store for a bare one, and the service worker serves pinned renders cache-first (CACHE_NAME v18). That closes a real gap. no-store meant widgets were the ONE thing the player's offline cache could never hold, so a display that lost its uplink lost its widgets — while its images and video kept playing. Offline resilience is the point of that cache. Old players sending no rev are unaffected: they still get no-store, because without a rev nothing distinguishes one render from the next. Verified live: bare URL -> no-store; ?rev=123 -> public, max-age=31536000, immutable. 859 server tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
07419fee1f |
feat(players): proof-of-play on Android+Tizen; close Tizen parity gaps
Android and Tizen never emitted device:play-event, so Reports showed Total Plays / Hours / proof-of-play as all zero for those devices (only the web player logged plays). Both now emit play_start on show and play_end on advance/teardown, mirroring the web player's contract (leader-gated for walls, widget-id fallback so durations close). Server side unchanged — play_logs and the reports queries were already waiting for the events. Tizen parity with the web player (from the parity audit): - audio: landscape <video> honors item.muted (warm-muted for autoplay, then applies the real state) instead of force-muting; wall followers stay silent - device:mute-changed: real-time per-item mute of the on-screen video - device:remote-key: D-pad/volume/mute/home, BACK=info overlay, POWER=screen-off - device:remote-touch: normalized-coordinate touch injection - buffered widget swap: reveal the new iframe on load then clear (no black flash) - diagnostic info overlay toggled by the dashboard BACK key Still muted on Tizen: the portrait AVPlay video path and transition-composited video (would need webapis.avplay volume APIs / renderVideoBuffered work). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ba00dd2811
|
fix(transition-engine): Android supersede wedge/leak + web/Tizen stale-video guard (#205)
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. |
||
|
|
96b71a0d56
|
feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated. |
||
|
|
335681b907
|
feat(directory-board): panel-ring scroll + in-place refresh + per-device frame diagnostic (#203)
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. |
||
|
|
76f072bf4b
|
fix(tizen): decode-gated image double-buffer to kill the black flash between stills [#187] (#193)
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
(
|
||
|
|
bc9e72ec0b
|
fix(content): render YouTube Shorts in 9:16 instead of a landscape frame (#184) (#189)
Vertical Shorts were played in a player forced to 100%x100% on a landscape frame, so they looked wrong (pillarboxed/small). Option A: detect vertical at ingest, persist it, and have every player honor it. - Ingest (routes/content.js): detect a Short from the /shorts/ URL form OR portrait oEmbed dims (oEmbed now queried with the ORIGINAL url so /shorts/ reports its true dimensions), and persist it as st_aspect=vertical on the stored embed URL. That's the only signal players get (remote_url), so it must be captured at ingest, not re-derived per loop. YouTube ignores the unknown param; players read the video id, not the full URL, to build the embed. - Players read st_aspect=vertical and center a 9:16 box (fills a portrait screen, pillarboxes cleanly on landscape) instead of 100%x100%: web (player/index.html), Android (WebViewSupport.youtubeEmbedHtml), Tizen (player.js single-zone + zone paths). Dashboard library uses a static thumbnail, so it's unaffected. Not doing Option B (yt-dlp): runtime dep + storage/bandwidth + maintenance + YouTube ToS; embed-disabled Shorts already skip gracefully. Tests: youtube-shorts.test.js (4) — /shorts/ and portrait-dims tag vertical, landscape stays untagged, /shorts/ tags even if oEmbed fails. Android compiles; web player inline JS + Tizen player.js parse. Note: pre-existing Shorts added before this aren't retagged (would need an oEmbed backfill) — re-add to fix, or a follow-up migration. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c2115c88e3
|
fix(tizen): portrait/flipped VIDEO via AVPlay (hardware-plane rotation) — no more black screen (#170) (#174)
Bold field report: portrait video on Tizen = black screen. Root cause: our orientation support rotates #stage with a CSS transform, but on Tizen the HTML5 <video> is composited on a HARDWARE video plane that ignores CSS rotate — so portrait/flipped video blacks out (images/widgets/text rotate fine). It's a Tizen platform limitation, not a regression. Fix: route portrait / portrait-flipped VIDEO through Tizen AVPlay, whose setDisplayRotation rotates the hardware plane itself. Scoped to portrait/flipped ONLY — landscape keeps the proven HTML5 <video> path (double-buffer + group-sync drift), so the working case is untouched. - index.html: <object id="avPlayer" type="application/avplayer"> hole-punch surface (hidden until a portrait video plays; off-hardware it's inert). - player.js: setOrientation()/avAvailable(); renderVideoAv() (open/setDisplayRect/ setDisplayRotation 90|270/setDisplayMethod LETTER_BOX/prepareAsync/play; onstreamcompleted -> loop|advance); avStop() torn down in clearStage() before every render; renderVideo() branches to AVPlay only for portrait/flipped when webapis.avplay exists. - Graceful: any AVPlay error/absence -> honest note (portrait_video_unsupported, 5 locales), NEVER a silent black screen. currentVideoEl stays null for AV (portrait is solo-first; schedule engine still drives index/position). - app.js: applyOrientation() now tells the player the orientation so renderVideo can choose the path. Exit-signal marker slice unaffected (test green). Landscape + off-hardware paths unchanged. NEEDS ON-DEVICE VALIDATION on a real Tizen TV (AVPlay can't be exercised off-hardware) — signed test .wgt built for Bold. Refs #170. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
501ffb11c1
|
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159. |
||
|
|
938a43a466
|
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]
Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.
Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
member whose playlist matches the group's shared playlist (playlist-match guard —
a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.
Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
GROUP = same leader/follower timing incl. the full video drift controller, but
full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.
Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.
Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): web + Tizen player parity [stage 2]
Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.
Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).
Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
(UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
group_sync block, else exits — content renders through the normal single-zone path.
Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]
On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
playback; server re-pushes to members so they enter/exit sync mode. A hint notes
it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
elect, which self-heals; or pin a specific member to always lead when online).
Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).
Frontend parses (ESM); server suite 407/407.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): rework to clock/schedule sync + double-buffer + polish
Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
- needs no server at play-time (offline-native), and
- has no leader role to double-elect (kills the split-brain class the leaked
WallController tick produced).
Server
- heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
discipline; the client caches the offset (survives an outage).
- POST /groups/:id/resync -> group:resync (manual "Resync now").
- (kept: group_sync payload; leader machinery is now vestigial/ignored.)
Clients (web / Tizen / Android)
- Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
- Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
slot formula identical across platforms so mixed-platform groups can't drift.
- Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
- Double buffer: warm the next clip a few s before the boundary -> instant
switch, no black hold. Android pre-decodes on a throwaway surface so the swap
doesn't flash one wrong-aspect (landscape-stretched) frame.
- In-place duration edits: duration_sec dropped from the change signature and
applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
- Live-log shows discrete corrections (jump/align/seek) immediately; only the
steady-state line is throttled.
Android
- Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
main looper outlived the Activity -> zombie broadcaster / split-brain).
Dashboard
- Group leader picker -> "Resync now" button.
Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
5f12315e39 |
fix(tizen): player wedge on shared #stage — same class as #162
PlaylistPlayer and ZoneRenderer share one #stage node, but every playlist-update unconditionally blanked the OTHER renderer (zoneRenderer.clear() in the single-zone branch, player.stop() in the layout branch) and then hit that renderer's unchanged- signature `return` — leaving the stage BLANK. Same class as the Android #162 wedge (a stale "still on screen" belief trusted while nothing is actually rendered): - fires on the routine ~60s heartbeat re-register (server re-pushes the same playlist), - permanent for a single looping item (no advance timer to self-heal), - also stranded the stage on suspend -> resume and cold-start cached-playlist restore. Fix: track which renderer owns the shared stage (stageOwner) and only blank the other one when actually switching modes, invalidating the incoming renderer's signature so it repaints on the switch; never blank on a same-mode unchanged update. Added ZoneRenderer.invalidate() to mirror PlaylistPlayer.invalidate(). Verified with a faithful state-machine simulation: old code blanks 8x across a realistic pair/heartbeat/switch/suspend sequence, fixed code 0x. The web player (server/player/index.html) was reviewed and is NOT vulnerable — its unchanged-guard already verifies real DOM surface health (needsReattach, the #146 fix) and its JS state resets on reload, so there is no analogous wedge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
646eab743a |
fix(tizen): P0 audit fix pass — watchdog config-proofing, teardown hygiene, dead-screen self-heal, offline snapshot
Client-only, no server change. Implemented in verified clusters: - H1 (config-proof, no heartbeat-ack): derive the liveness window from the server-negotiated pingInterval (version-robust read) + arm the watchdog only after a real inbound signal, so it degrades safe against any server and a raised PING_INTERVAL can't false-fire it into a storm. - A5: monotonic clock (performance.now) for watchdog/resume deltas — NTP/RTC jumps can't false-fire or blind the watchdog. - H4 (leak was verified ABSENT): timer/teardown hygiene — tracked register-retry + teardownSession() on reset/BACK (stop heartbeat/stream/player-loop/pending-register); all start*() are stop-first. - A1: single-item playlist retries a broken item (was a permanent black screen while heartbeat green). - A6: reconnect randomizationFactor 0.5 (no fleet thundering-herd) + timeout 10s->20s (parity). - A2 (minimal): cache last renderable playlist-update to localStorage, replay on cold-start/offline; cleared on unpair/reset/auth-error. - B3: input hardening (non-array assignments/zones guarded, duration_sec numeric-coerced). - A3: log keep-awake API availability so Bold can VERIFY the flap fix on real hardware. #148 double-connect discipline re-proven after socket-touching changes. |
||
|
|
9c4b48800f |
Tizen player 1.9.1-beta3: bug fixes, multi-zone layouts, video walls
Brings the Tizen TV player to parity with the other players: closes the five Tizen issues Bold Media Group filed (#118-#122) and adds the two larger renderer features it was still missing. Fixes (#118-#122) - #118 Sticky "Not authenticated" banner. On TV sleep/wake the socket reconnects and a heartbeat could land on the fresh, not-yet-registered socket; the server rejected it and the old handler painted a permanent banner AND dropped the saved credentials, forcing a re-pair. Heartbeats are now gated on a per-connection authenticated flag (true only between device:registered and disconnect/auth-error), the heartbeat stops on connect/disconnect/auth-error, the banner clears on device:registered, and the auth-error toast is non-sticky. - #119 app_version stuck at 1.0.0. Resolved at runtime from config.xml via the Tizen application API, with a fallback constant that build-wgt.sh stamps from config.xml. - #121 Remote commands. Added a device:command handler (refresh/launch/screen_on/ screen_off; honest no-op toasts for update/reboot/shutdown, which need B2B/MDM privileges a sideloaded app lacks). Removed the dead device:reload listener. - #120 Dashboard preview. Added device:screenshot-request + remote-start/remote-stop. Images capture; video/YouTube fall back to a status card (TV hardware video plane and cross-origin iframes can't be read into a canvas). - #122 Updates/boot. Documented the real paths (re-sideload or URL Launcher/MDM refresh; display-level kiosk/boot settings) since a sideloaded .wgt has no in-app OTA or config.xml autostart. Multi-zone layouts (Android parity) - New ZoneRenderer ports the Android ZoneManager: zones positioned by percent geometry with z_index/fit_mode/background, assignments grouped by zone_id (unassigned content goes to the first zone), each zone rotating independently with the same per-item schedule gating (#74/#75). app.js selects the renderer from payload.layout; single-zone playback is unchanged. Video walls (web-player parity; Android has none) - New WallController mirrors the web player: when payload.wall_config is present the stage is positioned (vw/vh) as this screen's slice of the wall. The leader plays normally and broadcasts wall:sync at 4Hz; followers hold the leader's item, align index, and lock their video to the leader's clock with a latency-compensated drift controller (hard-seek past 0.3s, gentle +/-3% playbackRate nudge past 0.05s), and request an immediate position on (re)connect via wall:sync-request. Per-tile rotation is not applied yet (matches the web player). Wall emits are gated on auth + connection so a pre-register tick can't trip device:auth-error. Not ported: video-wall per-tile rotation, plus the minor Android-only reporting events (device:playback-state, device:log) and the N/A offline-cache events (device:content-ack/content-delete). None affect on-screen playback. Verified: JS syntax + headless unit tests of zone grouping/geometry and wall leader/follower + drift logic. NOT yet validated on Tizen hardware - multi-screen video sync in particular needs a real wall to tune. |
||
|
|
2ccf3264a9 |
feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Each playlist item can carry schedule blocks (active days, start/end time-of-day, optional start/end dates). An item plays when the screen's local "now" matches at least one block; an item with no blocks always plays. #74 covers time-of-day/day-of-week windows including overnight wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is on-device, so dayparting and expiry work offline. - Shared evaluator contract: shared/schedule-vectors.json (39 vectors — DST US+AU, overnight-wrap anchoring, timezone correctness, date boundaries). Canonical JS evaluator in server/lib/schedule-eval.js; Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff test, Kotlin JUnit reads the shared JSON, new android-test CI job). - All three players (web, Android, Tizen) filter by schedule against their own clock, idle with a "Nothing scheduled" message + 30s re-check when everything is filtered, and fail open on any evaluator error. - Editor: per-item schedule modal + row badge in the playlist editor; client validation mirrors the server; editing marks the playlist draft. - Part B (behaviour change): device/group schedule overrides now evaluate in each device's effective timezone instead of server-local time. - Device detail shows the reported timezone + a clock-skew warning. - i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.* to avoid colliding with the device-schedule calendar's schedule.*). - CHANGELOG documents the feature, the Part B change, the fail-open guarantee, and the scheduled-single-video re-render tradeoff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0cfa09046c |
feat(tizen): Samsung Tizen TV web player (.wgt)
Ports the ScreenTinker player to a Tizen TV / signage web app, speaking the SAME /device socket.io protocol as the Android player — no server changes; a Tizen display pairs from the same dashboard. - app.js: device protocol client — register (pairing_code | device_id+token), device:registered/paired/unpaired/playlist-update, 15s heartbeat, keep-awake. Always reaches the server prompt until the display is actually paired; a saved-but-unreachable server falls back to the prompt (no blank screen); BACK returns to it. - player.js: fullscreen single-zone renderer — image (duration timer), video (play-to-end + loop), YouTube (iframe embed), widget (iframe render endpoint). - config.xml: Tizen TV manifest; build-wgt.sh packages (signs if Tizen CLI present, else unsigned); README covers URL-Launcher and signed-.wgt deploy. Validated: headless protocol test vs the live server passed end-to-end (register -> pair -> reconnect-auth -> playlist(2) -> content 200); loads + renders in Chromium with no JS errors. Not yet ported (fullscreen single-zone covers most signage): multi-zone, video walls, screenshots, remote control, self-OTA. .wgt is a build artifact (gitignored). |