{"id":51118877,"url":"https://github.com/detain/phlix-roku-client","last_synced_at":"2026-07-09T21:00:29.279Z","repository":{"id":358162586,"uuid":"1240294909","full_name":"detain/phlix-roku-client","owner":"detain","description":"Roku client for Phlix Media Server - Stream your media library to Roku devices","archived":false,"fork":false,"pushed_at":"2026-07-08T23:27:47.000Z","size":4477,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-07-09T01:12:12.569Z","etag":null,"topics":["brightscript","dlna","electron-app","hls-streaming","home-media","htpc","jellyfin-alternative","media-player","media-server-client","mobile-app","phlix","plex-alternative","react-native","reactjs","samsung-tizen","smart-tv","streaming-client","tv-app","upnp","video-player"],"latest_commit_sha":null,"homepage":null,"language":"Brightscript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/detain.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-16T01:16:41.000Z","updated_at":"2026-07-08T23:27:30.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/detain/phlix-roku-client","commit_stats":null,"previous_names":["detain/phlex-roku-client","detain/phlix-roku-client"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/detain/phlix-roku-client","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/detain%2Fphlix-roku-client","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/detain%2Fphlix-roku-client/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/detain%2Fphlix-roku-client/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/detain%2Fphlix-roku-client/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/detain","download_url":"https://codeload.github.com/detain/phlix-roku-client/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/detain%2Fphlix-roku-client/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35312540,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-09T02:00:07.329Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["brightscript","dlna","electron-app","hls-streaming","home-media","htpc","jellyfin-alternative","media-player","media-server-client","mobile-app","phlix","plex-alternative","react-native","reactjs","samsung-tizen","smart-tv","streaming-client","tv-app","upnp","video-player"],"created_at":"2026-06-25T00:30:24.725Z","updated_at":"2026-07-09T21:00:29.251Z","avatar_url":"https://github.com/detain.png","language":"Brightscript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Phlix Media Server - Roku Application\n\n[![Lint](https://github.com/detain/phlix-roku-client/actions/workflows/lint.yml/badge.svg)](https://github.com/detain/phlix-roku-client/actions/workflows/lint.yml)\n[![Tests](https://github.com/detain/phlix-roku-client/actions/workflows/test.yml/badge.svg)](https://github.com/detain/phlix-roku-client/actions/workflows/test.yml)\n[![Package](https://github.com/detain/phlix-roku-client/actions/workflows/package.yml/badge.svg)](https://github.com/detain/phlix-roku-client/actions/workflows/package.yml)\n![Platform](https://img.shields.io/badge/platform-Roku-662D91?logo=roku\u0026logoColor=white)\n![Language](https://img.shields.io/badge/BrightScript-SceneGraph-662D91)\n\nA native Roku application for the Phlix Media Server platform. Stream your media library with full playback control, seamless authentication, and progress synchronization.\n\n## Features\n\n- **Secure Authentication**: Device registration with token-based session management\n- **Library Browsing**: Browse movies, TV shows, and collections with intuitive navigation\n- **HLS Video Playback**: Stream video content with adaptive bitrate support\n- **Full Remote Control**: Complete playback control via Roku remote (play, pause, seek, stop)\n- **Progress Synchronization**: Track and sync watch progress across sessions\n- **Multiple User Support**: Personalized libraries and watch states per user\n- **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). See \"Hub / multi-server mode\" below for the known PUT/DELETE-over-relay limitation.\n- **Skip Intro/Outro**: Automatically displayed skip buttons when playback enters marker ranges defined by the server (intro start/end, outro start/end)\n- **SyncPlay / Watch Together** *(built to a not-yet-deployed server target; 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.\n- **Manual quality selection** *(server-A7-dependent; the transient content-swap-while-playing behaviour 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.\n- **Audio \u0026 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 (`subtitleTrack`, 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.\n- **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`\n\n## Prerequisites\n\n### Required\n- **Roku Device**: Any Roku device with developer mode enabled\n- **Phlix Media Server**: Running instance accessible on your network\n- **Roku Developer Account**: For sideloading apps\n\n### Development Tools\n- **Roku SDK**: For packaging and deployment ( rokupkg )\n- **BrightScript Editor**: VS Code extension \"BrightScript\" by彩虹 (or any editor)\n- **curl**: For direct device communication\n- **zip**: For creating packages\n\n### Network Requirements\n- Roku device and Phlix Media Server on the same network\n- Phlix Media Server API accessible from Roku device\n\n## Installation\n\n### 1. Enable Developer Mode on Roku\n\nPress the following button sequence on your Roku remote:\n1. Home (5 times)\n2. Up (2 times)\n3. Right (1 time)\n4. Left (1 time)\n5. Right (1 time)\n6. Left (1 time)\n7. Right (1 time)\n\nNote the IP address shown and enable dev mode via the web interface at `http://\u003cROKU_IP\u003e`.\n\n### 2. Clone and Configure\n\n```bash\n# Clone the repository\ngit clone https://github.com/your-org/phlix-roku.git\ncd phlix-roku\n\n# Review and update manifest if needed\n# Edit: manifest title, version, icons\n```\n\n### 3. Install Dependencies\n\nNo external dependencies required. BrightScript is natively supported by Roku devices.\n\n### 4. Configure Server Connection\n\nThere is **nothing to pre-configure** — the server URL is entered in-app on first launch.\n\nOn first run (no `server_url` persisted yet) the boot flow shows a **Connect to server**\nscreen (`ConnectScene`):\n\n1. Enter your server's address (a direct Phlix server **or** a Phlix Hub — both expose\n   `/health` identically), e.g. `https://my.phlix.server` or `http://192.168.1.100:8096`.\n2. Press **Connect**. The URL is normalized (a missing scheme is inferred — `http://` for\n   LAN/loopback hosts, `https://` otherwise — and a trailing `/` is stripped) and probed\n   via `GET {url}/health`.\n3. On a healthy probe the URL is persisted to registry storage under `server_url` and the\n   app proceeds to the login screen, then Home.\n4. If the probe fails (server unreachable, or `/health` is blocked), an error is shown and\n   a **Connect anyway** button appears — pressing it persists the entered URL and proceeds\n   regardless.\n\nOn subsequent launches the persisted `server_url` is reused and the Connect screen is\nskipped (boot goes straight to login or, if a session is restored, Home). The login screen\nitself only collects username/password — it no longer has a server-URL field.\n\n### 5. Hub / multi-server mode\n\nThe connect URL entered on the Connect screen can be **either a direct Phlix server or a\nPhlix Hub** — they expose `/health` and `POST /api/v1/auth/login` identically, so the\nconnect + login flow is the same for both. The client decides which it is talking to\n**after login**, transparently:\n\n1. **Probe.** Immediately after a successful login the client calls\n   `GET /api/v1/me/servers`.\n   - A **hub** returns `{servers:[ … ]}` (camelCase rows). The client persists\n     `connection_kind = \"hub\"` and routes to a new **server picker** screen\n     (`ServerPickerScene`).\n   - A **direct server** has no such route (404 / no `servers` array). The client persists\n     `connection_kind = \"direct\"` and goes straight to Home, exactly as before. Direct mode\n     is unchanged from F12a.\n2. **Pick a server.** The picker lists each claimed server with its name, online/offline\n   status, and library count. Selecting one persists `active_server_id` (and\n   `active_server_name` for display) and proceeds to Home.\n3. **Relay routing.** Once a server is picked, the media `ApiClient` is rebuilt against the\n   hub's **relay proxy** base — `{hub}/api/v1/servers/{serverId}/proxy` — so every existing\n   scene and background task transparently routes its requests through the hub to the chosen\n   server with **no per-scene changes**. The **hub Bearer token is the relay auth**: the hub\n   strips the client's `Authorization`/`Cookie` headers, verifies the logged-in user owns the\n   target server (403 otherwise), injects `X-Phlix-Relay-User`, and tunnels the request over\n   its WSS connection to the server, which trusts the tunnel. No separate per-server token is\n   involved.\n4. **Boot / session validation.** On a subsequent launch in hub mode the persisted **hub**\n   session is validated against the **hub directly** (`GET /api/v1/auth/me` on the bare hub\n   URL, not over the relay — `/auth/me` over the relay returns the hub-user perspective and is\n   unreliable). If valid and a server is already picked → Home; if valid but no server picked →\n   the server picker; otherwise → login. Logging out clears `connection_kind`,\n   `active_server_id`, and `active_server_name` but keeps `server_url`, so you return to the\n   login screen on the same hub/server.\n\n#### Hub mode storage keys\n\n| Key | Description |\n|-----|-------------|\n| `server_url` | The connect endpoint — a hub URL **or** a direct server URL (set on the Connect screen). |\n| `auth_token` / `refresh_token` | The login token. In hub mode this is the **hub** token, which doubles as the relay Bearer. |\n| `connection_kind` | `\"hub\"` or `\"direct\"`. Absent / unrecognized is treated as `\"direct\"`. |\n| `active_server_id` | The hub server chosen in the picker; appended to the relay proxy base. |\n| `active_server_name` | Display name of the chosen server. |\n\n#### Known limitation — non-GET/POST verbs over the relay\n\nThe hub registers **only `GET` and `POST`** on its relay proxy route\n(`/api/v1/servers/{id}/proxy/{path:.*}`). In hub mode the media `ApiClient` is bound to the\nrelay base, so `PUT`/`DELETE` calls cannot be tunneled to the server and degrade:\n\n- **favorites-remove** (`DELETE /media/{id}/favorite`),\n- **rating set / clear** (`PUT` / `DELETE /media/{id}/rating`),\n- **server-side session-end on logout** (`DELETE /sessions/{id}` — the local token still\n  clears, so logout works, but the server session is not ended).\n\nThe core flows — connect, login, server pick, browse, play, resume, search, and\n**favorites-add** — all use `GET`/`POST` and work over the relay. **Direct mode is\nunaffected** (all verbs reach the server). **Recommended follow-up:** a phlix-hub change to\nregister `PUT`/`DELETE`/`PATCH` on the proxy handler.\n\n\u003e **Hub-token refresh-over-relay is not wired.** The `ApiClient` refresh-on-401 path would, in\n\u003e hub mode, attempt to refresh through the relay against the server rather than against the hub.\n\u003e The hub token's `expires_in` is 3600s; on expiry the relay 401 does not refresh correctly, the\n\u003e token clears, and the next boot/login re-authenticates. Routing refresh to the hub in relay\n\u003e mode is a planned follow-up.\n\n### 6. SyncPlay (Watch Together)\n\nSyncPlay lets several devices watch the same content together, staying in sync without manual\ntimestamp coordination. It is a **TV-friendly, button-driven overlay inside the player** — there is\nno separate SyncPlay scene; all playback control stays in `PlayerScene`, so when the overlay is\nnever opened the player path is byte-identical to a non-SyncPlay build.\n\n\u003e ## ⚠️ LIMITATIONS — read before relying on SyncPlay\n\u003e\n\u003e - **DEVICE-UNVERIFIABLE.** Roku BrightScript only runs on hardware (there is no host runner), so\n\u003e   this code has been verified by `brighterscript` (bsc) + code review **only** — never executed.\n\u003e - **Built to a not-yet-deployed server target.** The whole slice targets the *post-`SP*`*\n\u003e   phlix-server SyncPlay wire contract. **The server's SyncPlay WebSocket worker is not live yet**\n\u003e   (`phlix-server/start.php` still stubs the `:8097` worker as `(Future)`), so SyncPlay cannot\n\u003e   actually connect or function until phlix-server Phase 8 (SP1/SP2/SP4/SP7) ships.\n\u003e - **`ws://` only — `wss://` is impossible.** Roku's `roStreamSocket` is plaintext TCP with **no\n\u003e   TLS**, so the hand-rolled WebSocket can speak `ws://` only. On a TLS-fronted production box\n\u003e   (HAProxy terminating TLS) SyncPlay connects **only if the server's plaintext `:8097` is reachable\n\u003e   from the Roku** (same LAN, or a documented plaintext exposure). The client derives a plaintext\n\u003e   `ws://\u003chost\u003e:8097/syncplay?token=…` URL from the connected server origin.\n\u003e - **Hub mode: disabled.** The hub relay proxy is HTTP-only (it strips the `Upgrade` header), so\n\u003e   there is no WebSocket path through the hub. In hub mode the overlay refuses to open with a\n\u003e   friendly message (\"Watch Together isn't available in hub mode yet\").\n\u003e - **Deferred:** chat / typing, host transfer, playback queue, periodic `playback_sync` broadcast,\n\u003e   group passwords (password-gated groups simply fail to join), WS reconnect/backoff (a disconnect\n\u003e   ends the session — no auto re-join), and strict `Sec-WebSocket-Accept` SHA-1 verification (the\n\u003e   client sends a valid random key and accepts the `101` without verifying the accept hash).\n\n#### Usage\n\n1. **Open the overlay.** During playback, press the **`*` / Options** key to open the **Watch\n   Together** overlay (direct mode only — in hub mode it shows the disabled message).\n2. **Pick or create a group.** The overlay lists existing groups (a read-only snapshot from\n   `GET /api/v1/syncplay/groups`, so no group-id typing on the TV). Select one to **Join**, press\n   **Create Group** to start a new room (named `\"\u003cdevice\u003e's Room\"`, no keyboard), or **Leave** to\n   exit. A status line shows the connection state, group name, member count, and sync (offset/stable)\n   indicator.\n3. **Watch together.** Playback follows the group **host**: inbound host `play` / `pause` / `seek`\n   are applied to the local `Video` node with NTP drift correction (ms→s at the boundary) and\n   echo-suppressed by your own member id. When **this** device is the host, your local play / pause /\n   seek are broadcast to the group.\n\n#### Wire contract (canonical `syncplay_*`)\n\nThe on-the-wire protocol mirrors `@phlix/syncplay` and the phlix-server `Messages::TYPE_*`\nconstants. The transport implementation is `source/lib/SyncPlayProtocol.brs` (pure RFC6455 framing +\nflat codec + NTP TimeSync, no I/O), driven by the long-lived `components/SyncPlayTask.{xml,brs}`\nnode (socket I/O off the render thread).\n\n- **Framing is FLAT.** Every message, in and out, is a single flat JSON object — payload fields\n  spread at the **top level**, *not* nested under `data`:\n  `{ \"type\": \"syncplay_\u003cname\u003e\", \"protocol_version\": 1, \"timestamp\": \u003cms\u003e, …payload }`. The repo's\n  old `syncplay.\u003cdot\u003e` event notation was wrong and never implemented — the real types use the\n  **`syncplay_` underscore** prefix.\n- `protocol_version` is always `1`; `timestamp` is sender wall-clock **milliseconds**; **all\n  positions / durations on the wire are milliseconds** (Roku `Video` position/seek are seconds → the\n  client converts at the boundary).\n- **`syncplay_group_state` is the one nested case:** `{ type, protocol_version, timestamp,\n  group:{…}, your_id:\"…\" }` — `group` and `your_id` sit flat at the top level, but `group` is itself\n  an object (`group_id`, `group_name`, `member_count`, `members:[{id,name,is_host,joined_at}]`,\n  `host_id`, `current_media_id`, `playback_position` (ms), `playback_state`, …). The client learns\n  its **real** member id from `group_state.your_id` (used for echo-suppression and host detection).\n- **Auth + URL:** the WS connection is authenticated on the upgrade via `?token=\u003caccess_token\u003e` (the\n  same access token the REST client holds in Storage `auth_token`); unauthenticated sockets are\n  rejected before any frame. The canonical URL is `wss://\u003chost\u003e/syncplay` → server **`:8097`**, but\n  since Roku can't do TLS the client connects to the derived plaintext\n  `ws://\u003chost\u003e:8097/syncplay?token=…`.\n\nThe 19 canonical message types (F13 implements the TV-friendly subset shown below; chat / typing /\nhost_transfer / queue / sync are deferred):\n\n| Type (`syncplay_*`) | Direction | F13 | Notes |\n|---|---|---|---|\n| `syncplay_group_create` | Client → Server | ✅ | `group_name`, `member_name?` (sent on Create) |\n| `syncplay_group_join` | Client → Server | ✅ | `group_id`, `member_name?` (sent on Join) |\n| `syncplay_group_leave` | Client → Server | ✅ | `group_id`, `member_id` (sent on Leave) |\n| `syncplay_group_list` | Client → Server | — | group browse uses the REST snapshot instead |\n| `syncplay_playback_play` | both | ✅ | `group_id`, `member_id`, `position` (ms), `server_time` (ms) |\n| `syncplay_playback_pause` | both | ✅ | same payload as `_play` |\n| `syncplay_playback_seek` | both | ✅ | `from_position`, `to_position`, `server_time` (all ms) |\n| `syncplay_playback_queue` | both | — | deferred (no queue UI) |\n| `syncplay_playback_sync` | both | — | deferred (no periodic broadcast) |\n| `syncplay_chat` / `syncplay_typing` | both | — | deferred (no chat UI) |\n| `syncplay_host_transfer` | Client → Server | — | deferred |\n| `syncplay_time_ping` | Client → Server | ✅ | `client_time` (ms = t1); sent on the periodic tick |\n| `syncplay_time_pong` | Server → Client | ✅ | `client_time` (echoed t1), `server_time` (t2) |\n| `syncplay_group_state` | Server → Client | ✅ | nested `group` + `your_id` (see above) |\n| `syncplay_host_elect` | Server → Client | ✅ | `elected_id`, `elected_by` (re-election on host leave) |\n| `syncplay_time_sync` | Server → Client | — | not consumed (client maintains its own offset) |\n| `syncplay_info` | Server → Client | ✅ | `message` (+ `member_id`/`member_name` on member JOIN) |\n| `syncplay_error` | Server → Client | ✅ | `error_code` (fallback `code`) + `message` |\n\n#### Time Synchronization Protocol (NTP-style, milliseconds)\n\nImplemented in `SyncPlayProtocol.brs` TimeSync, mirroring `@phlix/syncplay` SPEC §5:\n\n1. Every tick (~4s) the client sends `syncplay_time_ping` with `client_time` = t1 (ms).\n2. The server replies `syncplay_time_pong` with `client_time` (echoed t1) and `server_time` (t2 =\n   server receive time; there is **no** t3/`server_receive_time` field).\n3. Per pong (t3 = t2, t4 = local `NowMs()`): `rtt = t4 - t1`; `oneWay = rtt/2`;\n   `offset = t2 - t1 + oneWay`. Samples with `rtt \u003c 0` or `rtt \u003e 1000` are rejected.\n4. `offset` is the **weighted mean** (weight `1/max(1,rtt)`) over the last 5 samples; it is\n   considered **stable** when there are ≥5 samples and the recent-offset variance is `\u003c 50`.\n5. A drift-rate EMA `1.0 + 0.1*(Δoffset/Δt)/1000` (clamped to `[0.99, 1.01]`) scales the follow\n   position: adjusted ms = `position + (NowMs() + offset - serverTime) * driftRate`, converted to\n   seconds before any `video.seek`.\n\n## Configuration\n\n### Environment Variables\n\n| Variable | Description | Default |\n|---------|-------------|---------|\n| `ROKU_IP` | IP address of Roku device | `192.168.1.100` |\n| `ROKU_DEV` | Developer username | `rokudev` |\n| `ROKU_PASSWORD` | Developer password | `rokipassword` |\n\n### Manifest Configuration\n\nEdit `manifest` to customize:\n- `title`: Application name\n- `major_version`, `minor_version`, `build_version`: Version numbers\n- `ui_resolutions`: Supported resolutions (hd, fhd, uhd)\n\n### BrightScript Configuration\n\nThe app uses these configurable constants (in source files):\n\n```brightscript\n' In ApiClient.brs - Device capabilities\ndeviceProfile: {\n    MaxStreamingBitrate: 30000000  ' 30 Mbps\n    MaxStaticBitrate: 30000000\n    SupportedMediaTypes: [\"Video\", \"Audio\"]\n}\n```\n\n## Building the App\n\n### Standard Build\n\n```bash\n# Create package for sideloading\nmake package\n\n# This creates: phlix.zip\n```\n\n### Install to Device\n\n```bash\n# Install to configured Roku IP\nmake install ROKU_IP=192.168.1.100 ROKU_DEV=rokudev ROKU_PASSWORD=yourpass\n\n# Or install with rokupkg\nrokupkg --install phlix.zip\n```\n\n### Development Workflow\n\n```bash\n# 1. Make code changes\n# 2. Package and install\nmake package install ROKU_IP=192.168.1.100\n\n# 3. Launch app\nmake launch ROKU_IP=192.168.1.100\n\n# 4. Debug via telnet on port 8080\ntelnet 192.168.1.100 8080\n```\n\n### Manual Deployment\n\n```bash\n# Create package manually\nzip -r phlix.zip manifest source images\n\n# Sideload via curl\ncurl -v -u rokudev:password -X POST \\\n    http://192.168.1.100:8060/install/app \\\n    -F \"archive=@phlix.zip\" \\\n    -F \"manifest=@manifest\"\n```\n\n## Testing\n\n### Unit Tests\n\nUnit tests are located in `tests/unit/` and use BrightScript's testing patterns.\n\n```bash\n# List available tests\nmake test\n\n# Output shows:\n# Found test: tests/unit/ApiClient.test.brs\n# Found test: tests/unit/Storage.test.brs\n# Found test: tests/unit/Utilities.test.brs\n```\n\n### Running Tests on Device\n\n1. Deploy to device: `make install`\n2. Tests run automatically via the test framework when accessed via developer portal\n\n### Integration Tests\n\nIntegration tests in `tests/integration/` test API client against a live server.\n\n```bash\n# Run integration tests (requires running Phlix server)\n# Deploy tests to device and run via developer portal\n```\n\n### Test Structure\n\n```\ntests/\n├── unit/\n│   ├── ApiClient.test.brs      # API client unit tests\n│   ├── Storage.test.brs         # Storage unit tests\n│   └── Utilities.test.brs       # Utilities unit tests\n└── integration/\n    └── ApiIntegration.test.brs  # API integration tests\n```\n\n## Deployment to Roku\n\n### Pre-production Checklist\n\n- [ ] Test on physical Roku device\n- [ ] Verify all remote buttons work\n- [ ] Check video playback with various formats\n- [ ] Verify authentication flow\n- [ ] Test library browsing\n- [ ] Check network timeout handling\n- [ ] Verify progress sync\n\n### Publishing to Roku Channel Store\n\n1. Create developer account at [developer.roku.com](https://developer.roku.com)\n2. Sign in and go to Dashboard\n3. Upload your packaged app (phlix.zip)\n4. Complete store listing details\n5. Submit for review\n\n### Private Channel Testing\n\n```bash\n# Sideload directly for testing\nmake install\n\n# Or use roku's dev channel mechanism\n```\n\n## API Endpoints\n\nThe app communicates with these Phlix API endpoints:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| POST | `/api/v1/Auth/Login` | User authentication with device info |\n| DELETE | `/api/v1/Sessions/{id}` | End session |\n| GET | `/api/v1/Sessions` | List active sessions |\n| POST | `/api/v1/Sessions` | Create new session |\n| GET | `/api/v1/Library/VirtualFolders` | Get library folders |\n| GET | `/api/v1/Items` | Get items with filtering |\n| GET | `/api/v1/Items/{id}` | Get single item details |\n| GET | `/api/v1/Items/{id}/PlaybackInfo` | Get playback URLs and info |\n| POST | `/api/v1/Sessions/Play` | Start playback session |\n| POST | `/api/v1/Playstate` | Update playstate (play/pause/stop) |\n| POST | `/api/v1/Playstate/Progress` | Report playback progress |\n| POST | `/api/v1/Items/{id}/UserData` | Update user data (watched, etc.) |\n| GET | `/api/v1/Users/Me` | Get current user info |\n| GET | `/api/v1/collections` | List collections (read-only). Returns `{collections:[...]}`. Currently unauthenticated server-side. |\n| 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. Currently unauthenticated server-side. |\n| GET | `/api/v1/syncplay/groups` | SyncPlay group-list snapshot (read-only; server SP5). 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)\". |\n| 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. |\n| 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. |\n| GET/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). **Only `GET`/`POST` are registered** — `PUT`/`DELETE` are not routable over the relay (see the known limitation under \"Hub / multi-server mode\"). |\n| 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). |\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n| GET | `/api/v1/libraries/{id}/scan-status` | Admin-gated (internal `requireAdmin`). Returns the latest scan job for the library: `{scan_status: \u003cjob row | null\u003e}`. **200** even when never scanned (`scan_status` is `null`). Job row carries `job_type`, `status`, `current_path`, `started_at`, `completed_at`. |\n| 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). |\n| GET | `/api/v1/admin/users/{id}` | Admin-gated (AdminMiddleware). Returns one user: `{user}` (whole envelope). 404 `{error}` if not found. |\n| POST | `/api/v1/admin/users/{id}/approve` | Admin-gated (AdminMiddleware). Approves a pending user. Bodyless POST. `{message}` on success; 404 `{error}`. |\n| 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). |\n| 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). |\n| 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}`. |\n| 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. |\n| GET | `/api/v1/admin/profiles/{id}` | Admin-gated (AdminMiddleware). Returns one profile: `{profile}` (whole envelope). 404 `{error}` if not found. |\n| PUT | `/api/v1/admin/profiles/{id}` | Admin-gated (AdminMiddleware). Updates a profile; F10 sends `{rating: \u003cint 0-6\u003e}` only (name/PIN edits deferred). `{message}` on success; 400 `{error, field_errors?}` / 404 `{error}`. |\n| DELETE | `/api/v1/admin/profiles/{id}/pin` | Admin-gated (AdminMiddleware). Clears (removes) the profile's PIN. Bodyless DELETE. `{message}` on success; 404 `{error}`. |\n| 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:[]}`. |\n| 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://\u003chost\u003e: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. |\n| 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\"). |\n| 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\"). |\n| 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\"). |\n\n## Remote Control Reference\n\n| Button | Action |\n|--------|--------|\n| Select/Play | Play / Pause toggle |\n| Back | Go back / Close detail view |\n| Left | Seek backward 30 seconds |\n| Right | Seek forward 30 seconds |\n| Rewind | Seek backward 10 seconds |\n| Fast Forward | Seek forward 10 seconds |\n| 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. |\n| Up | Open/close the **video quality picker** (Auto + each ABR rung the current transcode advertises, server-A7-dependent; shows Auto-only when there's no ladder). While the overlay is open, Back/Up close it and the list owns Up/Down/Select. |\n| 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. |\n| Skip Button | Skip intro/outro section (shown automatically during marker ranges) |\n\n### Home Header Navigation\n\nThe Home screen header has up to four buttons. Use Left/Right to cycle between them (stops at the\nends — never wraps):\n\n`Search ↔ Favorites ↔ Collections ↔ Admin`\n\n- **Search** — open the search screen.\n- **Favorites** — browse your favorited items.\n- **Collections** — open the read-only Collections browse flow: `Home → Collections (list of\n  collection names) → a collection's items (poster grid) → item detail`. Items are type-routed\n  exactly like Library/Favorites (series → series view, season → season view, otherwise → detail).\n- **Admin** — shown **only for admin users**. On Home init the app calls `GET /auth/me` and reveals\n  the button when the returned user is `is_admin`; for non-admins the button stays hidden and the\n  Right-nav skips it (no dead end / no focus on a hidden node). Opens the read-only admin flow:\n  `Home → Admin (menu) → Dashboard | Libraries | Users | Live TV | TV Guide | Recordings | Series Rules`.\n  The Admin menu has seven rows:\n  - **Dashboard** — a read-only stats view (now-playing / storage / recent-activity) in a single list.\n  - **Libraries** — a button-driven library admin surface (no keyboard required):\n    `Admin → Libraries (LabelList of libraries) → a library's actions`. The per-library actions screen\n    has four buttons — **Scan (incremental)**, **Rescan (full)**, **Match Metadata**, and **Refresh\n    Status** — plus a scan-status summary line. Selecting Scan / Rescan / Match Metadata enqueues an\n    asynchronous job on the server (202 `{job_id, status, message}`) and then refreshes the status line;\n    only one request runs at a time. The status line shows the latest job's type / status / current path\n    / started / completed, or **\"Never scanned\"** when the library has no scan job yet. Status is\n    refreshed **manually** via the Refresh Status button — there is no auto-poll.\n  - **Users** — a button-driven user admin surface (no keyboard required):\n    `Admin → Users (LabelList of all users) → a user's actions`. The list shows every user (no\n    status-filter tabs) with a caption combining username, status, and an admin tag. The per-user\n    actions screen has six buttons — **Approve**, **Disable**, **Make Admin / Remove Admin** (a\n    toggle whose title reflects the user's current admin state), **Reset Password**, **Profiles**, and\n    **Refresh** — plus a multi-line detail line summarizing the user (username / email / status /\n    admin). Approve /\n    Disable / Set-Admin call the server and then re-fetch and re-render the user's state; only one\n    request runs at a time. **Reset Password** shows the server's one-time `new_password` **once** in\n    the status line (it is not re-fetched afterward, so the password stays visible). State is refreshed\n    **manually** via the Refresh button — there is no auto-poll. The server enforces guards (cannot\n    disable/demote yourself or the last admin) and returns those messages, which are surfaced verbatim.\n    **Deferred / future work:** creating and editing users (need an on-screen keyboard) and deleting or\n    rejecting users (destructive removal with no on-TV confirmation dialog) are intentionally not shipped\n    in this surface — the actions included are all recoverable/reversible.\n    - **Profiles** — a per-user profile admin surface (no keyboard required), reached from the\n      user's actions screen: `Admin → Users → (select user) → Profiles (LabelList of the user's\n      profiles) → a profile's actions`. Because the server's profile-listing route is **per-user**\n      (`GET /api/v1/admin/users/{userId}/profiles`), the Profiles button hangs off the selected\n      user rather than being a top-level Admin row. The profile list is a one-shot fetch reading the\n      whole `{profiles:[...]}` envelope; each row shows the profile name plus tags (rating label,\n      `admin`, `active`, `PIN`). Bool/TINYINT flags (`is_admin`, `is_active`,\n      `settings.pin_required_for_admin`) are read through type-guarded helpers and the computed\n      `rating` Integer (0-6) is mapped to a label — neither is ever string-compared. The per-profile\n      actions screen has nine buttons: seven flat **Rating: G / PG / PG-13 / R / NC-17 / X /\n      UNRATED** buttons (each sends `PUT /api/v1/admin/profiles/{id}` with `{rating: \u003cint 0-6\u003e}`),\n      **Clear PIN** (`DELETE /api/v1/admin/profiles/{id}/pin`), and **Refresh** — plus a multi-line\n      detail summary (Name / Rating / Admin / Active / PIN required). Set-Rating and Clear-PIN call\n      the server then re-fetch and re-render the profile; only one request runs at a time, and state\n      is refreshed **manually** (no auto-poll). **Deferred / future work:** creating a profile,\n      editing a profile's name, and setting a PIN all need an on-screen keyboard, and deleting a\n      profile is a destructive action with no on-TV confirmation dialog — so they are intentionally\n      not shipped. The shipped subset (\"view profiles + adjust the parental-control rating + clear a\n      forgotten PIN\") is the genuinely useful button-driven slice for a 10-foot remote.\n\n      \u003e **Upstream gap.** There is **no user-facing profile route** — profile management is\n      \u003e admin-gated only (the same gap the mobile E5 slice flagged). A self-service on-TV profile\n      \u003e switcher would need a `/users/me/profiles` route plus a profile-context request header.\n  - **Live TV** — a channels list + channel playback surface:\n    `Admin → Live TV (LabelList of channels) → select a channel → live player`. The list is a\n    one-shot `GET /api/v1/admin/livetv/channels` (AdminMiddleware-gated; the whole\n    `{success, channels:[...]}` envelope is read). Each channel row shows **`\u003cnumber\u003e  \u003cname\u003e`**\n    (the Integer `number` is stringified, not string-compared). Selecting a channel resolves its\n    stream — the client issues the Bearer-gated `GET /api/v1/admin/livetv/channels/{id}/stream`,\n    reads the **302 `Location`** header (without following the redirect), and opens a dedicated\n    lightweight live player (`LivePlayerScene`) on the final (unauthenticated) tuner/HLS URL. The\n    live player has **no sessions / progress / transcode / markers** — it does not reuse the VOD\n    `PlayerScene`. The status line surfaces friendly errors (\"Tuning…\", \"Couldn't start channel\",\n    \"Live TV unavailable\", \"Playback failed\"). The route group is **not registered when Live TV is\n    not configured** (the list then reports \"Live TV unavailable\").\n\n    \u003e **Streaming caveat (device-only-verifiable).** Channel playback hinges on resolving a\n    \u003e Bearer-gated **302** and reading the `Location` header _without following it_. Roku's\n    \u003e `roUrlTransfer` redirect-follow behavior is **firmware-dependent** and there is no documented\n    \u003e \"don't follow redirects\" setter: if the firmware auto-follows, `GetResponseCode()` returns\n    \u003e 200, no `Location` header is exposed, the resolved stream URL is empty, and live playback\n    \u003e fails with a friendly error. This cannot be verified without a physical device. **Recommended\n    \u003e upstream mitigation:** add a JSON `GET .../stream-url` endpoint (or a signed live-TV URL) so a\n    \u003e TV `Video` node can obtain the stream URL without resolving an authenticated redirect. The\n    \u003e `mpegts`-vs-`hls` `streamFormat` heuristic (`.m3u8` in the URL → `hls`, otherwise `mpegts`)\n    \u003e and raw-TS playback are likewise only verifiable on a device.\n\n    **Scope:** channels list + playback only.\n  - **TV Guide** — a read-only EPG list (`Admin → TV Guide`). A one-shot\n    `GET /api/v1/admin/livetv/guide` (AdminMiddleware-gated; the whole `{success, programs:[...]}`\n    envelope is read, sent with no query params → upcoming programs across all channels). Each row\n    shows **`\u003cstart time\u003e  \u003ctitle\u003e`** where the start time is the Integer `start_time` (UNIX seconds)\n    rendered by `Utilities.FormatUnixTime` as a local `\"M/D H:MM\"` wall-clock string (the Integer is\n    never string-compared). **Selection is inert** (read-only view). Empty result → \"No programs\";\n    missing `programs` key / unregistered route → \"Live TV unavailable\".\n  - **Recordings** — a read-only recordings list (`Admin → Recordings`). A one-shot\n    `GET /api/v1/admin/livetv/recordings` (whole `{success, recordings:[...]}` envelope). Each row\n    shows **`\u003ctitle\u003e  (\u003cstatus\u003e)`** plus ` — \u003cstart time\u003e` (via `FormatUnixTime`) when present, falling\n    back to the status or \"(recording)\" when there is no title. **Selection is inert.** Empty →\n    \"No recordings\"; missing key / unregistered route → \"Live TV unavailable\".\n  - **Series Rules** — a read-only series-recording-rule list (`Admin → Series Rules`). A one-shot\n    `GET /api/v1/admin/livetv/series-rules` (whole `{success, rules:[...]}` envelope). Each row shows\n    the rule **`title`** (falling back to `series_id`, then \"(rule)\"), optionally with `  (priority N)`\n    where `N` is the Integer `priority`. The rule row has **no top-level `id`** — only `rule_id` is\n    read. **Selection is inert.** Empty → \"No rules\"; missing key / unregistered route → \"Live TV\n    unavailable\".\n\n    With these three views, **F9 (live TV) is complete**. **Deferred / future work:** a guide\n    channel-filter picker, recording create / delete + recording playback, series-rule CRUD, and\n    channel edit (PUT) are intentionally not shipped — they need on-screen pickers / keyboard (or an\n    unscouted route), so all four Live TV admin surfaces remain read-only.\n\n## Project Structure\n\n```\nphlix-roku/\n├── source/\n│   ├── main.brs                 # Main entry point\n│   ├── lib/\n│   │   ├── ApiClient.brs       # API client (communication layer)\n│   │   ├── Storage.brs        # Persistent storage (registry)\n│   │   ├── AuthManager.brs     # Authentication manager\n│   │   ├── SessionManager.brs  # Session management\n│   │   ├── LibraryManager.brs  # Library browsing logic\n│   │   ├── TaskManager.brs     # Background task management\n│   │   ├── SyncPlayProtocol.brs # SyncPlay: pure RFC6455 WS framing + flat syncplay_* codec + NTP TimeSync (no I/O / no UI)\n│   │   └── Utilities.brs        # Helper functions\n│   ├── components/\n│   │   ├── PhlixApp.brs       # Main app controller\n│   │   ├── HomeScene.brs       # Home screen\n│   │   ├── LibraryScene.brs    # Library browser\n│   │   ├── DetailScene.brs     # Item detail view\n│   │   ├── CollectionsScene.brs # Collections list (LabelList of collection names, read-only)\n│   │   ├── CollectionScene.brs  # One collection's items (poster grid; raw rows normalized client-side)\n│   │   ├── AdminScene.brs       # Admin menu (LabelList; is_admin-gated entry; rows: Dashboard, Libraries, Users, Live TV, TV Guide, Recordings, Series Rules)\n│   │   ├── DashboardScene.brs   # Read-only admin dashboard (now-playing / storage / activity in one list)\n│   │   ├── LibraryAdminScene.brs # Admin libraries list (LabelList; drills into per-library actions)\n│   │   ├── LibraryAdminActionsScene.brs # Per-library admin actions (Scan / Rescan / Match Metadata / Refresh Status + scan-status line)\n│   │   ├── UserAdminScene.brs    # Admin users list (LabelList of all users; drills into per-user actions)\n│   │   ├── UserAdminActionsScene.brs # Per-user admin actions (Approve / Disable / Make-or-Remove Admin / Reset Password / Profiles / Refresh + user detail line)\n│   │   ├── ProfilesScene.brs     # Per-user profile list (one-shot LabelList; read-only; drills into per-profile actions)\n│   │   ├── ProfileActionsScene.brs # Per-profile admin actions (7 Rating buttons / Clear PIN / Refresh + profile detail line)\n│   │   ├── LiveTvScene.brs       # Admin Live TV channels list (one-shot LabelList; row-select resolves stream → live player)\n│   │   ├── LivePlayerScene.brs   # Dedicated lightweight live Video player (no sessions/progress/transcode/markers)\n│   │   ├── GuideScene.brs        # Admin Live TV guide/EPG list (one-shot LabelList; read-only, selection inert)\n│   │   ├── RecordingsScene.brs   # Admin Live TV recordings list (one-shot LabelList; read-only, selection inert)\n│   │   ├── SeriesRulesScene.brs  # Admin Live TV series-rules list (one-shot LabelList; read-only, selection inert)\n│   │   ├── 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-A7-dependent, no-ops without a ladder) (+ additive Audio/Subtitles Settings panel, opened with the Down key; persists preferred_audio_track / preferred_subtitle_track)\n│   │   ├── SyncPlayTask.{xml,brs} # Long-lived Task running the SyncPlay ws:// socket off the render thread (roStreamSocket; RunSocket loop; flat syncplay_* frames)\n│   │   ├── ConnectScene.brs    # First-run \"Connect to server\" screen (normalizes + probes /health, persists server_url, then proceeds to login)\n│   │   ├── LoginScene.brs      # Login screen (username/password only); after login probes GET /me/servers to detect hub vs direct\n│   │   ├── ServerPickerScene.brs # Hub mode: \"Choose a server\" list (one-shot GET /me/servers); pick persists active_server_id → relay routing\n│   │   └── GridItem.brs        # Grid item component\n│   ├── player/\n│   │   └── SkipButton.brs      # Skip intro/outro button\n│   ├── pages/\n│   │   ├── HomePage.brs        # Home page controller\n│   │   ├── LibraryPage.brs      # Library page controller\n│   │   └── SettingsPage.brs    # Settings page controller\n│   └── data/\n│       └── Theme.brs           # Theme constants\n├── tests/\n│   ├── unit/                   # Unit tests\n│   └── integration/            # Integration tests\n├── images/                      # App icons and splash screens\n├── manifest                    # App manifest\n├── Makefile                    # Build automation\n├── README.md                   # This file\n└── DEVELOPER.md                # Developer documentation\n```\n\n## Troubleshooting\n\n### App Won't Install\n\n1. Verify Roku IP address is correct\n2. Check developer credentials\n3. Ensure dev mode is enabled on Roku\n4. Try: `curl -u user:pass http://ROKU_IP:8060/` to verify connectivity\n\n### API Connection Failed\n\n1. Verify Phlix Media Server is running\n2. Check network connectivity from Roku\n3. Verify the server URL entered on the first-run Connect screen is correct (the\n   `/health` probe surfaces \"Couldn't reach that server\" when it is wrong/unreachable)\n4. Check server logs for connection attempts\n\n### Video Playback Issues\n\n1. Verify HLS support on your server\n2. Check network bandwidth\n3. Try lower quality streams\n4. Verify codec support (H.264/H.265 for video, AAC/AC3 for audio)\n\n### Debugging\n\n```bash\n# Connect to Roku debug console\ntelnet ROKU_IP 8080\n\n# Check app logs\n# View variable values\n# Step through BrightScript code\n```\n\n## Contributing\n\n1. Fork the repository\n2. Create a feature branch\n3. Make changes with tests\n4. Submit a pull request\n5. Ensure CI passes\n\n## License\n\nMIT License - See LICENSE file for details\n\n## Support\n\n- Issue Tracker: GitHub Issues\n- Documentation: [Phlix Wiki](https://github.com/your-org/phlix-roku/wiki)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdetain%2Fphlix-roku-client","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdetain%2Fphlix-roku-client","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdetain%2Fphlix-roku-client/lists"}