Commit graph

24 commits

Author SHA1 Message Date
ScreenTinker 5a7277523a Wire BrightSign native sync end to end, chosen per group
st-sync.js wrapped SyncManager but nothing drove it. The player now does: the
leader opens a new sync session on each advance, and every member — the leader
included — binds the video with attachVideo() on a NEW id only.

The leader binds from its own broadcast rather than at announce() time on
purpose. Starting when it announces would put it ahead of its followers by the
width of the network, which is the one desync nobody would think to look for
because the leader always looks correct.

Item selection stays clock-derived under both backends. Native sync replaces
only the seek/nudge drift correction, because setSyncParams has the element hold
its own alignment and correcting it ourselves would fight the platform — every
frame we moved is one it then has to undo. Keeping selection on the shared clock
is also what keeps images and widgets, which have no setSyncParams, advancing
with the videos instead of drifting off alone.

LEADER RULE: reuse the existing election (resolveGroupLeader) rather than adding
a column. It already resolves pinned-if-online, else first online member on the
shared playlist, else first by id — deterministic, stable, and already what the
group-sync payload reports. A second mechanism could only disagree with it.

Added on top: a group whose elected leader is OFFLINE falls back to our
protocol. Ours is leaderless and carries on; native sync has exactly one
broadcaster, so those members would sit waiting for an announcement that never
comes, with the dashboard showing a healthy group throughout.

device_groups.sync_backend ('auto'|'screentinker'|'brightsign') is the operator's
REQUEST; the answer comes from the existing pure resolveSyncBackend() so the
players, the dashboard and the stored setting cannot disagree. The resolved
backend, reason and downgraded flag ride in the group_sync payload and in the
group API, and the dashboard shows the refusal reason instead of a setting that
quietly isn't in force. An unrecognised value is rejected rather than stored,
because the resolver reads anything unknown as 'auto' — a typo would otherwise
return 200 and run a different protocol than the UI displayed.

FIXED WHILE HERE: the player re-entered group sync only when the group ID
changed, with a comment noting the clock protocol has no leader role. Native
sync has one, and neither a protocol switch nor leadership moving alters the
group id — so a player promoted to leader kept behaving as a follower, nobody
announced, and the group sat unsynchronised. The re-enter key now includes the
backend and the leader flag.

971 pass (+17).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:06:18 -05:00
ScreenTinker ead452d9b1 Dragging a screen onto a group now moves it instead of adding it
Reported by a customer with two screens and two groups: dragging a screen from one group
to the other showed a confirmation, changed what the screen was playing, but left the
displays page showing the old group — and a second attempt said it was already in group 2.

All three observations were correct. The drop handler borrowed the Manage modal's
"add it to X too?" confirm, then called addDeviceToGroup and nothing else, then reported
"Moved {name} to {group}". So it asked about adding, claimed to move, and added: the
screen ended up in BOTH groups. The page was not stale, it was accurate — and the retry
was right too, because by then it really was in group 2 as well as group 1.

The screen's content DID change because joining a group syncs the device's playlist to
the group's, which is why it looked half-applied rather than broken.

Drag is a move gesture, so it now removes the other memberships after adding the new one
— add first, so a failure leaves the screen in the group it already had rather than
ungrouped by a half-finished move. A removal that fails warns rather than reporting
success it did not achieve.

The Manage modal is deliberately left alone: its checkboxes are add/remove and its "too?"
wording is accurate there. Multi-group membership is a real feature; it just is not what
dragging means.

Not merely cosmetic: deviceSyncGroup() notes it picks "deterministically if it's somehow
in several", so a screen left in two sync-enabled groups gets an arbitrary one. A
half-completed move leaves synchronised playback ambiguous.

Strings added to the six locales that carry the dashboard set; hi.js has none of them and
falls back to English.
2026-07-29 21:33:56 -05:00
ScreenTinker 618af0811a Translate the labels that never went through t()
A title= is a tooltip the user reads and an aria-label is what a screen reader
says, but fourteen of them were hardcoded English. They were invisible to the
key checks added earlier precisely because they never call t() — so a French
user hovering the only route to workspace members read "Manage members", and a
German screen reader announced every modal's close button as "Close".

The user-visible ones matter most: the workspace switcher's Manage members and
Rename, the video wall's rename and remove, and the dashboard's select-for-wall.
All are translated into every active locale, along with the close buttons.

A test now rejects a capitalised literal in a title or aria-label, since that is
the shape this takes and nothing else catches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:52:52 -05:00
ScreenTinker 68dd1b3e05 Tell people what to do next, from what the account actually contains
A user reported not knowing how to get content onto a screen. There was already
onboarding — a modal wizard — but it is gated on a localStorage flag: skip it
once and it never comes back, and it never knew whether you succeeded at
anything. Someone who closed it was left with no thread to pull, which is
exactly what was described.

A second tour would repeat that mistake. Tours are dismissed and forgotten, and
they describe the product rather than the account. This is a checklist on the
dashboard that reads real state, so it cannot claim you have done something you
have not, it is still there tomorrow, and it names the one thing to do next
rather than everything the product can do.

The steps are the shortest true path to a screen showing something: connect a
screen, add content, put it in a playlist, send it to the screen. Only the last
one cannot be satisfied by creating an object and walking away — a screen has to
actually be pointed at something — so an account full of playlists with nothing
playing is correctly reported as unfinished, which is the failure that was
reported. Steps stay in dependency order, so nobody is sent to a page they
cannot use yet.

It disappears on its own once the first screen is live and can be hidden before
then, so it never nags someone who already knows the product. Once hidden or
finished it costs no extra request at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 18:06:47 -05:00
Fabian Mendoza e7483dfc24
feat(ui): show server URL in Add Display modal + GitHub Releases link on /download/apk (#210)
- Add Device modal now shows the server URL and full Smart TV player URL
- Smart TV note changed from bare /player to full URL (dynamic via JS)
- /download/apk error page now includes a download link to GitHub Releases
- i18n keys added in en + es, old smart_tv_note removed
2026-07-23 10:25:00 -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
ScreenTinker f1fe5d97bd feat(dashboard): exit-reason display — Offline annotation + tooltip + filter drill-in + list label
Surface the server's manner-of-death (crashed / clean_exit / silent) as a subordinate qualifier ON the
Offline badge (not a 4th liveness state), on both the device list and device-detail. Rides livenessBadge.
- Reliability-aware label (contract §10): clean_exit reads plainly on /player (reliable), "(best-effort)"
  on APK/.wgt. silent = "silent (no signal)".
- Honest hover tooltip on every reason (both views), incl. silent = "external/violent: power loss, network,
  force-stop, or MDM/kill". Never fabricates a reason (no-reason -> plain Offline); state-gated (reason only
  on Offline); clears on re-online (matches the server).
- Filter drill-in: <optgroup> "Offline by reason" -> Offline · silent / crashed / clean exit, matched via a
  data-offline-reason attribute (Offline·silent = the MDM-killed set — the Bold use case). Existing
  three-state filter (All/Healthy/Reconnecting/Offline) unchanged.
- List label shortened to fit the pill (full text stays on detail; tooltip carries the full honesty both).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:32:55 -05:00
ScreenTinker 2772d1fc4d fix(dashboard): liveness badge filter regression + list-view legibility
Two follow-ups from the alpha diagnosis:
- FIX A (regression): filterDevices() compared badge TEXT to the option values 'online'/'offline', but
  the badge text is now "Healthy"/"Reconnecting"/"Offline" — so selecting a status filter matched
  nothing and emptied the dashboard. Now compares the liveness STATE via a data-liveness attribute, and
  the filter is upgraded to All / Healthy / Reconnecting / Offline (an admin can filter TO reconnecting
  devices — the point of the Degraded distinction).
- FIX B (legibility): the list rendered liveness as a status-dot where healthy=green/offline=red were
  visually identical to the old indicator, so it didn't read as new. The list now renders the same
  device-status-badge PILL as device-detail (3 distinct colors; amber Reconnecting visible on the list),
  scoped with an is-liveness modifier so video-wall cards keep their dark "NxN wall" pill.

Frontend-only. 14/14 filter+render tests; full ES-module parse clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:56:18 -05:00
ScreenTinker a458c8f96a feat(dashboard): 3-state liveness badge (consume the patch4 server signal)
The patch4 server derives 3-state liveness (healthy / degraded-reconnecting / offline) and emits it as
data.liveness on dashboard:device-status, but the frontend only consumed binary online/offline — the
signal was thrown away. Add a shared livenessBadge() helper (utils.js) consumed by both the dashboard
device list and the device-detail view (initial render + live statusHandler). Degrades to the binary
status when liveness is absent (old payload / plain reconnect+disconnect emits / DB device object) so
nothing renders blank; unknown/no-data -> offline default. CSS: healthy=green, degraded=amber+pulse
(reads as reconnecting), offline=red — reusing the existing --success/--warning/--danger tokens. Labels
in en.js (all locales fall back to en). Frontend-only; server derivation unchanged. 13/13 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:22:15 -05:00
ScreenTinker 0c0a8dd68a fix(ota): surface stuck OTA on dashboard + read APK signer correctly on API 28/29 (#139)
Follow-up to the cache/backoff loop fix (aa23cf0): make a device that can't
self-install visible to operators, and fix the signature-verify bug that kept the
whole #139 fix from engaging on the actual Fire OS target.

Dashboard surface (Phase 2):
- devices gains ota_status / ota_target_version / ota_attempts / ota_updated_at
  via the idempotent ALTER TABLE ADD COLUMN migration (non-destructive,
  default-backfilled, idempotent on re-run).
- The device reports ota_status (OtaThrottle.statusFor -> none | pending |
  manual_update_required) in device_info; the server persists it on register
  (the reconnect backstop). devices d.* already surfaces it to the dashboard.
- Dashboard shows a non-blocking amber badge when manual_update_required
  ("Update available (vX) - install failed N times, manual update required");
  i18n key in en.js (non-en inherits via the en fallback). Server suite +1 test.

Event-driven status (Option B):
- New device:ota-status WS message, emitted on STATE TRANSITIONS only
  (enter-backoff -> manual_update_required, clear -> none), so the badge updates
  promptly without waiting for a reconnect and without per-poll/heartbeat chatter.
  Server handler persists the same fields; an unknown/forged device_id is a safe
  no-op. The register-path persist stays as the reconnect backstop.

Signature-verify fix (the critical piece):
verifyApkSignature read the downloaded APK's signer via
getPackageArchiveInfo(GET_SIGNING_CERTIFICATES).signingInfo, but that field is
null for ARCHIVE files on API 28/29 (populated only from API 30). On Fire OS 8
(Android 9 / API 28) - the actual deployment target - this returned 0 certs from
a correctly-signed APK, so every OTA was refused as "tampered," the cache was
deleted, and the full APK re-downloaded every check cycle. This was the real
cause of the #139 re-download loop, NOT a silent-install failure: the cache and
backoff added in this branch sit behind this verify gate and never engaged on
the target.

Fix: below API 30, read the archive's signer via the legacy GET_SIGNATURES +
.signatures (its v1/JAR cert, which IS populated on 28/29). Keep
GET_SIGNING_CERTIFICATES + signingInfo for API >= 30 and for the installed-app
read (which works on 28+). The archive's signer is still extracted and compared
to the installed app's signer; a mismatch or zero-cert APK is still rejected.
This reads the cert correctly on old APIs - it does not weaken verification.

Verified on emulators:
- API 28: verify now passes for a legit APK (was: 0 certs, refused). Full backoff
  then engages - 8.5MB pulled once, cache-hit on retries, backoff after 3,
  manual_update_required emitted once; clears on successful update.
- API 28 negative: a re-signed (different-key) APK is still refused on cert
  MISMATCH - no hole opened.
- API 30: unchanged path still passes (no regression).
- server suite 173/173, OtaThrottleTest 7/7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:49:01 -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 5d24c30ea1 feat(displays): drag-to-reorder display tiles within a section (#106)
Option A: tile-on-tile (same section) reorders; tile-on-section / cross-
section stays group-assign (existing behavior untouched). Ordering is
cosmetic (dashboard only — nothing the device/player reads).

Backend:
- Migration: devices.sort_order column (idempotent ALTER; default 0).
- GET /api/devices ordering: sort_order ASC, created_at ASC (was created_at).
- POST /api/devices/reorder — ordered id array -> transactional
  UPDATE sort_order=index, scoped WHERE workspace_id = caller's workspace
  (forged cross-workspace ids are no-ops). Write-gated (viewer read-only).
  Mirrors the playlist items reorder.

Frontend (the collision):
- Card-level dragover/drop: reorder ONLY when target is another card in the
  SAME section; otherwise no-op so the event bubbles to the section's
  group-assign handler. stopPropagation on the same-section drop prevents
  the section handler also firing. Drop indicator (inset box-shadow).
  Native HTML5 DnD; no library.

Validated (headless Chrome, synthetic DnD + a section-level drop spy):
- SAME-section reorder: section drop suppressed (sectionDrops=0), POST
  /devices/reorder fires, NO group call, sort_order persists in DB.
- CROSS-section: section drop fires (sectionDrops=1), POST /groups/:id/
  devices fires and membership actually changes — group-assign unbroken.
- The 0-vs-1 contrast proves stopPropagation disambiguates the shared gesture.
- 149 server tests green; migration applies clean on the prod-copy DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:15:21 -05:00
ScreenTinker a77ab365dd fix(dashboard): selection bar surfaces 'pick 1 more' hint when 1 display selected so the disabled Create Video Wall button isn't silently unresponsive 2026-05-11 22:56:15 -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 8e7a093150 i18n: extract all strings, add 6 language translations, restructure i18n module
Session 1 of 2 of the i18n rollout.

- Split i18n module into per-language files under frontend/js/i18n/ so a
  translator can edit one language without touching the others.
- Add Portuguese (pt) and seed Hindi (hi). Hindi is intentionally a skeleton
  -- 0 keys, full English fallback -- because we have an active Indian user
  and would rather ship "no Hindi" than ship machine-quality Hindi that
  could read as unprofessional or get formality/gender register wrong.
- 183 keys, 100% parity across en/es/fr/de/pt; native review still
  recommended before publicizing as "fully supported".
- Add t(key, vars) variable substitution and tn(keyBase, n, vars) plural
  helper for _one/_other key pairs.
- setLanguage() now triggers a CustomEvent + HashChangeEvent so the
  existing hash router naturally re-renders the current view, plus a
  subscriber pattern for nav labels rendered once outside the router.
- Wire t() into 3 high-traffic views end-to-end: dashboard, login,
  content-library. Sidebar nav labels in app.js update on language change.
- The remaining 16 views still ship with hardcoded English; they will be
  wired in session 2. The t() lookup is robust against unwired views, so
  the dashboard works in 5 languages while clicking into e.g. Schedule
  still shows English. No regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:25:22 -05:00
ScreenTinker 63dcc2b656 Drag-and-drop devices into groups on the dashboard
Device cards are now draggable. Group sections accept drops to add
membership (mirroring the Manage modal — same confirmation if the
device is already in another group). The Ungrouped section also
accepts drops to remove the device from every group it's in.

The existing Manage modal still works for bulk add/remove and for
finding devices not currently visible. Click-to-open on a card still
works; drag is only triggered on actual mouse movement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:54:33 -05:00
ScreenTinker ee6888e737 Fix display duplication on WebSocket reconnect
Server-side: when a device reconnects on a fresh socket while the old
TCP zombie is still around, the old socket's eventual disconnect handler
flipped the device offline and removed the new heartbeat entry. Now we
proactively evict any prior socket on register and ignore disconnects
from sockets that are no longer the registered one for that device_id.

Frontend: dedupe devices by id from the API response and only render
each device in the first group it belongs to (multi-group membership
is still tracked for the Manage modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:00 -05:00
ScreenTinker 7c8504d593 Mobile: grid + layout reflow (Commit 2/4)
- Dashboard stats row (.dash-stats-row): flex column on mobile
- Content-library toolbar: stack upload area + remote URL + YouTube boxes vertically
- Info grid: 1 col on mobile (was 2 col); device detail metadata reads cleaner
- Content grid: drop to 1 col below 480px (iPhone SE)
- Schedule controls: wrap, device select fills row
- Schedule calendar: already wrapped in overflow-x:auto, kept horizontal-scroll
  approach (future: dedicated mobile day-view)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:50:56 -05:00
ScreenTinker 52dd44a3e8 Add group-level scheduling, group playlist assignment, and persist audio unlock
Phase 4 group scheduling: schema migration adds group_id to schedules with
CHECK constraint, scheduler evaluates group+device schedules with priority,
group deletion converts schedules to per-device copies. Dashboard gets
playlist assignment dropdown and current playlist label on group headers.
Player persists audio unlock state in localStorage so version reloads
don't lose audio on unattended displays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:22:42 -05:00
ScreenTinker afbe113acf Security audit remediation: auth, IDOR, XSS, hardening
- Device WebSocket authentication: devices get a device_token on
  registration, must present it on reconnect. All WS events require
  prior auth. Timing-safe token comparison.
- IDOR fixes: ownership checks on schedules (device, week), layouts
  (all CRUD, zones, duplicate, device assign), video-walls (content,
  device-config).
- XSS prevention: shared esc() helper in utils.js, fixed 13 innerHTML
  injection points across 9 frontend files.
- OAuth hardening: no longer silently overwrites auth_provider on
  accounts with local passwords (returns 409).
- JWT pinned to HS256 for sign and verify.
- Password policy: change endpoint now requires 8 chars (was 6).
- HSTS header enabled (max-age 1 year, includeSubDomains).
- Stripe webhook rejects unsigned payloads when no secret configured.
- Screenshot size validation (max 2MB base64).
- Rate limiting on exports, imports, content operations.
- Content file serving checks playlist_items instead of old assignments.
- Content ownership verified in device-groups assign-content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:48:07 -05:00
ScreenTinker f57fc5ad81 Security hardening: auth checks, XSS escaping, input validation
- Add requireGroupOwnership middleware to all group endpoints
- Whitelist allowed command types (screen_on/off, launch, update, reboot, shutdown)
- Validate color format as #RRGGBB
- Escape all user-controlled strings (device/group names, emails) in dashboard HTML
- Restrict trust proxy to first hop only (prevents IP spoofing + rate limit bypass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:09:40 -05:00
ScreenTinker faa437881f Add device groups UI, group commands, proxy IP fix, and web player detection
- Dashboard now organizes devices by group with colored section headers
- Group command endpoint (POST /groups/:id/command) sends to all members
- Manage modal with multi-group confirmation prompt
- Destructive commands (reboot/shutdown) require confirmation
- Ungrouped devices shown separately at bottom
- trust proxy + X-Forwarded-For for real client IPs behind Nginx
- Hide Android-only telemetry (battery/storage/RAM/CPU/WiFi) for web players

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:03:44 -05:00
ScreenTinker 1594a9d4a4 Initial open source release
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:14:53 -05:00