mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 06:43:27 -06:00
44 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e4c25c39df |
Describe a portrait video wall as portrait, and stop a wall hiding its screens
#236: the wall canvas was secretly framebuffer space rather than the wall as the audience sees it. Invisible while every panel is the normal way up, and actively misleading the moment one isn't — two portrait-mounted panels standing side by side had to be STACKED VERTICALLY in the editor, with a pre-rotated copy of every video, before the output came out right. It worked, but only after trial and error, and it meant a portrait wall could never reuse content as-is. Each panel now carries a mounting rotation (0/90/180/270 clockwise, the same convention as the per-device orientation setting), the canvas means the physical wall, and the player works out the mapping. The geometry lives in one place, server/lib/wall-geometry.js, because four players have to agree on it to the pixel across a seam. Existing walls need no migration and do not move. Every wall in the field is rotation 0, and that case takes the original expression verbatim on all three players rather than the algebraically-equal centre-based one — the two differ in the last float bit, and a float's worth of disagreement between two panels is a hairline seam down a wall that was aligned yesterday. Pinned by the first test in wall-geometry.test.js and by wall-payload.test.js. While a display is in a wall its panel rotation replaces its own orientation: both describe the same physical fact, so honouring both turned the content twice. #235: a wall replaced its members' cards, so one dead panel of a four-panel wall was invisible from the dashboard, and inspecting a single screen meant pulling it out of the live wall and putting it back. The wall screen now lists its panels with live online state, a per-panel screenshot request, and a link to each device's page; the dashboard wall card carries per-member status chips that track socket updates. Tests: wall-geometry.test.js re-simulates the CSS box independently and asserts each panel's viewport maps onto exactly its own rect of wall space, for every rotation, plus a mixed wall and the Tizen player's hand-ported copy executed against the canonical rule. Full server suite green (1260). Not verified here: the Android and Tizen renders on real hardware. Kotlin compiles clean; the maths is shared/tested, the view plumbing is not. |
||
|
|
684e60fc55 |
Offline media on every player, and a revision so the cache can still be updated
Two halves of the same problem. A screen has to keep playing when the link is gone, and it must not keep playing the wrong thing once the link is back. CACHING FOR OFFLINE, on the players that could not: - Tizen cached nothing but the playlist, so a panel came back from a reboot knowing exactly what to show and fetched every frame of it from a server that was not there. tizen/js/media-cache.js caches the media itself to wgt-private (the store Tizen documents as surviving reboots), resumable via Range and If-Range, with the transfer async so a stalled chunk cannot freeze the player. offline.cache moves from "absent" to a runtime claim: a build with no writable private storage still says nothing. - The web player's worker stored only what a single fetch() happened to complete, which on a marginal link is nothing at all — a 200MB asset never finishes in one go and every retry starts from zero. It now accumulates in resumable chunks, driven by the player's playlist rather than by playback, so the prefetch is not competing with the video that is currently on screen for the same scarce bandwidth. BrightSign inherits this. STILL UPDATING, which caching quietly breaks: PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every cache keys on that id, so before this the new bytes could not reach a panel that already held the old ones — not until the next refresh, but never. Content now carries a revision, stamped onto each item at send time like widget revs, and every player keys its cache on it. The same send-time refresh fixes a second bug: a replace writes a new randomly-named file and unlinks the old one, so the filepath in a published snapshot pointed at a deleted file and web panels 404'd on the item until somebody republished the playlist. The route now also pushes to affected devices, which it never did. Bytes are kept only where they can be built upon: no validator means no safe resume, so the partial is discarded and the attempt backs off as the failure it is rather than re-fetching the same prefix forever. Server needed no new transfer support — res.sendFile already does Range, If-Range and 416. The Tizen cache and the service worker are both driven in Node against fakes, because neither can be exercised without hardware and "the chunks assemble correctly" is not something to discover from a panel showing a corrupt video. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
812e89f28f |
Android declares what it can actually do, and can wake a panel it slept
Two halves of platform-native parity.
THE DECLARATION. The player now sends a `capabilities` array on every register,
using the vocabulary in server/lib/player-capabilities.js so the dashboard can
stop offering controls that cannot work on a given panel.
Computed at registration, never cached, because almost everything interesting is
runtime state an APK cannot know about itself: accessibility gets switched on
months after install, device owner arrives through a provisioning flow, and
WRITE_SETTINGS is a grant an operator can revoke. A value captured once would be
wrong on the same hardware from one boot to the next.
The rule when uncertain is to UNDER-claim. A missing control is a support
question; a control that looks like it works and does nothing is a bug report,
and on a panel nobody can reach it is an expensive one. So:
system.reboot / kiosk / time owner only. Off-owner, reboot degrades to an
accessibility power DIALOG and kiosk to screen
pinning — both need someone at the screen, which
is not a remote capability.
system.install_apk owner or a delegated install scope.
system.brightness / timeout WRITE_SETTINGS or owner. Per-window dimming
works at any tier but is not what an operator
means by "brightness".
remote.screenshot / stream accessibility only. Without it capture falls
back to the app's own view.
display.power see below.
system.shell ALWAYS. It is app-UID `sh -c` and runs at any
tier; the directive grouped it with Tier-2, but
the code is not owner-gated and under-claiming
would hide a working diagnostic.
Never declared, so the dashboard stops offering them: display.resolution (needs
system/root — an app cannot change the negotiated output mode) and sync.native
(frame-accurate hardware sync is a BrightSign SyncManager feature; Android has
the clock-derived group sync, which IS declared).
THE WAKE PATH. display.power was asymmetric: screen_off worked via owner, admin
FORCE_LOCK or accessibility, while screen_on was a logged no-op. The retired
attempt was `input keyevent 224`, which exec denies to an app UID, and that one
failure had been read as "no wake path exists". A wake LOCK is a different
mechanism needing only WAKE_LOCK — a normal permission already in the manifest.
That asymmetry is expensive on a fleet: an operator sleeps a panel overnight and
cannot wake it remotely, so someone drives to the site. Losing the screen is the
wrong direction to fail in. Handled in the service as well as the Activity, since
the service is the only thing guaranteed alive, and paired with a keyguard
dismiss because waking to a lock screen is half a fix. The lock is held briefly
and self-expires, so a missed release cannot pin a panel on.
display.power is therefore declared on the OFF path (owner/admin/accessibility),
which is now the binding constraint — offering a control that sleeps a panel it
cannot wake would be the worst version of this feature.
DeviceInfo.isAccessibilityEnabled is internal rather than private so the
declaration asks the same question as the telemetry shown beside it, instead of
a second copy that drifts.
Verified: APK compiles (9,023,669 bytes); all 25 declared strings are known to
the server vocabulary, with zero unknown; and they survive R8 into classes4.dex
along with the `capabilities` payload key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
b419830629 |
Android: the boot notice now clears, and kiosk survives a reboot
Two field reports from a customer running the player on Android x86. THE "STARTING DISPLAY…" BANNER NEVER CLEARED. Relauncher launches the activity directly when the overlay permission is granted — the normal kiosk setup — and THEN posts the notification, deliberately, so a device that could not auto-launch still has a tappable way back. On a device where the launch DID work, that ordering posts the prompt after onCreate has already cancelled it, and nothing cancels it again: a permanent banner over content that is already playing. They sent a photo of exactly that. Cancelling in onCreate only ever closed half the race. It now also clears on every foreground: if the player is on screen, a "Starting display…" prompt is stale by definition, whoever posted it and whenever. KIOSK MODE DID NOT SURVIVE A REBOOT. startLockTask() is a runtime call on the Activity, and nothing persisted the operator's intent — so a locked panel came back up unlocked, silently, and the only symptom is that someone can suddenly leave the app. The flag is now written BEFORE the lock is attempted, so a device that reboots mid-call still comes back in the state that was asked for, and a lock that fails is retried on the next start rather than forgotten. Restored in onStart rather than onCreate because lock-task can be dropped on some transitions. Also theirs: an "Exit kiosk mode" entry in the PIN menu, shown ONLY when locked. With kiosk on and no other input, that menu is the only way out of a panel, and a menu entry that does nothing is worse than no entry. Builds clean: versionCode 100, v1 JAR signature intact. Reported by chris@chris-pc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
fca36c242a |
Open our own permissions screen from the in-service Settings menu
The Permissions entry showed a ✓/✗ read-out and then handed off to Android's App Info page. The
screen we actually built for this — a row per permission with its live state and a Manage button
that stays visible once granted — was only reachable during first-run setup, so an installer who
wanted to review or revoke something on a running panel had to re-pair to see it.
Manage Permissions is now the primary action and opens SetupActivity in review mode. Android's App
Info page stays as the secondary, because notification access and some OEM toggles are only
reachable there.
Review mode exists because three things in SetupActivity assume first-run, and every one of them
had to be exempted or this silently did nothing:
- proceedToNext() goes unconditionally to ProvisioningActivity. Without the exemption the button
an installer was told to press would send a paired, playing screen to the pairing page.
- onCreate returns early when setup_complete is set — and every device that can reach this menu
has it set, so the screen closed before it drew and the menu entry looked broken.
- updateStatuses() re-labels the continue button on every refresh, silently overwriting the label
set in onCreate. The label had to move to where it actually sticks.
Review mode also hides the first-run skip hint, does not re-stamp setup_complete, and returns to
playback rather than continuing anywhere.
Verified on an Android 12 emulator, both directions:
in service BACK x2 -> PIN -> Settings -> Permissions -> MANAGE PERMISSIONS -> our screen with
every row and its state -> DONE -> back to playback, no ProvisioningActivity launch,
widget rendering resumed
first run full uninstall + fresh install -> SetupActivity, button reads CONTINUE ANYWAY, skip
hint present, no DONE label, continue lands on ProvisioningActivity, pairing completes
and playback starts
That second run is the one that mattered: both early-exit guards are inverted conditions, and a
mistake in either would have broken onboarding for every new install.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
d580994bb3 |
Carry a widget's revision into the multi-zone path on Android
The widget-refresh work covered the fullscreen path only, so editing a widget placed in a ZONE still never reached the screen. Two independent gaps, both of which had to close: - The zone render URL was built from the widget id alone, with no rev, so even a forced re-render fetched a URL the WebView had already seen. - The decision to re-render zones at all keys on an assignment signature of content_id:zone_id:widget_id. A widget's identity does not change when it is edited, so the signature was byte-identical and the branch fell through to "Multi-zone unchanged, skipping". A zone holding a single widget never rotates either, so nothing else would have reloaded it. The customer edited a widget, the dashboard showed the new content, and that region of the screen kept the old version until the layout geometry changed or the app was force-stopped. The server has supplied widget_rev on every assignment since the fullscreen fix; both the fullscreen Android path and the web player's zone path already used it. This is the path that was missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
acadb4c1f4 |
Stop the playlist and the OTA checker when the Activity is destroyed
onDestroy already shuts down the wall and group controllers, and its comment says exactly why: those Handlers are on the main looper, which outlives the Activity, so a surviving tick "would keep broadcasting sync frames against the released player forever". Three other things on that same looper were never stopped. PlaylistController kept advancing after the Activity was gone. Every tick wrote the resume index and emitted play_start/play_end through the still-live WebSocketService, so after any relaunch — the "launch" command, Relauncher after OTA or boot, a re-pair, or a config change outside the ones the manifest handles — two controllers were reporting playback for one screen. That inflates Total Plays and Hours in Reports for that panel, and races over the resume position #234 depends on. Widget items also re-entered showWidget on a WebView nobody owned any more. UpdateChecker was never stopped either, and its install receiver was never unregistered: installReceiverRegistered is per-instance, so each recreate added another checker polling /api/update/check and another receiver for INSTALL_COMPLETE. N of those turns one STATUS_PENDING_USER_ACTION into N confirm dialogs stacked over customer content, and concurrent checkers race in tryPackageInstaller — which starts by abandoning ALL of the app's installer sessions, so one can abandon another's staged session mid-flight and the update never completes. shutdown() now does both, and the receiver is held so it can actually be unregistered. The Activity's own posted callbacks (the 30s failure-check loop among them) are cleared too. 134 Android JVM tests green. The effect is a leak and a duplicate reporting stream rather than a wrong value on a screen, so it is verified by reading the lifecycle rather than by a unit test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
88e4a9eb49 |
Respond to a server rejection once, and stop destroying the cache over a transient one
onUnpaired was assigned twice in setupServiceCallbacks. The later assignment silently replaced the first, so the handler added earlier this week to surface WHY the server refused a device — the one whose comment says "Only ProvisioningActivity ever assigned onUnpaired, and it is gone by the time playback is running" — could never run. Thirty lines below it, something else was assigning exactly that. What actually executed cleared the offline playlist cache and jumped to the pairing screen on EVERY rejection. That is wrong for the case the service is explicitly built to survive: handleServerRejection parses a settle window, sets awaitingRepair, holds all registration and schedules a single retry, so a reclaim-settle hold recovers on its own within the window. Tearing the player down over it cost the panel the cache it would have replayed from and forced a full re-download after re-pairing — the opposite of what the hold is for. The two are now one handler. It always surfaces the server's reason, and only navigates to provisioning when the rejection is terminal and not a block: transient the service recovers by itself; show the reason and stay put blocked a block deliberately survives a re-pair, so the pairing screen cannot resolve it terminal the device really is gone and the operator needs the code The cache is kept in every case. It is what lets a screen keep showing content while someone walks over to re-pair it, and re-pairing restores the settings anyway. The service now exposes whether a rejection carried a settle window, since only it can know. 4 tests over the decision, kept pure so it needs no Activity. 130 Android JVM tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
abdb3b434d |
Silence a backgrounded player, and rebuild zones when a layout is edited in place
Two more from #234, both Android-only. 1. "I closed the app and I can still hear the sound." Nothing in the Android lifecycle pauses a WebView, and MainActivity had no onStop at all, so a YouTube embed kept playing with the app in the background and the panel kept making noise with the app apparently closed. onStop rather than onPause: onPause also fires for a transient dialog or a permission prompt, and pausing playback for those would be a visible stutter on a wall. Pauses via the IFrame-API bridge that already exists for live mute, so returning to the foreground resumes in place instead of restarting the clip. 2. "I added 4 zones and they dont appear on the screen. I had 3 zones before and they appeared." The zone rebuild fired only when the layout ID changed. Editing a layout in place keeps its id, so setupZones never ran: the geometry stayed at three zones and only the assignments re-rendered into the old ones, which is why it took a force-stop to appear. The rebuild now also triggers on a signature of the zones themselves (id, position, size, z-index, type, fit). Compiles clean; NOT yet verified on hardware — both need a device to prove, unlike the audio-on- item-switch fix which was measured before and after on an emulator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
4cc750ba3a |
Text widgets: stop losing text off the bottom, and show an edit without an app restart
Two separate faults in the same widget, both reported on #234. 1. Text taller than the screen vanished in silence. renderText set overflow:hidden on the document with nothing able to scroll it, so anything past the bottom edge was simply gone: "Text goes to bottom and disappears. It dont fit." The content now gets a wrapper and an overflow mode: fit (default) shrink until it fits — a NO-OP when the content already fits, so it rescues widgets that are currently losing text without changing ones that are fine scroll pan through it on a loop with a pause at each end, for content genuinely longer than a screen where shrinking would make it unreadable clip the old behaviour, kept because a designer-positioned layout may deliberately run past the edge and must not be rescaled underneath its author Measuring runs after layout, after web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts arriving late is the classic cause of a fit computed against the wrong height. 2. Editing a widget did not reach the screen until the app was restarted. The render endpoint serves live config, but the player deliberately keeps a widget's WebView while its URL is unchanged (re-navigating every duration is a visible flash and destroys widget state — a half-typed directory search, scroll position). Editing changes the content, not the id, so the URL never changed and the reuse check always hit. The widget's updated_at now travels to the player as widget_rev and goes into the render URL, so the URL differs exactly when the content differs — and only then, so the anti-flash reuse still holds for untouched widgets. The rev is refreshed at send time rather than read from the published snapshot, because a widget edit does not republish the playlist. Editing a widget also now pushes to the displays showing it, instead of notifying nothing at all. 859 server tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
3159f94107 |
Make unblock stick, and say so when a device is refused
A customer blocked a screen once to see what the button did, then spent an evening
unable to get it back. Three separate faults stacked up.
1. Unblock did not stick. applyToDevice() restores `blocked` on re-pair — deliberately,
so a block cannot be shrugged off by deleting the device — which makes the SAVED copy
the real authority. Unblock only ever wrote `devices`, so the saved row stayed 1 and the
next delete + re-pair silently re-blocked. There was no way out from the dashboard at
all: unblock, re-pair, refused, repeat. Block and unblock now both mirror to the saved
copy, so the survives-a-re-pair property is deliberate rather than a leftover.
2. The refusal was invisible. handleServerRejection() clears credentials and calls
onUnpaired, but only ProvisioningActivity ever assigned that callback — and it is long
gone by the time playback is running. So the screen sat on "Connecting to server" and
the player eventually blamed the URL, sending the operator off checking their network
while the server had already said exactly what was wrong. MainActivity now handles it.
(This half was mine: clearing those leaked callbacks to stop the relaunch loop removed
the only thing that surfaced a rejection. It was a broken path — it fired into a
destroyed Activity — but it was the only one, and MainActivity should have owned it.)
3. The reason was thrown away. The server sends device:auth-error {error: "Device
blocked"} and the client discarded it. It is kept now, and a blocked screen says so
instead of implying a network fault. Localised in all six languages, matching the other
on-screen status strings.
Also ran on prod: one stale saved block cleared (fingerprint ef6540376599, the reporter's
tablet), DB backed up first. It was the only such row.
Tests pin both directions, because the two are easy to confuse: unblock must clear the
saved copy, AND a genuine block must still survive a delete + re-pair.
|
||
|
|
66d9dc7fef |
Resume the playlist where it was after an Activity rebuild (#234)
Reported as "if there are 2 pictures or one picture and one video only one plays", and the reporter had never once seen the second item. PlaylistController is constructed with MainActivity, so every rebuild gives it a fresh, empty instance. The playlist then arrives — from the disk cache or the socket, it does not matter which — and the controller sees "0 -> N items", treats it as a first load, and starts at the top. Anything the panel does that recreates the Activity therefore sends playback back to item 1. That would be survivable if it happened rarely. On the reproduction it happened at every item boundary: the device re-registers, the app relaunches itself with NEW_TASK|CLEAR_TOP, onCreate runs, and playback restarts. The second item was on screen for 135ms each cycle, which is why it read as "only one plays" rather than as a glitch. Prod play_logs agree: the second item logging 0-1s durations while the first accumulated every real second of playtime, on two unrelated customer devices. Position now lives in ServerConfig, outside the object that keeps being rebuilt, and start() resumes from it when the save is recent. A cold start, a stale save, a shrunken playlist, a missing save, or a clock that jumped backwards all fall back to starting at the top, so genuine first-runs are untouched. This does NOT address why the panel relaunches itself once per item — that is the noisier half and wants its own change. It does mean a relaunch costs a restarted item instead of a playlist that can never advance. Reproduced first, on an Android 9 emulator with the reporter's exact shape (12MP portrait JPEG + 40s MP4): image 135ms before, a full 10.05s after, with the video holding its 40.1s, over four clean cycles. |
||
|
|
56abfa3579 |
Make "force update" actually forceful, and make it say what happened
The dashboard button sent the same checkForUpdate() the 30-minute timer calls, so it
was subject to every guard the timer is subject to, and every one of those guards
returns silently. The toast fires on ack.delivered — which only means the command
reached the device's socket — so a panel that was capped, or standing down under an
MDM, looked exactly like one that had updated. "You get the toast popup, but nothing
happens" was an accurate description of working code.
A forced run is a different thing from a timer tick: a human aimed it at one device
and is watching that screen. So it now
- hands the attempt budget back (OtaThrottle.onForcedCheck), un-parking a device
sitting in backoff instead of making it wait out the window,
- overrides the MDM stand-down, since a targeted human action is a stronger and
better-aimed signal than the global OTA_ALLOW_MANAGED_DEVICES switch,
- and REPORTS the outcome, including the boring ones. "Already on the latest
version" is the single most valuable line here: silence was indistinguishable
from failure, and that ambiguity is the whole bug.
It also distinguishes "install launched" from "installed". Off device-owner Android
raises a confirm dialog somebody has to accept, and the gap between those two states
is precisely where the button appears to do nothing — so the report names which one
happened and says the dialog is waiting.
The timer path is unchanged and stays quiet on purpose: reporting every capped tick
would move a Fire-OS-restart flood onto the WS channel, which is what #139 fixed.
Verified on a real panel end to end: dashboard socket emit -> ack {"delivered":true}
-> "Force update check triggered (operator)" -> "Force update: already on the latest
version (1.9.23)". OtaBackoffCadenceTest additionally pins the retry cadence that
prompted this (3 fast attempts, then one per 24h, full budget back on a new release)
so it stops being re-derived from the source each time it comes up.
|
||
|
|
07419fee1f |
feat(players): proof-of-play on Android+Tizen; close Tizen parity gaps
Android and Tizen never emitted device:play-event, so Reports showed Total Plays / Hours / proof-of-play as all zero for those devices (only the web player logged plays). Both now emit play_start on show and play_end on advance/teardown, mirroring the web player's contract (leader-gated for walls, widget-id fallback so durations close). Server side unchanged — play_logs and the reports queries were already waiting for the events. Tizen parity with the web player (from the parity audit): - audio: landscape <video> honors item.muted (warm-muted for autoplay, then applies the real state) instead of force-muting; wall followers stay silent - device:mute-changed: real-time per-item mute of the on-screen video - device:remote-key: D-pad/volume/mute/home, BACK=info overlay, POWER=screen-off - device:remote-touch: normalized-coordinate touch injection - buffered widget swap: reveal the new iframe on load then clear (no black flash) - diagnostic info overlay toggled by the dashboard BACK key Still muted on Tizen: the portrait AVPlay video path and transition-composited video (would need webapis.avplay volume APIs / renderVideoBuffered work). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
96b71a0d56
|
feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated. |
||
|
|
335681b907
|
feat(directory-board): panel-ring scroll + in-place refresh + per-device frame diagnostic (#203)
Compositor panel-ring board scroll (smooth on Blink+Gecko, no blank-on-refresh), a per-device frame-rate diagnostic widget + dashboard card, and web/Android/Tizen device-id passthrough to widget render URLs. |
||
|
|
77322c631a
|
fix(android): show soft keyboard for PIN/URL dialogs over immersive fullscreen (#191)
The hidden-settings PIN box (2x back) and the change-server URL dialog couldn't be typed into on kiosk devices: a plain AlertDialog shown over the player's SYSTEM_UI_FLAG_IMMERSIVE_STICKY activity never gains window focus, so the soft keyboard doesn't attach and the EditText gets no cursor. On a panel/Fire TV with no hardware keyboard that means the PIN can't be entered at all. Pre-existing (showPinDialog unchanged since it was added; immersive flags since the initial release) — surfaces on tier-0 kiosk devices where immersive is active. Fix: shared showImeDialog() applies the standard immersive-dialog IME workaround — mark the dialog NOT_FOCUSABLE before show() (so it doesn't steal focus and reset the activity's immersive flags), mirror the immersive systemUiVisibility onto the dialog, then clear NOT_FOCUSABLE after show() so the IME can attach, force SOFT_INPUT_STATE_ALWAYS_VISIBLE, and requestFocus the field. Routed both the PIN and change-server dialogs through it. Compiles (:app:compileDebugKotlin). IME behavior needs a real tier-0 device to confirm (can't be exercised headless). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
fa31eb9cc3
|
fix(android): reset stuck download backoff on content change + network reconnect (#170) (#190)
Symptom 1's 'stuck on first load, fixed by toggling the playlist' is stale download backoff. On a fresh device the first downloads fail while the link is settling; DownloadCoordinator escalates an exponential backoff (15s..5min cap), and ensure() then SKIPS those items. The 60s playlist refresh re-fires onPlaylistUpdate but ensure() still skips them, and backoff was only ever cleared by forget() (content-delete) — never by a re-assignment. So the item stays stuck until the 5-min window happens to lapse; toggling the playlist is just a manual way to wait it out. Fix (storm-safe — neither reset fires on the routine same-playlist 60s refresh): 1. DownloadCoordinator.resetBackoff(id) / resetAllBackoff() — clear attempts + nextAttemptAt but KEEP inFlight (single-flight preserved, no duplicate .part). 2. onPlaylistUpdate resets backoff for each item ONLY when the content-id signature changed (first load, reassignment, toggle-back), then ensures — so a genuine (re)assignment retries immediately. Same-signature 60s refresh -> no reset -> the retry-storm guard stays intact. 3. Network onAvailable (was onLost-only) -> resetAllBackoff() + requestPlaylistRefresh, so content that failed while the link was settling retries the moment real connectivity arrives. Tests: DownloadCoordinatorTest — resetBackoff/resetAllBackoff re-attempt a backed-off item before the clock advances; existing backoff/single-flight tests unchanged. :app:testDebugUnitTest green. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ef91f644a7
|
feat(system-control): Tier 0/1 controls with no device-owner dependency (#160) (#169)
* feat(system-control): Tier 0/1 controls with no device-owner dependency [#160] Track A of the system-control split (Track B = device owner, shipped in #168). Ships the capabilities that need NO device owner, with graceful per-tier degradation. Capability reporting (keystone): - DeviceInfo now reports can_write_settings / accessibility_enabled / overlay_granted alongside the existing tier/device_owner flags; server persists them (3 additive device columns, older APKs default to 0); dashboard gates controls + shows what's grantable. Android SystemControl (new, all best-effort / no-op when unsupported): - Tier 0 (no permission): media volume (AudioManager STREAM_MUSIC), per-window brightness (WindowManager.LayoutParams.screenBrightness — dims our window only). - Tier 1 (WRITE_SETTINGS): system-wide brightness + screen-off timeout (Settings.System). - Commands set_volume / set_brightness / set_system_brightness / set_screen_timeout wired in MainActivity.onCommand; ALLOWED_COMMANDS extended for the group path. - SetupActivity gains a one-time WRITE_SETTINGS grant row (mirrors the overlay/accessibility grants); manifest declares WRITE_SETTINGS. Dashboard: - device-detail "System control" section (any Android panel): volume + this-app brightness sliders always; system brightness + sleep-timeout only when the panel reports can_write_settings, else a "grant on the panel" hint. Sends on release (not drag). Validated live on a non-owner tier-0 panel: dashboard → set_volume 0.75/0.15 → the panel's STREAM_MUSIC volume moved to 11/2 (of 15). 423 server tests green. Closes #160. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(system-control): volume slider reflects real volume + device-owner brightness/timeout [#160] Two fixes from live testing: 1. Volume "doesn't remember" — the slider hardcoded 50 because the panel never reported its current volume. Now DeviceInfo reports media_volume (0..1); a new lightweight device:info socket event lets the panel re-report right after a set_volume (no full re-register / playlist re-push); server stores devices.media_volume; the dashboard inits the slider from it. Validated: dashboard set_volume 0.60 -> panel STREAM_MUSIC 2->9 (of 15) -> stored 0.60. 2. System brightness/timeout on a DEVICE OWNER — was gated only on WRITE_SETTINGS, which an owner doesn't have. A device owner can set those via DevicePolicyManager.setSystemSetting with no grant, so SystemControl now takes that path when isDeviceOwner(), and the dashboard enables the Tier-1 controls when can_write_settings OR tier===2. STPolicy.setSystemSetting added. deviceSocket device_info UPDATE extracted into applyDeviceInfo(), shared by device:register and device:info. Migration: devices.media_volume REAL (additive). 423 server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(system-control): brightness/timeout remember + move controls into a tab [#160] Same "remember what it's set to" treatment as volume, now for brightness + sleep timeout, and the System control section moves off the top into its own "Controls" tab. Reporting (DeviceInfo -> device:info re-report -> devices columns -> dashboard slider init): - system_brightness (read from Settings.System, no permission) + screen_off_timeout_ms. - window_brightness: persisted in ServerConfig (survives relaunch, re-applied on MainActivity launch) so the per-window slider reflects it too. - reportInfoNow() now also fires after set_brightness / set_screen_timeout. Dashboard: new "Controls" tab (any Android panel) holding the volume/brightness/timeout controls; every control inits from the reported value; sleep dropdown preselects the current timeout. Server: +3 additive columns (system_brightness, window_brightness, screen_off_timeout_ms); the device_info UPDATE stores them. Migrations all additive/re-runnable. 423 server tests green. Validated live: set_brightness 0.40 -> stored window_brightness 0.40; volume 0.30 -> 0.33. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
501ffb11c1
|
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159. |
||
|
|
938a43a466
|
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]
Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.
Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
member whose playlist matches the group's shared playlist (playlist-match guard —
a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.
Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
GROUP = same leader/follower timing incl. the full video drift controller, but
full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.
Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.
Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): web + Tizen player parity [stage 2]
Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.
Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).
Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
(UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
group_sync block, else exits — content renders through the normal single-zone path.
Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]
On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
playback; server re-pushes to members so they enter/exit sync mode. A hint notes
it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
elect, which self-heals; or pin a specific member to always lead when online).
Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).
Frontend parses (ESM); server suite 407/407.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): rework to clock/schedule sync + double-buffer + polish
Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
- needs no server at play-time (offline-native), and
- has no leader role to double-elect (kills the split-brain class the leaked
WallController tick produced).
Server
- heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
discipline; the client caches the offset (survives an outage).
- POST /groups/:id/resync -> group:resync (manual "Resync now").
- (kept: group_sync payload; leader machinery is now vestigial/ignored.)
Clients (web / Tizen / Android)
- Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
- Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
slot formula identical across platforms so mixed-platform groups can't drift.
- Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
- Double buffer: warm the next clip a few s before the boundary -> instant
switch, no black hold. Android pre-decodes on a throwaway surface so the swap
doesn't flash one wrong-aspect (landscape-stretched) frame.
- In-place duration edits: duration_sec dropped from the change signature and
applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
- Live-log shows discrete corrections (jump/align/seek) immediately; only the
steady-state line is throttled.
Android
- Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
main looper outlived the Activity -> zombie broadcaster / split-brain).
Dashboard
- Group leader picker -> "Resync now" button.
Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |