Commit graph

69 commits

Author SHA1 Message Date
ScreenTinker a62396c2dd Attribute a widget play to the widget that played
A widget playlist item carries its id in widget_id and has no content_id at
all. The player sent only content_id, so a widget play arrived with nothing
identifiable and was written with both columns null — and play_end bound
content_id to BOTH columns, so that row could never match itself and was never
closed or given a duration.

Nothing looked broken: a row existed for every play. It just named neither what
had played nor which widget, and never ended. Reports read empty for any screen
showing a widget, which is most of the interesting ones. Seen on a live screen
playing a single widget: one open row, both columns null.

The player now sends widget_id alongside content_id, and a name falling back
through the fields a widget item actually has, so the event records what played
even when neither id resolves. The server prefers an explicit widget_id and
keeps the old content_id sniff as the fallback for players that predate this,
so an older client that puts a widget id in content_id still attributes
correctly.

Found by reading a real screen's proof-of-play rather than the code. The first
attempt at the fix broke the statement outright — the explanatory comment was
placed inside the SQL template literal, where a JS comment becomes SQL, and the
server logged `near "/": syntax error` on every play_end. Comments now sit
above db.prepare(), with a note saying why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:06:56 -05:00
ScreenTinker 433fbef191 Re-establish a player socket the server closed
socket.io does not retry every disconnect. On 'io server disconnect' it stands
down deliberately and waits to be told to reconnect. The player assumed the
opposite in two places: the disconnect handler stopped the watchdog because
"socket.io owns the reconnect once it KNOWS it's down", and verifyLivenessSoon
skipped a present-but-disconnected socket for the same stated reason.

So when the server closed a socket — a handler throwing, a deploy, an eviction
— nothing was left watching and the player stayed down until someone reloaded
the page. That is what it does on a wall: nothing, indefinitely, with no error
on screen. It happened to a live panel whose heartbeat hit a constraint error;
the server dropped the socket and the display sat dark until reloaded by hand.

A supervisor now backs up every disconnect the client did not itself initiate.
It re-establishes only a socket that is genuinely not connected, and only after
a grace longer than socket.io's maximum backoff, so the reconnection socket.io
does own is never raced. Our own teardown is excluded, since connect() closes
the previous socket before opening the next and supervising that would fight
the attempt already in flight. A resume now hands a stranded socket to the
supervisor rather than assuming someone else has it.

The decisions are pure functions alongside the existing watchdogShouldReconnect,
so they are testable without a browser, and a test asserts the grace still
exceeds the configured backoff ceiling if either is ever retuned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 16:38:19 -05:00
ScreenTinker 6268c1a4c0 Let a screen-only panel clear its identity from the URL
A display panel has no keyboard, no pointer and usually no way to clear site
data, but the URL it loads is configurable from whatever manages it. Loading
the player with ?reset=<token> now discards this install's identity so the
panel returns as a new device with a fresh pairing code — the recovery path
when a panel is holding an identity that belongs to a different screen, and the
ordinary path when redeploying a panel to another site.

It applies once per token, which is the whole design. A configured URL is
permanent; nobody goes back and removes the parameter. A reset that fired on
every load would drop the pairing on every reboot and present as a screen that
cannot hold its pairing at all — which reads as an intermittent server fault
rather than the URL doing exactly what it was told. The applied token is
remembered, so ?reset=1 left in place forever resets exactly once; any other
value resets again.

The server URL is deliberately kept, since clearing it would strand a panel
that cannot be typed into, and the cached playlist and layout are dropped so
the new device does not come up showing the previous screen's content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:58:21 -05:00
ScreenTinker 2bcc46bc26 Give each player install its own identity
The web player derived its fingerprint entirely from hardware traits: user
agent, screen geometry, colour depth, timezone, core count, platform and a
canvas raster. Every one of those describes a model rather than a unit, so two
identical panels produced the same value and the server, which matches on that
value globally, treated them as one device. Two UniFi Pro Displays at different
sites both produced web-m73u8w-5f; the second could not be brought online, and
the row ended up shared, each display evicting the other every thirty seconds.

The identity a player presents is now hardware plus a random per-install salt
kept in localStorage, so two identical panels differ from their first
connection. This is what the Tizen player has always done; the web player is
brought in line with it rather than given a new scheme.

The hardware value is still sent, but only as a hint, and only to move a caller
that has ALREADY authenticated with a device id and token onto its own row —
which is how an existing player carries its identity across this change. A
caller without credentials never resolves through it, however few rows it
appears to match: one row recorded does not mean one display exists, and that
distinction is the whole bug. Such a caller is provisioned a new device, which
costs one pairing code and cannot be wrong.

Older clients are unaffected. They send no hardware value, so they take the
exact-match path exactly as before, and both keep working: the APK's
fingerprint already includes ANDROID_ID and the Tizen player's is already a
stored random id, so neither ever shared an identity between units.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:54:00 -05:00
ScreenTinker f09dee810c Record where a player crashed, not just what it said
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Three players died with "Cannot set properties of null (setting 'textContent')"
and it could not be traced. The message names no file, and every candidate line
in the current player was ruled out by inspection: the unguarded writes all
build their element with createElement, every getElementById target exists in
the markup, and the script runs after the markup. That points at an older
cached build still served by the service worker, which is exactly the case
where reading current source proves nothing.

The ErrorEvent already carried filename, lineno and colno. They were being
discarded. Keeping them makes the next occurrence name its own line.

Composed to fit the 200 characters the server stores, so the location is not
truncated away: message plus one location, basename only since the origin is
already known from the device. A promise rejection has no filename, so it falls
back to the first stack frame. A cross-origin script, which reports a bare
"Script error." with nothing else, says so rather than emitting :0:0 as if that
were an answer.

A resource load failure still is not a crash; a test guards that, since this
touched the handler that decides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 14:17:43 -05:00
ScreenTinker 8f2195a6e3 Recover an unpaired player without needing a keyboard
A display panel usually has no keyboard and no pointer, so a recovery path that
waits for input is not a recovery path. When the server stopped recognising a
device, the player revealed the server-URL form — typing that cannot happen on a
screen-only panel — and hid the pairing section, which was the one thing that
would have rescued it. The screen then sat on "Device was removed from server"
until someone physically reloaded it, even though the player was still connected
to the right server and could have asked for a new code itself.

Both handlers now drop the stale credentials and reconnect on a short countdown.
Reconnecting re-registers with no device_id, so the server issues a fresh pairing
code and the existing registered handler puts it on screen. config.serverUrl is
known-good by construction — we are talking to that server at the moment we are
rejected — so there is nothing for a human to re-enter.

