# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is **Memby** — an Android TV (Leanback) Emby client by **ponzischeme89**, written in Kotlin + Compose for TV. It contains two surfaces over one shared data layer: the **client app** (setup → profile chooser → home → player) and the **system screensaver** (`DreamService`, labelled "Memby Screensaver"), which was the project's original purpose and still ships in the same APK. User-facing name is always **Memby**: `app_name`/`screensaver_name`/`developer_name` in `res/values/strings.xml`, and on-screen copy. **Except on the wire to Emby.** The `X-Emby-Authorization` header identifies the client as **`MbyATV`**, not Memby — that header travels to whatever Emby does with its own logs, and the product name has no business being what identifies a client to somebody else. It is sent from two places and they must agree, or one television signing in both ways appears as two clients: `MEMBY_CLIENT_NAME` in the gateway (`internal/config`) and the literal in `EmbyServiceFactory`'s auth interceptor on the direct path. The `Version=` beside it is the **television's app version**, which on the gateway path means `Credentials.ClientVersion` threaded from the session's `X-Memby-Version`; it was a hardcoded `"1.0"`, so every device in Emby's dashboard read as the same build and there was no way to tell which set was behind. A request the gateway makes for itself (library sync, health probe, device cleanup) carries no session, and reports the gateway's own `buildinfo.Version()` instead. `Emby*` class names (`EmbyRepository`, `EmbyApi`, `EmbyServiceFactory`, `EmbyModels`) are kept on purpose: those types model *Emby's* API, and renaming them would make the code lie about what it talks to. App-identity types are `Memby*`. ## Repository layout This is a two-language monorepo. `app/` and `benchmark/` are the Gradle build; `server/` is an independent Go module (the **Memby gateway**) that Gradle does not know about, built and run through Docker. `docker-compose.yml` at the root wires the gateway to Postgres and Redis. The two halves are coupled only by an HTTP contract — see "Gateway mode" below. ## Build, install, test Requires JDK 17 and the Android SDK. `deploy-debug.ps1` sets `JAVA_HOME` to Android Studio's bundled JBR; do the same when invoking Gradle directly if the shell JDK isn't 17. ```powershell .\gradlew.bat assembleDebug # build APK -> app/build/outputs/apk/debug/ .\gradlew.bat test # JVM unit tests (app/src/test) .\gradlew.bat :app:testDebugUnitTest --tests "*MediaBadgesTest" # one test class .\gradlew.bat installDebug .\deploy-debug.ps1 -Serial 192.168.20.3:41479 # force-stop, install, wake, relaunch .\gradlew.bat :benchmark:connectedCheck # macrobenchmarks; needs a connected TV ``` For the gateway (from `server/`): ```bash go build ./... && go test ./... # add -buildvcs=false on Windows if .git is unusable docker compose up -d --build # from the repo root; needs .env (see .env.example) ``` **Deploying the gateway to the NAS** is `deploy-server.ps1` (PowerShell 7): ```powershell .\deploy-server.ps1 # local tree -> 10.0.0.213:/share/Docker/Memby .\deploy-server.ps1 -SourceDirectory C:\src\memby -Destination /share/Docker/Memby-test ``` It tars the local `server/`, `docker-compose.yml` and `.env.example`, and streams them over one SSH connection (interactive password; stdin carries the archive, so OpenSSH prompts on the tty). The remote half stages into `.new.$$`, builds, then swaps directories and waits for all three health checks, restoring the previous release if anything fails. The named Postgres volume is preserved — it never runs `compose down -v`. **`.env.example` is the configuration.** It holds real values, and every deployment overwrites the NAS's `.env` with the local copy (the old one is kept beside it as `.env.previous`). The script requires `MEMBY_PORT=32768`, `MEMBY_ADMIN_TOKEN`, `MEMBY_EMBY_URL` and `POSTGRES_PASSWORD` before activation, then confirms the admin token reached the running container. The database volume is always preserved; deployment stops before activation if the Postgres password differs from the deployed value, because a credential change requires an explicit database migration. The local working tree is deployed directly; no commit or push is required. `local.properties` must contain `sdk.dir=...` when building from the CLI. Lint has `abortOnError = false` (media3's `@UnstableApi` opt-in check would otherwise fail the build), so lint failures do not surface at build time. Unit tests are plain JUnit 4 with no Android/Robolectric dependency — logic that needs testing must live in a pure function or a plain data class (`mediaBadges`, `HomeUiState`, `millisecondsToTicks`, `ringColorFromHex`, `EmbyProfile` handling are the existing examples). ## Identity One name everywhere: **`com.ponzischeme89.memby`** is the Kotlin package, the Gradle `namespace` and the `applicationId`. Identity types are `Memby*` (`MembyApp`, `MembyDreamService`, `Theme.Memby`). Historical note, because old APKs and TVs still carry it: through v0.1.52 the package was `com.mattcohen.embyscreensaver` and the `applicationId` was `com.mattcohen.embyclientsname`. Both changed in v0.1.53. **`applicationId` is the install identity** — changing it makes every TV treat the build as a brand-new app: the old icon stays until uninstalled, the DataStore session is gone, and users sign in again. Treat any future change to it as a migration, not a rename. adb commands, the benchmark `packageName` and `FileProvider` authorities all derive from it. **Versioning.** `versionCode` is derived from `versionName`: `major*10000 + minor*100 + patch` (0.1.53 → 153). Bump both together — the in-app updater compares `versionName`, while Android refuses an APK whose `versionCode` went backwards. `release.ps1 -Version` rewrites both, so prefer it over editing the build file by hand. **Releases.** APKs are self-hosted (NAS or any web server), not on a store. `release.ps1` builds a signed APK and assembles `dist/out/` — `index.html` (landing page from `dist/template/`), `latest.json` (the manifest the app polls) and the versioned APK. Release signing reads `memby.keystore` and friends from `local.properties`; with no keystore the build still succeeds but emits an unsigned APK and logs a warning. The key matters more than the code: Android identifies an app by applicationId **plus** signing key, so a changed key forces every user to uninstall and reinstall. For direct TV deployment without publishing a release, `deploy-tv.ps1` builds and verifies the signed release, connects over wireless ADB, installs it with `-r`, and launches the Leanback activity. It reads the same signing settings from the current user's persistent `MEMBY_KEYSTORE*` environment variables and defaults to the living-room Chromecast endpoint; pass `-Device host:port` when Android rotates the wireless-debugging port. `UpdateChecker` supports two sources, chosen by URL shape in `isManifestUrl` — a `.json` URL is a static manifest, anything else is a Gitea host. `resolveApkUrl` lets a manifest use a relative `apkUrl`. Both are unit-tested in `UpdateSourceTest`. **`CHANGELOG.md` is the version history the TV shows.** It is read into `BuildConfig.CHANGELOG_TEXT` at build time the way `LICENSE` and `NOTICE` are, parsed by the pure `parseChangelog` in `ui/settings/VersionHistory.kt`, and rendered by Settings → About as one collapsible release per entry. So a release edits one file and the history stays readable offline. Keep the `## ` / `- bullet` shape; anything else in the file is skipped as prose, and a bullet wrapped onto a second line is rejoined. **The same file is what a TV shows after it updates itself.** `ui/whatsnew/` puts the running build's changelog entry over the launcher once, and `Settings.whatsNewSeenVersion` (device state, deliberately not a synced preference — what is new is a property of the APK on *this* set) records that it has been. `whatsNewDecision` is the pure rule and holds the three cases that must not show a panel: a version already recorded, a **fresh install** (no record and nobody signed in — everything is new to that TV, so it is marked seen during setup instead), and a build the changelog does not describe, which is marked seen rather than shown as an empty panel. Signed out *with* a record is neither: the notes belong over the launcher, so that launch waits. Things to preserve — it is an overlay composed after `HomeScreen`, not a branch of `AppRoot`'s `when`, so the cached rows are already drawn behind it and nothing about it can delay startup; the version is recorded on dismissal, so a set switched off mid-panel is told again rather than never; and the Continue button points every direction back at itself, or one press of Down walks into rows the viewer cannot see behind the scrim. `maxChangesFor` derives the bullet count from the screen for the same reason detail panes derive their height — a panel that overruns a 720p set has no scrollbar to aim at and hides its own button. **Forced updates are server-controlled.** `server/internal/appupdate` decides `none` / `optional` / `mandatory` from the client's `X-Memby-Version` header against an operator-set policy (admin page → App updates). `HomeViewModel.checkForAppUpdate` runs on every launch and `ui/UpdateScreen.kt` renders the verdict — mandatory covers the whole home screen with `zIndex(10f)`, swallows Back and offers no dismiss. Two safeguards worth preserving: a client with an unreadable version is never forced (it could not escape the prompt), and the client ignores any verdict without a `downloadUrl` (`isActionable`), so a half-configured policy cannot produce a blocking screen with a dead button. The verdict must stay out of `/v1/home`, which is cached per user while this answer varies per client build. ## Architecture **Manual DI.** `ServiceLocator` (initialised in `MembyApp`) holds the single `SettingsStore` and `EmbyRepository`. Activities, composables and `MembyDreamService` all read from it — there is no DI framework and no per-screen repository construction. **Backend selection is build-time config.** Two Gradle properties in `gradle.properties` become `BuildConfig` fields, both read through `data/ServerConfig.kt`: - `memby.gatewayUrl` → `MEMBY_GATEWAY_URL`. Non-blank puts the app in **gateway mode**. - `memby.serverUrl` → `EMBY_SERVER_URL`. The Emby address for the direct path; when set, the repository's `activeServerUrl` prefers it over the persisted `Settings.serverUrl` and `SetupScreen` hides the address field. Prefer `activeServerUrl` over `snapshot.serverUrl` in new repository code, or a hardwired build silently falls back to a stale saved address. `resolveServerUrl` holds the precedence rule as a pure function so it can be unit-tested. **Gateway mode.** `EmbyRepository` is dual-path: every method starts with a `if (ServerConfig.isGateway)` branch that calls `GatewayApi`, then falls through to the original Emby code. Both paths must keep working — the direct path is the fallback when the container is down. Specifics worth knowing: - The gateway forwards **Emby's item JSON verbatim**, so `BaseItem` is the single item model in both modes. Only the envelope differs (`data/model/GatewayModels.kt`). - `Settings.token` holds the *gateway* token in gateway mode and the Emby token otherwise; `Settings.serverUrl` likewise holds whichever backend was signed into. No separate storage slots. - `supportsBatchHome` drives `HomeViewModel`: gateway mode fetches all four rows with one `getHome()` call, direct mode keeps the four-way parallel fan-out. - **Rows are server-composed.** `/v1/home` returns a `rows` array (id, title, kind, items) and `MainActivity.serverHomeRows()` renders it verbatim, so a new row type ships without an app release — an unknown `kind` falls back to poster cards rather than disappearing. `state.rows` is empty on the direct path, where `homeRowsFor()` composes rows locally. Two things are easy to miss: rows hold their own copies of items, so `HomeViewModel.updateUserData` must map over `rows` too or an optimistic favourite won't show on a recommendation card; and `loadBatchHome` keeps the previous rows when a response arrives with none, because the gateway omits recommendations while they build. - **Continue Watching and Next Up are one row**, merged by `api/continue_watching.go`. They answered the same question — "what am I in the middle of?" — and splitting them meant a show moved between rows the moment an episode ended, which is exactly when somebody most wants the next one. Merging is not concatenation: both lists are ordered by recency, so the row is a *merge of two sorted lists*, never a sort of their union — Emby's order within each is the useful part. That needs a time per card, and a Next Up episode has none of its own (it is unwatched), so `recentlyPlayedSeries` asks for the household's recent plays and each episode is placed by when its **series** was last watched. Things to preserve: a series in both is represented by its resume item, since somebody eleven minutes into an episode wants that episode; an undated card never displaces a dated one and falls back to the resume half, so a failed lookup degrades to the order the launcher had when they were two rows rather than to nonsense; and the rule exists twice, in `data/ContinueWatching.kt` for the direct path, pinned by deliberately parallel tests (`ContinueWatchingTest`, `continue_watching_test.go`) — with no gateway there is nobody to ask, and the row must not differ depending on whether the container is up. The client still folds a `nextup` row it is handed into Continue Watching (`foldNextUpIntoContinue`), because the home cache written by the previous build is what a TV draws before its first refresh lands. - **Continue Watching is not ranked** (`progressRow` in `api/ranking.go`). Every other row goes through `personalizeTitles`; this one keeps Emby's order, which is most-recently-watched first and the entire reason the row is useful. Ranking it by taste is not a neutral reshuffle: an episode's row payload carries no studios, cast or collection, its `Type` has no affinity evidence behind it, and its runtime fits a session profile built from features badly — so films sorted to the front, episodes sorted past the visible cards, and a show somebody had just watched an episode of read as having disappeared. The diversity caps and the exploration shuffle in `WeightedRank` compound it. `TestProgressRowsKeepEmbyOrder` pins it. - `HomeCache.rows` persists them for cold start. New fields there need defaults — an existing install decodes a cache written by the previous build. - Image URLs are built by the private `imageUrl()` helper. Coil fetches plain URLs with no interceptor, so the credential rides in the query string either way — `t=` for the gateway proxy, `api_key=` for Emby. - Video always direct-plays from Emby. The gateway returns a URL; it never proxies a stream. Don't route playback through it. - Search is dual-path like the rest: `/v1/search` on the gateway (Postgres full-text, falling back to Emby before the first import), `SearchTerm` on `Users/{id}/Items` directly. `ui/search/` renders it — see "Search" below. The wire contract is pinned from both ends: `GatewayPayloadTest.kt` / `ServerHomeRowsTest.kt` (Kotlin) and `internal/api/api_test.go` (Go). Change a field name or a row `kind` and one of them should fail. **Imported library.** `server/internal/library` copies Emby's catalogue into `library_items` (payload stored verbatim as JSONB, hot fields promoted to columns for filtering plus a generated `tsvector`). Search and the recommendation candidate pool read from it, falling back to Emby when it is empty — so both paths must keep working. It is imported with `EnableUserData=false` on purpose: the table is shared by the whole household, so watched/favourite/resume state must never be cached there and still comes from Emby live. A full import mark-and-sweeps on `synced_at`; incremental uses `MinDateLastSaved` with a minute of overlap. **External ratings** (MDBList) are bought by the day, not by the request, so the design question is never "how fast can we fetch" but "how few times must we ever ask". The answer is that a title is fetched once and kept: `external_media_ratings` holds the raw provider response permanently, `ratingsNeedRefresh` renews a scored title after 30 days and an empty answer after 3 (a new release genuinely gains scores), and a stored value is *always* served immediately — a refresh happens behind the viewer, never in front of one. Redis still fronts it, but only to save the Postgres read. Things to preserve: - **A row carries its own ratings.** `decorateItemRatings` injects `MembyRatings` into the item JSON of home rows, search, related and `/v1/items/{id}`, so a card draws its scores as the row appears rather than when D-pad focus reaches it. It attaches **only what is already stored** — one indexed read for a whole launcher, and never an external request on the request path. `/v1/items/{id}/ratings` still exists for a title nobody has looked up yet, and the client (`ItemRatingsStrip`) falls back to it on focus. - **Identity is the expensive half, so it is remembered.** MDBList is keyed by tmdb/imdb id, which Emby only reveals in a `ProviderIds` lookup — a request per card. `ProviderIds` is therefore in the library import's `syncFields`, and `item_rating_refs` records what each Emby item turned out to be as televisions navigate, so the index fills in without waiting on a full re-import. `ratingKeyFor` holds the rule the live lookup uses: films and shows only, an episode is rated as its series, tmdb wins over imdb. - **The warmer is fed by navigation and bounded three ways.** Rows report the titles they could not decorate; the queue is bounded and deliberately *lossy* (a dropped title is offered again the next time somebody scrolls past it), fetches are paced, and `claimRatingsBudget` caps the day — a 429 stops it for an hour. A household's allowance is spent on titles it actually looks at, in the order it looks at them. **Maintenance mode** gates the whole `/v1` subtree (that's why `Routes()` builds a separate `v1` mux) with a 503 carrying `maintenance: true`. `/healthz`, `/readyz` and `/admin` sit outside it deliberately. State lives in Postgres and is cached in memory, re-read every 30s. Client side, `parseMaintenanceMessage` pulls the operator's message out of the 503 body (trusting only the known `message` field, truncated) and `HomeUiState.maintenanceMessage` — distinct from `statusMessage`, which is the ordinary slow-connection banner — swaps the whole content area for `ui/MaintenanceScreen.kt`. The navigation rail stays mounted beside it so Settings and Switch user still work, and the retry button takes `contentFocusRequester` (with `focusProperties { left = … }` back to the rail) because otherwise D-pad focus has nowhere to go once the rows are gone. **Service alerts.** `/v1/status` is the only thing an open app polls continuously (10s, `MaintenanceMonitor`), so it doubles as the push channel: alongside maintenance state it carries an `alerts` array, and `ui/ServiceAlertBanner.kt` drops one in as a full-width bar across the top of the screen, broadcast-notice style (it spans the navigation rail too). Alerts come from two shapes of producer. **Derived**: `api/alerts.go` announces an episode whose Sonarr air time has passed but which Emby has not imported yet ("aired, coming soon"), recomputed per poll from the *cached* airing-today calendar, so polling clients never cost a Sonarr request. **Events**, which are published into one shared Redis list (`publishAlert` / `publishedAlerts`, also in `alerts.go`) and served from it until their window closes — a list rather than a push because the gateway holds no connection to a television, and a window is what lets a set that was off or in the screensaver at the time still hear the news. Four publishers today: - `api/radarr_alerts.go` — a film Radarr just imported. `POST /hooks/radarr` is the "On Import" webhook and the one thing that pushes *into* the gateway, guarded by `MEMBY_RADARR_WEBHOOK_TOKEN` (unset ⇒ 404, the stance `/admin` takes) and mounted outside both the auth middleware and the maintenance gate, because an event dropped during maintenance is lost rather than delayed. A quality upgrade is deliberately silent: the film was already there. - `AnnounceLibrarySync` in `api/server_alerts.go`, hung off `syncer.SetAfterSync` in `main.go` — "24 titles added or updated". Only a run that *changed* something is announced; the import is scheduled, most passes find nothing, and an hourly "no news" banner would train viewers to ignore the real ones. - `AnnounceDeployment`, same file, published by `deploy-server.ps1` through `POST /admin/api/deployment-alert` — "Memby server in deployment mode". **Where it is published from is the design**: the deploying script calls the gateway it is about to replace, using the admin token from the *deployed* `.env`, before the image is built. That build is the several minutes during which the old gateway still answers and every open TV polls `/v1/status` at least once. Announcing at the swap would be too late twice over — nothing is left to publish with once the stack is down, and Redis runs with `--save "" --appendonly no` and no volume, so the swap discards anything published but not yet collected. It is best-effort on both sides: a first deployment has no previous token to announce with, and a deployment must never fail over a banner. - `WatchEmbyReachability`, same file — "Emby has stopped communicating" and the matching "back online". Only transitions are announced, and only after `embyFailureThreshold` consecutive failures, because one timeout is a hiccup and repeating an outage every minute would bury everything else. This is the alert that earns the banner its place over playback: video direct-plays from Emby, so when Emby stops answering the film stalls with no explanation, and the gateway is still up to say why. `MEMBY_EMBY_HEALTH_INTERVAL=0` turns the probe and both banners off. `mergeAlerts` interleaves derived and published alerts newest-first and caps at `maxAlerts`, which is why every alert carries a timestamp. Alerts also carry their own `label` (the banner's eyebrow — "JUST AIRED", "NEW MOVIE ADDED", "SERVER NOT RESPONDING"), so a new kind of news reads correctly on an app that predates it; a client that receives none falls back to the episode wording. Things to preserve: the server has no idea which TVs saw what, so the client dedupes by id against `SettingsStore.markAlertSeen` (persisted, or every relaunch replays yesterday's news); an alert is only *offered* until the banner calls `alertShown` — nothing is persisted and no dismissal timer runs before that, so one arriving behind the screensaver waits rather than being consumed by nobody, and `pendingAlertExpired` drops it once the gateway stops offering it. The status loop itself runs under `ProcessLifecycleOwner … repeatOnLifecycle(STARTED)`, so a backgrounded app stops polling entirely instead of hitting the gateway every 10s at a TV nobody is watching. The banner is never focusable and times itself out after `MaintenanceMonitor.ALERT_VISIBLE_MS` (10s, with a ring counting it down — take the duration from that constant, or the ring and the timer drift apart), because stealing D-pad focus mid-browse is worse than a missed notice; and alerts are suppressed under maintenance and under a mandatory update, which own the screen. **The bar appears over playback too**, not only over the launcher: `PlayerActivity` mounts the same composable in a `ComposeView` (`player_service_alerts` in `activity_player.xml`, declared before the loading and error overlays so those cover it). `alertsSuppressed` starts *true* and is cleared only by `hidePlaybackLoading`, which is reached once the preroll is over and the first frame is up — an alert composed behind an overlay would be marked seen by a viewer who never saw it, which is the same failure `alertShown` exists to prevent. Because it now covers somebody's film, the bar is deliberately small (76dp), near-black, and eases in over ~680ms rather than snapping down. It shows the **Emby mark**, not item artwork: a library refresh and an outage have no artwork, and one constant mark reads as "your server is talking" where a poster made every alert look like a different feature. The wire still carries `itemId`/`imageTag`; the client just does not render them. `MEMBY_SONARR_ALERT_WINDOW=0` turns them off without touching the schedule row, and `MEMBY_RADARR_ALERT_WINDOW=0` does the same for movie imports. **Emby outage bar.** An alert is news; this is *state*, and both are needed. `internal/api/ emby_health.go` caches what the reachability probe found and `/v1/status` publishes it as `emby: {monitored, reachable, since, checkedAt, retrySeconds}`; `ui/EmbyOutageBanner.kt` renders it as a persistent red strip across the top, on the launcher *and* over playback, counting down to the next attempt. The case it exists for is a television switched on twenty minutes into an outage: it was never told anything, the film will not start, and the alert that announced it has long since fallen out of its window. Things to preserve: - **`monitored` is load-bearing.** `MEMBY_EMBY_HEALTH_INTERVAL=0` turns the probe off, and a client that trusted `reachable` alone would then show a permanent red bar on a server that is working. The server sends `reachable: true` in that case as well, but do not remove either half. - **The bar's threshold (`embyOutageThreshold`, 2) is lower than the alert's** (`embyFailureThreshold`, 3) on purpose. The announcement is a one-shot that cannot be taken back, so it waits to be sure; the bar clears itself the moment Emby answers, so being early costs a minute of red rather than a false claim left standing. - **An outage already on screen keeps its countdown.** The status poll runs six times per retry, so recomputing the deadline each time would reset the number every ten seconds and it would never reach zero. `nextOutageState` is the pure rule and is unit-tested. It counts in `SystemClock.elapsedRealtime`, not wall clock — a TV correcting its clock mid-outage must not throw the countdown. - **The news bar yields the strip while it is up** (`ServiceAlertBanner(suppressed = … || outage != null)`), in `MainActivity` and in `PlayerActivity` alike. They occupy the same place and one of them says it better. - **The direct path has its own probe.** With no gateway there is nobody to ask, so `MaintenanceMonitor.launchDirectEmbyProbe` pings `System/Info/Public` on the same minute — unauthenticated on purpose, since a probe needing a token would report a stale session as a server outage. **Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed 6×6 on-screen keyboard on the left, a results grid on the right that updates as you type. Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)` → `trim` → `distinctUntilChanged` → `collectLatest { repository.search(it) }` — and `collectLatest` is the load-bearing part: it cancels the in-flight request, so a slow response for a prefix can never overwrite the results for what was typed after it. Searching starts at two characters (`shouldSearch`); one letter matches half a library. `rankSearchResults` is a pure, stable sort that only lifts exact/prefix/word-boundary title matches above the backend's own relevance order — it never re-sorts alphabetically, and it keeps weak matches rather than showing an empty pane. A small access-ordered map caches results per query for the session, so backspacing is instant. Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the rightmost goes to the results grid, the grid's first column goes back to the *last key used* (a `FocusRequester` attached to whichever key that is), and the grid has a `focusRestorer`. Back moves results → keyboard → clear query → leave, one step per press. Physical keyboards and phone-remote apps feed the same state through one `onPreviewKeyEvent` that consumes only printable characters and backspace — D-pad and Back must fall through. The voice button needs the `android.speech.RecognitionService` entry in the manifest's ``, or `isRecognitionAvailable` returns false on Android 11+ and it hides itself on devices that actually support it. **Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events with dwell timing (injectable clock, unit-tested) and `HomeViewModel` flushes every 20s, on `ON_STOP`, and on dispose. Fire-and-forget by design — `reportRowEvents` swallows failures, because telemetry must never surface on a TV. Aggregates are read at query time in `store.RowStats`; raw events are pruned after 90 days. **Server logging** answers "who did what, from which television, on which build". Three pieces make that true and each is easy to undo: - `internal/logging` writes an **aligned console line** — timestamp, level, message, fields — because a labelled `time=` in front of the message is noise in every viewer that already has a timestamp column. Fields are ordered by `fieldRank`: identity (`component`, `user`, `device`, `client`) first so it can be read as a column, the constant `version` and the `error` last. `MEMBY_LOG_FORMAT` switches to `logfmt` or `json`; the ring buffer the admin page reads is fed the same records in every format. - `internal/api/logcontext.go` carries a **`*requestIdentity` in the request context**. `withLogging` creates it from the route and the client headers; `authed` fills in the viewer and television once the session resolves; both the handler's own events (`s.loggerFor(ctx)`) and the closing request line read it. It is a pointer precisely so the outer middleware sees what an inner layer learned — `r.WithContext` in the handler would not reach it. Prefer `s.loggerFor(ctx)` over `s.log` anywhere a request is in scope, or the line lands with no idea whose it was. - `componentFor(path)` is **the part of the app a call came from, derived from the route** rather than declared by the client: the TV would have to thread a surface name through every repository method, and this way an old APK is attributed correctly too. Keep it a pure function with a case per area, and add to the test when a route lands. The events that matter are logged as events, not inferred from request lines: sign-in (and rejection), sign-out, device removed/renamed, `playback requested` / `started` / `stopped` (with `watched=`), `next episode resolved`, `update offered`, media requests, maintenance and feature changes, library syncs. Playback reports carry only an item id, so `playbackTitles` (bounded, in-memory, lossy on restart) remembers what `/v1/items/{id}/playback` called the thing, which is what lets a stop be logged by name. Ten-second progress reports and per-keystroke searches are DEBUG on purpose. `internal/buildinfo/VERSION` is embedded and appears on every line, on `/healthz` and in the admin rail — bump it with a meaningful server change; nothing else identifies which tree a container was deployed from. **Admin interface** is `server/internal/api/admin/` — a shell, a stylesheet, a shared runtime and one fragment per page, all embedded and composed by `admin_console.go` at start-up into finished bytes per URL. No build step and no CDN: a strict no-dependency console is still the whole point, and serving a page is still a write of a `[]byte`. Guarded by `MEMBY_ADMIN_TOKEN`; unset means every `/admin` route 404s. It polls `/admin/api/status` every 30s and stops entirely on a hidden tab. It was one HTML file holding every screen at once, all but one of them hidden, which is why opening `/admin/logs` also sent the accounts settings editor and the feature grid, and why each page read as a pile of unrelated controls. Things to preserve: - **`adminNav` is the only place a page is declared.** The rail, the page titles, the set of legal `/admin/` URLs and the render loop all read it, so a page cannot be in the menu and 404, or be reachable and unnamed. Adding one is an entry there plus `admin/pages/.html` and `.js`; a fragment with no entry panics at start-up rather than sitting there looking maintained. - **Hidden pages are addressed by a route that carries something else in the path.** `/admin/accounts/{userID}` renders the `account` fragment; `/admin/account` is a 404 and `cleanInstallerDestination` refuses it, because a sign-in returning there would land on a page about nobody. That route sends the login form back to `/admin/accounts` instead. - **The page fragments contain no inline styles.** Everything is drawn from the component vocabulary in `admin.css` — card, tile, field, check, tag, chip, list, table, glyph. A screen that needs a look of its own is a missing component, not a licence for a style attribute; the previous page had four hundred lines of CSS and still reached for `style="..."` on every second element. - **Six tones, and three of them mean nothing.** Green is the verdict colour, amber is look at this, red is wrong — and beside them are info (blue), note (violet) and data (teal), which carry no judgement at all. They are what lets a page say a person is a different kind of thing from a television without every coloured element on screen reading as a warning. A tone is passed, never derived: the same idea wears the same colour on every page it appears on (the library is teal, a person violet, a television blue), which is most of what makes twelve screens read as one console. A verdict tag also carries a dot in its own colour, because tone alone is no signal to somebody who cannot separate the green from the amber. - **Icons live in `core.js` and nowhere else** — one stroked path on a 24×24 grid each, the same shape the rail's marks take. A fragment asks for one with `data-icon` (plus `data-icon-tone`) on any element and `Admin.decorate` fills it in once, when the page has parsed; anything a poll redraws asks with `ui.glyph`, or the mark is wiped on the first refresh. An unknown name draws nothing rather than a broken box: a mark is decoration, and a typo in one must never be what an operator notices about a page. - **`core.js` owns the transport, the error banner and the refresh loop**; a page registers `Admin.onStatus` (called with each status poll) or `Admin.onRefresh` (its own request alongside it). `Admin.settled`/`fill`/`check` are the one rule that must not be dropped: never redraw markup the operator is working inside, or every poll takes a half-typed field or an open select away mid-edit. - **Nothing on the overview page is editable.** It answers "is anything wrong" and links to the page that can do something about it. A screen that both summarises and changes state is where an accidental click lives. **Explaining a recommendation** is `recommend/explain.go`: `Why(profile, item, limit)` is a pure function turning the learned weights into the phrases a detail page shows. It is kept apart from `Score` on purpose — the scorer decides *order* and may be opaque, this decides *wording* and must never invent an affinity, which is what `reasonFloor` and the unit tests enforce. `PersonWeights` exists only for this: casting is a good reason to tell someone about a title and a poor reason to rank by it. `api/related.go` serves it beside Emby's similarity list at `GET /v1/items/{id}/related`, cached per user and item because building the profile costs the same Emby fan-out the home rows pay for. **Recommendations** live in `server/internal/recommend`: `profile.go` is pure scoring (recency-weighted genre/studio affinity, exclusion of anything seen) and `engine.go` does the Emby fan-out. Both are unit-tested without a network — `engine.go` takes a narrow `Source` interface so tests inject a fake. The engine never runs on the home request path: rows come from the `r::rows` cache, and a miss triggers a deduplicated background rebuild while home returns immediately. That key is intentionally outside the `u:` namespace that mutations wipe; only a finished playback retires it. **`EmbyRepository`** is the only place that talks to Emby. It keeps a `@Volatile` `snapshot` of `Settings` collected from DataStore so synchronous callers (URL builders, `rotationIntervalMillis`) don't suspend, and it caches the Retrofit `EmbyApi` instance, rebuilding only when the base URL changes. All image and stream URLs are built here with `api_key` appended. Errors reaching the UI go through `friendlyEmbyError` — never surface raw HTTP bodies, which can contain tokens (the OkHttp logging interceptor is pinned at `Level.NONE` for the same reason). **Emby query conventions.** List endpoints request the narrowest `Fields` / `EnableImageTypes` set that the row needs (`getHomeItems` enforces this); full metadata is fetched only via `getItemDetails` after D-pad focus settles (140 ms debounce in `HomeViewModel.focusItem`, with an LRU cache and cancellation of the in-flight job). Adding fields to a home query is a startup-cost regression — extend the detail call instead. Emby time values are 100-ns ticks; convert at the boundary (`millisecondsToTicks`, `resumePositionMs`). **Synced settings.** A viewer's settings live on the *server* and follow the person to whichever television they sign into; an operator can also read and push them per user from the admin console's accounts page. Three pieces: - **The vocabulary is `internal/api/preferences.go`**, a `preferenceCatalogue` in the same shape as `featureCatalogue`, and it is the only place that decides what a legal value is. The admin console renders its editor straight from it (it rides along on `/admin/api/accounts`), so a new setting is one catalogue entry plus the matching key on the TV. The store holds the document opaquely — adding a setting is never a migration. `normalizePreferences` returns a *complete* document with unknown keys dropped and illegal values replaced, which is what stands between a hand-edited admin request and a launcher that cannot draw a row; it is the piece worth testing hard. - **The revision is the delivery mechanism.** `user_preferences` carries one, `/v1/status` carries the current value, and `PreferencesSync` fetches the document only when it differs from what this TV holds. That is why an operator's push arrives within a poll without a second connection, and why the poll stays one integer for every open TV. Writes take an advisory lock and a revision check because every television in the house writes this row; a 409 returns the winner's document in the body, and the client **adopts rather than retries** — the other writer is usually the operator. `store.ForceRevision` is how the admin push deliberately wins that race. - **`PreferencesSync.lastSynced` is what stops a feedback loop.** It records the document both ends agreed on, and a push happens only when the local state differs from *it* — so adopting a pull, which writes to DataStore and re-emits the settings flow, never looks like a local edit. Keep that invariant or the two ends will push each other forever. **Every revision is kept, and the operator can put one back.** `user_preference_revisions` holds the whole document per revision with who wrote it, written in the *same transaction* as the document itself, so there is no state in which a revision exists and nothing records where it came from. `internal/api/admin_preferences.go` serves it at `/admin/accounts/{userID}/settings` — a hidden page, addressed by a route carrying the person in the path like the account page it is reached from. Things to preserve: - **A restore is a forward write, never a rewind.** It goes out as the *next* revision carrying an old document, with `restored_from` recording where it came from. The revision is the entire delivery mechanism — televisions compare numbers — so one that went backwards would leave every set in the house believing it was already up to date while holding what the operator had just replaced. It is also what makes a restore undoable: the version it replaced is still a row below it. - **The restored document is re-normalised.** A revision written before a setting existed has nothing to say about it, and one written before its options changed may hold a value the server would now reject — restoring verbatim would put that on a television. - **What a row *says* is `preferenceChanges`**, a pure function driven entirely by the catalogue, so a setting added tomorrow is described without touching it. It compares the rendered *labels* rather than the values: two documents that read identically have not changed anything an operator can see, and a television pushing back the document it already held is an ordinary event that must not fill the table with rows nobody made. The oldest revision held is labelled `initial` rather than diffed against the defaults, which would claim decisions nobody made. - **`user_preference_acks` is the receipt the revision does not have.** The status poll tells every open TV the number; being told is not having adopted, so an ack is written when a set *fetches* the document (and when its own write is accepted, or its 409 hands it a winner it adopts). That is what separates "the bedroom TV never fetched it" from "it fetched it and something has since overwritten it". History and acks are pruned together at write time by `preferenceHistoryLimit`, except that a device's most recent ack is never pruned — a set switched off for a year is exactly the one worth describing as "on revision 12" rather than as one that has never checked in. **Upgrading an existing install is schema 2** (`SettingsMigrationLogic`, `CURRENT_SETTINGS_SCHEMA`). Nothing is lost: the flat keys already hold every value and are what the app renders from, so a viewer sees their settings unchanged on first launch, and `preferencesRevision` starts at 0 — which means the first sync *pushes* what the TV has rather than pulling defaults down over it. The one thing that needed a migration step is the three toggles that moved from device-wide to per-profile: an existing install has them in the flat keys and in no profile, so they decode as the defaults, and the first profile switch would have copied those defaults back over the flat keys (`applyProfile` writes profile → flat) and then synced the result up as a deliberate choice. Step 1→2 folds the device-wide value into every stored profile, which is exact rather than approximate — while it was device-wide, that value really was in force for all of them. It cannot detect its own work (the encoder omits default values, so a profile that chose `true` is byte-identical to one that never chose), so the schema version is the only thing making it run once; a test pins that. What syncs is a person's choices; what does not is anything identifying a *television* — device name, update source and token, the screensaver's rotation and ring colour. `showTitleLogo` / `autoPlayNextEpisode` / `showTenMinuteReminder` moved from device-wide to per-profile as part of this, because a device-wide value would push whoever signed in last into everyone else's account. `SettingsStore.applyRemotePreferences` writes all seventeen keys and the revision in **one** edit — DataStore rewrites the whole file per edit, and the revision landing apart from the values it describes would leave a TV permanently believing it was up to date while holding something else. **Multi-profile session state.** `SettingsStore` stores a list of `EmbyProfile` (server, token, userId) *and* mirrors the active profile into the flat top-level keys the rest of the app reads. `switchProfile`/`saveSession` must keep both in sync; `legacyProfile()` synthesises a profile from the flat keys for installs that predate the list. `deviceId` is intentionally preserved across `clearSession()`. **`deviceId` is the television's identity**, in Emby's devices list and in Settings → Devices alike: the gateway holds one session per `(user, device_id)` and Emby keys its own device record on the same value, so a set that signs in with an id either list has seen replaces its entry rather than adding one. It therefore has to outlive the app's own storage, which on these sets it does not — every APK is sideloaded, an install that will not go over the old one is done by hand as an uninstall and reinstall, and the DataStore's `ReplaceFileCorruptionHandler` empties the file after a process killed mid-write. So `deviceIdFor` derives it from `ANDROID_ID` (hashed, so the platform id is never sent anywhere) rather than generating a UUID, and falls back to a random id only for the values that identify nothing — null, blank, all zeroes, or the one a batch of early devices shared, where two televisions would otherwise become one. An id already stored is kept: changing it is the duplicate this avoids, which is why installs predating this keep theirs and converge only when they are next reinstalled. Removing a device is two deletions, not one. `handleDeleteDevice` (and the admin console's equivalent) revokes the gateway session and then calls `retireEmbyDevice`, because logging out only invalidates the token — Emby keeps the device row in its dashboard until the row itself is deleted, so a TV removed from one list would stay visible in the other. It is best-effort on purpose: the session is already gone, which is what ends that TV's access, and it needs the sync credentials since the record belongs to the server rather than to the viewer. **A television that changes its device id supersedes its old one.** Sessions are unique per `(user, device_id)`, so a second row for one set can only mean the id itself moved — a reinstall on a build that generated a random one, or an install predating the derived id. Left alone each of those keeps a session row, an Emby device record and a build history of its own, and one set in a living room reads as three. `store.supersedeDevices` runs inside `CreateSession`'s transaction, deletes the same user's other rows carrying the same device *name*, and hands them back so `retireSupersededDevices` can take the cached session, the build history and the Emby record with them. Two things hold it up: the match is on the name because it is the only evidence there is — the token, the session and the Emby record are all new — and `supersedeName` (pure, tested) refuses a blank name and `store.DefaultDeviceName`, the compatibility placeholder an unnamed build sends, or the second unnamed set in a household would sign the first out on every launch. It happens after the sign-in has succeeded, because tidying a set's previous life must never be what stops it getting in. **Build history is per device id, not per session** (`device_versions`). A session row carries only the version in force right now and is overwritten by the next call reporting a different one, so on its own "what has this set been running" is one value deep. It is written from two places and needs both: `handleLogin` for a fresh sign-in, and `captureClientIdentity` for a set that updated *itself* and will therefore never sign in again — guarded there on the version actually having moved, since every authenticated request reaches that path. It is keyed on the device rather than the viewer because the history belongs to the television, and it is deleted wherever a device row is. The profiles blob deliberately does **not** carry cached home JSON. Preferences DataStore rewrites and fsyncs the whole file on every edit, so embedding a several-hundred-KB cache per profile meant every settings toggle rewrote all of them. `writeProfiles` is the single writer and strips the field out into a per-profile `home_cache::@` key, which doubles as the migration for installs that still embed one — `applyProfile` reads the dedicated key and falls back to the embedded copy. Add a profiles write and it must go through `writeProfiles`. That per-profile key is also the **read** path (`activeHomeCache`), and the only place the cache is written. The flat `home_cache` key remains only for a store with no active profile to key against, and for installs written before the split. It briefly held a second copy of every cache alongside the per-profile one, which put the largest value in the store into the file twice — on a format that rewrites and fsyncs the whole file per edit, and on a value rewritten by every home refresh. **Home startup path.** `HomeCache` (last successful home response) is persisted per profile and used as the initial `HomeUiState`, so the launcher renders rows before the network returns; sections then refresh in parallel under a `Mutex` and re-persist. Playback stops are broadcast through `repository.playbackStops` and refresh only the Continue/Next-Up rows. Three things protect that "before the network returns" promise, and all three are easy to undo by accident: - **Nothing on the signed-in path may block on a request.** `AppRoot` used to hold the launcher on the loading screen until `getRecommendationOnboarding()` answered, which on a slow connection meant the cached rows could not be drawn until the connect timeout expired. Onboarding completion is now persisted (`Settings.hasCompletedOnboarding`, `markOnboardingCompleted`) and consulted first; the gateway is still asked in the background and stays authoritative for anyone not yet recorded. For a profile with no record yet the ask is still on the critical path, so it is bounded by `ONBOARDING_CHECK_TIMEOUT_MS` and fails open to the launcher — and only a verdict the gateway actually returned is persisted, or a single slow response would retire the rating screen for someone who has never seen it. The `onboardingToken` guard that stops repeat asks must always be paired with a null check on the verdict itself: on its own it can match a restarted effect's own token and strand the launcher on the loading screen permanently. - **The settings flow must never be able to terminate.** It is the gate everything waits behind, and a `shareIn`ed flow that completes exceptionally never emits again — so a single failed read shows up as "Opening Memby…" forever, surviving relaunch. Hence the `ReplaceFileCorruptionHandler` on the DataStore and the `catch` before `shareIn`. Do not remove either: this store is rewritten on every home refresh, so a process killed mid-write is an ordinary event on a TV. - **The cache is decoded off the main thread.** `SettingsStore.primeHomeCache` parses it on the store's IO scope as the settings flow emits, and `homeCache()` returns that memoized copy — `HomeViewModel`'s constructor runs during composition, so decoding there parsed the whole blob on the main thread at exactly the wrong moment. - **`setHomeCache` skips unchanged writes** (`lastPersistedHomeCache`). It runs on every refresh *and* every playback stop, and most passes find nothing new. **Screensaver hosting.** `ScreensaverContent` is shared by `MembyDreamService` and `ScreensaverActivity`. A `DreamService` is not a `ComponentActivity`, so `DreamLifecycleOwner` supplies the ViewTree lifecycle/ViewModelStore/SavedState owners Compose requires; D-pad handling lives in the composable while the hardware Play/Pause key is intercepted in `dispatchKeyEvent` and routed via the `ScreensaverActions` holder. Playback from the dream `finish()`es first and starts `PlayerActivity` on a delayed main- thread post to avoid the "activity behind the dream" race. **In-app updates.** `UpdateChecker` polls a user-configured **Gitea** release (`/api/v1/repos/{owner}/{repo}/releases/latest`, token auth for private repos), downloads and verifies the APK, then commits it to a **`PackageInstaller` session**. Because replacing the APK kills a running Dream and leaves a black surface, `UpdateRecoveryReceiver` catches `MY_PACKAGE_REPLACED` and relaunches `MainActivity` with `EXTRA_LAUNCH_UPDATED_SLIDESHOW`. The session is not an implementation detail — it is why the updater works on a television. The phone idiom, an `ACTION_VIEW` intent at the APK's content URI, fails three ways here: the implicit intent is subject to package visibility on Android 11+, several TV builds expose no activity for the package-archive MIME type at all, and nothing reports what the installer then did. Sets that hit it had to be reinstalled by hand to move a version. So: - **`verifyApk` must distinguish "wrong key" from "could not read the key".** It compares the downloaded APK's signers against the installed app's, and the trap is that `getPackageArchiveInfo` leaves `signingInfo` null on several Android versions where `getPackageInfo` fills it in. Asking only for `GET_SIGNING_CERTIFICATES` therefore produced an empty set for the archive, which the check reported as *"not signed by Memby's trusted release key"* — on correctly signed APKs, every release, leaving a manual reinstall as the only way to move a version. So: both flags on both sides, prefer `apkContentsSigners` and fall back to `signatures`, and let `signerVerdict` (pure, unit-tested) return `UNVERIFIABLE` rather than `MISMATCH`. An unverifiable read proceeds: the APK has already been matched against the published SHA-256, package name and version, and Android enforces signature identity at install time regardless — a real mismatch comes back as `STATUS_FAILURE_CONFLICT` with a message saying to reinstall. - **The outcome is a broadcast, not a return value.** `downloadAndInstall` succeeding means the session was *committed*; `InstallResultReceiver` receives what happened and publishes it on `AppInstall.messages`, which every screen that can start an install collects. A mandatory update is the reason this matters: the screen cannot be dismissed, so "Opening the installer…" with nothing following it is a dead end with no explanation. `installStatusMessage` is the pure wording rule and is unit-tested — a TV has no logcat and no support channel, so that sentence is the whole diagnosis. - **`STATUS_PENDING_USER_ACTION` is the normal path, not a failure.** The system hands back an intent for its own confirmation screen and the receiver must launch it. - **The permission is asked for during setup, not at update time.** Every TV here is sideloaded, in practice through Downloader — which means *Downloader* holds Android's per-app install permission and Memby never does. `ui/InstallPermissionScreen.kt` sits between `FirstRunScreen` and `SetupScreen` on a fresh install, and is deliberately **skippable**: a permission that only matters later must never block a new install, and on a TV with no permission screen there would be nothing the viewer could do to satisfy it. The numbered steps are the substance — Android's own screen is an unexplained list of app names with switches, reached by a remote. - **The operator can push that step to TVs already in service**, which is the half that fixes the existing fleet rather than only new installs. It is an ordinary entry in the gateway's `featureCatalogue` (`install_permission_prompt`), so it rides the status poll and the admin console renders its toggle with no extra work. Three conditions guard it and all three matter: the client must declare `install_permission_v1` (an older app can never be sent a screen it does not have), the operator must have it on, and the permission must actually be missing — which is what makes it self-clearing, since granting it removes the only reason it appears. `MaintenanceMonitor.installPermissionPrompt` defaults to **false**: a missing field must not conjure a screen. - **The permission dead end is stated, not retried.** Many TV builds do not implement `ACTION_MANAGE_UNKNOWN_APP_SOURCES`; when starting it fails, the message names the path through the TV's own settings instead. Before this the failure was swallowed and the screen promised to continue "when you return" from a screen that never opened. - Stale sessions are abandoned before a new one is created (they hold a staged APK and count against the per-app limit), and `UPDATE_PACKAGES_WITHOUT_USER_ACTION` lets later updates apply silently once Memby is its own installer of record — never depended on, since the system falls back to asking. **Playback** uses Emby's direct stream (`/Videos/{id}/stream?static=true`) — no `PlaybackInfo`/transcode negotiation, so exotic codecs may fail. The `media3-exoplayer-hls` dependency is already present for when that's added. Progress is reported back to Emby via `reportPlaybackStarted/Progress/Stopped`. **Which subtitle comes on is the gateway's decision**, not the television's. The server is what calls `PlaybackInfo` and enumerates the streams, so `selectSubtitle` in `internal/api/subtitles.go` picks one from the viewer's synced settings (`subtitlesEnabled`, `subtitleLanguage`) and `/v1/items/{id}/playback` and `/next` return it as `selectedSubtitleId`. Turning subtitles off, or choosing Italian, in the player's overlay writes those two settings (`SettingsStore.setSubtitlePreference`), so the choice follows the person to every set rather than staying in the room it was made in. Things to preserve: - **The rule exists twice on purpose.** `selectSubtitleId` in `data/SubtitleSupport.kt` is the direct path's copy, and the two are pinned by deliberately parallel tests (`SubtitlePreferenceTest`, `subtitles_test.go`) — with no gateway there is nobody to ask, and a viewer must not get different subtitles depending on whether the container is up. The language alias table is duplicated for the same reason: Emby writes three-letter codes, media3 reports two, and both ends have to agree on what "Italian" means. - **A chosen language that the title does not have falls back to a *forced* track and nothing else.** Falling through to the default would put English on screen for somebody who asked for Italian; forced subtitles translate what is foreign to the film's own audio and are wanted either way. - **`subtitlesEnabled: false` disables the text track explicitly.** Declining to select one is not enough — media3 turns on a default-flagged track by itself, so "off" has to be said. - **`selectedSubtitleId` is matched on `Format.id`**, which is the id the sidecar's `SubtitleConfiguration` was built with. It can legitimately fail to match a container's embedded track, which is why `preferredTextTrack` falls back to running the same rule over the player's real tracks rather than giving up. **A subtitle the library does not have is fetched through Bazarr**, from the player, in `server/internal/bazarr` and `internal/api/subtitle_download.go`. The whole feature rests on one property of Bazarr: it writes the file *beside the media file*. So the gateway stores nothing, serves nothing and never holds a provider credential — it asks Bazarr to fetch, calls `emby.RefreshItem` so Emby notices, waits `embyRefreshSettleDelay`, and re-reads the streams; the new track then arrives down the ordinary `PlaybackInfo` path, which is why `playableSubtitle` needed no new shape and `selectSubtitle` works on it with no special case. Things to preserve: - **The hard part is identity, not the download.** Bazarr keys on the *arr's id (`radarrid`, Sonarr's `episodeid`) and Emby knows neither, so `bazarrMovieFor` / `bazarrSeriesFor` / `bazarrEpisodeFor` match by title, year and episode number. They are pure and tested hard because a mismatch writes one film's subtitle next to another. Episodes match on *numbers*, never titles — the two disagree often enough (translations, differently named two-parters) to reject correct matches — and season 0 is specials, a real season, not "no season". - **The provider row is opaque.** `subtitle` is a provider-specific token that must be handed back verbatim on the download call; it round-trips through the television untouched rather than living in a server-side cache, so a viewer reading the list by remote cannot have their choice expire underneath them. - **`subtitleDownloadAvailable` rides the playback response**, not `/v1/status`: the drop-up is the only thing that asks and it already holds that response, where the status poll is made by every open TV every ten seconds. It is `bazarr != nil` **and** the `subtitle_download` feature, and the client default is **false** — a missing field must never conjure a row that leads to a request the backend cannot answer. - **Client-side it is a second screen, not a third section.** The drop-up is 344dp by about a third of a 720p screen; stacking a track list, the size chips and search results squeezed the tracks to one visible row. `SubtitleDownloadState.expanded` swaps `player_subtitle_main_section` out, and Back steps out of the download half before it closes the menu — one press per level. - **`subtitleRequestInFlight` is a flag, not `job?.isActive`.** `lifecycleScope` uses the immediate main dispatcher, so a coroutine body runs synchronously up to its first suspension — before `subtitleSearchJob =` has been assigned. Reading the job would see the previous one on exactly the redraw meant to disable the rows. - **A download resets `subtitleAutoSelectionAttempted`.** The new track only exists in a freshly built media item; without the reset the sidecar is attached and nothing turns it on. **Changing it mid-film is a drop-up**, `player_subtitle_overlay.xml`, anchored over the subtitle button it opens from rather than the full-height panel it used to be. It sits on somebody's film, so there is no scrim and the panel is near-black: the option under focus is the only fill on it (accent green), the choice in force is a quiet grey plate with a green label, and every other row has no background at all. The margins are measured off the transport row — 44dp end, 112dp bottom to clear its 72dp strip and the controls' 28dp padding — and the top margin is what caps the track list before its `ScrollView` (which takes the overflow via `layout_weight`) starts scrolling instead of growing up the screen. `bindSubtitleMenu` in `ui/player/SubtitleMenu.kt` fills it from plain `SubtitleMenuEntry` lists so `SubtitleMenuScreenshotTest` can render the real menu with no player, server or decoder → `build/screenshots/subtitles-menu/`; deciding *what* the entries are stays in `PlayerActivity`, the only thing that can read media3's tracks. Only the first open animates — redrawing after a choice would slide the menu again under someone still working down it. **Who is that? is a button, not a menu item.** The cast panel has its own `player_cast` control beside the subtitle button in `memby_player_controls.xml`, because the question is asked mid-scene and one that has to survive a dialog and four menu rows is one nobody asks twice. It is deliberately *not* also in `showTrackMenu`'s list: one thing reachable two ways is one thing whose two entry points drift apart. Things to preserve: - **The panel is a fade, not a card.** `player_cast_scrim` carries it up from the bottom edge so the scene stays legible above the names — which is the reason somebody opened it. The 48dp side inset matches the transport row, so opening it does not shift the column the title and controls are read in. - **The heading is the title, not the word "Cast".** The accent eyebrow above already says what the panel is; repeating the button just pressed costs the line that could confirm what is being watched. - **Initials sit behind every portrait** (`castInitials`, pure and tested). Emby has no photo for a good part of a typical cast, and a row of identical grey rectangles says nothing about which name is which. They are behind rather than instead of the image, so nothing has to decide in advance whether artwork will arrive. - **The focus ring is the `foreground`**, drawn over the artwork, and the portrait takes `duplicateParentState` because the *card* is what is focusable. A remote has no hover: the ring and the scale are the only thing saying which face is selected. - `bindCastPanel` in `ui/player/CastPanel.kt` takes a `CastPanelState` and an injected image loader, so `CastPanelScreenshotTest` renders the real cards with no player, server or network → `build/screenshots/cast-panel/`. `loaded` is separate from an empty list because "still fetching" and "no cast recorded" are different things to be told. **Time to first frame** is the number playback is judged by, and a *resume* is the worst case: it is a seek, and a seek over HTTP is several more requests before a single frame is decoded. Four things exist to hold it down, and each is easy to give back: - **`ui/player/PlayerEngine.kt` builds the player**, apart from the activity, because construction is on the critical path of every launch. It pulls media bytes through `HttpStack` rather than media3's own HttpURLConnection client, so the header, index and offset requests a resume makes reuse one connection instead of repeating the handshake three times; and it enables constant-bitrate seeking, so a container with no usable seek table computes the offset instead of reading its way there. Both are borrowed from [Wholphin](https://github.com/damontecres/Wholphin), a Jellyfin TV client under the same GPL-2.0 licence. - **`PlayerActivity.onCreate` is ordered as critical path then decoration**, with the comment saying so. Build the player, hand it the stream, *then* wire the overlays. This is safe because media3 posts its callbacks to the main thread and none can arrive until `onCreate` returns. The service-alert `ComposeView` mounts on the first call to `hidePlaybackLoading` and the cast lookup runs from `startPlaybackSession`: both used to run during `onCreate`, spending Compose's first composition and an Emby request at exactly the moment the decoder wanted the main thread and the connection pool. - **A resume opens the player before the stream is resolved.** `PlaybackRequest` carries what the launcher already knew from the card, `PlayerActivity` resolves the stream while the activity, its layout and its decoder are starting, and `adoptPlayable` takes on whatever the server settled (for a series, which episode). A cold start deliberately still resolves first: its wait is already spent inside the pre-roll, which cannot begin until there is a stream playing behind it, and whether there is a pre-roll at all is part of the same answer. `MainActivity` keeps two states for this — `launchingItem` is the gate that stops a second Play press stacking a second player, `resolvingItem` is the loading screen and belongs only to the route that waits. - **`ui/player/PlaybackTrace.kt` says where the time went.** "Playback is slow" is not actionable; `event=first_frame … activity=…(+…) player=… stream=… prepared=… ready=… first_frame=…` is. Marks are cumulative from the Play press, a repeated stage keeps the first time it was reached, and a stage that never happened is absent rather than zero. `PlaybackTraceSections.kt` names the same two spans for a systrace so `:benchmark` can measure what the log can only report — see "Benchmarks" below. Measured on a Chromecast with Google TV against the NAS gateway, the shape is: `prepare()` → first frame is **over 90%** of a resume, the stream negotiation is ~120 ms and the app's own startup ~365 ms. Within that, the largest single term is **cold versus warm connection to Emby** — the same file, same seek, was 4177 ms on the first playback of a session and 2004 ms on the second. Artwork and API traffic go to the *gateway* host, so the pool has nothing open to *Emby* when the first playback starts, and that first resume pays DNS, TCP, TLS and Emby's file open. Pre-warming that connection is the open opportunity. Two things that look like causes and are not: the subtitle auto-selection costs 20–200 ms, not seconds, and the seek itself is about 900 ms. **Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up `player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings → Playback turns it off; `Settings.autoPlayNextEpisode`). Which episode that is comes from `repository.nextEpisode`, dual-path like everything else: `/v1/items/{id}/next` on the gateway, `Shows/{seriesId}/Episodes?AdjacentTo=` directly. Both rely on Emby returning `[previous, current, next]` in running order, so it is the *position* of the current episode that identifies the next one — never the length of the list, which shrinks at both ends of a season (`episodeAfter` in `playback.go`, unit-tested). Three things are easy to break: the countdown is driven off the playhead, not a timer of its own, so pausing holds it and seeking backwards out of the window re-arms it; advancing swaps the `MediaItem` inside the running player instead of relaunching the activity, so `itemId`/`playbackStarted` /`stopReported` must all be reset together or the outgoing episode is never reported stopped; and a movie simply resolves to null, which is why nothing special-cases item type. **Skipping is Left and Right, and it does not open anything.** `ui/player/SeekControls.kt` holds the arithmetic and the wording; `PlayerActivity.dispatchKeyEvent` owns the keys and `player_seek_indicator.xml` is the centred chip that says what just happened. How far one press moves is `Settings.seekIntervalSeconds` — 10, 20 or 30, a synced per-profile setting like the subtitle ones, with the same vocabulary in `data/SeekPreference.kt` and in the gateway's catalogue. Things to preserve: - **A press moves a target, not the playhead.** `SeekPreview` accumulates and the seek is committed `SEEK_COMMIT_DELAY_MS` after the last press, because a seek over HTTP is several requests before a frame is decoded — four quick presses must be one seek of two minutes, not four the viewer sits through in turn. It also has to accumulate against the *previous target* rather than the live position, or the film running underneath swallows part of every press after the first. - **The keys are only taken while the transport is hidden** (`seekControlsActive`, the same gate shape as `centrePausesPlayback`). With the controls up, Left and Right belong to whatever holds focus, and taking them would leave the subtitle and cast buttons unreachable. A stream that is not seekable falls through to media3 instead: nothing errors and nothing claims to have skipped. - **Only discrete presses count.** A held key repeats at the platform's rate, which is fast enough to throw somebody minutes down a film they meant to nudge — the repeats are consumed rather than acted on, so letting go does not open the transport either. - **A pending skip is committed in `onStop`** and dropped by `resetSeekControls` when the episode underneath changes, or the position reported to Emby — and so where the title resumes from — is one the viewer had already skipped past. - **The buffering a skip causes belongs to the skip** (`seekBuffering`), so the loading overlay is withheld while the seek lands and the OSD is held up in its place — the viewer asked to move, and "+30s · 1:12:40" over their film is the answer to that where "Opening Memby…" reads as a failure. It also un-wedged the keys: `seekControlsActive` refuses to act while the overlay is up, so the overlay a skip raised swallowed the next press of the same key. Two bounds keep it honest — `showPlaybackLoading` clears the flag, so a retry, an error or the next episode is never withheld on a skip's account, and `SEEK_LOADING_GRACE_MS` puts the overlay up after all if the seek is still buffering 6 seconds later, because past that it is not a skip landing, it is a film that stopped. **Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to tag `EmbyClientPerf`. `benchmark/` is a `com.android.test` macrobenchmark module targeting the release variants the `androidx.baselineprofile` plugin generates, so its numbers are real rather than debug-influenced. `HomeBenchmark` deliberately measures cold start twice — `CompilationMode.None()` and `Partial()` — because the only way to know the baseline profile is earning its keep is to see both numbers. `PlaybackBenchmark` measures time to first frame off the `Memby.playback*` trace spans, with `resumeFromContinueWatching` and `coldStartFromHomeHero` as a pair — a resume differs from a cold start in one way that matters, so it takes both to say whether a change helped seeking or helped everything. Unlike the rest of the module it **needs a signed-in TV and a reachable Emby**, since most of what it measures is network and decoder; its numbers only compare against other runs on the same TV, server and title. It exists because a handful of hand-timed launches could not settle anything — identical runs of one file varied by 70%. Things to preserve: browsing happens in `setupBlock` so only the Play press is timed; `openResumableDetailPage` *searches* the Continue Watching row for a Resume button rather than assuming a position, because the row reorders as the household watches, and skips the run rather than silently measuring a cold start and calling it a resume. The section names are duplicated between `PlaybackTraceSections` and the benchmark because a `com.android.test` module cannot link against the app — `PlaybackTraceNamesTest` is what stops a rename turning the benchmark into one that finds no slices and cheerfully reports zero. Two task names to get right, both of which fail confusingly rather than obviously. To check it compiles use `:benchmark:compileBenchmarkReleaseKotlin` — `:benchmark:assemble` triggers baseline-profile generation against a connected device. To *run* a benchmark use `:benchmark:connectedBenchmarkReleaseAndroidTest`, not `connectedCheck`, which also runs the `nonMinifiedRelease` variant and measures everything a second time. And `JAVA_HOME` must point at the JBR, as for any direct Gradle invocation here. **Release builds are minified.** `isMinifyEnabled`/`isShrinkResources` are on, which takes the APK from ~14.9MB to ~3.1MB and the dex from 49MB across four files to 4.9MB in one — no multidex, which matters because `minSdk` is 23. `proguard-rules.pro` is what keeps that safe: it matches `@kotlinx.serialization.Serializable` on the *annotation* rather than listing packages, because the previous rules named `com.mattcohen.embyscreensaver.data.model` and had silently matched nothing since v0.1.53. Lint still has `abortOnError = false`, so R8 warnings do not fail the build — check the task output after changing dependencies. **Baseline profile.** `androidx.profileinstaller` plus a profile generated by `benchmark/BaselineProfileGenerator.kt`. Regenerate against a real television with `.\gradlew.bat :app:generateReleaseBaselineProfile`; the result is checked in under `app/src/release/generated/baselineProfiles`, so an ordinary `assembleRelease` needs no device. A stale profile is not harmful, only progressively less useful. **One HTTP stack.** `data/remote/HttpStack.kt` owns the single `OkHttpClient` that the Emby API, the gateway API, Coil's artwork loader and the video stream itself all derive from with `newBuilder()`, so they share one connection pool and dispatcher. This matters most for artwork: in gateway mode the images are proxied by the same HTTPS host that serves `/v1/home`, so a separate client would repeat the TLS handshake for every poster. It matters again for a resume, which opens the same file three times over before the first frame. Don't construct a bare `OkHttpClient.Builder()` — derive from `HttpStack.base`. The stream's derived client raises the read timeout and sets **no call timeout**, which would cap the length of a film. ## Language **Everything a person reads is New Zealand English.** No American spellings: `-ise`/ `-isation` (personalise, synchronisation, organise), `-our` (colour, favourite, behaviour), `-re` (centre, theatre), **licence** the noun and *license* the verb, **programme** for a broadcast, and grey, catalogue, cancelled, labelled, travelling. This covers on-screen copy in Kotlin and `res/values/strings.xml`, `CHANGELOG.md` (which the TV renders twice — Settings → About and the what's-new panel), the admin console, the release landing page in `dist/template/`, every string the gateway sends the client to display (row titles, alert `label`s, the preference and feature catalogues, error messages), and this repository's own prose and comments. **It does not apply to identifiers or anything on a wire.** Emby's API is American (`favorites`, `IsFavorite`), so are Android and Compose (`Color`, `fontSize`, `TheaterComedy`, `RecognizerIntent`), Go and Kotlin (`synchronized`, and the literal `"request canceled"` in `images.go`, which matches `net/http`'s own error text), SPDX and GPL names ("GNU General Public License"), and CSS. Renaming any of those breaks the wire or the build. The rule is about words a person reads, never tokens a machine matches. The boundary sits at the render, and the favourites row is the worked example: its id and `kind` stay `favorites` on both sides, `personalisedFavouritesTitle` puts **"Favourites"** on the screen. When a new string is both, spell the display half and leave the key alone. ## UI conventions Use `androidx.tv.material3` components (`Button`, `Card`, `Text`) rather than the phone Material 3 ones. `MainActivity.kt`, `HomeComponents.kt` and `ScreensaverContent.kt` are the three large files — new screens generally belong in `ui//` rather than growing them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`); everything must be reachable by D-pad only. **Animations must not recompose.** This app ships to weak TV boxes, so an animated value read in a composable body — `val x by animateFloat(...)` then using `x` in the layout — is a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and read it inside a `Canvas`/`drawBehind` block (draw phase only), and derive any text from it with `derivedStateOf` so it recomposes when the *displayed* value changes, not when the float does. `ServiceAlertBanner`'s countdown ring and pulse are the worked example: ~10 recompositions of one number over ten seconds instead of ~600 of the whole bar. The same rule applies to collecting flows — collect in the smallest composable that needs the value, not at the top of `MainActivity`, or every emission recomposes the launcher. **Where a composable is too large to split, narrow the state instead.** `HomeScreen` is the case: it cannot reasonably collect `HomeUiState` in one place, because reading the whole object there meant an arriving update verdict, a slow-connection banner or any one of the four section loads invalidated the launcher *and* rebuilt every row with it. So `HomeViewModel` exposes three `distinctUntilChanged` projections — `content` (rows and their loading flags), `status` (connection health) and `appUpdate` — and the screen subscribes to each where it is rendered. `contentSlice()` blanks the non-row fields rather than introducing a separate type, which is what lets `homeRowsFor` keep taking a `HomeUiState` and the tests that pin it keep working; read only rows and `loading` from it. **Design tokens.** `ui/theme/DesignTokens.kt` is the one vocabulary both surfaces read: `MembySurface` (the near-black), `MembyAccent`, `MembyOnSurface`/`MembyMutedText`/ `MembyQuietText`, `MembyRatingsSurface` behind the ratings strip, three corner radii (`MembyChipCorner` 8dp, `MembyCardCorner` 10dp, `MembyPanelCorner` 14dp), and the two separators — `FactSeparator` between facts, `ValueSeparator` inside a fact that holds a list. `HomeComponents`' `EmbyGreen`/`MutedText`/`QuietText` and `DetailPageComponents`' `Detail*` colours are aliases of these; they had drifted into four near-blacks, four greens and two secondary greys, which is visible the moment a detail page opens from a row. A new colour or radius belongs in the token file, or is a considered exception — not a fifth value. **One button language.** `ui/MembyButtons.kt` — `MembyPlayButton` (focusable), `MembyPlayChip` (the same surface as decoration inside an already-focusable parent, for the home hero) and `MembyChoiceChip`. There were three: this one, a hand-rolled copy in the hero with the same look and different metrics, and raw `androidx.tv.material3.Button`s with glyphs typed into their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours nothing around them uses. **Settings is black, flat, and says one thing once.** `ui/settings/SettingsSheet.kt` had four stacked surfaces to show two switches — the page, the rail, a titled section card, and the rows inside it — and the card's title repeated the page header, which repeated the rail item already highlighted beside it. It is now a black canvas with the rail separated by a single right-edge hairline, and `SettingsGroup` lays rows flat with `SettingDivider` between them: no card, no icon chip, no section heading. `SettingsGroup(label = …)` exists only for a page with genuinely two groups (About), and is a quiet caption rather than a second heading. Three things to keep: the *controls* are untouched (`StatusToggle`, `SettingsChoiceChip`, the badge pill) because they are what makes the screen read as Memby and they look better on black than they did on a card; the row under focus is the only lit surface on the page, so nothing else may grow a background; and everything shares a 16dp left inset — header, rows, dividers, notices — because with the card gone that inset is the only thing holding the column together. Copy is plain-language and second person ("Ten minutes left", "Hide films you have seen"), not feature names. **One runtime formatter, one 4K threshold.** `detail/DetailFacts.kt` owns `formatRuntime`, `heroFacts`, `dynamicRangeLabel` and `UHD_MIN_WIDTH`; the home hero and the card metadata call them rather than carrying private copies. That is why the hero and the card directly beneath it agree on "2h 4m", and why `mediaBadges` and the spec row's `(4K)` suffix fire at the same width. **A score is only ever shown by the ratings strip.** Emby's `CommunityRating` used to be drawn as a gold `★ 8.4` on the detail fact row, the home card metadata panel and the home hero, beside the strip that was already showing IMDb and Rotten Tomatoes — two ratings in one panel, one of which named no provider at all. The client no longer renders it anywhere outside the screensaver (which has no strip), and `EmbyRepository.getRatings` no longer falls back to it either: standing it in for a real source meant a card claiming a TMDb score TMDb had never been asked for. A title MDBList cannot answer for now shows no strip, which is the honest answer. Scores are formatted by the gateway alone (`formatRatingScore`), by the scale they are measured on — a fractional scale always keeps its decimal, so IMDb 7 goes out as `7.0` rather than a bare `7` beside somebody else's `8.2`. **Home rows all lead with the same header**: `HomeRowHeaderIcon` + `HomeRowHeaderIconGap`, `HomeRowHeaderSpacing`. A header that skips the icon chip starts its title 38dp left of every other row, and the launcher's row titles are read as one column. **The home hero** (`ui/HomeMovieHero.kt`) is a featured card plus three minis, each a `HomeHeroPick` — the item *and the row it was drawn from*. The caption used to be the card's slot ("POPULAR", "NEW RELEASE", "TRENDING" by index) while the selection interleaves sources and falls back to every movie in the response, so it routinely lied. Only the **minis** carry that caption now: they have no fact line, so the label is the only reason the card gives, where on the featured card it sat above a line already printing the year and cost the height that broke the button. **Play is measured before the words.** The featured card is a fixed height, and a Column gives each child what the ones before it left — so the chip, being last, was handed the remainder after a two-line title and rendered as a green sliver with its label squeezed out. Compressed, not clipped, which is why it read as malformed rather than missing. The text now sits in a `weight(1f, fill = false)` child, and weighted children are measured from what is left over: the spacer and the chip take their natural size first and the prose gives way. Keep that inversion. The `titleLines == 1` rule that stands the synopsis down is still worth having — it means the give usually costs nothing visible — but it is a tidiness, not the guarantee. `HomeMovieHeroScreenshotTest` renders the wrapping-title case for exactly this. **The hero changes daily, at local midnight.** `selectHomeHeroMovies(rows, day)` takes a count of local days and rotates the starting point of each candidate list; `MainActivity` keys its `remember` on `rememberHomeHeroDay()`, which sleeps until the next local midnight rather than polling. Three properties are load-bearing and unit-tested. It is a *rotation*, not a shuffle: the server's ranking is still the order, so what it thinks is worth leading with comes round again and yesterday's hero is one place down rather than somewhere arbitrary. The same day always yields the same four cards — the launcher rebuilds on every home refresh and focus change, and a hero that re-picked each time would churn under someone walking past. And the day is *local*: "resets at midnight" means the viewer's midnight, which is why the zone offset is a parameter to the pure `localEpochDay` / `millisUntilNextLocalDay` rather than read inside them. `Math.floorDiv`/`floorMod` for longs arrived in API 24 and this app ships to 23, so that arithmetic is written out by hand. **Detail pages** are one editorial layout shared by movies and series: `DetailPageScaffold` in `ui/DetailPageComponents.kt` over the pure vocabulary in `ui/detail/DetailFacts.kt`. It is a full-bleed cinematic hero — backdrop under two scrims, logo or title, `heroFacts` line (year · length · certificate) with the score and format badges trailing it, genres, three lines of synopsis, one recommendation reason, then Play and the circular secondary actions — with an uppercase tab strip on a hairline rule anchored under it and the tab's content beginning below the fold. `MediaDetailContent` is the movie page, `SeriesDetailContent` the series one; they differ only in which tabs they offer. The tabs are Overview, Episodes (series only), More Like This and Cast & Details. - **The page is a `LazyColumn` of exactly three items** — hero, tabs, content — and the hero owns the opening frame: while focus is in it the list is pinned to offset 0 (see `detailHeroScrollTarget` and the `snapshotFlow` beside it), because LazyColumn's own focus relocation would otherwise leave Play visible with the title scrolled off the top. Moving to the strip or the content releases the pin. - **A pane never scrolls.** Every section is a tab and every tab fits its slot, so adding content means adding a tab: a page that scrolls *and* has tabs gives the D-pad two meanings for Down. The slot is `detailPaneHeight(viewportHeight)`, derived from the screen rather than fixed — it was a hard 250dp, and everything `technicalSpecs()` produces fell off the bottom of Cast & Details, which is the whole reason that tab exists. If a pane needs more than the budget, cut rows; do not add a scroller. - **The strip keeps a safe-area inset.** `DetailFoldPeek` holds it off the bottom edge, where overscan was cutting the selection underline in half, and leaves the top of the pane showing beneath it. That peek and the chevron at the end of the strip are the only things on screen saying that Down reveals anything. - **Focus is selection** in the tab strip. A remote has no hover, so a strip that highlights one tab while a different one stays open would need a second press to mean anything and would show content that contradicts the highlight. - **The strip is decided by what the item is, never by what has loaded.** `detailTabs(isSeries)` returns a fixed list, and a section with nothing in it yet says so in its own pane. It used to offer only the sections that already had content, which meant a movie opened with one tab and grew two more when its detail record landed, moving the strip under the viewer's thumb. `detailTab(key, available)` still resolves a remembered key, but now only has to catch a key carried over from the other kind of item. - **One `FocusRequester` per pane**, never one shared between them. `AnimatedContent` keeps the outgoing pane composed for its 80ms fade, so a requester attached by both the Overview and the Cast & Details pane is attached to two live nodes, and a Down press landing in that window can focus the pane that is disappearing. - **Position is remembered per item** in `ui/detail/DetailPosition.kt` — tab, season, which band held focus, and both rails' scroll offsets — in a process-scoped, capped, LRU store outside the composition, because closing a detail overlay destroys `rememberSaveable` with it. `RestoreDetailFocus` focuses Play first (it exists on frame one, so the remote is live) and then restores the band, once, only if that band has something placed to land on. Deliberately not persisted: a TV switched on the next morning should open a show where the *show* is up to. - Every `focusProperties { up/down/left/right = … }` target must be attached **on the current frame**. Season chips and episode cards do not exist while the episode request is in flight, on a one-season show, or on any tab but Episodes — pointing at their `FocusRequester` anyway throws the moment the viewer presses that direction. `SeriesDetailContent` resolves each target to `FocusRequester.Default` when its destination is off screen; keep that. - **Moving between the three bands is stated as intent, not as one destination.** Hero → strip → pane is the page's whole navigation and it must never be dead, so the scaffold routes those presses through `Modifier.onVerticalNavigation` and `focusFirstAvailable`, which takes a *list* — the selected tab, then the band as a focus group, then the pane — and moves to the first that is actually placed. Down out of the hero is on the hero as a whole rather than on the row of buttons, because whatever in there holds focus the press means the same thing; a band with nothing in it yet (an episode page whose seasons have not arrived) is passed through rather than stopped at. Two things to keep. It is `onKeyEvent`, not the preview, so a control that means something of its own by Up or Down keeps it, and an unhandled press still falls through to Compose's own focus search. And the **content pane keeps `focusProperties { up = … }`** rather than a key handler: panes navigate vertically inside themselves and override that property where they do (`EpisodeCard` sets `up = FocusRequester.Default` on every card but the first), which a blanket handler would take away. The tab strip anchors that requester to the first tab when the selected one is not in the list, so the property always names something real. - The backdrop is held under two gradients before any text is drawn. The reference is flat black and that flatness is most of why it reads as modern; the artwork is there for tone, not as a picture. - A tab item is `Modifier.width(IntrinsicSize.Max)`. Without it the underline's `fillMaxWidth` claims the whole strip and pushes every later tab off the screen. - The hero honours `Settings.showTitleLogo` and `useTextTitleForLogo` (`ui/TitleLogo.kt`, shared with the screensaver): transparent Emby logos are commonly black, and a black title treatment on this scrim is an invisible heading. - **Why you might enjoy it** is the single accent line above the actions, from `GET /v1/items/{id}/related`. It comes from the same profile the home rows are built from (`recommend.Why`), so the page can only claim a taste the engine actually learned; a cold profile falls back to catalogue facts. Never focusable. - **More Like This** is the same response's `items`, as its own tab. Selecting one opens *its* detail page, and `MainActivity.detailsTrail` walks Back home one page at a time. On the direct path there is no engine, so `EmbyRepository.getRelated` returns Emby's `Items/{id}/Similar` with no reasons at all — both halves are allowed to be empty and the page still opens. - **Nothing about related titles is allowed to fail.** It is asked for on *focus* (`HomeViewModel.focusItem` warms it while the card is highlighted), so it is the most frequently made request on the launcher and was by some way the loudest thing in the gateway's error log. `RelatedTo` now degrades at every step instead: a taste profile that cannot be built costs the reasons and not the carousel (`Why` falls back to catalogue facts, `FilterUnseen` keeps everything), a failed or empty `Similar` lookup falls through to `genreNeighbours` — the imported catalogue, in this title's genres, best rated first, which is also the only half that works while Emby is the thing that is down — and `relatedSubject` reads the item itself from `library_items` when Emby will not answer for it. The only error the engine still returns is the viewer having navigated on, and the handler answers that with silence rather than a logged 502. Two supporting rules: an empty carousel is cached for `relatedEmptyTTL` rather than the item lifetime, because a ten-minute answer must not outlive the minute of trouble that produced it; and client-side `getRelated` is single-flighted on the repository's own scope, so the cancelled focus prefetch neither aborts the request the detail page is about to want nor caches its own failure as an answer. - `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer) and delegate; `SeriesDetailContent`/`MediaDetailContent` are parameter-driven so they can be screenshotted (`DetailPageScreenshotTest`, which drives the tab strip by clicking it and also renders each pane on its own at `detailPaneHeight`) without a server. **"Estimated finish: 18 August"** is `data/SeriesPace.kt`, a quiet line under the series hero's progress bar. It is derived entirely from the episode list the detail page already holds — Emby's per-episode `UserData.Played` and `LastPlayedDate` — which is the whole design: there is no new storage, nothing to invalidate, and **no second implementation on the direct path**, unlike the subtitle and Continue Watching rules. It is per viewer and per series by construction, since that user data is Emby's and is keyed that way, and it recalculates for free because finishing an episode, marking one watched, a history sync from another client and a newly imported episode all change that list and nothing else. Things to preserve: - **Most of the function is about refusing to answer.** A wrong date is worse than none, so every guard returns null: fewer than three completions (two only when they are on separate days and within a week of each other), nothing watched in a month, one or zero episodes left, a horizon past a year. The load-bearing one is that the window must span **at least two distinct local days** — three episodes in one evening is a sitting, not a rate, and reading a daily pace off it is exactly how the opening of a binge promises a finish this week. - **The window ends at the present and stops at the last break** (`sinceLastBreak`), rather than averaging a viewer's whole history with the show. Somebody who took a year over season one and is now watching nightly is watching nightly; including the silence predicts a finish years out. - **Estimating and wording are separate functions.** `estimateSeriesPace` answers in numbers and `seriesPaceLabel` turns them into a sentence, so a "finish this weekend" row or a completion reminder can use the first without inheriting the second. Near dates are named and far ones rounded to weeks or months — a pace measured over a fortnight cannot honestly pick a day four months out. - **"Catch up" is not a synonym for "finish".** `BaseItem.isOngoingSeries` prefers Sonarr's lifecycle where the gateway attached one and falls back to Emby's `Status`, which is the only source the direct path has; both absent means "finish", the weaker claim. `Status` is in `fieldsDetail` on both paths for this, and the gateway's item cache key moved to `item:v5:` so entries written before it cannot hide the field. - **Quiet is the property a unit test cannot check**, so `SeriesPaceScreenshotTest` renders the line on a real hero → `build/screenshots/series-pace/`. It covers both spacing cases (with a progress bar above it and without), both verbs, and — the one worth keeping — the empty case, which must leave the hero identical with nothing holding the line's space. Its history is built relative to the clock rather than pinned, because a fixture with a fixed date would fall out of the recency window and capture the empty case by accident. - **The date is formatted here, not by the platform.** `formatPaceDate` names the month from its own table so a set configured in US English cannot start printing "August 18" into New Zealand copy, and the civil-calendar arithmetic is hand-rolled because `java.time` needs API 26 and this app ships to 23 — the same reason `data/LocalDays.kt` writes out `floorDiv`. That file is where the local-day arithmetic now lives; it was private to `HomeMovieHero` while the daily hero rotation was its only caller. **An episode has its own page.** `ui/EpisodeDetailsOverlay.kt`, reached whenever `item.isEpisode` — which is what Continue Watching hands over. It is the same `DetailPageScaffold` as the other two with two substitutions, both of them scaffold parameters rather than a second layout: - The logo belongs to the *series*, so the hero takes an `eyebrow` ("SEASON 3 · EPISODE 4") and a `subtitle` (the episode's own title) under it, plus a `title` override for the no-logo fallback — without that override the fallback heading printed the episode's name a second time. - `strip` replaces the tab strip in the band under the hero, keeping the same `DetailStripHeight`, the same fold and the same focus contract (it is handed the requester above it, the one below it, and the callback that pins the page). An episode gets the **season scroller**: every season the library holds, the current one flagged WATCHING, earlier ones dimmed and ticked. The rules are pure and pinned in `EpisodeDetailTest` — seasons *before* the current one count as watched however patchy they are, seasons after never do however much has been sampled, and specials (season 0) are exempt from the "behind you" rule entirely because a tick on an unwatched special is a claim the page must not make. `seriesProgressLabel` counts numbered seasons only for the same reason. - The pane under it is the selected season's episodes, opened on the episode the page is about and flagged THIS EPISODE. It reuses `EpisodeCard` from the series page rather than copying it, or the two screens drift on what a watched episode looks like. - Screenshots are `EpisodeDetailScreenshotTest` → `build/screenshots/episode-detail/`. **A schedule card opens the show, and says why.** The "Shows airing in the next 5 days" row is informational — its cards are episodes that have not aired, `MembyPlayable: false`, so pressing one used to do nothing at all. It now opens the *series* page, with the air time restated on it (`ui/detail/AiringNotice.kt` → the accent band in `DetailPageScaffold`, where the recommendation reason would otherwise sit). Four things hold it together: - **The link is server-side.** `MembySeriesItemId` is resolved in `api/sonarr.go` by matching the Sonarr title (and year, which wins when both a remake and its original are in the library) against `store.SeriesRefs`. A show Sonarr follows but Emby has never imported carries none, and its card stays inert rather than opening an empty page. - **The notice belongs to the route, not to the show.** `MainActivity.detailsAiringNotice` is set only by that row's `onItemSelected` and cleared everywhere else a page opens — including "More like this", which is why walking Back does not restore it. The same series reached from Favourites or search must never claim a schedule. - **Its wording is the gateway's**, copied off the card (`membyAirLabel` and friends). The TV never derives an air time from a timestamp, so the page cannot contradict the card that was just pressed. - **The page opens on a stub** (`scheduleSeriesStub`) and fills in from `HomeViewModel.focusItem`, the same swap `FocusedDetailsOverlay` already does — waiting on an item request before anything appears is what would make the row feel broken. The episode's own overview and artwork are deliberately dropped: they belong to the episode. **One lifecycle word, one colour, three rows.** A schedule card and a My Shows card both wear a tag saying whether the show is still being made or the film has actually come out — CONTINUING, ENDED, IN CINEMAS — and `LifecycleBadge` in `HomeComponents.kt` is the single place that colours them, so the same word never means two things on one launcher. Green is still going, red is over, blue is not out yet, amber is in cinemas. Two things worth keeping: the *wording* is the gateway's (`MembyLifecycleText`, from `api/lifecycle.go`) and only the *slug* is a lookup key, so an *arr status a build predates still reads correctly instead of falling back to a slug; and a card with no lifecycle wears no tag rather than an invented one — an older gateway, a cached row, or a show *arr has no status for. The availability badge above it answers a different question (has the household's copy downloaded), which is why they occupy opposite corners. `myShowBadge` puts CANCELLED ahead of everything else on a followed show: nothing else on that card matters as much. **Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV, landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather than a bare `@Preview`, which defaults to a phone and misrepresents every layout here. A preview does not run `ServiceLocator`, so only composables that take their state as parameters are previewable — the same property that makes them unit-testable. Prefer previewing the still inner composable over an animated wrapper (`AlertBanner`, not `ServiceAlertBanner`): a frozen frame of a slide-in shows nothing useful. **Screenshots.** `OnboardingScreenshotTest` covers everything a new television shows before the launcher — first run, the install-permission step in both its states, and sign-in empty, filled, rejected, connecting and adding-a-viewer — into `build/screenshots/onboarding/`. It is the sequence nobody sees twice and the one that decides whether that TV can ever update itself, so being able to look at it without reinstalling on hardware matters more here than anywhere else. It is also why `SignInContent` and `InstallPermissionContent` are stateless: `SetupScreen` keeps the authentication, the content takes parameters. `app/src/test/.../ServiceAlertBannerScreenshotTest.kt` renders composables to PNGs under `app/build/screenshots//` via Roborazzi + Robolectric, at TV 1080p qualifiers — the way to look at a layout without a TV to hand. This is the *only* Android dependency allowed in `app/src/test`; keep it confined to `*ScreenshotTest.kt` files so logic tests stay pure JUnit. Recording is always on (`roborazzi.test.record` in `testOptions`): these are artifacts to look at, not checked-in goldens, and a screenshot test that silently captures nothing is worse than none. Give each feature its own kebab-case folder and keep all variants of that feature together. AGP's own `com.android.compose.screenshot` plugin was tried first and discovers zero previews on AGP 8.13.2 — don't re-litigate it without checking that upstream.