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>
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>
Once inside a folder, the only drop targets shown were that folder's
own subfolders — no way to drag a file back up to root or to a parent
without opening the edit modal. Breadcrumb segments now accept content
drops: drop on 'All Content' to move to root, or onto a parent folder
name to move there. The edit modal still works for cross-branch moves.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Released APK 1.7.8 includes the OOM/crash-loop fix, WebSocket crash
hardening, and the http(s)-only ImageLoader scheme guard. Bumped
versionCode 10 -> 11 and versionName 1.7.7 -> 1.7.8 so existing
1.7.7 installs auto-update on the next UpdateChecker poll.
Also fixed the safeOn extension function: Socket.on() returns Emitter,
not Socket, so the original `return on(...)` failed compile with a
type mismatch. Switched to `on(...); return this` for proper chaining.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The previous CSS fix used 100% width/height but YT.Player can bake in
300x150 fallback pixel dimensions if the placeholder isn't laid out at
construction time. Inline pixel dimensions beat percentage CSS at
equal specificity, so the iframe stayed small.
Use absolute positioning with !important to force fullscreen over
whatever YT set inline. Bumped sw cache to v6 to invalidate the
previously-cached player HTML.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .zone iframe sizing rule only applies to multi-zone layouts. In
fullscreen single-zone mode the YT IFrame API replaces our placeholder
div with an iframe directly inside #playerContainer, where no CSS rule
sized it — leaving it at the iframe default size (~300x150) and
producing a tiny square in the corner. Added explicit rules so any
iframe child of #playerContainer fills the viewport.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
userHasInteracted was initialized from localStorage('rd_audio_unlocked')
on every page load. Browser autoplay policy is per-document, so a flag
from a prior session does not actually grant autoplay rights — but the
player code used it to decide whether to start the YouTube embed muted
(autoplay-able) or unmuted (blocked). Result: kiosks with the flag set
loaded a YT embed with mute=0 that the browser refused to start.
- userHasInteracted now always starts as false. The cold-load tap
overlay flips it to true on real gesture; the 5s auto-dismiss leaves
it false and playback stays muted (still allowed).
- unlockAudio() now also calls activeYtPlayer.unMute() so the muted
embed unmutes immediately when the user finally taps the overlay.
- Removed the now-unused localStorage writes of rd_audio_unlocked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser autoplay policy is per-document — a previous session's
localStorage flag does not grant the new page autoplay rights. The
'audio previously unlocked, skipping tap overlay' branch was racing
with YouTube's autoplay block, leaving the player stuck on a paused
embed.
Removed the skip-overlay optimization. The existing 5s auto-dismiss
+ muted-connect fallback still handles unattended kiosks, and a real
user only needs to tap once per cold load to get audio.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cached-playlist restore at the top of the script synchronously calls
playCurrentItem -> renderContent -> createYoutubeEmbed, which references
ytGeneration / activeYtPlayer / ytApiReady / ytApiCallbacks. Those were
declared with `let` further down in the script, so the references hit
the temporal dead zone and threw on every cold start with a YouTube
item in the cached playlist:
Uncaught ReferenceError: can't access lexical declaration
'ytGeneration' before initialization
Hoisted the four declarations to the top of the script alongside the
other player state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two pre-existing bugs surfaced during deploy:
- /api/devices/:id/screenshot fell back to a query referencing
screenshots.created_at, but the schema column is captured_at. Threw
SqliteError 500 whenever the in-memory cache was cold (e.g. just
after a server restart).
- The SPA catch-all at /* served index.html for non-/api paths but did
nothing for unmatched /api/ paths — the response hung until the
upstream timeout (524 from Cloudflare at 15s). Now returns 404 JSON.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LOW 1 (notes XSS): device.notes textarea content now goes through
esc(). Notes weren't in the sanitizeBody allow-list at write time, so
HTML in the field would render unescaped on the device-detail page.
LOW 2 (CSP): enabled Helmet contentSecurityPolicy with default-src
'self', script-src 'self', style-src 'self' 'unsafe-inline', plus the
data:/blob:/https: image and media sources the player needs. Strict
script-src blocks <script> injection; script-src-attr 'unsafe-inline'
keeps existing inline onclick handlers working until they can be
refactored to addEventListener (TODO comment in code).
CSP applies to /app and most other paths. Skipped on the public
widget and kiosk render endpoints, the landing page, and /player —
those legitimately need inline scripts/styles. upgrade-insecure-
requests is explicitly disabled so HTTP-only self-hosted LAN
deployments aren't broken.
Refactored two inline onclick handlers in index.html to data-close-
modal attributes wired by a delegated listener in app.js. Was the
only blocker for /app under strict script-src.
LOW 3 (CORS): Express CORS now only allows screentinker.com (and
subdomains) + localhost in production. SELF_HOSTED=true bypasses the
allowlist (operator owns their deployment). Development mode stays
open. Same policy applied to the Socket.IO CORS config which was
previously origin: '*'. Native clients (Android, server-to-server,
kiosk iframes) send no Origin and pass through unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HIGH 1 (teams IDOR): POST/DELETE /api/teams/:id/devices now require the
caller to own the device before assigning or detaching it. Without this
check, any team member could pull any device into their team via UUID
guess and gain remote-control access.
HIGH 2 (schedules IDOR): PUT /api/schedules/:id now re-verifies
ownership of every changed target field — device_id, group_id,
content_id, widget_id, layout_id, playlist_id. Previously only the
schedule owner was checked, letting users fire arbitrary content on
victim devices via update.
HIGH 3 (filename XSS): file.originalname captured by multer bypassed
sanitizeBody. New safeFilename() wraps every INSERT path (multipart
upload, remote URL, YouTube). Frontend sinks now go through esc() in
content-library.js, device-detail.js, video-wall.js. Web player gets
an inline escHtml helper for its info overlay where filenames, device
name, and serverUrl land in innerHTML.
HIGH 4 (kiosk public XSS): config.idleTimeout is now coerced via the
existing safeNumber() helper at both interpolation sites. A crafted
value with a newline can no longer escape the JS line comment to
inject arbitrary code into the public render endpoint.
HIGH 5 (folder DoS): POST /api/folders enforces a per-user cap of 100
folders (429 on overflow). Superadmin exempt.
MED 1 (SSRF): ImageLoader.decodeUrl rejects any URL scheme other than
http(s) so a malicious remote_url can't read local files via file://.
On the server, validateRemoteUrl() is extracted and now also runs on
PUT /api/content/:id remote_url updates — previously the SSRF check
only fired on POST.
MED 2 (fingerprint takeover): the WS device:register fingerprint
reclaim path now rejects takeover while the target device is online or
within 24h of its last heartbeat. A leaked fingerprint can no longer
hijack an active display.
MED 3 (npm audit): bumped uuid 9.x -> 14.0.0 (v3/v5/v6 buffer bounds
CVE; we only use v4 so not exploitable, but clears the audit). path-
to-regexp resolved to 0.1.13 via npm audit fix. 0 vulns remaining.
MED 4 (folder admin consistency): ownedFolder() and the content.js
folder_id move check now both treat only superadmin as privileged,
matching GET /api/folders. Previously a plain "admin" could rename
or delete folders they couldn't see, and could move content into
folders they couldn't list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
multer/busboy decode multipart filename headers as latin1 by default,
which mangled umlauts and other non-ASCII characters end-to-end
(Größe.jpg arrived as Größe.jpg and was stored that way). Setting
defParamCharset: 'utf8' on the multer options makes the entire
upload pipeline consistent UTF-8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New content_folders table with hierarchical parent_id and per-user
scoping. content.folder_id added (ON DELETE SET NULL so deleting a
folder drops items back to root). New /api/folders route exposes
list/create/rename/move/delete with cycle detection on move.
Content library UI: breadcrumb navigation, subfolder grid, "+ New
Folder" creates inside the current folder, drag-and-drop content
items onto folder cards to move them, and the edit modal has a
folder dropdown showing each folder's full path.
Per-user scoping is enforced server-side: every folder query
filters by user_id, and folder ownership is checked on both folder
mutations and content.folder_id updates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every Socket.IO listener now goes through a safeOn helper that wraps
the body in try/catch(Throwable). Unsafe args[0] as JSONObject and
data.getString() patterns replaced with firstOrNull as? JSONObject
and optString — a malformed payload from the server, or a transient
state error during disconnect, no longer surfaces as an unhandled
exception on the IO thread.
Reconnection now uses explicit exponential backoff with jitter
(1s → 60s, randomizationFactor 0.5) so a fleet doesn't reconnect in
lockstep after a server blip. EVENT_DISCONNECT stops the heartbeat
while disconnected; the player keeps showing cached content. register,
sendHeartbeat, requestPlaylistRefresh, sendScreenshot, sendContentAck,
sendPlaybackState, and disconnect are all wrapped — telemetry / WiFi
service calls can throw on some devices.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A 4K image assigned to a 1080p display decoded as a ~33 MB ARGB_8888
bitmap and OOM'd. Worse, the cached playlist on disk meant relaunch
hit the same image and crashed again — only a reinstall recovered.
New ImageLoader utility reads bounds via inJustDecodeBounds, computes
inSampleSize against the device screen (or zone size for multi-zone
layouts), and returns null on OOM/Throwable so callers skip the item
instead of crashing. MediaPlayerManager exposes an onImageError
callback wired to playlistController.next() so a bad item advances
the playlist. The cached-playlist restore in onCreate now catches
Throwable (was Exception) and clears the cache on any failure,
breaking the crash loop. android:largeHeap="true" added as belt and
braces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Curl-pipe URLs, --help output, clone-and-run path, and the root-check
error message all referenced pi-setup.sh / setup.sh / screentinker/pi,
none of which exist. Point them all at the actual filename and path:
scripts/raspberry-pi-setup.sh.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Turns the Raspberry Pi script from a basic Chromium kiosk launcher
into a full installer with two modes:
- All-in-One: installs Node.js, clones the repo, runs the server
on port 3001, and launches the kiosk pointing at localhost. One
Pi does everything.
- Player-Only: connects to an existing server; same kiosk behavior
as before but with better Chromium flags and crash-flag cleanup.
Other changes:
- Detects Pi OS Lite vs Desktop and adjusts strategy (startx + vt1
for Lite, plain kiosk launcher for Desktop)
- Auto-login on tty1 for Lite installs
- GPU memory, overscan, console-blanking, and watchdog tweaks
- screentinker-{status,update,logs} management commands
- MOTD with command hints
- Cleans up the legacy remotedisplay.service / kiosk script on
upgrade so old installs migrate cleanly
- set -euo pipefail, root check, architecture check, tee'd log at
/var/log/screentinker-setup.log
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- README + landing page footer now link to the community Discord
- Landing page feature grid gains Playlists, Directory Board,
Offline Resilience, and Mobile Dashboard cards; Scheduling and
Self-Hosted copy updated to mention group-level schedules and
the DISABLE_REGISTRATION env var
- Structured data featureList expanded to match; Organization
sameAs now includes Discord
- README feature list clarifies scheduling precedence, mobile
responsiveness scope, and the auth/IDOR/XSS audit work
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The JWT only carries { id, email, role } and the server reads plan_id
fresh from the DB per request, but the frontend cached the user object
in localStorage at login and never refreshed it. After an admin changed
a user's plan, the dashboard kept rendering the old plan until the
user logged out and back in.
Added api.getMe() and a refreshCurrentUser() helper that runs at
startup and on every hashchange. Settings page now fetches the user
fresh via api.getMe() on render, with localStorage as fallback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Root cause: the Settings page loaded /api/white-label into the form
inputs but never applied the saved values (primary_color, bg_color,
brand_name, favicon, custom_css) to the actual document. Nothing in
app.js bootstrap touched branding. So the save hit the DB correctly,
reload kept the DB value correctly, but the page always rendered the
hardcoded defaults from css/variables.css and the static "ScreenTinker"
label in index.html — which looked like the save had reverted.
Fix: new frontend/js/branding.js module that fetches /api/white-label
once at startup (app.js) and applies values to:
- --accent and --bg-primary CSS vars
- document.title and the .sidebar-header .logo span text
- all <link rel="icon">/apple-touch-icon hrefs
- a <style id="wl-custom-css"> tag for custom_css
- the theme-color meta tag
Settings save now calls resetBranding() after POST so changes apply
immediately without a reload.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When DISABLE_REGISTRATION=true (or 1), POST /api/auth/register returns
403 with a clear error. OAuth endpoints (/google, /microsoft) also
refuse to auto-create new accounts — existing OAuth users can still
sign in. First-user setup (empty users table) is always allowed so a
fresh install can still be initialized.
GET /api/auth/config now returns registration_enabled so the login
view can hide the "Create Account" button and the trial banner when
registration is off. Absence of the flag is treated as enabled for
back-compat with older servers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extend the public /api/content/:id/file gate to unlock content referenced
by widgets (previously only playlists unlocked it), so device browsers
and kiosk iframes can fetch logos and background images that widgets
embed.
Security: scope the widget lookup to the content owner's widgets only
(w.user_id = content.user_id). Otherwise a user could unlock another
user's content file by creating their own widget whose config references
the victim's content UUID. The pre-existing playlist gate has the same
shape and is left for a separate fix.
Also adds a 30/min rate limit on POST /api/widgets/preview, which
inlines user content as base64 and is memory-intensive.
Perf note: the widgets.config LIKE scan is O(n). Fine at current scale;
revisit with a content_widget_refs join table if the widget table grows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inline editor with:
- Collapsible categories, reorder up/down, delete
- Entries with identifier, name, subtitle, available toggle
- Add/remove with auto-focus on new row
- Empty state prompts first category
- Theme, scroll speed, column count selectors
- Reusable content picker (single/multi-select) against user's image library
- Logo picker + background image picker (multi) via that picker
- Preview button posts unsaved config to /widgets/preview and shows the
returned HTML in a modal iframe (srcdoc + injected <base> so relative
content URLs resolve against our origin)
- Delete confirms with widget name
Also escapes w.name / typeMeta.name / w.id in the widget grid to prevent
stored XSS against admins viewing other users' widgets.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lobby-style tenant/room directory with vertical marquee, seamless loop via
content cloning, pixel shift + bg pulse for anti-burn-in, rotating background
images with crossfade. Supports logo, title, footer, subtitles per entry,
and Available (green) state. All user strings rendered via textContent in
browser — no server-side HTML escaping of entries needed.
Also refactors render dispatch into renderWidgetHtml() and adds a POST
/preview endpoint that inlines user-owned image content as base64 data
URIs so the editor can preview unsaved widgets. Preview is gated by:
- image/* MIME only
- 10 MB size cap
- user_id ownership check
- path traversal guard via basename + resolve
Unknown widget_type on /preview returns 400.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Retarget primary keywords ("open-source", "CMS") in title, description,
OG/Twitter tags and hero h1
- Swap OG/Twitter image from icon-512 to dashboard-preview.png with
width/height/alt metadata
- Add GitHub link in nav (icon), hero (secondary btn), footer, and a
new Open Source callout section
- Wrap content in <main> landmark; add width/height on screenshot for
CLS; add third-party license page to sitemap; Organization schema
sameAs now points to the GitHub repo
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Swaps the live-app iframe for a static PNG of the Displays view.
Faster load, no auth flash, looks sharp.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removes the far-right floating position; Sign In sits in the nav
cluster alongside the CTA instead of pinned to the viewport edge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign In now lives outside the nav-links cluster with margin-left:auto,
pinning it to the top-right corner with visible separation from the
primary CTA.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pixel 8 Pro portrait (~412px) was clipping Sign In because logo + both
buttons overflowed. Hide logo text below 420px, shorten 'Start Free Trial'
to 'Try Free' on mobile, nowrap nav-links with tighter padding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously hidden behind the primary CTA; now shows alongside it with
tighter padding on small screens.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a per-user Account section in Settings with name edit and password
change. Password change requires current password; local auth only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
scripts/reset-admin.js signed a JWT with a synthetic id ("recovery-XXX")
and instructed the operator to paste it into localStorage. But the
requireAuth middleware always SELECTs the user row by id, so every
authed API call under the recovery token returned 401 "User not found"
and the recovery flow was effectively dead.
Fix:
- reset-admin.js now sets a `recovery: true` claim on the JWT.
- requireAuth / optionalAuth short-circuit the DB lookup when
decoded.recovery === true and synthesize a req.user record in
memory (role: admin, plan_id: enterprise). The synthetic user is
never persisted, so FK-constrained writes that expect a real
user (creating devices, etc.) will still fail — which is fine,
recovery is only meant to let the operator reset a password or
create a fresh admin via the Settings UI.
Security: a recovery token still requires the jwtSecret to sign,
so only someone with filesystem access to the server can mint one.
Token TTL remains 1h.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Login view:
- Remove `margin-left: calc(-1 * var(--sidebar-width))` from the
centering wrapper. It was a hack to compensate for the sidebar
offset, but app.js already zeros the app margin on the login
route. On mobile this was pushing the login card ~240px off
the left edge of the viewport.
- Use min-height + padding so the card breathes on short screens.
- Drop inline font-size:11px on the support-token input so the
global .input 16px mobile rule applies (iOS focus-zoom prevention).
app.js:
- Hide the mobile hamburger button on the login route; it has no
function there since the sidebar is already hidden.
Landing page:
- Scope the old blanket `.nav-links { display: none }` to hide only
the section anchors + secondary Sign In button, so the primary
"Start Free Trial" CTA stays visible on mobile.
- Wrap the 5-column Compare table in a horizontal-scroll container
and set min-width:560px so it scrolls instead of overflowing
the page.
- Add min-height:44px to .btn on mobile, tighten section padding
to 16px (from 24px) so content doesn't feel cramped against
the viewport edge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inline width:NNNpx beats the .modal { width: 95vw } mobile rule due to
specificity. Convert to max-width:NNNpx;width:95vw on the three affected
modals so they cap at their desktop size but still shrink on mobile:
- playlists.js add-item modal (560px)
- device-detail.js assign-playlist modal (650px)
- content-library.js edit-content modal (500px)
Same fix pattern for fixed-width form controls flagged in QA — selects
and inputs change to max-width:NNNpx;width:100% so they keep their
desktop size but shrink to container on mobile:
- admin.js role/plan selects (120/130px)
- teams.js member role + add-device selects (100/200px)
- content-library.js search input + folder filter (250/180px)
- onboarding.js pairing code + display name inputs (240px)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Toast now announces via role="status"/aria-live="polite" by default,
and role="alert"/aria-live="assertive" for errors. Screen readers
previously got nothing when notifications appeared.
- Move playlist-item flex-wrap:wrap from inline style into the
@media (max-width: 768px) block so desktop rows don't wrap controls
when the viewport is intermediate-narrow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds accessible up/down arrow buttons alongside the existing drag-to-
reorder handle on each playlist item. Touch users (and keyboard users)
now have a reliable way to re-order without relying on HTML5 drag-drop,
which is effectively unusable on mobile. First/last items have the
respective arrow disabled.
Uses the same /reorder API the drag handler uses, so behavior stays
consistent. flex-wrap on the item container prevents control overflow
on narrow screens.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Wrap wide tables (admin, settings, reports) in .table-wrap with
min-width on the table so they scroll horizontally on narrow screens
instead of collapsing rows.
- Add global .table-wrap { overflow-x: auto } utility.
- Mobile: add mask-image fade on .tabs right edge to hint scrollability
when tabs overflow; flex-shrink:0 on .tab keeps labels intact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Buttons: min-height 44px (36px for .btn-sm, 40px for .btn-icon) on mobile
- Inputs/selects/textarea: font-size 16px (prevents iOS focus zoom), min-height 44px
- Pairing input: scaled letter-spacing down so 6 digits fit at 375px width
- Modals at 95vw: tighter header/body/footer padding so content breathes
- Toast container: bar-style full-width (left/right:12px) instead of
fixed-right 280px that clipped below 400px viewports
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Move hamburger click + backdrop click out of inline onclick into app.js
- Add aria-label/aria-expanded/aria-controls to hamburger button
- Close drawer on Escape keypress
- Bump hamburger button to 44px, nav-link min-height to 44px (tap targets)
- Bump .content top padding to 68px on mobile to match 44px hamburger
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The CREATE INDEX on schedules(group_id) in schema.sql ran before the
phase4 migration added the group_id column, crashing on existing databases.
Move the index creation to the migration which already handles it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
Player polls /api/version every 30s and reloads if the hash changes.
Server hash now includes player/index.html and sw.js so player code
updates are detected without requiring a hard refresh.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Clear pending advance timers when switching content items to prevent stale
image/widget duration timers from interrupting video playback. Also skip
showing "Connecting..." overlay when cached playlist is already playing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The SW was causing "unexpected error" on video/image fetches due to
range request handling, opaque response caching, and stale SW races.
Fix: SW now ONLY caches player page + socket.io JS for offline boot.
Content files are left to browser native HTTP cache (server already
sets Cache-Control: public, max-age=2592000, immutable).
Also: auto-reload player when new SW activates so deploys take effect
immediately without manual hard refresh.
Bumped cache to v5 — activate purges all old caches (including the
broken rd-content-v1 content cache).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>