The URL field stays editable throughout, and typing cancels the countdown, so
someone who does have a remote and wants to repoint the player is not yanked
mid-edit. The countdown is the same helper the first-boot path already used,
lifted out and shared rather than duplicated; its input listener is bound once
at setup instead of per countdown, which would have stacked a listener each time.

The Android player already behaved this way (ProvisioningActivity repair mode);
this brings the web player in line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 13:17:22 -05:00
screentinker 2b137bc40b
fix(widgets): honest webpage-widget note — blocked sites don't work on device (#230)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
The webpage-widget preview note claimed: "the site blocks embedding in a browser
— it will still display on the device screen." The second half is false. The
widget renders the URL in an <iframe> (renderWebpage), and the device player
loads that page in a Chromium WebView, so a site sending X-Frame-Options /
CSP frame-ancestors (Amazon, Google, most large sites/banks) is refused on the
device exactly as in the browser preview. The note set the wrong expectation —
a customer (and we) chased CORS and "should work on device" when the live
device screen was blank too.

Reword to tell the truth in all 6 languages (en/es/fr/de/it/pt), both the
frontend i18n key (widget.webpage_blocked_note) and the player's
preview_webpage_blocked string: if the preview is blank the site blocks
embedding and won't display on the device either — try a page that allows it.

