# TiviBro knowledge base for AI assistants

Generated 2026-11-05 from commit 9e60650 by `scripts/gen-ai-context.js`.  Source: <https://github.com/ohmybahgosh/TiviBro>.  Newest copy: <https://tivibro.app/ai/tivibro-for-ai.md>.

Contents: the rules and workflow; the architecture; what every source file does; settings and their defaults; remote actions; scripts and tests; compatibility; the roadmap; recent changes.

## Rules and workflow

### What TiviBro is

A free, open-source IPTV player for LG webOS TVs, built to feel like TiviMate and go further.  People bring their own Xtream Codes or M3U subscription with an XMLTV guide; TiviBro provides no channels, streams or playlists.

Four parts live in this repository:

1. **The TV app** (`app/`): plain HTML, CSS and JavaScript in one webOS web app (`app.tivibro.tv`).  No framework, no bundler, no modules: scripts load in order from `app/index.html`, and each file exposes one global (`Views`, `Player`, `Store`...).
2. **The network helper** (`service/`): a Node.js webOS service (`app.tivibro.tv.service`) bundled in the same IPK.  A localhost relay on 127.0.0.1:47811 that gets around CORS, plus big jobs the app can't afford in memory (guides, movie lists), the web editor's LAN server, reminders, and the phone app's LAN API on port 47810.
3. **The web editor** (`service/editor/index.html`): one big page the helper serves on the home network (port 8787 by default) for editing channels, groups, guides, playlists and backups from a phone or computer.
4. **The Android app** (`android/`): Kotlin and Jetpack Compose.  Pairs with the TV and works as a remote, Play on TV, and the editor full screen.  Built only in CI.

Also here: `site/` (tivibro.app, GitHub Pages), `relay/` (a Cloudflare Worker that turns problem reports into GitHub issues and counts anonymous installs), `scripts/` (build, release, wiki, stats), `tests/` (unit, soak, engines, Android), `docs/` (user guide, architecture, compatibility, wiki source).

### Rules that are not optional

**Old TVs.**  One IPK runs on webOS 4 to 26.  App code must parse and run on Chromium 53: no `async`/`await`, no object spread, no optional chaining or `??`, no CSS grid, no `\p{...}` Unicode property classes in regular expressions, no `gap` without the `nogap` fallback (`npm run nogap`).  Newer APIs only behind a `typeof` check with a fallback (`AbortController`, `DecompressionStream`, `TextDecoder`).  The helper service is plain ES5 for Node 0.12: `var` and `function`, callbacks, no arrow functions, no template literals, no `let`/`const`.  `bash tests/engines/run.sh` runs the app and helper on the old Chromium and Node builds (`npm run lint` only checks syntax with today's Node, so it won't catch these).  See `docs/COMPATIBILITY.md`.

**The remote first.**  Every feature works with arrows, OK and Back alone, and with the Magic Remote's pointer.  Back always goes up one level.  Hold OK (600 ms) opens options.  Views receive action names (`up`, `ok`, `hold`, `back`, `red`, `long:info`...) from `UI`, never key codes.

**Speed and memory.**  Lists stay fast with 15,000 channels (`VList` pools rows), nothing slows a channel starting, and webOS closes an app at roughly 500 MB.  Big data belongs in the helper or IndexedDB, not in app memory; keep objects flat and short-keyed.

**Never lose the user's work.**  The provider's data is never changed: hiding, renaming, custom groups and sorting are overlays (`Store.edits`) keyed by stable channel keys, so a playlist refresh keeps every edit.  Anything destructive asks first or can be undone.

**Show the current state.**  Every settings page, status line and screen shows what is true now, and updates when it changes (see `Settings.live`, `WebEdit.onStatus`).

**Nothing plays by itself.**  TiviBro never starts video on its own (no autoplay after setup or a playlist change).  The only exception is the user's own "Turn on last channel at launch" setting.

**No secrets anywhere.**  Never put a provider address, username, password or stream link in code, tests, fixtures, screenshots, logs or issues.  Logs mask credentials (`Log`), the web editor never receives logins (only server names), and tests use `tests/mock/mock_provider.js` (any login works; `demo` / `demo`).  Real-provider tests (`tests/real/`) read logins from a file outside the repository.

**Privacy.**  The TV app has no analytics.  The only thing it sends about itself is an anonymous daily count (version and webOS version, no ID, `Update.countMe`), which users can switch off.  Don't add telemetry, tracking IDs, third-party SDKs or remote calls without a matching line in `docs/wiki/Privacy-and-security.md` and `site/privacy/index.html`.

**Shared files stay in sync.**  `app/js/names.js`, `tidy.js`, `pubepg.js`, `links.js` and `events.js` are copied into `service/editor/` by `npm run build`; lint fails if a copy differs.  Edit the `app/js/` original and run the build (or copy the file).

**Writing style for anything a person reads** (UI text, the user guide, the changelog, release notes, issue replies): plain words for viewers, not developers; two spaces between sentences; never an em dash (use a colon, a comma or a new sentence).  Say what happens and what to press, with screen names in bold in docs (**Settings > Playback and buffering**).

### How a change is made

1. Read the files you will touch.  Each source file starts with a comment that explains what it does and why; keep it accurate when you change the file.
2. Make the change in the right layer (see "Where things go" below).
3. Add or update tests: a unit test in `tests/unit/` (plain Node, `ok(name, condition)` style, added to `test:unit` in `package.json`), and for anything on screen a soak test in `tests/soak/` that drives the app in Playwright against the mock provider.
4. User-facing change: a line under `## Unreleased` in `CHANGELOG.md` with a **bold headline that makes sense on its own** (release notes show only the headline), and the user guide (`docs/USER-GUIDE.md`; the wiki is built from it with `node scripts/wiki/build.js`).  New settings go in the guide's settings tables.
5. Run `npm run lint` and `npm test`.  For screens, run the soak test (`node tests/mock/mock_provider.js`, `npm run serve`, then `node tests/soak/<name>.js`).  For old TVs, `bash tests/engines/run.sh browser`.
6. Open a pull request against `main` and fill in the template.  Don't edit the `homebrew` branch, release files, the version number or release notes: maintainers cut releases.

### Where things go

| You want to | Do it in |
|---|---|
| Add a setting | A default in `Store`'s `DEFAULTS` (`app/js/store.js`), a row in `app/js/settings.js` (`choice`, `toggle`, `action`, `page`, `info`), the behaviour where it applies, and a row in the user guide's settings table |
| Add a remote action | `Keymap.ACTIONS`, `SHORT` and `CONTEXT_ACTIONS` (and `DEFAULTS` if it gets a button) in `app/js/keymap.js`, then a `case` in `Views.runAction` (`app/js/views.js`) |
| Add a quick menu button | `QP_BUTTONS` and `qpActions()` in `app/js/views.js` |
| Change the guide | `app/js/epg.js` (app side, worker), `service/guide.js` (helper side for big guides): keep the two matching rules in step |
| Change channel loading or a provider format | `app/js/source.js` (Xtream Codes and M3U into one compact channel shape) |
| Change what a channel or group name shows | `app/js/names.js` (rules) or `Library` overlays (`app/js/library.js`) |
| Change Movies and Shows | `app/js/vod.js` (lists, languages, categories, playback), `service/vod.js` (the helper slims big lists) |
| Change playback | `app/js/player.js` (engines, buffering, reconnects), `app/js/caps.js` (what the TV decodes), `app/js/embedsubs.js` (built-in subtitles: reading, timing, showing), `app/js/mkvsubs.js` and `app/js/mp4subs.js` (MKV and MP4 subtitle formats), `app/js/convert.js` with `service/convert.js` and `service/ffmpeg.js` (sound the TV can't play, converted by the helper's ffmpeg: docs/FFMPEG.md) |
| Add a web editor page or action | `service/editor/index.html` (the page) and the `RUN` table in `app/js/webedit.js` (commands the TV carries out); the browser sends `POST /api/command {type, args}` |
| Add a helper route | `service/proxy.js` routes to modules like `service/guide.js` and `service/vod.js`; plain ES5; bind localhost only unless it is the editor or phone LAN server |
| Change the phone app protocol | `service/companion.js` (TV side), `app/js/phone.js` (TiviBro side), `android/.../proto/` (phone side), `docs/wiki/Phone-app-protocol.md`, and the Android tests (`tests/android/fake-tv.js`) |
| Change the first-run screens or the set-up tour | `App.setup` in `app/js/app.js`, `app/js/tour.js` |
| Change the website | `site/` (deployed by `.github/workflows/pages.yml`) |

### Storage

