Files
memby/CLAUDE.md
T
2026-08-19 21:30:44 +12:00

3456 lines
264 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
**The header is not the only thing Emby writes down.** The **device profile** sent with
`PlaybackInfo` is named in Emby's playback device list too, and it read `Memby Android TV`
on both paths — the product name, in the one place it must never be. It is now `MbyATV`
(`deviceProfileName` in `internal/emby/device_profile.go`, the literal in
`DeviceProfile.embyAndroidTv`), and like the header the two must agree or one television
playing both ways appears as two clients.
**And the gateway is not a television.** `Credentials.Gateway` marks a request the server
makes on its own behalf, and Emby records those under `MEMBY_GATEWAY_CLIENT_NAME`
(**`MbyGateway`**, `emby.DefaultGatewayClientName`) with a device name to match, so the
sync, the health probe, device cleanup and an operator signing into the admin console or
the web installer are separable from the sets in the house. Two things to preserve: the
flag is **stated, never inferred** from a missing token or version — an old APK reports
neither, and reading one as the server would file a television under the wrong name — and
a gateway request with no device name falls back to the gateway's name rather than to
`store.DefaultDeviceName`, the placeholder that made the server read as somebody's unnamed
TV.
`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
.\deploy-server.ps1 -SkipAppRelease # server/admin-only; no APK is built
```
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
`<destination>.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`. For a server/admin-only change, pass
`-SkipAppRelease`: this skips the local APK build so only the gateway and admin console are
replaced.
**`.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. An APK is built
on a workstation or by CI and published to the gateway through `POST /admin/api/release`,
which stores it in the shared `memby-releases` volume — either by `deploy-server.ps1` (which
builds, signs, verifies and publishes in the same operation) or by
`.gitea/workflows/release.yml` on a pushed semantic tag. The gateway itself builds nothing;
its release-publish token is a read-only Compose secret under `/run/secrets`.
The existing `release.ps1` compatibility path builds a signed APK and assembles
`dist/out/``index.html` (landing page from `dist/template/`), `latest.json` (the manifest
the app polls), the versioned APK and its `.sha256` checksum. Local 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.
Every version bump is also a publish operation: update the version and changelog together,
commit the complete release, and push it to GitHub. Never leave a bumped version only in the
local working tree.
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 `## <version> — <date>` / `- bullet` shape; anything else
in the file is skipped as prose, and a bullet wrapped onto a second line is rejoined.
**An update is acknowledged once with a toast.** `ui/whatsnew/whatsNewDecision` compares the
running build with `Settings.whatsNewSeenVersion`, and `AppRoot` briefly says “Memby has been
updated to version …” over the launcher before recording it. The record is device state,
deliberately not a synced preference: what is new is a property of the APK on *this* set.
Two quiet cases matter: a **fresh install** has not updated from anything, so setup records
the current build without announcing it; and a signed-out set with an older record waits
until somebody signs in, because the notice belongs over the launcher. The changelog is no
longer part of this decision — release history remains available in Settings → About, while
local or unreleased builds still receive the same one-time update acknowledgement.
**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.
**A retired build must be told why it was signed out.** The destructive floor
(`destructiveUpdateFloor`) deletes the session on the next authenticated request and answers
401; a retired build's sign-in is then refused with 426. The television only saw the sign-out
— so it drew a sign-in form the gateway would refuse, and the mandatory update screen was up
to `UPDATE_CHECK_INTERVAL_MS` (an hour) away, force-closing the app being the only way
through, since a fresh launch checks for updates before it draws anything. Both refusals
carry `X-Memby-Update-Required`, so `RequiredUpdateInterceptor` on the gateway client
publishes it through `update/RequiredUpdateSignal` and `AppRoot`'s check loop waits on
*either* the hourly interval or that signal. Things to preserve:
- **It is an interceptor** because the refusal lands on whichever request happened to be in
flight — the status poll, a home refresh, a sign-in — and only one of those has any reason
to know about update policy. The signal replays one value, because it is commonly reported
before the check loop is waiting on it.
- **The refusal is written to disk** (`Settings.requiredUpdateVersion`, via
`RequiredUpdateGuard` on the service locator), because it is announced exactly *once*: the
401 that deletes the session carries the header and every 401 after it is an ordinary
missing session. In-memory only, a television told and then restarted had nothing left to
learn it from but the launch check — which is bounded by `UPDATE_CHECK_TIMEOUT_MS` (2.5s)
and, on missing it, put the viewer back on the welcome and sign-in screens. It is written
by the locator rather than by a screen for the same reason the interceptor exists: nothing
that knows about update policy is necessarily composed when the refusal arrives.
- **Only the gateway may withdraw it.** A verdict that is not a required update clears the
flag; a failed check never does, because an unreachable server is no evidence about which
builds it accepts. `requiredUpdateSatisfied` is the one exception and it is pure and
tested — the build the refusal demanded is now the build running, which is what the moment
after a successful self-update looks like.
- **`RetiredBuildScreen` is what the television shows meanwhile**, ahead of sign-in,
profiles, the launcher and the null-settings case alike. There is no attempt budget any
more: giving up used to hand the viewer a sign-in form as the *final* answer, and every
screen underneath this one is something the gateway would refuse. The loop asks every
`UPDATE_REQUIRED_RETRY_MS` for `UPDATE_REQUIRED_FAST_ATTEMPTS`, then settles onto
`UPDATE_REQUIRED_BACKOFF_MS`, and the screen's Try again wakes it through the same channel
a refusal does.
- **Every refusal wakes the loop, including a repeat.** The 401 that retires the session and
the 426 that refuses the sign-in after it name the same version, and the second is the one
a viewer is standing in front of — filtering it as already-handled is what left a
television on a form it could not get through until the next hourly check.
## 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.
**But asking Emby is no longer how the gateway finds out.** Sonarr and Radarr are the
things that put files on disk, so they are what the catalogue learns from: both post to
`/hooks/sonarr` and `/hooks/radarr`, `internal/library/events.go` turns a notification into
a piece of work, and `ingest.go`'s single worker reads that one title out of Emby a minute
later. An episode imported at 19:05 is searchable at 19:06 rather than as late as 20:00,
and the hourly sweep becomes reconciliation for what the *arrs do not manage — a file
dropped in by hand, a title edited in Emby, a webhook that arrived while the container was
down. Things to preserve:
- **`library_ingest_queue` is durable, and it is the only queue in the schema that is.** A
Tracearr-derived credits candidate is rebuilt from one query on restart; "Sonarr imported
this at 19:05" cannot be rederived from anything, so a container restarted during the
settle delay must still read the file. The key names the **file** rather than the
delivery, which is what makes `ON CONFLICT` the whole of the repeat-delivery defence —
both *arrs re-notify on retry — while a file deleted and re-imported is a different file
and its own work.
- **Both hooks sit outside the quiet-time gate**, which the Radarr one previously sat
inside. That gate answers 503 and neither *arr re-delivers, so a quiet hour silently
discarded every import that happened during it. The hook records at any hour and the
*worker* is where quiet time is honoured — which is only possible because the queue is
durable.
- **An upgrade is silent as news and still refreshes the row.** Those are two judgements
made in two places: `AnnounceLibraryIngest` refuses to announce anything that is not
`ReasonImport` — the title was already there — and the catalogue re-reads it because the
file genuinely changed. A rename is a
refresh and never an invalidation — the Emby item id survives a move, and so does the
credits marker measured against it. A delete only counts when the media went with it: a
series unfollowed in Sonarr with its files left on disk is still in the library.
- **Emby not having scanned yet is the expected first answer**, not a fault. One
`RefreshItem` nudge at the parent, then a widening backoff (`IngestRetryDelay`) out to an
attempt limit — because past the last step the cause is not timing, and a row retrying for
ever is one nobody looks at.
- **The lookup asks for `syncFields`**, the scheduled import's own set, for the reason
`Syncer.Find` does: a thinner query leaves an event-imported title without People,
MediaStreams or ProviderIds — no cast, no ratings lookup, no format badges — until Emby
next reports it changed, which for a film nobody edits again is never.
- **A refresh resolves through Emby; a delete resolves through the catalogue.** That
asymmetry is deliberate. The local series index answers for every show ever imported and
Emby is asked only when it misses, which is exactly the case the feature exists for — a
brand-new show whose first episode has just landed, whose series row is then written
beside its episode. A delete is the other way round because the file is gone and Emby is
the least likely thing to still be able to name it.
- **The sweep interval is an operator override** (`librarySyncMinutes`, the
`embyHealthInterval` pattern) and `Syncer.Schedule` takes a *function* rather than a
value, because a setting read once at start-up is not a setting: lengthening the sweep to
6h after wiring the webhooks up must not need a restart.
- **`store.DeleteLibraryItem` takes the credits marker with the row.** `credits_markers` is
keyed on the item id and nothing else prunes it, so a deleted title would otherwise leave
a Skip Credits position behind for a file that no longer exists.
**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:
- `AnnounceLibraryIngest` in `api/ingest_alerts.go` — a film or an episode whose scan has
**finished**. `POST /hooks/radarr` and `POST /hooks/sonarr` are what push *into* the
gateway, guarded by `MEMBY_RADARR_WEBHOOK_TOKEN` / `MEMBY_SONARR_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 — but the *hook* no longer announces anything. It records, and the announcement
is hung off `Ingester.Announce` in `main.go`, the `SetAfterSync` arrangement, so
`library` stays ignorant of what an alert is. Things to preserve:
- **The webhook is not the news.** Both *arrs fire the moment they have moved a file and
Emby has not scanned it in yet, which is why the banner published from the hook could
only ever promise a film would be available "shortly" and why an episode could not be
announced at all — there was nothing true to say about one until it was there. Behind
the scan the banner says the title is **ready to watch**, and a title Emby never
manages to scan is never announced, which is the right way round.
- **A quality upgrade is still silent, and so are a rename and a delete.** Only
`ReasonImport` is news; the file genuinely changed, so the row is still re-read. That
judgement lives in `api` rather than in the worker: the worker's business is that the
row moved, this is the separate question of whether anybody should be told.
- **A season pack is one banner.** `ingestRuns` tallies a season's arrivals within
`ingestRunWindow` and every later one replaces the same alert, because the id is
anchored on the run's **first** episode. The anchor is what makes both halves work:
within the window a burst collapses, and next week's episode — arriving after it has
closed — starts a run of its own rather than reusing an id every television in the
house has already dismissed as seen. Bounded and lossy in memory, the `playbackTitles`
arrangement; a gateway restarted mid-pack announces the rest as a second run.
- **One arrival is named, several are counted.** Naming the last of six would be
arbitrary — nothing makes it the one worth mentioning — where the count is what the
viewer wants. `episodeSummary` drops the episode title when Emby has recorded it as
the show's own name, since "S03E05 — The Bear" reads as a mistake.
- **Emby's names outrank the *arr's.** The result carries what was actually written to
the catalogue, so the banner and the card underneath it cannot name one thing two ways.
- **The two windows still switch their own half off**: films answer to
`MEMBY_RADARR_ALERT_WINDOW`, episodes to `MEMBY_SONARR_ALERT_WINDOW`. `sonarr-import`
is its own kind, distinct from `sonarr-aired` — one says an episode has been broadcast
and is *not* here, the other that it is.
- `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.
**Notifications belong to a person, so they live in the user picker.** A service alert is the
house being told something; these are one viewer's own news (a followed show returning),
stored per user on the gateway and following them to whichever television they sign into.
`ui/alerts/AlertsPage.kt` is the full page and the user menu in `UserSwitcherOverlay` is the
way in, beside Manage users. It replaced a bell in the corner of the launcher, which was
drawn only on Home and cost a focus target on every set whether or not there was anything
behind it. Things to preserve:
- **The badge counts alerts, not unread ones** (`alertBadgeLabel`, pure and tested). An
alert that has been read but not dismissed is still sitting there, and a badge that
cleared itself the moment somebody glanced at the page would never agree with the list
underneath it. `AlertBadgeMax` is what stops the pill growing wider than its row.
- **The badge is drawn twice on purpose** — on the "Switch user" rail item and on the My
Alerts row inside the picker. The page is one level in now, so without the mark out on the
rail nothing on the launcher would ever say there was news waiting.
- **Inbox and Seen, and there is no off switch.** The page is laid out as My Requests is —
the marked heading, a tab strip, one pane at a time — because the two answer the same shape
of question about a person's own list. `AlertsTab` (pure, tested) is the whole of it:
membership is the *read* flag and nothing else, so both panes and both counts derive from
the one list the caller already holds and there is no third state to keep in step. The
count is on the tab because it is the question a viewer opens the page with, and zero is
printed rather than hidden — a count that disappeared when it emptied would read as one
that had failed. Turning notifications off used to be a chip on this page, which was the
page offering a way to make itself permanently useless; the reason to reach for it was a
single list mixing new news with everything already read, which is what the split fixes.
The stored preference is still honoured and still says so in the empty state — it is
simply no longer the viewer's to switch from here.
- **A press dismisses, and the focused row says so.** This is the only page whose whole job
is emptying itself; a confirmation press per alert is what made the panel it replaced not
worth opening. **Nothing moves under the remote by being looked at**: focus used to mark a
row read, which was harmless while the list was one list and would now empty the Inbox
merely by somebody scrolling it, so Seen is a state a viewer puts a row into. "Dismiss
all" is the same per-alert call in a loop — the gateway has no bulk route — and takes
**only the pane on screen**, or emptying Seen would throw away an Inbox nobody had read;
it empties optimistically, or a row lingers under a thumb that will press it again.
- **A row holds two focus targets, and that is what makes a seen toggle possible.** A remote
has one confirm key and it was already spent on dismissing, so the body keeps that press
and Right reaches a toggle beside it (`alertSeenActionLabel`, `POST
/v1/notifications/{id}/unread` → `store.MarkNotificationUnread`). Down still moves to the
next row from either, so the second target costs nothing to somebody who never wants it.
Three things to preserve: the toggle **names the action, not the state** — a button
labelled with the state it is in reads as a claim rather than as something to press; the
lit surface is the *row's*, driven by `hasFocus` rather than `isFocused`, or a row goes
dark the moment the remote steps sideways into its own control; and marking unread is a
plain assignment where marking read is a `COALESCE`, because read is set by merely looking
at a row and must not move on a second glance, while unread is only ever somebody saying so.
- **The toggle is what moves a row between the two halves, so focus comes back to the
toggle.** Marking a row seen takes it out of the pane it was in, exactly as a dismissal
does, and the page re-aims focus the same way — but onto the *toggle* of the row that took
its place rather than onto the body, because a run of "Mark as seen" presses that landed on
a body would be a run that ended in a dismissal. That is why a row carries two focus
requesters and `pendingFocusToggle` records which of them the change was aimed at.
- **The list is paged on the television, not on the wire** (`ui/alerts/AlertsPaging.kt`,
pure and tested). The gateway answers with the whole undismissed list and the page cuts it
up locally, which is what lets a page flip cost nothing on a weak box, lets the locally
held update notice merge into page one without making a server's page boundaries lie about
it, and keeps a dismissal optimistic instead of needing the page it left refetched.
`AlertsPageSize` is **four**, measured against a 540dp television that spends a third of
its height on the heading and the controls — `my-alerts-paged-crowded.png` is the capture
that figure is answerable to. Two rules: `alertPageAfterChange` **clamps rather than
resets**, because the common change here is the last row of the last page going away and a
viewer sent back to page one for it would lose their place every time they finished a
page; and an arrow at either end is **not drawn** rather than drawn dead, the stance the TV
calendar's month controls take, with both ends keeping a fixed-width slot so the page
indicator is not the one thing on the row that moves.
- **The page is stateless**, like `SignInContent` and the detail panes: `MainActivity` owns
the list and the requests, which is what lets `AlertsPageScreenshotTest` render it (and
the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`. What
the page does hold is only ever about the remote in the room — which tab is open, which
page of it, and which row a dismissal or a seen toggle aimed focus at.
- **A cancellation is a transition, not a status read in isolation.**
`sonarr_series_status_history` stores the first daily Sonarr reading as a quiet baseline
and appends only changes after it; `WatchSonarrLifecycle` creates a notification for every
known user when any show moves from continuing/upcoming to ended/deleted. Cancellation
news is household-wide and does not depend on whether a viewer followed the show. Without
the baseline,
enabling the scanner would announce every show that had already ended as new news, and
without durable history a gateway restart could announce the same change again.
**Every outbound notification leaves through one door.** `server/internal/notify` is that
door and `internal/api/notifications.go` is the Memby half of it, so the flow is
`feature/event → notify.Service → Deliverer → notification log`. Before it, each feature
both decided to notify somebody *and* performed the delivery: the Sonarr lifecycle scanner
wrote into `user_notifications`, the ingester pushed a banner into Redis, the integrations
dispatcher posted to Discord. Each knew how to deliver and none knew the others existed, so
"what did Memby send, to whom, and did it work" could only be answered by reading three
subsystems' log lines and hoping each had logged. The console reads the trail at
`/admin/notifications` over `admin_notification_log.go` and `store/notifications.go`. Things
to preserve:
- **Logging never blocks delivery, and that is an ordering rather than a promise.** `Send`
delivers *first* and records afterwards, on a context detached from the caller's, and a
write that fails is logged and swallowed. A history written first would be a claim rather
than a record; one written inside the delivery path could fail the delivery.
- **A skip is the most useful row on the page.** "Memby never told me" and "Memby decided
not to tell you" are the same silence from outside, so a viewer's preferences declining a
notification, a source key that had already been used, and an operator's alert window set
to zero are all *recorded* with their reason (`declineUser`, `StatusSkipped`) rather than
being an early return. That is why `announceImportedMovie` no longer short-circuits on a
zero window — `deliverBroadcast` records it instead.
- **Sent and Delivered are different answers.** Most channels here are stores, finished the
moment they return with nobody to acknowledge them; `Delivered` is reserved for a provider
that actually confirmed, which today means a webhook answering 2xx. Collapsing them would
have the console claim an acknowledgement nothing gave.
- **Nothing secret is ever recorded.** A webhook URL is the credential, so `Notification`
carries `Target` — a destination's *name* — and never its address, and `Redact` drops any
metadata key that looks like one on the way in. It is a match on the *key*, because a
token is not recognisable by looking at it and what a caller reliably gets right is what
they called the field.
- **The integrations dispatcher reports rather than being driven.** It is the one producer
that calls `notify.Log` instead of `Send`: it is a bus subscriber with its own queue,
pacing and transport registry, and routing its posts through the audit trail would make
the trail the thing deciding what Discord receives.
- **`UpsertNotification` reports whether it inserted.** The digest job fires hourly and
re-sends the same weekly key all evening on purpose, so without that boolean every
catch-up pass would read as a second summary nobody received.
- **The facets are built from what has been sent**, not from a list of constants, and over
the whole retention window rather than the current filter — a dropdown whose options
vanish as you narrow the table is one you cannot use to widen the question again.
- **`store.NotificationRetention` (90 days) is the one figure.** The housekeeping task
prunes to it and the console derives its widest window from it, so a page can never offer
a range the prune has already emptied.
**Watch time is Tracearr's, and it is never counted twice.** `store.watchedMsExpr` in
`internal/store/watch_time.go` is the one definition of "how long was this actually watched"
— the greater of Tracearr's `durationMs` and `progressMs`, capped at the title's own length —
and the console's figure and the viewer's summary are both queries over `tracearr_sessions`
rather than a second table of minutes. A table counting watching separately would be a copy
of a copy, wrong the moment Tracearr corrects a session. It is read two ways:
- **The console reads it beside the person.** `/admin/api/accounts` carries a `watchTime`
per account — week, month, lifetime, and when they last watched — from one grouped query
for the whole household, because that page grows with the family. `matched` is the
load-bearing field: a household running no Tracearr and a viewer Tracearr has never seen
both arrive as zeroes, and a console drawing those as "0 min this week" would have an
operator investigating a person rather than an integration. A watch-time read that fails
costs the figures and never the account list.
- **`attributeWatchTime` joins the two systems on the username**, which is the identity they
genuinely share, and prefers the Tracearr id `recommendation_user_profiles` recorded where
there is one — so a viewer renamed in one system keeps their figures instead of silently
reporting zero. It is pure, so the console and the digest cannot attribute the same rows
differently.
- **The weekly summary is a personal notification, not a service alert.** A service alert is
the house being told something; how long somebody watched is nobody else's news, so it
lands in My Alerts (`watch-time-week` / `watch-time-month`, which an app that predates them
renders with the fallback icon) and follows the person to every set. `RegisterWatchTimeTasks`
registers it as an ordinary scheduler job, so an operator can see when it last ran and send
one by hand — which for a job that fires once a week is the difference between "it has sent
nothing" and "it has not run".
- **The source key is the only thing preventing a repeat.** It runs hourly and sends from the
appointed hour to the end of that day, because `watch-time:weekly:2026-W33` is written
`ON CONFLICT DO NOTHING`: a container restarted three times on a Sunday evening delivers one
summary, and a gateway that was off all evening still delivers it the next hour it is up.
The monthly summary is the same trick over a `YYYY-MM` key, which is why it catches up
rather than being skipped for ever by a gateway that was down on the first.
- **Sunday evening, not Monday morning**, because the figure sent is week-to-date: on a Monday
it would summarise almost nothing. Every boundary is a household-local *calendar* date
(`weekStartIn`, `monthStartIn`, `previousMonth`), never `now.Add(-7*24*time.Hour)` — a week
containing a daylight-saving change is 23 or 25 hours short or long, and subtracting hours
puts the boundary an hour inside the previous Sunday twice a year. `watch_time_test.go` pins
the clock-change week.
- **Two switches, and they answer different questions.** `watch_time_digest` in the
`featureCatalogue` is the household's and carries no capability — nothing on the television
has to understand this. `NotificationPreferences.WatchTimeDigest` is the viewer's own, kept
apart from `SystemAlerts` because this is the only notification there that is about *them*.
Turning it off also withdraws the summaries already sitting in their list
(`filterStoredNotifications`): switching a weekly notice off is a statement about the ones
already there as much as about the next one.
- **Nothing under `watchTimeDigestFloor` is sent**, and a preference that will not load is
read as "not now" rather than as consent. A digest reporting four minutes is a notification
about a title somebody abandoned, and a feed carrying those is one nobody opens.
**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.
**Every search the tab performs is recorded, and the gateway is what records it.** It used
to depend entirely on the television posting to `/v1/search/history` after a result landed,
which meant a query answered from the client's own cache, one whose post failed, or one
from a build that predates the call was never written down at all — and the table the
console reads back was a partial record of what the household looks for. `handleSearch`
now calls `recordSearchQuery` itself, before the Redis lookup, so a cached answer counts
the same as one that reached Emby. Things to preserve:
- **The write is detached from the request context.** Instant search cancels the in-flight
request on every keystroke, so a write hung off `r.Context()` would be abandoned for
exactly the searches somebody typed fastest. It is also fire-and-forget: a search whose
record failed still returns results, and the failure is DEBUG for the same reason the
search line is.
- **`store.SearchDedupeWindow` is what makes two writers safe.** The handler and the
client's post both describe one search, and the POST route is still there because an
older APK is the only thing that records at all. An identical query inside the window is
the same search; a minute later it is its own row.
- **`searchQueryRecordable` is the one rule both routes apply**, so a query `/v1/search`
records is exactly one `/v1/search/history` would have accepted. Length is counted in
runes, or a title in Japanese is rejected at a third of an English one's length.
**And the console can read it back** — `/admin/searches`, in the Insights group beside Row
engagement, over `internal/api/admin_searches.go` and the store queries in
`internal/store/searches.go` (where the writer moved to, so one table's rules sit in one
file). It is **two tables of the same rows on purpose**: the summary groups by
`lower(query)` and answers "what does this house look for", which is what a library is
organised against; the log is uncollapsed and newest-first and answers "what happened just
now", which is the one to read when somebody reports that search is not finding something,
because it shows the query as it was typed, by whom, and when. Things to preserve:
- **The widest window is the retention period.** `searchWindowDays` is derived from
`store.SearchRetention` rather than written down, because `RecordSearch` prunes to it —
a page offering 90 days would draw a flat line for two thirds of it. A tile states the
retention for the same reason: a quiet week and a window that has aged out look identical.
- **`SearchTotals` is its own query, not a sum of the table above it.** The summary is
capped at `searchTermLimit`, so adding it up would report the top twenty-five's total as
the household's, wrong by however long the tail is.
- **Names are resolved in Go from `KnownUsers`, not joined per row**, and an id with no
session left keeps its row wearing the id — the query is what the page is for, and a
viewer whose sessions have expired is still one searcher rather than nobody.
- **Nothing on it is editable**, the stance every insights page takes. It also cannot
delete: the 30-day prune is the only thing that removes a row.
**A genre is browsed, not searched.** The chips on the empty state ran their own label
through `/v1/search`, which is a text query: "Drama" matched a film *called* Drama, anything
with the word in its overview, and — relevance being a score rather than a rule — a
scattering of titles not in the genre at all, while missing most of the ones that were. A
chip now opens `GET /v1/genres/{genre}/items` (`server/internal/api/genres.go`,
`EmbyRepository.browseGenre`), which filters on Emby's `Genres` parameter and answers a page
at a time. Things to preserve:
- **It is a mode, not a query.** `SearchUiState.genre` sits beside the query rather than
pretending to be one, which is what lets the pane head itself "Comedy" instead of "Search
results for “Comedy”" and lets Back step out of the shelf without clearing something
nobody typed. Typing supersedes it; `runSearch` returns early while a genre is open, or
the empty-query transition that opening one causes would wipe the shelf it just filled.
- **Paging must not repeat or skip a card**, which is why both paths sort on
`PremiereDate,SortName` rather than a date alone — two titles sharing a premiere could
otherwise swap places between requests, and the scroll would show one twice and the other
never. Newest first, because the alphabet is not an answer to "show me Comedy".
- **Two rules end the scroll and both are needed** (`hasMoreGenreItems`, pure and tested):
reaching the total is the ordinary end, and a page shorter than the one asked for is the
other — a backend that would not count says nothing useful with its total. `genreTotal` on
the server is the same judgement from the other side, for an Emby that did not count.
- **A page is appended by its own offset**, never by order of arrival: a response for an
offset already scrolled past, or for a genre the viewer has left, is dropped rather than
pasted into the middle of the grid. A page that fails part way down keeps what is on
screen and simply stops paging.
- **The scroll trigger is a `snapshotFlow`, not a composable read.** The last visible index
changes on every frame of a scroll, and reading it in the body would recompose the grid
the whole way down a genre — the same rule as the animation one above. It asks
`LOAD_MORE_ROWS_AHEAD` rows early, since a request that starts when the viewer arrives at
the end is one they watch.
- **Something has to take the focus the chip was holding**, because opening a shelf unmounts
the whole discovery pane. The grid claims it when the first page lands, and an empty or
failed genre hands it back to the keyboard rather than leaving a television with nothing
focused.
- **Episodes are excluded on both paths.** An episode inherits its series' genres, so
including them fills a page with twenty entries of one comedy and buries the rest.
- **Movies and TV Series have their own full-width genre browser.** A fixed row of colourful
mini cards is the first focus target on both destinations; it is deliberately a product
catalogue rather than whatever genre names happened to arrive in the home rows, so its
order and availability never jump around during refresh. Neighbouring Emby labels are
merged where they answer the same browsing intent (`Action|Adventure`,
`Science Fiction|Sci-Fi|Sci Fi|Fantasy`, `War|History`) using Emby's pipe-delimited genre
filter. `ui/genre/` keeps the resulting pages in memory, preserves the active category
behind a detail page, returns focus to the title that was opened, and consumes
`HomeViewModel.favoriteChanges` so a Favourite press and its possible rollback reach the
paged copy immediately. Its `type=Movie` / `type=Series` query is still enforced by the
gateway and on the direct Emby path, so no category can mix the two grids.
- **The gateway path degrades to a keyword search on the *first* page only**, so a set on a
new build talking to a gateway that predates the route still shows something. A later page
does not: a gateway that answered page one and failed on page two is having trouble, not
missing the route, and search results pasted onto the end of a genre would be nonsense.
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 → genre → 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 `<queries>`, 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.
That ring is restored from a bounded JSONL archive in the persistent `memby-logs`
volume, so a deployment replaces the process without erasing the operator's history.
History compaction materialises the ordered ring only once per ringful; do not move
that copy back onto every append. Cursor reads calculate their ring offset directly.
The Admin Console retains and filters the full delivered window but virtualises the
display, caches each record's formatted/searchable form and stops polling while hidden.
- `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.
**A slow request says where its time went.** `internal/timing` is a per-request trace
carried in the context by pointer, the `requestIdentity` arrangement, and every layer
under the handler records against it: an `http.RoundTripper` wrapper on all eight upstream
clients (Emby, both *arrs, Tracearr, MDBList, Bazarr, OpenSubtitles, the integrations
dispatcher), a `pgx.QueryTracer` on the pool, and the cache's own `Get`/`Set`/`Delete`.
`withLogging` attaches the result as a `breakdown` field once the request passes
`slowRequestThreshold` — an operator override over `MEMBY_SLOW_REQUEST_THRESHOLD`, 500ms
deployed — and the console gives it a heading of its own in the log drawer and a second
line on the row. A duration column could only ever say that something was slow; this is
what makes it actionable:
```
GET /v1/home 9.51s breakdown=emby.favourites=7.80s fanout=7.81s rows=1.40s db=18ms encode=31ms miss=1
```
Things to preserve:
- **It measures stages, not spans in a tree.** Most of what matters here is concurrent —
Home fans five Emby queries out at once — so a total of wall-clock spans would exceed
the request's own duration and mean nothing. What a stage reports is summed busy time
and a **call count**, and the count is the half that finds duplicate work: `emby=7.8s
×14` on a page that should make two lookups is the finding whatever the seconds say.
- **The transport times the body too**, not only `RoundTrip`. Headers arrive while several
hundred kilobytes of row JSON is still on the wire, so timing the round trip alone would
under-report exactly the largest responses.
- **`timing.WithLabel` is what makes a fan-out legible.** Five concurrent Emby queries
under one `emby=` stage say the launcher waited on Emby without saying which of them it
waited on, which is the whole of the next question. Each home row, and each leg of the
season-finale chain, names its own stage. A label is only ever a finer name for a stage
the client would have recorded anyway, so its absence changes nothing.
- **`elsewhere` is the duration less the *largest* stage**, never less their sum, and is
clamped at zero. It is deliberately not called "gateway processing": with overlapping
stages no such figure exists, and what this is instead is a lower bound on time nothing
accounted for.
- **Everything is inert without a trace.** A scheduled sync, a health probe and every unit
test call the same helpers and pay one nil check.
**And the fixes the trace found.** They are all one shape — work on the critical path that
did not have to be there — and each is easy to reintroduce:
- **The automatic My Shows check ran a hundred times per episode.** It hangs off playback
reports, one arrives every ten seconds, and its own guard (half watched) is true for
every one of those after the halfway mark — so the second half of every episode ran two
serial Emby lookups, a Sonarr catalogue read and a Postgres write per tick and threw all
but the first away at the durable insert. `followChecks.claim` is a map lookup that comes
*first*, before the feature check and long before Emby. Deduplicating on the insert was
correct and was never the problem; what it could not do is prevent the work leading up to
it.
- **`InvalidateUser` walked the whole keyspace.** Redis SCAN's cost is a property of the
keyspace rather than of the pattern, so dropping one viewer's dozen cached rows meant
paging through every item lookup the household had ever cached — on the playback stop
report, which is the request a television makes as somebody presses Back out of a film.
`Cache.Set` now indexes user-scoped keys into `idx:u:<user>` in the same pipeline as the
write, and invalidation reads that set. The SCAN survives as the fallback for a user with
no index, which is the honest answer for a gateway upgraded with older keys still live.
- **Stable metadata lived where invalidation could reach it.** A person's biography, a
film's extras and whether an episode closes its season carry no user data and cannot
change when somebody finishes an episode, yet all three sat under `u:<user>:` and were
destroyed several times an evening by playback stops — then re-read from Emby by the next
card anybody looked at. `cache.MetadataKey` is where they live now: outside the
invalidated namespace and unkeyed by viewer, so one lookup answers for the household. The
rule for putting something there is narrow and worth stating: **the value must contain
nothing derived from a viewer.** Anything carrying `UserData` belongs in `UserKey`, where
invalidation can reach it — which is why the *filmography* stayed behind while the
biography moved.
- **Identical concurrent misses each asked upstream.** `s.cachedRead` / `buildCached`
(`coalesce.go`) put a `singleflight` between the cache miss and the upstream call, keyed
by the cache key. The log showed one person lookup answering in 2.2s, then 4.6s, then
7.7s within a few seconds; nothing was getting slower, three televisions had missed the
same key at the same moment and Emby was serving three copies of one expensive query.
The cache could not help — nothing had been written when the second and third arrived.
Two properties are load-bearing: **the shared work does not inherit the caller's
cancellation** (singleflight hands every joiner the first caller's result, so a D-pad
moving off a card would otherwise fail everybody queued behind it), and **a joiner is
counted** on the breakdown, because "slow" and "queued behind itself" are different
problems with different fixes. It is applied to the person biography and filmography,
related titles, extras, series episodes and item detail — every route the launcher warms
on focus and then asks for again on the press.
- **Serial chains that were never chains.** The season-finale route fetched Sonarr's series
catalogue *after* both Emby lookups it does not depend on; the extras route made its two
lookups in turn; `filterRecommendationPermissions` walked its hundred-id batches one at a
time on the home tail. All three now run together, and the permission check keeps its
original refusal — one failed batch hides every candidate, because a partial answer is
not evidence that the rest is permitted.
- **Home read its ranking evidence after the rows arrived.** `rankingContext` is seven
Postgres reads and `UserRowStats` an eighth, all of them functions of the viewer's id and
nothing else — so they ran with nothing else in flight, on the critical path, for answers
that were available before Emby was asked anything. They are issued *beside* the Emby
fan-out now (`rankingInputs`, `personalizeTitlesWith`), and the seven inside
`rankingContext` are issued together. The two that are genuinely a chain stay one, but
their *writes* are ordered rather than their reads: `ApplyOnboarding` and
`ApplyExplicitPreference` are not commutative and the ranking must never depend on which
query answered first.
- **Two documents were queried per request and could not change per request.** The feature
policy is read from sixteen places plus `/v1/status`, which every open television polls
every ten seconds; the household completion scores are a six-month aggregate whose answer
is *the same for every viewer in the house*. Both are cached in memory — five seconds and
a minute — and the operator's own write invalidates the first outright, so the window is
"how long until another instance notices", not "how long until my change takes effect".
A failed household read returns the previous reading rather than caching an empty one.
- **The connection pool was four.** `pgxpool`'s default is `max(4, GOMAXPROCS)`, which on
the NAS is fewer than one household's televisions — and a query waiting for a connection
is invisible, because the query time stays honest while the request is slow anyway.
`store.Open` floors it at 16 with 4 kept warm, and leaves alone any deployment that
stated `pool_max_conns` for itself.
Two things the trace found and did **not** change, because both are behaviour rather than
waste. `fieldsRow` asks Emby for `People` and `RecursiveItemCount`, which are the two
expensive fields on a row query — but the weighted ranker scores person affinity from the
first and a series card prints its episode count from the second, so dropping either is a
feature decision. And the home cache key carries the device id, which multiplies the
rebuild by the number of televisions; that is there because a recommendation's reason names
"this TV", although the recommendations it decorates come from a cache that is not
device-keyed. Both are worth revisiting deliberately.
**Emby's own version rides the reachability probe**, and the television prints it beside the
gateway's on Settings → About: `0.1.50 (4.10.0.21)`. `emby.Client.Ping` already asks
`/System/Info/Public`, which carries `Version`, so reading it costs nothing — a second call
per probe to learn a string that moves a few times a year would be the wrong trade. Three
things to preserve: the last known version is **kept through an outage**, because About is
exactly the page somebody opens when the server has stopped answering and a version that
blanked itself would replace a fact that is still true with nothing; `version` is **omitted
rather than sent empty**, so a client can tell "no probe has answered yet" from "Emby
answered without one"; and `gatewayVersionLabel` (pure, tested) prints the gateway's version
alone in every unknown case rather than empty brackets, which read as a fault. A gateway that
is not answering prints "Not connected" with no brackets at all — it cannot vouch for what
Emby is running either.
**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.
**Who may open it is Emby's answer, not the gateway's.** The sign-in behind the console is
the same discreet Emby password check the public installer uses, so on its own it admitted
anybody in the household — and opening `/admin/` then handed them the admin token cookie.
`embyAdministrator` requires `Policy.IsAdministrator`, which Emby returns *in the
authentication response*, so the check costs no request; a response carrying no policy at
all is asked again directly and an Emby that will not answer refuses the sign-in, because
silence is not permission and reading it as "no" would lock an operator out of their own
console. Three things to preserve: the refusal is worded exactly as a wrong password —
somebody who may not administer the server has no business learning that their password
was right — and the reason goes to the log instead; the *installer* is deliberately not
gated this way, since a viewer setting up a new television is who it is for; and the two
sign-ins issue **one cookie**, so they are separated by being signed with their own
purpose (`installerSessionPurpose` / `adminSessionPurpose`) rather than by a claim inside
the payload. An admin session satisfies the installer's gate; an installer session can
never satisfy the console's. `adminSessionTTL` is 90 days of idle time, matching
`MEMBY_SESSION_IDLE_EXPIRY` so a browser and a television are forgotten on the same
schedule, and only a request an operator actually made slides it forward — see
`operatorPresent`.
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/<page>` 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/<id>.html` and `<id>.js`; a fragment with no entry panics at start-up rather
than sitting there looking maintained.
- **The console is addressable by name as well as by menu.** Twenty-odd pages is more than a
rail six groups deep is read down, so the top bar carries a search field (Shift+S, and the
shortcut is *printed* on it — it is the only thing saying the console can be driven from
the keyboard). Its list is server-rendered from `.Nav` in `shell.html`, so it reads from
`adminNav` like everything else and cannot offer a page that does not exist or miss one
that does. Two rules in `prepareSearch`: a page's own **name outranks the sentence under
it**, or typing "se" answers with every page whose intro happens to contain those letters
and buries Searches, with ties keeping the rail's order so equally good matches never
reshuffle; and what the arrows walk is read **out of the DOM**, not out of the array the
markup was built from, because ranking re-orders the list and the two disagree the moment
it does. The shortcut stands down inside a field — the console is full of them, and one
that ate a capital S would be worse than none.
- **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.
**"25 goroutines" was the one figure on the console nobody could act on**, and
`server/internal/runtimestats` is what replaced it. A count says nothing about what those
goroutines are doing, which part of Memby they belong to, whether it is normal, or whether
it has been climbing all week — so the overview's Process card now prints a *verdict* and
signposts `/admin/runtime`, where the detail lives. The package is organised entirely by
what a thing costs, and that is the design:
- **The registry is free**, so it runs always. A long-running worker is started through
`runtimestats.Go(name, component, fn)` rather than a bare `go`, at every launch site in
`main.go` plus the scheduler's loop and the integrations dispatcher, and the console can
therefore *name* "Library ingest" instead of inferring it. `Starts` is on the record
because a worker being restarted in a loop reads exactly like a healthy one from a single
snapshot. Nothing in the package recovers a panic — reporting must never change what it
is reporting on.
- **The sampler is cheap**, so it runs on a minute's tick: a ring of `Sample`s four hours
deep. A single instantaneous number cannot show a leak and this can. `trendOf` is pure and
compares the median of the window's oldest quarter with its newest rather than fitting a
line, because a burst — an import, a household arriving home — is exactly the shape
least-squares reports as a trend; the threshold is proportional *with an absolute floor*,
or a busy gateway is permanently "rising" and a quiet one calls one extra goroutine a leak.
`DirectionUnknown` is a real answer and the honest one for the first quarter of an hour.
- **The breakdown is expensive**, so it is a button. `CollectGoroutines` walks every stack,
which stops the world, and the answer says when it was taken and what it cost.
`parseGoroutines` is pure and pinned by a fixture carrying a real dump, and the categories
must always **partition the total** — a breakdown whose parts do not add up is worse than
none, which is what the unrecognised-state bucket and both admin tests exist for. The
`componentRules` order is load-bearing: Memby's own packages are tested before the
libraries they call, or every query in the gateway files under "Database pool".
`?format=text` hands over the raw dump, which is the alternative to leaving a profiler
endpoint permanently mounted.
- **The verdict is the gateway's wording, not the console's** (`assess`, pure and tested),
the stance every label the gateway prints takes: the threshold and the sentence explaining
it belong together, and an older console must not be the thing deciding what "watch"
means. Every note carries an **area** — a number that has moved is only actionable once it
points somewhere — and a rise slower than `goroutineLeakPerHour` is not a note at all, or
the card cries wolf on every busy evening.
- **`Process` is what /proc knows and the runtime does not** — processor time, open sockets,
the descriptor limit. `CPUKnown`/`FilesKnown` are separate from the figures because "no
open sockets" and "could not look" are different answers, and a developer machine is the
second.
**The gateway's own settings are `/admin/settings`**, reached from the account menu in the
top bar rather than from the rail — every other page decides what the *televisions* do, and
this one is about the server process. It is `store.GatewaySettings` (one `app_settings` row)
over `internal/api/gateway_settings.go`, and it holds the household timezone, the log level,
the idle sign-out, the two alert windows and the Emby health probe. Things to preserve:
- **Every field is an override, and `.env` is still the configuration.** Blank means
"whatever this container was started with", which the page prints beside each field, so
clearing a setting is a real undo rather than a value the operator has to remember. A
setting that can legitimately be *off* therefore needs a third value: **-1 is off, 0 is
deployed** (`store.GatewaySettingsOff`), because a plain zero would make "turn this off"
indistinguishable from "leave it alone".
- **Nothing reads the document directly.** The effective-value helpers in
`gateway_settings.go` — `householdLocation`, `sessionIdleExpiry`, `sonarrAlertWindow`,
`radarrAlertWindow`, `embyHealthInterval` — are the only readers, so adding a setting is
one helper beside its config field rather than teaching every call site that an override
exists. `s.cfg.SonarrLocation` in particular should not be read directly any more:
`householdLocation()` is what makes a timezone change reach the schedule rows, the hero
rotation and the sign-in history.
- **A setting that is read once at start-up is not a setting.** `WatchEmbyReachability`
re-reads its cadence every tick and keeps ticking (slowly) while the probe is off, so
switching it back on does not need a restart; the idle sweep reads the expiry inside
`Run` rather than closing over it; and the log level is a `*slog.LevelVar` threaded from
`main` through `Deps.LogLevel`, applied on save rather than waited for — an operator who
has just turned debug on and gone to look at the log must not spend thirty seconds
believing it did not work. `deployedLogLevel` is remembered because clearing the
override has to restore *something*, and the variable itself has by then been moved.
**Integrations are their own area, and they are one axis over the scheduler rather than a
second system.** `/admin/integrations` is the overview, `/admin/integrations/{id}` is one
service, and `internal/api/integrations_catalogue.go` is the only place a service is
declared — the `featureCatalogue` shape. Before it, MDBList's settings were on a Movie
ratings page, the Sonarr and Radarr switches were on a page about Discord webhooks, their
request policies were beside those, and Tracearr could not be reached from the console at
all: "is everything Memby depends on working" was four pages and one impossible question.
Things to preserve:
- **A run history is not a second store.** An integration run *is* a
`scheduled_task_runs` row: `scheduler.Task` carries an `Integration` id, the row records
it, and `store.IntegrationRuns` reads the same table along the other axis. Giving the
area a table of its own would mean two schedulers, two retention jobs and two places one
piece of work could be recorded as having failed. What was added is four counters
(`store.RunCounts` — processed, changed, skipped, failed) and `scheduler.Work`, the
counting form of `Run`; a task declares one or the other and `Register` panics on both.
The counters exist because "412 films checked, 7 updated" is the question an operator
has and a sentence is a poor place to keep numbers — they cannot be compared between
runs or sorted.
- **Runs and Logs answer different questions and say so.** Logs are the technical events
behind a failure; this is the operational record of what Memby attempted and what came of
it. A failing service's page links to `/admin/logs?q=<id>` — which is why the log viewer
seeds its text filter from `?q=` — so the two are joined rather than merged.
- **The work is scheduler tasks now, and that is the half that changed behaviour.** The
Sonarr lifecycle scan, the Radarr catalogue refresh, the Tracearr import and the daily
For You rebuild were four goroutines with tickers of their own in `main`. A ticker could
report nothing to an operator, could not be started by hand, and could not be counted
against a service. `foryou.Service.Schedule` and `nextDailyRebuild` are gone with them;
the "is the rebuild due" rule moved to `api.forYouRebuildDue`, which is pure and tested
and compares **local calendar days** rather than subtracting 24 hours.
- **A switched-off integration reports a *skipped* run, never silence and never an error.**
Silence is indistinguishable from a scheduler that has stopped, and an error would put a
red row in the console for a state the operator chose.
- **One stored truth per integration, and one reader.** The switch lives where that
service's configuration already lives — Sonarr and Radarr in `ArrIntegrationPolicy`,
MDBList in `MDBListSettings.Enabled`, Tracearr in the new `integration_policy` document
— and `integrationEnabled`/`SetEnabled` are the only ways in. Consolidating them would
be a migration with a window in which two documents disagreed about whether a service
was on. Absence means **on**, or the day this shipped would have switched every
household's imports off.
- **`integrationSuppressed` is not the negation of `integrationEnabled`.** The latter is
also false for a service this deployment never configured, and a household can perfectly
well point Sonarr's *webhook* at Memby without giving Memby Sonarr's API key. The import
hooks use the former, and answer 200 while ignoring the payload — neither *arr
re-delivers a rejection, so refusing would read as Memby losing imports rather than as
the switch the operator set.
- **A switch consulted once at start-up is not a switch.** `recommend.Engine.TracearrAllowed`
is a function read at call time, wired in `main` after the server exists, and nil means
allowed so every test behaves as it did. Switching Tracearr off costs the extra signal
and never the row — the same degradation an unreachable Tracearr already produced.
- **Health is probed on a schedule, not on page load.** The console polls; probing per
request would make every open tab its own request against somebody's Sonarr. One
`integration-health` task every five minutes fills `integrationHealthCache`, and the
console's Test button calls the *same* `probeIntegration` — a test taking a different
path could report a service as working while the row beside it stayed red. A switched-off
service is not probed at all, which is what the switch promises.
- **MDBList has no probe, and the page says so rather than drawing "never checked".**
Its allowance is bought by the day, and spending a request of it to colour a status dot
would have the console competing with the televisions for the thing it is reporting on.
Its health comes from its own run history.
- **The status word is the gateway's** (`integrationStatus`, pure and tested), the stance
every label Memby prints takes. It resolves in priority order — unconfigured, disabled,
running, then the evidence — and a failure followed by a success is *history rather than
a verdict*, or one bad night would leave a service red until the retention job removed
the row. `idle` is a real answer for a service that has not done anything yet; claiming
either verdict would be a guess.
- **Switching one off states what goes with it.** `Powers` in the catalogue is rendered on
the service's page and repeated in the activity event, because "Sonarr disabled" tells an
operator nothing they did not just do — what they may not have thought about is the
television calendar going with it.
- **`/admin/ratings` redirects** to the MDBList page rather than 404ing: it was
bookmarkable and appears in older activity links. The old Integrations page survives as
`/admin/integrations/webhooks`, which answers the opposite question from the rest of the
area — those are services Memby *depends on*, that is a place Memby *posts to*, and
removing every webhook leaves the gateway unchanged.
**Two things a television does are notifications now**, both in `internal/api/device_activity.go`.
`TypeDeviceFirstUse` announces the first time a set opened Memby on a household-local day
and `TypeDeviceUpdated` announces one that finished updating itself. Things to preserve:
- **First use is anchored on `/v1/home`**, not on the auth middleware every call passes
through: a set left on overnight polls `/v1/status` every ten seconds, so any-request
would announce it at midnight — an event nobody did, at the hour nobody is reading. It is
marked *before* the cached response is served, because a household whose rows are still
warm from another set has still just been opened by this one.
- **The answer comes from the insert.** `store.MarkDeviceDay` is `ON CONFLICT DO NOTHING`
on `device_activity_days`, so two televisions racing on the same row cannot both be told
they were first. The marks are retired with the device and pruned by their own
housekeeping task.
- **An update needs a previous version to be an update.** `announceDeviceUpdate` fires from
`captureClientIdentity` — the only place a set that updated in place is ever seen, since
it never signs in again — and an empty previous version is a build this gateway had never
been told the version of, not a version that moved. A *downgrade* is still announced: a
sideloaded step backwards is news too.
**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:<userId>: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.
**"Because you watched …" rotates, because the top of a watch history does not.** The rows
were anchored to the two most recent seeds, and the head of history is a resumable title
plus whichever series the household is part-way through — neither of which moves for weeks,
so the same two rows came back day after day. `selectSeeds` (pure, tested) instead cuts the
most recent `SeedPool` seeds into `MaxSimilarRows` equal bands and draws one from each by
`dailySeed(userID)`. Things to preserve: it is a rotation *within recency bands*, never a
shuffle of the window, so the first row is still anchored to something watched lately and
the rows below it reach further back; the same day always yields the same seeds, because
rows are rebuilt on every cache miss and a set that re-picked each time would change under
somebody browsing; the last band takes the remainder, so a pool that does not divide evenly
still reaches its oldest entry; and a history shorter than the pool falls back to plain
recency rather than pretending to rotate. `similarRow` also runs `diversifyRanked` over its
cards with the same daily variation, the way curated shelves do — a row that keeps its seed
across two days must not present the same posters in the same order. `MEMBY_RECOMMEND_TTL`
(24h) is what makes the rotation daily in practice: the seed only changes at a rebuild.
**`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`).
**A detail page is warmed while its card is focused, in two waves.** By the time somebody
presses a card, the item record and its "why you might enjoy it" have been fetched — and now
so have its episode list and its trailer, which were the two things
the page still opened cold. That mattered most on Continue Watching, where every card is an
episode and pressing one opened a page with no season scroller and no episode list until the
network answered. Things to preserve:
- **Playback itself is never warmed on focus.** Emby's PlaybackInfo negotiation creates a
playback session, so asking for it while somebody merely browses pollutes server history
with titles they never played. Stream resolution begins only after a Play action.
- **The two waves have deliberately different delays.** The metadata warm follows the D-pad
closely at `FOCUS_METADATA_DEBOUNCE_MS` (140 ms) because it decides what the panel beside
the row says; `warmDetailPage` waits `DETAIL_PREFETCH_DELAY_MS` (450 ms) because an
episode list is the largest request this client makes — a long-running show is a thousand
records — and warming one per card as somebody scans a shelf would spend more than it
saves. The job is cancelled when focus moves, so a viewer travelling along a row never
reaches it and one who has stopped, which is what precedes a press, does.
- **Everything warmed on focus must be single-flighted on the repository's own scope**, for
the reason `getRelated` already was: the warming job dies with the D-pad, and a request
cancelled at the socket is one the gateway logs as a failure and one nobody keeps the
answer of. `getSeriesEpisodes` and `getLocalTrailer` now take the same shape. A prefetch
added without it makes navigation *slower*, because the press that follows re-asks.
- **`getLocalTrailer` caches its negative answer** (`CachedTrailer`, the `CachedTrickplay`
precedent). Most of a library has no local trailer, so before this every detail page opened
with a request the gateway answered 404 to, repeated on walking Back and again for every
step of the "More like this" trail.
- **An episode is keyed on its series**, not on itself — that is what its own page will ask
for, and it is what makes one warm serve a whole row of Continue Watching.
**Trailers are a provider chain, not ordinary title playback.** `GET /v1/items/{id}/trailers`
is the cheap availability answer used while a detail page is warmed; the Play press opens
`PlayerActivity` immediately and `POST /v1/items/{id}/trailers/resolve` selects a candidate.
Official Apple and labelled official YouTube sources come first, followed by local Emby media
and then the remaining recognised remote trailers. Local media is returned directly; Apple
and YouTube pages are resolved and validated on the television, then played natively. That
boundary is deliberate: provider requests and any IP-bound media URL must originate from the
viewer's real client address, never the gateway. The player keeps the subject and rejected
candidate ids: an error or an eight-second stall before the first frame asks for the next
provider behind the same loading surface. Exhausting the chain closes the player and returns
to the still-composed detail page; it never shows the ordinary playback error pane.
Keep these boundaries:
- Metadata discovery may be warmed and cached, but local `PlaybackInfo` and remote stream
resolution begin only after the viewer presses Trailer.
- Provider details stay behind the resolver interface. Compose knows only whether a trailer
exists, and the player knows only how to ask for the next candidate. The client resolver
caches only mappings that passed a media probe; a failed player source is evicted before
fallback.
- A trailer never uses the Memby pre-roll. Back and natural completion return to the screen
that launched it, while the normal native controls and aspect-ratio handling remain intact.
The player's title treatment receives the subject's logo URL, with text only as its fallback.
- Direct-to-Emby mode retains local trailers. Remote Apple and YouTube resolution belongs to
the gateway-assisted path because it owns metadata discovery and candidate ordering, but
provider resolution itself stays on the television. `POST /v1/items/{id}/trailers/report`
records started, failed and completed attempts with the forwarded client address; successful
candidates become the gateway's next first choice only after a real first frame.
**A detail overlay seeds its settings from `repository.currentSettings`**, never
`Settings.EMPTY`. Collecting a flow with an empty initial value draws the first frame under
default preferences and then recomposes the entire page — and restarts the effects keyed on
those preferences, which is a second ratings request — one frame later, at exactly the moment
the page is trying to appear.
**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 nineteen
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::<userId>@<serverUrl>` 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 **Gitea** release
(`/api/v1/repos/{owner}/{repo}/releases/latest`, token auth for private repos) or a static
manifest, 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`.
**There is no "check for updates" on the television.** Settings had an Updates page with that
button on it, and the address, repository and token it needs could not be entered anywhere on
this app — so the only thing it could ever report was a failure, on the one screen a viewer
opens when they already suspect something is wrong. The single answer about updates is the
gateway's (`server/internal/appupdate` → `ui/UpdateScreen.kt`), which arrives on every launch
and carries its own `downloadUrl`; `SettingsPage` has no `UPDATES` entry and the version this
TV is running is stated once, on About. The `updateBaseUrl`/`updateRepo`/`updateToken` keys
remain in `Settings` and nothing writes them — they are what an install predating this still
has in its DataStore.
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 from the player**, in
`internal/api/subtitle_download.go` over the provider layer in `subtitle_providers.go`.
**Two backends answer and they are not the same shape**, which is the one thing to hold on
to about this feature. **Bazarr** (`server/internal/bazarr`) writes the file *beside the
media file*, so the gateway stores nothing and serves nothing — 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. **OpenSubtitles** (`server/internal/opensubtitles`) hands back *bytes*, and the
gateway has no reach into the media directory, so a file fetched there is stored in
`downloaded_subtitles` and served back as a sidecar from `/v1/subtitles/{file}`. That
difference is the entire reason the gateway now holds a subtitle at all, and it is
contained: `mergeSubtitleTracks` puts what the gateway holds beside Emby's tracks inside
`playbackSubtitles`, so a downloaded subtitle is an ordinary track on every later playback
rather than something that exists only in the response that produced it. Things to
preserve:
- **The operator's switches are `store.SubtitlePolicy`, not environment variables.** A
provider is offered when the `subtitle_download` feature is on *and* it is configured
*and* it is switched on — `subtitleSources`, one place. Bazarr's address stays an
environment variable because it is a service the household runs; OpenSubtitles is an
account, so its key and login live in that document and the console's Subtitles page can
enter, replace or remove them without a redeployment. The store refuses to record
OpenSubtitles as on with no key, so the console can never show a switch that does
nothing. Nothing on that page ever returns a credential — only whether one is saved,
the stance the MDBList page takes.
- **A candidate carries its `Source` and the download dispatches on it.** The two tokens
are opaque in different ways and handing one to the other is a mistake nothing
downstream could detect. Empty means Bazarr, because an app built before there was a
second provider sends no source and all its rows came from one place.
- **The two providers make opposite trades on identity, which is the hard part.** 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". OpenSubtitles keys on an imdb
or tmdb id, which Memby already holds because the library import asks Emby for
`ProviderIds` so external ratings can be looked up, so there is **no guessing on that
path at all**. An episode is searched by its series' id plus season and episode number
whenever the episode carries no id of its own, since a show has one far more often than
each of its episodes does.
- **`resolveSubtitleTarget` reads the item once and fails per provider.** A film Bazarr
has never heard of may still have an imdb id, and a title with no provider id may still
be in Bazarr's list; only both failing is a failure. `providerSubtitles` then searches
both at once — a manual search is a live provider query measured in seconds — and a
provider that fails is dropped rather than failing the search.
- **The provider row is opaque.** The token is provider-specific and 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.
- **A machine translation is offered, and says so.** It is a real answer and sometimes the
only one, so `rankMergedCandidates` sinks it below everything a person wrote rather than
hiding it, and `MachineOnly` is on the wire because it is the one property that changes
whether a viewer wants the row at all. The provider is now *named* on a row, where the
single-provider version deliberately did not name it: with two backends the same
language appears twice and "which of these is which" is a question the row has to answer.
- **The exhausted-allowance case keeps its own wording** (`opensubtitles.QuotaError` →
`subtitleFailureMessage` / `subtitleDownloadFailureMessage`). It is the only failure
where pressing the button again is definitely not the answer, and a television has no
log and no support channel — that sentence is the whole diagnosis.
- **A stored subtitle's id is derived, not random** (`storedSubtitleID`), so fetching the
same language for the same title twice replaces the file rather than growing a second
track a viewer has to tell apart by guessing. Its `gw:` prefix is what keeps it out of
Emby's namespace, which is stream indices — plain numbers — because the player matches a
track on the id it was handed.
- **The gateway sends a *path* for a subtitle it serves, never an address.** It does not
reliably know its externally reachable name and the television does, because it is the
thing talking to it. `EmbyRepository.resolveSubtitleUrls` puts the base and the `t=`
token on it, exactly as `imageUrl` does for artwork and for the same reason: media3
fetches a sidecar as a plain URL with none of Memby's headers attached. A URL that is
already absolute is left alone, so it is safe over every playback response.
- **`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. The client default is **false** — a
missing field must never conjure a row that leads to a request the backend cannot answer.
- **A title with no subtitles is told so, in the track section** (`SubtitleTracksState`).
A list holding nothing but "Off" is indistinguishable from a menu that failed to load,
and the row that could fix it sits below a rule under a heading the eye has no reason to
travel to — so the notice goes under the SUBTITLES heading and the focus ring opens on
the search row instead of on "Off", which is the state the viewer is already in. Where
no provider can be asked it says that instead, once, rather than leaving somebody
looking for an option that is not there. `SubtitleMenuScreenshotTest` covers both.
- **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. PlaybackInfo and media bytes use a dedicated dispatcher, so a launcher full
of queued artwork cannot occupy the stream's per-host slots; it still shares the same
connection pool, retaining the warm connection. It also enables constant-bitrate seeking,
so a container with no usable seek table computes the offset instead of reading its way
there. The connection reuse and seeking choices 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.
- **The two launch forms are two places a `Playable` has to be unpacked, and only one of
them is obvious.** `adoptPlayable` handles the request form; on the URL form — the cold
start, and *any* launch where `readyPlayableForLaunch` had a warm prefetch in hand — the
intent is the only thing that will ever carry an answer, and a field with no
`putExtra`/`getExtra` pair silently takes its default. That is how `trickplayAvailable`,
`skipIntroAvailable` and `endCreditsAvailable` were all shipped switched off: each
defaults to **false** on purpose, so the omission produced no error, no log line and no
request — the gateway saw zero `/trickplay` and zero `/intro` calls across seventy-seven
playbacks. A new "is it worth asking the backend" boolean must be added in **four**
places: the `Playable`, the intent's parameter list, its `putExtra`, and `onCreate`'s
`getBooleanExtra`. `subtitleDownloadAvailable` is the worked example of all four.
- **`ui/player/PlaybackTrace.kt` says where the time went.** "Playback is slow" is not
actionable; `event=first_frame … play_clicked=0 … source_resolved=… player_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.
- **Startup is bounded even when Media3 never throws.** Source resolution has a 15-second
deadline and one fresh foreground retry. After `prepare()`, first frame has a 20-second
deadline; the first expiry stops the player, clears its media items, negotiates a fresh
playback session and prepares again. A second expiry becomes the ordinary retry/exit
error screen. The foreground resolution discards a stale focus-prefetch after a short
grace period rather than awaiting a process-scoped deferred indefinitely. These bounds
are cancelled while the activity is stopped and reinstated on return, so backgrounding
the app is not itself treated as a playback failure.
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. Two things that look like causes and are not: the
subtitle auto-selection costs 20200 ms, not seconds, and the seek itself is about 900 ms.
**`StreamWarmer` is what closes that gap** (`data/remote/StreamWarmer.kt`). It opens a
connection to the machine video comes from before anybody asks for video, which is only
worth anything because `HttpStack` shares one connection pool: a connection opened by an
unrelated HEAD request is the connection ExoPlayer picks up later. Nothing is handed over
but the address. Four things to preserve:
- **It warms the host, never the title.** The obvious version asks for the first byte of the
film, which would also warm Emby's file cache — and would put a delivery for a title
nobody watched into somebody's server history. This app already refuses to warm
`PlaybackInfo` on focus for that reason. Any answer establishes the connection, so the
request is a bare HEAD of the origin and a 404 or a 405 is as good as a 200.
- **The address outlives the process.** On the gateway path a resolved stream URL is the only
thing that ever names Emby, so a television that has just started has no idea where the
video lives. Remembering it is what moves the saving to the *first* playback after a cold
start, which is exactly the one that was slowest. It is kept in its own small
`SharedPreferences` file rather than in the settings DataStore, which rewrites and fsyncs
everything it holds on every edit.
- **It is warmed from two places and needs both.** `MembyApp.onCreate` covers the cold start,
and `HomeViewModel.warmDetailPage` covers a viewer who has been browsing for a while —
focus settling on a playable card is the best warning of a Play press this app gets, and
the three-minute interval is shorter than the pool's five-minute keep-alive so that press
meets a live connection rather than one the pool has just evicted.
- **Failure is silent and costs nothing.** It is a performance hint: a server asleep, an
address that has moved, or no network at all each leave playback exactly as slow as it was
before. A failed warm clears the interval so the next attempt is not held off for minutes.
`originOf` is the pure half and is unit-tested — a warm aimed at the wrong host is worse
than none, since it opens a connection nothing will use and leaves the one that matters
cold.
**An advance opens the next episode's stream before anybody asks for it.** `StreamWarmer`
warms the *host*; this warms the title, which it can only do here because an advance is the
one case where the app knows what is next minutes ahead — `NextUpResolver` has the answer
five minutes before the credits (`NEXT_UP_STREAM_WARM_LEAD_MS`). Media3's
`DefaultPreloadManager` does the work; `ui/player/NextEpisodePreloader.kt` owns the two
decisions it cannot make for itself, and `PreloadPlan.kt` holds them as pure functions so
they can be pinned by plain JUnit. Things to preserve:
- **The player and the manager are built from one builder.**
`DefaultPreloadManager.Builder.buildExoPlayer` *overwrites* the media source factory,
renderers, load control, bandwidth meter, track selector and playback looper on whatever
`ExoPlayer.Builder` it is handed, so everything shared is set on the manager's builder and
the player's carries only what the manager has no opinion about. The looper is the one
that would actually break: a source prepared on one playback thread and played on another
is a crash, not a slow start.
- **Ranking data is a position in the journey, not a playlist index.** Nothing here is a
playlist — the player is handed one episode at a time and the next is discovered while it
plays — so a rank is assigned when an answer arrives and only ever moves forward. Exactly
one episode ahead is preloaded (`preloadTargetFor`); two would double the cost for a
viewer who has two episodes' worth of time to walk away.
- **Eviction never touches the episode playing.** On an advance the player has just been
handed that episode's `MediaSource`, and `remove` releases the source underneath the
decoder using it. `obsoletePreloadRanks` is strictly-behind for that reason, `advanceTo`
is called *after* `startMedia` and not before it, and the entry for the playing episode is
left in the manager to be quietened by its target status turning to
`PRELOAD_STATUS_NOT_PRELOADED` on the next `invalidate`.
- **The bound is a memory ceiling first.** `PRELOAD_RANGE_MS` is five seconds because the
expensive half of starting a stream is the connection, the container header and the seek
index — which `specifiedRangeLoaded` pays by preparing the source and selecting tracks —
not the bytes. A 4K direct play runs past 30 Mbps, so every second held ahead is megabytes
on a box with none spare, for a title the viewer may not go on to.
- **Registration hangs off the resolver, not off one call site.** `NextUpResolver`'s
`onResolved` fires for the first lookup *and* for every re-negotiation of a stale stream,
and a re-negotiation is exactly when preloaded work stops matching the URL the player will
be handed. It fires only for the episode still playing, or an answer that arrived after
the viewer moved on would have the preloader open a connection for a journey that no
longer exists.
- **Every part of it degrades to what came before.** A manager that will not build leaves
the preloader unattached, `sourceFor` answers null and `startMedia` takes the ordinary
`setMediaItem` path — which is also what a cold start, the direct-to-Emby path, a retry
that re-negotiated, and an unfinished preload all take. That fallback is *logged*
(`event=preload_unavailable`), because it is invisible from the viewer's side and a set
that never preloads anything otherwise looks exactly like one where the feature works and
never happens to save time.
- **`PRELOADING_ENABLED` and `DYNAMIC_SCHEDULING_ENABLED` are separate switches** in
`PlayerEngine`, the `THEME_PICKER_ENABLED` precedent, so a television that misbehaves on
Media3's experimental scheduling can have that taken away without losing preloading or the
version bump underneath both.
- **`event=first_frame` says which start it is measuring** (`start=cold` / `start=preloaded`).
The whole feature is a claim about one of two latencies, and a log that could not separate
them could not show whether it worked. `event=preload_ready` carries how long the preload
itself took and the range it was bounded to.
**Media3 is one version across every artifact, and the Jellyfin FFmpeg extension pins which
one that can be.** That extension is compiled against `media3-exoplayer` and reached
*reflectively* through `EXTENSION_RENDERER_MODE_ON`, so a core from a different minor line
fails at renderer construction rather than at compile time — and `PlayerEngine`'s
`LinkageError` fallback would swallow it, silently withdrawing surround software decode with
nothing in the log to say why. Jellyfin publishes up to the 1.9 line, so `media3Version` in
`app/build.gradle.kts` is on it. `enablePerStreamMediaProgression` arrived in 1.11 and is
therefore not available here; `experimentalSetDynamicSchedulingEnabled` is the part of that
same work which is. Moving the core past 1.9 means finding a matching extension first, or
deciding to do without DTS.
**Where a title resumes from is the television's own answer, not the card's.** The launcher
hands `PlaybackRequest` the position it read off the card, and *nothing downstream corrects
it*: the direct path resolves no position at all, and the gateway takes `resumePositionMs`
as a hint (`playbackHint`) and echoes it back rather than paying a second Emby lookup. So a
card is the whole answer, and a card is only as fresh as the last home refresh — which is
how a short session came to be lost. Somebody watched twenty seconds, left, and pressed Play
again inside the second or two the stop report and the refresh take, and was sent back to
where they had started. `EmbyRepository.localResume` is what closes that window: the playhead
is recorded **before** the report is attempted, from `enqueuePlaybackStopped` synchronously
and from every progress report, and `launchResumePositionMs` consults it at
`playbackRequest()` — the one funnel every launch passes through, and therefore also what
opens the player and what the gateway is given as its hint. Things to preserve:
- **It is a *greatest*, never a preference**, which is what retires the record with no
bookkeeping at all: once a refresh brings the card back carrying that position — or a later
one, watched on another set — the card is at least as current and the local record can no
longer change the answer. `LOCAL_RESUME_MAX_AGE_MS` is only a backstop for a set that
recorded a position and was then left alone.
- **A completed title is forgotten rather than remembered.** The server resets a finished
title's position, so a record kept past that would drop somebody into the closing minutes of
something they had deliberately started again. That is the only reason `durationMs` is
carried through `PlaybackStopWorker.enqueue` — zero means the runtime was not known, never
that the title is zero long. Marking watched or unwatched, and hiding a title from the
shelf, forget it for the same reason.
- **The launcher is told before the server is.** `repository.playbackPositions` is emitted as
the player exits and `HomeViewModel.applyPlaybackPosition` moves the card's progress bar
immediately, ahead of the `playbackStops` refresh that replaces it with the server's own
answer — and it never moves a card backwards, because a stop and the ten-second report
before it can arrive in either order. It is also what puts the new position into the
persisted `HomeCache`, so a cold start does not draw the old one.
- **`clearPlayableCache` deliberately does not touch it.** A playback stop clears that cache,
and outliving it to answer the launch that follows is the entire point. Session changes call
`clearLocalResume` beside it — another viewer's playheads are not this one's.
**Playback position has one ordered exit path.** Ten-second progress updates, pause/seek
updates and the final Stop all pass through `EmbyRepository`'s `playbackReportMutex`, so a
slow older Progress request cannot complete after Stop and move Emby's saved playhead back.
`PlaybackStopWorker.enqueue` still writes the final position to WorkManager first, but also
sends it immediately from the repository's process scope — leaving the activity no longer
means waiting for WorkManager before Emby Web can resume at the right frame. A successful
immediate delivery cancels that exact fallback request, not the unique work name, because a
newer stop for the same playback session may already have replaced it. The worker drops a
report after 60 seconds: a late retry overwriting progress made in another Emby client is
worse than losing an old fallback. `PlaybackStopWorkerTest` pins that freshness boundary.
**Playback keeps the selected title visible while it starts.** The launcher hands
`PlayerActivity` the backdrop it already has, and `player_loading.xml` holds that artwork
under a dark wash rather than replacing it with an almost-black field. This is intent
metadata, not another playback-path request; it is saved across recreation and replaced by
the next episode's landscape artwork during auto-advance. On the station-style pre-roll, a
television episode replaces the Memby mark at top-left with the programme's own logo and
puts its episode code and title directly beneath it. The same hierarchy is repeated by the
five-second ident once the first frame lands. `seriesName` and `episodeCode` travel with the
resolved `Playable`; a missing or failed logo falls back to the series name instead of
leaving an empty corner. `PrerollScreenshotTest` and `PlaybackIdentityScreenshotTest`
record the pre-roll, the first-frame ident and the backdrop loading state under
`build/screenshots/sonarr-preroll/`, `build/screenshots/playback-identity/` and
`build/screenshots/playback-loading/`.
**The station ident is one announcement, and the corner it uses has one owner.** It read as
duplicated or overlapping — for films and episodes alike, intermittently — and nothing was
ever drawn twice: the ident (`player_playback_identity`, activity-owned, above the
`PlayerView`, top-start at 48/34dp) and the transport controller's own
`player_now_playing_group` (inside `memby_player_controls.xml`, top-start at 48/30dp) are the
same logo and the same title, four density pixels apart, bound by two methods
(`setUpPlaybackIdentity` and `bindTitleArtwork`) that had never heard of each other. Whenever
the transport happened to be up inside the ident's five seconds — a remote press, a pause,
closing the cast or subtitle overlay, media3's own `auto_show` — both drew, which is exactly
why it was intermittent. `ui/player/PlaybackIdentity.kt` holds the rule instead. Things to
preserve:
- **`playerIdentitySlot` is the whole of the priority**, pure and tested: paused outranks the
transport, the transport outranks the ident. Pause is `NONE` because the pause overlay
already carries the poster, the title and the synopsis — a logo above it is the same
programme said twice in two type sizes. `applyIdentityRegion` is the only thing that sets
`nowPlayingGroup`'s visibility; `updatePauseOverlay` setting it directly is what the defect
looked like from the transport's side.
- **The transport's arrival *ends* the ident** rather than moving or fading it. Of the three
possible rules that is the only one that never has two answers to "what is playing" on
screen at once, and it needs no measured choreography between two layouts that know nothing
about each other. `PlayerView.ControllerVisibilityListener` is the only honest source of
that state — media3 raises the transport for reasons the activity never hears about.
- **The ident is a phase, never a boolean** (`PlaybackIdentityPhase`, `shouldRaiseIdent`).
"Has not opened yet" and "has already had its turn" are different answers to an arriving
playback-started event, and every launch reports one at least once — first frame, pre-roll
hand-off, a recovery re-prepare. A withheld ident is *spent*, not deferred, or it would
appear seconds into the programme when the controls timed out.
- **`setUpPlaybackIdentity` binds and never shows.** `adoptPlayable` re-binds on every gateway
launch once the server settles which episode it is, so a bind that could also raise the
ident is a second one.
- **`resetPlaybackIdentity` is called wherever the *subject* changes inside a player the
viewer never left** — an episode advance, a trailer resolving, a next-episode preview and
the return from a failed one. Those are the paths that would otherwise carry the outgoing
title's spent ident into the incoming title, or leave the outgoing one's fade running over
it.
- **The logo and its text fallback both start hidden.** The fallback was `visible` in the
layout and only hidden when Coil reported success, so a title with a logo showed its name
and then its logo — a swap in the same corner that reads as the ident drawing twice.
- **The episode line is subordinate and fixed in place.** The logo sits in a fixed 82dp box
aligned to its bottom, so `S01E01 — Bob Smith` lands at the same height whatever the
artwork's proportions are and whether there is artwork at all; the line itself is a compact
white-on-near-black plate, one line, ellipsised, so a long episode title cannot reach the
middle of the screen. A film has no line at all — nothing is reserved for one.
**The local Memby preroll is prepared while Home is idle.** `PrerollPreloader` owns one
process-scoped ExoPlayer for `res/raw/emby_preroll.mp4`; `MembyApp` queues its first prepare
on the main queue's idle handler, so decoder construction and the local resource read never
sit in `Application.onCreate` or in front of launcher composition. When a fresh playback
requires the preroll, `PlayerActivity` borrows that player and shows it in the pre-roll video
frame while the requested title negotiates and prepares, paused at zero, behind the overlay.
Three details preserve the performance claim: the clip's actual duration owns the hand-off
rather than a stale configured estimate; the old paused-content-frame path remains the
failure fallback; and the preroll player is stopped and parked at hand-off so it releases its
hardware decoder while HEVC content plays. Returning to Home prepares the same instance again
in the next idle window rather than constructing one per title.
**The same clip plays behind the cold-start screen** (`ui/LaunchPreroll.kt`). It is the one
screen every launch shows and the clip was the one thing Memby owns that nobody ever saw
there. It borrows the *same* cached instance — no second decoder, no second copy of the
file — and hands it back on dispose, so the next playback still opens on a prepared player.
**It now gates the launcher**, which is the whole point of it: it was decoration, uncovered
whenever the app happened to be ready, and on a warm start that was a fraction of a second —
so the one thing Memby owns was in practice never seen. The clip plays once from the
beginning, its last frame is held for `LAUNCH_INTRO_HOLD_MS` (2s), and only then is the home
screen composed. Things to preserve: **it gates but it can never trap** — no player, a
decoder error, or no rendered frame within three seconds all report finished immediately,
and `LAUNCH_INTRO_MAX_MS` (9s, the clip's four plus the hold plus room for a weak box) is
`AppRoot`'s outer bound on top of that, because a viewer must never be held on a black
screen by a branding clip; **`LaunchIntro.played` is process-scoped, not saved**, so an
activity Android recreates behind somebody is not a second launch and a profile switch does
not replay it; the pulsing mark and the welcome line fade *out* under the clip and back in
if the app is still opening when it ends, since the waiting screen and a four-second sting
said two things at once; and it no longer loops — looping existed so a slow cold start never
froze on the last frame, which the hold and the fade-out do instead, and a clip that never
ends cannot gate anything. It is still muted, because it now runs on every single app open,
where the pre-roll before a programme is audible and must *end* — `PrerollPreloader.acquire`
/`recycle` normalise volume and repeat mode so a borrower cannot leave the next one wedged.
It is acquired **after** `withFrameNanos`, because a cold start has nothing cached and
constructing an ExoPlayer inside the first composition of the screen that must appear
immediately is the cost the idle-handler prepare exists to avoid. And
`AppRoot` calls `MembyLoadingScreen` from **one** call site: the three states meaning "still
opening" were three, and Compose identifies a composable by where it is called from, so
moving between them disposed the screen and rebuilt it — which now means returning and
re-borrowing the player twice during the busiest stretch of a launch.
**What it says while it is opening is `ui/WelcomeQuotes.kt`.** Twenty-five lines per tone
plus eight headlines, because this is the most-read copy in the app and five per tone meant
a household saw the same sentence roughly every fifth time they switched the set on.
`WelcomeQuotesTest` counts the distinct lines a pool yields: a pool that shrank back, or
gained a duplicate on a copy-paste, looks identical to one that did not. The headline is
kept apart from the quotes and is not keyed on the tone — it names what the app is *doing*,
and rerolling it when the settings flow arrives would change the line mid-launch.
**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.
**One next-item pipeline.** `ui/player/NextUpPipeline.kt` owns the answer to "what plays
after this?", and everything that wants to know reads it from there: the manual Next Episode
button, the next-up banner, the credits pane, the countdown and the ended frame. Before it,
each of those read one `nextEpisode` field which was populated only when
`Settings.autoPlayNextEpisode` was on — so with the setting off the field was permanently
null and the credits pane, the banner and the completion handler were code nothing could
reach. Things to preserve:
- **Resolution and automatic advance are two decisions, not one.** `NextUpResolver` resolves
unconditionally for episodic content and `shouldAutoAdvance` is the *only* place the
viewer's setting is read. Somebody who has turned automatic advance off has said they want
to press something, not that they want to be returned to the launcher and made to find the
next episode by hand. Putting that setting back on the lookup restores the original defect.
- **Every trigger goes through `startNextEpisode`**, which claims `advanceRequested`
synchronously. `advancing` cannot do that job: it is not set until the coroutine that
re-negotiates the stream returns, and the credits marker, a 250 ms progress tick and a
button press all land well inside that window — which is how one press used to be able to
produce two advances.
- **The stream is re-negotiated before it is used, never after it fails.** The URL and play
session a lookup returns were obtained when the *current* episode started, which on a
full-length episode is long enough ago for Emby to have expired the session — the
intermittent failure to establish the stream on an advance. `NextUpResolver.playable`
re-fetches past `STREAM_FRESHNESS_MS`, and `warm()` does it ahead of the credits so the
press itself waits on nothing. The metadata is not re-fetched with it: the banner and the
button draw from the stale copy immediately, because only the stream goes off.
- **The resolver is single-flight and keyed on the subject.** Repeated callers join the
outstanding request rather than starting another, and `begin` discards the outgoing
episode's answer so it can never be offered against the incoming one. `resolvedAt` is
nullable rather than a zero sentinel, because `elapsedRealtime` is time since boot and is
legitimately near zero on a television that has just been switched on.
- **The two optional transport controls are removed, not disabled.** `player_next_episode`
and `player_magic` in `memby_player_controls.xml` are `gone` until they mean something: a
television is driven by a D-pad, and a greyed button is a stop on the way to the one the
viewer wanted. Their rules are the pure `shouldOfferNextEpisodeButton` /
`shouldOfferMagicButton`, unit-tested in `NextUpPipelineTest`.
**Magic is "put something on and don't ask me what".** `POST /v1/magic` →
`recommend.MagicPick`, which was written in 0.2.68 and had no route, no client and no way of
being pressed until now. It is the movie player's control and Next Episode is the episode
player's: the picker is films only (`onlyMovies`), on the reasoning that the button plays
something immediately and a series is a question about which episode — and beside an explicit
Next Episode action, a second "play something else" control is two answers to one question.
Things to preserve:
- **The exclusions are the client's.** The film playing now and the last `MAGIC_MEMORY` picks
travel with the request, because it is the player that knows what it has already put in
front of somebody. That is why it is a POST: a query string that lengthens with every press
is one something in the middle eventually truncates, and the failure would be silent
repetition.
- **It is never cached.** The whole point is that pressing it twice gives two answers.
- **Every way it can fail is the same answer to the viewer.** The direct path has nobody to
ask, a gateway predating the route answers 404, and a household with nothing unseen left
answers 404 too — all three withdraw the button rather than showing an error over somebody's
film. Hence `magicAvailable` starting at `ServerConfig.isGateway` and switching off on the
first unanswerable press.
- **The pick is announced before the picture changes.** A button that silently replaces the
programme is one nobody presses twice.
- **A film is a new subject, not the next step of this one**, so it launches through the
ordinary `PlaybackRequest` intent rather than through `playNext`: a fresh session, a fresh
pre-roll decision, exactly as pressing Play on its detail page gives. Nothing is negotiated
before the viewer has been shown what was chosen, the same rule that keeps `PlaybackInfo`
off the focus-warming path.
The optional **next-episode recap or preview** extends that same auto-advance path. Once the
next episode is known, the television searches YouTube in the background using series name,
episode code and title, ranks official previews and recaps above reactions, reviews and
breakdowns, resolves the best playable native stream, and caches the result. Playback never
waits for this work. If the setting is on and the playhead naturally crosses two minutes
remaining, the preview replaces the closing credits and then advances to the episode. Seeking
straight into that window does not trigger it. A failure before the first preview frame
silently restores the current episode at its saved position; a failure after that frame moves
on to the next episode, because the outgoing episode has already been closed. Preview time is
never reported to Emby as episode progress, and no YouTube account or embedded web player is
involved.
**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. `DiscreteSeekPresses`
remembers the physical DOWN until its matching UP because some remotes report every held
repeat with `repeatCount == 0`; all 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.
**And it shows the frame it will land on.** The idea and the shape are borrowed from
[Wholphin](https://github.com/damontecres/Wholphin), under the same GPL-2.0 licence, the
way `PlayerEngine`'s HTTP stack was; what differs is the format underneath. Jellyfin serves
tile sheets, so Wholphin crops a sub-image out of a grid. **Emby serves BIF files**
(`/Videos/{id}/index.bif?Width=320`): a 64-byte header, one 8-byte (timestamp, offset)
entry per frame plus a terminator, then the JPEGs laid end to end — 320×172 every ten
seconds, about five megabytes for a two-hour film.
The index sitting at the *front* of the file is the whole reason this is affordable on a
television. Read the first few kilobytes and every frame's byte range is known, so one
thumbnail costs a ranged request of about seven kilobytes rather than a download nobody
would wait through mid-seek. Things to preserve:
- **Trust the 206, not the headers.** Emby 4.10.0.21 answers ranges on that route properly
and says so — a 206 with `Accept-Ranges: bytes` and a `Content-Range` naming the BIF's own
length — but earlier builds were reported to serve the range while advertising
`Accept-Ranges: none` and a `Content-Length` borrowed from the media file. So both
`emby.TrickplayBytes` and `TrickplayClient` cap the read at what was asked for regardless,
because being wrong about that must not turn a press of Right into a five-megabyte
download.
- **A zero-frame BIF is an answer, not a fault.** Emby returns a perfectly well-formed
72-byte file for a title whose thumbnails it has not generated, and for a width it does
not hold — which is why `trickplayWidth` is not a free parameter and the client's
`TRICKPLAY_WIDTH` must match it. The gateway caches that no for
`trickplayMissingTTL` (shorter than the index's day, since thumbnails are generated on a
schedule) or every press on such a title is a fresh round trip for the same answer.
- **The parsing exists twice**, in `data/Trickplay.kt` and `server/internal/trickplay`,
pinned by deliberately parallel tests (`TrickplayTest`, `bif_test.go`) — the usual reason:
with no gateway there is nobody to ask. What differs between the paths is *where the
reading happens*, not what is read. In gateway mode the television holds no Emby
credential, so the gateway reads the file and serves a frame at a time from
`/v1/items/{id}/trickplay/{n}.jpg`; on the direct path the TV range-reads Emby's file
itself. `Trickplay.bif` being null is what distinguishes them.
- **The manifest is its own request, deliberately not a field on `/v1/items/{id}/playback`.**
Reading the index costs the gateway a round trip to Emby, and that response is the one
thing standing between a Play press and a decoder starting. Only the boolean
`trickplayAvailable` rides there — the `subtitleDownloadAvailable` precedent — so an older
or deliberately-configured-off gateway is never asked. It is fetched from
`startPlaybackSession`, beside `loadCast()`, for the same reason that one is.
- **Nothing about it may be on the path of a press.** The chip has always said where the
skip lands and still says it with no thumbnail: a title with no previews, a server that
will not answer and the moment before the first frame arrives are all the same wordless
chip, which is what `SeekIndicatorScreenshotTest`'s empty case exists to hold. Every
failure is silent, and `handleTrickplay` answers trouble with "no previews" rather than an
error nobody could act on and everybody would log once per press.
- **Cancelling the in-flight frame is load-bearing**, the same property `collectLatest` gives
search: presses arrive faster than a fetch completes, and without it a slow response for a
frame already skipped past lands on screen after the one being waited for.
- **Scrubbing the transport shows them too**, not only the Left/Right chip. Those are the two
ways to move through a film, and previews that stopped the moment somebody pressed a button
to look at the controls read as the feature having broken rather than as a deliberate line.
With the transport up the presses are the time bar's — `seekControlsActive` stands down —
so `bindScrubPreview` hangs a `TimeBar.OnScrubListener` off `exo_progress` and draws into
`player_scrub_preview`, a strip above the bar in the *controller* layout, which is what
makes it disappear with the controls and need no visibility of its own. Things to preserve:
it is the same `TrickplayPreview` instance as the chip, so one layout is fetched per title
and one cache of frames serves both; it carries no wording, because the transport is already
printing the position a caption would repeat; its horizontal place is computed from the
scrubber in window coordinates and clamped to the bar, since a thumbnail parked mid-screen
while the scrubber is at the far end is a picture of some other moment, and one pushed past
the end is one overscan cuts; and it goes down on `onScrubStop` rather than on a timer, so
it can never outlive the scrub that raised it.
- **The cache holds JPEG bytes, not bitmaps**, and is the player's own rather than Coil's.
Decoded, forty frames would be most of a megabyte; as bytes they are a couple of hundred
kilobytes, and decoding one costs a millisecond off the main thread. Keeping them out of
Coil matters too — a burst of presses walks through dozens, and letting that churn through
the artwork cache would evict the backdrops the launcher is about to want back.
**Skipping the opening titles is Emby's own answer, not a detector.** Emby finds intros
itself and writes them into an episode's chapter list as two markers, `IntroStart` and
`IntroEnd`, interleaved with the ordinary chapters in playback order — so there is nothing
to detect on either end and nothing to store: reading them is one `Fields=Chapters` lookup.
`introFromChapters` (`server/internal/api/intro.go`) and `introSegmentFrom`
(`data/Intro.kt`) are the pure rule, pinned by deliberately parallel tests (`intro_test.go`,
`IntroTest`) for the usual reason — with no gateway there is nobody to ask, and a skip must
not land somewhere different depending on whether the container is up. Things to preserve:
- **Most of the rule is about refusing to answer.** Half a pair, a pair out of order, a
segment under 5 s or over 5 min all produce nothing, and nothing is a good answer: the
player simply never offers the button. A wrong skip costs somebody the opening of a scene,
which is far worse than not being offered one. The first `IntroStart` wins — two starts
mean the markers are already untrustworthy, and the later one is the larger, more damaging
skip.
- **The segment is its own request** (`/v1/items/{id}/intro`), the `trickplay` precedent:
reading it costs a round trip to Emby and the playback response is the one thing standing
between a Play press and a decoder starting. Only the boolean `skipIntroAvailable` rides
there, and the client default is **false**. It is fetched from `startPlaybackSession`
beside `loadCast()`, which is safe because the earliest intro in a typical library starts
a couple of minutes in. "No intro" is cached (server and client) — most of a library has
no markers, and without it the same no would be fetched on every playback.
- **`skipIntroMode` is a synced per-profile setting** (`prompt` / `auto` / `off`), with the
vocabulary duplicated in `data/SkipIntroPreference.kt` and the gateway's catalogue like
the seek interval's. It normalises to **`prompt`**, never `auto`: costing a set its button
because it cannot read a value is recoverable, jumping through somebody's episode on a
string this build cannot parse is not.
- **The ring counts the offer, not the title sequence.** `SkipIntroCountdownView` draws a
draining arc with the figure inside it, advanced from the playhead by
`updateSkipIntroCountdown` — so pausing during the titles holds it and seeking moves it,
neither of which a wall-clock timer could do. It runs from where the button appears to
where it goes, `SKIP_INTRO_TAIL_MS` short of the end of the intro. The two are seconds
apart and only one can be drawn honestly: a ring measuring the whole sequence would stop
with a sliver left and vanish mid-sweep, which reads as a broken countdown rather than as
a lapsed offer. Three things to preserve — the view takes its colours from its own
drawable state and the layout feeds it `duplicateParentState`, because the pill inverts to
white on focus and a ring that did not follow would draw white on white; it refuses to
redraw for movement under a degree, which over a two-minute opening is most of the ticks;
and `formatRemaining` switches to `1:58` over a minute with the text sized from the
string's length, since a title sequence is commonly long enough to be counted in minutes
and "118" would print over its own arc.
- **The button takes focus and the notice does not.** A remote has no other way to say
"press this", so it is focusable and `centrePausesPlayback` stands down while it is up —
otherwise the one button on screen is unpressable. It never appears over the transport,
the drop-up, the cast panel or the next-up banner, which already own the remote. An
automatic skip instead swaps the same view into "Intro skipped" wearing the timing cues'
quiet plate (`dressSkipIntro`), because a picture that jumps for no visible reason reads
as the stream glitching, and a notice that still looks like a button gets pressed. The
ring goes with it rather than freezing at zero: there is nothing left to press and nothing
left to run out.
- **`skipIntroTaken` is never re-armed within an episode**, unlike `skipIntroDismissed`.
Rewinding to before the titles offers the button again — somebody who went back there did
it on purpose — but in automatic mode re-arming would drag them forward again the moment
they reached the opening they had just returned for.
- **The seek goes through `seekBuffering`**, the same door a press of Right uses, so the
couple of seconds it takes to decode at the new position is treated as a skip landing
rather than as a film that has stopped.
- `SkipIntroScreenshotTest` renders it over a deliberately *bright* fake scene →
`build/screenshots/skip-intro/`. There is no scrim under this button, so a capture over
black would prove nothing.
**The closing credits are read out of the same chapter list as the intro.** `markersFor` in
`server/internal/api/intro.go` reads one `Fields=Chapters` response and answers for both
features, and `EmbyRepository.chapterMarkers` caches one reading that `introSegment` and
`creditsStartMs` both read. That is **the whole reason this is affordable** — adding a second
lookup anywhere in that path gives away the only performance claim the feature has. From the
result, `PlayerActivity` scales the picture into the left half, ramps it to 2× and puts what
is on next in the right half.
**Two sources, and the one it was built on does not exist.** `CreditsStart` is in Emby's
`MarkerType` enumeration, which is what this was originally written against — but a survey of
the 20,000-item library it ships to found `Chapter`, `IntroStart` and `IntroEnd` and **no
`CreditsStart` at all** on Emby 4.10. The enum having a value is not the detector populating
it. What that survey did find was 216 items carrying a chapter *named* like credits, clustered
at 9098% of runtime and consistent within a show, and that is where every bit of the
feature's coverage comes from today (roughly 5% of items, but effectively all episodes of the
shows that have it). The marker is still read first, so the day a version writes one this
needs no change. Things to preserve:
- **The position floor is the load-bearing guard**
(`creditsMinimumPositionFraction` / `CREDITS_MINIMUM_POSITION_FRACTION`, 0.75). Chapter
names are not a vocabulary anybody agreed on, and real media carries **"Opening Credits"** —
Belfast at 1% of runtime, Game of Thrones at 0%. A name match without a position test starts
the pane in the *first minute* of a film and runs its opening at double speed, which is the
worst thing this feature could do. Three quarters is deliberately far below the evidence
rather than near it: every genuine roll in that survey began at 90% or later. The name
exclusions beside it are belt-and-braces — a position test catches wordings nobody thought
of, a word list only catches the listed ones.
- **The rule exists twice** — `creditsFromChapters` (Go) and `creditsStartFrom`
(`data/Credits.kt`) — pinned by deliberately parallel tests carrying the real library's
cases, the usual reason: with no gateway there is nobody to ask.
- **The two sources resolve in opposite directions, and that is not an inconsistency.** For a
*marker*, the last wins (as the intro rule takes the first): two starts mean the markers are
untrustworthy, so each rule picks whichever risks least, and the two features are damaged in
opposite directions — an intro skip firing late throws somebody past the story, while the
credits pane firing early runs the last scene past them at double speed. For a *name*, the
earliest qualifying chapter wins: several credits-named chapters are ordinary rather than
suspicious ("The Pitt" carries both "Credits" and "End Credits") and they describe one roll,
which begins at the first of them.
- **A marker below the floor gives way to a name; with no runtime at all, the marker is
honoured and the name is not.** An explicit marker is Emby asserting a position, so it gets
the benefit of the doubt on wording — but not on position, and never enough to skip the one
test that separates an opening sequence from a closing one.
- **`RunTimeTicks` rides the chapter lookup on both paths** for exactly that test. It is a
default field on the response, so it costs nothing; do not drop it to tidy the query.
- **The duration guard is a second, separate refusal.** `creditsWorthShowing` asks whether
enough of the roll is left to be worth moving the picture for (`CREDITS_MINIMUM_TAIL_MS`),
and lives client-side because the player has the decoder's exact duration.
- **2× is a ceiling that falls, never a speed that is defended.** Doubling the speed doubles
the bitrate pulled from Emby over HTTP, and a high-bitrate file on a remote server may not
sustain it. `STATE_BUFFERING` calls `stepDownCreditsSpeed`, `creditsCeilingAfterStall` only
ever goes down, and it is never re-armed inside one roll — a marginal stream that could
climb back would oscillate between stuttering and recovering for the length of the credits.
Reaching 1× leaves the pane up: what is on next is still worth showing, and only the
speeding up failed.
- **It is the next-up banner's replacement, not a second thing beside it.** Both shrink the
same picture and both say what is on next, so `updateNextUpFromPlayhead` returns early
while the pane is up and the countdown moves into it — that countdown only appears inside
the last minute, where it was always the banner's job. The pane also rides the banner's
250 ms tick and its `nextEpisode` guard, which is exactly the right gate: a film and the
last episode of a season both correctly get nothing. It does **not** inherit auto-play's
switch — see "One next-item pipeline" below. It used to, because nothing resolved a next
episode when that was off, which meant this pane and the banner were unreachable code on
every set whose viewer preferred to press something. The pane still appears and its Play
still works; only the countdown, which promises a transition that will happen by itself,
is conditional on the setting.
- **The transform is a scale, never a reparent.** The pre-roll moves the `PlayerView` between
parents; doing that mid-playback tears the SurfaceView down and flashes black over
somebody's credits. `CREDITS_VIDEO_SCALE`/`CREDITS_VIDEO_SHIFT_X` are public so
`EndCreditsScreenshotTest` can place its stand-in picture at exactly the transform the
activity applies — a capture that guessed the split would prove nothing about whether the
two halves balance, which is the only thing worth looking at. Neither is a round number:
0.5 and 0.25 put the picture's left edge at exactly x=0, which is the first thing overscan
cuts.
- **`speedUpCredits` is a synced per-profile toggle**, defaulting to **on** — a different
trade from `skipIntroMode`'s, which defaults to the button rather than the automatic seek.
This one is visible, reversible and over in a minute, so somebody who dislikes it turns it
off having watched exactly what it does. `maskMarkers` withholds the half of a reading whose
feature an operator has turned off, on the way *out* rather than on the way in, so the cache
keeps the truth and a feature switched back on takes effect on the next playback.
- Screenshots are `EndCreditsScreenshotTest` → `build/screenshots/end-credits/`, over a
deliberately bright frame: there is no scrim between the credits and the panel.
**`StartupTrace` times the two waits either side of playback** — opening the app, and
opening a page — because "the launcher feels slow" is no more actionable than "playback is
slow" was, and `PlaybackTrace` already answers the second. Two shapes, because there are two
kinds of question: **launch milestones** (`home_visible`, `first_row_visible`,
`home_interactive`) are cumulative from process start, in the way playback's marks are
cumulative from the Play press, and are recorded once — a relaunch is a new process, and an
activity Android recreated behind somebody is not a second launch; **spans** are repeatable,
which is the shape a detail page needs, since a viewer opens many in a session and each is
its own wait. It is debug-only, like `PerformanceMonitor`, and every entry point returns
before allocating on a release build — a television has no log anybody reads, so in
production these would be pure cost. The milestones are recorded from `LaunchedEffect`s and
never from a composable body: measuring the launcher must never be a reason the launcher
recomposes. Read them with `adb logcat -s MembyStartup`, and the playback half with
`MembyPlayback`.
**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.
**R8 shrinks Kotlin; nothing shrinks a `.so`.** The APK is dominated by whatever native
code it carries, and native code is packaged **uncompressed** here (`minSdk` 23 means
`extractNativeLibs=false`), once per ABI. `io.github.abdallahmehiz:mpv-android-lib` was
added in v0.2.40 as a last-resort software video fallback and took the release APK from
3.1MB to **168MB**: a whole FFmpeg, `libc++_shared` and a 6MB subtitle font, times four
ABIs, for a path almost nobody ever reaches. It has been removed, along with
`MpvFallbackActivity`, `shouldUseLibmpvFallback` and the `is.xyz.mpv` keep rule. Two things
came out of it and both are cheap to keep:
- **`defaultConfig.ndk.abiFilters` is `arm64-v8a` + `armeabi-v7a`.** Android TV is ARM; the
x86 slices only ever served the emulator, which is not how this app is tested.
- **A new native dependency is a size decision, not a dependency decision.** Check what it
weighs across both ABIs before adding it — the Jellyfin FFmpeg *audio* decoder that
actually delivers DTS is 1.5MB per ABI because it links only the decoders it needs, which
is the shape to look for. Anything on mpv's scale belongs behind a separately downloaded
split, not in the base APK that every television sideloads on every update.
**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/<feature>/` rather than growing
them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`);
everything must be reachable by D-pad only.
**A keyed lazy list must never be handed a repeated key.** `LazyRow`/`LazyColumn` throw on
one — *"Key … was already used"* — and every list on these screens is keyed by an id that
came off a wire, where nothing promises distinctness. Emby lists the same person twice on a
good fraction of a real cast; a "Because you watched" row built from two seeds can reach one
title by both; a search that falls back to Emby before the import finishes can return what
the library also matched; and a paging boundary is where a backend repeats a card by
definition. `ui/ListKeys.kt` is the one rule: **deduplicate, never disambiguate.** Folding
the index into the key would also stop the crash, but it keys an item by *where it is*, and
position is exactly what changes when a row reorders — which the return-focus and
scroll-restoration behaviour throughout this app depends on identity to survive. Apply it
where the data enters state (`HomeViewModel.sanitisedRows`, `EmbyRepository.loadRelated` and
`loadSeriesEpisodes`, the two genre pagers, `SearchViewModel.runSearch`) rather than in a
composable; where a composable is the only place, keep it inside a `remember(list)`. And
where a pager deduplicates, **how far it has read is counted in what the backend sent**, not
in the length of the list (`SearchUiState.genreOffset`, `GenreBrowseUiState.readOffset`) — a
dropped duplicate would otherwise make the next offset point before the end of the last
page, and the shelf would stop growing while re-requesting the same page for ever.
**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.
**The eight slots a theme sends are not the vocabulary the screens paint with**, which is
why picking a colour scheme used to change almost nothing outside the detail pages. The
launcher, the cold-start screen, Settings, Search, the update and maintenance screens and
the two overlays were drawn with a hundred-odd literal hexes — a lighter green for a label,
a near-black ink for text on a green fill, three neutral steps for controls — none of which
the palette could reach. Those shades are now *derived* in `DesignTokens.kt`
(`MembyAccentBright`, `MembyAccentInk`, `MembyAccentMuted`, `MembyControlSurface`,
`MembyControlSurfaceRaised`, `MembyOutline`, `MembyDisabledText`, `MembySplashTint`).
Derived rather than added to the wire on purpose: a theme sends the *decisions* and the app
works out the shades around them, so a scheme invented on the gateway tomorrow arrives
complete rather than half-applied — the same reason the palette carries no radii. What
stays a literal is anything carrying meaning of its own: the red/amber/blue status colours,
the genre tiles, and the ratings providers' own brand colours. And a screen-local alias must
be `get()`, never `val` — `SettingsSheet`'s `Canvas`/`Panel`/`TextPrimary` were values, so
the settings page, which is where the theme is *chosen*, was the one screen that could never
repaint.
**Those colours are the server's answer, not constants.** Every token in `DesignTokens.kt`
is now a `get()` over one process-wide `mutableStateOf(MembyPalette)`, and `applyMembyPalette`
is what repaints the app. Two things follow and both are easy to undo: an alias must be a
getter too (`HomeComponents`' `EmbyGreen`, `DetailPageComponents`' `Detail*`, the settings
sheet's own `EmbyGreen`), because a `val` captures whichever theme was loaded when its class
initialised and never changes again; and `MembyTheme` builds its `darkColorScheme` per
composition rather than holding one, which is how a theme change reaches every component
that never names a colour. Shape and punctuation are deliberately *not* themeable — a
palette that could move a corner radius could make a layout wrong from the server, and the
whole safety of this feature is that the worst a bad theme does is look bad.
**The colour-scheme picker is currently withheld from Settings** —
`THEME_PICKER_ENABLED` in `ui/settings/SettingsSheet.kt`, one `const val` to put back.
Choosing a scheme does not reliably repaint the app, and a control that appears to do
nothing is read as a fault in the television rather than as an unfinished feature.
Everything below is otherwise untouched: seasonal themes still arrive and still apply, the
synced `themeId` preference is still carried, and the palette plumbing is unchanged. This
hides the question, not the answer.
**Themes** are `server/internal/api/themes.go`, and there are two kinds. A **selectable**
theme is the viewer's own choice, held as the ordinary synced preference `themeId` and
picked in Settings → Appearance. A **seasonal** theme (Halloween, Christmas, Easter) is not
a choice at all: it is in force for its dates and nothing on the television can decline it,
because a per-person opt-out is a thing somebody turns off in October and never reconsiders,
which is the same as the feature not existing. The only switch is the operator's
`seasonal_themes` feature flag, for the whole house. Things to preserve:
- **`resolveTheme` is the whole rule and it is pure**: a season outranks the viewer, the
viewer outranks the default, and the operator's per-user allowlist narrows the *choice* but
never a season. There is no argument a television can send that suppresses one, which is
what "cannot be controlled by the user" means in code. The viewer's own pick is still
reported as `chosen` underneath a season, or the picker would show nothing selected for a
fortnight and read as having forgotten it.
- **The two windows resolve their edges deliberately.** Halloween opens on 25 October (a
theme nobody sees until the evening of the 31st is one nobody sees) and Christmas closes on
Boxing Day (the tree is down; red-and-green on the 30th reads as a server nobody maintains).
Easter is the anonymous Gregorian computus in `easterSunday` — computed, because a
hard-coded table is a feature with an expiry date on it — over Good Friday to Easter Monday.
`seasonalThemeFor` takes the time rather than reading the clock, so every edge is tested.
- **The revision is a hash of the resolved theme, not a counter.** There is no write to
attach a counter to: nobody writes anything at midnight on 1 December, the answer simply
becomes different. `/v1/status` carries `theme: {id, revision, locked, seasonal}` — the
`preferencesRevision` precedent — and the TV fetches `/v1/theme` only when it moves. It
rides the poll rather than the sign-in because that *is* the feature: a season has to reach
a set that is already switched on.
- **The allowlist is `user_themes`, and absence is permissive.** No row exists for anybody
until an operator restricts somebody, so an empty list means "unrestricted"; reading it the
other way would empty every picker in the house on the day it shipped.
`normalizeThemeAllowlist` stores "every box ticked" as the empty list for the same reason —
they are the same decision — and drops seasonal ids, which are not grantable per person.
It is a separate table from `user_preferences` because that document is the viewer's own
choices and every television they own writes it; this is policy *about* them and only the
console writes it.
- **The available list is per viewer and comes from the server.** `/v1/theme` sends only the
themes that person may pick, so a withheld scheme is a row the television was never sent
rather than a greyed one — the client has no catalogue of its own to fall back to, and the
picker is simply not drawn when fewer than two arrive.
- **Nothing repaints from the local choice.** `setThemeId` writes the preference and stops;
the palette arrives through `ThemeSync` when the gateway has resolved it. That is what makes
a choice a season covers, or one the operator has since withdrawn, visibly not take effect
rather than take effect and be yanked back a second later.
- **`ThemeSync` paints from the cached palette before any request**, the promise `HomeCache`
makes about the rows, and clears the cache on a profile switch (a different person, a
different scheme). The palette cache is device state; only `themeId` syncs.
- **There is no second copy of the rule on the direct path**, unlike subtitles, intros and
Continue Watching. Those exist twice because the direct path would otherwise *behave*
differently; here it behaves as it always did — the default palette. A television deciding
from its own clock that it is Halloween, while the household's gateway has seasons switched
off, would be the feature failing rather than degrading. `data/Themes.kt` is only hex
parsing, and it refuses anything it cannot read so the app's own token stands in.
**Icons are the server's answer too.** `ui/theme/MembyIcons.kt` is `DesignTokens.kt` for
marks: an enum of ~70 **slots** named for what they mean (`Search`, `Drama`, `Sparkle`), one
process-wide `mutableStateOf(MembyIconPack)`, and `applyMembyIconPack` to repaint. Before it
the app held seventy literal `Icons.Default.*` across nineteen files, which put icons exactly
where the palette was before its own work: unreachable from the gateway, because the server
can only change what the television has a slot for. The packs are
`ui/theme/MembyIconPack*.kt` — Material (what the app shipped with), Lucide and Font Awesome
Solid, from `com.composables:icons-*-cmp` — and the gateway names one with `iconSet` on the
theme document. Things to preserve:
- **A slot is named for the job, never for the mark that fills it today.** Filing the
recommendation slot under `AutoAwesome` would describe Material's four stars, and a pack
whose answer is a wand would then sit under a name that lies about it.
- **Nothing may hold a resolved mark.** `MembyIcon.mark` reads process-wide state, so a mark
captured in an `enum` constant or a top-level `val` freezes whichever pack was loaded when
that class initialised — the same `val`-versus-`get()` trap that made `SettingsSheet` the
one screen a palette could never reach. `BrowseDestination`, `SettingsPage` and
`RequestCardAction` therefore carry the **slot** and resolve it where they draw.
- **The marks are lambdas, not vectors.** An `ImageVector` is built when it is first read, so
a map of them would build all seventy on the first frame that touched a pack — on the cold
start, which is the one thing in this app nothing may cost.
- **A pack may be partial, and an absent slot falls back to Material.** Lucide is stroke-only
and has no filled heart, so mapping `Favourite` and `FavouriteOutline` to one glyph would
make "this is a favourite" and "this is not" identical on screen — a pack must never cost
the app a distinction. Font Awesome Solid declines the outline halves for the same reason
from the other side. The mixture is small and is the honest answer.
- **The wire carries a slug and never geometry**, the line the palette already draws: a
gateway that could send paths could draw an unreadable rail, where the worst a pack slug
does is look unchanged. An unknown slug resolves to Material at *both* ends —
`membyIconPackFor` on the television and `knownIconPack` on the gateway, which refuses to
echo a pack nothing can draw.
- **It rides the theme revision**, so it costs no new field on the status poll and no second
sync loop: `ThemeSync` already refetches on a revision it does not hold. The slug is cached
beside the palette and applied *before* any request, and on its own evidence — a set whose
stored palette will not parse still opens wearing the marks it was told to wear.
- **Where it is chosen is the `iconSet` preference**, beside `themeId`, so an operator sets it
per viewer from the console's existing catalogue-driven editor and no admin page was needed.
A **season may replace the marks; a selectable theme may not** (`IconSet` on
`themeDefinition`, the `Decoration` shape) — a season is a look, where a scheme somebody
picked to live with all year taking their marks away leaves no way to tell which of the two
choices did it.
- **R8 is what makes three packs affordable.** Only the slots named in the maps survive out of
packs holding a thousand icons each: measured, two complete packs cost **+16 KB** on the
release APK. A slot nothing draws costs three vectors for nothing.
- **`IconPackScreenshotTest` is the only test that can judge this** (`build/screenshots/
icon-packs/`), the point `ThemeScreenshotTest` makes about palettes. A unit test can check
that a pack names a mark for a slot; it cannot check whether that mark *means* the slot —
it is what caught Lucide's Action genre drawing an award ribbon. Both sizes are captured
because a stroke set has least to spare at the 21dp the rail draws.
**Seasonal decorations** are `ui/seasonal/SeasonalDecorations.kt`: snow, bats or blossom
drifting over the launcher for the few days a season is on. A palette on its own is a thin
idea of Christmas — the colours change and nothing says why — and this is the half that
does. It is also the most expensive thing in the app, the only animation that runs
continuously while somebody is merely browsing, so:
- **Nothing about it recomposes.** One `Canvas`, one animated `State<Float>` never read in a
composable body, every position derived arithmetically inside the draw lambda. A full field
costs zero recompositions and one draw pass. The palette colours *are* read in composition,
deliberately, so a theme change repaints this node.
- **A particle is `index` and `progress` and nothing else** — no array, no per-particle state
to allocate or re-seed. `drawSeasonalField` is therefore a pure function of one number,
which is what lets a screenshot capture an exact frame with no animation clock.
- **Every cycle count is a whole number**, so at the instant the driving value rolls 1 → 0
the entire field is exactly where it was. Without that it visibly jumps once a minute.
`decoration-snow-0.png` and `decoration-snow-100.png` must be identical; that is what they
are for.
- **Placement is stratified, not hashed.** Each particle owns a slice of the axis and the
hash only jitters within it. Twenty-six samples is far too few for a hash to look evenly
spread — the first version put visible bands and a bare patch through the middle, and the
eye finds a clump in a snowfield instantly.
- **The slug comes from the gateway** (`decoration` on the theme), never derived from the
theme id: an operator turning `seasonal_decorations` off sends an empty one, and a set
holding a cached Christmas palette must stop snowing. It is a *second* switch from
`seasonal_themes` because the palette and the animation have quite different costs on a
weak box. An unknown slug draws nothing.
- **Launcher only.** Not over playback — a film is the one thing nothing may drift across —
and not over the settings sheet. It is a sibling of `HomeScreen` rather than inside it, so
an arriving theme cannot invalidate the rows.
- **The platform's "remove animations" setting is honoured.** A season is deliberately not
the viewer's to decline, but an accessibility choice is not a preference.
- **The alpha was tuned by looking, not reasoned about.** The layer sits over an opaque
surface, so a flake occasionally lands on the Play button; at this value that reads as snow
in front of the screen and a few points higher it reads as a rendering fault.
`decoration-over-content.png` exists for exactly that judgement.
- **Screenshots are the only real test this feature has.** A unit test can check that a hex
string parses; it cannot check whether Easter's pale accent is legible on its own
near-black or whether Forest still has a hairline. `ThemeScreenshotTest` renders one series
page under every palette → `build/screenshots/themes/`, deliberately one screen across nine
themes rather than nine screens on one, so the images differ in nothing but colour. It
composes once and repaints by assigning the palette, which is both the only thing
`setContent` allows and the more honest picture — that is exactly what a set already
showing the page does when a season begins. The palettes are a *fixture* copied from the
catalogue, not a second copy of the rule: nothing derives a colour from it. It caught the
chip row overflowing at six themes, which is why that row is a `FlowRow` where every other
choice row on the page is a `Row`, and it is why the blossom is a five-petal flower — a
single petal rendered as a grey seed, recognisable as *something falling* and nothing else.
**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. `MembyArtworkPlayCue` is the fourth member and the same rule: the
circular Play mark over a focused artwork card, on the launcher's posters *and* on a detail
page's episode rows, which used to draw their own black disc at their own diameter.
**And one card-focus language on the detail pages**, `Modifier.detailCardFocus` in
`ui/detail/DetailCardFocus.kt`. The cast grid, the "More like this" posters and the Extras
thumbnails had three copies of the same lift-and-grow at 1.06, 1.06 and 1.05 over 110ms,
100ms and 100ms; nobody chose three figures, they were written on three days. The animated
value is read only inside the `graphicsLayer` block, so travelling a grid redraws two cards
rather than recomposing every card in it — which is why it is a modifier and not a wrapper.
**"Close Memby?" is the one full-stop dialog**, `ui/ExitConfirmation.kt`, drawn when the
viewer has asked to be asked (`confirmExitMemby`). It is the panel that appears *over*
Memby, so it was the one thing on screen that must not look like somebody else's app — and
it was two identical raw `androidx.tv.material3.Button`s wearing Material's own colours,
which meant the destructive answer and the safe one were the same shape at three metres.
Things to preserve: the two actions **do not look alike** (Stay is the accent fill and takes
focus first, closing is a quiet outline), the entrance is read only inside `graphicsLayer`
lambdas so nothing recomposes while the panel arrives, and **Back means stay** — it is the
key that raised the panel and pressing it again must not be what closes the app. The scrim
is a wash rather than a flat black, so the library underneath stays faintly readable and
this reads as a question asked over it. `focusedForCapture` exists because Robolectric's
window never takes focus and the focus ring is the whole of what says which action a press
would take; `ExitConfirmationScreenshotTest` renders both answers under it, plus one
capture under a foreign palette — this is the only dialog drawn entirely from the tokens,
so a theme that could not reach it would show here and nowhere else.
**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.
**Up out of the top of a page returns to the page list.** Nothing sits above a pane's first
control, so that press did nothing at all on every page — and a remote that stops responding
is not read as a list that has run out. About is where it was reported, its pane being a
changelog long enough that walking back up it is the ordinary way to leave. The escape is an
`onKeyEvent` on the content column that makes the move the default handler would have made
and falls back to the rail only when it fails, so a page's own vertical navigation is
untouched: `focusProperties { up = … }` is inherited by every row and would take that
navigation away, and `exit` is never consulted when the search finds nothing anywhere, which
is the whole case. The rail item for the page being *drawn* carries a second requester for
it — the selection rather than the highlight, so a press arriving mid-settle still lands on
the page that is on screen. `SettingsRailFocusTest` pins both halves, the escape and the
navigation between rows it must not disturb.
**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 — the featured card says why in a sentence instead (see below).
**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 is composed by the gateway** (`server/internal/api/hero.go`), because the three
things worth ranking it by are three things the television cannot see. **Radarr knows when a
film actually came out** — `digitalRelease` is the date the household could first have
watched it, where Emby's `PremiereDate` is the theatrical date when it is right at all and a
metadata agent's guess when it is not, so ranking "new releases" by it produced an order
with nothing to do with when anything became watchable. **Sonarr knows a premiere from an
ordinary episode**, so a new show or a returning season can lead where before a series could
only reach the hero as a random card off a shelf. And **the review scores are already
attached to the cards** by `decorateHomeRatings`, so a well-received release can outrank a
fresher one nobody liked at no cost. `rankHeroCandidates` is the pure rule
(`heroRecencyWeight`/`heroRatingWeight`, `hero_test.go`); `selectHomeHeroMovies`'s original
row-interleaving rule survives underneath as the **direct path's** hero and the fallback for
a gateway older than the feature, which is why `serverHeroPicks` is consulted first and
returns nothing rather than throwing. Things to preserve:
- **It asks Emby for nothing.** The movie candidates are the rows already assembled and
their ratings are already attached, so the expensive half of the launcher is reused
rather than repeated. The two *arr calendars it does read are cached for the day behind a
shared lock, like the schedule rows' — one household pays one miss each per day — and the
three lookups run concurrently, because this is the tail of a response every television
in the house is waiting on.
- **Every card it produces is playable.** A premiere the household has not downloaded, a
film Radarr is still waiting on, a synthetic schedule card — all are news for the schedule
row, and a lead card that does nothing when pressed is worse than no lead card at all.
`sonarrPremieres` requires `HasFile` *and* an Emby series id for exactly this.
- **A premiere is the first episode of a season**, S01E01 or S05E01 alike, and season 0 is
specials rather than a premiere. One card per series, the newest season winning, or a show
that premiered and returned inside one window appears twice.
- **An unrated title is not a bad title** (`heroUnratedScore`, deliberately mid-scale). On a
household that has not configured MDBList that is every title, and burying them would
empty the hero; `heroRatingOf` falls back to Emby's `CommunityRating`, which the client is
still forbidden from *drawing* — ordering four cards by a score claims nothing to anybody,
where printing it beside a provider's name that was never asked is a lie.
- **The captions and the reason are the gateway's wording** (`MembyHeroLabel`,
`MembyHeroReason`), the `MembyAirLabel` precedent, so a kind of hero card invented
tomorrow reads correctly on today's build. `labelTint` matches them as strings for the
same reason, and an unknown one gets the neutral wash rather than nothing.
- **A label is a claim that has been earned**, and `heroReason` returns empty rather than
inventing one — the captions this replaced were the card's *slot*, which is how a 2019
film came to be announced as new.
- **The row is consumed, never drawn.** `serverHomeRows` drops `kind == "hero"`; without
that the four featured titles print a second time as an unnamed row of posters directly
beneath the hero they are already in. `supportsHomeHero` gates it at 0.2.27 for the same
reason — an older television has no idea the kind is special. That floor is the version
the feature shipped *in* rather than one after it, so a 0.2.27 build predating it would
draw the duplicate row; moving the floor up is the fix if that ever bites.
- **The row is a draw from merit bands, re-made four times a day.** Ranking straight to the
row's length was the original design, on the reasoning that the facts behind it change
daily — and they do not: a digital release date does not move, a premiere aired when it
aired, a score settles within a week, so the same two cards led the launcher for five days
at a stretch. `rotateHeroCandidates` ranks a pool of `heroPoolLimit` instead, cuts it into
as many bands as there are cards to send, and draws one from each by
`heroVariationSeed(userID, heroRotationSlot(now, location))` — the `selectSeeds` shape, and
for the same reason it is not a shuffle: merit still decides which *band* a title is in, so
the best-reviewed release of the week can never land in the fourth slot, and variation only
picks between titles the scorer could not separate. The slot is the household's local part
of the day and carries the date, the seed is per viewer, and a pool with no spare
candidates goes out in merit order untouched. Home is cached for a minute and rebuilt
constantly behind it, which is why one slot must always yield the same draw.
The rotation further down belongs to the direct path, which has no merit to rank by.
**The reason sits above the ratings strip**, and that order is load-bearing. The featured
card's text column is what gives way when a title wraps onto two lines, so whatever is last
in it is cut — with the reason below the strip, the one line explaining why this card leads
the launcher was silently dropped on exactly the long-titled films most likely to be leading
it. The scores are also on the detail page the card opens; the reason is nowhere else. It
takes the synopsis's place rather than adding a line, so preferring it can only make the
card shorter, and there is still no eyebrow above the title.
**The direct path's 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`,
`ui/detail/DetailTabs.kt` and `ui/detail/DetailHeroPhases.kt`. Movie, series and episode are
three sets of parameters to that one scaffold, never three layouts — the shared pieces are
`DetailHeroActions` (so a Continue Watching page cannot grow its own Play button metrics),
`DetailIdentity`, `DetailStripFrame` and `Modifier.detailCardFocus`, and anything that has to
differ between them is a scaffold parameter so the difference is stated in one place. 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. A movie's sections are Overview, Cast, Extras,
More Like This and Details; a series' are the same with **Episodes** in Overview's place,
because a show's synopsis is already in the hero and what a show is *for* is its episodes.
- **The page is a `Column` and nothing scrolls it.** Hero, strip, pane, footer. It used to be
a `LazyColumn` of three items with the list pinned to offset 0 while focus was in the hero,
which meant the hero's own height decided where the strip ended up — so a two-line title
pushed the tabs down and the content with them, and every press into the content was a
scroll the viewer had to watch land.
- **The hero collapses instead.** `detailHeroCollapsed(zone)` is the whole rule and it is
pure and tested: whole while focus is on Play, out of the way the moment focus is below it.
A single `animateFloatAsState` drives it and is read **only inside layout and draw
lambdas** — `Modifier.collapsingHeight` for the band and `graphicsLayer` for everything
else — so a collapse costs a measure pass rather than recomposing the page sixty times a
second. Two details hold the promise that no focused thing ever resizes: the hero is
measured at its *full* height and slid upward under a clip, so nothing inside it re-wraps;
and the pane takes `weight(1f)`, so it simply receives what the hero gives up.
- **One number drives the whole transformation**, and what each part of it means is
`ui/detail/DetailHeroPhases.kt`. The height, the backdrop's opacity, the deepening wash,
the supporting text, the action row and the pinned header are **bands of that one value**
rather than animations of their own — which is what makes the collapse read as one
movement, and what stops the parts drifting out of step when it is interrupted half way.
`DetailHeroPhaseTest` pins the *ordering* rather than the figures: the supporting half is
always at least as far gone as the primary half (that is progressive disclosure, stated as
an assertion), the two headings are never both on screen, and the plate always leads the
pinned header in. It is a **critically damped spring**, not a tween: a held D-pad produces
a press every few frames, and a spring retargets from wherever it is rather than restarting
a duration — and it can never overshoot past 1, which on a value driving opacities would be
a flicker.
- **The backdrop recedes; it never disappears.** `heroArtworkAlpha` takes it down as the
content takes over and the wash over it deepens as a gradient, so the picture settles back
rather than a sheet being drawn across it. Behind the pinned header is a soft plate
(`pinnedScrimAlpha`) occupying exactly the band the header lands in and fading from nothing
at its top edge — a **gradient, never a bar**: the point is a perceptible separation
between a fixed header and scrolling content, and a solid block reads as a phone toolbar.
- **What is left is `DetailCollapsedHeader`** — the title, the fact line, and on an episode
the show and `S03E04`. Not a shrunken hero: the synopsis, the ratings, the reason and the
actions all answer "is this worth watching", which somebody down in the Cast grid has
already answered. What remains is only what stops the page becoming anonymous. The compact
eyebrow is its own parameter (`pinnedEyebrow`) because "SEASON 3 · EPISODE 4" spelled out
pushes the fact line off the end of a one-line header.
- **The hero is a budget, and the reading column is what pays.** The expanded content is
anchored to the bottom of its band and grows upward, so `DetailHeroMetrics.TopInset` is the
ceiling it may not cross — and with a ceiling, something has to give. What gives is the
**supporting block** (ratings, genres, synopsis, pace, reason), which is the Column's one
`weight(1f, fill = false)` child: weighted children are measured from what the unweighted
ones left over, so a two-line title costs prose rather than costing the primary action its
shape. Getting that inversion wrong is precisely the 0.2.67 regression — an episode page
reached from Continue Watching carries more than any other variant, and the Play button,
being last in the column, was handed whatever height was left and rendered as a squeezed
sliver. It is the same inversion the home hero already makes, and it is now asserted:
`EpisodeDetailScreenshotTest` renders the most crowded hero the app can produce and fails
the build if the primary action is under its natural height.
- **Prose gives way a whole line at a time.** `Modifier.wholeLines` reports a height rounded
down to the last complete line, with the clip *outside* it — a `Text` handed less room
still draws every line it was asked for, and without the clip the dropped lines painted
over whatever the column placed underneath. Two lines instead of three reads as nothing at
all; prose sliced through the middle of its letters reads as a rendering fault.
`DetailHeroMetrics.SynopsisLines` is **two**, not three: the whole description is one press
away in Overview, and the third line was the difference between the movie hero fitting its
band and overflowing it.
- **The identity block reserves its height only where a logo could actually appear.**
Deciding whether a logo is legible means fetching and decoding it, so every page begins on
the text fallback and changes its mind a moment later — a swap that used to move everything
under it on the opening frame. `logoUrl != null` is known synchronously from the image
tags, so a title Emby holds no logo for is drawn at its natural height and gives the space
to the synopsis. Very wide and very tall logos are both a letterboxed picture inside a
fixed box: a logo can never change the page's shape.
- **The ratings strip is the one place it does not reserve.** Most of a library has no
scores — an episode is rated as its series, a household with no MDBList key has none at all
— and 42dp held open for them was 42dp taken off the synopsis on every one of those pages.
It sits inside the flexible block, so a strip arriving late costs a line of prose rather
than moving anything the viewer is aiming at.
- **The circular actions name themselves.** A heart, a tick, a bookmark and a film reel are
guesses at three metres, so the focused one's description is printed on a **reserved** line
under the row — reserved, because a line that appeared when focus reached the second button
would move the whole hero on every press of Right.
- **A pane fits its slot, or is a grid.** Prose panes (Overview, Details) do not scroll — a
page that scrolls *and* has tabs gives the D-pad two meanings for Down — and now have
roughly three times the room they had, since `detailPaneHeight(viewportHeight)` is what the
collapsed hero and the strip leave rather than a 250dp budget. Cast, Extras and More Like
This *are* grids and scroll inside themselves, which is a different thing: Down inside a
grid still means "next row".
- **The strip keeps a safe-area inset.** `DetailFoldPeek` holds it off the bottom edge while
the hero is whole, 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. A *click* reports itself as arriving in
the strip too, or a mouse or a test could change the pane and leave the hero over it.
They are still **marked separately**, and that is not a contradiction: the accent underline
is *selection* — which pane is open — and the plate behind the label is *focus* — where the
remote is. One causes the other while the viewer is in the strip, and they come apart the
moment they press Down, which is the case the distinction exists for. The season scroller
on an episode page marks them the same two ways for the same reason.
- **Both bands wear `DetailStripFrame`** — the plate, the gutters, the hairline and the
chevron. The tab strip and the season scroller define the same fold, and two hand-written
copies of it is exactly how the two pages came to sit their content a couple of pixels
apart.
- **The strip is content-driven, and frozen once the viewer is inside it.** `detailTabs` is
pure over a `DetailTabAvailability`, and `rememberDetailTabs` recomputes it only while
focus is still in the hero. That is the seam between two real failures: a tab leading to an
apology is dead weight on every set in the house for ever, and a tab appearing a second
after the page opens shoves every tab to its right under a moving thumb. Three rules make
it safe — the landing pane is structural and always first, so the tab under focus on frame
one can never move; optional tabs are only ever *appended* in one order, so one arriving
cannot reorder the rest; and each flag's "still loading" value is chosen by which way it is
usually wrong (related titles exist for nearly everything, so the tab is there while the
request runs; extras exist for a small minority, so the tab waits until one is found).
- **`detailTab(key, available)` falls back to the landing pane, not to Overview.** Three
things arrive with a key that is not on offer and all are ordinary: a series' Episodes
carried to a movie, a tab whose content has gone away, and `cast-details` remembered by a
build that predates the Cast/Details split.
- **One `FocusRequester` per pane**, never one shared between them. `AnimatedContent` keeps
the outgoing pane composed for its 80ms fade, so a requester attached by two panes 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, the episode rail's offset, and the cast grid's *two* numbers — 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. The band is reported on the way *in* to it,
never on every focus move inside it — the pane's `hasFocus` fires for each card a viewer
walks past, and each of those was a write into the store, on the one press that must feel
free.
- **Returning from the player comes back to the page it was launched from.**
`MainActivity` holds a `DetailsReturn` — the item, the trail and the airing notice — across
the playback and reopens on `LifecycleResumeEffect`. Deliberately a *reopen* rather than
leaving the page composed under the player: the page has to tell the viewer the truth, and
the episode they just finished is now watched, the film is now part-way through, and the
next episode is a different one, so the item is re-requested on the way in. Reopening also
runs the same restore path a Back press through the trail already uses, which is what
brings back the tab, the season, the grid offset and the band that held focus — rather than
depending on Compose having held focus across another activity's window.
- 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, and a poster *grid*.
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.
- **Cast is a grid, in `ui/detail/CastGrid.kt`.** It replaced a compact rail wedged into the
corner of a combined "Cast & Details" pane, which showed six faces of a forty-strong cast
and gave the other thirty-four no way of being reached. Things to preserve: the column
count is **fixed** rather than `GridCells.Adaptive`, because Up out of the first row has to
return to the tab strip and the only way to know which cards are in the first row is to
know how many there are — `castColumns` is that arithmetic, pure and tested at every
television width; the card width is tuned so the **second row peeks**, since one row filling
the pane with nothing under it reads as a shelf that failed rather than as a grid;
`castMembers` deduplicates rather than disambiguating, the `ui/ListKeys.kt` rule, because
position is what has to survive coming back to the page; the character line is **reserved,
not conditional**, or a card with no role recorded sits a line higher than the one beside
it; and the initials are drawn *behind* the portrait, the stance the player's cast panel
takes, so nothing has to decide in advance whether artwork will arrive. Selecting a card is
inert for now — a person page is the obvious next thing and the grid already remembers
which card to come back to.
- **Extras is `GET /v1/items/{id}/extras`**, and it is a *join*: Emby answers the question in
two places and neither includes the other, `SpecialFeatures` holding the featurettes and
deleted scenes and `LocalTrailers` holding the trailer. `server/internal/api/extras.go`
joins them (only *both* lookups failing is a failure — a title can perfectly well have one
and not the other) and `dedupeExtras` drops a file filed in both, because the television
renders these into a keyed grid and a repeated key throws. The direct path makes the same
two calls itself. Two client rules: the repository caches the **empty** answer, since most
of a library has none and every page open asks; and a 404 is read as "no extras" rather
than as an error, so a new APK against a gateway that predates the route simply does not
offer the tab. `extraKindLabel` names the kind from Emby's `Type`, spacing out anything it
does not recognise so a category added tomorrow still reads correctly.
- **Details is `detailRows(item)`** beside `technicalSpecs(item)` — catalogue facts on the
left, what the file is on the right. Its vocabulary (Studios, Taglines, PremiereDate,
OriginalTitle, ProductionLocations) is read nowhere else and had to be added to
`fieldsDetail`, which is why the gateway's item cache key moved to `item:v6:` — entries
written before it cannot hide a newly requested field. A row is omitted rather than printed
empty, and Studio is dropped from the file column when the catalogue column already said
it: printed twice side by side it reads as a page that cannot make up its mind.
- **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.
- **Nothing is loaded for a tab nobody has opened.** Each request is its own
`LaunchedEffect` in the overlay and none blocks the page: episodes, related, trailer,
extras and ratings all arrive independently, and every one of them is single-flighted and
cached in the repository, so warming a detail page on D-pad focus and then opening it costs
one request rather than two. The pane itself is the second half of that — while the hero is
whole the pane is 34dp, so a grid composes one row until the viewer actually goes there.
- `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer,
extras) 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.
Because a click now reports itself as arriving in the strip, every tab capture is also the
check that the hero collapsed and the strip moved to the top of the usable area.
**"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.
**A film the household does not own has a page of its own.** The "Upcoming Movie releases"
row is Radarr's, and its cards were the one thing on the launcher that did nothing at all
when pressed: `MembyPlayable` is false, `scheduleSeriesStub` answers only for Sonarr, and
there is no Emby item behind them to open. `GET /v1/radarr/movies/{id}`
(`server/internal/api/radarr_detail.go` → `ui/RadarrMovieDetailsOverlay.kt`) is what they
open instead — the artwork, the description, the genres, the certificate, the studio, the
stored review scores, when it is expected, and a trailer. Things to preserve:
- **Nothing manufactures an Emby item to reuse the movie page.** `radarrMovieDetail` is its
own type on both sides, because the ordinary page's whole shape — Play, resume, a progress
bar, watched state, tabs of cast and extras — is built around a file that exists, and a
page carrying those over a film nobody can watch would be four lies arranged as furniture.
The card itself is passed in for artwork and for the title, which is what makes the page
appear on the press rather than after the request; it is the row's own card, not a stand-in.
- **Which page opens is the gateway's answer, not the television's.** `MembyMovieItemId` is
the `MembySeriesItemId` arrangement — resolved in `embyMovieIndex` by **TMDb id**, since
Radarr writes one and the library import already asks Emby for `ProviderIds`, so unlike the
Sonarr row there is nothing here to match by title. A card carrying one opens the ordinary
movie page through `scheduleMovieStub`; a card carrying none is `BaseItem.isRadarrOnly` and
opens this one. Neither is inert, which is what the card used to be.
- **The detail route resolves the link a second time, and that is not redundancy.** The home
row is cached for the day, so a film imported at lunchtime still arrives on a card with no
Emby id until midnight. `radarrEmbyStub` reads the live answer and hands the viewer to the
ordinary page — which is the whole of "once it is in Emby, the card follows the normal
path", with nothing on either side to invalidate.
- **The trailer joins the existing chain rather than starting a second one.**
`trailerManifest` recognises a `radarr:` id and builds its candidate list from Radarr's
`youTubeTrailerId`, so `/v1/items/{id}/trailers`, `/resolve`, `/report`, the client's
`hasTrailer` cache and the player's walk through candidates all work unchanged on a subject
Emby has never heard of. `trailerAvailable` rides the detail response for the same reason
`subtitleDownloadAvailable` rides the playback one: the button is decided before it is
drawn, so it can never be one that fails after being pressed.
- **Most of the release wording is about refusing to be precise.** `radarrExpectedLabel`
prints a published digital date to the day and the schedule row's cinema-plus-a-month
estimate only to the month — "Expected November 2026" is true where "Expected 14 November
2026" is a date somebody would plan an evening around — and a film with neither is told
plainly rather than guessed at. It is the gateway's wording, the stance every schedule
label takes, so a phrasing added next month reads correctly on today's build; the three
Radarr dates are listed beside it so a viewer can see which one the headline came from.
- **`radarrMovieState` answers about the household's copy, where the lifecycle tag answers
about the film.** Coming Soon, Not Yet Available, Awaiting Release, Not Tracked and Almost
Ready are five different reasons a viewer cannot watch this tonight, and the line under the
state must not repeat the date above it — an unannounced film said "Release date not yet
announced" twice before it said anything about downloading.
- **Ratings come from the household's own store**, keyed by the TMDb or IMDb id Radarr
already holds, rather than from Radarr's own ratings block: two different numbers for one
film under one provider's name is worse than no strip.
- **Radarr is asked for nothing the catalogue can answer.** `radarrMovie` reads the cached
household catalogue first — one request already shared by the whole house — and only falls
back to `/api/v3/movie/{id}` for a title added since. `HomeViewModel.warmDetailPage` warms
it on focus, which is the only warm a schedule card has any use for.
- **The long-press menu is built from a list now, not from hand-written indices.** A Radarr
card has no Emby record to favourite or mark watched and has a trailer where an ordinary
card does not, so what belongs on that menu varies — `QuickAction` derives the focus
indices from the entries rather than leaving four pieces of arithmetic to hold in step.
- **There is no second implementation on the direct path**, the stance the TV calendar takes
and for the same reason: the answer is Radarr's, which a television holds no credential for
and Emby knows nothing about. With no gateway the row does not exist either.
- Screenshots are `RadarrMovieDetailScreenshotTest` → `build/screenshots/radarr-movie/`.
The claim the page makes is that an unwatchable film reads as *deliberately* unavailable
rather than as a page whose Play button failed to load, which is not a thing a unit test
can check.
**The TV calendar is the schedule row's other shape.** The launcher's row answers "what is
on this week"; `GET /v1/calendar` (`server/internal/api/calendar.go` → `ui/calendar/`)
answers "what is on this month, and when does it come back", which is a question no shelf
has a form for — so it is a rail destination with a weekly TV guide: seven days in a rail
and the selected day's artwork-led programme list beside it. It reuses
`toSonarrScheduleItem`, so a calendar card and a
schedule card are the same card, with the same availability badges, lifecycle tag and Emby
series link; pressing one makes the same substitution the row does (`scheduleSeriesStub` +
`airingNoticeFor`), because an episode that has not aired has no page of its own. Things to
preserve:
- **The television does no calendar arithmetic.** The gateway sends `firstWeekday`,
`dayCount` and its own `today`; `calendarWeeks` lays out the month from those alone and
`calendarAgendaWeeks` only pages those cells seven at a time. A
set working out for itself which years are leap years, in its own zone rather than the
household's, would be a second calendar free to disagree with the days the episodes were
grouped into — which on the wrong side of midnight it would. The one thing the set does
read its own clock for is *when to ask again*: `CalendarViewModel` drops every cached
month once the device's local day changes, because everything else about a month is fixed
and only `today` goes stale. Being wrong about that by an hour costs one request, where
being wrong about the layout would draw a calendar that disagrees with itself.
- **A month is claimed by what was asked for, not by what arrives.** Cancelling a coroutine
already past its last suspension point does not stop it, and a held D-pad on a month arrow
is exactly how two requests come to be in flight — so a response is dropped unless it is
still the month `requestedMonth` names. The cache is bounded for the same reason: "held
for the life of the page" and "grows while somebody holds the D-pad" are otherwise the
same sentence, and a month is a list of episodes with artwork behind it.
- **Focus is selection**, the stance the detail page's tab strip takes. A remote has no
hover, and a calendar needing a press per day to say what is on it is one nobody reads. A
press moves *into* the day panel; Back steps out of the panel before leaving the page.
- **Month and week travel are explicit controls.** Left and Right inside the guide already
mean moving between the day rail and its programmes, so those keys cannot also change the
date range. An arrow at the end of either range is not drawn rather than drawn dead.
`calendarMonthRange` (12) is what stops a
held D-pad walking Sonarr into the 2050s one request at a time; `parseCalendarMonth` refuses
an out-of-range month rather than clamping, or the header would disagree with the grid.
- **The day rail summarises; the programme pane explains.** A day names its count and first
show only. The pane has the space for Sonarr fanart (or a graphical monogram fallback), an
Emby title logo when the series was matched, episode details, availability and a prominent
season-premiere/finale label. Finale wording comes from Sonarr's `finaleType`; an absent
value makes no claim. `CalendarScreenshotTest` renders a crowded day and the artwork-free
fallback because only a screenshot can check that hierarchy at television distance.
- **The rail entry is a server feature** (`tv_calendar`, capability `tv_calendar_v1`), because
a household running no Sonarr would otherwise carry a destination that only ever opens an
apology. A set standing on the page when it is switched off is moved to Home, or it is left
somewhere nothing can navigate back to.
- **A failed month is an empty month, not an error.** The page is informational, and somebody
who pressed Right past a Sonarr hiccup must be able to press Left back out of it.
- **There is no second implementation on the direct path.** Unlike subtitles or Continue
Watching, the answer is Sonarr's, which a television holds no credential for and Emby knows
nothing about — with no gateway there is genuinely no calendar, and the rail says so by
omitting the entry.
**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/<feature-name>/` 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.