Skip to content

Repository files navigation

Phlix Media Server - Roku Application

Lint Tests Package Platform Language

A native Roku application for the Phlix Media Server platform. Stream your media library with full playback control, seamless authentication, and progress synchronization.

Note: identifiers such as R7.6, MJ-8, CHECK 83 and W-min91 in this README and docs/ are internal fix-lane / ledger references kept for provenance; they are not public issue numbers.

Features

  • Secure Authentication: Device registration with token-based session management
  • Library Browsing: Browse movies, TV shows, and collections with intuitive navigation
  • HLS Video Playback: Stream video content with adaptive bitrate support
  • Full Remote Control: Complete playback control via Roku remote (play, pause, seek, stop)
  • Progress Synchronization: Track and sync watch progress across sessions
  • Multiple User Support: Personalized libraries and watch states per user
  • Hub / Multi-Server Mode: Point the Connect screen at a Phlix Hub instead of a single server — after login the client detects the hub (GET /api/v1/me/servers), shows a server picker, and routes all media requests to the chosen server through the hub's relay proxy (the hub Bearer is the relay auth). The hub relay supports all HTTP verbs (GET, PUT, DELETE, PATCH, POST). See "Hub / multi-server mode" below.
  • Skip Intro/Outro: Automatically displayed skip buttons when playback enters marker ranges defined by the server (intro start/end, outro start/end)
  • SyncPlay / Watch Together (device-unverifiable — see the LIMITATIONS box under "SyncPlay (Watch Together)"): a hand-rolled RFC6455 WebSocket client (source/lib/SyncPlayProtocol.brs + components/SyncPlayTask.{xml,brs}) lets several devices watch the same content in sync. Open the Watch Together overlay in the player (the */Options key), pick a group from the list (or Create), and playback follows the host (play/pause/seek) with NTP-style drift correction. Direct mode only (disabled in hub mode), and ws:// only because Roku's roStreamSocket has no TLS.
  • Manual quality selection (the transient content-swap-while-playing behavior needs an on-device smoke test — see the CHANGELOG): press Up in the player to open a quality picker listing Auto (server-driven ABR, the multi-variant master) plus each rung the active transcode advertises, highest first, read from the server's variants[] ladder. Picking a rung swaps to that rung's own signed playlist and resumes at the same position; picking Auto hands playback back to native ABR. The choice is remembered (Storage key preferred_quality) and re-applied on the next transcode. With no ladder (direct-play or a legacy job) the picker shows Auto only and no-ops gracefully.
  • Audio & subtitle track selection: press Down in the player to open a Settings panel (Audio / Subtitles). The tracks are read from playback info (audio_tracks / subtitle_tracks); picking a subtitle track applies it to the Video node (currentSubtitleTrack, with "Off" to disable) and picking an audio track records the preference. The choice is remembered (Storage keys preferred_audio_track / preferred_subtitle_track). Additive: the panel is hidden by default, so the default playback path is unchanged when it is never opened, and it degrades gracefully to an empty-state message when no tracks are present.
  • Profile Management (admin): View a user's profiles and adjust the parental-control rating or clear a forgotten PIN — button-driven, no keyboard. Reached via Admin → Users → (select user) → Profiles

Not Yet Implemented

The following features are not currently available:

  • Books / e-reader functionality
  • DVR write operations
  • Casting / screen mirroring

Prerequisites

Required

  • Roku Device: Any Roku device with developer mode enabled
  • Phlix Media Server: Running instance accessible on your network
  • Roku Developer Account: NOT needed for sideloading — device developer mode covers it (make install). The paid account is a store-submission prerequisite only (docs/publishing.md Prerequisites).

Development Tools

  • Toolchain in this repo: make package (zip + scripts/verify-package.sh) builds, make install sideloads via curl to the device's dev-mode installer. There is no external Roku SDK CLI to install — neither rokupkg nor rokudev resolves on the public npm registry (404; control-probed 2026-11-03), so the old "( rokupkg )" prerequisite was fiction [review CR-4]
  • BrightScript Editor: the VS Code extension BrightScript Language by RokuDeveloper (the canonical marketplace extension) or any editor
  • curl: For direct device communication
  • zip: For creating packages
  • python3: Used by repo helper scripts (scripts/)
  • unzip: Used by make verify-package to inspect the built zip

Network Requirements

  • Roku device and Phlix Media Server on the same network
  • Phlix Media Server API accessible from Roku device

Installation

1. Enable Developer Mode on Roku

Press the following button sequence on your Roku remote:

  1. Home (3 times)
  2. Up (2 times)
  3. Right (1 time)
  4. Left (1 time)
  5. Right (1 time)
  6. Left (1 time)
  7. Right (1 time)

Note the IP address shown and enable dev mode via the web interface at http://<ROKU_IP>: enable the Development Application Installer, accept the Developer Tools License Agreement, and set a device password — the player reboots with the installer page live (the same on-device flow docs/device-verification.md §1 documents). The password you set there is exactly what ROKU_PASSWORD (§ Environment Variables, example rokipassword) must hold: make install authenticates the upload with it (HTTP digest against port 80's /plugin_install — step 3 of the same doc), so a lost password means restarting dev mode from the remote sequence above to set a new one.

2. Clone and Configure

# Clone the repository
git clone https://github.com/detain/phlix-roku-client.git
cd phlix-roku-client

# Review the manifest (title/icons); the shipped version triple comes from
# package.json's "version", not from hand-editing manifest version fields

3. Install Dependencies

Nothing is required on the device — BrightScript is natively supported by Roku OS. The development toolchain is not: make lint runs npx bsc, and all three CI workflows (lint.yml, test.yml, package.yml) run npm ci against package.json's devDependencies first. All three also run scripts/audit-gate.mjs (RT-088): a documented npm-audit allow-list — the seven allowed advisory ids all hang in the brs@0.45.0 dev chain (the device-free test interpreter, superseded upstream) with NO reachable fix, per-entry proofs printed on every run; ANY other advisory at ANY severity reddens the gate, and a broken audit never exits green. From a fresh checkout:

npm ci    # devDependencies: brighterscript, @rokucommunity/bslint, rooibos-roku, brs

4. Configure Server Connection

There is nothing to pre-configure — the server URL is entered in-app on first launch.