- `localStorage`: `tivibro.settings` (all settings, playlists with their logins) and `tivibro.edits.<playlist id>` (the user's overlays).  Changes go through `Store.saveSettings()` and `Store.saveEdits()`.
- IndexedDB `tivibro`: channel lists (`playlist:<id>`), movie and show lists (`vod:<id>:movie|series`), restore points.
- Backups (`Backup` in `app/js/webedit.js`) carry settings and edits; keys in `KEEP_LOCAL` stay with the TV.

### Things that look like bugs but are on purpose

- The Phone app settings section shows only in test builds (`alpha` or `beta` versions).
- Video for the TV's own player goes direct for http and through the helper for https or a custom User-Agent; MKV files with subtitles and MP4 files with subtitles share one resolved session URL between the player and subtitle reads (`embedsubs.js`), and every new engine start (a reconnect, play after a long pause) asks the link again for a fresh session with a 16-byte read, keeping the parsed index, the lines read so far and the selection.
- New editor passwords and pairing codes leave out look-alike characters (`0/O`, `1/I/L`, `2/Z`, `5/S`, `6/G`, `8/B`, `U/V`); the editor login treats look-alikes as the same.
- Headless Chromium can't decode HEVC or AC-3; a stream that fails in a soak test may play fine on a TV.
- `docs/wiki/` is generated from `docs/USER-GUIDE.md`: edit the guide, not the wiki pages.

### Asking for a change

A prompt that works well, after attaching the knowledge base:

> I want to add *(the feature)* to TiviBro.  Viewers should see *(what changes on screen)* and use it with *(which buttons)*.  Follow the rules in the knowledge base: Chromium 53 and Node 0.12 compatible, remote first, no secrets, tests, a CHANGELOG line and the user guide.  First list the files you'll change and why, then show the changes.

Ask your assistant to show you the files it plans to change before it writes code, and paste back test output if anything fails.

## Architecture

Plain scripts loaded in order by `app/index.html` (no modules, no bundler: webOS blocks module scripts under `file://`).  Each file exposes one global.

| File | Global | Role |
|---|---|---|
| util.js | `U` | DOM helpers, keys, fetch with timeout |
| log.js | `Log`, `TIVIBRO_VERSION` | Ring-buffer log, persisted, credentials redacted.  **Version constant lives here** |
| net.js | `Net` | Starts the helper service, rewrites provider URLs through it (Automatic/Direct) |
| keymap.js | `Keymap` | Remote remapping: player/list contexts, short/long press, learned keys |
| store.js | `Store` | Settings in localStorage; the playlist on the helper's storage (`bigGet`/`bigSet`, IndexedDB without the helper); multi-playlist, migrations |
| names.js | `Names` | Channel/group name cleaner: default rule packs + TiviMate-style prefix/suffix lists |
| source.js | `Source` | Xtream Codes + M3U loading, http/https detection, catch-up URLs |
| library.js | `Library` | Provider data + user overlays (hide, rename, custom groups, favorites, sorting) |
| parental.js | `Parental` | 4-digit PIN for channels/groups/settings |
| epg.js | `Epg` | XMLTV per playlist (worker parsed), short EPG fallback, name matching, assignment |
| pubepg.js | `PubEpg` | Public guide fill-in: sources (epg.pw, iptv-epg.org), countries, matching keys (also run in the guide worker); `Epg` runs it as a second pass for channels with no guide |
| caps.js | `Caps` | What the TV decodes (MSE vs TV player, plus known LG rules such as no DTS on 2020-2022 models), stream descriptions from HLS / mpegts.js / Xtream, and pipeline routing for HDR, Dolby Vision and codecs |
| convert.js | `Convert` | Sound the TV can't play (DTS on 2020-2022 models, TrueHD, FLAC surround): before a movie starts, picks a playable audio track or has the helper convert the sound to AC-3 (`/convert`), and offers the audio converter download once |
| player.js | `Player` | mpegts.js / hls.js / TV player engines, buffer profiles, a cushion that grows on unsteady streams, a start check every 50ms until the first frame, timestamp-jump following, starvation detection, watchdog, reconnect ladder (backoff with jitter, offline wait, other pipelines, slow retry), resume, live pause, suspend/resume |
| vlist.js | `VList` | Virtual list with pooled rows |
| ui.js | `UI` | Key routing, long-press, modal menus, prompts |
| views.js | `Views` | Live list, fullscreen + OSD, guide grid, menus |
| search.js | `Search` | Search across channels, the guide, movies and shows, ranked by match |
| vod.js | `Vod` | Movies and Shows: lists (kept and filtered by the helper's `service/vod.js` on a TV), languages, hidden and adult categories, details, playback and resume |
| vodview.js | `VodView` | Movie and show language tags and what shows; shared with the helper (`service/editor/vodview.js` is a build copy) |
| embedsubs.js | `EmbedSubs` | Built-in subtitles for the TV's own player: Range reads that wait for playback, only the selected track's window, timing, track modes and the menu state; formats plug in as adapters |
| mkvsubs.js | `MkvSubs` | MKV adapter for `EmbedSubs`: SRT, ASS/SSA and WebVTT tracks from the Cues index |
| mp4subs.js | `Mp4Subs` | MP4 adapter for `EmbedSubs`: tx3g text tracks from the moov box, with edit lists and language labels |
| links.js | `Links` | Splits a provider's full link into server, username and password (shared with the editor) |
| events.js | `Events` | Event channels: the game in a pay-per-view or sports-pass channel name, as a guide entry |
| tidy.js | `Tidy` | Smart clean-up answers (countries, East and West, locals, duplicates), shared with the editor |
| tour.js | `Tour` | The set-up tour after a new playlist |
| logos.js | `Logos` | Logos from open logo projects for channels without one |
| remind.js / watch.js | `Remind`, `Watch` | Reminders (handed to the helper so they work with TiviBro closed) and the watchlist |
| update.js | `Update` | Checks for new releases, installs through the Homebrew Channel, the anonymous daily count |
| phone.js | `Phone` | The phone app from TiviBro's side: pairing screens, remote commands, state and lineup for the phone |
| webedit.js | `WebEdit`, `Backup` | The web editor from TiviBro's side: snapshots, applying saves, the `RUN` command table, backups |
| settings.js | `Settings` | All settings pages |
| diag.js | `Diag` | Test connection + log viewer |
| app.js | `App` | Boot: paint cache, resume, background refresh |

### Network helper (`service/`)

Files: `service.js` (the webOS service: start subscription, wake activity), `proxy.js` (the relay and its routes), `guide.js` (big guides), `vod.js` (keeps movie and show lists), `kv.js` (keeps the playlist), `editor.js` (the web editor's LAN server), `companion.js` (the phone app's LAN API on 47810 and UDP discovery), `remind.js` (reminders with TiviBro closed), `ffmpeg.js` and `convert.js` (the audio converter: download, probe and conversion, see docs/FFMPEG.md), `rawhttp.js`, `tssync.js`, `unzip.js`, `compat.js`.

webOS enforces CORS for installed apps and most providers send no CORS headers.  `service.js` wraps `proxy.js`, a localhost relay on **127.0.0.1:47811** using path-style URLs: `/x/<scheme>/<host[:port]>/<path>`.  It adds CORS headers, rewrites redirects, passes Range through, accepts self-signed certs, exposes `/ping` and `/log`.  It keeps provider connections alive between requests and remembers DNS answers for a minute, so channel changes don't wait on the TV's resolver.  When Node's HTTP parser refuses a provider's reply (stray control characters, `Content-Length` twice), the request is repeated through `rawhttp.js`, a small forgiving HTTP/1.x reader.  Live MPEG-TS passes through `tssync.js`, which drops stray bytes and keeps 188-byte packets aligned, so a corrupted stretch costs a glitch instead of a stalled demuxer.  The app keeps a `start` subscription open; when the app closes the service exits.

**Big guides (`guide.js`).**  With the helper running, XMLTV guides are read by the helper, not the app: webOS closes an app at about 500 MB, and a full guide parsed in the app can pass that (a 64 MB iptv-epg.org file did on a 2 GB C2).  The app posts what to keep (`POST /guide/load`: sources as relay addresses with `/unz/<id>`, wanted guide ids, name matches, the public guide's matching keys) and follows `GET /guide/status`.  The helper streams each file through its own relay, keeps the playlist's channels inside the time window (same rules as the in-app worker), spills programmes to bucket files, then writes per playlist and kind (`main`, `pub`) `titles.tsv` (start, end, title), `desc.json` with `desc.idx` (one channel's descriptions by offset) and `meta.json` (guide channel list, icons, name matches, report).  It keeps the old guide when every file fails, and stores under the service folder on the TV's storage (falls back to `/tmp`).  The app holds only titles and times (`Epg.request`, search, the watchlist, the grid) and asks `GET /guide/desc` when a description is shown (`Epg.desc`, last 80 channels kept).  A restart reads what is stored, with no download.  Without the helper the worker in `epg.js` does it all, within a memory budget.  `service/editor/pubepg.js` is a build copy of `app/js/pubepg.js` (lint checks it).

**Movie and show lists (`vod.js`) and the playlist (`kv.js`).**  An old TV gives the web app only a small share of its storage (issue #18: a 51,000-channel list and its movie lists did not fit a 2019 C9), so with the helper running both live on the TV's storage: `/media/internal/.tivibro` when the helper can write there (kept across updates), else beside the guide.  `POST /vod/list` downloads and slims a list once, keeps it (`vod/<playlist>-<kind>.json`, and in the helper's memory for 3 minutes), and answers with only what shows: the languages picked and no hidden categories, using `VodView` like the app.  A language or category change asks again; the app never holds every language.  `GET/POST /kv/get|put|del?k=` keep the playlist (`Store.bigGet`/`bigSet`); they need TiviBro's secret like `/editor/` (the playlist holds stream addresses with the login).  A copy left in IndexedDB by an older TiviBro is moved over once.  Without the helper (a browser) both stay in IndexedDB, and a save that runs out of space frees other playlists' caches and tries again.

**My media from Dropbox (`dropbox.js`).**  The phone app signs in (PKCE, no app secret) and hands the TV only a refresh token, in a sealed `mymedia` ask (`dbfolders` for its folder picker, `adddropbox` to add).  The TV keeps it with the source in `tivibro.media` like a WebDAV password: never in backups, logs, problem reports or replies to the phone or web editor.  The helper turns it into access tokens (cached per token, renewed under 5 minutes left), scans with one recursive `files/list_folder` (2000 a page, then `/continue`; a page counts as a folder for the scan budget, and the cursor and the video files listed so far are the checkpoint), and plays from `files/get_temporary_link` (cached 3.5 of its 4 hours, played directly, redacted in logs like every `*.dropboxusercontent.com` link).  Ids are `x:` + `path_lower`, paths `path_display`.  Routes: `POST /media/dropbox/folders`, `/media/dropbox/account`, `/media/dropbox/revoke` (removing a source signs out).  A 429 waits `Retry-After` (at most 30 s, three tries).  `TIVIBRO_DROPBOX_API` points it at a mock for tests.

Video handed to the TV's native player goes direct for http, through the helper for https.

### Storage keys
`tivibro.settings`, `tivibro.edits.<playlistId>`, `tivibro.devRelay` (desktop testing only), IndexedDB `tivibro`, and on a TV the helper's `kv/` and `vod/` folders.

### Web editor ("Edit on a phone or computer")
- `service/editor.js`: LAN HTTP server (0.0.0.0:<port>, only while switched on) serving `service/editor/index.html` + `names.js` (copied from `app/js/names.js` by the build; lint checks it is current).  Browser API: `POST /api/login`, `GET /api/state`, `GET /api/rev`, `POST /api/save`, `POST /api/logout`, token in `X-Token`.
- Control from the app over the localhost relay: `POST /editor/start {port,password}`, `/editor/stop`, `GET /editor/status`, `POST /editor/snapshot`, `GET /editor/pending?since=N`.
- `app/js/webedit.js`: starts/stops it, sends snapshots (group/channel names, logos, edits, name rules; never credentials or stream URLs), polls every 2 s and applies browser saves to `hiddenGroups`, `hiddenChannels`, `renames`, `custom`, `groupOrder` and the playlist's name rules.
- Actions (playlists, guide sources, backup, restore): browser `POST /api/command {type,args}` → app picks them up from `/editor/pending` (`commands`), runs them (`WebEdit` RUN table), answers via `POST /editor/result`; browser polls `GET /api/result?id=`.  Backups are created and restored by `Backup` in `app/js/webedit.js`; restore freezes Store saves, writes localStorage and reloads, and `WebEdit.resume()` picks the running editor back up.
- Desktop testing: `npm run serve`, set `tivibro.devRelay`, switch it on in Settings, open http://localhost:8787.

#### Editor model (v2)
- The browser holds a working copy of the edits (E) and name rules (N).  Every change goes through `mut(label, fn)`, which records an undo step, saves the draft in localStorage (`tivibro.editor.draft.<playlist>`) and updates the change bar.  `send()` posts the whole copy to `/api/save` and waits for `appliedRev`.
- Edits the TV understands: hiddenGroups, hiddenChannels, renames, groupRenames, custom (with optional `orig` name), groupOrder, epgMap, groupAdd {group: [keys]}, groupRemove {group: {key: 1}}, groupSort {group: provider|az|za|number|custom}.  `Library.channels()` applies groupAdd/groupRemove/groupSort.
- Restore points: `WebEdit` keeps them in IndexedDB under `snaps:<playlist id>`; an automatic one is taken before each browser save is applied.
- Guide progress: the XMLTV worker posts `{progress}` messages while it reads; `Epg.onProgress` drives the TV chip (`#epgProg`) and `WebEdit` relays it to `/editor/progress` (throttled to about one a second).  Guide commands start the load and answer straight away.
- Smart match runs in the browser against `guide.channels` from the snapshot (inverted token index, bigram similarity, country, East/West, number and call-sign rules).

## The TV app: every file (app/js)

### `app/js/app.js`

Boot sequence, tuned for a fast cold start on a slow TV:
1. Paint the cached channel list from IndexedDB immediately.
2. Resume the last channel straight away if that option is on.
3. Refresh the provider list and guide in the background afterwards.

### `app/js/awake.js`

Keeps the TV's screensaver away while something plays (issue #37).  webOS starts its screensaver
when no remote button has been pressed for a while, even over video in a web app, so a channel or a
movie ends up behind the screensaver (or behind a black screen with text, with a homebrew one such as
Aerial).  The TV's power service asks every app that registered whether it may start:
luna://com.webos.service.tvpower/power/registerScreenSaverRequest  { subscribe, clientName }
sends a message with a timestamp when it is about to, and the app answers
luna://com.webos.service.tvpower/power/responseScreenSaverRequest  { clientName, ack, timestamp }
ack: false holds it off, ack: true lets it start.  The same calls Kodi, Moonlight and other webOS apps
use; developer-mode web apps may make them, so no helper and no extra permission are needed.  webOS 4
leaves out the message's "state" field, so any message with a timestamp is a question.

TiviBro says no while live TV, a movie or episode, My media or catch-up plays (also while it loads or
buffers), and yes when nothing plays, playback is paused, or TiviBro is in the background.  Each
refusal is logged.  On by default, no setting.

### `app/js/caps.js`

What this TV can decode, and through which pipeline.

Two pipelines can play a stream:
built-in   hls.js / mpegts.js feeding Media Source Extensions (MSE).  Fine buffer control,
but only codecs MSE accepts, and HDR is signalled by the browser engine.
TV player  the <video src> pipeline (LG's own media player).  Plays raw MPEG-TS, HLS, MKV
and MP4, switches the panel into HDR10, HLG or Dolby Vision itself, and decodes
AC-3 / E-AC-3 natively.

Rules, from what LG TVs are known to do (Jellyfin's webOS device profile is the best public
record of it):
- HEVC (also in MPEG-TS), AC-3 and E-AC-3 play on every webOS 4+ TV.
- AV1 on webOS 5+ (2020 models on).
- Dolby Vision profiles 5 and 8 play through the TV player on webOS 4+, even where the TV
does not report it.  Through MSE it is not dependable, so Dolby Vision goes to the TV player.
- DTS: not on LG TVs from 2020, 2021 and 2022 (webOS 5, 6 and 22, including the C1 and C2),
whatever webOS version they were later updated to.  Back on 2023 models.
- Dolby TrueHD: no LG TV decodes it.
- FLAC with more than two channels does not play.
Sound that can't play (unplayable()) can be converted to AC-3 by the helper (convert.js).
probe() asks the TV as well and keeps both answers, so Settings > Updates, about and diagnostics can show them.

### `app/js/captions.js`

Closed captions (CEA-608) for live TV.
US channels carry captions inside the video itself: H.264 SEI messages with "GA94" caption data
(ATSC A/53).  hls.js reads them by itself; for MPEG-TS (most IPTV) the patched mpegts.js hands every
caption SEI over as PES private data with pid -608 (vendor/PATCHES.md), and this decodes it.

Captions.feed(seiBytes, ptsMs): the SEI as it came (Annex B or not), with its presentation time.
Decoding happens in time order (B-frames send captions out of order), into "screens": what CC1
shows from a time on.  Captions.screenAt(t) is the screen to show at video time t.

CEA-608 in short: pairs of bytes with odd parity.  0x10-0x1F as the first byte is a control code
(sent twice, the copy ignored); the rest is text.  Three styles: pop-on (written off screen, then
swapped in whole), roll-up (lines scroll up from a base row) and paint-on (written in place).

### `app/js/compat.js`

Small fixes for older webOS browsers, loaded before everything else.
webOS TV 25 (Chromium 120) needs none of this; it keeps 4.x (Chromium 53), 5.x (68), 6.x (79)
and 22 (87) working with the same code.  Keep it tiny and dependency-free: plain ES5.
- Promise.prototype.finally (Chromium 63)
- Object.entries / Object.values (Chromium 54) and String padStart / padEnd (Chromium 57):
the bundled players use them (hls.js, and mpegts.js when it reads an HEVC stream)
- Flexbox "gap" (Chromium 84): when missing, <html> gets class "nogap" and css/nogap.css
spaces flex children with margins instead (that file is generated by scripts/gen-nogap.js).
It also makes sure a TV never just shows a black screen: on webOS 3 and older (no modern
JavaScript) it says the TV is too old, and if the app fails to start it shows why.

### `app/js/convert.js`

Sound the TV can't play.  LG TVs from 2020 to 2022 (webOS 5, 6 and 22, the C2 among them) have no DTS
at all, no LG TV plays Dolby TrueHD, and surround FLAC fails.  A movie whose only sound is one of those
plays silent.  The network helper can convert that sound to Dolby Digital (AC-3) with a small audio
converter (ffmpeg, about 2 MB) that it downloads to the TV's storage the first time it is needed.
Video is never touched (service/convert.js, service/ffmpeg.js, docs/FFMPEG.md).

What happens when a movie, episode or My media file starts (Player.play asks plan()):
- the sound the app already knows plays here (Xtream's movie details): nothing changes;
- otherwise, with the converter on the TV, the helper reads the file's tracks (/convert/probe);
- another track this TV plays, in the same language: that track, no conversion;
- every track is one the TV can't play: the movie plays from a conversion link, and the viewer
is told once ("Converting DTS audio for this TV");
- no converter yet: TiviBro offers it once.  Declined, the movie plays as before and says why
there is no sound.
Settings > Playback > Convert audio the TV can't play: Automatic or Off.

### `app/js/deadlist.js`

A playlist whose channels the provider won't send (issue #25).  A list can log in fine, with an
account that is active for months, and still have almost nothing behind it: every channel answers
404 Not Found or 5xx, because the list is out of date or its streams only work from another
country.  Each channel that fails like that is counted (once per channel), and so is each channel
that plays.  When five or more have failed and next to none played, TiviBro says so once a day per
playlist, instead of leaving it to look like one bad channel after another.

One channel that won't play while the rest do (issue #37): a channel whose last two tries both
failed, in the last three days, gets a small "not playing lately" mark in the channel lists, the
guide and the phone app.  The first time it plays again the mark goes.  Kept per playlist on the TV
(tivibro.deadCh), at most 300 channels each, so it lasts past a restart.

### `app/js/diag.js`

Diagnostics: a step-by-step connection test and a log viewer, usable from Settings and
from the setup screen (before any playlist works).  Each step says what it tried, what
came back, and how long it took, so a photo of the screen is enough to find the cause.

### `app/js/embedsubs.js`

Embedded subtitles, the part every container shares.  The TV's own <video> plays MKV and MP4
files but never exposes the text subtitle tracks inside them, so a format reader (mkvsubs.js,
and later others) finds the tracks and where their lines sit, and this core does the rest:
HTTP Range reads on the playing file's own session URL (the provider sees one session), reads
paused while the playback buffer is low and aborted when the player waits, retries with backoff,
a per-file cache that survives seeks and reconnects, real <track>s for the subtitle menu, and an
overlay that draws the one selected track (webOS can draw ::cue badly or not at all).

EmbedSubs.run(url, video, onResolved, adapter) -> { cancel }.  onResolved(sessionUrl) is called
once, after the first read resolved the provider's redirect, and the video must start on that
URL; null for a reanchor (seek), which keeps the video as it is.  An adapter is a container:
id, headBytes                  name for logs, size of the first (session-resolving) read
startupDelayMs                 optional delay before the first read (tests, diagnostics)
retryBaseMs                    optional first wait after a refused read (1000; shorter in tests)
startupDeadlineMs              0, or how long the video may wait for the first read: after it the
video starts on the original URL and subtitles give up for this play
lazy                           reads only tracks asked for (ctx.demand(i): null, 'now', { from, to }
or a list of those): each track gets
_embedLoad(fromSec, toSec, timeoutMs) -> Promise, which asks for
that window until nothing in it is left to read (Line up's reference)
describe() entries may carry forced: true.  Every attached track has _embedded = true and
_forced, set before it is attached (the addtrack handler reads them).
discover(head, range, rec, alive) -> Promise of the format's index, null (tracks, none usable)
or { none: 'empty' } (no subtitle tracks at all: the menu says so)
describe(index)                -> [{ label, srclang }] in attach order
candidates(rec, now, ctx)      -> ordered work items { id, keys? } not yet fetched, near now.
ctx.demand(i): null, or 'now' while track i is showing; ctx.timing(i):
{ scale, off, delay }, how Subs shows track i's lines (shown =
source * scale + off + delay).  Formats that read every track ignore it
complete(rec, now)             -> true when nothing is left anywhere in the file
read(rec, item, range, alive)  -> Promise of [{ track, s, e, text }], track = index into describe()
range(start, end, metadata) reads bytes [start, end] into a Uint8Array whose .total is the file
size from Content-Range (NaN if unknown); metadata reads skip the buffer wait.  alive()
is false once cancelled: check it before parsing a response, so a stopped player costs no work.

### `app/js/epg.js`

Program guide, built so the UI thread never parses anything large.

Sources, per playlist (Settings > Guide > Guide sources):
- Your XMLTV addresses, in priority order, each with its own time offset.
- The provider's guide: for Xtream either per-channel ("short", fast and light) or the
full XMLTV file; for M3U the address inside the playlist.
XMLTV is streamed and parsed in a Web Worker, trimmed to a time window and to channels
that exist in the playlist, then cached in IndexedDB.  Channels are linked to guide data
by their guide id, by a manual assignment, or (optionally) by matching names.
Xtream channels the XMLTV doesn't cover still get the per-channel guide.
Channels still without a guide after all that are looked up in a free public guide (pubepg.js),
in a second, separate pass, so your own guide is on screen first.

### `app/js/events.js`

Event channels: pay-per-view, league passes and one-off games, where the provider puts the whole
listing in the channel name instead of the guide:
"NCAAB 001 | NORTH FLORIDA @ CHARLESTON SOUTHERN (M) | 12.18 11:00 AM | ESPN+"
"PPV 03 | UFC | Makhachev v Machado Garry | Sat 15th Aug 2:00AM UK/9:00PM ET"
"NBA: Kings vs Pelicans @ Oct 29 7:30 PM :Nbc Sports  04"
parse() reads such a name into a short channel name ("NCAAB 001"), the matchup, extra details
and a start time, so the channel list can show a short name and the guide can show the event
at its time (programmes() makes guide entries when the guide has nothing for the channel).
Times that carry a zone (ET, EDT, PT, UK, CET...) use it; the others are read in the playlist's
event time zone (Settings > Channels and groups > Event channels), by default the
provider's own.  Daylight saving rules are built in, so old TVs without time zone data work.

### `app/js/filter.js`

Content filter (Settings > Parental controls > Content filter; issue/discussion #20).
Channels and groups it matches are left out of everything, from the moment a playlist loads: the
guide, every list, Search, the set-up tour, the phone app and the web editor never see them.  It is
separate from the PIN lock, which still blocks what you choose.  Nothing is deleted: change the
filter and the channels come back.

Store.settings.filter = { adult: true|false, words: [..], chans: [..channel names], groups: [..group names], asked }
adult   the built-in list of adult words, in several languages ("Adult Swim" is left alone)
words   your own words or call signs, matched as whole words in channel and group names
chans   channels you hid with the filter ("Hide with the filter" in a channel's options), by name
groups  groups you hid with the filter, by name
A new TV starts with adult on; the set-up tour's first screen asks.  TVs that already had a
playlist before the filter existed start with it off.

### `app/js/keymap.js`

Remote control remapping.
Three contexts: "player" (fullscreen live TV), "guide" (the guide grid) and "list" (the Channels screen).
Every button can have a short-press and a long-press action.  Buttons the app doesn't
know (extra keys on some LG remotes) can be learned by pressing them once.
Catch-up and movie playback keep fixed seek controls so they always behave the same.

### `app/js/library.js`

The channel library: provider data plus the user's edits.
Provider data is never mutated; hiding, renaming and custom groups are overlays
stored by stable key, so a playlist refresh keeps every edit.

### `app/js/links.js`

A provider's full link, split into what TiviBro needs (issue #11).  Shared by the TV's set-up screen
and the web editor (copied to service/editor/links.js by the build).
Links.parse('http://host:8080/get.php?username=a&password=b&type=m3u_plus&output=ts')
-> { type: 'xtream', server: 'http://host:8080', user: 'a', pass: 'b', from: 'get.php' }
Any link with a username and password is an Xtream Codes login: get.php, player_api.php, xmltv.php
and panel_api.php links, and stream links (/live/user/pass/123.ts, /movie/..., /series/...).  A panel
under a folder keeps it (http://host/tv/get.php?... -> http://host/tv).  Anything else is an M3U
playlist address: { type: 'm3u', m3u: link }.  null for an empty or unreadable link.

### `app/js/log.js`

Logging.  Everything that can go wrong on the way to a picture is written here:
helper start-up, every provider request (status, time, size, error type), login
details, player engine choices and failures, guide loading.
- Kept in memory (last 800 lines) and saved (last 300) so a previous session can be read
after a restart.
- Shown in Settings > Updates, about and diagnostics (and from the setup screen), and mirrored to the
console for `ares-inspect`.
- Credentials are masked before anything is stored or shown.

### `app/js/logos.js`

Channel logos from open logo projects, for channels the provider and the guide have no logo for
(or, if you like, for every channel, so they all match).  Settings > Appearance and start screen > Logo library.
TiviBro hosts no logo images.  It downloads a small index (tivibro.app/logos/, built by
scripts/logos/build-index.js) for the countries in your playlist, matches each channel by its guide
id, its cleaned name and country, or a local station's call sign, and loads the logo from where its
project keeps it:
"Matching set"    tv-logo/tv-logos, one style for dark screens (falls back to the original)
"Original logos"  iptv-org/database, each channel's own logo (falls back to the set)
The index is kept on the TV for a week.

### `app/js/media-series.js`

My media show navigation.  Pages stay in the helper; menus retain parent callbacks for Back.
Movie-only phone endpoints remain separate.  Chromium 53-compatible.

### `app/js/media.js`

My media: the viewer's own movies, from a home media server (DLNA, like a Fritz!Box) or a cloud
(WebDAV, like premiumize, or Dropbox, signed in from the phone app).  Full-width poster shelves and the normal movie player.  The helper does the
network work (service/media.js); MediaStore keeps what is remembered.

Titles start with folder names.  Explicitly requested TMDB lookups add movie details and posters;
only titles/years or chosen movie IDs are sent.  A fresh signed link or authenticated
localhost stream is resolved for every WebDAV play.

### `app/js/mediastore.js`

My media: what the TV remembers about the viewer's own movies.  Sources and where each movie stopped
stay in localStorage under their own key, and the library in the helper's persistent storage (IndexedDB in a browser), so all of it belongs to the TV
rather than a playlist.  Keeping them out of the settings also keeps source logins out of backups and
problem reports, which carry the settings.  Like playlist logins, they are stored unencrypted.

A scan that fails, stops early or finds nothing never takes movies away: a disk that is unplugged or
still being indexed shouldn't empty the list.  Signed links are never kept, they expire.

### `app/js/mkvsubs.js`

Embedded text subtitles in MKV movies/series: the MKV reader.  The native engine's <video>
decodes an MKV's video/audio fine but never exposes its embedded subtitle tracks
through video.textTracks -- that's a near-universal limitation of browser <video>
elements, not a webOS quirk.  This reads just enough of the MKV container itself
(Tracks, then SeekHead -> Cues -> the handful of Clusters a subtitle track's cues
actually live in) to pull the subtitle text out.  EmbedSubs (embedsubs.js) does the
HTTP Range reads, the cache, the <track>s and the overlay, so the app's existing subtitle
UI (auto-on, the in-player Subtitles menu) works on its own.  SRT, ASS/SSA and WebVTT as
plain text; if a file's Cues doesn't index its subtitle track, this gives up rather than
scanning the file.

### `app/js/movies.js`

Movies on now: guide programmes that are movies, confirmed and filled in by TMDB with your own token
(Settings > Guide > Movies in the guide).
Which programmes might be movies:
strong  the guide says Movie (its <category>), the title has a year or "Movie:", or a movie
channel (HBO, Cinemax, TCM, a "Movies" group…) shows something 75 minutes or longer
weak    anything else 80 minutes to 4 hours long that isn't news, sport, a show or a marathon
Each is looked up once (the helper asks TMDB, media-metadata.js guideLookup): a movie of exactly that
title, and for a weak one also well known and not a TV series of the same name.  Answers, misses too,
are kept for 30 days, so after the first day only a few titles an hour go to TMDB.
What it gives: Movies.of(c, p) for the guide's details (poster, year, genres, rating) and the "Movie"
tag in the grid, and the Movies on now group (Library.MOVIES): movies on now, most left first, then
the ones starting within the hour.

### `app/js/mp4subs.js`

Mp4Subs: subtitles stored inside MP4 files (3GPP timed text, the 'tx3g' tracks that ffmpeg's
mov_text, HandBrake and most MP4 muxers write).  The TV's own player doesn't expose them, so
TiviBro reads them itself, the way MkvSubs does for MKV, and hands them to the shared core
(EmbedSubs) for Range transport, caching and the overlay.

This part reads the file's structure:
- box(): one box header (ISO/IEC 14496-12 §4.2): 32-bit size, 64-bit largesize (size 1), or
  size 0 (to the end of its parent).  Positions are absolute file offsets; files past 4 GB are
  normal, so offsets and sizes are plain numbers checked against Number.MAX_SAFE_INTEGER, never
  bit operators.
- readTrak(): what a 'trak' says about itself: track header (§8.3.2), media header with its
  timescale and packed ISO-639-2/T language (§8.4.2), handler (§8.4.3), the first sample
  description (§8.5.2; for tx3g the display flags of 3GPP TS 26.245 §5.16), data references
  (§8.7.2), the edit list (§8.6.6), chapter references (tref/chap, §8.3.3) and udta/name.
- samples(): each sample's file offset, size and time on the presentation timeline from the
  sample tables: stts (§8.6.1.2), ctts (§8.6.1.3), stsc (§8.7.4), stsz (§8.7.3.2) and
  stco/co64 (§8.7.5), with the track's own edit list applied.

Rejected (the track is left out rather than shown wrong): more than one sample description or
a chunk using another one, samples not proven to be in this file (the sample entry's data
reference must be a self-contained 'url ', or 'alis' in QuickTime files), compact sample sizes
(stz2), tables that disagree on the sample count, more than limits.maxSamples samples, a sample
over limits.maxSampleBytes, data past the end of the file, and edit lists other than none or
one optional leading empty edit followed by one normal rate-1 edit.  Malformed or truncated
boxes give null, never an exception.

### `app/js/names.js`

Channel name editor engine.
Two layers, applied per playlist:
1. Default rules, preloaded and on by default.  Regex based, so one rule covers every
separator style: "USA | ", "|US| ", "[US] ", "US: " and "us- " are all "country prefix".
Each rule set can be switched on or off, and "recommended defaults" restores them.
2. Your own prefix and suffix lists, matched literally and case-insensitively at the
start or end of a name, exactly like TiviMate.  Spaces count: " HD" will not touch "WHD".
Groups have their own lists too (gprefixes, gsuffixes), applied to group names only, after
the channel rules when those also clean groups.
Safety: a step that would leave nothing (or just a number) is undone, so a channel
is never cleaned into a blank.

### `app/js/net.js`

Network routing.
webOS enforces CORS on installed apps and most IPTV providers send no CORS headers, so
every provider request the app makes with fetch/XHR goes through the bundled helper
service (a localhost relay, see /service).  Video handed to the TV's own player is not
subject to CORS and goes direct.

Settings > Playback > Connection: Automatic (relay when the helper is running), Always relay,
or Direct (for providers or proxies like Dispatcharr that already send CORS headers).

### `app/js/onnow.js`

Movies on now, as a screen in the side menu: big posters of the movies on your channels right now and
starting in the next three hours, then the same movies by genre (movies.js finds them, TMDB fills them in with your token), the
focused one large at the top over its backdrop, with its title logo.  OK watches it; Hold OK or Red
lists every channel showing it, a reminder for one starting later, or the guide on it.

### `app/js/parental.js`

Parental controls: a 4 digit PIN guarding blocked channels/groups and, optionally, Settings.

### `app/js/phone.js`

The TiviBro phone app, from the TV's side (the helper's service/companion.js does the work).
Settings, Phone app: switch the connection on, pair a phone with a QR code or a code typed on the
phone, see and remove paired phones, and name the TV.  While the connection is on, TiviBro checks
the helper every few seconds: a phone that asked for the web editor gets it started here.  It also
carries out the phone's remote presses and answers its questions (ASK: the guide, schedules,
reminders, favorites, Movies and Shows, typing into text boxes on screen, adding a shared playlist
link, backups and restores).
Plain ES5 for the old Chromium on webOS.

### `app/js/player.js`

Playback, built around one idea: IPTV streams are bursty, so the player should hold a
cushion and refill it properly instead of stuttering on every hiccup.

Engines
ts     mpegts.js in a worker   (raw MPEG-TS, most Xtream live)
hls    hls.js in a worker      (.m3u8)
native the TV's own pipeline   (zero JS demux cost, least control, ignores CORS)

Smoothness layers
1. Prebuffer:   don't start until N seconds are buffered.
2. Refill goal: when the buffer runs dry, pause and refill to a target instead of
the browser's default of resuming at ~0.1s and stuttering again.
3. Gap jumping: hop over small holes in the buffer that freeze live TS streams, and follow
the stream when its timestamps jump (an encoder restart upstream).
3b. Growing cushion: every time a channel runs dry, the next refill aims higher, so a
sloppy channel settles a few seconds further behind live and stops
stuttering.  It shrinks again after a long clean run.
4. Watchdog:    if the clock stops, recover in steps (nudge, decoder recover,
reconnect with backoff), then fall back to another engine.
5. Memory:      back buffer trimmed so long sessions don't starve the TV.
6. Never silent: offline waits for the network, a channel that keeps failing tries every
other pipeline, then keeps retrying slowly with a message on screen
instead of dying.  Movies and catch-up resume where they dropped.

Routing: HDR and codec facts (Caps) decide the pipeline before the first frame.  Dolby
Vision goes to the TV player; HDR10/HLG stays on the built-in player unless Settings say
otherwise; codecs MSE can't take go to the TV player.

Sound the TV can't play (DTS on 2020 to 2022 TVs, TrueHD, surround FLAC): Convert.plan() is asked
before a movie starts.  It may pick another audio track, or have the helper convert the sound
(cur.conv).  A converted movie is a stream, not a file: a seek starts a new conversion at that time
(conv.t), and position = conv.base + video.currentTime, where conv.base is conv.t unless the TV's
player already shows the movie's own times.  Everything outside asks Player.position, and
subtitles add video._tbOffset (the same base) to the video's clock.

### `app/js/playmemo.js`

What playback learns, kept across restarts (localStorage tivibro.playMemo, not in backups).

Per channel (per playlist): the pipeline that last played it when that was not the usual first choice
(a channel that only plays as HLS, or only on the TV's player, starts that way at once instead of
spending seconds on the others), and the extra cushion it earned by running dry (so it doesn't have to
run dry again before the cushion grows).  At most 1,500 channels, the least recently used go first,
and anything not seen for 30 days.

Per provider (the stream address's host): how often its channels run dry for each hour watched.  The
Automatic buffer profile (player.js) starts channels fast on a steady provider and holds a bigger
cushion on a shaky one.  The counts are halved every 20 hours watched, so it follows a provider that
changes.

### `app/js/plshape.js`

Turning a provider's answer into TiviBro's channel list, shared by the app (source.js) and the helper
(service/live.js, which downloads and shapes a big list on the TV so the app never holds the
provider's whole answer: a 180,000-channel list took the app to about 270 MB while it loaded).
Both must give the same channel keys, so a playlist's edits keep working either way.  Plain ES5.

### `app/js/pubepg.js`

Public guide fill-in: free XMLTV guides by country, for the channels that still have no guide
after your own guide sources and the provider's.  Settings > Guide > Public guide fill-in.
Two open projects, tried in this order:
epg.pw          20 countries, lighter files, updated daily
iptv-epg.org    70 countries, bigger and more complete, updated hourly (needs the helper)
TiviBro hosts nothing and sends no logins: the TV downloads the files straight from them.  Only
channels with no guide are looked up, matched by guide id, a local station's call sign, or name.
Your guide sources and the provider's guide always come first; this only fills the gaps.
keys() runs on the page and, as source, in the guide worker (epg.js).

### `app/js/remind.js`

Reminders outside the app: hands the reminders to the helper (service/remind.js), which shows a
webOS alert at reminder time even while TiviBro is closed or another input is on, and opens
TiviBro on the channel when Watch is pressed.  Also tells the helper whether TiviBro is on
screen, so the TV never shows the same reminder twice.

### `app/js/reports.js`

Your problem reports: what happened to them.
A report sent from the TV (or from the web editor) becomes a GitHub issue opened by the report relay,
so whoever sent it gets no GitHub notifications.  The TV keeps the number and the relay's key for each,
and asks the relay now and then (GET /status, relay/worker.js) whether the developer has answered.  A
new reply, or the report being closed, shows once: what the developer said, Update when the fix is in
a newer version, Still happening (a comment with a fresh log, which reopens a closed report) and a code
to read the whole thread on a phone.  Settings > Updates, about and diagnostics > Your problem reports lists them all.
Nothing but the issue numbers leaves the TV when it asks.

### `app/js/search.js`

Search across channel names and every programme the guide has loaded, now and to come.
find() is shared with the watchlist (watch.js).

### `app/js/settings.js`

Settings.  Left/right changes a value in place, OK opens or cycles, Back goes up a page.

### `app/js/source.js`

Playlist loading.  Normalises Xtream Codes and M3U into one compact shape:
channel = { k: stable key, id, n: raw name, g: group id, logo, num, epg, url, arch }
Kept deliberately flat and short-keyed: a 20k channel list is a lot of objects on a TV.

### `app/js/store.js`

Persistence.  Settings in localStorage, big lists in IndexedDB.
Multiple playlists: each has its own id, cache and edits (groups, hidden, renames,
favorites), so switching playlists never mixes channel ids from two providers.

### `app/js/subs.js`

Subtitles for movies and shows (discussion #19): find them on OpenSubtitles.com, move them earlier or
later, and line one up with another that is already in time.
- Find subtitles: a search filled in from what's playing (title, year, season and episode), which you
can change (for a title the subtitle sites know by another name), in your subtitle languages.
The helper asks OpenSubtitles (service/subtitles.js) with the viewer's own free API key; with a free
account too, 20 downloads a day instead of 5.
- Timing: + / - 0.1 s and 1 s, for subtitles a little early or late (any track whose lines can be read),
and a frame rate fix (23.976, 24, 25 fps) for lines that drift further out of time as the film goes on.
- Line up with another track: finds the shift (and a frame-rate difference, 23.976 / 24 / 25 fps)
that best matches a subtitle already in time, like AutoSubSync does with a reference subtitle.
- Drawing: TiviBro draws the lines itself (drawLines), exactly the ones whose time includes now, and
hides the TV's own drawing of them, which could leave a line stuck on screen after its times moved.
The key and login stay on this TV (localStorage 'tivibro.subs'), out of backups and problem reports.

### `app/js/teams.js`

My teams: the sports teams you follow, gathered into one group near the top of the guide.
The group has each team's own channel (providers' "NFL TEAMS", "NHL TEAMS" groups: "NHL | DETROIT RED
WINGS") and every channel with one of its games on now or starting soon: event channels ("USA NHL 02:
WPG VS CAR @ 1:00 pm", "NBA 1 | Oct 29 7:55 PM | Atlanta Hawks Cleveland Cavaliers") and guide
programmes ("Detroit Lions at Minnesota Vikings").  Games on now come first, then the next ones, then
the team channels.  Followed teams live in the settings, so they stay when you switch playlists.
Optionally their games get reminders, through the watchlist (app/js/watch.js).

### `app/js/tidy.js`

Smart clean-up engine, shared by the TV (the first-run tour) and the web editor (Smart clean-up).
It only reads the provider's list and answers questions about it; hiding is done by the caller.
var t = Tidy.make({ groups: [...], channels: [...] });
t.countries()     groups by country, read from names, channel prefixes and divider sections
t.dividers()      "⫘⫘⫘ UK ⫘⫘⫘" groups that only split the list into sections
t.feeds()         channels that come in both an East and a West feed
t.locals()        local stations by city ("CBS | DENVER | KCNC")
t.dupes(opts)     the same channel more than once (4K, FHD, HD, SD copies)
Runs on old TVs (Chromium 53), which cannot read Unicode property classes in regular expressions, so LET below stands in for "any letter".

### `app/js/tour.js`

First-run tour: right after a new playlist connects, TiviBro walks through tidying it up and
choosing how it looks, one screen at a time, with the remote.  Every step shows what your own
playlist has (how many countries, East and West copies, local cities, event channels...), so it
only asks about what applies.  Nothing changes until the last screen; a restore point is saved
first, so the web editor can undo it.  Run it again any time from Settings > Playlists >
the playlist > Set-up tour.
Steps: welcome, countries, channel names, East and West, local channels, event channels, clutter,
logos, look, and a summary with tips.

### `app/js/ui.js`

Key routing and dialogs.
Keys are delivered as small action names so views never deal with keycodes.
OK is split into a tap ("ok") and a hold ("hold", 600ms) for the options menu.

### `app/js/update.js`

Updates: checks GitHub releases for a newer TiviBro and, on rooted TVs with the Homebrew
Channel, installs it in place through luna://org.webosbrew.hbchannel.service/install (the
Homebrew Channel downloads the IPK, checks its SHA-256 and installs it).  Other TVs see the new
version and how to install it from a computer.
Release data comes from the GitHub API through the network helper; the IPK hash comes from the
Homebrew manifest that every release carries (scripts/gen-manifest.js).

### `app/js/util.js`

Shared helpers.  Plain ES2018 so it runs on webOS 6 / Chromium 79 and up.

### `app/js/views.js`

Live, Fullscreen and Guide views.
Speed rules used throughout:
- Focus changes repaint at most two rows; the preview starts only after the
remote has rested on a channel (450ms), so scrolling never spins up streams.
- Zapping in fullscreen updates the OSD instantly but commits the stream after
300ms, so CH+ CH+ CH+ opens one stream, not three.
- Guide data is requested only for rows on screen.

### `app/js/vlist.js`

Virtual list: a fixed pool of row elements repositioned with transforms.
A 20,000 channel group costs the same DOM as a 12 channel one.

### `app/js/vod.js`

Movies and Shows (Xtream VOD).  Lists are cached per playlist like live channels;
details load only for the title you rest on.  Files play through the TV's own player,
which seeks well and handles MKV/MP4 without any JS demuxing.

### `app/js/vodview.js`

Movie and show languages and what shows, shared by the app (vod.js) and the helper (service/vod.js,
which keeps the full lists on the TV's storage and hands the app only what is shown).  Plain ES5.

### `app/js/watch.js`

Watchlist: words and names to look for in the guide ("Yankees", "Bluey", "Gordon Ramsay").
Every few minutes, and whenever the guide loads, programmes coming up in the next hours whose
title has one of them as a whole word get a reminder (or just a message, Settings > Guide,
start screen, reminders > Watchlist).  Each programme is handled once: a reminder you cancel
does not come back.  Lives in the playlist's edits, so a backup and the web editor carry it.

### `app/js/webedit.js`

Edit on a phone or computer.
Starts the web editor inside the network helper (service/editor.js) on the TV's network
address, protected by a random password shown on the TV.  While it runs, the app:
- sends the helper a snapshot of the channel list, guide matching and the user's edits
(no provider login or stream links; playlists show their host name only), and again
whenever something changes on the TV;
- checks every 2 seconds for edits saved in the browser and applies them;
- runs actions the browser asks for (add or change playlists, guide sources, backup,
restore) and reports back.
It is off at every app start; the user switches it on in Settings when needed.  A page
reload while it runs (restore does that) picks the running editor back up.


## The network helper: every file (service)

### `service/blewake.js`

Turning the TV on over Bluetooth (Wake on BLE), the way LG's ThinQ app does it.
While the TV is on, a paired phone registers a made-up Bluetooth address of its own with the TV's
asc service (luna://com.webos.service.asc/registerDevice, type "ThinQ").  When the TV is off and
"Bluetooth advertising" (bleAdvertisingOnOff, network settings) is on, the TV listens for that
address in an LG advertisement from the phone and turns itself on.  This module only does the TV
side: registering, checking and removing a phone's address.  The phone sends the advertisement.
Answered by the helper itself (POST /v1/blewake in companion.js), so TiviBro may be closed.
Plain ES5 and callbacks for the old Node on webOS.

### `service/companion.js`

TiviBro phone app connection (the "companion").
Lets the TiviBro phone app find this TV on the network, pair with it once, and then wake TiviBro,
open the web editor full screen and (later) act as a remote, even while TiviBro is closed.

Off until the user switches it on in TiviBro (Settings, Phone app).  While on, the helper keeps
running in the background with a small LAN server on port 47810 (TCP) and answers discovery on
47810 (UDP).

Security, with no certificates on a plain-http LAN:
- Pairing needs the TV in hand: TiviBro shows a one-time code (and a QR code carrying it) for ten
minutes.  The code itself never crosses the network.  The phone proves it knows it with an HMAC
under a key stretched from the code (PBKDF2-SHA256, 100,000 rounds, salted with this TV's id);
the TV answers with a fresh random key for that phone, encrypted under the same stretched key
(AES-256-GCM), plus its own HMAC proof, so each side knows the other saw the code.  Someone
listening on the network learns nothing they can use; five wrong tries close the pairing.
- After that every request is signed with the phone's key (HMAC-SHA256 over the method, path,
time, a random nonce and the body), checked against the TV's clock and a nonce list (no replay),
and every answer is signed back, so the phone can tell a forged or altered answer.
- Phones are listed in TiviBro and can be removed there; a removed phone's key stops working at
once.  Keys stay in companion.json next to the helper, never in a backup.
Questions that need TiviBro itself (the guide, Movies and Shows, favorites, reminders, typing into the
TV, adding a playlist, backups) go through POST /v1/ask: queued for TiviBro like commands, answered on
/companion/answer.  Logins and backups only travel sealed (AES-256-GCM under the phone's key).
See docs/wiki/Phone-app-protocol.md.  Plain ES5 and callbacks for the old Node on webOS (compat.js).

### `service/compat.js`

Fills in the few Node.js features the helper uses that old webOS versions lack.
Node by webOS TV version: 4.x and older 0.12, 5.x and 6.x 8.12, 22 and 23 12.x, 24 and 25 16.x,
26 20.x.  Required first by service.js and proxy.js; does nothing on Node 4.5 and newer.
Plain ES5: no let/const, arrow functions or other ES2015 syntax (Node 0.12 can't parse them).

### `service/convert.js`

Converting sound the TV can't play (DTS on 2020 to 2022 LG TVs, Dolby TrueHD, surround FLAC) to
Dolby Digital (AC-3), with the bundled ffmpeg (ffmpeg.js).  Video is copied, never decoded.

Routes (proxy.js):
GET  /convert/key                 (TiviBro's secret) { key }: the key the app puts in conversion links
GET  /convert/probe?u=<url>[&ua=] (TiviBro's secret) what the file holds: audio tracks (codec,
channels, language, default), video codec, duration.  Kept per url.
GET  /convert/<key>/movie.mkv?u=<url>&t=<seconds>&a=<audio track>[&ua=<agent>][&in=pipe]
GET  /convert/<key>/movie.ts?...  the converted stream, for the TV's own player (MKV) or the
built-in player (MPEG-TS, live channels).  No Content-Length, not
byte-seekable: a seek is a new request with another t.
The <video> element can't send TiviBro's secret, so conversion links carry a key instead: random per
helper start and handed only to TiviBro (over the secret-protected /convert/key).  Other apps on the
TV or the network can't use the converter without it.

How ffmpeg reads the movie: through the helper's own relay (http://127.0.0.1:<port>/ua/.../x/...), so
the playlist's User-Agent, redirects, odd provider replies and certificates are handled exactly as for
every other stream, and ffmpeg itself only ever talks plain http to this TV.  Range requests pass
through, so a seek doesn't download the movie up to that point.  &in=pipe instead has the helper
fetch the movie itself and feed it to ffmpeg's stdin (no seeking in the source: kept for servers that
refuse Range).  A movie link that is already the helper's (My media) is read as it is.

One conversion at a time: a new one stops the last (TERM, then KILL after 2 s).  ffmpeg runs under
"nice -n 10" when the TV has nice, so the TV stays responsive.  Its output goes through a spool on the
TV's storage that the player can come back to (Session, below).
Plain ES5 and callbacks for Node 0.12 (webOS 4).

### `service/dropbox.js`

My media from Dropbox, helper side.  The phone app signs in to Dropbox (PKCE, no app secret) and
hands the TV only a refresh token, kept with the source like a WebDAV password.  This module turns
it into short-lived access tokens (cached per token, renewed when under 5 minutes are left), lists
folders, and asks for the temporary links movies play from (4 hours; reused for 3.5).

Secrets: the refresh token, the access tokens and the temporary links are never logged or put in
an answer other than the one that needs them (a link goes to the player).  Caches are keyed by a
hash of the refresh token, never the token itself.

Errors are kinds media.js turns into words: login (Dropbox refused the sign-in), unreachable,
cert, address (the folder or file is not there).  A 429 waits Retry-After (at most 30 s) and tries
again, three times at most.  Calls time out after 20 s.  Certificates are always checked; an old
TV that can't check one tries once more with the chain in media-ca.js, like the rest of My media.
TIVIBRO_DROPBOX_API and TIVIBRO_DROPBOX_CONTENT point the calls at a mock for tests.
Plain ES5 for Node 0.12 on webOS 4 (see compat.js).

### `service/editor.js`

TiviBro web editor server.
Lets someone edit groups, channels and name rules from a browser on a computer on the same
network, at http://<TV address>:<port>, protected by a password shown on the TV.

The app stays the source of truth:
- The app (on the TV) sends a snapshot of its playlist and edits over localhost
(POST /editor/snapshot on the relay) and polls for changes (GET /editor/pending).
- The browser logs in (POST /api/login), reads the snapshot (GET /api/state), and saves its
edited copy (POST /api/save).  The app applies it and sends a fresh snapshot.
Only editing data travels: group and channel names, logos and the user's edits.  Never the
provider username, password or stream links (playlists show their host name only).
Actions the app must carry out (add or change a playlist, guide sources, backup, restore)
go through a small command queue: the browser posts a command, the app picks it up with
its next check, runs it, and posts the result, which the browser polls for.  A backup is
the one thing that contains logins, and only when the user asks to download it.

The LAN server only runs while the user has switched it on in the app, and stops with the
app.  Logins are rate limited per address.  Plain ES5 for the old Node on webOS TVs (see compat.js).

### `service/ffmpeg.js`

The audio converter: a tiny static ffmpeg (docs/FFMPEG.md, scripts/ffmpeg/build.sh).  Release builds of
TiviBro carry it (service/ffmpeg-bundle/, put there by scripts/ffmpeg/bundle.sh): the helper unpacks it
onto the TV's storage a few seconds after it starts, with no download and nothing to ask.  A build
without it downloads the same file the first time a movie needs it, or when the viewer asks in
Settings > Playback.

Where it lives: Storage.sub('bin'), on the TV's big app storage (allows running programs, nosuid),
named with its version (ffmpeg-7.1-tb1-armv7).  Never in memory (/tmp): if the helper's storage is
only memory, there is no converter.  Older versions are removed when a new one is in place.

The bundled .gz or the download: the .gz for this TV's userland from the GitHub release below, checked against the
SHA-256 written here (never run anything whose hash doesn't match), unpacked with Node's zlib,
chmod 700, and tried with -version before it counts as ready.

Which build: armv7 unless the helper's own Node is a 64-bit ARM program.  A 2022 C2 has an aarch64
kernel (uname -m says so) but a 32-bit userland, and its Node is 32-bit.

TIVIBRO_FFMPEG=<path> uses that program instead (tests, a computer running the dev helper).

Routes (proxy.js, behind TiviBro's secret):
GET  /ffmpeg/status    { state: none | downloading | ready | failed, percent, version, size, where, error, bundled }
POST /ffmpeg/get       start the download (answers at once with the status)
POST /ffmpeg/remove    delete it
Plain ES5 and callbacks for Node 0.12 (webOS 4).

### `service/grant.js`

TiviBro's helper asks for three permissions it is refused in developer mode (service/grant.sh says
which and why).  On a rooted TV the Homebrew Channel can run grant.sh as root; the helper does that
once when TiviBro opens, if the TV's manifest for TiviBro doesn't list the extra permissions yet
(an in-app update writes it afresh), or an older grant lacks a group added since.  The groups apply to the helper from its next start.
Not rooted, or the manifest can't be read: nothing happens, and the helper keeps its fallbacks
(a reminder shows even if the screensaver is on).

### `service/guide.js`

Big guides, read and kept by the helper instead of the app.
webOS closes an app that passes roughly 500 MB, and a full XMLTV guide on top of a provider's can
take more than that while it is parsed (a 64 MB iptv-epg.org file did on a 2 GB C2).  The helper is
a separate process, so here the guide is streamed, trimmed to the playlist's channels and the
time window, and written to the TV's storage.  The app then reads back only:
titles   every programme's start, end and title (small: search, the watchlist and the grid),
desc     one channel's descriptions when it shows them,
meta     the guide's channel list, icons, name matches and how each source went.
The matching is the same as the app's worker (epg.js workerMain): guide ids, names, and for the
public guide pass (pubepg.js) call signs and names by country.  Keep the two in step.

Routes (on the relay, 127.0.0.1 only):
POST /guide/load            { pl, kind: 'main'|'pub', sig, msg }   starts a load (replaces one running)
GET  /guide/status?pl&kind  progress, or what is stored
GET  /guide/meta?pl&kind    GET /guide/titles?pl&kind    GET /guide/desc?pl&kind&id
GET  /guide/find?pl&kind&q=["words"]&max   programmes whose description has one of the words
Downloads go through the relay itself (msg.sources[].url are relay addresses with /unz/<id>), so
custom User-Agents, unzipping, the DNS cache and the lenient parser all apply.
Plain ES5 and callbacks for Node 0.12 (webOS 4).

### `service/kv.js`

Big things TiviBro keeps, on the TV's storage instead of the browser's.
An old TV gives the web app only a small share of its storage (issue #18: a 51,000-channel list
on a 2019 C9 did not fit), so the playlist itself is kept here, beside the guide, and the app reads
it back when it starts.  Only TiviBro can use these: they hold stream addresses with the login, so
proxy.js checks TiviBro's secret on them like the web editor's and the phone app's controls.

Routes (on the relay, 127.0.0.1 only, with TiviBro's secret):
GET  /kv/get?k=<key>     the stored JSON, or 404
POST /kv/put?k=<key>     body: the JSON to keep (written to a new file, then swapped in)
POST /kv/put?k=<key>&from=<id>   body: a few fields; the channel list comes from live.js (keep)
POST /kv/del?k=<key>
Keys: letters, digits, ":", "_" and "-", up to 80.  Plain ES5 and callbacks for Node 0.12 (webOS 4).

### `service/live.js`

Live channel lists, downloaded and shaped by the helper instead of the app.
A big provider's get_live_streams answer (or M3U file) is tens of MB; parsed in the app it costs
several times that for a moment (a 180,000-channel list took the app to about 270 MB while it
loaded, and webOS closes an app at about 500 MB).  The helper is a separate process: it downloads
the list through its own relay, shapes it with the app's own code (app/js/plshape.js, so channel
keys and every edit stay the same) and hands the app only the channel list it keeps.

Routes (on the relay, 127.0.0.1 only, with TiviBro's secret like /kv/):
POST /live/xtream  { u: <relayed get_live_streams address>, uc: <relayed get_live_categories> }
-> { groups, channels, n, cats }
POST /live/m3u     { u: <relayed playlist address> }
-> { groups, channels, epgUrl, bytes, vod: { movies, series } }, or 422 { kind: 'parse' } when it isn't an M3U playlist
Either with keep: true also answers live: <id>, and the answer is kept for /kv/put?from=<id> (kv.js).
Any other failure answers 502 and the app loads the list itself, as before.
Plain ES5 and callbacks for Node 0.12 (webOS 4).

### `service/media-ca.js`

Certificates My media tries when a WebDAV server's can't be checked with the TV's own list, one per
entry (Node 0.12 reads only the first from a joined string).  Some servers send only their own
certificate, and old TVs' Node predates these, so Let's Encrypt's chain up to the ISRG roots is carried
here (service/media.js tries it once after a certificate error, for any server).  The short-lived YE1
intermediate rotates: if a scan says the certificate could not be checked, update it here.  Plain ES5.

### `service/media-catalog.js`

My media catalogue, helper side: files, shows and episodes, and every change to them.

Storage is media-library.js (small JSON files, see there).  This file holds the rules:
- One queue runs every read and change in order (serial).  A job that throws or fails gives
the queue back and drops what it had in memory, so the next job reads the files again.
- One revision counts changes to the catalogue.  Lists and edits carry it, so a stale list or
edit is refused instead of overwriting newer work.  Playback progress is not part of it: it
is saved on its own (at most once a minute while something plays, and when playback stops),
so watching never invalidates lists or the web editor's edits.
- Work is per show: a show's episodes and the files they play from live in that show's own
file.  A change reads and writes only the shows and sources it touches (apply, below), and a
list reads one file at a time and keeps only the page it returns.
- Private locators (paths, links, logins) never leave through project(): only file() hands a
whole record to media.js, to play it.
Plain ES5 with callbacks for Node 0.12 (webOS 4).

### `service/media-chapter-request.js`

Strict binary Range transport for already-authorised media sessions.  Never follows redirects
with source credentials and never accepts a full video body as a chapter response.  ES5.

### `service/media-chapters.js`

Bounded physical chapter readers.  MKV simple editions and MP4 Nero chpl markers are independent
of subtitle extraction.  Unknown virtual timelines never become invented episode boundaries.
ES5 / Node 0.12; readers consume bounded buffers provided by an authenticated Range session.

### `service/media-classify.js`

Local media evidence classifier.  No network or storage: observations and saved rules go in,
typed candidates and review reasons come out.  ES5 for Node 0.12.  Unknown is never season 1.

### `service/media-corrections.js`

My media corrections: a preview, then a commit; folder rules saved with their source; undo.
A preview covers at most PREVIEW_CAP files and only the files it names are read again on commit.
Previews live in memory for 15 minutes (at most 4), undos in undo.json (at most 10, 30 days).
ES5 / Node 0.12.

### `service/media-fast-edit.js`

Small My media edits: watched, favourite, preferred version, matches, season details, episode
ranges, and correcting one file.  Each reads and writes only the show or source file that holds the
item (media-catalog.js), never the whole library.  ES5 / Node 0.12.

### `service/media-grouping.js`

Show merge and split, episode orders, and relinking a missing file.  History follows the physical
files; nothing guesses a rename from equal names or sizes.  Each operation reads only the shows and
files it names (media-catalog.js apply).  ES5 / Node 0.12.

### `service/media-import.js`

A source scan into the catalogue.  The scan's own list is the only large thing in memory: the
source's existing files are read one shard at a time, once, as each shard is rewritten.
The scan list is never changed, so a failed import can be retried with the same scan.
Existing records keep the viewer's overlays (corrections, matches, favourites, ranges, first-seen
time); a full scan marks files it no longer found as missing (a partial or empty one never does).
A missing file is hidden (not in Needs review, not an episode) but kept with everything the viewer
set on it, because a server can list less for a while without an error (a DLNA server re-indexing,
an unmounted disk, a listing cut short).  It comes back as it was, or leaves after MISSING_DAYS.
ES5 / Node 0.12.

### `service/media-legacy-reader.js`

Pull one bounded page from the legacy JSON array.  UTF-8 decoding is delegated to the stream;
braces inside strings do not delimit records.  Node 0.12-compatible backpressure.

### `service/media-library.js`

My media storage: small JSON files in one folder, read when needed and kept in memory only briefly.

Why this shape: the helper runs on small TVs (webOS kills it around 500 MB), and flash storage
wears out with frequent rewrites of big files.  So the library is split into many small files and
nothing reads the whole library at once:
catalog.json        the index: revision, sources, saved command ids, row counts per file
shows.json          every show's own record (title, match, favourite, order)
handles.json        which file holds each media file (file handle -> shard name)
progress.json       playback positions (outside the revision, see media-catalog.js)
undo.json           the last few correction undos
src_<hash>.json     one per source: its movies, extras and files that need review, and its folder rules
show_<hex>.json     one per show: its episodes, the files they play from, season details, merges
Each file is written whole to a temporary file and renamed over the old one (atomic on the TV's
file system), and only when its content changed (a digest of each file's last known content is
kept, a few bytes per file).  A read keeps the parsed file in a memo for a minute after its last
use, and the memo is capped by size; work that goes through many files (an import, a list of every
file) reads them "passing", without filling the memo.

Plain ES5 with callbacks for Node 0.12 (webOS 4).

### `service/media-metadata-cache.js`

Bounded metadata request scheduling and cache.  Entries are kept per TMDB credential (a hash of
it, never the credential itself), so one viewer's token never answers for another's; the key never
holds the credential either.  Injected transports, clock and timers keep locale caching and
rate-limit behaviour testable.  ES5 / Node 0.12.

### `service/media-metadata.js`

TMDB movie details.  The app sends the viewer's locally stored TMDB credential per request.  Only title/year or a chosen movie ID and locale leave
the TV.  Provider logins and playback links never reach TMDB.  ES5 / Node 0.12.

### `service/media-migrate.js`

Movies saved before the TV-show library (kv media:lib) into the catalogue, once.
The old list is copied to a snapshot first and read from the copy in pages of 256, so the app can
keep writing its list meanwhile.  Until a migration succeeds the snapshot is copied again on each
try, so a retry never migrates an older list.  Files already in the catalogue win on the same id.
App credentials are never part of these records.  ES5 / Node 0.12.

### `service/media-quicktime-chapters.js`

QuickTime chapter text tracks referenced by an enabled track's tref/chap.  Physical timing only:
unsupported edit lists return a manual-timing fallback.  ES5 / Node 0.12.

### `service/media-scan-jobs.js`

Where a paused My media scan goes on from, saved in the helper's own storage (never sent anywhere).
A checkpoint belongs to one source as it was set up: its fingerprint is an HMAC, with a random key
made once on this TV, of the address, user name and scan settings.  The password is not part of it
and is never saved.  Checkpoints are capped (CAP_ITEMS records, CAP_BYTES on disk): a bigger one is
not saved, and an unreadable or oversized one is ignored, so the next scan simply starts fresh.
ES5 / Node 0.12.

### `service/media-stream.js`

Authenticated WebDAV files, streamed through localhost with Range/HEAD support.
The player gets a random, temporary address; credentials stay in bounded memory.
Redirects keep the login only on its original origin.  TLS is always verified,
including the bundled root fallback needed by Node 0.12 on older TVs.

### `service/media.js`

My media, helper side.  Finds and reads the viewer's own movie servers: DLNA (like a Fritz!Box, no
login), WebDAV (like premiumize, with a login) and Dropbox (signed in from the phone app, see
dropbox.js).  The app asks over /media/ routes, which need the
helper's secret because a login goes through them (see proxy.js).

Three things stay separate on purpose: a movie's id (from its stable path), its container, and the
address it plays from.  At every play, WebDAV gives either a fresh signed link or
a temporary localhost stream whose login stays in the helper (media-stream.js), and Dropbox a
temporary link (Range works on it, so the player reads it directly).

Logs name hosts only.  A login goes only to the host it was entered for, never after a redirect to
another one, and certificates are always checked for WebDAV.
TV shows (contentMode automatic or shows) go into the catalogue (media-catalog.js).  A movies-only
scan never needs it, nor a checkpoint: if either cannot be read the scan still answers.  A show
scan for a source not saved yet waits for /media/library/adopt (at most two, five minutes) and is
only dropped once imported, so a failed adopt can be retried.
Plain ES5 for Node 0.12 on webOS 4 (see compat.js).

### `service/pace.js`

Pacing image downloads after a host says "too many requests" (HTTP 429).  Channel logos are often all on
one free host (imgur, for example), and a guide full of them asks for dozens at once: the host then turns
most of them away and those logos stay blank (issue #45).  After a 429 from an image, the helper waits as
long as the host asked (Retry-After, 2 to 60 seconds, 10 when it doesn't say), asks for that image once
more, and for a minute after that sends the host's images one at a time, a little apart, instead of all at
once.  Streams, playlists and guides are never held back: only image addresses are paced.

### `service/proxy.js`

TiviBro network helper: a tiny localhost HTTP relay.
webOS enforces CORS on installed apps, and most IPTV providers send no CORS headers, so the
app sends its provider requests here instead and this relay adds the headers.

URL scheme (path style, so relative links inside HLS playlists keep resolving):
http://127.0.0.1:PORT/x/<http|https>/<host[:port]>/<path>?<query>
Optional custom User-Agent for providers that require one (TiviMate's "User-Agent" setting):
http://127.0.0.1:PORT/ua/<base64url of the agent>/x/<http|https>/<host>/<path>
The prefix sits in front, so relative links inside HLS playlists keep it automatically.
Upstream redirects are returned as redirects to the relayed form of the new address, so the
browser always knows the real location.  Bodies are streamed, never buffered, and Range
requests pass through for seeking.  Plain ES5 for the old Node versions on webOS (see compat.js).

### `service/rawhttp.js`

A forgiving HTTP/1.x client, used only when Node's own parser refuses a provider's reply.
Node's parser rejects replies that cheap IPTV panels really send: a stray control character in
a header (refused by Node 8, webOS 5 and 6, which has no lenient option), Content-Length sent
twice (refused by Node 12 and newer, webOS 22 on, even in lenient mode) and bare LF line
endings.  When that happens the helper asks again through this reader, which:
- accepts CRLF or bare LF line endings,
- drops control characters from header values,
- keeps a repeated Content-Length when the copies agree, and otherwise reads to the end,
- undoes chunked transfer encoding,
and hands back a readable stream with statusCode and headers, like http.IncomingMessage.
One request per connection (Connection: close), so a body without a length ends with the
connection.  Plain ES5 for Node 0.12 (webOS 4).

### `service/remind.js`

Reminders that work while TiviBro is closed.
The app sends its reminders (per playlist) whenever they change.  For each one the helper asks
webOS's activity manager to call it back at reminder time ("fire"), which starts the helper even
when nothing of TiviBro is running.  At that moment it shows a webOS alert (Watch / Dismiss)
over whatever is on screen, or a toast if the TV refuses alerts; Watch or the toast opens
TiviBro straight on the channel.  If TiviBro is open and on screen, it shows its own question
instead and the helper stays quiet.
The list is also kept in a small file, so a reminder removed in the app never fires; the
callback carries everything needed in case that file can't be written.

### `service/service.js`

webOS JS service wrapper for the relay.  The app subscribes to "start"; while that
subscription is open (and an activity holds the service), the relay keeps running.
When the app closes, the subscription drops and the service shuts down on its own, unless the
phone app connection is on (companion.js): then the helper stays, with only that LAN server
running, and an activity every 5 minutes brings it back after the TV restarts or webOS stops it.
install.sh also adds one that isn't part of the package (an update removes the package's own); the
wake call below ends either for good once the phone app connection is off.

### `service/storage.js`

Where the helper keeps big things on the TV: playlists, movie and show lists, My media and the guide.
One folder for all of it, chosen once:
- on the TV's storage that survives a TiviBro update (the helper's own folder is replaced by every
update, which used to throw the guide away each time), and on the partition with the most free
room.  On recent LG TVs /media/internal and /media/developer sit on app storage (/mnt/lg/appstore,
2.7 GB on a C2), not on user data (720 MB, shared with webOS); older TVs may differ, so the free
space decides;
- sticky: a folder that already holds TiviBro's data keeps being used, so nothing moves around;
- private: /media/internal is shared into other apps' jails (YouTube, Netflix, the browser...), and
playlists hold logins, so everything here is readable by the helper only (umask 077, and files
left by older versions are locked down at start).
Falls back to the helper's own folder, then to memory (/tmp), which vanishes with the TV.
TIVIBRO_KV_DIR overrides it (tests and the dev relay).  Plain ES5 for Node 0.12 (webOS 4).

### `service/subtitles.js`

Subtitles from OpenSubtitles.com for what's playing (discussion #19).  The app sends the viewer's own
API key (and login token, if they signed in) with each request, the way it sends the TMDB token;
only the search words (title, year, season and episode) and the chosen file id leave the TV.
POST /media/subs/search   { key, token, query, year, season, episode, type, langs: ['en','ea'] }
-> { results: [{ id, lang, release, downloads, hi, fps, ai, trusted, title }] } | { error, message }
POST /media/subs/get      { key, token, id } -> { text, name, remaining, reset } | { error, message }
POST /media/subs/login    { key, user, pass } -> { token, allowed, level } | { error, message }
OpenSubtitles asks for its query parameters sorted and lower case (it redirects otherwise), and an
app name in the User-Agent.  Without a login it allows 5 downloads a day per address; with a free
account, 20.  ES5 / Node 0.12.

### `service/tssync.js`

MPEG-TS resync for live streams passing through the helper.
A TS stream is 188-byte packets, each starting with 0x47.  When a provider's stream picks up
stray bytes (a restreamer hiccup, a broken proxy, a half-written packet after a reconnect
upstream), every packet after them is misaligned and the app's demuxer stops decoding until
the channel is reconnected.  This filter keeps the output aligned: it passes whole packets,
and when the 0x47 rhythm breaks it drops bytes until three packets in a row line up again.
If no TS rhythm is found at all in the first 64 KB, the data is passed through untouched (an
error page, or a different packet size), so nothing is ever made worse.
Plain ES5 for Node 0.12 (webOS 4).

### `service/unzip.js`

Unzipping guide downloads in the helper, so every TV can read .xml.gz guides.
Browsers on webOS 6 and older (Chromium < 80) have no DecompressionStream, and even where it
exists, Node's zlib is faster and keeps the work off the app.  The app asks for it with the
/unz/<id> relay prefix; the helper then:
- asks the server for gzip or deflate and undoes whatever Content-Encoding comes back,
- looks at the first bytes and unzips gzip data however it is labelled (.gz files served as
application/octet-stream, xmltv.php answers that are gzipped without saying so, and files
that were zipped twice: a .gz file sent with Content-Encoding: gzip),
- passes anything else through untouched,
- counts the compressed bytes per <id>, readable at /xfer/<id>, for real download progress.
Plain ES5 for Node 0.12 (webOS 4).  Uses only zlib and stream, which every webOS Node has.

### `service/vod.js`

Movie and show lists, slimmed by the helper instead of the app.
A big provider's get_vod_streams answer can be 60 MB or more (160,000 movies, each with a dozen
fields the lists never use).  Parsing that in the app costs several times its size for a moment,
on top of the live channels, and webOS closes an app that passes roughly 500 MB.  The helper is a
separate process, so it downloads and parses the list here and hands the app only what the Movies
and Shows screens show, as compact rows.

Route (on the relay, 127.0.0.1 only):
POST /vod/slim   { kind: 'movie'|'series', u: <relayed player_api address> }
-> { v: 1, kind, n, rows }   movie  rows: [id, name, cover, rating, ext, cat, added]
series rows: [id, name, cover, plot, rating, year, cat, added]
POST /vod/list   { pl, sig, kind, u, uc, maxAge, refresh, picked, navLang, hidden }
-> { v: 2, kind, at, cats, langs, keep, total, adultCat, rows }
The full list (u: the titles, uc: the categories) is kept on the TV's storage, not in the app,
so an old TV's small browser storage never fills up and the app never holds every language.
Each call answers from the copy kept here (memory for a few minutes, then the file) until it
is maxAge old (0: until refresh), and returns only what shows: the languages picked, without
hidden categories (vodview.js, shared with the app).  Rows are the slim rows plus
[lang, q (4K), adult, p (the code taken off the name)].
POST /vod/list   { pl, sig, kind, m3u: 1, picked, navLang, hidden }
An M3U playlist's movies and shows: the same answer, from what the helper kept when it read the
list (saveM3u, called by live.js).  Nothing is downloaded here.
POST /vod/m3u    { pl, op: 'series', id }  -> the show's episodes, shaped like Xtream's get_series_info
{ pl, op: 'url', id }     -> { url } for a movie
The address goes in the body, not the query, so it never shows in a log line.  It must be a relay
address on this TV, so the download gets the playlist's User-Agent and the relay's DNS cache.
Plain ES5 and callbacks for Node 0.12 (webOS 4).


## The web editor

`service/editor/index.html` is the whole editor: HTML, CSS and one inline script.  It loads `names.js`, `tidy.js`, `pubepg.js`, `links.js` and `events.js` (build copies of the `app/js/` files).  Pages are `<section class="page" id="p-NAME">`; `show(name)` switches them.  Changes are drafts sent with `/api/save`; actions go through `command(type, args)` to the TV's `RUN` table in `app/js/webedit.js`.

## The Android app (android)

### `android/app/src/main/java/app/tivibro/phone/MainActivity.kt`

The app's single activity: opens pairing links (tivibro://pair and https://tivibro.app/pair from the TV's
QR code), catches the Dropbox sign-in's return (https://tivibro.app/dropbox, or tivibro://dropbox from that
page's button) and shows the Dropbox folder picker, takes a provider's link shared to TiviBro (Share, then
TiviBro), runs the code scanner, asks for the Bluetooth permission the first time Turn on is used (to wake the
TV over Bluetooth, ble/BleWaker.kt), starts the weekly backups, and moves between the screens (home, add a TV, type the code, one TV on any of its
tabs, the editor on any of its pages, adding a shared playlist, backups and restore points, reminders).

### `android/app/src/main/java/app/tivibro/phone/ble/BleWaker.kt`

Turning the TV on over Bluetooth (Wake on BLE), as LG's ThinQ app does.  The phone keeps a made-up
Bluetooth address of its own (made once per install), registered with each TV while it is on
(POST /v1/blewake, service/blewake.js).  To wake a TV that is off, the phone advertises LG's wake packet
(proto/BleWake.kt: the phone's address and the TV's) for up to a minute, next to Wake-on-LAN.
Android 12 and later need the "Nearby devices" permission (BLUETOOTH_ADVERTISE), asked the first time
Turn on is used; without it, or with Bluetooth off, only Wake-on-LAN runs.

### `android/app/src/main/java/app/tivibro/phone/ble/BleWatcher.kt`

Watching for the TV over Bluetooth while the phone wakes it, as LG's ThinQ app does: an LG TV advertises
itself (service 0000feb9, proto/BleWake.kt reads it), so the phone can tell it came on even when the phone
isn't on Wi-Fi.  Android 12 and later only: scanning needs the Nearby devices permission
(BLUETOOTH_SCAN, declared never used for location).  Older Android would need the location permission,
so it doesn't scan and relies on Wi-Fi as before.

### `android/app/src/main/java/app/tivibro/phone/ble/WakeLog.kt`

The Bluetooth log of the last Turn on (or Listen for the TV): what the phone sent and what it heard from the
TV, second by second, so a wake that didn't show up can be looked into.  Only the last one is kept, on the
phone; the TV card's menu shows it, with Share.  It holds no secrets: the TV's name, the phone's model and
what the TV advertises to anyone nearby.

### `android/app/src/main/java/app/tivibro/phone/data/BackupStore.kt`

Backups of each TV's TiviBro, kept on the phone: made by hand or automatically once a week (while
TiviBro is open on the TV, see work/BackupWorker.kt).  A backup holds every playlist login, so each one
is encrypted with a key in the Android Keystore and kept in app storage that is left out of backups
and device transfers.  The newest eight automatic ones per TV are kept; ones made by hand, copies,
merges and edits made on the phone (BackupTools.kt) stay until deleted.  Each can have a name.

### `android/app/src/main/java/app/tivibro/phone/data/BackupTools.kt`

Working on backups on the phone, without the TV (discussion #23): merging two, changing a few settings
inside one, and putting edits made in the web editor on the phone (OfflineEditorScreen.kt) back into
one.  The backup is the TV's own format (app/js/webedit.js, Backup.create): settings with every
playlist, each playlist's edits by id, and its restore points.  Everything here makes a new copy;
the original backup is never changed.

### `android/app/src/main/java/app/tivibro/phone/data/EditorCopies.kt`

The channel list the web editor showed, kept on the phone so a backup can be edited without the TV
(OfflineEditorScreen.kt, discussion #23).  Each time the editor opens with a TV (EditorScreen.kt), it
hands over what it loaded: the playlist's groups and channels (names, logos, numbers, guide ids) and
the guide's channel names.  It holds no logins and no stream links (service/editor.js keeps those on
the TV).  One copy per playlist, the newest.
It arrives in pieces (begin, part, end): the editor gzips it and sends a few hundred KB at a time, so a
list of 186,000 channels gets through too, and is written to storage as it comes, never held whole.

### `android/app/src/main/java/app/tivibro/phone/data/TvStore.kt`

Paired TVs and their keys, kept in the app's private storage and encrypted with a key in the Android
Keystore, so they never leave the phone (excluded from backups too).

### `android/app/src/main/java/app/tivibro/phone/proto/AppUpdate.kt`

Is there a newer TiviBro phone app?  The GitHub releases list, the newest one carrying an APK, compared
with this app's version (issue #33: nothing said a new one was out).  Test builds (0.1.0-dev) never ask.
Also fetches that APK for an update in place (update/Updater.kt installs it): https only, with its size
and, when GitHub gives one, its SHA-256 checked before anything is installed.

### `android/app/src/main/java/app/tivibro/phone/proto/Ask.kt`

What TiviBro on the TV answers to the phone's questions (POST /v1/ask, see app/js/phone.js): the
guide a page of channels at a time, one channel's schedule, Movies and Shows (categories, titles,
details with seasons and episodes).  Plain JVM code, read from the TV's JSON.

### `android/app/src/main/java/app/tivibro/phone/proto/BackupFile.kt`

Backup files the web editor can read: a TiviBro backup encrypted with a passphrase the user picks,
in the same format as the editor's Download a backup (service/editor.js): AES-256-GCM with a key from
PBKDF2-SHA256, 200,000 rounds.  The phone saves a copy this way so it can be restored on any TV, with
or without the app.

### `android/app/src/main/java/app/tivibro/phone/proto/BleWake.kt`

Turning the TV on over Bluetooth (Wake on BLE), as LG's ThinQ app does: the bytes of the advertisement
and the phone's made-up Bluetooth address.  Plain JVM code, unit tested.  The advertising itself is in
ble/BleWaker.kt, the TV side in service/blewake.js.

### `android/app/src/main/java/app/tivibro/phone/proto/Dropbox.kt`

Dropbox sign-in for My media: authorization code with PKCE (no app secret anywhere).  The phone opens
Dropbox's sign-in page, catches the redirect to https://tivibro.app/dropbox (or tivibro://dropbox from
that page's button), swaps the code for a refresh token here, and hands only the refresh token to the TV
(ask "mymedia", ops dbfolders and adddropbox, sealed).  The TV never sees the code or the verifier.
Nothing here is logged: the code, the verifier and the token are secrets.

### `android/app/src/main/java/app/tivibro/phone/proto/Links.kt`

A provider's link shared to the app (from an email, a message or a browser), read the way the TV
reads it (app/js/links.js), so the app can say what it found before sending it to the TV:
http://host:8080/get.php?username=a&password=b&type=m3u_plus  ->  Xtream Codes, host:8080, user a
Any link with a username and password is an Xtream Codes login (get.php, player_api.php, xmltv.php,
panel_api.php, or a /live/user/pass/1.ts stream link); anything else is an M3U playlist address.

### `android/app/src/main/java/app/tivibro/phone/proto/Net.kt`

Network: HTTP calls to a TV's phone app API (port 47810), UDP discovery of TVs on the network, and
Wake-on-LAN.

### `android/app/src/main/java/app/tivibro/phone/proto/Protocol.kt`

The phone side of the pairing and request protocol (docs/wiki/Phone-app-protocol.md): reading pairing
links, PBKDF2 and HMAC for pairing, opening the TV's AES-GCM key delivery, and signing every request
and checking every answer.  Plain JVM code, unit tested against the TV's real service/companion.js.

### `android/app/src/main/java/app/tivibro/phone/proto/TvAsks.kt`

What a few of TiviBro's answers hold, for the phone's own screens: a question the TV would have shown on
its own screen (a box to type in, a menu, an "are you sure"), now asked on the phone instead (the
Settings tab); the reminders still to come (the Reminders screen); and the restore points kept on the
TV (Backups and restore points).  See app/js/phone.js and app/js/settings.js on the TV.

### `android/app/src/main/java/app/tivibro/phone/tile/TvTileService.kt`

The Quick Settings tile: one tap opens TiviBro on the chosen TV (waking it first when it is off, with
Wake-on-LAN and, when allowed, Bluetooth).

### `android/app/src/main/java/app/tivibro/phone/ui/AddScreen.kt`

Adding a TV: scan the QR code, pick a TV found on the network, or type its address; then CodeScreen,
where the 12-character code from the TV is typed.

### `android/app/src/main/java/app/tivibro/phone/ui/AppModel.kt`

The app's state and actions, shared by every screen: paired TVs, discovery on the network, pairing,
refreshing what each TV is doing, remote keys and actions, Play on TV, the channel list (lineup),
favorites, the guide and reminders, Movies and Shows, typing into the TV, adding a shared playlist
link, backups, volume, the editor address and Wake-on-LAN.  Talks to the TV only through proto/.

### `android/app/src/main/java/app/tivibro/phone/ui/BackupsScreen.kt`

Backups and restore points: the one place for them on the phone (the home screen's shortcut, the
Settings tab's Backups rows and the web editor's Backup page all come here).
Saved on this phone: back up now, the weekly automatic backups (on unless switched off), put one back
on this TV or another paired TV.  Without the TV (discussion #23): name, duplicate and merge backups,
change settings inside one, edit a playlist's channels in the web editor on the phone
(OfflineEditorScreen.kt), and delete with two confirmations.  Each of those makes a new backup and
leaves the one it came from alone.
Backup file: save a locked backup file to the phone's storage (the web editor reads it too), and
restore from one.
Restore points on the TV (ask "snaps"): copies of the channel edits and names the TV keeps by itself
and when asked; make one, go back to one (TiviBro restarts on the TV) or delete one.

### `android/app/src/main/java/app/tivibro/phone/ui/ChannelSheet.kt`

Long press on a channel (in the Guide and Channels tabs): watch it on the TV, its schedule, Favorites,
follow the team (a team's own channel, for My teams),
your own groups (in or out, or a new group with it), rename it, or hide it on the TV.  The changes are
made by TiviBro on the TV (ask "chan" in app/js/phone.js), the same as from the TV's own menus.

### `android/app/src/main/java/app/tivibro/phone/ui/EditorScreen.kt`

The TV's web editor full screen in a WebView, signed in with a one-time token from the TV, opened on
the page asked for (a Settings row can open, say, Channel and group names).  Saves the files the
editor makes (backups, problem reports) through a JavaScript bridge, opens the file picker for
restores, and keeps Back inside the editor until its first page.  The same bridge (window.TiviBroApp,
which the editor uses to tell it is inside the app) lets the editor send you to the phone's own
screens: TiviBroApp.open('media' | 'backups' | 'reminders' | 'settings').

### `android/app/src/main/java/app/tivibro/phone/ui/GuideTab.kt`

The guide on the phone, built for a small screen instead of a squeezed TV grid: pick a time (Now, or
any hour in the next day and a half) and a group, and each channel shows what is on then and what
comes after.  Tap a channel for its whole schedule, day by day, with descriptions: watch it on the TV,
or set a reminder that the TV keeps (the TV's own reminders, also while TiviBro is closed).

### `android/app/src/main/java/app/tivibro/phone/ui/HomeScreen.kt`

The first screen: each paired TV as a card (on, off, TiviBro open) with shortcuts straight to the
Remote, Guide, On now, Movies and Shows, My media, Reminders, Edit channels, Backups and Settings (the
ones this TV has, see Shortcuts.kt), and in its menu Turn on, Open TiviBro, the Quick Settings tile and
Forget; plus adding a TV.  A TV that is off still offers Edit channels and its backups on the phone.

### `android/app/src/main/java/app/tivibro/phone/ui/LogScreen.kt`

TiviBro's log on the phone (Settings tab > TiviBro log): the same text as Report a problem on the TV,
the app's log for this and earlier sessions and the network helper's, with provider, guide and server
names already taken out by the TV (ask "logs").  Problems only shows the warnings and errors; Share
sends the text to another app (a message, an email, a GitHub issue).

### `android/app/src/main/java/app/tivibro/phone/ui/MediaSourcesDialogs.kt`

My media sources from the phone (Settings > My media): adding a DLNA server, a WebDAV folder or a
Dropbox folder, each source's rescan, content, details and remove, and the TMDB token, typed here
instead of on the TV.  Dropbox: the phone signs in (proto/Dropbox.kt, AppModel.dropboxStart), and
DropboxDialog picks the folder with the TV (ops dbfolders and adddropbox).  The TV does the work (ask "mymedia", see phone.js; sealed, as logins cross); a scan that takes
longer than about 25 seconds carries on on the TV, and the answer says so.

### `android/app/src/main/java/app/tivibro/phone/ui/MyMediaTab.kt`

The helper's paginated My media shows.  Existing movie browsing stays compatible with older TVs.

### `android/app/src/main/java/app/tivibro/phone/ui/OfflineEditorScreen.kt`

A backup's channels in the web editor, on the phone, without the TV (discussion #23).  The editor
comes from the app's own copy (service/editor, put in the APK's assets when it is built), with
assets/offline.js standing in for the TV: it reads the channel list kept the last time the editor was
open with this TV (EditorCopies.kt) and the backup's own edits, and Save hands the edits back here.
Leaving with saved changes asks whether to keep them as a new backup, which can then be restored on
the TV like any other.  Nothing reaches the network: the page is served from inside the app.

### `android/app/src/main/java/app/tivibro/phone/ui/OnNowTab.kt`

Movies on now, on the phone: the TV's Movies on now (onnow.js, TMDB with the TV's own token) as big
posters in shelves: On now, Starting soon, then the same movies by genre (the TV picks the genres).
A tap opens the movie: its backdrop with the title logo, the facts and plot, and every channel
showing it.  Watch on TV for one that's on; for one that starts later, a reminder on the TV (asked
first) or the channel now.

### `android/app/src/main/java/app/tivibro/phone/ui/RemindersScreen.kt`

The reminders TiviBro on the TV will give, soonest first (ask "reminders"): when, the channel with its
logo, the programme, and the watchlist word or team that set it.  Cancel one, or all of them.  Set new
ones from the Guide (tap a channel, then the bell next to a programme) or On now.

### `android/app/src/main/java/app/tivibro/phone/ui/SettingsTab.kt`

TiviBro's Settings on the phone: every page the TV has, built by the TV's own code and sent as rows
(ask "settings", see Settings.phone in app/js/settings.js), so the phone always matches the TV.
Switches and choices change the TV at once.  Buttons run on the TV, and any question they ask (a box
to type in, a menu, an "are you sure") is asked here instead (TvDialogs.kt), so nothing needs
answering on the TV.  Some buttons open the phone's own screens: My media sources and the TMDB token
(MediaSourcesDialogs.kt), the log, Reminders, Backups and restore points, and the web editor.  A few
things only make sense on the TV's screen (the button tester, the set-up tour): those say so.
Locked with the TV's PIN when its Settings are.

### `android/app/src/main/java/app/tivibro/phone/ui/ShareScreen.kt`

A provider's link shared to TiviBro from an email, a message or a browser (Share, then TiviBro): the
app says what it found (an Xtream Codes login or an M3U playlist, without showing the password), you
pick the TV, and the TV adds the playlist the way the web editor's Add a playlist does.  The link
travels sealed: it holds the login.

### `android/app/src/main/java/app/tivibro/phone/ui/Shortcuts.kt`

The shortcuts on each TV's card on the home screen, and which ones this TV can use: On now needs a
TMDB token on the TV, Movies and Shows a provider that has them, My media a media source.  Kept apart
from the drawing (HomeScreen.kt) so it can be tested.

### `android/app/src/main/java/app/tivibro/phone/ui/Theme.kt`

Colours, shapes and type: TiviBro's "Midnight Electric" look, the same as the TV app.

### `android/app/src/main/java/app/tivibro/phone/ui/TvDialogs.kt`

A question TiviBro on the TV would have shown on its own screen (a box to type in, a menu, an "are you
sure"), asked here on the phone instead, so nothing needs answering on the TV.  The answer goes back to
the TV (op "answer" in SettingsTab.kt), which may ask the next one (a username, then a password).

### `android/app/src/main/java/app/tivibro/phone/ui/TvScreen.kt`

One TV: what it is showing now (followed with a long poll), typing on the phone into any text box the
TV is showing, a remote (arrows, OK, Back, channel and volume buttons, menu actions), the TV's own
channel list to play a channel on the TV and star favorites, the guide (GuideTab.kt), Movies and
Shows (VodTab.kt), My media and the TV's settings (SettingsTab.kt).  Opens on the tab the home
screen's shortcut asked for.

### `android/app/src/main/java/app/tivibro/phone/ui/VisibilityScreen.kt`

Show or hide channels (channel sheet > Show or hide channels…): every channel of a group, hidden ones
too, with a tick for the ones the TV shows.  Like the TV's own list: nothing changes until you leave,
then one save (Back, the arrow, or Save).  Hiding is the TV's: a hidden channel is hidden in every group.

### `android/app/src/main/java/app/tivibro/phone/ui/VodTab.kt`

Movies and Shows on the phone: the TV's own lists (its languages, hidden categories and PIN), as a
poster grid that is quicker to browse with a thumb than with a remote.  Search, pick a category, open
a title for its details, and play it on the TV: a movie from where it stopped or from the start, a show
by season and episode.  My media (the TV's own movies) uses the same screen.  Everything plays on the TV;
nothing plays on the phone.

### `android/app/src/main/java/app/tivibro/phone/update/Updater.kt`

Updates the phone app in place: downloads the release APK into the app's own cache (proto/AppUpdate.kt
checks its size and fingerprint), makes sure it is this app and newer, and hands it to Android's
PackageInstaller.  Android sends the outcome to UpdateReceiver: asking the user to confirm (the screen it
carries is opened), installed, or failed (its message is shown).  The release APK is signed with the same
key every time, so it installs over the old one; test builds (app.tivibro.phone.debug) can't, and keep the
browser link.  Everything here outlives the home screen's card, which only shows [step].

### `android/app/src/main/java/app/tivibro/phone/work/BackupWorker.kt`

Automatic backups: about twice a day on Wi-Fi, Android lets this check each paired TV.  A TV whose last
backup is a week old gets a new one, but only when the TV is on and TiviBro is already open on it
(noOpen: TiviBro is never opened over something else just to back it up).  Off in the app's Backups
screen.


## Build, release and tooling scripts

### `scripts/build.sh`

Package app/ + service/ into dist/<id>_<version>_all.ipk, then verify it.
bash scripts/build.sh           minified (release)
bash scripts/build.sh --dev     unminified (readable in ares-inspect)

### `scripts/bump-version.sh`

Set one version everywhere:  bash scripts/bump-version.sh 0.5.1

### `scripts/gen-ai-context.js`

One file to hand to an AI assistant (Claude, ChatGPT, Gemini, Cursor...) before working on TiviBro.
npm run ai                        writes tivibro-for-ai.md in the repository (git-ignored)
node scripts/gen-ai-context.js <file>
Built from what is already true in the repository, so it never drifts: docs/ai/GUIDE.md (the rules,
written by hand), docs/ARCHITECTURE.md, the opening comment of every source file, the settings and
their defaults (Store's DEFAULTS), the remote actions, npm scripts and tests, the compatibility rules,
the roadmap and the latest changelog entries.  The website workflow publishes it at
tivibro.app/ai/tivibro-for-ai.md on every deploy.

### `scripts/gen-manifest.js`

Homebrew Channel files for a release.
node scripts/gen-manifest.js dist/<ipk> <outdir> [owner/repo] [tag]
Writes:
<appId>.manifest.json  the package manifest (same fields webosbrew-toolbox-gen-manifest writes);
the in-app updater and install.sh read it from the release
homebrew-entry.json    this build as one Homebrew Channel repository entry, with the manifest
inside it; scripts/update-homebrew-repo.js puts it into repo.json and
repo-testing.json on the "homebrew" branch
The Homebrew Channel fetches repositories from the TV like a web page, so they have to come from a
host that allows that (raw.githubusercontent.com does; github.com release links do not).  With the
manifest inside the entry it needs that one request; the IPK itself is downloaded by its service.

### `scripts/gen-nogap.js`

Writes app/css/nogap.css: margin-based spacing for every flex rule in app/css/app.css that uses
"gap", for TVs older than webOS 22 (Chromium < 84) where flexbox gap does nothing.
app/js/compat.js adds class "nogap" to <html> only on those TVs, so newer TVs ignore this file.
node scripts/gen-nogap.js           write the file
node scripts/gen-nogap.js --check   exit 1 if the file is out of date (used by npm test)

### `scripts/gen-thanks.js`

Special thanks: everyone who has helped build TiviBro on GitHub, kept up to date in README.md and on
tivibro.app (site/index.html), between <!-- thanks:start --> and <!-- thanks:end -->.
node scripts/gen-thanks.js            write both files
node scripts/gen-thanks.js --check    exit 1 if they are out of date
Counts merged pull requests, and issues (ideas and bug reports), from everyone but the owner and
bots.  GITHUB_TOKEN raises the API's rate limit; REPO overrides the repository.  The Thanks workflow
runs it when a pull request is merged or an issue is opened, and every day.

### `scripts/gen-viewer.js`

tivibro.app/view: the README's and the user guide's screenshots, in the order they appear, with
their alt text as captions.  Pictures in those pages link to tivibro.app/view/#<name>, which
shows them large (GitHub's Markdown can't), with ← → through the rest.
node scripts/gen-viewer.js [out.json]      (default: site/view/shots.json; Pages builds it)

### `scripts/inspect.sh`

Open Chrome DevTools against the app on the TV.  Add -s to inspect the network helper service instead.

### `scripts/install.sh`

Install the current dist IPK on the TV.   bash scripts/install.sh [-d tv]

### `scripts/launch.sh`

Launch TiviBro on the TV.   bash scripts/launch.sh [-d tv]

### `scripts/lint.sh`

Syntax-check every JS file (app, service, tests).

### `scripts/logsink.js`

Development log receiver for TiviBro.  The app (in the simulator or a desktop browser) finds it
on 127.0.0.1:47812 and streams every log line here.  Writes:
logs/session-<date>-<time>.log   one file per run
logs/latest.log                  always the current run (what Claude reads)
Credentials are already masked by the app before they are sent.

### `scripts/pkg-version.js`

The package version webOS sees for a release tag.
node scripts/pkg-version.js 0.6.0-alpha.5     ->  0.5.10005
webOS needs appinfo.json versions to be three numbers, and the Homebrew Channel offers an
install whenever the repository's version differs from the installed one.  So every build
needs its own numeric version, and test builds must sort below the release they lead up to:
X.Y.0            ->  X.Y.0                      (a release keeps its version)
X.Y.0-alpha.N    ->  X.(Y-1).(10000 + N)
X.Y.0-beta.N     ->  X.(Y-1).(20000 + N)
X.Y.0-rc.N       ->  X.(Y-1).(30000 + N)
(for Y = 0 the line before is (X-1).999).  Test builds are only made for X.Y.0 versions.
The app itself shows the full tag (TIVIBRO_VERSION), so people still see "0.6.0-alpha.5".

### `scripts/release-notes.js`

Short release notes for one release, from CHANGELOG.md (the GitHub release page).
node scripts/release-notes.js 0.6.0-alpha.33     what is new in that test build
node scripts/release-notes.js 0.6.0              a stable release: everything in its section
Each change is one line, its bold headline; the full text stays in CHANGELOG.md (linked).
Exits with an error when there is nothing to say, so a release can't go out without notes.

### `scripts/release.sh`

Cut a release: checks everything, tags vX.Y.Z and pushes the tag.  GitHub Actions does the rest
(tests, build, IPK, Homebrew manifest, GitHub release).
bash scripts/release.sh            release the version in app/appinfo.json
bash scripts/release.sh alpha.1    a pre-release of it, tagged vX.Y.Z-alpha.1
Before running: bash scripts/bump-version.sh X.Y.Z, rename "## Unreleased" in CHANGELOG.md to
"## X.Y.Z (date)", and commit.

### `scripts/serve.sh`

Run the app in a desktop browser: static server for app/ on :8078 plus the dev relay on :47811.
Open http://localhost:8078/index.html  (the app picks up the relay via localStorage 'tivibro.devRelay'=47811).

### `scripts/sim.sh`

Run TiviBro in LG's webOS TV Simulator (an Electron AppImage kept in tools/, nothing installed system-wide).
bash scripts/sim.sh            launch app/ in the webOS 25 simulator (auto-reloads on save)
bash scripts/sim.sh --open     just open the simulator (then use its menu to launch app/ and service/)
SIM_VERSION=26 bash scripts/sim.sh    use another simulator version in tools/
Troubleshooting only:  SIM_FLAGS="--ozone-platform=x11" bash scripts/sim.sh --open
(LG also suggests --no-sandbox on Ubuntu 24+; avoid it unless the simulator will not start without it.)

### `scripts/test-build.sh`

Run guide soak tests against the minified release build, not the source.  The release build is
minified by ares-package (names renamed, comments dropped), which once broke the guide worker
("pubKeys is not defined", "s is not defined") while every test of the source passed.
MOCK_EVENTS=1 MOCK_PORT=8088 node tests/mock/mock_provider.js &
npm run serve &
bash scripts/test-build.sh

### `scripts/update-homebrew-repo.js`

Puts a release into the Homebrew Channel repository files.
node scripts/update-homebrew-repo.js dist/homebrew-entry.json <dir> [install.sh]
<dir> is a checkout of the "homebrew" branch.  Writes:
repo.json          the newest full release; until the first one exists, the newest test build
repo-testing.json  the newest build of any kind
install.sh         the on-TV installer from the newest build (when given)
Users add one of these in the Homebrew Channel settings, through raw.githubusercontent.com:
https://raw.githubusercontent.com/<owner>/<repo>/homebrew/repo.json
An older tag never replaces a newer one (re-running an old release build is harmless).

### `scripts/wiki/build.js`

Builds the GitHub wiki pages in docs/wiki/ from docs/USER-GUIDE.md, so the guide and the wiki
never drift apart.  Each "###" section of the guide becomes a wiki page; the hand-written pages
(Home, Installing, FAQ, ...) already in docs/wiki/ are left alone.
node scripts/wiki/build.js            then   bash scripts/wiki/publish.sh
Images point at the files in the main repository, so the wiki itself holds only text.

### `scripts/wiki/publish.sh`

Publishes docs/wiki/ to the repository's GitHub wiki.
node scripts/wiki/build.js && bash scripts/wiki/publish.sh
GitHub creates the wiki's git repository only after its first page exists, so the very first
time, open the Wiki tab on GitHub, press "Create the first page" and save it as is.

### `scripts/stats/collect.js`

The day's GitHub numbers for the TiviBro stats dashboard (tivibro.app/stats), sent to the relay
(relay/worker.js) by the Stats workflow every day, because GitHub keeps traffic for 14 days only.
TB_STATS_TOKEN  fine-grained token, this repo, Administration: read (traffic needs it)
GITHUB_TOKEN    the workflow's own, used only when TB_STATS_TOKEN is missing (its stargazer list is refused)
TB_STATS_KEY    the relay's stats key;  STATS_URL  the relay (default below)
node scripts/stats/collect.js [--dry]     --dry prints instead of sending

### `scripts/companion/tb-phone.js`

A reference client for the TiviBro phone app protocol (service/companion.js,
docs/wiki/Phone-app-protocol.md): what the Android app does, in plain Node, for tests and for
trying a real TV from a computer.
node scripts/companion/tb-phone.js discover
node scripts/companion/tb-phone.js pair 192.168.1.20 ABCD-EFGH-JKMN     (code from the TV)
node scripts/companion/tb-phone.js info | open | editor | state | lineup | forget
node scripts/companion/tb-phone.js key ok | action guide | play <key> | text michigan | volume up
The pairing (TV address, ids and this "phone"'s key) is kept in ~/.tivibro-phone.json (mode 600),
or TB_PHONE_FILE.


## The relay (Cloudflare Worker)

### `relay/worker.js`

TiviBro report relay: a Cloudflare Worker that turns a "Report a problem" from the web editor
(on someone's phone or computer) into a GitHub issue, so people don't need a GitHub account.

POST /report  JSON { title, area, often, what, steps, expected, version, tv, browser, log }
-> 200 { ok: true, url, number }   the new issue
-> 4xx/5xx { error }               shown to the person, who can still use the GitHub form
GET /         -> { ok: true, service: 'tivibro-report' }

Following a report up (TiviBro tells the viewer when the developer answers, see app/js/reports.js):
/report also answers { key }: a per-report code (an HMAC of the issue number), kept by the TV.
GET  /status?n=24,25  -> { ok, issues: [{ n, state, reason, url, reply: { id, at, text }, fixed }] }
the newest reply from the repo's owner or members (not the relay's own follow-ups), plain text,
and the newest TiviBro version named in those replies.  Only issues the relay opened (from-tv).
POST /followup JSON { n, key, what, version, tv, log }  -> { ok, url }
"Still happening": a comment on that issue, with a fresh log, and the issue reopened if it was
closed.  Needs the issue's key, so nobody can comment on reports that are not theirs.

Settings (Workers > tivibro-report > Settings > Variables and secrets, and Bindings):
GITHUB_TOKEN  secret: fine-grained token for the repo, Issues read and write, nothing else
REPO          text:   ohmybahgosh/TiviBro
REPORT_KEY    secret: signs the per-report keys (if missing, the GitHub token does)
RATE          KV namespace: counters for the rate limits below
The TV already masks logins and server names before a report leaves it; this masks them again,
caps every field, and keeps reports to a few an hour from one network and 60 a day in all.
Nothing is stored except the counters (a hash of the address, kept for an hour or a day).

Install numbers (also here, so there is one small service to look after):
POST /count  JSON { e: 'install' | 'update' | 'active', v: '0.6.0-alpha.31', w: '25' }
install.sh counts an install or an update; TiviBro counts itself once a day ("active"),
unless switched off in Settings, Updates.  Each network is counted once a day per kind; only
the day's totals are kept (by kind, version and webOS version), never an address or an id.
POST /stats/github  (the Stats workflow, with X-Stats-Key)  the day's GitHub numbers
GET  /stats?days=N  (X-Stats-Key, or ?k= from tivibro.app/stats)  everything, for the dashboard
STATS: KV namespace for those totals.  STATS_KEY: secret shared with the workflow and dashboard.


## Settings and their defaults

From `Store` (`app/js/store.js`).  Users change them in `app/js/settings.js`; the user guide documents each one.

```js
var DEFAULTS = {
    playlists: [],           // [{ id, name, type, server, user, pass, m3u, epg }]
    active: '',
    engine: 'auto',          // auto | mse | native
    network: 'auto',         // auto | relay | direct   (see net.js)
    format: 'ts',            // ts | m3u8   (Xtream live URL extension)
    buffer: 'auto',          // auto | low | balanced | stable | max | custom  (auto: learns per provider, playmemo.js)
    custom: { prebuffer: 3, ahead: 30, back: 8, stall: 6, retries: 8 },
    meter: 'auto',           // auto | always | off
    meterPeek: 10,           // seconds the buffer meter shows on demand (Hold Info, Stream info); 0 = until pressed again
    previewPlay: true,
    resumeLast: true,
    showHidden: false,
    display: 'contain',      // Aspect: contain (Original) | fill | cover | 4:3 | 16:9 | 21:9 | stretch43
    names: {
      enabled: true, groups: true,
      packs: { country: true, quality: true, tags: true, superscript: true, feeds: false, brackets: false },
      prefixes: '', suffixes: ''
    },
    sortChannels: 'playlist',   // playlist | number | name | watch
    sortGroups: 'playlist',     // playlist | name   (manual moves always win)
    showAll: true, showRecent: true, favFirst: true,
    recentCount: 20,
    cacheHours: 12,
    epgOffset: 0,               // minutes added to every guide time
    epgHours: 12,               // XMLTV refresh interval
    dimPast: true,
    accent: '#5b8cff',
    fontScale: 1,
    osdTimeout: 5,
    showNumbers: true,
    showClock: true,
    exitTwice: true,
    rewindLeft: true,           // Left on a live channel with catch-up rewinds instead of last channel
    skip: 30,                   // seconds per Left/Right while watching catch-up or VOD
    remindBefore: 2,            // minutes
    pin: '',                    // hashed parental PIN
    pinScope: 'channels',       // channels | settings | both
    pinStay: true,              // stay unlocked until the app restarts
    keymap: null,               // remote remapping, see keymap.js (null = defaults)
    keyAliases: {},             // learned keyCode -> button name
    // Added after the TiviMate menu review (all off/neutral by default unless noted).
    theme: 'dark',              // dark (Midnight Electric) | black | slate | ocean | forest | plum | mocha | contrast | classic
    palette: '',                // a full theme (views.js PALETTES: tokyo, dracula, nord…); '' = your own mix of theme and accent
    accentMode: '',             // '' (one of the presets) | custom (accentHue + accentLight)
    accentHue: 210, accentLight: 62,
    focusStyle: 'fill',         // fill | ring (white outline as well)
    hdrRoute: 'auto',           // auto (Dolby Vision on the TV player) | tv (all HDR on the TV player) | builtin
    convertAudio: 'auto',       // auto | off  sound the TV can't play converted to AC-3 by the helper (convert.js)
    convertAsked: false,        // the offer to get the audio converter was shown (kept for older backups)
    convertNever: false,        // "Don't ask again" on that offer
    textSize: 'm',              // s | m | l
    clockDate: false,           // show the date next to the clock
    numFont: 'tv',              // tv | even (every digit the same width) | mono (fixed width, slashed zero)
    overlay: 'm',               // l | m | h  transparency of panels over video
    nowStyle: 'fill',           // fill | color | off  how what's on now is marked in the guide
    guideNames: true,           // channel names in the guide (off = logos only)
    twoLineNames: false,        // channel names may wrap to two lines
    showCatchupMark: true,      // ↺ marks for catch-up channels and programmes
    logoPref: 'playlist',       // playlist | guide
    logoLib: 'off',             // off | fill | all  logos from the open logo projects (logos.js)
    logoStyle: 'set',           // set (tv-logos, one style) | original (iptv-org)
    logoBase: '',               // logo index address; empty = tivibro.app/logos/
    blackSwitch: false,         // hide the old picture while the next channel tunes
    backups: 'auto',            // a channel that stops: auto (switch to another version) | ask | off
    osdOnZap: true,             // info panel when switching channels
    osdMedia: true,             // resolution and player in the info panel
    autoSubs: false,            // turn subtitles on when a stream has them
    ffStep: 120,                // seconds for RW / FF while watching catch-up or movies
    showFav: true,              // Favorites group in the lists
    newGroups: 'show',          // show | hide  groups the provider adds later
    allHidden: false,           // All channels includes channels from hidden groups
    remindAction: 'ask',        // ask | switch
    remindTimeout: 20,          // seconds the reminder question stays up
    qpHidden: {},               // quick menu buttons switched off
    newDays: 7,                 // days a channel stays in Recently added (0 = no such group)
    audioLang: '',              // preferred audio language ('' = the stream's own choice), see U.lang
    audioFallback: '',          // when the preferred audio language is missing, try this one ('' = keep the track playing)
    audioSurround: false,       // prefer a 5.1 / Dolby track in that language
    subsLang: '',               // with autoSubs: only subtitles in this language ('' = any)
    watchAhead: 24,             // hours ahead the watchlist looks in the guide
    watchAction: 'remind',      // remind (set a reminder) | tell (just a message)
    myTeams: [],                // teams followed, 'league:abbr' (app/js/teams.js), for the My teams group
    teamsGroup: true,           // show the My teams group when teams are followed
    teamsRemind: false,         // reminders for their games, the same way as the watchlist
    teamsAhead: 6               // hours ahead a game counts for the My teams group
  };
```

## Remote buttons and actions

From `Keymap` (`app/js/keymap.js`).  `Views.runAction(action, context)` carries them out.

```js
var ACTIONS = {
    none: 'Nothing',
    panel: 'Quick menu (actions and recent channels)', list: 'Channel guide over the video', osd: 'Show info panel', menu: 'Options menu',
    chnext: 'Next channel', chprev: 'Previous channel', last: 'Last channel',
    rewind: 'Rewind (catch-up), else last channel', restart: 'Watch from start', playpause: 'Play / pause',
    guide: 'TV guide', search: 'Search', settings: 'Settings', editor: 'Edit on a phone or computer (QR code)', live: 'Channels screen', exit: 'Leave fullscreen',
    fav: 'Add or remove favorite', favorites: 'Open Favorites', recents: 'Open Recent channels',
    picture: 'Change the aspect', audio: 'Audio track', cc: 'Closed captions on or off', subs: 'Subtitles menu', sleep: 'Sleep timer',
    movies: 'Movies', shows: 'Shows', reconnect: 'Reconnect stream', buffer: 'Show buffer info', streaminfo: 'Stream info and buffer',
    full: 'Watch fullscreen (the channel in the window, else the highlighted one)', now: 'Jump to now', groups: 'Open the groups', stopwin: 'Stop the window'
  };

var CONTEXT_ACTIONS = {
    player: ['none', 'panel', 'list', 'osd', 'menu', 'chnext', 'chprev', 'last', 'rewind', 'restart', 'playpause', 'guide', 'search', 'settings', 'editor', 'exit',
      'fav', 'favorites', 'recents', 'picture', 'audio', 'cc', 'subs', 'sleep', 'reconnect', 'buffer', 'streaminfo'],
    list: ['none', 'menu', 'last', 'guide', 'search', 'settings', 'editor', 'fav', 'favorites', 'recents', 'movies', 'shows', 'playpause'],
    guide: ['none', 'full', 'menu', 'groups', 'now', 'last', 'stopwin', 'live', 'search', 'settings', 'editor', 'fav', 'favorites', 'recents', 'movies', 'shows']
  };

var DEFAULTS = {
    player: {
      ok: 'panel', 'long:ok': 'list', up: 'chprev', down: 'chnext', chup: 'chnext', chdown: 'chprev',
      left: 'rewind', right: 'osd', red: 'menu', green: 'guide', yellow: 'last', blue: 'audio',
      playpause: 'playpause', stop: 'exit', rew: 'rewind', ff: 'none', info: 'osd', 'long:info': 'buffer'
    },
    list: { 'long:ok': 'menu', red: 'menu', green: 'guide', yellow: 'last', blue: 'settings', playpause: 'none', info: 'menu', stop: 'none', rew: 'none', ff: 'none' },
    // What the guide always did with these buttons, now changeable.
    guide: { 'long:ok': 'menu', red: 'menu', info: 'menu', green: 'live', blue: 'settings', yellow: 'groups', playpause: 'full', stop: 'stopwin' }
  };
```

## npm scripts

```json
{
  "build": "bash scripts/build.sh",
  "install:tv": "bash scripts/install.sh",
  "launch": "bash scripts/launch.sh",
  "sim": "bash scripts/sim.sh",
  "sim:open": "bash scripts/sim.sh --open",
  "inspect": "bash scripts/inspect.sh",
  "serve": "bash scripts/serve.sh",
  "lint": "bash scripts/lint.sh",
  "ai": "node scripts/gen-ai-context.js",
  "test": "npm run test:unit",
  "test:unit": "node tests/unit/compat.test.js && node tests/unit/captions.test.js && node tests/unit/filter.test.js && node tests/unit/subs.test.js && node tests/unit/subs-lineup.test.js && node tests/unit/subs-draw.test.js && node tests/unit/caps.test.js && node tests/unit/convert-decide.test.js && node tests/unit/convert-probe.test.js && node tests/unit/ffmpeg-get.test.js && node tests/unit/convert-endpoint.test.js && node tests/unit/embedsubs.test.js && node tests/unit/mkvsubs.test.js && node tests/unit/mp4subs.test.js && node tests/unit/player.test.js && node tests/unit/playmemo.test.js && node tests/unit/awake.test.js && node tests/unit/deadlist.test.js && node tests/unit/look.test.js && node tests/unit/names.test.js && node tests/unit/events.test.js && node tests/unit/cleanup.test.js && node tests/unit/logos.test.js && node tests/unit/logo-index.test.js && node tests/unit/proxy-ua.test.js && node tests/unit/proxy-lenient.test.js && node tests/unit/proxy-pause.test.js && node tests/unit/proxy-hls.test.js && node tests/unit/proxy-dns.test.js && node tests/unit/pace.test.js && node tests/unit/unzip.test.js && node tests/unit/tssync.test.js && node tests/unit/update.test.js && node tests/unit/tv-install.test.js && node tests/unit/homebrew.test.js && node tests/unit/editor.test.js && node tests/unit/companion.test.js && node tests/unit/companion-remote.test.js && node tests/unit/companion-ask.test.js && node tests/unit/blewake.test.js && node tests/unit/vod-lists.test.js && node tests/unit/live-lists.test.js && node tests/unit/links.test.js && node tests/unit/library.test.js && node tests/unit/teams.test.js && node tests/unit/prefs.test.js && node tests/unit/remind.test.js && node tests/unit/grant.test.js && node tests/unit/movies.test.js && node tests/unit/alert-sh.test.js && node tests/unit/pubepg.test.js && node tests/unit/guide-fallback.test.js && node tests/unit/guide-empty.test.js && node tests/unit/webedit-epgsource.test.js && node tests/unit/media-races.test.js && node tests/unit/media-metadata.test.js && node tests/unit/media-metadata-ui.test.js && node tests/unit/media-editor.test.js && node tests/unit/media-store.test.js && node tests/unit/media-auto.test.js && node tests/unit/media-stream.test.js && node tests/unit/media-classify.test.js && node tests/unit/media-library.test.js && node tests/unit/media-library-transactions.test.js && node tests/unit/media-catalog.test.js && node tests/unit/media-shelves.test.js && node tests/unit/media-removed-files.test.js && node tests/unit/media-show-sort.test.js && node tests/unit/media-corrections.test.js && node tests/unit/media-grouping.test.js && node tests/unit/media-migrate.test.js && node tests/unit/media-chapters.test.js && node tests/unit/media-metadata-cache.test.js && node tests/unit/media-series-ui.test.js && node tests/unit/media-series-auto.test.js && node tests/unit/media-series-queue.test.js && node tests/unit/media-helper-fixes.test.js && node tests/unit/dropbox.test.js && node tests/unit/service-engines.test.js && node tests/unit/storage.test.js",
  "test:relay": "node tests/unit/proxy.test.js",
  "soak:chaos": "node tests/soak/chaos.js",
  "soak:monkey": "node tests/soak/monkey.js",
  "soak:zap": "node tests/soak/zap.js",
  "soak:speed": "node tests/soak/speed.js",
  "soak:editor": "node tests/soak/editor.js",
  "soak:drafts": "node tests/soak/drafts.js",
  "soak:events": "node tests/soak/events.js",
  "soak:features": "node tests/soak/features.js",
  "soak:reminders": "node tests/soak/reminders.js",
  "soak:editor-order": "node tests/soak/editor-order.js",
  "soak:tour": "node tests/soak/tour.js",
  "soak:captions": "node tests/soak/captions.js",
  "soak:subtitles": "node tests/soak/subtitles.js",
  "soak:filter": "node tests/soak/filter.js",
  "soak:load-guard": "node tests/soak/load-guard.js",
  "soak:m3u-vod": "node tests/soak/m3u-vod.js",
  "soak:exit-ask": "node tests/soak/exit-ask.js",
  "soak:guide-rows": "node tests/soak/guide-rows.js",
  "soak:editor-mobile": "node tests/soak/editor-mobile.js",
  "logos": "node scripts/logos/build-index.js",
  "test:engines": "bash tests/engines/run.sh",
  "soak:backup": "node tests/soak/backup.js",
  "soak:boot": "node tests/soak/boot.js",
  "version:bump": "bash scripts/bump-version.sh",
  "release": "bash scripts/release.sh",
  "manifest": "node scripts/gen-manifest.js",
  "demo:provider": "node tests/mock/mock_provider.js",
  "demo:record": "node scripts/demo/record.js",
  "demo:encode": "bash scripts/demo/encode.sh",
  "wiki": "node scripts/wiki/build.js",
  "wiki:publish": "node scripts/wiki/build.js && bash scripts/wiki/publish.sh",
  "nogap": "node scripts/gen-nogap.js"
}
```

## Tests

- **Unit** (`npm test`): `tests/unit/alert-sh.test.js`, `tests/unit/awake.test.js`, `tests/unit/blewake.test.js`, `tests/unit/caps.test.js`, `tests/unit/captions.test.js`, `tests/unit/cleanup.test.js`, `tests/unit/companion-ask.test.js`, `tests/unit/companion-remote.test.js`, `tests/unit/companion.test.js`, `tests/unit/compat.test.js`, `tests/unit/convert-decide.test.js`, `tests/unit/convert-endpoint.test.js`, `tests/unit/convert-probe.test.js`, `tests/unit/deadlist.test.js`, `tests/unit/dropbox.test.js`, `tests/unit/editor.test.js`, `tests/unit/embedsubs.test.js`, `tests/unit/events.test.js`, `tests/unit/ffmpeg-get.test.js`, `tests/unit/filter.test.js`, `tests/unit/grant.test.js`, `tests/unit/guide-empty.test.js`, `tests/unit/guide-fallback.test.js`, `tests/unit/homebrew.test.js`, `tests/unit/library.test.js`, `tests/unit/links.test.js`, `tests/unit/live-lists.test.js`, `tests/unit/logo-index.test.js`, `tests/unit/logos.test.js`, `tests/unit/look.test.js`, `tests/unit/mask.test.mjs`, `tests/unit/media-auto.test.js`, `tests/unit/media-catalog.test.js`, `tests/unit/media-chapters.test.js`, `tests/unit/media-classify.test.js`, `tests/unit/media-corrections.test.js`, `tests/unit/media-editor.test.js`, `tests/unit/media-grouping.test.js`, `tests/unit/media-helper-fixes.test.js`, `tests/unit/media-library-transactions.test.js`, `tests/unit/media-library.test.js`, `tests/unit/media-metadata-cache.test.js`, `tests/unit/media-metadata-ui.test.js`, `tests/unit/media-metadata.test.js`, `tests/unit/media-migrate.test.js`, `tests/unit/media-races.test.js`, `tests/unit/media-removed-files.test.js`, `tests/unit/media-series-auto.test.js`, `tests/unit/media-series-queue.test.js`, `tests/unit/media-series-ui.test.js`, `tests/unit/media-service.test.js`, `tests/unit/media-shelves.test.js`, `tests/unit/media-show-sort.test.js`, `tests/unit/media-store.test.js`, `tests/unit/media-stream.test.js`, `tests/unit/mkvsubs.test.js`, `tests/unit/movies.test.js`, `tests/unit/mp4subs.test.js`, `tests/unit/names.test.js`, `tests/unit/pace.test.js`, `tests/unit/player.test.js`, `tests/unit/playmemo.test.js`, `tests/unit/prefs.test.js`, `tests/unit/proxy-dns.test.js`, `tests/unit/proxy-hls.test.js`, `tests/unit/proxy-lenient.test.js`, `tests/unit/proxy-pause.test.js`, `tests/unit/proxy-ua.test.js`, `tests/unit/proxy.test.js`, `tests/unit/pubepg.test.js`, `tests/unit/relay-followup.test.mjs`, `tests/unit/relay-stats.test.mjs`, `tests/unit/remind.test.js`, `tests/unit/service-engines.test.js`, `tests/unit/storage.test.js`, `tests/unit/subs-draw.test.js`, `tests/unit/subs-lineup.test.js`, `tests/unit/subs.test.js`, `tests/unit/teams.test.js`, `tests/unit/tssync.test.js`, `tests/unit/tv-install.test.js`, `tests/unit/unzip.test.js`, `tests/unit/update.test.js`, `tests/unit/vod-lists.test.js`, `tests/unit/webedit-epgsource.test.js`.
- **Soak** (Playwright against `tests/mock/mock_provider.js` and `npm run serve`): `tests/soak/backup.js`, `tests/soak/bigreload.js`, `tests/soak/boot.js`, `tests/soak/buffer-peek.js`, `tests/soak/captions.js`, `tests/soak/channels-tab.js`, `tests/soak/chaos.js`, `tests/soak/convert-audio.js`, `tests/soak/convert-mymedia.js`, `tests/soak/crash-report.js`, `tests/soak/drafts.js`, `tests/soak/editor-cleanup.js`, `tests/soak/editor-guide-states.js`, `tests/soak/editor-mobile.js`, `tests/soak/editor-offline.js`, `tests/soak/editor-order.js`, `tests/soak/editor-ports.js`, `tests/soak/editor-report.js`, `tests/soak/editor-shortcut.js`, `tests/soak/editor.js`, `tests/soak/events.js`, `tests/soak/exit-ask.js`, `tests/soak/features.js`, `tests/soak/filter.js`, `tests/soak/full-link.js`, `tests/soak/guide-big.js`, `tests/soak/guide-fallback.js`, `tests/soak/guide-groups-move.js`, `tests/soak/guide-memory.js`, `tests/soak/guide-rows.js`, `tests/soak/hold-frame.js`, `tests/soak/list-ends.js`, `tests/soak/load-guard.js`, `tests/soak/look-notices.js`, `tests/soak/m3u-vod.js`, `tests/soak/media-dropbox.js`, `tests/soak/media-editor.js`, `tests/soak/media-library-large.js`, `tests/soak/media-memory.js`, `tests/soak/media-series.js`, `tests/soak/media-shots.js`, `tests/soak/media.js`, `tests/soak/mkv-subtitles.js`, `tests/soak/monkey.js`, `tests/soak/movies.js`, `tests/soak/mp4-subtitles.js`, `tests/soak/my-teams.js`, `tests/soak/onnow.js`, `tests/soak/phone-asks.js`, `tests/soak/phone-remote.js`, `tests/soak/phone.js`, `tests/soak/pubepg.js`, `tests/soak/reminders.js`, `tests/soak/report-followup.js`, `tests/soak/search-names.js`, `tests/soak/search-vod.js`, `tests/soak/settings-audit.js`, `tests/soak/settings-live.js`, `tests/soak/setup-feedback.js`, `tests/soak/speed.js`, `tests/soak/store-full.js`, `tests/soak/subtitles.js`, `tests/soak/tour.js`, `tests/soak/tvfix.js`, `tests/soak/visibility.js`, `tests/soak/zap.js`.
- **Old engines**: `bash tests/engines/run.sh` (Chromium 53 to 87, Node 0.12 to 12).
- **Android**: `tests/android/` (a stand-in TV running the real `service/companion.js`).
- **Real providers** (maintainers only, logins outside the repository): `tests/real/guide-kinds.js`, `tests/real/vod.js`, `tests/real/xc.js`.

## Compatibility

TiviBro is built and tested first on **webOS TV 25**, and runs the same code on older and newer TVs.  There is one IPK for every version.

| webOS TV | Years | Browser engine | Node (helper) | Status |
|---|---|---|---|---|
| 26 | 2026 | Chromium 132 | 20.12 | Supported.  Access Control Groups (`requiredACG`) are declared, so the stricter permission checks pass. |
| **25** | 2025 | Chromium 120 | 16.20 | **Main target.**  Developed and tested in the webOS 25 Simulator, and running on a real LG C2 (2022, OLED65C2PUA) upgraded to webOS 25 (10.3.1). |
| 24 | 2024 | Chromium 108 | 16.19 | Supported, same engine family as 25. |
| 22, 23 | 2022 to 2023 | Chromium 87, 94 | 12.x | Supported.  Run in Chromium 87 and Node 12 (see below), and in use on a real LG 32LQ630B (2022, webOS 22). |
| 6.x | 2021 | Chromium 79 | 8.12 | Supported.  Run in Chromium 78 and Node 8.12.  Flex spacing comes from the fallback stylesheet.  `.gz` guides are unzipped by the helper. |
| 5.x | 2020 | Chromium 68 | 8.12 | Supported, same notes as 6.x (run in Chromium 69), and in use on a real LG 50NANO79 (webOS 5.6). |
| 4.x | 2018 to 2019 | Chromium 53 | 0.12 | Supported.  Run in Chromium 53 and Node 0.12, and in use on a real LG OLED65C9 (2019, webOS 4.10). |
| 3.x and older | 2016 and older | Chromium 38 or WebKit | 0.12 or older | Not supported.  The app shows "This TV is too old for TiviBro" instead of a black screen. |

"Supported" for versions other than 25 means the code is checked against that engine (syntax and APIs, see below), the fallbacks are in place, and the app and helper have run on the matching Chromium and Node builds (see "Run on the real engines").  TiviBro is in alpha testing on the real TVs in this table; reports from other models and versions are very welcome.

### How the app stays compatible

- **One code base, no transpiling.**  App code is plain JavaScript that parses on Chromium 53: no `async`/`await`, object spread, optional chaining or `??`.  The helper service is plain ES5 for Node 0.12: `var` and `function` only.
- **`app/js/compat.js`** loads before everything else (plain ES5).  It adds `Promise.prototype.finally` (Chromium 63), `Object.entries` and `Object.values` (Chromium 54) and `String.prototype.padStart`/`padEnd` (Chromium 57).  The last three are for the bundled players: hls.js uses `Object.entries`, and mpegts.js uses `padStart` when it reads an HEVC stream, which would otherwise stop HEVC channels on webOS 4.  It also detects whether flexbox `gap` works (Chromium 84); on TVs where it doesn't, `<html>` gets the class `nogap`.
- **Never a black screen.**  `compat.js` checks for modern JavaScript first: on webOS 3 and older it shows "This TV is too old for TiviBro".  After the page loads it checks that every part of the app arrived (a file that fails to load leaves its part missing) and shows "TiviBro could not start" with the error, the missing parts and the browser version, and OK to try again.  Errors during start-up show the same screen if the app still isn't up 8 seconds later; a slow first download never triggers it.
- **`app/css/nogap.css`** spaces flex children with margins instead of `gap`, only under `.nogap`.  `scripts/gen-nogap.js` writes it from `app.css` (`npm run nogap`), and lint and tests fail if it is out of date.
- **Bundled players.**  hls.js and mpegts.js are ES5 builds; their newer API use is either polyfilled by `compat.js` or behind their own feature checks (`AbortController`, `navigator.mediaCapabilities`, `Object.getOwnPropertyDescriptors`).
- **Symbols on every TV.**  The UI uses the TV's own fonts (`LG Smart UI`).  For symbols those fonts may lack (↺ ★ ▶ ✓ ⚠), `app/fonts/symbols.woff` (4 KB, a renamed subset of DejaVu Sans, licence in `app/fonts/LICENSE.txt`) is listed as a fallback with a `unicode-range`, so it is only ever used for those characters.  Emoji-only symbols such as ⏪ ⏩ are not used on screen.
- **Logos and posters over https.**  2018 to 2020 TVs without recent updates don't trust today's Let's Encrypt certificates, which many providers' image hosts use.  An https image that fails to load gets one more try through the helper (`Net.retryImage`), which doesn't depend on the TV's certificate list.
- **Memory.**  The guide keeps past programmes only for catch-up channels (up to their catch-up length and **Keep past days**); other channels keep the last three hours, which is all the guide can show for them.  On a 15,000-channel playlist with a 211 MB XMLTV file that is 0.85 instead of 1.1 million programmes, and 128 instead of 163 MB.  On webOS 4 and 5 (the TVs with the least memory) programme descriptions are kept to 250 characters instead of 400.
- **Full storage.**  Settings and edits that can't be saved (the TV's storage for the app is full) are logged and reported once on screen instead of failing silently, and a guide cache write that the TV aborts no longer leaves anything waiting.
- **Sloppy providers on every TV.**  Cheap panels send replies that Node's HTTP parser refuses: a stray control character in a header (Node 8 on webOS 5 and 6 refuses it and has no lenient mode; Node 12 and newer refuse it without one), bare LF line endings, and `Content-Length` sent twice (refused by Node 12 and newer even in lenient mode).  The helper asks for Node's lenient parser (`insecureHTTPParser`), and when Node still refuses a reply it asks again with its own forgiving reader, `service/rawhttp.js`, which accepts all three (and chunked replies).  Node 0.12 accepts them all by itself.
- **Guarded newer APIs.**  `AbortController` (Chromium 66) and `DecompressionStream` (Chromium 80) are only used after a `typeof` check, with a fallback.
- **Zipped guides on every TV.**  Guide downloads go through the helper with the `/unz/<id>` prefix, and `service/unzip.js` unzips them with Node's zlib: `.gz` files, `Content-Encoding: gzip` or `deflate`, gzip sent without a label, and files zipped twice.  `/xfer/<id>` reports the zipped bytes so progress stays accurate.  With Network set to Direct, the app falls back to `DecompressionStream` (Chromium 80, webOS 22 and later).
- **`service/compat.js`** fills in `Object.assign`, `Buffer.from` and `crypto.timingSafeEqual` on Node versions that lack them.  `proxy.js` and `editor.js` load it first.
- **Codecs per TV.**  `app/js/caps.js` asks the TV what it decodes and applies known LG rules (for example no DTS on 2020 to 2022 models), then routes each stream to the player that can play it.  The webOS version comes from the browser engine (Chromium 132 is webOS 26; anything newer counts as 26 until LG publishes the next mapping).
- **In-app updates** call `luna://org.webosbrew.hbchannel.service/install`, which exists only on rooted TVs with the Homebrew Channel and has no ACG group.  When the call is missing or refused, the app says it can't install by itself and points to the release page.
- **Access Control Groups.**  `appinfo.json` lists every Luna permission the app and its helper use: `systemconfig.query` (TV model and webOS version, `caps.js`) and `activity.operation` (keeps the helper running, `service.js`).  webOS 25 and older ignore the list; 26 checks it when present, and 27 requires it.

### Video decoding and hardware

Every stream is decoded by the TV's hardware decoder, whichever player TiviBro uses:

- **Built-in demuxer (MPEG-TS) and HLS:** mpegts.js and hls.js only unpack the stream (in a background worker, off the UI thread) and hand the compressed video to the browser's Media Source Extensions, which on webOS feed the TV's own decoder.
- **TV player:** the TV's media pipeline does everything, including unpacking.  It uses the least CPU but gives TiviBro no control over buffering, so Automatic uses it only where it is needed (Dolby Vision, codecs the browser refuses, servers that block the built-in path).

webOS doesn't let apps change decoder settings, switch the display's refresh rate or resolution, or pass audio through untouched, so there is nothing more to switch on.  The video sits on the TV's video plane behind the page; TiviBro keeps the page opaque around it and puts no transforms or filters on the video element, which keeps it there.

Start time: a lone CH+ or CH− starts the next channel at once (a quick run of presses waits until you stop, so it opens one stream rather than many), a channel number starts as soon as it can't get any longer, the helper reuses connections and DNS answers, and the player checks every 50 ms until the first frame.  How much buffer a stream needs before it starts is still set by the buffer profile, so start speed never comes out of stability.

Measured in a desktop browser with the CPU slowed to TV speed: steady fullscreen playback uses about 11 ms of main-thread time per second (around 1% of a core), and a channel change reaches a moving picture in about half a second (0.12 s at full speed) when the provider sends a few seconds up front.

### Checked on every test run

`tests/unit/compat.test.js` (part of `npm test` and CI):

- Every app file parses as ES2015 (Chromium 53), the bundled players as ES5, and every helper file as ES5 (Node 0.12).
- No app file uses APIs that Chromium 53 lacks without a guard, the bundled players use nothing Chromium 53 lacks that isn't polyfilled, and no helper file uses APIs Node 0.12 lacks.
- The stylesheets use no CSS that Chromium 53 ignores (8-digit hex colours, `inset`, `aspect-ratio`, `:is`/`:where`/`:has`, `clamp()`, `position: sticky`, grid and others; flex `gap` is covered by `nogap.css`).
- The shims work: `Object.assign`, `timingSafeEqual`, `Promise.prototype.finally`, `Object.entries`/`values`, `padStart`/`padEnd`, and the `nogap` detection.  A browser without modern JavaScript gets the "too old" screen.
- The webOS version detection maps every engine from Chromium 53 to 132.
- The symbol font and its licence are bundled, nothing on screen uses emoji-only symbols, and the helper relays sloppy replies (`tests/unit/proxy-lenient.test.js`).
- `appinfo.json` keeps a three-number version, the full HD layout and `disableBackHistoryAPI`.
- `nogap.css` matches `app.css`, and `index.html` loads `compat.js` before the libraries.
- `requiredACG` covers every Luna call in the app and helper.

Also, in a browser (`tests/soak/boot.js`, `npm run soak:boot`): a normal start shows no error screen, a file that fails to load shows "could not start" with the details, and a browser without modern JavaScript shows "too old".

### Run on the real engines

`npm run test:engines` (`tests/engines/run.sh`, Linux) downloads the official Node and Electron builds once into `.engines/` (git-ignored; Electron checked against its published SHA-256 sums) and runs:

- **The helper** (`tests/engines/helper.js`, plain ES5) on Node 0.12.2, 8.12.0, 12.22.12, 16.20.2 and 20.18.0: relaying with CORS, HEAD, Range, redirects, a custom User-Agent, unzipping `.gz` guides (also cut-off ones), MPEG-TS realignment, sloppy replies, the DNS cache, a provider that is down or never answers, a viewer zapping away (the provider stream closes), the log, the web editor (login, snapshot, save, encrypted backup and restore) and the webOS service wrapper.  40 checks, all passing on every version.  The same file runs with the unit tests on the current Node.
- **The app** (`tests/engines/browser/`) at 1920x1080 in Electron 1.4.16 (Chromium 53, webOS 4), 4.2.12 (Chromium 69, webOS 5), 7.3.3 (Chromium 78, webOS 6) and 11.5.0 (Chromium 87, webOS 22), against the mock provider: start-up without the error screen, channels, the guide, remote-key navigation, a channel preview that plays, Settings, and no page errors.  All passing on every engine, with screenshots in `tests/.shots/engines-*/`.  A zipped XMLTV guide (`.xml.gz` without a Content-Encoding label) is also unzipped by the helper and read on Chromium 53 and 87.

What this found and fixed: Node 8.12 (webOS 5 and 6) refused provider replies with a stray control character in a header, and Node 12 and newer refused `Content-Length` sent twice; both now go through `rawhttp.js`.

Still not covered: a real TV's hardware decoder (Electron decodes in software), its memory limits, and the webOS media pipeline used by the TV player.  Electron 1.4 needs GConf and the Pango 1.40 libraries of Ubuntu 18.04 (current Pango crashes it); `run.sh` fetches those from archive.ubuntu.com and uses them for Electron 1.4 only.

### Adding code

- Stick to ES5 plus the ES2015 parts Chromium 53 has (`let`/`const` and arrow functions are fine in the app, not in the helper).  Lint and `npm test` tell you when something slips in.
- New flex layouts can use `gap`; run `npm run nogap` afterwards.
- A new Luna call needs its ACG group in `appinfo.json` (see LG's [ACG guide](https://webostv.developer.lge.com/develop/guides/acg-guide)), and its service in the list at the end of `compat.test.js`.

## Decisions

- **Name and IDs:** TiviBro, `app.tivibro.tv`.  No personal domain anywhere in the app.  No "webOS" or "LG" in the name (LG trademarks).
- **Stack:** plain JS + hls.js (full build, for separate audio tracks and subtitles) + mpegts.js, no framework, no bundler.  Keeps cold start fast on slow TV CPUs and avoids module-script issues under `file://`.
- **Playlists at launch:** Xtream Codes or manual M3U + XMLTV only, per-playlist guide sources and TiviMate-style refresh intervals.  Stalker deferred.
- **Must-haves before launch:** remote button remapping; solid channel name editor with preloaded defaults and "Enable recommended defaults".
- **Buffering first:** buffer profiles (Fast zapping, Balanced, Smooth, Rock solid, Custom), refill-to-cushion on underrun, stall watchdog, reconnect with backoff.
- **CORS:** bundled localhost helper service rather than requiring provider CORS.  Network setting Automatic/Direct as an escape hatch, reachable from the setup screen.
- **HTTPS:** accept self-signed certs in the helper; auto-detect scheme when omitted; portless servers are normal.
- **requiredACG omitted** on purpose: enforced only from webOS 26, and adding it on 25 and below switches permission checks on.
- **Credentials** are masked in every log and on screen.
- **Public guide fill-in:** on by default, but only for channels with no guide from the user's sources or the provider, and never over one.  epg.pw first (smaller files, CORS), then iptv-epg.org (more complete, needs the helper).  The TV fetches the files itself, at most twice a day, after the user's own guide; nothing is hosted or proxied by TiviBro.  Matching is by guide id, US call sign, then a looser name key, within the channel's own country when known.

## Roadmap

### Now: alpha testing on real TVs
- Alpha builds (0.6.0 alpha) are in daily use on real LG TVs: a C2 on webOS 25, an OLED65C9 on webOS 4, a 50NANO79 on webOS 5 and a 32LQ630B on webOS 22, plus the Android phone app.
- Problem reports come straight from the TVs (Settings > Diagnostics > Report a problem) and land in GitHub issues; fixes ship in the next alpha.
- Next: beta.

### Known gaps
- **Mock provider incomplete.**  `tests/mock/mock_provider.js` is now in the repo, but it is the demo provider: plain http on 127.0.0.1:8077, any login, about 60 made-up channels.  The original used by `tests/e2e/*` and `tests/unit/proxy.test.js` lived only in the old chat sandbox and was not in the source zip.  Those suites also expect :8443 (self-signed https), 2000 channels, 3 live categories, `guide2.xml`, `/hls/`, `/redir.m3u8`, timeshift/movie paths and test users `goodu`, `bad`, `html`, `empty`, `full`.  Until the mock covers that, CI runs the unit tests only and `npm run test:relay` and the e2e suites can't run.
- tests/e2e were written before the Back/Left navigation changes and will need updating when the mock provider is rebuilt.

### Before public launch
- ~~Make the repo public~~ (done; Homebrew Channel repository on the `homebrew` branch, address in the README).
- "All playlists" combined view.
- Decide on `requiredACG` once webOS 26 enforcement applies (deliberately omitted for 25 and below).

### Done since 0.5.0 (see CHANGELOG)
- Magic Remote: hover and click in every list, menu, the guide grid and settings; wheel scrolls.
- Real logo: app icons, splash screen and wordmark.
- Backup/restore of settings, playlists and edits, locked with a passphrase.
- Custom User-Agent per playlist, sent by the network helper (no longer needs a proxy upstream).

### Not possible in a webOS web app
Recording to the TV, auto frame rate, Android-only player options, multiview (an app plays one video at a time; removed in alpha 24).  Dispatcharr or another proxy covers recording upstream.

### Deferred
- Stalker portal.
- Multiview, someday: the TV's own Multi View pairs an app with an HDMI input, so look at whether a second source could be offered to it as an input.  Research only.

## Contributing

Thanks for helping.  Bug reports, ideas, docs fixes and code are all welcome.

### Reporting a problem

Use [New issue](https://github.com/ohmybahgosh/TiviBro/issues/new/choose) and pick the form that fits: something doesn't work, channels/streams/guide, installing or updating, a question, or docs.  Ideas and feature requests go to [Discussions > Ideas](https://github.com/ohmybahgosh/TiviBro/discussions/categories/ideas).

- **Never post your provider's address, username, password or a stream link**, in the text, a log or a screenshot.  TiviBro masks logins in its own log, but check anyway.
- Include the TiviBro version (Settings > Updates and about) and your TV model and webOS version.
- For playback problems, run **Settings > Diagnostics > Run connection test** first and include the result.
- A phone photo of the TV screen is a perfectly good screenshot.

TiviBro doesn't provide channels, streams or playlists, so we can't help with a provider's content, prices or accounts.

### Working on the code

TiviBro is plain HTML, CSS and JavaScript with no bundler.  Start with the [README's Develop section](README.md#develop) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).

```bash
npm install          # the webOS CLI and test tools
npm run serve        # the app in a desktop browser at http://localhost:8078, with the dev relay
npm run lint
npm test
```

`node tests/mock/mock_provider.js` runs a made-up Xtream Codes provider for testing (`MOCK_EVENTS=1` adds event channels, East and West feeds and local stations).  The `tests/soak/` scripts drive the app and the web editor in a real browser; `bash tests/engines/run.sh` runs the app on the browser and Node versions of old TVs.

**Working with an AI assistant?**  Give it the knowledge base first: [tivibro-for-ai.md](https://tivibro.app/ai/tivibro-for-ai.md) (download it and attach it to Claude, ChatGPT or Gemini, or run `npm run ai` in your clone).  It has the rules below, what every source file does, the settings, the remote actions and how to test, so the assistant writes changes that fit.  Coding agents read [AGENTS.md](AGENTS.md) and [CLAUDE.md](CLAUDE.md) by themselves.  [docs/ai/GUIDE.md](docs/ai/GUIDE.md) explains how to use it.

Ground rules:

- **Old TVs matter.**  The same IPK runs on webOS 4 (Chromium 53, Node 0.12 for the service).  No CSS grid, no Unicode property classes (`\p{L}`) in app regular expressions, ES5-style code in the service.  See [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
- **The remote first.**  Everything has to work with arrows, OK and Back, and with the Magic Remote.  Back always goes up one level.
- **Speed and stability over flash.**  Lists stay fast with 15,000 channels; nothing should slow down a channel starting.
- **Never lose the user's work.**  Edits are overlays on the provider's list; anything destructive asks first or can be undone.
- **Shared code stays shared.**  `app/js/names.js` and `app/js/tidy.js` are copied into `service/editor/` by `npm run build`; lint fails if they differ.
- **No secrets in the repository.**  Real provider tests read logins from a file outside the repo (see `tests/real/xc.js`).
- **Write for viewers.**  Plain words in the UI and docs, and a line in CHANGELOG.md under "## Unreleased" for anything they'd notice.

Open a pull request against `main`; the template lists what to check.  Don't target the `homebrew` branch: it only holds the files the Homebrew Channel and the installer read, and the release workflow writes it.

### Releases

Maintainers cut releases with `npm run release -- alpha.N` after moving "## Unreleased" in CHANGELOG.md under a version heading.  GitHub Actions builds the IPK, publishes the release, updates the Homebrew Channel repository and the website.

### Code of conduct

Be kind.  See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).

## Pull request template

### What this changes

<!-- One or two sentences.  Link the issue it fixes: "Fixes #12". -->

### Why

<!-- The problem it solves, from the viewer's side: what was wrong or missing on the TV or in the editor. -->

### How it was tested

<!-- What you ran and on what.  Tick what applies. -->

- [ ] `npm run lint` and `npm test` pass
- [ ] Tried on a real LG TV (model and webOS version: )
- [ ] Tried in the webOS Simulator or `npm run serve`
- [ ] Old TVs: `bash tests/engines/run.sh browser` (Chromium 53 to 87) for app changes
- [ ] The soak test for the area, if there is one (`npm run soak:tour`, `soak:editor`, `soak:editor-mobile`, `soak:events`...)

### Screenshots or a video

<!-- For anything you can see.  A phone photo of the TV is fine. -->

### Checklist

- [ ] App code still parses on Chromium 53 (webOS 4): no CSS grid, no Unicode property classes in regular expressions; see docs/COMPATIBILITY.md
- [ ] Works with the remote alone (arrows, OK, Back), and with the Magic Remote
- [ ] CHANGELOG.md has a line under "## Unreleased" for anything a viewer would notice
- [ ] The user guide (docs/USER-GUIDE.md) covers new settings or screens
- [ ] No logins, server addresses or stream links anywhere: code, tests, screenshots, logs

## Recent changes (headlines; full text in CHANGELOG.md)

### 0.6.0 (alpha 85, 2026-11-04)
#### New in alpha 85
- A channel whose picture keeps freezing switches to its other format.
- The screensaver stays off while you watch, also right after an update.
- The web editor keeps working after the TV dozes
- Drafts are kept on the TV again
- What's new shows soon after an update.
#### In alpha 84
- One reminder per game for your teams.
- Reminders on the phone show their channel while the TV is still starting.
- The phone's Reminders screen shows the whole time.
#### In alpha 83
- Subtitles look the way you like
- Downloaded subtitles are kept for a week
- Film shape in Aspect
- Notices wait until the movie is over
- The buffer pill's colours fit the Automatic buffer
- The web editor's sign-in on a phone
#### In alpha 82
- MKV subtitles keep up in movies with many languages
- The subtitle menu keeps its settings at the top