Copy-only; no behaviour change. This is not an Amazon-side fix (embedding refusal
is the site's choice) — just accurate messaging.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 19:17:00 -05:00
screentinker 8529be5a30
feat(content): subtitle/caption support as a content property (#223)
Subtitles/captions are set once in the content library and applied
automatically by the player — no in-player controls (the player stays bare).

- DB: 4 new content columns (captions_enabled, captions_lang for YouTube;
  subtitle_url, subtitle_lang for uploaded videos), all default off/NULL.
- buildSnapshotItems: denormalize the 4 fields into published_snapshot so the
  player receives them (enumerated query).
- content.js PUT: accept the 4 fields (subtitle_url only clearable here).
- POST /:id/subtitle: dedicated .vtt uploader (separate multer, since the main
  filter is video/image-only); stores the file in the content dir, records
  subtitle_url + subtitle_lang. Old subtitle file replaced; DELETE cleans up
  the sidecar.
- Player: YouTube -> loadModule('captions') + setOption(...languageCode) in
  onReady (best-effort, wrapped). Uploaded video -> a <track kind="subtitles">
  appended to the <video>, forced mode='showing' on load (same-origin, so
  CORS-clean like the video).
- Edit modal: YouTube gets an enable-captions checkbox + language; uploaded
  video gets a .vtt file picker + language + a remove-subtitle option. en/es.

Honest limitation (in the PR): YouTube caption control via the IFrame API is
undocumented/version-dependent and only works if the video actually has
captions — hence best-effort and wrapped so it can never break playback.

Test: content-subtitles.test.js — the 4 fields survive publish -> snapshot,
the .vtt upload endpoint stores + records the file, and a non-.vtt is rejected.
Suite 550/550.

Closes #216

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:33:35 -05:00
screentinker ad03a5ec0a
feat(content): unstable-connection mode — cap YouTube at 720p for weak WiFi (#220)
On Android TV with weak/unstable WiFi, YouTube embeds auto-select 1080p+
and buffer/stall. Add a per-item "Unstable connection" flag that biases the
YouTube embed toward a 720p ceiling.

- DB: content.unstable_connection INTEGER NOT NULL DEFAULT 0 (non-destructive,
  existing rows unchanged).
- buildSnapshotItems: denormalize the flag into published_snapshot so it
  reaches the player (that query enumerates columns, so it had to be added
  explicitly — covered by a new test). preview-payload reuses the same query.
- content.js PUT: accept + coerce unstable_connection to 0/1.
- Player: playerVars.vq='hd720' + best-effort setPlaybackQuality('hd720') in
  onReady when the flag is set. Both are hints YouTube may still override, but
  together they bias the initial selection down to 720p.
- Edit modal: YouTube-only checkbox + hint; en/es i18n.

Scoped to the core ask; the issue's optional "Shorts get inverse hd1080"
refinement is intentionally left out (dubious for signage, and forcing higher
quality on a weak link is the opposite of the goal).

Closes #217

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:38:18 -05:00
screentinker 9d6c3c79b0
fix(web-player): YouTube ENDED safety net for Shorts + flaky Android TV (#219)
YouTube Shorts never fire the ENDED state via the IFrame API, and some
Android TV WebViews drop ENDED even for regular videos. The player advanced
solely on onStateChange ENDED, so a missing event stalled the playlist
indefinitely.

Arm a duration-based fallback timer in onReady (getDuration + 3s slack) that
calls nextItem() if ENDED never arrives. It is cleared on a real ENDED, on
onError, when a newer player is created, and in teardownCurrentMedia so a
stale timer can't force a spurious advance after rotation. Skipped when
looping (single-item playlists) and when duration is 0 (live streams).

The Tizen player is not affected: it embeds YouTube as a plain iframe and
already advances multi-item playlists on a duration timer rather than the
YT JS API, so it never waits for ENDED.

Closes #215
Refs #184

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:34:25 -05:00
ScreenTinker 363f8de809 fix(web-player): hoist renderSeq to top-level state — fixes cold-start TDZ crash
The cold-start cached-playlist restore runs at top-level during initial
script execution: it calls startPlaybackAt(0) -> playCurrentItem ->
renderContent, whose first statement is `renderSeq++`. But renderSeq was
declared with `let` next to the buffered-video code far below, so it was
still in the temporal dead zone on that early path:

  ReferenceError: can't access lexical declaration 'renderSeq' before
  initialization  (renderContent -> playCurrentItem -> startPlaybackAt)

Result: any paired device with a cached playlist + known layout threw on
cold load and rendered nothing. Regression from the warm-play/buffered
render work, which made renderContent touch renderSeq at its very top.

Fix: declare `let renderSeq = 0` with the other top-level player state so
it is initialized before the restore path can call renderContent. No
behavior change to the buffered-render logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:34:35 -05:00
screentinker 8c0bf77428
fix(web-player): reconcile advanceTimer on group/wall mode transitions (#200) (#208)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
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.
2026-07-21 09:00:42 -05:00
screentinker 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.
2026-07-20 16:58:28 -05:00
screentinker 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.
2026-07-20 16:45:32 -05:00
screentinker 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.
2026-07-17 20:17:21 -05:00
screentinker bb6c7597da
fix(web-player): buffered widget swap + schedule-aware solo-board hold (directory-board black flicker) (#202)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* 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>
2026-07-17 13:42:12 -05:00
screentinker ba0663edc1
fix(pairing): close deferred-offline reclaim race + idempotent same-code adopt (#192)
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>
2026-07-15 16:05:50 -05:00
screentinker 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>
2026-07-15 08:41:43 -05:00
screentinker 9c70fcc790
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause

Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.

Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
  crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.

Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
  discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
  contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
  lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
  link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
  (router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
  most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.

Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.

Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.

CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).

Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.

Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)

Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
  - link_lost=true                     -> Wi‑Fi/Ethernet link lost (device's own link)
  - link up, internet_ok=true          -> server_down: internet reachable, OUR server was unreachable
  - link up, internet_ok=false         -> no_internet: router/ISP down
  - link up, no probe result           -> generic router/upstream (unchanged fallback)

- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
  the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.

Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)

When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:26:04 -05:00
screentinker 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.
2026-07-12 19:41:07 -05:00
screentinker 938a43a466
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* 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>
2026-07-11 14:24:31 -05:00
Fabian Mendoza c63af0e6bd
fix(player): send device_id/token on reconnect before pairing (#164)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
When the web player socket reconnects before the device is paired,
register() omitted device_id and device_token (gated behind config.paired).
This caused the server's fingerprint reclaim guard to treat the reconnect
as a fresh anonymous registration with a colliding fingerprint, firing
device:auth-error.

Now device_id and device_token are sent whenever they exist, regardless
of paired status. The pairing code is also reused across reconnects
instead of generating a new random code each time.

Closes #163

Co-authored-by: BlazzzPlay <fabianma7@gmail.com>
2026-07-10 13:04:18 -05:00
ScreenTinker 8ad2258e7c feat: app-ending signal (exit-signal contract v1) — server + APK + .wgt + /player
Best-effort "last gasp" so Offline is annotated with WHY it went away — completing the liveness story.
Categories: crashed (client uncaught-exception), clean_exit (client confident lifecycle-end, best-effort),
silent (SERVER-inferred by absence — the honest catch-all for violent/external death incl. force-stop/MDM).

SERVER:
- device:exit socket handler + token-authed beacon POST /api/device/exit (reliable-on-unload). Both gated
  by liveness.sanitizeExitReason (honesty: only crashed/clean_exit accepted; 'silent'/unknown rejected).
- offline_reason/offline_reason_at/offline_detail columns (additive migration). Clear-on-online (a reason
  is always THIS session's); offline transition COALESCEs to 'silent'. Pure annotation — offline detection
  and #148/liveness are untouched. Offline dashboard emits carry offline_reason + client_type.
CLIENTS (canonical {reason,detail} shape):
- /player: window error/unhandledrejection + pagehide(persisted=false) -> sendBeacon.
- .wgt: same + BACK-key exit -> socket.emit + sendBeacon.
- APK: global UncaughtExceptionHandler -> crashed (blocking beacon, chains to default); Service.onDestroy
  -> clean_exit (socket + bounded beacon). New ExitSignal.kt. onStop/onPause NOT wired (background != exit).
Proven (Phase 3): per-category classification, nothing misclassified, external kill -> silent (never
clean_exit), backgrounding emits no false exit, #148/reconnect-vs-exit intact. 382/382 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:32:40 -05:00
ScreenTinker 80d6242806 feat(player): v4 liveness contract — throttle-aware watchdog + browser triggers + identity
/player v4 — client-only (served web player). Brings the browser player onto the locked v4
liveness contract, IDENTICAL on the wire to the now-v4 APK and .wgt:
- v4 liveness watchdog: consume device:heartbeat-ack, lastServerMessageAt refreshes on ANY inbound
  (onAny + engine ping) while ARMING gates on the ack (degrade-safe); 45s±10s jittered threshold;
  backoff 1s/30s/±0.2 on the socket.io Manager; NO status/health poll; teardown-before-reopen (#148).
- Browser-specific half-open triggers (the /player-unique part): Page Visibility, sleep/resume
  (pageshow/bfcache), network change (online) — all drive the SAME #148 teardown-first reconnect.
- THROTTLE-AWARE: silence computed by timestamp (not timer-fire-count); watchdog does NOT act while
  the tab is HIDDEN (throttled-timer gap is expected); on becoming visible, verifyLivenessSoon()
  resets the grace and reconnects ONLY if genuinely dead — never spuriously tears down a live socket.
- v4 client identity block on register (client_type=player / client_version / platform / v4).
Verified: arm-after-ack both directions, v4 values MATCH .wgt exactly, browser-trigger no-duplicate-
socket, throttle-aware reproduce-then-prove (real extracted checkLiveness/verifyLivenessSoon),
foreground-recovery one-socket. Depends on the server device:heartbeat-ack (core pass) — degrade-safe
until then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:34:05 -05:00
ScreenTinker 5ba5905637 fix(#146): web player — guard PlayerMediaHealth calls by METHOD, not object (stale-module TypeError)
Live error: "Uncaught (in promise) TypeError: PlayerMediaHealth.shouldShowIdle is not a
function" inside the device:paired socket handler.

WHAT WAS ACTUALLY WRONG: not a missing/misnamed definition — the module DOES export
shouldShowIdle (served + unit-tested). It's a VERSION SKEW: /player/* is served network-first
by the service worker, so a transient module-fetch failure falls back to the STALE cache (an
older player-media-health.js that predates shouldShowIdle) while index.html loads fresh with
the call. window.PlayerMediaHealth then exists but lacks the method, and the call site
guarded the OBJECT (`window.PlayerMediaHealth ? ...`) not the METHOD — so it threw, aborting
the rest of the device:paired handler (the showStatus after it was skipped).

FIX: guard the METHOD at both call sites (typeof X.method === 'function') so a stale/partial
module can never throw — it falls back to the safe inline default (!isPlaying for the idle
decision) and the handler runs to completion.

SIBLING (errors travel in pairs): the needsReattach call in the "Playlist unchanged" branch
had the SAME object-not-method guard. It was inside a try/catch so it couldn't throw uncaught,
but a stale module would silently skip the re-attach/hideStatus. Guarded it the same way.

No service-worker change needed: network-first already self-heals on the next good load; the
method-guard covers the transient/offline-fallback skew permanently.

Tests: player-media-health.test.js +1 module-surface test (both needsReattach and
shouldShowIdle are exported functions — catches the define-vs-call class). Inline player JS
syntax-checked; both guards verified present. Suite 319/319.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:19:22 -05:00
ScreenTinker b57e7eec7f fix(#146): web player — reconnect drops video to "Waiting for content" (idle reset over live playback)
CONFIRMED from a live console capture (restore cache -> video plays -> reconnect ->
"Playlist unchanged" -> screen falls to "Waiting for content..."). Audio survived because
only the "showing content" VIEW was covered, not the audio path.

ROOT CAUSE: the server re-emits device:paired on EVERY re-register of an already-paired
device (ws/deviceSocket.js:510) — i.e. on every reconnect, while content is already playing.
The player's device:paired handler called showStatus('Waiting for content...') UNCONDITIONALLY
(the "falls through to idle" sibling), putting the idle overlay OVER the live video. The
following device:playlist-update -> "Playlist unchanged" branch returned early and never
cleared it, so the idle screen stuck on top of playing content.

FIX (idle screen only when genuinely idle; unchanged is a strict no-op that keeps playback):
- lib/player-media-health.js: new shouldShowIdle(state) — idle ONLY when nothing is playing
  AND there's genuinely no content. Already-playing (or content-present-about-to-render) is
  never idle.
- device:paired handler: gate showStatus on shouldShowIdle({isPlaying, hasContent}) instead
  of showing it unconditionally. On a reconnect while playing -> no-op.
- "Playlist unchanged" branch: when healthy playback is confirmed, hideStatus() to clear any
  stale idle overlay a reconnect's device:paired may have put up — so the confirmation can
  never leave "Waiting for content..." over live content. Still leaves the actual media
  element exactly as-is (no teardown, no flicker).
- sw.js cache v10 -> v11.

SIBLING SCAN: device:paired was the only unconditional idle reset. The connect() idle
prompts (connecting / connecting_muted) were already guarded by !isPlaying; empty-playlist
and no-renderable idles are genuine.

Tests: player-media-health.test.js +2 (shouldShowIdle: playing never idle; idle only when
empty+not-playing). Inline player JS syntax-checked; module served + guard referenced on a
booted server. Suite 318/318.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:03:44 -05:00
ScreenTinker 26c72d62bf fix(#146): web player — no-change refresh loses video (keeps audio); re-attach idempotently
ROOT CAUSE (hypothesis A, pre-existing — NOT a beta7 regression; server/player/index.html
is untouched since v1.9.2-beta6): handlePlaylistUpdate's "Playlist unchanged" branch blindly
returned. The media re-attach (renderContent) lives ONLY in the content-changed branch, so
if the <video> surface was lost (element detached from the DOM while still decoding — video
gone, audio still playing) a no-new-content refresh never re-attached it. New-content
refreshes were fine because they re-render.

FIX (make the refresh idempotent for the media surface, no flicker on the healthy path):
- server/lib/player-media-health.js (new, UMD + unit-testable, mirrors schedule-eval.js):
  needsReattach(state) — re-attach ONLY when playback should be happening but the current
  item's surface is actually lost (video null / detached / ended / errored; non-video: no
  mounted surface). A healthy attached+live video returns false, so a routine poll stays a
  no-op (no re-render, no flicker). Served at /player/player-media-health.js from the single
  source; loaded by the player.
- index.html no-change branch: extract the current item's DOM facts and, iff
  PlayerMediaHealth.needsReattach, call playCurrentItem() to re-render the current item.
  Wrapped so the health check can never break a refresh.
- teardownCurrentMedia: also release currentVideoEl even when it was DETACHED from the
  container — a detached-but-playing <video> keeps emitting audio and the container-scoped
  querySelectorAll can't find it. This kills the "ghost audio" on re-attach.
- sw.js cache bumped v9 -> v10 so players pick up the new index.html + module.

Tests: test/player-media-health.test.js (6) exercises the branch selection — healthy video
-> no re-attach; detached/null/ended/errored -> re-attach; idle -> never; non-video by
surface presence. Inline player JS syntax-checked; module served + referenced verified on a
booted server. Suite 316/316.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:51:52 -05:00
ScreenTinker 0ebbd20968 fix(player): composite multi-zone layouts in screenshot/stream capture
captureAndSend() grabbed a single querySelector('video'|'img') and stretched it
across a fixed 960x540 canvas, so multi-zone Now-Playing screenshots and the 1fps
remote stream showed one zone stretched fullscreen instead of the actual layout.

- Multi-zone layouts now composite each zone from its REAL rendered geometry
  (getBoundingClientRect relative to the container, scaled proportionally onto the
  canvas), so positions/sizes stay true to the layout.
- Canvas height derives from the container aspect (not a hardcoded 540); media is
  drawn honouring its object-fit (cover/contain/fill) instead of being stretched.
- Cross-origin / iframe zones (YouTube, widgets) can't be read back without
  CORS-tainting the whole canvas (which makes toDataURL throw and kills the entire
  capture). They now get a deliberate, labelled placeholder ("YouTube"/"Widget"/
  "Video") so the shot still shows the layout structure with that zone marked,
  instead of a transparent hole or a failed capture.
- Split rendering into renderCaptureCanvas() (socket-free, headlessly verifiable)
  and captureAndSend() (encode + emit). One full-quality path serves BOTH the
  on-demand screenshot and the 1fps stream — the composite is only a few drawImage
  calls over already-decoded media, so no separate low-quality stream path.

Web player only; Android (view.draw already composites correctly) untouched.
Verified headlessly on a 3-zone device: red/green image zones render in their
correct positions, the YouTube zone shows a labelled placeholder, and the capture
succeeds with no CORS taint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:22:12 -05:00
ScreenTinker a36880b147 fix: per-item mute round-trip + multi-zone orphan-zone fallback & warnings
Two independent multi-zone bugs, plus operator-facing warnings, i18n, and
regression tests guarding the data contracts.

Bug 1 — per-item mute was a no-op end to end:
- GET /api/devices/:id dropped the `muted` column from its assignments SELECT,
  so the dashboard toggle never reflected state (the muted=false case in
  particular). Column restored to the device payload.
- Android player now honours the per-item mute flag for YouTube (initial state
  + live via the IFrame JS API).

Bug 2 — items whose zone_id belongs to a different layout were silently dropped:
- Player fallback (web + Android): an orphaned zone_id is recovered into the
  largest zone instead of vanishing, with telemetry.
- server/lib/zone-validate.js is the single source of truth for the orphan rule
  (zone not in the device's active layout); used by the device payload
  (per-item `orphan` flag + `active_layout_zones`) and the device list
  (`orphan_count`).
- Assign-time hardening: a stale zone_id (not in the device's active layout) is
  cleared to null on POST/PUT rather than persisted as a new orphan.
- scripts/find-orphan-zone-items.js: read-only sweep for existing orphans.

Dashboard warnings (operator-facing, never on the live player):
- Per-item badge + reassign affordance, device-list glance, preview banner.
- Graceful degradation: the zone selector falls back to /api/layouts/:id so it
  can't vanish on a stale payload.

i18n: orphan-zone strings added to en/es/fr/de/pt/it (hi falls back by design;
count strings interpolate through tn()).

Tests: server/test/device-zone-contract.test.js adds 5 regression tests for the
data contracts above (muted true/false round-trip, active_layout_zones, orphan
flag + count, orphan-clears-on-reassign, assign-time clearing). 172/172 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:16:29 -05:00
screentinker 6f0e4a07f6
Fix per-item mute (#129): persist, ship to device, and toggle in real time (#130)
* fix(server): persist + ship + real-time per-item mute (#129)

The dashboard mute toggle was a no-op end to end. The active model is playlist_items
(the device payload is its published_snapshot); the legacy `assignments` table the bug
report cited is unused for devices. Three breaks:

- PUT /api/assignments/:id silently dropped `muted` (only read sort_order/duration_sec/
  zone_id). It now accepts muted (coerced 0/1) and ITEM_SELECT returns it, so the toggle
  persists and its on/off state sticks.
- playlist_items had no `muted` column — added (schema + idempotent migration).
- buildSnapshotItems didn't select muted, so it never reached the published_snapshot /
  device payload — now included.

Real-time: on a mute change, emit device:mute-changed { content_id, widget_id, muted } to
every device on that playlist so the player toggles the matching item's volume live,
decoupled from publish (the value is also in the next snapshot, so it persists). Adds a
[mute] log line (the report noted zero mute log entries).

Test: test/mute.test.js — PUT persists + returns muted, it reaches the published
snapshot, and a non-mute update doesn't reset it. Server suite 164/164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(player): apply per-item mute live on Android + web (#129)

Honor the new per-item mute from the server, both in real time and on reload.

Android:
- WebSocketService: onMuteChanged callback + main-thread device:mute-changed handler.
- MediaPlayerManager.setVideoMuted(): flips the live ExoPlayer volume on the current
  video (YouTube autoplays muted; images/widgets are silent).
- MainActivity: on device:mute-changed, apply immediately if the toggled item is the
  one playing now.
- PlaylistController.sig(): include muted so a published mute change re-renders/persists
  instead of being de-duped.

Web player (server/player/index.html):
- device:mute-changed handler toggles the current <video>; the video mount now also
  honors item.muted so a published mute sticks across reloads.

Tizen intentionally not included: its player mutes ALL video for autoplay, so per-item
unmute isn't achievable there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:54:23 -05:00
screentinker 965920cd17
PiP overlay MVP: push image/web overlays to a device or group (#109) (#127)
* PiP overlay MVP: push image/web overlays to a device or group (#109)

Implements the #109 MVP from docs proposal: a floating overlay PUSHED to a device or
group in real time, rendered above the playlist without disturbing it. Scope is the
MVP only — video/RTSP, MQTT, offline-queue, and the priority/stacking system are
deferred to follow-up PRs as the proposal specifies.

Protocol (/device socket, player-agnostic):
- device:pip-show { pip_id, type:image|web, uri, position, width, height, duration,
  title?, title_color?, background_color?, opacity?, border_radius?, close_button? }
- device:pip-clear { pip_id? }
The player fetches uri itself (same trust model as remote_url content; server never
proxies). type:web is full-trust by design, hence the 'full' token scope.

Server (server/routes/pip.js, new; mounted in config/api-surface.js PUBLIC_ROUTERS):
- POST /api/pip and POST /api/pip/clear + DELETE /api/pip, all requireScope('full').
- Resolves device_id to a device OR a group, expands a group to members, and emits
  per-device — reusing the group command route's room-size online check and
  {device_id, name, status: sent|offline} result shape. Generates pip_id.
- Validates type/position allowlists, uri http(s), numeric bounds on
  width/height/duration/opacity/border_radius, colors via the existing VALID_COLOR
  (#RRGGBB; transparency is the separate opacity field).
- Workspace-isolated: every target query is scoped to req.workspaceId, so a token
  bound to workspace A can't address workspace B (404). Offline devices are reported,
  never queued (PiP is ephemeral).

Player overlay layer (Tizen; tizen/js/pip-overlay.js, new):
- A #pip sibling ABOVE #stage that PlaylistPlayer/ZoneRenderer never touch.
- applyOrientation now applies the SAME transform to #pip as #stage, so corner
  positions track the visible CONTENT in all four orientations.
- image -> <img>, web -> <iframe> (muted by default: empty allow= denies autoplay),
  sized/positioned/styled per payload, optional title bar.
- Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
  (id-aware) or timer tears down; teardown wrapped so a malformed payload can't wedge
  the layer. Reports show/clear over device:log (tag 'pip').

Dashboard: a minimal "Send overlay" / "Clear overlay" tester on the device-detail
controls (device/group via the open device, type, uri, position, duration), calling
POST /api/pip through the api helper.

Tests (server suite green, 161/161):
- api.test.js: PiP tier — authz (read/write 403, full passes), workspace isolation
  (wsA token -> wsB device 404), payload validation, device + group targeting, clear;
  plus the PUBLIC_ROUTERS snapshot-firewall updated for /api/pip.
- pip-overlay.test.js: loads the real player.js + pip-overlay.js in a vm with a DOM
  shim; proves the overlay shows, auto-dismisses on the duration timer, and never
  changes the playlist signature / touches #stage; web->iframe, last-show-wins,
  id-aware clear, malformed-payload safety.

Not in this PR (intentional):
- Android player overlay — fast-follow. Protocol + server are player-agnostic; the
  Android layer (an overlay View above the player, orientation-matched to MainActivity's
  rootView rotation) is the same shape and lands next.
- OpenAPI docs for POST /api/pip — the contract test's scope heuristic only treats
  'command' paths as full-scope, so documenting a full-scope non-command route there
  needs that heuristic extended first; deferred with the docs item (proposal §8.6).
- video/rtsp types, MQTT, offline queue-on-reconnect, priority/stacking, arbitrary
  (x,y)/selector positioning (proposal §6).

Refs #109

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PiP overlay: add Android + web players (#109)

Extends the #109 PiP MVP to the other two players so the protocol (device:pip-show /
device:pip-clear) is honored fleet-wide, not just on Tizen. No server/protocol changes —
the route and socket messages are player-agnostic; these are the two missing surfaces.

Web player (server/player/index.html):
- New #pipContainer layer above #playerContainer, pointer-transparent, that the playlist
  render never touches. The same orientation transform is applied to it as to
  #playerContainer (extended to also reset width/height on landscape so a
  portrait->landscape switch realigns), so corner positions track the visible content.
- Inline PiP logic mirroring tizen/js/pip-overlay.js: image -> <img>, web -> <iframe>
  (muted by default via empty allow=), position/size/bg/opacity/radius/title, single slot
  last-show-wins, duration timer (0 = until cleared), id-aware clear, wrapped teardown.
- device:pip-show/clear handlers; reports show/clear over device:log (tag "pip").

Android player:
- activity_main.xml: a pipLayout FrameLayout as the LAST child of rootLayout — it draws
  above the content AND inherits rootView's orientation rotation/translation, so corner
  positioning is orientation-matched for free.
- PipOverlay.kt (new): builds the overlay box into pipLayout. image -> ImageView (decoded
  off-thread via ImageLoader, dropped if torn down mid-decode); web -> WebView with
  mediaPlaybackRequiresUserGesture=true (mute-by-default). Gravity-based corner/center
  placement with a 4% inset, GradientDrawable bg + corner radius, alpha=opacity, optional
  title bar. Single slot last-show-wins; duration timer; id-aware clear; teardown wrapped
  and also run on activity destroy (WebView cleanup).
- WebSocketService: onPipShow/onPipClear callbacks + safeOn handlers posted to the main
  thread (they build Views) + a sendLog(tag, level, message) emitter for device:log.
- MainActivity: instantiate PipOverlay (log -> wsService.sendLog("pip", ...)), wire the
  callbacks, tear down on destroy.

Verified: Android assembleDebug builds clean; web player inline JS parses; server suite
still 161/161 (no server changes this commit). Not yet validated on real hardware —
four-orientation corner positioning mirrors the player container/rootView transform but
should be eyeballed on a panel.

Refs #109

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:54:44 -05:00
ScreenTinker cbabbeb78c feat(preview): device-manager preview — second surface for #104 (combined)
Completes #104's two surfaces by reusing the now-generalized player preview
for devices, seam-safe (device-bound layout, NOT playlist-derived).

Server:
- GET /api/devices/:id/preview-payload returns buildPlaylistPayload(deviceId)
  — the device's OWN layout/orientation (device row) + its published items —
  with wall_config forced null (v1: wall members preview full-frame; a
  socket-free follower would otherwise freeze waiting for leader wall:sync).
  Device-READ gate (mirrors GET /:id, viewers allowed); NOT requirePlaylistRead.

Player (generalized, shared seam):
- Boot dispatch now accepts ?preview=1 with EITHER playlist=ID OR device=ID.
- bootPreview(qs) builds the right URL; shared body factored into
  renderPreviewFromUrl(url) used by both. Renderer still UNTOUCHED.
- derivePreviewLayout stays PLAYLIST-only; never touches the device path.

Dashboard:
- Device manager gets a Preview button -> /player?preview=1&device=ID
  (modal iframe, aspect from device orientation). Playlist-view button as-is.
- i18n x6 (device.preview_btn).

Validated (not just tests): 149 server tests green (generalization didn't
break the playlist path); device preview renders socket-free in headless
Chrome; layout proven device-bound on real data (device playlist has 0 zoned
items -> playlist-derivation would give NULL, but payload returns the device
row's "Vertical Full HD"); wall-member device previews full-frame (inWallMode
false) without freezing; auth gate outsider->403, no-token->401; playlist
path still renders the webpage note post-refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:57:19 -05:00
ScreenTinker 1c748b8d3b feat(preview): draft-aware device-free playlist preview via player reuse (#104)
Replaces the broken/fragmented preview with a single surface that renders a
DRAFT playlist exactly as a device does, by reusing the player's renderer in a
same-origin iframe. Fixes "not all items load" (one renderer, full type union)
and inherits the player's YouTube correctness (YT.Player handshake).

Server:
- deviceSocket: extract assemblePayload() (zone-reset + canonical shape) from
  buildPlaylistPayload so the device path and preview can't drift. Pure refactor
  (all 149 tests green).
- playlists: GET /:id/preview-payload (requirePlaylistRead, workspace-scoped).
  Draft-aware via buildSnapshotItems (live items, not published_snapshot);
  derivePreviewLayout() resolves layout from the playlist's own zone-bound items
  (0 zoned -> fullscreen; 1 -> use it; >1 -> dominant + ambiguous flag, never
  crashes). orientation validated/passthrough; wall_config/timezone null.

Player (renderer UNTOUCHED):
- ?preview=1&playlist=ID boot branch: fetch preview-payload (same-origin Bearer
  token) and call handlePlaylistUpdate(). Gated before the pairing/socket path
  so the unpaired auto-connect never fires. All socket emits already guarded.
- Webpage widgets: always-visible honest note (no auto-detection — an XFO
  refusal is provably indistinguishable client-side from a working embed).

Dashboard:
- playlists: Preview button + player-iframe modal with landscape/portrait toggle.
- widgets: same honest note on the existing widget preview modal (the surface the
  bug was reported on).
- i18n x6 (en/es/fr/de/it/pt) + player i18n x5.

Validated end-to-end (headless Chrome + CDP): preview boots, webpage note
renders, 3-zone layout derives+renders, shape parity with device snapshot proven
on real data, auth gate returns 401. The world-readable /uploads finding is
tracked separately as #107 (not a #104 concern — same path the device uses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:11:05 -05:00
ScreenTinker 2ccf3264a9 feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
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>
2026-06-11 15:46:41 -05:00
ScreenTinker 397aedf2d8 fix(player-web): don't optimistic-render fullscreen when layout is unknown
Follow-up to the layout cache. On a cold start with a cached playlist but no cached
layout yet (first run after shipping, or cleared cache), the player still rendered
fullscreen and flashed before the payload arrived. Now gate the optimistic cached
render on the layout being KNOWN (cache key present — null=fullscreen vs object=
zoned, both fine); if unknown, wait ~1s for the payload to drive the first render.
Eliminates the fullscreen flash on the very first pass too.
2026-06-09 08:30:58 -05:00
ScreenTinker 00964e90a8 fix(player-web): cache layout so cold start renders zones on first pass
The player cached only the playlist, not the layout. On cold start it restored the
playlist and rendered immediately with layout=null -> fullscreen, then re-rendered
into zones once the server payload arrived (the 'fullscreen first, then split'
flash). Cache the layout alongside the playlist and restore it before the first
render; cleared on reset.
2026-06-09 08:27:41 -05:00
ScreenTinker 4fe8e87416 fix(player-web): render widgets in any zone, not just zone_type=widget
A widget (e.g. directory board) assigned to a 'content' zone rendered as a black
zone: showZoneItem gated the widget branch on zone.zone_type==='widget', so the
widget was skipped and (mime_type null) nothing else matched either. Key off the
assignment's widget_id instead - matching the Android ZoneManager, which is why
the same layout worked on the APK but not the web player.
2026-06-09 08:22:05 -05:00
ScreenTinker 546fcdc105 fix(player-web): independent per-zone rotation in multi-zone layouts
Mirror of the Android fix. The web player showed only the FIRST assignment per
zone (playlist.find) and an image zone set the GLOBAL advanceTimer->nextItem, so
the whole layout re-rendered on one global tick instead of each zone cycling its
own content. Now each zone groups its assignments (by zone_id, sorted), renders
the first, and advances on its OWN timer (images/widgets/youtube: duration;
videos: on end; single-item zones loop). Cleared in teardown. Also render zones
before the single-item 'renderable?' bail so an empty current item can't blank
the screen.
2026-06-08 23:12:29 -05:00
ScreenTinker fe36c8c4b9 security(widgets): add sandbox="allow-scripts" to widget iframes
Addresses the primary finding from the May 27 security report (issue #8):
the admin widget preview modal (frontend/js/views/widgets.js) and the web
player widget renderer (server/player/index.html, 2 sites) loaded
user-authored widget HTML into unsandboxed iframes. Same-origin scripts
in the widget content could access window.parent.localStorage and
exfiltrate the JWT.

sandbox="allow-scripts" without allow-same-origin sandboxes the widget
into a unique origin: inline scripts (clock, RSS, weather widgets)
continue to work, but parent-origin access and same-origin requests are
blocked. Verified via Playwright probe against all 6 widget types in the
dev DB (clock, rss, social, text, weather, webpage): each renders
correctly under the new sandbox and contentDocument access from the
parent is blocked (opaque-origin enforcement working). Admin preview
unchanged in appearance; player display unchanged.

Webpage widget (server/routes/widgets.js) sandbox tightening (drop
allow-same-origin) is a separate forthcoming commit - needs test against
real embed URLs since some sites rely on same-origin behavior. The
sandbox-attribute intersection rule means today's outer-iframe sandbox
will cascade and strip allow-same-origin from the webpage widget's inner
iframe too; accepted as a narrow cosmetic regression (cookies/localStorage
stripped for embedded sites) until the deliberate inner-iframe handling
ships.

SECURITY.md added with reporting process (GitHub Security Advisories
primary, support@bytetinker.net fallback) and scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:28:34 -05:00
ScreenTinker 19f434d05a Add player debug overlay and server-side error telemetry sink
Smart TVs (Tizen, WebOS, Fire TV, Bravia) have no accessible browser
devtools, so when the player misbehaves on those platforms we previously
had zero visibility. This adds two paths to fix that:

- Visible debug overlay rendered on the TV screen for phone-photo capture
- Automatic server-side telemetry sink for hands-off error reporting

Client side (server/player/):
- Inline ES5 error trap as first script in index.html captures errors
  even from parse-time failures in later scripts. Captures into
  window.__debugLog with 200-entry cap.
- debug-overlay.js renders a fixed-position overlay covering the top 40%
  of the screen. Activates via ?debug=1, d-e-b-u-g key sequence, Samsung
  red button (keyCode 403), or smart-TV UA + ?autodebug=1. Freeze toggle
  (F key or Samsung green) with visible FROZEN badge for phone capture.
  pointer-events: none so touches pass through to the player underneath.
- Reporter machinery posts captured errors to /api/player-debug with
  5-second debounce batching, sendBeacon on unload (with payload size
  capping to stay under 64KB), 5-minute backoff after 429 responses.
  UA-gated: smart-TV allow-list first (handles Tizen-with-Chrome/108),
  modern-desktop deny-list second, default-report for unknown UAs.
- Two-pass djb2 fingerprint (16 hex chars) per error for future grouping.
- Absolute script src (/player/debug-overlay.js) so the script loads
  regardless of trailing-slash on the player URL.

Server side:
- New player_debug_logs table (10000-row FIFO cap, indexed on
  fingerprint + created_at). Schema in schema.sql, idempotent via
  CREATE TABLE IF NOT EXISTS.
- POST /api/player-debug unauthenticated (so unpaired players can also
  report), rate-limited 10/min/IP, per-field length caps to prevent abuse.
- Dynamic /player HTML route injects window.__playerConfig.debugReporting
  based on PLAYER_DEBUG_REPORTING env var (defaults on; =off suppresses
  all client telemetry traffic). Other player assets still served static.
- Admin routes (requireAuth + requireSuperAdmin):
  GET /api/player-debug/list with pagination and filters
  GET /api/player-debug/summary for UA family counts
  DELETE /api/player-debug/older-than for manual purge

Admin view (#/admin/player-debug):
- UA family summary at top (Tizen/WebOS/Fire TV/Bravia/Edge/Chrome/etc)
- Filter row: UA contains, date range, has-error checkbox
- Paginated table with expand-row JSON viewer for error_data and context
- device_id labeled (self-reported) since field is unauthenticated input
- Manual delete-older-than button with confirmation dialog

Verified end-to-end with Playwright + Chromium (17/17 checks pass) plus
manual real-browser verification including UA-spoofed Tizen flow landing
rows in the admin view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:20:42 -05:00
Christopher Cookman f6ef75549b Fix possible race condition in player auto-connect 2026-05-14 14:54:17 -06:00
Christopher Cookman 98e742c612
Merge branch 'screentinker:main' into main 2026-05-14 13:46:40 -06:00
Christopher Cookman d5e4e4d927 Feat: Web player auto connect
Add a simple 5 second countdown to the web player to get a code without interacting (for systems where interaction is a hassle, or impossible)
2026-05-14 13:46:19 -06:00
ScreenTinker 1aee4f2d5b fix(socket): raise Engine.IO ping/pong + prefer WebSocket transport
Connection-stability layer for issue #3. LG webOS WebKit (and other
TV-grade clients) miss Engine.IO pongs under decode load with the
Socket.IO defaults of 25s ping / 20s timeout, causing spurious
transport drops and a connect/reconnect/evict/disconnect loop on
the device. Default polling-first transport adds another fragility
layer via the polling->WebSocket upgrade dance.

- pingInterval / pingTimeout default to 30000 / 30000 (worst-case
  dead-socket detection 60s, up from ~45s). Both env-configurable
  via PING_INTERVAL / PING_TIMEOUT.
- Player Socket.IO client: transports: ['websocket', 'polling'].
  Tries WebSocket first; falls back to polling on the same connect
  attempt if WebSocket fails. Polling fallback preserved for
  firewall-restricted networks.

App-level heartbeat checker is unchanged and remains the safety net
for clients that miss the transport-level ping/pong window.

Tradeoffs documented in inline comments. README env table extended
with PING_INTERVAL and PING_TIMEOUT rows.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:02:34 -05:00
ScreenTinker 1e23335356 fix(player): graceful handling when displayed content is removed
Deleting a content asset that was actively displayed on screens
caused affected players to go black and never recover; deleting an
actively-playing video also failed to stop playback (audio kept
going). Root cause: handlePlaylistUpdate never tore down the current
media element and could drive currentIndex to NaN when a late
onended fired during the playlist swap.

- Add teardownCurrentMedia() - pause, clear src, .load() to actually
  release the decoder and kill audio; null event handlers to prevent
  late onended races
- handlePlaylistUpdate: preserve continuity - if the playing item
  survives the update keep it playing, otherwise walk forward from
  the old position to the next surviving item; empty playlist tears
  down to waiting state
- Guard playCurrentItem against empty playlist / non-finite index
- Remove dead device:content-delete socket handler (never emitted)

Resolves #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:17:40 -05:00
ScreenTinker 2068bc8833 Video walls: free-form canvas editor, leader-driven sync, group dissolve, progress bars
Wall editor: replaces the small grid with a Figma-style pan/zoom canvas. Each
display is a rectangle that can be dragged/resized to match its physical
arrangement; a separate semi-transparent player rect overlays the screens and
defines what content plays where. Drag empty space to pan, wheel to zoom,
"Center" button auto-fits content. Per-rect numeric x/y/w/h panel; arrow keys
nudge by 1px (10px with shift). Negative coordinates supported for screens
offset above/left of the origin. Coords rounded to integers on save.

Wall rendering: each device receives screen_rect + player_rect, maps the
player into its viewport with vw/vh and object-fit:fill so vertical position
of every source pixel is identical across devices that share viewport height.
Leader emits wall:sync at 4Hz with sent_at timestamp; followers apply
latency-adjusted target and use playbackRate ±3% for sub-300ms drift,
hard-seek for >300ms. Followers stay muted; leader unmutes via gesture with
AudioContext priming and pause+play retry to bypass Firefox autoplay.
"Tap to enable audio" overlay as a final fallback.

Reconnect handling: server re-evaluates leader on device:register so the
top-left tile reclaims leadership when it returns. Followers emit
wall:sync-request on entering wall mode (incl. reconnect) so they snap to
position immediately instead of drifting until the next periodic tick.

Group dissolve: removing a device from its last group clears its playlist
to mirror wall-leave semantics. Leaving a group with playlists on remaining
groups inherits the next group's playlist.

Dashboard: walls render as their own card section (hidden the device cards
they contain). Multi-select checkboxes on cards + "Create Video Wall" toolbar
action that creates the wall, removes devices from groups, and opens the
editor. dashboard:wall-changed broadcast triggers live re-render. Per-card
playback progress bar driven by play_start events forwarded from devices.

Security: PUT /walls/:id/devices verifies caller owns each device (or has
team-owner access via the widgets pattern), preventing cross-tenant device
takeover. wall:sync and wall:sync-request validate that the sending device
is a member of the named wall; relay re-stamps device_id with currentDeviceId
so clients can't spoof or shadow-exclude peers.

Schema: video_walls += player_x/y/width/height, playlist_id;
video_wall_devices += canvas_x/y/width/height. All idempotent migrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:11:16 -05:00
ScreenTinker aebaacf2c1 i18n batch 7: index.html modal + player overlay
- Add-Display modal in index.html: marked translatable elements with
  data-i18n / data-i18n-placeholder / data-i18n-html attributes
- app.js: translateStaticDom() walks data-i18n* on init and on every
  language-changed event so static HTML stays in sync
- server/player/index.html: standalone player gets its own inline
  PLAYER_I18N table (en/es/fr/de/pt) with a tiny _t() helper. Reads
  rd_lang from localStorage (set by dashboard) so the player picks up
  the same language. Translates info overlay, setup screen, and
  status messages.
- 1018 keys total in dashboard locales, parity 100%.

This completes the wiring; Android resources are next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:19:06 -05:00
ScreenTinker b2aa7fab54 Player: keep video playing if unmute is blocked
video.play().catch(() => {}) silently swallowed the rejection from the
browser's autoplay policy, so when a user click triggered the unmute
path the video paused (browser side-effect of unmuting a muted-autoplay
video) and never resumed.

Surface the play() rejection in the log, and fall back to muted playback
if the unmuted play() is blocked. Same for the YT side: explicitly set
volume on unmute. Bumped SW cache to v9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:18:32 -05:00
ScreenTinker a3551a2654 Player: only request fullscreen on real user clicks
The remote-control feature dispatches synthetic click events on the
player when the dashboard forwards touches. The global click handler
called requestFullscreen() on every click, but the browser only honors
that API for trusted user gestures — synthetic events rejected with
"Permissions check failed" / "API can only be initiated by a user
gesture", spamming the console for the duration of any remote session.

Gate the fullscreen request on event.isTrusted. Local user clicks still
trigger fullscreen; remote-control taps no longer try (and fail).
Bumped SW cache to v8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:13:58 -05:00
ScreenTinker a4c85eaabc Remove playerContainer position:relative override that nuked YT iframe
createYoutubeEmbed set container.style.position = 'relative' to anchor
the click-to-unmute overlay. That overrode #playerContainer's
position:fixed/inset:0 — the container fell into normal flow with
zero height (the YT iframe inside has no intrinsic size), so the new
absolute-positioned iframe rendered as 100% of 0 = black screen.

The container is already position:fixed, so absolute children anchor
to it correctly without the override. Removed the line. Bumped SW
cache to v7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:36:39 -05:00