# 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`, the `MediaBrowser Client="Memby"` auth header Emby shows in its devices list, and on-screen copy. `Emby*` class names (`EmbyRepository`, `EmbyApi`, `EmbyServiceFactory`, `EmbyModels`) are kept on purpose: those types model *Emby's* API, and renaming them would make the code lie about what it talks to. App-identity types are `Memby*`. ## Repository layout This is a two-language monorepo. `app/` and `benchmark/` are the Gradle build; `server/` is an independent Go module (the **Memby gateway**) that Gradle does not know about, built and run through Docker. `docker-compose.yml` at the root wires the gateway to Postgres and Redis. The two halves are coupled only by an HTTP contract — see "Gateway mode" below. ## Build, install, test Requires JDK 17 and the Android SDK. `deploy-debug.ps1` sets `JAVA_HOME` to Android Studio's bundled JBR; do the same when invoking Gradle directly if the shell JDK isn't 17. ```powershell .\gradlew.bat assembleDebug # build APK -> app/build/outputs/apk/debug/ .\gradlew.bat test # JVM unit tests (app/src/test) .\gradlew.bat :app:testDebugUnitTest --tests "*MediaBadgesTest" # one test class .\gradlew.bat installDebug .\deploy-debug.ps1 -Serial 192.168.20.3:41479 # force-stop, install, wake, relaunch .\gradlew.bat :benchmark:connectedCheck # macrobenchmarks; needs a connected TV ``` For the gateway (from `server/`): ```bash go build ./... && go test ./... # add -buildvcs=false on Windows if .git is unusable docker compose up -d --build # from the repo root; needs .env (see .env.example) ``` **Deploying the gateway to the NAS** is `deploy-server.ps1` (PowerShell 7): ```powershell .\deploy-server.ps1 # local tree -> 10.0.0.213:/share/Docker/Memby .\deploy-server.ps1 -SourceDirectory C:\src\memby -Destination /share/Docker/Memby-test ``` It tars the local `server/`, `docker-compose.yml` and `.env.example`, and streams them over one SSH connection (interactive password; stdin carries the archive, so OpenSSH prompts on the tty). The remote half stages into `.new.$$`, builds, then swaps directories and waits for all three health checks, restoring the previous release if anything fails. The named Postgres volume is preserved — it never runs `compose down -v`. **`.env.example` is the configuration.** It holds real values, and every deployment overwrites the NAS's `.env` with the local copy (the old one is kept beside it as `.env.previous`). The script requires `MEMBY_PORT=32768`, `MEMBY_ADMIN_TOKEN`, `MEMBY_EMBY_URL` and `POSTGRES_PASSWORD` before activation, then confirms the admin token reached the running container. The database volume is always preserved; deployment stops before activation if the Postgres password differs from the deployed value, because a credential change requires an explicit database migration. The local working tree is deployed directly; no commit or push is required. `local.properties` must contain `sdk.dir=...` when building from the CLI. Lint has `abortOnError = false` (media3's `@UnstableApi` opt-in check would otherwise fail the build), so lint failures do not surface at build time. Unit tests are plain JUnit 4 with no Android/Robolectric dependency — logic that needs testing must live in a pure function or a plain data class (`mediaBadges`, `HomeUiState`, `millisecondsToTicks`, `ringColorFromHex`, `EmbyProfile` handling are the existing examples). ## Identity One name everywhere: **`com.ponzischeme89.memby`** is the Kotlin package, the Gradle `namespace` and the `applicationId`. Identity types are `Memby*` (`MembyApp`, `MembyDreamService`, `Theme.Memby`). Historical note, because old APKs and TVs still carry it: through v0.1.52 the package was `com.mattcohen.embyscreensaver` and the `applicationId` was `com.mattcohen.embyclientsname`. Both changed in v0.1.53. **`applicationId` is the install identity** — changing it makes every TV treat the build as a brand-new app: the old icon stays until uninstalled, the DataStore session is gone, and users sign in again. Treat any future change to it as a migration, not a rename. adb commands, the benchmark `packageName` and `FileProvider` authorities all derive from it. **Versioning.** `versionCode` is derived from `versionName`: `major*10000 + minor*100 + patch` (0.1.53 → 153). Bump both together — the in-app updater compares `versionName`, while Android refuses an APK whose `versionCode` went backwards. `release.ps1 -Version` rewrites both, so prefer it over editing the build file by hand. **Releases.** APKs are self-hosted (NAS or any web server), not on a store. `release.ps1` builds a signed APK and assembles `dist/out/` — `index.html` (landing page from `dist/template/`), `latest.json` (the manifest the app polls) and the versioned APK. Release signing reads `memby.keystore` and friends from `local.properties`; with no keystore the build still succeeds but emits an unsigned APK and logs a warning. The key matters more than the code: Android identifies an app by applicationId **plus** signing key, so a changed key forces every user to uninstall and reinstall. For direct TV deployment without publishing a release, `deploy-tv.ps1` builds and verifies the signed release, connects over wireless ADB, installs it with `-r`, and launches the Leanback activity. It reads the same signing settings from the current user's persistent `MEMBY_KEYSTORE*` environment variables and defaults to the living-room Chromecast endpoint; pass `-Device host:port` when Android rotates the wireless-debugging port. `UpdateChecker` supports two sources, chosen by URL shape in `isManifestUrl` — a `.json` URL is a static manifest, anything else is a Gitea host. `resolveApkUrl` lets a manifest use a relative `apkUrl`. Both are unit-tested in `UpdateSourceTest`. **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. - `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. **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. Three 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. - `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. **Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed 6×6 on-screen keyboard on the left, a results grid on the right that updates as you type. Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)` → `trim` → `distinctUntilChanged` → `collectLatest { repository.search(it) }` — and `collectLatest` is the load-bearing part: it cancels the in-flight request, so a slow response for a prefix can never overwrite the results for what was typed after it. Searching starts at two characters (`shouldSearch`); one letter matches half a library. `rankSearchResults` is a pure, stable sort that only lifts exact/prefix/word-boundary title matches above the backend's own relevance order — it never re-sorts alphabetically, and it keeps weak matches rather than showing an empty pane. A small access-ordered map caches results per query for the session, so backspacing is instant. Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the rightmost goes to the results grid, the grid's first column goes back to the *last key used* (a `FocusRequester` attached to whichever key that is), and the grid has a `focusRestorer`. Back moves results → keyboard → clear query → leave, one step per press. Physical keyboards and phone-remote apps feed the same state through one `onPreviewKeyEvent` that consumes only printable characters and backspace — D-pad and Back must fall through. The voice button needs the `android.speech.RecognitionService` entry in the manifest's ``, or `isRecognitionAvailable` returns false on Android 11+ and it hides itself on devices that actually support it. **Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events with dwell timing (injectable clock, unit-tested) and `HomeViewModel` flushes every 20s, on `ON_STOP`, and on dispose. Fire-and-forget by design — `reportRowEvents` swallows failures, because telemetry must never surface on a TV. Aggregates are read at query time in `store.RowStats`; raw events are pruned after 90 days. **Admin interface** is `server/internal/api/admin.html`, a single embedded page (no build step, no CDN — a strict no-dependency page is the whole point). It polls `/admin/api/status` every 5s. Guarded by `MEMBY_ADMIN_TOKEN`; unset means every `/admin` route 404s. **Explaining a recommendation** is `recommend/explain.go`: `Why(profile, item, limit)` is a pure function turning the learned weights into the phrases a detail page shows. It is kept apart from `Score` on purpose — the scorer decides *order* and may be opaque, this decides *wording* and must never invent an affinity, which is what `reasonFloor` and the unit tests enforce. `PersonWeights` exists only for this: casting is a good reason to tell someone about a title and a poor reason to rank by it. `api/related.go` serves it beside Emby's similarity list at `GET /v1/items/{id}/related`, cached per user and item because building the profile costs the same Emby fan-out the home rows pay for. **Recommendations** live in `server/internal/recommend`: `profile.go` is pure scoring (recency-weighted genre/studio affinity, exclusion of anything seen) and `engine.go` does the Emby fan-out. Both are unit-tested without a network — `engine.go` takes a narrow `Source` interface so tests inject a fake. The engine never runs on the home request path: rows come from the `r::rows` cache, and a miss triggers a deduplicated background rebuild while home returns immediately. That key is intentionally outside the `u:` namespace that mutations wipe; only a finished playback retires it. **`EmbyRepository`** is the only place that talks to Emby. It keeps a `@Volatile` `snapshot` of `Settings` collected from DataStore so synchronous callers (URL builders, `rotationIntervalMillis`) don't suspend, and it caches the Retrofit `EmbyApi` instance, rebuilding only when the base URL changes. All image and stream URLs are built here with `api_key` appended. Errors reaching the UI go through `friendlyEmbyError` — never surface raw HTTP bodies, which can contain tokens (the OkHttp logging interceptor is pinned at `Level.NONE` for the same reason). **Emby query conventions.** List endpoints request the narrowest `Fields` / `EnableImageTypes` set that the row needs (`getHomeItems` enforces this); full metadata is fetched only via `getItemDetails` after D-pad focus settles (140 ms debounce in `HomeViewModel.focusItem`, with an LRU cache and cancellation of the in-flight job). Adding fields to a home query is a startup-cost regression — extend the detail call instead. Emby time values are 100-ns ticks; convert at the boundary (`millisecondsToTicks`, `resumePositionMs`). **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()`. The profiles blob deliberately does **not** carry cached home JSON. Preferences DataStore rewrites and fsyncs the whole file on every edit, so embedding a several-hundred-KB cache per profile meant every settings toggle rewrote all of them. `writeProfiles` is the single writer and strips the field out into a per-profile `home_cache::@` key, which doubles as the migration for installs that still embed one — `applyProfile` reads the dedicated key and falls back to the embedded copy. Add a profiles write and it must go through `writeProfiles`. That per-profile key is also the **read** path (`activeHomeCache`), and the only place the cache is written. The flat `home_cache` key remains only for a store with no active profile to key against, and for installs written before the split. It briefly held a second copy of every cache alongside the per-profile one, which put the largest value in the store into the file twice — on a format that rewrites and fsyncs the whole file per edit, and on a value rewritten by every home refresh. **Home startup path.** `HomeCache` (last successful home response) is persisted per profile and used as the initial `HomeUiState`, so the launcher renders rows before the network returns; sections then refresh in parallel under a `Mutex` and re-persist. Playback stops are broadcast through `repository.playbackStops` and refresh only the Continue/Next-Up rows. Three things protect that "before the network returns" promise, and all three are easy to undo by accident: - **Nothing on the signed-in path may block on a request.** `AppRoot` used to hold the launcher on the loading screen until `getRecommendationOnboarding()` answered, which on a slow connection meant the cached rows could not be drawn until the connect timeout expired. Onboarding completion is now persisted (`Settings.hasCompletedOnboarding`, `markOnboardingCompleted`) and consulted first; the gateway is still asked in the background and stays authoritative for anyone not yet recorded. For a profile with no record yet the ask is still on the critical path, so it is bounded by `ONBOARDING_CHECK_TIMEOUT_MS` and fails open to the launcher — and only a verdict the gateway actually returned is persisted, or a single slow response would retire the rating screen for someone who has never seen it. The `onboardingToken` guard that stops repeat asks must always be paired with a null check on the verdict itself: on its own it can match a restarted effect's own token and strand the launcher on the loading screen permanently. - **The settings flow must never be able to terminate.** It is the gate everything waits behind, and a `shareIn`ed flow that completes exceptionally never emits again — so a single failed read shows up as "Opening Memby…" forever, surviving relaunch. Hence the `ReplaceFileCorruptionHandler` on the DataStore and the `catch` before `shareIn`. Do not remove either: this store is rewritten on every home refresh, so a process killed mid-write is an ordinary event on a TV. - **The cache is decoded off the main thread.** `SettingsStore.primeHomeCache` parses it on the store's IO scope as the settings flow emits, and `homeCache()` returns that memoized copy — `HomeViewModel`'s constructor runs during composition, so decoding there parsed the whole blob on the main thread at exactly the wrong moment. - **`setHomeCache` skips unchanged writes** (`lastPersistedHomeCache`). It runs on every refresh *and* every playback stop, and most passes find nothing new. **Screensaver hosting.** `ScreensaverContent` is shared by `MembyDreamService` and `ScreensaverActivity`. A `DreamService` is not a `ComponentActivity`, so `DreamLifecycleOwner` supplies the ViewTree lifecycle/ViewModelStore/SavedState owners Compose requires; D-pad handling lives in the composable while the hardware Play/Pause key is intercepted in `dispatchKeyEvent` and routed via the `ScreensaverActions` holder. Playback from the dream `finish()`es first and starts `PlayerActivity` on a delayed main- thread post to avoid the "activity behind the dream" race. **In-app updates.** `UpdateChecker` polls a user-configured **Gitea** release (`/api/v1/repos/{owner}/{repo}/releases/latest`, token auth for private repos), downloads the APK and hands it to the system installer via `FileProvider`. 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`. **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`. **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. **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. **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 and Coil's artwork loader 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. Don't construct a bare `OkHttpClient.Builder()` — derive from `HttpStack.base`. ## UI conventions Use `androidx.tv.material3` components (`Button`, `Card`, `Text`) rather than the phone Material 3 ones. `MainActivity.kt`, `HomeComponents.kt` and `ScreensaverContent.kt` are the three large files — new screens generally belong in `ui//` rather than growing them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`); everything must be reachable by D-pad only. **Animations must not recompose.** This app ships to weak TV boxes, so an animated value read in a composable body — `val x by animateFloat(...)` then using `x` in the layout — is a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and read it inside a `Canvas`/`drawBehind` block (draw phase only), and derive any text from it with `derivedStateOf` so it recomposes when the *displayed* value changes, not when the float does. `ServiceAlertBanner`'s countdown ring and pulse are the worked example: ~10 recompositions of one number over ten seconds instead of ~600 of the whole bar. The same rule applies to collecting flows — collect in the smallest composable that needs the value, not at the top of `MainActivity`, or every emission recomposes the launcher. **Where a composable is too large to split, narrow the state instead.** `HomeScreen` is the case: it cannot reasonably collect `HomeUiState` in one place, because reading the whole object there meant an arriving update verdict, a slow-connection banner or any one of the four section loads invalidated the launcher *and* rebuilt every row with it. So `HomeViewModel` exposes three `distinctUntilChanged` projections — `content` (rows and their loading flags), `status` (connection health) and `appUpdate` — and the screen subscribes to each where it is rendered. `contentSlice()` blanks the non-row fields rather than introducing a separate type, which is what lets `homeRowsFor` keep taking a `HomeUiState` and the tests that pin it keep working; read only rows and `loading` from it. **Design tokens.** `ui/theme/DesignTokens.kt` is the one vocabulary both surfaces read: `MembySurface` (the near-black), `MembyAccent`, `MembyOnSurface`/`MembyMutedText`/ `MembyQuietText`, `MembyScore` for the community rating, 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. **One runtime formatter, one 4K threshold.** `detail/DetailFacts.kt` owns `formatRuntime`, `heroFacts`, `ratingLabel`, `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. **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. The featured card is a fixed height with its content centred, so anything over budget is lost top and bottom and Play, being last, goes first: a wrapped title stands the synopsis down rather than the button. **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. - 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. - `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. **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.** `app/src/test/.../ServiceAlertBannerScreenshotTest.kt` renders composables to PNGs under `app/build/screenshots/` via Roborazzi + Robolectric, at TV 1080p qualifiers — the way to look at a layout without a TV to hand. This is the *only* Android dependency allowed in `app/src/test`; keep it confined to `*ScreenshotTest.kt` files so logic tests stay pure JUnit. Recording is always on (`roborazzi.test.record` in `testOptions`): these are artifacts to look at, not checked-in goldens, and a screenshot test that silently captures nothing is worse than none. 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.