# Changelog ## 1.9.29-rc4 ### Fixed — the web player's offline cache was switched off at the URL everyone uses A service worker's default scope is its own directory, so `/player/sw.js` could only ever control `/player/` **and below** — which does not include `/player` itself. The player is served at all three of `/player`, `/player/` and `/player/index.html`, and `/player` is the one that gets used: it is what the dashboard shows and what gets typed into a panel. On that URL registration *succeeded*, logged "Service Worker registered", and then controlled nothing at all: no shell cache, no content cache, no offline playback, and no error to notice. Registration now asks for scope `/`, and the server sends `Service-Worker-Allowed` to permit it. Both halves are load-bearing — without the header the registration does not narrow, it fails outright. Found by driving a real browser at the player; no unit test could have seen it, because the bug lived entirely in the relationship between a URL and a header. ### Fixed — screens went black on a bad link instead of playing cached content Reported from a one-bar 5G site. The offline playback path was never the problem: the cache could never be **filled**. Every download attempt started at byte 0 and the partial was deleted on any interruption, so an asset larger than one uninterrupted transfer was discarded and re-fetched forever — minutes of progress thrown away, back off, repeat. With nothing cached, the player showed its waiting state, which from across a room reads as a black screen. Downloads now resume: an interrupted transfer keeps its partial and asks for the rest with `Range`. Two ways that could corrupt a cache, both closed — `If-Range` makes a changed asset come back as a full body (restart) rather than a spliceable tail, and a partial longer than the asset is discarded on a 416. Bytes are kept only where they can be built upon: with no validator there is no safe resume, so the partial is dropped and the attempt backs off as the failure it is. ### Added — every player now caches media for offline playback - **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. It now caches the media itself to `wgt-private`, resumable, with the transfer asynchronous so a stalled chunk cannot freeze the player. `offline.cache` is declared at runtime rather than assumed: a build with no writable private storage still says nothing. - **The web player** (and BrightSign, which runs it) stored only what a single `fetch()` happened to complete — nothing at all on a marginal link. It now accumulates in resumable chunks, driven by the player's playlist rather than by playback, so the prefetch does not compete with the video on screen for the same scarce bandwidth. ### Fixed — replacing an asset could never reach a screen that had already cached it `PUT /api/content/:id/replace` changes an asset's bytes under a stable id, and every player caches by that id — so the new bytes could not reach a panel that already held the old ones. Not "until the next refresh": never. Content now carries a revision, stamped onto each item at send time, 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 baked into a published playlist snapshot pointed at a **deleted** file, and web panels 404'd on that item until somebody thought to republish. The route now also pushes to affected devices, which it never did. Superseded copies are reclaimed rather than left for the quota: the player declares the complete set of media it needs and the worker drops everything else. ### Added — capability declaration across all four players Each player declares what it can actually do at registration, and the dashboard stops offering controls that cannot work on that hardware. An absent declaration falls back to a per-platform baseline, so the displays already in the field keep their controls rather than losing them the moment this ships. ## 1.9.29-rc3 ### Fixed — autorun.zip could not be opened by a player Reported from a real automated deployment: the rc2 archive reached the player and was rejected as invalid. Two causes, both ours. - **The archive must be STORED, not compressed.** The player bootstrap extracts `autozip.brs` by itself before any script runs, and `roBrightPackage` supports a specific set of methods, of which "no compression" is the universally safe one. Both builders now store — and the server-side package builder had been using maximum deflate, so **every self-update package it produced would have failed the same way**, silently and in the field. - **`roBrightPackage`, not `roUnzip`**, is the supported reader. Converted in `autozip.brs` and in the self-update path. Both builders now assert the property instead of trusting the flag: the build script refuses a compressed member, and a test walks the archive's local file headers. A compressed package uploads, downloads and deploys perfectly and only then fails to open, which reads as a broken deployment rather than a broken zip. `autozip.brs` also adopts the shipped volume-discovery pattern — probe `USB1:`/`SD:`/`SSD:`/`FLASH:` for the archive rather than guessing, since a player may boot from internal flash. ### Fixed — muting never reached a YouTube item A YouTube item is a cross-origin iframe, so `el.muted` reaches nothing. The two browser-family players failed in opposite directions: the web player consulted autoplay policy and nothing else, so an item muted in the admin console **played with sound** and a wall follower blared alongside its leader; Tizen hardcoded `mute=1`, so YouTube there was **permanently silent** and no toggle could change it. Android was already correct. The rule now lives once in `server/lib/media-mute.js`, and the unmute prompt no longer appears on an item an operator deliberately muted. ### Fixed — screenshots reported success while sending blank frames Capture marked itself successful because the draw did not throw. On a hardware plane `drawImage(video)` returns a fully transparent image and throws nothing, so the dashboard showed a dead screen while the panel played perfectly. Capture is now proven by an alpha probe, so a genuine fade-to-black still reads as captured. ### Added - **BrightSign native synchronisation**, wired end to end and chosen per group, reusing the existing leader election. A group whose leader is offline falls back to the clock protocol rather than waiting for an announcement that never comes. - **Real telemetry and hardware identity** — temperature, player storage, model, OS version, serial and output index — instead of a block of nulls and a `wifi_ssid` of "Web Player" on a PoE appliance. - **Offline content caching** with correct range-request handling, and a **package self-update** whose version is stamped into `autorun.brs` at build time — unstamped, a player applies an update, still reports the old version, and is offered it forever. - **Command parity**: real `reboot`, real display blanking, and `set_volume` on BrightSign. ### Removed - The `user_agent` fallback in BrightSign detection. `devices` has no such column, so the branch was unreachable and passed only in a test that fabricated the field. ## 1.9.29-rc2 Fixes for three things rc1 only revealed once it was deployed and pointed at real hardware. ### Fixed - **The player assets 404'd in a container.** `/player/st-bridge.js` and `/player/st-sync.js` are served from `../brightsign` so the copy the player loads can never drift from the copy on the player's own storage — but the Dockerfile never copied that directory into the image, so both routes worked from a dev checkout and failed in Docker. Note how this fails when the route is absent entirely: the SPA fallback answers **200 with `text/html`**, so the browser gets a page where it expected JavaScript and the bridge silently never exists. - **A BrightSign kept re-pairing on every boot.** The bridge persisted `device_id` but not `device_token`. The server authenticates a claim to an existing display with the token, so an id presented without one reads as a brand-new player and gets a fresh device row. - **A BrightSign was labelled "Web Player".** It runs the same web player, so `client_type` is `player` and the device view fell through to a hardcoded label — indistinguishable from a browser tab, for a dedicated signage appliance. ### Added - **`autorun.zip` — a single-file player installer**, attached to every release and built by `scripts/build-autorun-zip.sh`. Drop it on the root of a player's storage and power-cycle. - **Booting from internal flash.** A player runs `FLASH:/autorun.brs` with no card present at all, so a failed card slot no longer ends a player's life. Confirmed on an XT245 with a physically dead microSD interface. ## 1.9.29-rc1 **BrightSign port.** The player on BrightSign is the ordinary web player running in an `roHtmlWidget` — that part already worked. This release adds the host around it, which covers what a page cannot do for itself, and a per-group choice of synchronisation protocol. Release candidate: cut for testing on alpha, not for production fleets. ### Added - **`brightsign/autorun.brs` — a supervised host, not a URL wrapper.** It owns the widget lifecycle, because a page-initiated `location.reload()` does not reliably bring an `roHtmlWidget` back: a deploy on 2026-07-28 reloaded every connected player and the BrightSign was the only one that never returned. The page now posts `{type:"restart"}` and the host rebuilds the widget. It also retries `load-error` with backoff, falls back to a local page, and runs a heartbeat watchdog that catches a page which loaded fine and then wedged — the case `load-error` never reports. - **`brightsign/st-bridge.js` — the page's half of that contract**, over `@brightsign/messageport`. Registry-backed identity (the registry outlives `localStorage` on this platform), restart-instead-of-reload, heartbeat, and sync-backend reporting. Every method degrades to a no-op off-platform, so it is served to every player rather than gated on a user agent. - **`brightsign/st-sync.js` — native SyncManager support.** Frame-accurate video sync between BrightSign players via `setSyncParams` on the standard `