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