On first run (no server_url persisted yet) the boot flow shows a Connect to server screen (ConnectScene):

  1. Enter your server's address (a direct Phlix server or a Phlix Hub — both expose /health identically), e.g. https://my.phlix.server or http://192.168.1.100:8096.
  2. Press Connect. The URL is normalized (a missing scheme is inferred — http:// only for private/LAN address literals (RFC1918 incl. 10.x/172.16-31.x/192.168.x, loopback 127.x/::1/localhost, link-local 169.254.x), https:// for everything else — and a trailing / is stripped) and probed via GET {url}/health.
  3. On a healthy probe the URL is persisted to registry storage under server_url and the app proceeds to the login screen, then Home. Credential custody (M-ROKU-15): an http:// origin outside that private class must pass a loud consent dialog first — connecting over cleartext means the login password and every later Bearer ride it unencrypted. Declining persists nothing (default-deny); both this path and the "Connect anyway" override below funnel through the same gate.
  4. If the probe fails (server unreachable, or /health is blocked), an error is shown and a Connect anyway button appears — pressing it persists the entered URL and proceeds regardless (subject to the same consent gate).

Credential-bearing API requests never auto-follow 3xx redirects: the client decides each hop itself and re-sends credentials only for a same-origin target (or a same-host http -> https upgrade), bounded at 3 hops; anything else refuses loudly as a dedicated "redirect refused" error instead of walking a Bearer/password to a host the user never chose. The zero-credential /health probe still follows redirects normally.

Bearer custody is device-class by design, not a deferral (estate cookie-auth design, COOKIE-DESIGN §2.1/§4.6, owner ruling D6c, 2026-11-05): a Roku channel has no cookie jar and never participates in browser-session cookie semantics, so the Authorization: Bearer header on API requests and the two-entry bearer subprotocol on the SyncPlay socket are the permanent carrier shape for the tokens this app persists in registry storage — they never migrate to cookie custody. (The M-ROKU-23 roSecureSocket deferral is a hub-relay WSS transport deferral pending on-device verification; it makes no claim about token custody.)

On subsequent launches the persisted server_url is reused and the Connect screen is skipped (boot goes straight to login or, if a session is restored, Home). The login screen itself only collects username/password — it no longer has a server-URL field.

5. Hub / multi-server mode

The connect URL entered on the Connect screen can be either a direct Phlix server or a Phlix Hub — they expose /health and POST /api/v1/auth/login identically, so the connect + login flow is the same for both. The client decides which it is talking to after login, transparently:

  1. Probe. Immediately after a successful login the client calls GET /api/v1/me/servers.
    • A hub returns {servers:[ … ]} (camelCase rows). The client persists connection_kind = "hub" and routes to a new server picker screen (ServerPickerScene).
    • A direct server has no such route (404 / no servers array). The client persists connection_kind = "direct" and goes straight to Home, exactly as before. Direct mode is unchanged.
  2. Pick a server. The picker lists each claimed server with its name, online/offline status, and library count. Selecting one persists active_server_id (and active_server_name for display) and proceeds to Home.
  3. Relay routing. Once a server is picked, the media ApiClient is rebuilt against the hub's relay proxy base — {hub}/api/v1/servers/{serverId}/proxy — so every existing scene and background task transparently routes its requests through the hub to the chosen server with no per-scene changes. The hub Bearer token is the relay auth: the hub strips the client's Authorization/Cookie headers, verifies the logged-in user owns the target server (403 otherwise), injects X-Phlix-Relay-User, and tunnels the request over its WSS connection to the server, which trusts the tunnel. No separate per-server token is involved.
  4. Boot / session validation. On a subsequent launch in hub mode the persisted hub session is validated against the hub directly (GET /api/v1/auth/me on the bare hub URL, not over the relay — /auth/me over the relay returns the hub-user perspective and is unreliable). If valid and a server is already picked → Home; if valid but no server picked → the server picker; otherwise → login. Logging out clears connection_kind, active_server_id, and active_server_name but keeps server_url, so you return to the login screen on the same hub/server.
  5. Command-consumer origin law (M-ROKU-23). The "Alexa, play X" surface is a second, separate use of the hub's relay: while the app is open in hub mode it keeps a long-lived socket to the relay worker on the fixed plaintext port 8804, carrying a hub-minted relay token on the raw ws:// handshake (components/HubCommandTask.brs). That socket is roStreamSocket — plaintext TCP. Refusal law: when the persisted hub origin is https://, the client refuses the cleartext :8804 dial outright (HubRelayCleartextRefusedForOrigin, checked before the token is even read, at both the scene and the task — CHECK55) — a credential minted over a TLS lane never rides cleartext behind the hub's own TLS identity; "Alexa, play X" stays closed on https hubs. http:// and legacy scheme-less hubs keep the consumer exactly as before (their REST lane is already cleartext, so the dial adds no NEW exposure class). The wss arm via roSecureSocket is deferred, not impossible: the managed hub's TLS front-end already routes GET /syncplay/ to the relay worker (phlix-hub docs/websockets.md), so a wss://<hub-domain>/syncplay/{server_id} route exists server-side today; the client leg awaits on-device verification of roSecureSocket's surface (the platform-API verification floor — same posture as the :8097 SyncPlay lane above).

Hub mode storage keys

Key Description
server_url The connect endpoint — a hub URL or a direct server URL (set on the Connect screen).
auth_token / refresh_token The login token. In hub mode this is the hub token, which doubles as the relay Bearer.
connection_kind "hub" or "direct". Absent / unrecognized is treated as "direct".
active_server_id The hub server chosen in the picker; appended to the relay proxy base.
active_server_name Display name of the chosen server.

SyncPlay (Watch Together)

SyncPlay lets several devices watch the same content together, staying in sync without manual timestamp coordination. It is a TV-friendly, button-driven overlay inside the player — there is no separate SyncPlay scene; all playback control stays in PlayerScene, so when the overlay is never opened the player path is byte-identical to a non-SyncPlay build.

Room-scene retention (dead path). components/SyncPlayScene.{xml,brs} + source/lib/SyncPlayManager.brs (the abandoned standalone P8-S4 room UI and its REST-snapshot manager) are a DEAD PATH — not wired into the shipped UI. The room scene has zero instantiation sites (no CreateObject, no XML child, no route) and the manager's sole caller is that never-instantiated scene; the live watch flow is the PlayerScene overlay driving SyncPlayTask directly. The files still ship, retained for a possible room revival; the wire-vs-retire disposition is an open owner decision. Check 56 (scripts/verify-runtime.sh) pins this claim to the instantiation graph, so silently wiring the room — or silently deleting these truth markers — reddens CI.

⚠️ LIMITATIONS — read before relying on SyncPlay

  • DEVICE-UNVERIFIABLE. Roku BrightScript only runs on hardware (there is no host runner), so this code has been verified by brighterscript (bsc) + code review only — never executed.
  • Built to a shipped server target. The whole slice targets the post-SP* phlix-server SyncPlay wire contract, which is live: the :8097 WebSocket worker is registered in phlix-server/start.php (section 4a) and served by src/Server/WebSocket/Workers/SyncPlayWorker.php on origin/master. The two-entry bearer-subprotocol auth law landed in phlix-server 424c14d0; this client flipped to it in 07eef68 (BearerSubprotocolOffer, source/lib/SyncPlayProtocol.brs:309 at that commit; the definition now sits at :473 of the live file; grep BearerSubprotocolOffer rather than trusting the number).
  • ws:// only — this build ships no wss:// path. The WebSocket rides Roku's roStreamSocket, which is plaintext TCP with no TLS, so this hand-rolled client can speak ws:// only. That is a BUILD limitation, not a platform one: Roku's TLS client socket roSecureSocket exists, and a wss migration stays a deferred option (docs/static-checks.md, Check 46 scope note) — the credential is guarded by the refusal law below, not by a transport that cannot do TLS. The deferral carries a build-time floor too: bsc's vendored Roku type data (brighterscript/dist/roku-types/data.json) models roStreamSocket but not roSecureSocket (it omits roSocket likewise — the defs are non-exhaustive, not disproof), so when the deferred arm is implemented, bsc type-support and the no-fabricated-apis law must be re-verified BEFORE writing code against the socket. On a TLS-fronted production box (HAProxy terminating TLS) SyncPlay connects only if the server's plaintext :8097 is reachable from the Roku (same LAN, or a documented plaintext exposure). The client derives a plaintext ws://<host>:8097/syncplay URL from the connected server origin; the JWT rides the two-entry bearer subprotocol handshake header, not the URL query. Refusal law (M-ROKU-14, review roku__exploiter M3): when the connected server origin is https://, the client refuses the plaintext :8097 dial outright (SyncPlayWsRefusedForOrigin, checked before the credential is even read) — a TLS-established account JWT is never offered on a cleartext handshake; http:// origins keep dialling (their REST lane is already cleartext there).
  • Hub mode: disabled. The hub relay proxy is HTTP-only (it strips the Upgrade header), so there is no WebSocket path through the hub. In hub mode the overlay refuses to open with a friendly message ("Watch Together isn't available in hub mode yet").
  • Deferred: chat / typing, host transfer, playback queue, and periodic playback_sync broadcast. Password-gated joining SHIPPED with the join-by-code form (MJ-2): the group's sp_* code is typed on the TV and the password is presented only as its SHA-256 digest (syncplay_group_join.password_hash, SPEC §4) — the plaintext never touches the socket.
  • Handshake proof is enforced (M-ROKU-14). The client sends a random Sec-WebSocket-Key and validates the server's Sec-WebSocket-Accept hash (Base64 SHA-1 of key + the RFC6455 magic GUID) before treating the socket as a session — fail-closed: an absent header, a mismatch, or a device without SHA-1 support all refuse the dial (local.ws_accept_invalid). The era that accepted the 101 unverified is over.

Usage

  1. Open the overlay. During playback, press the * / Options key to open the Watch Together overlay (direct mode only — in hub mode it shows the disabled message).
  2. Pick, create, or join by code. The overlay lists existing groups (a read-only snapshot from GET /api/v1/syncplay/groups). Select one to Join, press Join With Code to type a group's sp_* code shared from another device — plus its password when the room is protected (the digest is computed on-device; the plaintext never leaves) — press Create Group to start a new group (named "<device>'s Group", no keyboard), or Leave to exit. A status line shows the connection state, group name, member count, and sync (offset/stable) indicator.
  3. Watch together. Playback follows the group host: inbound host play / pause / seek are applied to the local Video node with NTP drift correction (ms→s at the boundary) and echo-suppressed by your own member id. When this device is the host, your local play / pause / seek are broadcast to the group.

Wire contract (canonical syncplay_*)

The on-the-wire protocol mirrors @phlix/syncplay and the phlix-server Messages::TYPE_* constants. The transport implementation is source/lib/SyncPlayProtocol.brs (pure RFC6455 framing + flat codec + NTP TimeSync, no I/O), driven by the long-lived components/SyncPlayTask.{xml,brs} node (socket I/O off the render thread).

  • Framing is FLAT. Every message, in and out, is a single flat JSON object — payload fields spread at the top level, not nested under data: { "type": "syncplay_<name>", "protocol_version": 1, "timestamp": <ms>, …payload }. The repo's old syncplay.<dot> event notation was wrong and never implemented — the real types use the syncplay_ underscore prefix.
  • protocol_version is always 1; timestamp is sender wall-clock milliseconds; all positions / durations on the wire are milliseconds (Roku Video position/seek are seconds → the client converts at the boundary).
  • syncplay_group_state is the one nested case: { type, protocol_version, timestamp, group:{…}, your_id:"…" } — group and your_id sit flat at the top level, but group is itself an object (group_id, group_name, member_count, members:[{id,name,is_host,joined_at}], host_id, current_media_id, playback_position (ms), playback_state, …). The client learns its real member id from group_state.your_id (used for echo-suppression and host detection).
  • Auth + URL: the WS connection is authenticated on the upgrade via the two-entry bearer subprotocol request header Sec-WebSocket-Protocol: bearer, <access_token> (the same access token the REST client holds in Storage auth_token); unauthenticated sockets are rejected before any frame. The canonical URL is wss://<host>/syncplay → server :8097; this build's client rides the plaintext roStreamSocket (no wss path implemented — see the LIMITATIONS box: TLS-origin dials are refused outright), so it connects to the derived plaintext ws://<host>:8097/syncplay — no token in the URL. (Estate policy WEBSOCKET_URL_QUERY_REFUSED, flipped for this client by phlix-server 424c14d0; the server still accepts the legacy ?token= query carrier transitionally for older builds but refuses a handshake whose two carriers disagree, and echoes Sec-WebSocket-Protocol: bearer on accepted offered handshakes — the client's 101 check is status-line-only, so the echo is tolerated by design.)

The 19 canonical message types (the TV client implements the subset shown below; chat / typing / host_transfer / queue / sync are deferred):

Type (syncplay_*) Direction On TV Notes
syncplay_group_create Client → Server ✅ group_name, member_name? (sent on Create)
syncplay_group_join Client → Server ✅ group_id, member_name? (sent on Join)
syncplay_group_leave Client → Server ✅ group_id, member_id (sent on Leave)
syncplay_group_list Client → Server — group browse uses the REST snapshot instead
syncplay_playback_play both ✅ group_id, member_id, position (ms), server_time (ms)
syncplay_playback_pause both ✅ same payload as _play
syncplay_playback_seek both ✅ from_position, to_position, server_time (all ms)
syncplay_playback_queue both — deferred (no queue UI)
syncplay_playback_sync both — deferred (no periodic broadcast)
syncplay_chat / syncplay_typing both — deferred (no chat UI)
syncplay_host_transfer Client → Server — deferred
syncplay_time_ping Client → Server ✅ client_time (ms = t1); sent on the periodic tick
syncplay_time_pong Server → Client ✅ client_time (echoed t1), server_time (t2)
syncplay_group_state Server → Client ✅ nested group + your_id (see above)
syncplay_host_elect Server → Client ✅ elected_id, elected_by (re-election on host leave)
syncplay_time_sync Server → Client — not consumed (client maintains its own offset)
syncplay_info Server → Client ✅ message (+ member_id/member_name on member JOIN)
syncplay_error Server → Client ✅ error_code (fallback code) + message

Time Synchronization Protocol (NTP-style, milliseconds)

Implemented in SyncPlayProtocol.brs TimeSync, mirroring @phlix/syncplay SPEC §5:

  1. Every tick (~4s) the client sends syncplay_time_ping with client_time = t1 (ms).
  2. The server replies syncplay_time_pong with client_time (echoed t1) and server_time (t2 = server receive time; there is no t3/server_receive_time field).
  3. Per pong (t3 = t2): rtt = t4 - t1, where t1 and t4 both ride NowMonoMs() — the same monotonic base at send and receipt, so an NTP step can neither forge nor poison an rtt (MJ-15). oneWay = rtt/2; offset = t2 - t1 + oneWay. Samples with rtt > MAX_RTT (5000 ms) are rejected — MN-13: the old 1000 ms bound starved the offset estimator on congested Wi-Fi, so every sample over one second was dropped and the party ran uncorrected.
  4. offset is the weighted mean (weight 1/max(1,rtt)) over the last 5 samples; it is considered stable when there are ≥5 samples and the recent-offset variance is < 50.
  5. A drift-rate EMA 1.0 + 0.1*(Δoffset/Δt)/1000 (clamped to [0.99, 1.01]) scales the follow position: adjusted ms = position + (NowMonoMs() + offset - serverTime) * driftRate, converted to seconds before any video.seek.

Configuration

Environment Variables

Variable Description Default
ROKU_IP IP address of the Roku device (sideload/launch targets: install, launch, stop) 192.168.1.100
ROKU_DEV Developer username rokudev
ROKU_PASSWORD Developer password rokipassword
ROKU_HOST Device address for the test targets — setting it makes make test / make test-unit drive rooibos on hardware instead of the device-free headless gate. It is NOT read by install (that uses ROKU_IP) — (unset)
ROKU_TEST_HOST Test-gate alias for ROKU_HOST — either name arms the device path; neither arms anything outside the test targets — (unset)

Manifest Configuration

Edit manifest to customize:

  • title: Application name
  • ui_resolutions: hd — the manifest's actual declaration, pinned by Check 38 to the hardcoded 1280×720 design plane (the legacy full alias stays retired alongside it; see the manifest comment above the key)

The version triple is not edited here: make package derives major_version/minor_version/build_version in the build manifest from package.json's version (via scripts/derive-version.sh; Check 18 reddens drift, and store submissions require a version bump — see docs/publishing.md "Version Discipline").

Building the App

Standard Build

# Create package for sideloading
make package

# This creates: phlix.zip
# Version fields are injected into build/manifest by scripts/derive-version.sh

# Or build a signed store-submission package (see docs/publishing.md)
make package-signed

Install to Device

# Install to configured Roku IP (device dev-mode password, not the portal one)
make install ROKU_IP=192.168.1.100 ROKU_DEV=rokudev ROKU_PASSWORD=yourpass

Development Workflow

# 1. Make code changes
# 2. Package and install
make package install ROKU_IP=192.168.1.100

# 3. Launch app
make launch ROKU_IP=192.168.1.100

# 4. Debug via telnet on port 8080
telnet 192.168.1.100 8080

Manual Deployment

# Create package manually (components/ and locale/ are required — omitting either
# ships a UI-less or raw-key-rendering channel; 'make package' additionally derives
# the manifest version and verifies the layout via scripts/verify-package.sh)
zip -r phlix.zip manifest source components images locale

# Sideload via curl. The endpoint is the device's dev-mode installer — the
# web form on port 80, /plugin_install, HTTP digest auth — NOT an ECP :8060
# endpoint: :8060 documents only launch/keypress/query. Three laws in one
# recipe [review CR-3 + MN-30, port truth per M-ROKU-21]: no 'manifest' form
# field (the zip's root manifest is the single source the installer must
# see — a raw second copy re-creates the dual-source shape the C1 law
# forbids); credentials never ride argv (-u user:pass is visible in ps);
# -sS, not -v (verbose echoes the auth header into scrollback).
read -rs -p "Roku dev-mode password: " ROKU_PASS
# Response law [ROK-6]: capture the body + HTTP status and fail loud - the
# device answers 200 even for an 'Install Failure' page.
printf 'user = "rokudev:%s"\ndigest\nurl = "http://192.168.1.100/plugin_install"\nrequest = "POST"\nform = "mysubmit=Install"\nform = "archive=@phlix.zip"\nform = "passwd="\n' "$ROKU_PASS" | curl -sS --config - -o roku-install.out -w '%{http_code}\n'
! grep -qiE 'Install Failure|Error Message' roku-install.out || { echo "install FAILED" >&2; false; }

Testing

Unit Tests

Unit tests live in tests/unit/ — 31 rooibos unit suites at time of writing, one *.test.brs file per module. The full listing is the Test Structure block below; Check 62 pins this count and the listing to the shipped tree, so a re-drift reddens CI.

# Drive the FULL suite on hardware (rooibos) — needs a real device/emulator:
ROKU_HOST=192.168.1.100 make test-unit

# Device-free (the CI default): make test-unit / make test run the headless
# BrightScript interpreter gate — node tests/scripts/run-headless-unit.mjs —
# which executes every pure-logic suite in brs and exits non-zero on any
# failure. Suites are excluded there with per-test reasons (function-object
# state, roByteArray, NUL bytes…), listed in that script's EXCLUDED maps and
# the W-min91 fixlog — one entry is NOT a device idiom but a fiction class:
# the `Theme` suite drives theme constants that exist nowhere in the tree, so
# those tests never ran anywhere, device included. Integration suites
# still require a device: `make test-integration` says so and fails loudly.

Running Tests on Device

  1. Deploy to device: make install
  2. Run make test-unit (headless host runner, or rokuunit when ROKU_HOST is set); make test-integration is device-only

Integration Tests

Integration tests in tests/integration/ test API client against a live server.

# Run integration tests (requires running Phlix server)
# Deploy tests to device and run via developer portal

Route Manifest Gate

Every URL the client can put on the wire is pinned tuple-exact against a vendored copy of the phlix-server route manifest. It is device-free (static scan + exact compare) and runs in CI via .github/workflows/test.yml.

# Compare every issued URL against tests/fixtures/server-route-manifest.json
make validate-routes

# Same gate, plus a planted unserved URL proving the check can still go red
node tests/scripts/verify-route-manifest.mjs --self-test

Re-vendoring tests/fixtures/server-route-manifest.json also moves the PROVENANCE_SHA / TOTAL_TUPLES pins at the top of tests/scripts/verify-route-manifest.mjs — update both together.

Test Structure

The tests/ tree as shipped — regenerated from the directory (Check 62 reds CI the moment this block and the tree disagree, in either direction):

tests/
├── unit/                                  # 31 rooibos suites, one *.test.brs per module
│   ├── AdminScanRoutines.test.brs
│   ├── ApiClient.test.brs
│   ├── ApiEnvelope.test.brs
│   ├── AppContext.test.brs
│   ├── AuthManager.test.brs
│   ├── CaptionsMode.test.brs
│   ├── CollectionSceneRoutines.test.brs
│   ├── ContentIdGuard.test.brs
│   ├── DetailSceneYear.test.brs
│   ├── Discovery.test.brs
│   ├── FavoritesSceneRoutines.test.brs
│   ├── HomeSceneRoutines.test.brs
│   ├── HubRelayOriginLaw.test.brs
│   ├── LibrarySceneRoutines.test.brs
│   ├── PanelStack.test.brs
│   ├── PhlixApp.test.brs
│   ├── RedirectCustody.test.brs
│   ├── SearchSceneRoutines.test.brs
│   ├── SkipButton.test.brs
│   ├── SleepTimer.test.brs
│   ├── Storage.test.brs
│   ├── SyncPlayErrors.test.brs
│   ├── SyncPlayJoinCode.test.brs
│   ├── SyncPlayManager.test.brs
│   ├── SyncPlayProtocol.test.brs
│   ├── SyncPlayWsTls.test.brs
│   ├── Theme.test.brs
│   ├── TranslateWithParams.test.brs
│   ├── Utilities.test.brs
│   ├── Utilities2.test.brs
│   └── WireGuard.test.brs
├── integration/
│   └── ApiIntegration.test.brs            # API client against a live server
├── fixtures/
│   └── server-route-manifest.json         # vendored phlix-server route manifest (route gate)
└── scripts/
    ├── run-headless-unit.mjs              # the device-free CI unit gate (make test / make test-unit)
    ├── verify-route-manifest.mjs          # make validate-routes
    └── verify-runtime-portable.sh         # verify-runtime.sh portability regression

Deployment to Roku

Pre-production Checklist

  • Test on physical Roku device
  • Verify all remote buttons work
  • Check video playback with various formats
  • Verify authentication flow
  • Test library browsing
  • Check network timeout handling
  • Verify progress sync

Publishing to Roku Channel Store

Store submission takes the signed .pkg, not the sideload phlix.zip:

  1. Bump package.json's version — Roku requires a strictly increasing (major, minor, build) triple per submission (Check 40 pins the law docs)
  2. Build the signed artifact: make package-signed → phlix-X.X.X.pkg (needs a real Roku-issued code signing certificate; a self-signed cert produces a signing-chain smoke artifact only — it neither uploads nor installs: no .pkg is device-installable, device testing uses make install with the unsigned zip; see the certificate section in docs/publishing.md)
  3. Create/sign in a developer account at developer.roku.com, go to Dashboard → Channels, upload the .pkg, complete the listing and certification checklist, submit for review

The full flow, the CI release vehicle (a rolling latest GitHub prerelease — no versioned releases, no GitHub-side rollback; both are open owner decisions) and the certificate story live in docs/publishing.md. Device token storage is plaintext per-channel registry NVRAM — the platform offers no keystore; the full disclosure lives in that document's section "Security Notes — Device Token Storage (platform limits)".

Private Channel Testing

# Sideload directly for testing
make install

# Or use roku's dev channel mechanism

API Endpoints

The app's complete, gated server URL set is pinned in tests/fixtures/server-route-manifest.json — the make validate-routes CI gate rejects any URL this client issues that is not a (method, path) tuple in that manifest, and scripts/verify-runtime.sh Check 30 cross-verifies every endpoint row below against it. The two hub-mode routes (/api/v1/me/servers and the relay proxy) are registered by phlix-hub, not by phlix-server, so they are deliberately absent from the server manifest.

Method Endpoint Description
GET /api/v1/collections List collections (read-only). Returns {collections:[...]}. Auth-gated (Bearer) and created_by-scoped server-side: members see their own collections plus legacy NULL-owner rows; active admins see all.
GET /api/v1/collections/{id} Get one collection with its items (read-only). Returns {collection, items, total}; items are raw DB rows normalized client-side. Auth-gated and created_by-scoped; a collection the caller may not see returns the same 404 {error:"Collection not found"} as an absent id (no 403).
GET /api/v1/syncplay/groups SyncPlay group-list snapshot (read-only). Returns the whole {groups:[{id, name, member_count, has_password, current_media, is_playing}]} envelope (NOT unwrapped) — note the list uses id/name, not group_id/group_name. Used to populate the Watch Together overlay without typing a group id on the TV; group create/join/leave go over the WebSocket, not REST. See "SyncPlay (Watch Together)".
GET /api/v1/auth/me Get the current user (auth-gated). Returns the unwrapped {user}, which carries is_admin (TINYINT 0/1) and status. The Home screen calls this on init to gate the admin entry. In hub mode the boot session-validation hits this against the hub directly (not over the relay), because /auth/me over the relay returns the hub-user perspective on the server and is unreliable.
GET /api/v1/me/servers Hub-only. Returns {servers:[ … ]} (camelCase rows: serverId, userId, serverName, version, lastSeenAt, status "online"/"offline"/"claiming"/"disabled", hostnameCandidates:[url], relayActive (bool), libraryCount). A direct server has no such route → 404 / no servers array. The client calls this right after login to detect hub vs direct, and the server picker reads the whole {servers} envelope.
GET, PUT, DELETE, PATCH, POST /api/v1/servers/{serverId}/proxy/{path:.*} Hub relay proxy (the only registered relay route). In hub mode, after a server is picked, the media ApiClient is bound to {hub}/api/v1/servers/{serverId}/proxy and sends the hub Bearer; the hub strips client auth, verifies server ownership (403 server.not_owned otherwise), injects X-Phlix-Relay-User, and tunnels the request to the server (which trusts the tunnel). All HTTP verbs are supported over the relay.
GET /api/v1/admin/dashboard/now-playing Admin-gated, read-only. Active streams. Envelope {success, data, count}; data[] rows carry username, media_title, progress_percent, etc. AdminMiddleware → 401 (no/invalid token) / 403 (non-admin).
GET /api/v1/admin/dashboard/storage Admin-gated, read-only. Per-type storage usage. Envelope {success, data, count}; data[] rows carry media_type, item_count, server-preformatted formatted_total. AdminMiddleware → 401/403.
GET /api/v1/admin/dashboard/activity Admin-gated, read-only. Recent activity (optional ?limit=N). Envelope {success, data, count}; data[] rows carry occurred_at, username, event_type. AdminMiddleware → 401/403.
POST /api/v1/libraries/{id}/scan Admin-gated (internal requireAdmin). Enqueues an incremental scan job. 202 {job_id, status, message}. Sent as a bodyless POST.
POST /api/v1/libraries/{id}/rescan Admin-gated (internal requireAdmin). Enqueues a full rescan job. 202 {job_id, status, message}. Sent as a bodyless POST.
POST /api/v1/libraries/{id}/match-metadata Admin-gated (internal requireAdmin). Enqueues a metadata-matching job. 202 {job_id, status, message}. Sent as a bodyless POST.
GET /api/v1/libraries/{id}/scan-status Admin-gated (internal requireAdmin). Returns the latest scan job for the library: `{scan_status: <job row
GET /api/v1/admin/users Admin-gated (AdminMiddleware). Lists all users (optional ?status=pending|active|disabled). Returns the whole {users:[...]} envelope; each user row carries id (UUID), username, email, display_name, is_admin (TINYINT 0/1), status (pending/active/disabled), created_at, last_login. AdminMiddleware → 401 (no/invalid token) / 403 (non-admin).
GET /api/v1/admin/users/{id} Admin-gated (AdminMiddleware). Returns one user: {user} (whole envelope). 404 {error} if not found.
POST /api/v1/admin/users/{id}/approve Admin-gated (AdminMiddleware). Approves a pending user. Bodyless POST. {message} on success; 404 {error}.
POST /api/v1/admin/users/{id}/disable Admin-gated (AdminMiddleware). Disables a user. Bodyless POST. {message} on success; 404 / 400 {error} (cannot disable self; cannot disable last admin).
POST /api/v1/admin/users/{id}/set-admin Admin-gated (AdminMiddleware). Promotes/demotes a user; body {is_admin: bool}. {message} on success; 404 / 400 {error} (cannot demote self; cannot demote last admin).
POST /api/v1/admin/users/{id}/reset-password Admin-gated (AdminMiddleware). Resets the user's password. Bodyless POST. {message, new_password} on success (the new password is shown once); 404 {error}.
GET /api/v1/admin/users/{userId}/profiles Admin-gated (AdminMiddleware). Lists a user's profiles. Returns the whole {profiles:[...]} envelope (admin getters do not unwrap). Each profile row carries id (UUID), user_id, name, avatar_url|null, is_active (bool/TINYINT), is_admin (bool/TINYINT), rating (computed Integer 0-6: G=0, PG=1, PG-13=2, R=3, NC-17=4, X=5, UNRATED=6), created_at, updated_at, and an optional settings object (content_rating label string, pin_required_for_admin bool, max_daily_watch_time, allow_unrated, …). 404 {error} if the user is not found.
GET /api/v1/admin/profiles/{id} Admin-gated (AdminMiddleware). Returns one profile: {profile} (whole envelope). 404 {error} if not found.
PUT /api/v1/admin/profiles/{id} Admin-gated (AdminMiddleware). Updates a profile; the TV client sends {rating: <int 0-6>} only (name/PIN edits deferred). {message} on success; 400 {error, field_errors?} / 404 {error}.
DELETE /api/v1/admin/profiles/{id}/pin Admin-gated (AdminMiddleware). Clears (removes) the profile's PIN. Bodyless DELETE. {message} on success; 404 {error}.
GET /api/v1/admin/livetv/channels Admin-gated (AdminMiddleware). Lists Live TV channels. Returns the whole {success, channels:[...]} envelope (admin getters do not unwrap). Each channel row carries id (== channel_id, the UUID used for /stream), name, number (Integer), type, frequency (Integer), tuner_id, service_id, visual_id, description, icon_url, visibility, created_at, updated_at. AdminMiddleware → 401 (no/invalid token) / 403 (non-admin). If Live TV is not configured the route group is not registered → 404; configured-but-no-tuner returns {success, channels:[]}.
GET /api/v1/admin/livetv/channels/{id}/stream Admin-gated (AdminMiddleware, Bearer). Returns a 302 redirect (Location header) to the actual stream URL. The redirect target is an unauthenticated tuner/HLS URL (HDHomeRun: a direct http://<host>:5004/auto/vN raw MPEG-TS URL; IPTV: may be an .m3u8). Because the Roku Video node cannot carry the Authorization header, the client resolves this 302 with Bearer and hands the final (unauthenticated) URL to the player. See the Live TV caveat below.
GET /api/v1/admin/livetv/guide Admin-gated (AdminMiddleware). Read-only EPG. Returns the whole {success, programs:[...]} envelope (admin getters do not unwrap). The client sends no query params → upcoming programs across all channels (next 7 days, capped 500 server-side). Each program row carries id (== program_id), channel_id, title, description, start_time / end_time (Integer, UNIX seconds), duration (Integer, seconds), category, series_id, episode_number (Integer|null), episode_title, rating, year (Integer|null), is_repeat / is_film (Bool), created_at, updated_at. AdminMiddleware → 401/403. If Live TV is not configured the route group is not registered → 404 (treated as "Live TV unavailable").
GET /api/v1/admin/livetv/recordings Admin-gated (AdminMiddleware). Read-only recordings list. Returns the whole {success, recordings:[...]} envelope. Each recording row carries id (== recording_id), channel_id, program_id|null, user_id|null, title, description|null, start_time / end_time (Integer, UNIX seconds), duration (Integer, seconds), priority (Integer), quality|null, storage_path|null, storage_size (Integer), status (String), series_rule_id|null, created_at, updated_at. AdminMiddleware → 401/403; route group absent → 404 ("Live TV unavailable").
GET /api/v1/admin/livetv/series-rules Admin-gated (AdminMiddleware). Read-only series-recording rules. Returns the whole {success, rules:[...]} envelope. ⚠️ Each rule row has no top-level id — the key is rule_id. Rows carry rule_id, series_id, channel_id|null, title, priority (Integer), pre_padding_seconds / post_padding_seconds (Integer), max_recordings (Integer|null), days_ahead (Integer), is_active (Bool), created_at|null, updated_at|null. AdminMiddleware → 401/403; route group absent → 404 ("Live TV unavailable").

Remote Control Reference

Button Action
Select/Play Play / Pause toggle
Back Go back; close detail/overlay; on Home (empty stack), exit the channel
Left Seek backward 10 seconds
Right Seek forward 10 seconds
Rewind Seek backward 30 seconds
Fast Forward Seek forward 30 seconds
Replay (Instant Replay) Seek backward 10 seconds and briefly flash captions if they are off. Standard Roku behavior per remote control UX guidance.
Info Show item title and current playback position for 3 seconds.
Options (*) Open/close the Watch Together (SyncPlay) overlay (direct mode only; shows a disabled message in hub mode). While the overlay is open, Back/Options close it.
Up Open/close the video quality picker (Auto + each ABR rung the current transcode advertises, server-advertised; shows Auto-only when there's no ladder). While the overlay is open, Back/Up close it and the list owns Up/Down/Select.
Down Open/close the Settings panel (Audio / Subtitles track selection). Selecting a row opens the track list (or an empty-state message); Back/Down close the panel.
Skip Button Skip intro/outro section (shown automatically during marker ranges)

Seek Interval Convention

Per Roku's remote control UX guidelines, the dedicated transport keys (Rewind/Fast Forward) perform the larger seek interval (±30 seconds), while the directional keys (Left/Right) perform the smaller seek interval (±10 seconds).

Home Header Navigation

The Home screen header has up to seven buttons. Use Left/Right to walk them in visual x-order (stops at the ends — never wraps):

Search ↔ For You ↔ Favorites ↔ Collections ↔ Settings ↔ Live TV ↔ Admin

  • Search — open the search screen.
  • For You — open the personalized Recommendations screen.
  • Favorites — browse your favorited items.
  • Collections — open the read-only Collections browse flow: Home → Collections (list of collection names) → a collection's items (poster grid) → item detail. Items are type-routed exactly like Library/Favorites (series → series view, season → season view, otherwise → detail).
  • Settings — open the settings screen: account/logout, server URL, playback preferences, captions mode, notifications, clear watch history, and PiP. Every user gets it — the walk steps into Settings from both sides (Collections ↔ Settings, and for admins Settings ↔ Live TV), and for non-admins the chain simply ends there.
  • Live TV — shown only for admin users (revealed on the same GET /auth/me proof of is_admin as Admin, hidden by default). Opens the Live TV channel list directly, skipping the Admin-menu hop; the Admin menu keeps its row as the longer route. Non-admins never see it and the walk never steps onto it.
  • Admin — shown only for admin users. On Home init the app calls GET /auth/me and reveals the button when the returned user is is_admin; for non-admins the button stays hidden and the Right-nav skips it (no dead end / no focus on a hidden node). Opens the read-only admin flow: Home → Admin (menu) → Dashboard | Libraries | Users | Live TV | TV Guide | Recordings | Series Rules. The Admin menu has seven rows:
    • Dashboard — a read-only stats view (now-playing / storage / recent-activity) in a single list.

    • Libraries — a button-driven library admin surface (no keyboard required): Admin → Libraries (LabelList of libraries) → a library's actions. The per-library actions screen has four buttons — Scan (incremental), Rescan (full), Match Metadata, and Refresh Status — plus a scan-status summary line. Selecting Scan / Rescan / Match Metadata enqueues an asynchronous job on the server (202 {job_id, status, message}) and then refreshes the status line; only one request runs at a time. The status line shows the latest job's type / status / current path / started / completed, or "Never scanned" when the library has no scan job yet. Status is refreshed manually via the Refresh Status button — there is no auto-poll.

    • Users — a button-driven user admin surface (no keyboard required): Admin → Users (LabelList of all users) → a user's actions. The list shows every user (no status-filter tabs) with a caption combining username, status, and an admin tag. The per-user actions screen has six buttons — Approve, Disable, Make Admin / Remove Admin (a toggle whose title reflects the user's current admin state), Reset Password, Profiles, and Refresh — plus a multi-line detail line summarizing the user (username / email / status / admin). Approve / Disable / Set-Admin call the server and then re-fetch and re-render the user's state; only one request runs at a time. Reset Password shows the server's one-time new_password once in the status line (it is not re-fetched afterward, so the password stays visible). State is refreshed manually via the Refresh button — there is no auto-poll. The server enforces guards (cannot disable/demote yourself or the last admin) and returns those messages, which are surfaced verbatim. Deferred / future work: creating and editing users (need an on-screen keyboard) and deleting or rejecting users (destructive removal with no on-TV confirmation dialog) are intentionally not shipped in this surface — the actions included are all recoverable/reversible.

      • Profiles — a per-user profile admin surface (no keyboard required), reached from the user's actions screen: Admin → Users → (select user) → Profiles (LabelList of the user's profiles) → a profile's actions. Because the server's profile-listing route is per-user (GET /api/v1/admin/users/{userId}/profiles), the Profiles button hangs off the selected user rather than being a top-level Admin row. The profile list is a one-shot fetch reading the whole {profiles:[...]} envelope; each row shows the profile name plus tags (rating label, admin, active, PIN). Bool/TINYINT flags (is_admin, is_active, settings.pin_required_for_admin) are read through type-guarded helpers and the computed rating Integer (0-6) is mapped to a label — neither is ever string-compared. The per-profile actions screen has ten buttons: seven flat Rating: G / PG / PG-13 / R / NC-17 / X / UNRATED buttons (each sends PUT /api/v1/admin/profiles/{id} with {rating: <int 0-6>}), Clear PIN (DELETE /api/v1/admin/profiles/{id}/pin), Parental Controls (opens the ParentalControlsScene — access Schedules / blocked Tags / Stream Limits for that profile via the non-admin /api/v1/profiles/{id}/schedules|tags|stream-limits routes), and Refresh — plus a multi-line detail summary (Name / Rating / Admin / Active / PIN required). Set-Rating and Clear-PIN call the server then re-fetch and re-render the profile; only one request runs at a time, and state is refreshed manually (no auto-poll). Deferred / future work: creating a profile, editing a profile's name, and setting a PIN all need an on-screen keyboard, and deleting a profile is a destructive action with no on-TV confirmation dialog — so they are intentionally not shipped. The shipped subset ("view profiles + adjust the parental-control rating + clear a forgotten PIN") is the genuinely useful button-driven slice for a 10-foot remote.

        Upstream gap. There is no user-facing profile route — profile management is admin-gated only (the same gap flagged for the mobile client). A self-service on-TV profile switcher would need a /users/me/profiles route plus a profile-context request header.

    • Live TV — a channels list + channel playback surface: Admin → Live TV (LabelList of channels) → select a channel → live player. The list is a one-shot GET /api/v1/admin/livetv/channels (AdminMiddleware-gated; the whole {success, channels:[...]} envelope is read). Each channel row shows <number> <name> (the Integer number is stringified, not string-compared). Selecting a channel resolves its stream — the client issues the Bearer-gated GET /api/v1/admin/livetv/channels/{id}/stream, reads the 302 Location header (without following the redirect), and opens a dedicated lightweight live player (LivePlayerScene) on the final (unauthenticated) tuner/HLS URL. The live player has no sessions / progress / transcode / markers — it does not reuse the VOD PlayerScene. The status line surfaces friendly errors ("Tuning…", "Couldn't start channel", "Live TV unavailable", "Playback failed"). The route group is not registered when Live TV is not configured (the list then reports "Live TV unavailable").

      Streaming caveat (device-only-verifiable). Channel playback hinges on resolving a Bearer-gated 302 and reading the Location header without following it. The resolution pins EnableRedirects(false) (M-ROKU-15 custody law — resolveLocation GUARANTEES the 3xx is not auto-followed, it is no longer best-effort firmware luck), so the header read is deterministic; what stays device-only is the firmware's real-world Location surfacing and raw-TS playback of the resolved URL. Recommended upstream mitigation: add a JSON GET .../stream-url endpoint (or a signed live-TV URL) so a TV Video node can obtain the stream URL without resolving an authenticated redirect. The mpegts-vs-hls streamFormat heuristic (.m3u8 in the URL → hls, otherwise mpegts) and raw-TS playback are likewise only verifiable on a device.

      Scope: channels list + playback only.

    • TV Guide — a read-only EPG list (Admin → TV Guide). A one-shot GET /api/v1/admin/livetv/guide (AdminMiddleware-gated; the whole {success, programs:[...]} envelope is read, sent with no query params → upcoming programs across all channels). Each row shows <start time> <title> where the start time is the Integer start_time (UNIX seconds) rendered by Utilities.FormatUnixTime as a local "M/D H:MM" wall-clock string (the Integer is never string-compared). Selection is inert (read-only view). Empty result → "No programs"; missing programs key / unregistered route → "Live TV unavailable".

    • Recordings — a read-only recordings list (Admin → Recordings). A one-shot GET /api/v1/admin/livetv/recordings (whole {success, recordings:[...]} envelope). Each row shows <title> (<status>) plus — <start time> (via FormatUnixTime) when present, falling back to the status or "(recording)" when there is no title. Selection is inert. Empty → "No recordings"; missing key / unregistered route → "Live TV unavailable".

    • Series Rules — a read-only series-recording-rule list (Admin → Series Rules). A one-shot GET /api/v1/admin/livetv/series-rules (whole {success, rules:[...]} envelope). Each row shows the rule title (falling back to series_id, then "(rule)"), optionally with (priority N) where N is the Integer priority. The rule row has no top-level id — only rule_id is read. Selection is inert. Empty → "No rules"; missing key / unregistered route → "Live TV unavailable".

      With these three views, live TV is complete. Deferred / future work: a guide channel-filter picker, recording create / delete + recording playback, series-rule CRUD, and channel edit (PUT) are intentionally not shipped — they need on-screen pickers / keyboard (or a route we have not yet scouted), so all four Live TV admin surfaces remain read-only.

Project Structure

phlix-roku/
├── source/
│   ├── main.brs                  # Main entry point
│   ├── lib/
│   │   ├── ApiClient.brs         # API client (communication layer)
│   │   ├── AppContext.brs        # Hub/connection-state core (active server, relay routing, server_url custody)
│   │   ├── AuthManager.brs       # Authentication manager
│   │   ├── Storage.brs           # Persistent storage (registry)
│   │   ├── SyncPlayProtocol.brs  # SyncPlay: pure RFC6455 WS framing + flat syncplay_* codec + NTP TimeSync (no I/O / no UI)
│   │   ├── SyncPlayManager.brs   # SyncPlay DEAD PATH — not wired into the shipped UI: REST group snapshot + WS session-state coordinator for the abandoned standalone room; its ONLY caller is the never-instantiated components/SyncPlayScene.brs; retained for room revival (see "SyncPlay (Watch Together)" — Room-scene retention — and Check 56)
│   │   ├── ToastManager.brs      # Toast/notification singleton for background errors (GetToastManager())
│   │   └── Utilities.brs         # Helper functions (locale loader + Translate, SyncPlay error law)
│   └── player/
│       ├── PanelStack.brs        # PlayerScene panel-stack unwind law (child-first Back, focus follows the stack)
│       ├── SkipButton.brs        # Skip intro/outro button (shared logic behind components/SkipButton)
│       └── SleepTimer.brs        # Sleep timer
├── components/                   # SceneGraph nodes — each .brs pairs with its .xml (the exception is ToastScene: xml-only, no script)
│   ├── PhlixApp.brs              # Root app controller (boot loop, scene coordination, toast queue/dismiss)
│   ├── HomeScene.brs             # Home screen (continue-watching / next-up rails, retry recovery chrome)
│   ├── LibraryScene.brs          # One library's poster grid
│   ├── SeriesScene.brs           # One series: seasons grid (flat episodes for single-season shows)
│   ├── SeasonScene.brs           # One season's episodes grid
│   ├── EpisodeListTask.brs       # Episode/season list loader off the render thread
│   ├── PlayerScene.brs           # Video player (+ additive "Watch Together" SyncPlay overlay, opened with the "*"/Options key; gated, hub-disabled, ws:// only) (+ additive video-quality picker, opened with the Up key; server-advertised, no-ops without a ladder) (+ additive Audio/Subtitles Settings panel, opened with the Down key; persists preferred_audio_track / preferred_subtitle_track)
│   ├── SkipButton.brs            # Skip intro/outro button node (Group; owns its button, exposes skipRequested)
│   ├── DetailScene.brs           # Item detail view
│   ├── RatingBadge.brs           # Rating star/score badge (Rectangle overlay shown on poster/detail chrome)
│   ├── CollectionsScene.brs      # Collections list (LabelList of collection names, read-only)
│   ├── CollectionScene.brs       # One collection's items (poster grid; raw rows normalized client-side)
│   ├── SearchScene.brs           # Search keyboard + results grid (GET /media?search= via ApiTask)
│   ├── FavoritesScene.brs        # Favorites browse (one-shot PosterGrid of GET /users/me/favorites)
│   ├── RecommendationsScene.brs  # Recommendations list (GET /me/recommendations via ApiTask)
│   ├── MusicScene.brs            # Music browser (Artists / Albums / Tracks — text LabelLists; music ships no artwork)
│   ├── MusicAlbumScene.brs       # One album's track list (LabelList; serialized playback launch through ApiTask)
│   ├── AudiobookScene.brs        # Audiobook library grid (infinite-scroll paging; R7.6)
│   ├── AudiobookPlayerScene.brs  # Audiobook player (Audio node; chapter navigation + progress write-back; R7.6)
│   ├── PhotosScene.brs           # Photo date-album grid (entry point of a photo library)
│   ├── PhotoAlbumScene.brs       # One date album's thumbnail grid (signed thumbnail_urls)
│   ├── PhotoViewerScene.brs      # Full-screen photo viewer (caption/info chrome)
│   ├── SettingsScene.brs         # Settings, 7 sections (Account / Server / Playback / Captions / Watch History / Notifications / About; R7.1)
│   ├── ParentalControlsScene.brs # Parental controls, 3 sections (access Schedules / blocked Tags / Stream Limits; P5-S5)
│   ├── LiveTvScene.brs           # Admin Live TV channels list (one-shot LabelList; row-select resolves stream → live player)
│   ├── LivePlayerScene.brs       # Dedicated lightweight live Video player (no sessions/progress/transcode/markers)
│   ├── ListTask.brs              # Unified Live TV list loader off the render thread
│   ├── GuideScene.brs            # Admin Live TV guide/EPG list (one-shot LabelList; read-only, selection inert)
│   ├── RecordingsScene.brs       # Admin Live TV recordings list (one-shot LabelList; read-only, selection inert)
│   ├── SeriesRulesScene.brs      # Admin Live TV series-rules list (one-shot LabelList; read-only, selection inert)
│   ├── AdminScene.brs            # Admin menu (LabelList; is_admin-gated entry; rows: Dashboard, Libraries, Users, Live TV, TV Guide, Recordings, Series Rules)
│   ├── DashboardScene.brs        # Read-only admin dashboard (now-playing / storage / activity in one list)
│   ├── LibraryAdminScene.brs     # Admin libraries list (LabelList; drills into per-library actions)
│   ├── LibraryAdminActionsScene.brs # Per-library admin actions (Scan / Rescan / Match Metadata / Refresh Status + scan-status line)
│   ├── UserAdminScene.brs        # Admin users list (LabelList of all users; drills into per-user actions)
│   ├── UserAdminActionsScene.brs # Per-user admin actions (Approve / Disable / Make-or-Remove Admin / Reset Password / Profiles / Refresh + user detail line)
│   ├── ProfilesScene.brs         # Per-user profile list (one-shot LabelList; read-only; drills into per-profile actions)
│   ├── ProfileActionsScene.brs   # Per-profile admin actions (7 Rating buttons / Clear PIN / Refresh + profile detail line)
│   ├── ApiTask.brs               # Generic one-operation API Task — the render-thread network choke (docs/architecture-apitask.md, "The Threading Model": every scene's blocking HTTP runs here)
│   ├── DiscoveryTask.brs         # One-shot LAN discovery Task (SSDP via the documented socket surface, task thread; drives ConnectScene's server pick — M-ROKU-04)
│   ├── SyncPlayTask.brs          # Long-lived Task running the SyncPlay ws:// socket off the render thread (roStreamSocket; RunSocket loop; flat syncplay_* frames)
│   ├── SyncPlayScene.brs         # DEAD PATH — not wired into the shipped UI: abandoned P8-S4 room UI (public-room list / create / join); zero instantiation sites repo-wide; retained for a possible room revival, owner wire-vs-retire decision open (see "SyncPlay (Watch Together)" — Room-scene retention — and Check 56)
│   ├── HubCommandTask.brs        # Long-lived Task consuming hub relay pending_command / play_media frames (mints a relay token, Bearer on the RFC6455 handshake; the plaintext :8804 dial is REFUSED behind https hubs — M-ROKU-23, wss deferred)
│   ├── ToastScene.xml            # Toast overlay — Dialog-derived node shown via the current surface's dialog field (M-ROKU-17; queue/dismiss owned by the PhlixApp root coordinator, no script)
│   ├── ConnectScene.brs          # First-run "Connect to server" screen (normalizes + probes /health, persists server_url, then proceeds to login)
│   ├── LoginScene.brs            # Login screen (username/password only); after login probes GET /me/servers to detect hub vs direct
│   └── ServerPickerScene.brs     # Hub mode: "Choose a server" list (one-shot GET /me/servers); pick persists active_server_id → relay routing
├── tests/
│   ├── audit-gate.test.mjs       # node --test mirror of the npm audit gate decision layer (RT-088)
│   ├── unit/                     # BrightScript unit tests (Storage/ApiClient/AuthManager/SyncPlay/WireGuard/…)
│   ├── integration/              # BrightScript integration tests (ApiIntegration)
│   ├── fixtures/                 # Vendored server-route-manifest.json (route gate) + error-codes.json (Check 99 contracts-registry pin)
│   └── scripts/                  # run-headless-unit.mjs (the device-free CI unit gate), verify-route-manifest.mjs, verify-runtime-portable.sh
├── scripts/                      # verify-runtime.sh (static-check legs), validate-xml.sh, verify-package.sh, package-signed.sh, derive-version.sh, add-headers.py, audit-gate.mjs (documented-allow-list npm audit gate, RT-088)
├── locale/                       # Seven catalogs, same flattened key set (Check 21 parity; see docs/i18n.md)
│   ├── de_DE/strings.json
│   ├── en_US/strings.json        # Base locale — the fallback + comparison source
│   ├── es_ES/strings.json
│   ├── fr_FR/strings.json
│   ├── it_IT/strings.json
│   ├── ja_JP/strings.json
│   └── pt_BR/strings.json
├── docs/                         # architecture-apitask, api-envelope, navigation, i18n, static-checks, device-verification, debugging, publishing, certification, bif_thumbs, image_generation_info, BLOCKED_R0.1/R0.8
├── tools/                        # gen_channel_art.py (channel-art generator)
├── images/                       # App icons and splash screens
├── manifest                      # Roku channel manifest
├── Makefile                      # Build automation (lint, tests, package)
├── package.json                  # npm scripts (route-manifest gate tool deps) + version pin
├── package-lock.json             # npm lockfile
├── bsconfig.json                 # brighterscript compiler config
├── bslint.json                   # bslint config
├── LICENSE                       # MIT
├── README.md                     # This file
├── CHANGELOG.md                  # Release history (Keep-a-Changelog style, adapted: one rolling [latest] section, not a versioned ladder — see the model block at the head)
├── DEVELOPER.md                  # Developer documentation
├── CLAUDE.md                     # Agent-context entry file
└── CALIBER_LEARNINGS.md          # Agent-context learnings (caliber)

Troubleshooting

App Won't Install

  1. Verify Roku IP address is correct
  2. Check developer credentials
  3. Ensure dev mode is enabled on Roku
  4. Confirm the dev-mode installer itself answers: curl -sS -o /dev/null -w '%{http_code}\n' http://ROKU_IP/. A 200 (installer page) or 401 (it demands credentials) proves the port-80 installer is listening; a refused connection or timeout means the IP is wrong or dev mode is off. The install endpoint is the port-80 /plugin_install web form — NOT an ECP :8060 route (:8060 documents only launch/keypress/query, so probing it tests the wrong service). Never put credentials in argv (-u user:pass is visible in ps); when authenticating, pass them out of band as the sideload recipe above shows (curl --config - with digest).

API Connection Failed

  1. Verify Phlix Media Server is running
  2. Check network connectivity from Roku
  3. Verify the server URL entered on the first-run Connect screen is correct (the /health probe surfaces "Can't reach the server. Check its address and your network." when it is wrong/unreachable)
  4. Check server logs for connection attempts

Video Playback Issues

  1. Verify HLS support on your server
  2. Check network bandwidth
  3. Try lower quality streams
  4. Verify codec support (H.264/H.265 for video, AAC/AC3 for audio)

Debugging

See docs/debugging.md for the full error dictionary and telnet guide.

# Connect to Roku debug console
telnet ROKU_IP 8080

# Check app logs
# View variable values
# Step through BrightScript code

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Make changes with tests
  4. Submit a pull request
  5. Ensure CI passes

License

MIT License - See LICENSE file for details

Support

Releases

Packages

Used by

Contributors

Languages