mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
24 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
938a43a466
|
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]
Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.
Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
member whose playlist matches the group's shared playlist (playlist-match guard —
a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.
Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
GROUP = same leader/follower timing incl. the full video drift controller, but
full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.
Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.
Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): web + Tizen player parity [stage 2]
Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.
Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).
Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
(UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
group_sync block, else exits — content renders through the normal single-zone path.
Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]
On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
playback; server re-pushes to members so they enter/exit sync mode. A hint notes
it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
elect, which self-heals; or pin a specific member to always lead when online).
Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).
Frontend parses (ESM); server suite 407/407.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): rework to clock/schedule sync + double-buffer + polish
Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
- needs no server at play-time (offline-native), and
- has no leader role to double-elect (kills the split-brain class the leaked
WallController tick produced).
Server
- heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
discipline; the client caches the offset (survives an outage).
- POST /groups/:id/resync -> group:resync (manual "Resync now").
- (kept: group_sync payload; leader machinery is now vestigial/ignored.)
Clients (web / Tizen / Android)
- Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
- Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
slot formula identical across platforms so mixed-platform groups can't drift.
- Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
- Double buffer: warm the next clip a few s before the boundary -> instant
switch, no black hold. Android pre-decodes on a throwaway surface so the swap
doesn't flash one wrong-aspect (landscape-stretched) frame.
- In-place duration edits: duration_sec dropped from the change signature and
applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
- Live-log shows discrete corrections (jump/align/seek) immediately; only the
steady-state line is throttled.
Android
- Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
main looper outlived the Activity -> zombie broadcaster / split-brain).
Dashboard
- Group leader picker -> "Resync now" button.
Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
f60f677cf0 |
fix(android): player provisioning + playback robustness
Client-side fixes to the Android signage player, all validated end-to-end on a Pixel-10 emulator (Android 16) against the alpha server. - content download: a local item with "remote_url": null was mis-tagged as a remote stream (org.json optString returns the STRING "null" for a JSON null), so it was ack'd "ready" and NEVER downloaded — stranding the screen on "waiting for content" and only ever playing 1 of N files. Guard with isNull(). - playback (#162): PlaylistController trusted isRunning+currentIndex as "already playing" and never re-called playItem, permanently stranding a panel on "waiting for content" after a restart/OTA/content-not-ready-at-first-start. Guards now require hasContentOnScreen (a genuine render) before short-circuiting. - provisioning: revert to the URL-entry screen if a connect attempt hangs >60s (wrong/unreachable URL) instead of an endless "Connecting to server…". - re-pair: a server rejection (device:unpaired / auth-error) left the device connected-but-unregistered with no pairing code (stuck); a naive re-register then stormed the #150 reclaim guard ~20x/s. Now: re-register once, debounced + backed off; honor the reclaim-settle window with a stable "re-pairing available in Xs" countdown; show the code only once the server accepts it (isPairingCodeLive). - status: a fully-online device could sit on a stale "Connecting to server…" when MainActivity was relaunched (CLEAR_TASK) after the service already registered — it now pulls a fresh playlist on bind so the real state renders. - setup: add a Default Launcher (HOME role) step so a kiosk can be set as the default launcher without adb (prevents ~45s activity-recreate churn). - debug: new DebugLog.v() streams the deep download/playback trace only while live dashboard debug is enabled; silent in production. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d474122334 |
Merge origin/main into feat/android-hidden-settings-menu
Resolved conflict in server/db/database.js: kept both settings_pin migration (our change) and device_settings table migration (main's #150). |
||
|
|
58f27d56e8 |
fix(android): server-provisioned settings PIN replaces hardcoded 0000
- Remove stray brace that broke compilation (MainActivity line 985) - Server generates unique 6-digit PIN per device during pairing - PIN stored in encrypted SharedPreferences (ServerConfig.settingsPin) - Fallback: generate random PIN locally if server doesn't send one - Include settings_pin in device:paired on pair + reconnect - DB migration: settings_pin column on devices table - Hint changed from hardcoded 0000 to generic 'PIN' string |
||
|
|
c7f1eed63f | feat(android): PIN gate for hidden settings menu | ||
|
|
6ca2782ec1 | fix(android): remove orphaned duplicate showExitDialog() block | ||
|
|
57cdaf7e4e |
feat(apk): v4 liveness contract + caching-cluster fix + reconnect-safe downloads
APK v4 — client-only (Android player). Brings the reference client up to the locked v4 liveness contract and fixes the "stuck downloading / offline in CMS" caching bug: - v4 liveness watchdog (LivenessWatchdog): half-open detection via server-silence, arm-ONLY-after device:heartbeat-ack (degrade-safe), 45s±10s jittered threshold, exp backoff 1/2/4/8/16→30s ±20%, no-poll; reconnect delegates to the #148 ConnectionGuard (teardown-before-reopen, single socket). - Caching two-root fix: callTimeout + .part+Content-Length integrity + atomic swap (CacheValidation), onPlayerError advance, re-ack cached content + reconnect re-ack. - Screen resilience (PlaylistSelection): a pending/failed download never blanks the screen — keep-current, swap only fully-valid content. - Reconnect-safe background downloads (DownloadCoordinator): single-flight per contentId + bounded pool + failure backoff + cancellation; a reconnect mid-fetch can't orphan/duplicate/storm. Refuse 206 partials. - v4 client identity block on register (client_type/version/platform/contract_version). Tests: 52 JVM unit tests (watchdog, cache validation, reproduce-then-prove download stall/truncation/reconnect-mid-download, screen selection, assembly soak). 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> |
||
|
|
69be6e804e |
feat(android): hidden settings menu with multi-tap BACK/ESC detection
Add an in-app settings menu reachable via 2× BACK (or ESC) taps, with a 1.8s window — Android TV and touch devices. - 2 taps: settings dialog (change server, re-pair, permissions, exit) - 3 taps: exit dialog directly (skip menu) - Auto-banner after 10+ consecutive connection failures Settings options: - Change server URL (pre-fills ProvisioningActivity) - Reconfigure device (clear credentials → re-pair) - Permissions (Accessibility + Notifications status → system settings) - Device info (ID, APK version, connection status) - Exit app (finishAffinity) Also adds EXTRA_SERVER_URL to ProvisioningActivity and a consecutiveFailures counter to WebSocketService. |
||
|
|
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 (
|
||
|
|
aa23cf02dd |
fix(ota): stop OTA re-download loop on devices that cannot silently install (#139)
Devices that download an OTA APK but cannot silently install it (Fire TV: no device-owner path) re-downloaded the full APK every check cycle indefinitely - install never completes, version never advances, next check re-triggers. Client (UpdateChecker.kt, ServerConfig.kt, OtaThrottle.kt): - Reuse a cached, signature-verified APK instead of re-downloading every cycle; delete leftover invalid files; keep the verified APK on disk as the manual-install artifact. - Persisted per-version attempt budget (EncryptedSharedPreferences) so it survives the Fire OS app restarts that drive the loop. An attempt is counted only when an install is launched - a download/verify failure does not consume the budget, so a transient network problem cannot park a healthy device in backoff. After 3 failed installs, back off to one retry per 24h. - Clear OTA state and caches when a check returns update_available=false while state is pending (app relaunched as the new version). - Report OTA status to the dashboard via device:log (tag ota) on state transitions only (enter-backoff, clear) to avoid flooding the channel. - Extract throttle decision logic into a pure OtaThrottle object (no Android deps) with JUnit coverage (OtaThrottleTest) for the state transitions. Server (server.js): - Reword /download/apk log from "OTA update in progress" to "APK served" and rate-limit to once per IP / 10 min so a looping device cannot flood the log. Note: client-cooperative fix - prevents the loop in cohorts running this APK. Currently-stuck beta4 devices still require a one-time manual update. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
7660d7433e
|
fix(#109): render Android PiP overlay above the YouTube WebView video plane (#135)
* fix(#109): render Android PiP overlay above the YouTube WebView video plane The PiP overlay (#109) returned sent:1 and showed its title in `uiautomator dump`, but nothing painted on screen while YouTube was playing. By elimination (YouTube-specific, landscape so no off-screen transform, real on-screen bounds in the dump) the cause is surface occlusion: pipLayout sat as the last child of rootLayout — the SAME compositing band as R.id.youtubeWebView — so the playing video surface drew over it. Fix (task option 1a): reparent pipLayout out of rootLayout to the window content (android.R.id.content) as a top-level sibling drawn after rootLayout, so it composites above the WebView. MainActivity.mirrorTransformToPip() copies rootView's orientation/wall transform onto it so corner positions still track the rotated content (web/Tizen parity). show() also bringToFront()+ requestLayout()+invalidate() on attach (covers the cause-3 measure/visibility path). Remote-view screenshots now capture the content root so the PiP is still included. Instrumentation (Phase 1, default OFF): PipOverlay.pipDebug paints a solid magenta box + border with media on top (box paints even if media never loads) and logs box/pipLayout/rootView/youtubeWebView geometry over device:log tag "pip"; loadImageInto also logs on success. Toggled via device:command {type:"pip_debug"} (routed through MainActivity.onCommand). Server: POST /api/pip and the clear handler log one concise [pip] dispatch line (target + sent/offline) so journalctl shows PiP activity. Validated end-to-end on an emulator (pixel10/API34) paired to an isolated local server with YouTube playing: no crash, the PiP box composites above the live video frame (center + top-right), clear removes it, and the portrait transform mirror rotates the overlay with the stage (no off-screen). The Fire TV hardware-overlay punch-through still needs real hardware (emulator composites video inline); pipDebug + docs/109-android-pip-visibility.md cover that. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#109): image PiPs never painted — set slot token before decode Emulator e2e of an image PiP (a QR PNG) found the image area always blank (box background + title only). Pre-existing defect, also on main, independent of the occlusion reparent. Root cause in PipOverlay.show(): teardown() clears `current` to null, then loadImageInto() captured `token = current` (null) as its drop-if-replaced guard, but `current` was set to the new pip_id AFTER the media was built. The image decode finishes on a background thread and posts back after show() returns, so `token != current` (null != pip_id) was always true and every decoded bitmap was dropped. Web PiPs and the box/title were unaffected, which masked it. Fix: set `current = pip_id` before building media so loadImageInto's token matches. Verified on emulator — a QR image PiP now renders over both a static image and live YouTube (hardware screencap + the app's software view.draw capture both show it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(#109): record web PiP (HTML+JS) verification on emulator Web PiP type loads its WebView and executes JS (a page stamping JS OK · <time> rendered over live YouTube). No code change — web PiPs don't use the image path that had the token bug. Completes the image/web/box content-type verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#109): implement PiP close_button on Android (was a documented no-op) The server forwarded close_button (routes/pip.js) and it's in openapi.yaml, but no player rendered it — Tizen deferred "close-button focus" as non-MVP, the web player has none, and Android's PipOverlay never read the flag. So the documented field did nothing on any device. Implement it on Android: when close_button:true, a tappable ✕ floats at the box's top-right in a FrameLayout wrapper that is a SIBLING of the box — so it isn't clipped by the box outline or dimmed by the overlay opacity. Tapping it clears THIS overlay (id-matched via the captured token). Only the ✕ is clickable; the rest of the full-screen pipLayout stays touch-transparent, so taps elsewhere fall through to the playing content (no input regression). Verified on the emulator over live YouTube: the ✕ renders at the corner, and tapping it removes the overlay while the video keeps playing. Parity note: web/Tizen players still don't implement close_button; D-pad focus of the ✕ on non-touch TV hardware is intentionally not wired (MVP = touch/pointer, matching the Tizen focus deferral). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
0cd2a904e5 |
Android player: video-wall (wall:sync) support
Ports the wall:sync protocol the web and Tizen players already ship to native Kotlin/ExoPlayer, so the Android player can join a video wall. - WallController (new): 4Hz leader broadcast; follower latency-compensated drift controller (hard-seek past 0.3s, gentle +/-3% playbackRate nudge past 0.05s); role handling with immediate align on entry and on wall:sync-request. Per-tile rotation intentionally not applied (web/Tizen parity; left as a TODO). - MediaPlayerManager: expose position/duration/seekExact/setSpeed for the drift controller; RESIZE_MODE_FILL / ImageView FIT_XY in wall mode (object-fit:fill parity), restored to fit/fitCenter on exit. Follower mute (setWallMute) persists across leader-driven item switches, and followers loop (REPEAT_MODE_ONE) so they never freeze on the last frame if the leader's next index is late. - PlaylistController: wallFollower flag suppresses auto-advance (leader drives the index); getIndex/gotoIndex for follower tracking; itemStartedAtMs for non-video sync position. - WebSocketService: onWallSync/onWallSyncRequest handlers (posted to the main thread since they drive ExoPlayer) + emitWallSync/emitWallSyncRequest senders guarded on socket.connected() like sendPlaybackState. - MainActivity: parse wall_config in onPlaylistUpdate and branch before the orientation + multi-zone paths; size/translate rootView to this screen's slice; exit() restores full screen. Compiles clean (./gradlew :app:assembleDebug). NOT yet validated on a device or a real wall — the ExoPlayer seek/speed sync and the slice transform need on-device tuning before this is trusted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2ccf3264a9 |
feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Each playlist item can carry schedule blocks (active days, start/end time-of-day, optional start/end dates). An item plays when the screen's local "now" matches at least one block; an item with no blocks always plays. #74 covers time-of-day/day-of-week windows including overnight wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is on-device, so dayparting and expiry work offline. - Shared evaluator contract: shared/schedule-vectors.json (39 vectors — DST US+AU, overnight-wrap anchoring, timezone correctness, date boundaries). Canonical JS evaluator in server/lib/schedule-eval.js; Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff test, Kotlin JUnit reads the shared JSON, new android-test CI job). - All three players (web, Android, Tizen) filter by schedule against their own clock, idle with a "Nothing scheduled" message + 30s re-check when everything is filtered, and fail open on any evaluator error. - Editor: per-item schedule modal + row badge in the playlist editor; client validation mirrors the server; editing marks the playlist draft. - Part B (behaviour change): device/group schedule overrides now evaluate in each device's effective timezone instead of server-local time. - Device detail shows the reported timezone + a clock-skew warning. - i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.* to avoid colliding with the device-schedule calendar's schedule.*). - CHANGELOG documents the feature, the Part B change, the fail-open guarantee, and the scheduled-single-video re-render tradeoff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
dfc8a4e358 |
feat(player): software orientation (portrait + flipped) on both players (1.7.12)
The dashboard exposes landscape / portrait / landscape-flipped / portrait-flipped and the README promises rotation, but neither player ever read the device's orientation field - it was hardcoded landscape. Reported by a customer testing Firestick + Samsung signage. Rotate the CONTENT in software, not the panel: Fire TV / Android TV / Tizen are fixed-landscape and ignore setRequestedOrientation (can't physically rotate). - Android (MainActivity): applyOrientation() resizes rootView to the rotated dimensions, recenters, and rotates 0/90/180/270. rootView is the shared container for single-zone AND multi-zone, so both are covered. Driven from the playlist-update payload. - Tizen (app.js): CSS transform on the stage (rotate + swapped 100vh/100vw), same four values, from the playlist payload. Verified on an Android 16 emulator: device set to portrait -> 'Applied orientation: portrait (rotation=90, swap=true)' and the video renders rotated. |
||
|
|
d9d7a8ae0f |
feat(android): reliable boot-launch incl. Android TV (1.7.11)
The player has a launcher (category.HOME) + a boot receiver, but auto-start was unreliable where you can't set a home launcher (Android TV) and on Android 14+, where USE_FULL_SCREEN_INTENT is auto-revoked for non-calling apps so the boot full-screen launcher silently no-ops. Boot launch: - BootReceiver now does a direct background startActivity when 'display over other apps' (SYSTEM_ALERT_WINDOW) is granted — a real exception to the bg-activity-launch restriction, and the one path that works on Android TV. Full-screen-intent notification kept as a fallback (locked screen / no overlay). - Boot notification moved to a dedicated HIGH-importance channel (full-screen intents are only honored from one), and it auto-dismisses once the UI is up. Setup screen — new permission rows so operators can grant what boot-launch needs: - Launch on Boot (USE_FULL_SCREEN_INTENT, shown on Android 14+) - Background Activity (battery-optimization exemption) - Display Over Apps (SYSTEM_ALERT_WINDOW) Made the screen scrollable and ~50% smaller text/buttons so all rows + Continue fit on one screen (incl. landscape signage). Install-Unknown-Apps subtitle now states updates are signature-verified, so it doesn't read as 'install anything'. Verified end-to-end on an Android 16 emulator: after reboot the app auto-launched (Direct launch via overlay) and the boot notice cleared itself; all rows toggle. |
||
|
|
c94757fc97 |
fix(android): per-zone rotation + stop fullscreen controller in multi-zone
From Chris's live debug logs on the L-Bar layout:
- ZoneManager only rendered the FIRST assignment per zone -> the Main zone (3
images) never rotated ('says it's switching but it's not'). Now each zone
cycles its own assignments: images/widgets on a duration timer, videos on
end (single-item zones still loop).
- The fullscreen PlaylistController kept running BEHIND the zones (playItem every
10s, would leak audio for a zone video) because startIfNeeded() ran after every
playlist update. Now only start it when not in multi-zone (zoneManager.hasZones).
- renderAssignments still called container.removeAllViews() (the same static-view
nuke the cleanup() fix addressed) -> now removes only its own zone views.
|
||
|
|
73912d5f58 |
feat(debug): live per-device debug logging toggle on the device screen
Checkbox on the device-detail page streams the Android player's player/zone logs live (no adb). Transient (off on reconnect), not persisted. - Android: DebugLog util (logcat + optional socket emit); 'set_debug' command wires the sink + flag; key player/zone decisions (layout mode, playItem, per-zone render) emit through it. - Server: relay device:log -> dashboard workspace room as dashboard:device-log. - Dashboard: 'Debug logging' checkbox sends set_debug; live log panel streams lines (rendered via textContent; capped at 500). |
||
|
|
911cd07951 |
fix(android): render widgets in fullscreen / single-zone layouts
Widgets worked in multi-zone layouts (ZoneManager renders them in a WebView) but
were broken in "default fullscreen" (no layout) and the fullscreen template (a
single-zone layout) - both take the single-zone PlaylistController path, which:
1) called getString("content_id"), throwing on a widget assignment (no
content_id) - in both the playlist builder AND the pre-download loop, which
could break the whole fullscreen playlist; and
2) had no widget render case in playItem (so a widget never displayed).
Fix:
- PlaylistItem gains widgetId/widgetType + isWidget; the builder reads them and
tolerates a missing content_id.
- playItem renders a widget fullscreen via MediaPlayerManager.showWidget() (loads
/api/widgets/:id/render in the full-screen WebView, mirroring ZoneManager).
- Widgets auto-advance on their duration like images.
- Pre-download loop skips widget assignments (no file to fetch).
Compile-checked; signed APK builds. Needs on-device check: a widget plays in
default-fullscreen and the fullscreen template, and mixed widget+media playlists
advance correctly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
cd6e39a4a7 |
Fix Android app OOM crash on 4K images and crash loop recovery
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> |
||
|
|
dc7450b6a7 |
Offline resilience: persist playlist cache for cold-start recovery
Web player: - Cache playlist JSON to localStorage on every update - Restore and start playing immediately on boot before connecting - Clear cache on unpair/reset Android app: - Cache playlist JSON to EncryptedSharedPreferences on every update - Restore cached playlist on cold-start, play from disk-cached content - Update cache on content deletion, clear on unpair Server (device socket): - Fingerprint reconnect: issue fresh token instead of rejecting - Send device:paired on fingerprint recovery for claimed devices - Add status logging and dashboard notification on fingerprint reconnect Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
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> |