2026-07-27 08:16:20 +12:00
|
|
|
|
# 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
|
2026-08-06 22:33:56 +12:00
|
|
|
|
`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.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
|
|
`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)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
`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`,
|
2026-07-27 08:22:55 +12:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
Every version bump is also a publish operation: update the version and changelog together,
|
|
|
|
|
|
commit the complete release, and push it to GitHub. Never leave a bumped version only in the
|
|
|
|
|
|
local working tree.
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-07-27 08:22:55 +12:00
|
|
|
|
`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`.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**`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.
|
|
|
|
|
|
|
2026-08-09 16:04:40 +12:00
|
|
|
|
**An update is acknowledged once with a toast.** `ui/whatsnew/whatsNewDecision` compares the
|
|
|
|
|
|
running build with `Settings.whatsNewSeenVersion`, and `AppRoot` briefly says “Memby has been
|
|
|
|
|
|
updated to version …” over the launcher before recording it. The record is device state,
|
|
|
|
|
|
deliberately not a synced preference: what is new is a property of the APK on *this* set.
|
|
|
|
|
|
Two quiet cases matter: a **fresh install** has not updated from anything, so setup records
|
|
|
|
|
|
the current build without announcing it; and a signed-out set with an older record waits
|
|
|
|
|
|
until somebody signs in, because the notice belongs over the launcher. The changelog is no
|
|
|
|
|
|
longer part of this decision — release history remains available in Settings → About, while
|
|
|
|
|
|
local or unreleased builds still receive the same one-time update acknowledgement.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
|
2026-07-27 08:34:04 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-11 12:08:51 +12:00
|
|
|
|
**A retired build must be told why it was signed out.** The destructive floor
|
|
|
|
|
|
(`destructiveUpdateFloor`) deletes the session on the next authenticated request and answers
|
|
|
|
|
|
401; a retired build's sign-in is then refused with 426. The television only saw the sign-out
|
|
|
|
|
|
— so it drew a sign-in form the gateway would refuse, and the mandatory update screen was up
|
|
|
|
|
|
to `UPDATE_CHECK_INTERVAL_MS` (an hour) away, force-closing the app being the only way
|
|
|
|
|
|
through, since a fresh launch checks for updates before it draws anything. Both refusals
|
|
|
|
|
|
carry `X-Memby-Update-Required`, so `RequiredUpdateInterceptor` on the gateway client
|
|
|
|
|
|
publishes it through `update/RequiredUpdateSignal` and `AppRoot`'s check loop waits on
|
|
|
|
|
|
*either* the hourly interval or that signal. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **It is an interceptor** because the refusal lands on whichever request happened to be in
|
|
|
|
|
|
flight — the status poll, a home refresh, a sign-in — and only one of those has any reason
|
|
|
|
|
|
to know about update policy. The signal replays one value, because it is commonly reported
|
|
|
|
|
|
before the check loop is waiting on it.
|
|
|
|
|
|
- **The refusal is written to disk** (`Settings.requiredUpdateVersion`, via
|
|
|
|
|
|
`RequiredUpdateGuard` on the service locator), because it is announced exactly *once*: the
|
|
|
|
|
|
401 that deletes the session carries the header and every 401 after it is an ordinary
|
|
|
|
|
|
missing session. In-memory only, a television told and then restarted had nothing left to
|
|
|
|
|
|
learn it from but the launch check — which is bounded by `UPDATE_CHECK_TIMEOUT_MS` (2.5s)
|
|
|
|
|
|
and, on missing it, put the viewer back on the welcome and sign-in screens. It is written
|
|
|
|
|
|
by the locator rather than by a screen for the same reason the interceptor exists: nothing
|
|
|
|
|
|
that knows about update policy is necessarily composed when the refusal arrives.
|
|
|
|
|
|
- **Only the gateway may withdraw it.** A verdict that is not a required update clears the
|
|
|
|
|
|
flag; a failed check never does, because an unreachable server is no evidence about which
|
|
|
|
|
|
builds it accepts. `requiredUpdateSatisfied` is the one exception and it is pure and
|
|
|
|
|
|
tested — the build the refusal demanded is now the build running, which is what the moment
|
|
|
|
|
|
after a successful self-update looks like.
|
|
|
|
|
|
- **`RetiredBuildScreen` is what the television shows meanwhile**, ahead of sign-in,
|
|
|
|
|
|
profiles, the launcher and the null-settings case alike. There is no attempt budget any
|
|
|
|
|
|
more: giving up used to hand the viewer a sign-in form as the *final* answer, and every
|
|
|
|
|
|
screen underneath this one is something the gateway would refuse. The loop asks every
|
|
|
|
|
|
`UPDATE_REQUIRED_RETRY_MS` for `UPDATE_REQUIRED_FAST_ATTEMPTS`, then settles onto
|
|
|
|
|
|
`UPDATE_REQUIRED_BACKOFF_MS`, and the screen's Try again wakes it through the same channel
|
|
|
|
|
|
a refusal does.
|
|
|
|
|
|
- **Every refusal wakes the loop, including a repeat.** The 401 that retires the session and
|
|
|
|
|
|
the 426 that refuses the sign-in after it name the same version, and the second is the one
|
|
|
|
|
|
a viewer is standing in front of — filtering it as already-handled is what left a
|
|
|
|
|
|
television on a form it could not get through until the next hourly check.
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
## 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.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
- **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.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
- `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.
|
2026-07-29 15:26:27 +12:00
|
|
|
|
- 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.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
**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).
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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
|
2026-08-06 22:33:56 +12:00
|
|
|
|
still hear the news. Four publishers today:
|
2026-08-02 22:10:19 +12:00
|
|
|
|
|
|
|
|
|
|
- `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.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
- `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.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
- `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:
|
2026-07-29 15:26:27 +12:00
|
|
|
|
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
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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.
|
2026-07-29 15:26:27 +12:00
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-07 10:44:17 +12:00
|
|
|
|
**My Alerts belongs to a person, so it lives in the user picker.** A service alert is the
|
|
|
|
|
|
house being told something; these are one viewer's own news (a followed show returning),
|
|
|
|
|
|
stored per user on the gateway and following them to whichever television they sign into.
|
|
|
|
|
|
`ui/alerts/AlertsPage.kt` is the full page and the user menu in `UserSwitcherOverlay` is the
|
|
|
|
|
|
way in, beside Manage users. It replaced a bell in the corner of the launcher, which was
|
|
|
|
|
|
drawn only on Home and cost a focus target on every set whether or not there was anything
|
|
|
|
|
|
behind it. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **The badge counts alerts, not unread ones** (`alertBadgeLabel`, pure and tested). An
|
|
|
|
|
|
alert that has been read but not dismissed is still sitting there, and a badge that
|
|
|
|
|
|
cleared itself the moment somebody glanced at the page would never agree with the list
|
|
|
|
|
|
underneath it. `AlertBadgeMax` is what stops the pill growing wider than its row.
|
|
|
|
|
|
- **The badge is drawn twice on purpose** — on the "Switch user" rail item and on the My
|
|
|
|
|
|
Alerts row inside the picker. The page is one level in now, so without the mark out on the
|
|
|
|
|
|
rail nothing on the launcher would ever say there was news waiting.
|
|
|
|
|
|
- **A press dismisses, and the focused row says so.** This is the only page whose whole job
|
|
|
|
|
|
is emptying itself; a confirmation press per alert is what made the panel it replaced not
|
|
|
|
|
|
worth opening. Focus marks read, so nothing has to be pressed to clear the "new" flag.
|
|
|
|
|
|
"Dismiss all" is the same per-alert call in a loop — the gateway has no bulk route — and
|
|
|
|
|
|
empties the list optimistically, or a row lingers under a thumb that will press it again.
|
|
|
|
|
|
- **The page is stateless**, like `SignInContent` and the detail panes: `MainActivity` owns
|
|
|
|
|
|
the list and the requests, which is what lets `AlertsPageScreenshotTest` render it (and
|
|
|
|
|
|
the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`.
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
**Every search the tab performs is recorded, and the gateway is what records it.** It used
|
|
|
|
|
|
to depend entirely on the television posting to `/v1/search/history` after a result landed,
|
|
|
|
|
|
which meant a query answered from the client's own cache, one whose post failed, or one
|
|
|
|
|
|
from a build that predates the call was never written down at all — and the table feeding
|
|
|
|
|
|
the recent-searches row and future per-user ranking was a partial record of what the
|
|
|
|
|
|
household looks for. `handleSearch` now calls `recordSearchQuery` itself, before the Redis
|
|
|
|
|
|
lookup, so a cached answer counts the same as one that reached Emby. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **The write is detached from the request context.** Instant search cancels the in-flight
|
|
|
|
|
|
request on every keystroke, so a write hung off `r.Context()` would be abandoned for
|
|
|
|
|
|
exactly the searches somebody typed fastest. It is also fire-and-forget: a search whose
|
|
|
|
|
|
record failed still returns results, and the failure is DEBUG for the same reason the
|
|
|
|
|
|
search line is.
|
|
|
|
|
|
- **`store.SearchDedupeWindow` is what makes two writers safe.** The handler and the
|
|
|
|
|
|
client's post both describe one search, and the POST route is still there because an
|
|
|
|
|
|
older APK is the only thing that records at all. An identical query inside the window is
|
|
|
|
|
|
the same search; a minute later it is its own row.
|
|
|
|
|
|
- **`searchQueryRecordable` is the one rule both routes apply**, so a query `/v1/search`
|
|
|
|
|
|
records is exactly one `/v1/search/history` would have accepted. Length is counted in
|
|
|
|
|
|
runes, or a title in Japanese is rejected at a third of an English one's length.
|
|
|
|
|
|
|
|
|
|
|
|
**And the console can read it back** — `/admin/searches`, in the Insights group beside Row
|
|
|
|
|
|
engagement, over `internal/api/admin_searches.go` and the store queries in
|
|
|
|
|
|
`internal/store/searches.go` (where the writer moved to, so one table's rules sit in one
|
|
|
|
|
|
file). It is **two tables of the same rows on purpose**: the summary groups by
|
|
|
|
|
|
`lower(query)` and answers "what does this house look for", which is what a library is
|
|
|
|
|
|
organised against; the log is uncollapsed and newest-first and answers "what happened just
|
|
|
|
|
|
now", which is the one to read when somebody reports that search is not finding something,
|
|
|
|
|
|
because it shows the query as it was typed, by whom, and when. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **The widest window is the retention period.** `searchWindowDays` is derived from
|
|
|
|
|
|
`store.SearchRetention` rather than written down, because `RecordSearch` prunes to it —
|
|
|
|
|
|
a page offering 90 days would draw a flat line for two thirds of it. A tile states the
|
|
|
|
|
|
retention for the same reason: a quiet week and a window that has aged out look identical.
|
|
|
|
|
|
- **`SearchTotals` is its own query, not a sum of the table above it.** The summary is
|
|
|
|
|
|
capped at `searchTermLimit`, so adding it up would report the top twenty-five's total as
|
|
|
|
|
|
the household's, wrong by however long the tail is.
|
|
|
|
|
|
- **Names are resolved in Go from `KnownUsers`, not joined per row**, and an id with no
|
|
|
|
|
|
session left keeps its row wearing the id — the query is what the page is for, and a
|
|
|
|
|
|
viewer whose sessions have expired is still one searcher rather than nobody.
|
|
|
|
|
|
- **Nothing on it is editable**, the stance every insights page takes. It also cannot
|
|
|
|
|
|
delete: the 30-day prune is the only thing that removes a row.
|
|
|
|
|
|
|
|
|
|
|
|
**A genre is browsed, not searched.** The chips on the empty state ran their own label
|
|
|
|
|
|
through `/v1/search`, which is a text query: "Drama" matched a film *called* Drama, anything
|
|
|
|
|
|
with the word in its overview, and — relevance being a score rather than a rule — a
|
|
|
|
|
|
scattering of titles not in the genre at all, while missing most of the ones that were. A
|
|
|
|
|
|
chip now opens `GET /v1/genres/{genre}/items` (`server/internal/api/genres.go`,
|
|
|
|
|
|
`EmbyRepository.browseGenre`), which filters on Emby's `Genres` parameter and answers a page
|
|
|
|
|
|
at a time. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **It is a mode, not a query.** `SearchUiState.genre` sits beside the query rather than
|
|
|
|
|
|
pretending to be one, which is what lets the pane head itself "Comedy" instead of "Search
|
|
|
|
|
|
results for “Comedy”" and lets Back step out of the shelf without clearing something
|
|
|
|
|
|
nobody typed. Typing supersedes it; `runSearch` returns early while a genre is open, or
|
|
|
|
|
|
the empty-query transition that opening one causes would wipe the shelf it just filled.
|
|
|
|
|
|
- **Paging must not repeat or skip a card**, which is why both paths sort on
|
|
|
|
|
|
`PremiereDate,SortName` rather than a date alone — two titles sharing a premiere could
|
|
|
|
|
|
otherwise swap places between requests, and the scroll would show one twice and the other
|
|
|
|
|
|
never. Newest first, because the alphabet is not an answer to "show me Comedy".
|
|
|
|
|
|
- **Two rules end the scroll and both are needed** (`hasMoreGenreItems`, pure and tested):
|
|
|
|
|
|
reaching the total is the ordinary end, and a page shorter than the one asked for is the
|
|
|
|
|
|
other — a backend that would not count says nothing useful with its total. `genreTotal` on
|
|
|
|
|
|
the server is the same judgement from the other side, for an Emby that did not count.
|
|
|
|
|
|
- **A page is appended by its own offset**, never by order of arrival: a response for an
|
|
|
|
|
|
offset already scrolled past, or for a genre the viewer has left, is dropped rather than
|
|
|
|
|
|
pasted into the middle of the grid. A page that fails part way down keeps what is on
|
|
|
|
|
|
screen and simply stops paging.
|
|
|
|
|
|
- **The scroll trigger is a `snapshotFlow`, not a composable read.** The last visible index
|
|
|
|
|
|
changes on every frame of a scroll, and reading it in the body would recompose the grid
|
|
|
|
|
|
the whole way down a genre — the same rule as the animation one above. It asks
|
|
|
|
|
|
`LOAD_MORE_ROWS_AHEAD` rows early, since a request that starts when the viewer arrives at
|
|
|
|
|
|
the end is one they watch.
|
|
|
|
|
|
- **Something has to take the focus the chip was holding**, because opening a shelf unmounts
|
|
|
|
|
|
the whole discovery pane. The grid claims it when the first page lands, and an empty or
|
|
|
|
|
|
failed genre hands it back to the keyboard rather than leaving a television with nothing
|
|
|
|
|
|
focused.
|
|
|
|
|
|
- **Episodes are excluded on both paths.** An episode inherits its series' genres, so
|
|
|
|
|
|
including them fills a page with twenty entries of one comedy and buries the rest.
|
2026-08-09 13:10:55 +12:00
|
|
|
|
- **Movies and TV Series have their own full-width genre browser.** A fixed row of colourful
|
|
|
|
|
|
mini cards is the first focus target on both destinations; it is deliberately a product
|
|
|
|
|
|
catalogue rather than whatever genre names happened to arrive in the home rows, so its
|
|
|
|
|
|
order and availability never jump around during refresh. Neighbouring Emby labels are
|
|
|
|
|
|
merged where they answer the same browsing intent (`Action|Adventure`,
|
|
|
|
|
|
`Science Fiction|Sci-Fi|Sci Fi|Fantasy`, `War|History`) using Emby's pipe-delimited genre
|
|
|
|
|
|
filter. `ui/genre/` keeps the resulting pages in memory, preserves the active category
|
|
|
|
|
|
behind a detail page, returns focus to the title that was opened, and consumes
|
|
|
|
|
|
`HomeViewModel.favoriteChanges` so a Favourite press and its possible rollback reach the
|
|
|
|
|
|
paged copy immediately. Its `type=Movie` / `type=Series` query is still enforced by the
|
|
|
|
|
|
gateway and on the direct Emby path, so no category can mix the two grids.
|
2026-08-08 16:56:18 +12:00
|
|
|
|
- **The gateway path degrades to a keyword search on the *first* page only**, so a set on a
|
|
|
|
|
|
new build talking to a gateway that predates the route still shows something. A later page
|
|
|
|
|
|
does not: a gateway that answered page one and failed on page two is having trouble, not
|
|
|
|
|
|
missing the route, and search results pasted onto the end of a genre would be nonsense.
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
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
|
2026-08-08 16:56:18 +12:00
|
|
|
|
`focusRestorer`. Back moves results → keyboard → genre → leave, one step per press.
|
2026-07-29 15:26:27 +12:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**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.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-07 10:44:17 +12:00
|
|
|
|
**"Because you watched …" rotates, because the top of a watch history does not.** The rows
|
|
|
|
|
|
were anchored to the two most recent seeds, and the head of history is a resumable title
|
|
|
|
|
|
plus whichever series the household is part-way through — neither of which moves for weeks,
|
|
|
|
|
|
so the same two rows came back day after day. `selectSeeds` (pure, tested) instead cuts the
|
|
|
|
|
|
most recent `SeedPool` seeds into `MaxSimilarRows` equal bands and draws one from each by
|
|
|
|
|
|
`dailySeed(userID)`. Things to preserve: it is a rotation *within recency bands*, never a
|
|
|
|
|
|
shuffle of the window, so the first row is still anchored to something watched lately and
|
|
|
|
|
|
the rows below it reach further back; the same day always yields the same seeds, because
|
|
|
|
|
|
rows are rebuilt on every cache miss and a set that re-picked each time would change under
|
|
|
|
|
|
somebody browsing; the last band takes the remainder, so a pool that does not divide evenly
|
|
|
|
|
|
still reaches its oldest entry; and a history shorter than the pool falls back to plain
|
|
|
|
|
|
recency rather than pretending to rotate. `similarRow` also runs `diversifyRanked` over its
|
|
|
|
|
|
cards with the same daily variation, the way curated shelves do — a row that keeps its seed
|
|
|
|
|
|
across two days must not present the same posters in the same order. `MEMBY_RECOMMEND_TTL`
|
|
|
|
|
|
(24h) is what makes the rotation daily in practice: the seed only changes at a rebuild.
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
**`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`).
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
**A detail page is warmed while its card is focused, in two waves.** By the time somebody
|
|
|
|
|
|
presses a card, the item record, its "why you might enjoy it" and its playable URL have all
|
|
|
|
|
|
been fetched — and now so have its episode list and its trailer, which were the two things
|
|
|
|
|
|
the page still opened cold. That mattered most on Continue Watching, where every card is an
|
|
|
|
|
|
episode and pressing one opened a page with no season scroller and no episode list until the
|
|
|
|
|
|
network answered. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **The two waves have deliberately different delays.** The metadata warm follows the D-pad
|
|
|
|
|
|
closely at `FOCUS_METADATA_DEBOUNCE_MS` (140 ms) because it decides what the panel beside
|
|
|
|
|
|
the row says; `warmDetailPage` waits `DETAIL_PREFETCH_DELAY_MS` (450 ms) because an
|
|
|
|
|
|
episode list is the largest request this client makes — a long-running show is a thousand
|
|
|
|
|
|
records — and warming one per card as somebody scans a shelf would spend more than it
|
|
|
|
|
|
saves. The job is cancelled when focus moves, so a viewer travelling along a row never
|
|
|
|
|
|
reaches it and one who has stopped, which is what precedes a press, does.
|
|
|
|
|
|
- **Everything warmed on focus must be single-flighted on the repository's own scope**, for
|
|
|
|
|
|
the reason `getRelated` already was: the warming job dies with the D-pad, and a request
|
|
|
|
|
|
cancelled at the socket is one the gateway logs as a failure and one nobody keeps the
|
|
|
|
|
|
answer of. `getSeriesEpisodes` and `getLocalTrailer` now take the same shape. A prefetch
|
|
|
|
|
|
added without it makes navigation *slower*, because the press that follows re-asks.
|
|
|
|
|
|
- **`getLocalTrailer` caches its negative answer** (`CachedTrailer`, the `CachedTrickplay`
|
|
|
|
|
|
precedent). Most of a library has no local trailer, so before this every detail page opened
|
|
|
|
|
|
with a request the gateway answered 404 to, repeated on walking Back and again for every
|
|
|
|
|
|
step of the "More like this" trail.
|
|
|
|
|
|
- **An episode is keyed on its series**, not on itself — that is what its own page will ask
|
|
|
|
|
|
for, and it is what makes one warm serve a whole row of Continue Watching.
|
|
|
|
|
|
|
|
|
|
|
|
**A detail overlay seeds its settings from `repository.currentSettings`**, never
|
|
|
|
|
|
`Settings.EMPTY`. Collecting a flow with an empty initial value draws the first frame under
|
|
|
|
|
|
default preferences and then recomposes the entire page — and restarts the effects keyed on
|
|
|
|
|
|
those preferences, which is a second ratings request — one frame later, at exactly the moment
|
|
|
|
|
|
the page is trying to appear.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**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
|
2026-08-08 16:56:18 +12:00
|
|
|
|
into everyone else's account. `SettingsStore.applyRemotePreferences` writes all nineteen
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
**Multi-profile session state.** `SettingsStore` stores a list of `EmbyProfile` (server,
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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()`.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**`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.
|
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
|
|
**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.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
**In-app updates.** `UpdateChecker` polls a **Gitea** release
|
|
|
|
|
|
(`/api/v1/repos/{owner}/{repo}/releases/latest`, token auth for private repos) or a static
|
|
|
|
|
|
manifest, downloads and verifies the APK, then commits it to a **`PackageInstaller`
|
|
|
|
|
|
session**. Because replacing the APK kills a running Dream and leaves a black surface,
|
|
|
|
|
|
`UpdateRecoveryReceiver` catches `MY_PACKAGE_REPLACED` and relaunches `MainActivity` with
|
2026-07-27 08:16:20 +12:00
|
|
|
|
`EXTRA_LAUNCH_UPDATED_SLIDESHOW`.
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
**There is no "check for updates" on the television.** Settings had an Updates page with that
|
|
|
|
|
|
button on it, and the address, repository and token it needs could not be entered anywhere on
|
|
|
|
|
|
this app — so the only thing it could ever report was a failure, on the one screen a viewer
|
|
|
|
|
|
opens when they already suspect something is wrong. The single answer about updates is the
|
|
|
|
|
|
gateway's (`server/internal/appupdate` → `ui/UpdateScreen.kt`), which arrives on every launch
|
|
|
|
|
|
and carries its own `downloadUrl`; `SettingsPage` has no `UPDATES` entry and the version this
|
|
|
|
|
|
TV is running is stated once, on About. The `updateBaseUrl`/`updateRepo`/`updateToken` keys
|
|
|
|
|
|
remain in `Settings` and nothing writes them — they are what an install predating this still
|
|
|
|
|
|
has in its DataStore.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
**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`.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
**A subtitle the library does not have is fetched from the player**, in
|
|
|
|
|
|
`internal/api/subtitle_download.go` over the provider layer in `subtitle_providers.go`.
|
|
|
|
|
|
**Two backends answer and they are not the same shape**, which is the one thing to hold on
|
|
|
|
|
|
to about this feature. **Bazarr** (`server/internal/bazarr`) writes the file *beside the
|
|
|
|
|
|
media file*, so the gateway stores nothing and serves nothing — it asks Bazarr to fetch,
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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
|
2026-08-08 16:56:18 +12:00
|
|
|
|
case. **OpenSubtitles** (`server/internal/opensubtitles`) hands back *bytes*, and the
|
|
|
|
|
|
gateway has no reach into the media directory, so a file fetched there is stored in
|
|
|
|
|
|
`downloaded_subtitles` and served back as a sidecar from `/v1/subtitles/{file}`. That
|
|
|
|
|
|
difference is the entire reason the gateway now holds a subtitle at all, and it is
|
|
|
|
|
|
contained: `mergeSubtitleTracks` puts what the gateway holds beside Emby's tracks inside
|
|
|
|
|
|
`playbackSubtitles`, so a downloaded subtitle is an ordinary track on every later playback
|
|
|
|
|
|
rather than something that exists only in the response that produced it. Things to
|
|
|
|
|
|
preserve:
|
2026-08-06 22:33:56 +12:00
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
- **The operator's switches are `store.SubtitlePolicy`, not environment variables.** A
|
|
|
|
|
|
provider is offered when the `subtitle_download` feature is on *and* it is configured
|
|
|
|
|
|
*and* it is switched on — `subtitleSources`, one place. Bazarr's address stays an
|
|
|
|
|
|
environment variable because it is a service the household runs; OpenSubtitles is an
|
|
|
|
|
|
account, so its key and login live in that document and the console's Subtitles page can
|
|
|
|
|
|
enter, replace or remove them without a redeployment. The store refuses to record
|
|
|
|
|
|
OpenSubtitles as on with no key, so the console can never show a switch that does
|
|
|
|
|
|
nothing. Nothing on that page ever returns a credential — only whether one is saved,
|
|
|
|
|
|
the stance the MDBList page takes.
|
|
|
|
|
|
- **A candidate carries its `Source` and the download dispatches on it.** The two tokens
|
|
|
|
|
|
are opaque in different ways and handing one to the other is a mistake nothing
|
|
|
|
|
|
downstream could detect. Empty means Bazarr, because an app built before there was a
|
|
|
|
|
|
second provider sends no source and all its rows came from one place.
|
|
|
|
|
|
- **The two providers make opposite trades on identity, which is the hard part.** Bazarr
|
|
|
|
|
|
keys on the *arr's id (`radarrid`, Sonarr's `episodeid`) and Emby knows neither, so
|
|
|
|
|
|
`bazarrMovieFor` / `bazarrSeriesFor` / `bazarrEpisodeFor` match by title, year and
|
|
|
|
|
|
episode number. They are pure and tested hard because a mismatch writes one film's
|
|
|
|
|
|
subtitle next to another. Episodes match on *numbers*, never titles — the two disagree
|
|
|
|
|
|
often enough (translations, differently named two-parters) to reject correct matches —
|
|
|
|
|
|
and season 0 is specials, a real season, not "no season". OpenSubtitles keys on an imdb
|
|
|
|
|
|
or tmdb id, which Memby already holds because the library import asks Emby for
|
|
|
|
|
|
`ProviderIds` so external ratings can be looked up, so there is **no guessing on that
|
|
|
|
|
|
path at all**. An episode is searched by its series' id plus season and episode number
|
|
|
|
|
|
whenever the episode carries no id of its own, since a show has one far more often than
|
|
|
|
|
|
each of its episodes does.
|
|
|
|
|
|
- **`resolveSubtitleTarget` reads the item once and fails per provider.** A film Bazarr
|
|
|
|
|
|
has never heard of may still have an imdb id, and a title with no provider id may still
|
|
|
|
|
|
be in Bazarr's list; only both failing is a failure. `providerSubtitles` then searches
|
|
|
|
|
|
both at once — a manual search is a live provider query measured in seconds — and a
|
|
|
|
|
|
provider that fails is dropped rather than failing the search.
|
|
|
|
|
|
- **The provider row is opaque.** The token is provider-specific and must be handed back
|
|
|
|
|
|
verbatim on the download call; it round-trips through the television untouched rather
|
|
|
|
|
|
than living in a server-side cache, so a viewer reading the list by remote cannot have
|
|
|
|
|
|
their choice expire underneath them.
|
|
|
|
|
|
- **A machine translation is offered, and says so.** It is a real answer and sometimes the
|
|
|
|
|
|
only one, so `rankMergedCandidates` sinks it below everything a person wrote rather than
|
|
|
|
|
|
hiding it, and `MachineOnly` is on the wire because it is the one property that changes
|
|
|
|
|
|
whether a viewer wants the row at all. The provider is now *named* on a row, where the
|
|
|
|
|
|
single-provider version deliberately did not name it: with two backends the same
|
|
|
|
|
|
language appears twice and "which of these is which" is a question the row has to answer.
|
|
|
|
|
|
- **The exhausted-allowance case keeps its own wording** (`opensubtitles.QuotaError` →
|
|
|
|
|
|
`subtitleFailureMessage` / `subtitleDownloadFailureMessage`). It is the only failure
|
|
|
|
|
|
where pressing the button again is definitely not the answer, and a television has no
|
|
|
|
|
|
log and no support channel — that sentence is the whole diagnosis.
|
|
|
|
|
|
- **A stored subtitle's id is derived, not random** (`storedSubtitleID`), so fetching the
|
|
|
|
|
|
same language for the same title twice replaces the file rather than growing a second
|
|
|
|
|
|
track a viewer has to tell apart by guessing. Its `gw:` prefix is what keeps it out of
|
|
|
|
|
|
Emby's namespace, which is stream indices — plain numbers — because the player matches a
|
|
|
|
|
|
track on the id it was handed.
|
|
|
|
|
|
- **The gateway sends a *path* for a subtitle it serves, never an address.** It does not
|
|
|
|
|
|
reliably know its externally reachable name and the television does, because it is the
|
|
|
|
|
|
thing talking to it. `EmbyRepository.resolveSubtitleUrls` puts the base and the `t=`
|
|
|
|
|
|
token on it, exactly as `imageUrl` does for artwork and for the same reason: media3
|
|
|
|
|
|
fetches a sidecar as a plain URL with none of Memby's headers attached. A URL that is
|
|
|
|
|
|
already absolute is left alone, so it is safe over every playback response.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
- **`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
|
2026-08-08 16:56:18 +12:00
|
|
|
|
poll is made by every open TV every ten seconds. The client default is **false** — a
|
|
|
|
|
|
missing field must never conjure a row that leads to a request the backend cannot answer.
|
|
|
|
|
|
- **A title with no subtitles is told so, in the track section** (`SubtitleTracksState`).
|
|
|
|
|
|
A list holding nothing but "Off" is indistinguishable from a menu that failed to load,
|
|
|
|
|
|
and the row that could fix it sits below a rule under a heading the eye has no reason to
|
|
|
|
|
|
travel to — so the notice goes under the SUBTITLES heading and the focus ring opens on
|
|
|
|
|
|
the search row instead of on "Off", which is the state the viewer is already in. Where
|
|
|
|
|
|
no provider can be asked it says that instead, once, rather than leaving somebody
|
|
|
|
|
|
looking for an option that is not there. `SubtitleMenuScreenshotTest` covers both.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
- **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.
|
2026-08-08 16:56:18 +12:00
|
|
|
|
- **The two launch forms are two places a `Playable` has to be unpacked, and only one of
|
|
|
|
|
|
them is obvious.** `adoptPlayable` handles the request form; on the URL form — the cold
|
|
|
|
|
|
start, and *any* launch where `readyPlayableForLaunch` had a warm prefetch in hand — the
|
|
|
|
|
|
intent is the only thing that will ever carry an answer, and a field with no
|
|
|
|
|
|
`putExtra`/`getExtra` pair silently takes its default. That is how `trickplayAvailable`,
|
|
|
|
|
|
`skipIntroAvailable` and `endCreditsAvailable` were all shipped switched off: each
|
|
|
|
|
|
defaults to **false** on purpose, so the omission produced no error, no log line and no
|
|
|
|
|
|
request — the gateway saw zero `/trickplay` and zero `/intro` calls across seventy-seven
|
|
|
|
|
|
playbacks. A new "is it worth asking the backend" boolean must be added in **four**
|
|
|
|
|
|
places: the `Playable`, the intent's parameter list, its `putExtra`, and `onCreate`'s
|
|
|
|
|
|
`getBooleanExtra`. `subtitleDownloadAvailable` is the worked example of all four.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
- **`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.
|
|
|
|
|
|
|
2026-08-10 07:11:14 +12:00
|
|
|
|
**The local Memby preroll is prepared while Home is idle.** `PrerollPreloader` owns one
|
|
|
|
|
|
process-scoped ExoPlayer for `res/raw/emby_preroll.mp4`; `MembyApp` queues its first prepare
|
|
|
|
|
|
on the main queue's idle handler, so decoder construction and the local resource read never
|
|
|
|
|
|
sit in `Application.onCreate` or in front of launcher composition. When a fresh playback
|
|
|
|
|
|
requires the preroll, `PlayerActivity` borrows that player and shows it in the pre-roll video
|
|
|
|
|
|
frame while the requested title negotiates and prepares, paused at zero, behind the overlay.
|
|
|
|
|
|
Three details preserve the performance claim: the clip's actual duration owns the hand-off
|
|
|
|
|
|
rather than a stale configured estimate; the old paused-content-frame path remains the
|
|
|
|
|
|
failure fallback; and the preroll player is stopped and parked at hand-off so it releases its
|
|
|
|
|
|
hardware decoder while HEVC content plays. Returning to Home prepares the same instance again
|
|
|
|
|
|
in the next idle window rather than constructing one per title.
|
|
|
|
|
|
|
2026-08-10 09:39:09 +12:00
|
|
|
|
**The same clip plays behind the cold-start screen** (`ui/LaunchPreroll.kt`). It is the one
|
|
|
|
|
|
screen every launch shows and the clip was the one thing Memby owns that nobody ever saw
|
|
|
|
|
|
there. It borrows the *same* cached instance — no second decoder, no second copy of the
|
|
|
|
|
|
file — and hands it back on dispose, so the next playback still opens on a prepared player.
|
2026-08-10 12:23:23 +12:00
|
|
|
|
**It now gates the launcher**, which is the whole point of it: it was decoration, uncovered
|
|
|
|
|
|
whenever the app happened to be ready, and on a warm start that was a fraction of a second —
|
|
|
|
|
|
so the one thing Memby owns was in practice never seen. The clip plays once from the
|
|
|
|
|
|
beginning, its last frame is held for `LAUNCH_INTRO_HOLD_MS` (2s), and only then is the home
|
|
|
|
|
|
screen composed. Things to preserve: **it gates but it can never trap** — no player, a
|
|
|
|
|
|
decoder error, or no rendered frame within three seconds all report finished immediately,
|
|
|
|
|
|
and `LAUNCH_INTRO_MAX_MS` (9s, the clip's four plus the hold plus room for a weak box) is
|
|
|
|
|
|
`AppRoot`'s outer bound on top of that, because a viewer must never be held on a black
|
|
|
|
|
|
screen by a branding clip; **`LaunchIntro.played` is process-scoped, not saved**, so an
|
|
|
|
|
|
activity Android recreates behind somebody is not a second launch and a profile switch does
|
|
|
|
|
|
not replay it; the pulsing mark and the welcome line fade *out* under the clip and back in
|
|
|
|
|
|
if the app is still opening when it ends, since the waiting screen and a four-second sting
|
|
|
|
|
|
said two things at once; and it no longer loops — looping existed so a slow cold start never
|
|
|
|
|
|
froze on the last frame, which the hold and the fade-out do instead, and a clip that never
|
|
|
|
|
|
ends cannot gate anything. It is still muted, because it now runs on every single app open,
|
|
|
|
|
|
where the pre-roll before a programme is audible and must *end* — `PrerollPreloader.acquire`
|
|
|
|
|
|
/`recycle` normalise volume and repeat mode so a borrower cannot leave the next one wedged.
|
|
|
|
|
|
It is acquired **after** `withFrameNanos`, because a cold start has nothing cached and
|
|
|
|
|
|
constructing an ExoPlayer inside the first composition of the screen that must appear
|
|
|
|
|
|
immediately is the cost the idle-handler prepare exists to avoid. And
|
2026-08-10 09:39:09 +12:00
|
|
|
|
`AppRoot` calls `MembyLoadingScreen` from **one** call site: the three states meaning "still
|
|
|
|
|
|
opening" were three, and Compose identifies a composable by where it is called from, so
|
|
|
|
|
|
moving between them disposed the screen and rebuilt it — which now means returning and
|
|
|
|
|
|
re-borrowing the player twice during the busiest stretch of a launch.
|
|
|
|
|
|
|
|
|
|
|
|
**What it says while it is opening is `ui/WelcomeQuotes.kt`.** Twenty-five lines per tone
|
|
|
|
|
|
plus eight headlines, because this is the most-read copy in the app and five per tone meant
|
|
|
|
|
|
a household saw the same sentence roughly every fifth time they switched the set on.
|
|
|
|
|
|
`WelcomeQuotesTest` counts the distinct lines a pool yields: a pool that shrank back, or
|
|
|
|
|
|
gained a duplicate on a copy-paste, looks identical to one that did not. The headline is
|
|
|
|
|
|
kept apart from the quotes and is not keyed on the tone — it names what the app is *doing*,
|
|
|
|
|
|
and rerolling it when the settings flow arrives would change the line mid-launch.
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-07 10:44:17 +12:00
|
|
|
|
**And it shows the frame it will land on.** The idea and the shape are borrowed from
|
|
|
|
|
|
[Wholphin](https://github.com/damontecres/Wholphin), under the same GPL-2.0 licence, the
|
|
|
|
|
|
way `PlayerEngine`'s HTTP stack was; what differs is the format underneath. Jellyfin serves
|
|
|
|
|
|
tile sheets, so Wholphin crops a sub-image out of a grid. **Emby serves BIF files**
|
|
|
|
|
|
(`/Videos/{id}/index.bif?Width=320`): a 64-byte header, one 8-byte (timestamp, offset)
|
|
|
|
|
|
entry per frame plus a terminator, then the JPEGs laid end to end — 320×172 every ten
|
|
|
|
|
|
seconds, about five megabytes for a two-hour film.
|
|
|
|
|
|
|
|
|
|
|
|
The index sitting at the *front* of the file is the whole reason this is affordable on a
|
|
|
|
|
|
television. Read the first few kilobytes and every frame's byte range is known, so one
|
|
|
|
|
|
thumbnail costs a ranged request of about seven kilobytes rather than a download nobody
|
|
|
|
|
|
would wait through mid-seek. Things to preserve:
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
- **Trust the 206, not the headers.** Emby 4.10.0.21 answers ranges on that route properly
|
|
|
|
|
|
and says so — a 206 with `Accept-Ranges: bytes` and a `Content-Range` naming the BIF's own
|
|
|
|
|
|
length — but earlier builds were reported to serve the range while advertising
|
|
|
|
|
|
`Accept-Ranges: none` and a `Content-Length` borrowed from the media file. So both
|
|
|
|
|
|
`emby.TrickplayBytes` and `TrickplayClient` cap the read at what was asked for regardless,
|
|
|
|
|
|
because being wrong about that must not turn a press of Right into a five-megabyte
|
|
|
|
|
|
download.
|
2026-08-07 10:44:17 +12:00
|
|
|
|
- **A zero-frame BIF is an answer, not a fault.** Emby returns a perfectly well-formed
|
|
|
|
|
|
72-byte file for a title whose thumbnails it has not generated, and for a width it does
|
|
|
|
|
|
not hold — which is why `trickplayWidth` is not a free parameter and the client's
|
|
|
|
|
|
`TRICKPLAY_WIDTH` must match it. The gateway caches that no for
|
|
|
|
|
|
`trickplayMissingTTL` (shorter than the index's day, since thumbnails are generated on a
|
|
|
|
|
|
schedule) or every press on such a title is a fresh round trip for the same answer.
|
|
|
|
|
|
- **The parsing exists twice**, in `data/Trickplay.kt` and `server/internal/trickplay`,
|
|
|
|
|
|
pinned by deliberately parallel tests (`TrickplayTest`, `bif_test.go`) — the usual reason:
|
|
|
|
|
|
with no gateway there is nobody to ask. What differs between the paths is *where the
|
|
|
|
|
|
reading happens*, not what is read. In gateway mode the television holds no Emby
|
|
|
|
|
|
credential, so the gateway reads the file and serves a frame at a time from
|
|
|
|
|
|
`/v1/items/{id}/trickplay/{n}.jpg`; on the direct path the TV range-reads Emby's file
|
|
|
|
|
|
itself. `Trickplay.bif` being null is what distinguishes them.
|
|
|
|
|
|
- **The manifest is its own request, deliberately not a field on `/v1/items/{id}/playback`.**
|
|
|
|
|
|
Reading the index costs the gateway a round trip to Emby, and that response is the one
|
|
|
|
|
|
thing standing between a Play press and a decoder starting. Only the boolean
|
|
|
|
|
|
`trickplayAvailable` rides there — the `subtitleDownloadAvailable` precedent — so an older
|
|
|
|
|
|
or deliberately-configured-off gateway is never asked. It is fetched from
|
|
|
|
|
|
`startPlaybackSession`, beside `loadCast()`, for the same reason that one is.
|
|
|
|
|
|
- **Nothing about it may be on the path of a press.** The chip has always said where the
|
|
|
|
|
|
skip lands and still says it with no thumbnail: a title with no previews, a server that
|
|
|
|
|
|
will not answer and the moment before the first frame arrives are all the same wordless
|
|
|
|
|
|
chip, which is what `SeekIndicatorScreenshotTest`'s empty case exists to hold. Every
|
|
|
|
|
|
failure is silent, and `handleTrickplay` answers trouble with "no previews" rather than an
|
|
|
|
|
|
error nobody could act on and everybody would log once per press.
|
|
|
|
|
|
- **Cancelling the in-flight frame is load-bearing**, the same property `collectLatest` gives
|
|
|
|
|
|
search: presses arrive faster than a fetch completes, and without it a slow response for a
|
|
|
|
|
|
frame already skipped past lands on screen after the one being waited for.
|
2026-08-08 16:56:18 +12:00
|
|
|
|
- **Scrubbing the transport shows them too**, not only the Left/Right chip. Those are the two
|
|
|
|
|
|
ways to move through a film, and previews that stopped the moment somebody pressed a button
|
|
|
|
|
|
to look at the controls read as the feature having broken rather than as a deliberate line.
|
|
|
|
|
|
With the transport up the presses are the time bar's — `seekControlsActive` stands down —
|
|
|
|
|
|
so `bindScrubPreview` hangs a `TimeBar.OnScrubListener` off `exo_progress` and draws into
|
|
|
|
|
|
`player_scrub_preview`, a strip above the bar in the *controller* layout, which is what
|
|
|
|
|
|
makes it disappear with the controls and need no visibility of its own. Things to preserve:
|
|
|
|
|
|
it is the same `TrickplayPreview` instance as the chip, so one layout is fetched per title
|
|
|
|
|
|
and one cache of frames serves both; it carries no wording, because the transport is already
|
|
|
|
|
|
printing the position a caption would repeat; its horizontal place is computed from the
|
|
|
|
|
|
scrubber in window coordinates and clamped to the bar, since a thumbnail parked mid-screen
|
|
|
|
|
|
while the scrubber is at the far end is a picture of some other moment, and one pushed past
|
|
|
|
|
|
the end is one overscan cuts; and it goes down on `onScrubStop` rather than on a timer, so
|
|
|
|
|
|
it can never outlive the scrub that raised it.
|
2026-08-07 10:44:17 +12:00
|
|
|
|
- **The cache holds JPEG bytes, not bitmaps**, and is the player's own rather than Coil's.
|
|
|
|
|
|
Decoded, forty frames would be most of a megabyte; as bytes they are a couple of hundred
|
|
|
|
|
|
kilobytes, and decoding one costs a millisecond off the main thread. Keeping them out of
|
|
|
|
|
|
Coil matters too — a burst of presses walks through dozens, and letting that churn through
|
|
|
|
|
|
the artwork cache would evict the backdrops the launcher is about to want back.
|
|
|
|
|
|
|
|
|
|
|
|
**Skipping the opening titles is Emby's own answer, not a detector.** Emby finds intros
|
|
|
|
|
|
itself and writes them into an episode's chapter list as two markers, `IntroStart` and
|
|
|
|
|
|
`IntroEnd`, interleaved with the ordinary chapters in playback order — so there is nothing
|
|
|
|
|
|
to detect on either end and nothing to store: reading them is one `Fields=Chapters` lookup.
|
|
|
|
|
|
`introFromChapters` (`server/internal/api/intro.go`) and `introSegmentFrom`
|
|
|
|
|
|
(`data/Intro.kt`) are the pure rule, pinned by deliberately parallel tests (`intro_test.go`,
|
|
|
|
|
|
`IntroTest`) for the usual reason — with no gateway there is nobody to ask, and a skip must
|
|
|
|
|
|
not land somewhere different depending on whether the container is up. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **Most of the rule is about refusing to answer.** Half a pair, a pair out of order, a
|
|
|
|
|
|
segment under 5 s or over 5 min all produce nothing, and nothing is a good answer: the
|
|
|
|
|
|
player simply never offers the button. A wrong skip costs somebody the opening of a scene,
|
|
|
|
|
|
which is far worse than not being offered one. The first `IntroStart` wins — two starts
|
|
|
|
|
|
mean the markers are already untrustworthy, and the later one is the larger, more damaging
|
|
|
|
|
|
skip.
|
|
|
|
|
|
- **The segment is its own request** (`/v1/items/{id}/intro`), the `trickplay` precedent:
|
|
|
|
|
|
reading it costs a round trip to Emby and the playback response is the one thing standing
|
|
|
|
|
|
between a Play press and a decoder starting. Only the boolean `skipIntroAvailable` rides
|
|
|
|
|
|
there, and the client default is **false**. It is fetched from `startPlaybackSession`
|
|
|
|
|
|
beside `loadCast()`, which is safe because the earliest intro in a typical library starts
|
|
|
|
|
|
a couple of minutes in. "No intro" is cached (server and client) — most of a library has
|
|
|
|
|
|
no markers, and without it the same no would be fetched on every playback.
|
|
|
|
|
|
- **`skipIntroMode` is a synced per-profile setting** (`prompt` / `auto` / `off`), with the
|
|
|
|
|
|
vocabulary duplicated in `data/SkipIntroPreference.kt` and the gateway's catalogue like
|
|
|
|
|
|
the seek interval's. It normalises to **`prompt`**, never `auto`: costing a set its button
|
|
|
|
|
|
because it cannot read a value is recoverable, jumping through somebody's episode on a
|
|
|
|
|
|
string this build cannot parse is not.
|
|
|
|
|
|
- **The ring counts the offer, not the title sequence.** `SkipIntroCountdownView` draws a
|
|
|
|
|
|
draining arc with the figure inside it, advanced from the playhead by
|
|
|
|
|
|
`updateSkipIntroCountdown` — so pausing during the titles holds it and seeking moves it,
|
|
|
|
|
|
neither of which a wall-clock timer could do. It runs from where the button appears to
|
|
|
|
|
|
where it goes, `SKIP_INTRO_TAIL_MS` short of the end of the intro. The two are seconds
|
|
|
|
|
|
apart and only one can be drawn honestly: a ring measuring the whole sequence would stop
|
|
|
|
|
|
with a sliver left and vanish mid-sweep, which reads as a broken countdown rather than as
|
|
|
|
|
|
a lapsed offer. Three things to preserve — the view takes its colours from its own
|
|
|
|
|
|
drawable state and the layout feeds it `duplicateParentState`, because the pill inverts to
|
|
|
|
|
|
white on focus and a ring that did not follow would draw white on white; it refuses to
|
|
|
|
|
|
redraw for movement under a degree, which over a two-minute opening is most of the ticks;
|
|
|
|
|
|
and `formatRemaining` switches to `1:58` over a minute with the text sized from the
|
|
|
|
|
|
string's length, since a title sequence is commonly long enough to be counted in minutes
|
|
|
|
|
|
and "118" would print over its own arc.
|
|
|
|
|
|
- **The button takes focus and the notice does not.** A remote has no other way to say
|
|
|
|
|
|
"press this", so it is focusable and `centrePausesPlayback` stands down while it is up —
|
|
|
|
|
|
otherwise the one button on screen is unpressable. It never appears over the transport,
|
|
|
|
|
|
the drop-up, the cast panel or the next-up banner, which already own the remote. An
|
|
|
|
|
|
automatic skip instead swaps the same view into "Intro skipped" wearing the timing cues'
|
|
|
|
|
|
quiet plate (`dressSkipIntro`), because a picture that jumps for no visible reason reads
|
|
|
|
|
|
as the stream glitching, and a notice that still looks like a button gets pressed. The
|
|
|
|
|
|
ring goes with it rather than freezing at zero: there is nothing left to press and nothing
|
|
|
|
|
|
left to run out.
|
|
|
|
|
|
- **`skipIntroTaken` is never re-armed within an episode**, unlike `skipIntroDismissed`.
|
|
|
|
|
|
Rewinding to before the titles offers the button again — somebody who went back there did
|
|
|
|
|
|
it on purpose — but in automatic mode re-arming would drag them forward again the moment
|
|
|
|
|
|
they reached the opening they had just returned for.
|
|
|
|
|
|
- **The seek goes through `seekBuffering`**, the same door a press of Right uses, so the
|
|
|
|
|
|
couple of seconds it takes to decode at the new position is treated as a skip landing
|
|
|
|
|
|
rather than as a film that has stopped.
|
|
|
|
|
|
- `SkipIntroScreenshotTest` renders it over a deliberately *bright* fake scene →
|
|
|
|
|
|
`build/screenshots/skip-intro/`. There is no scrim under this button, so a capture over
|
|
|
|
|
|
black would prove nothing.
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
**The closing credits are read out of the same chapter list as the intro.** `markersFor` in
|
|
|
|
|
|
`server/internal/api/intro.go` reads one `Fields=Chapters` response and answers for both
|
|
|
|
|
|
features, and `EmbyRepository.chapterMarkers` caches one reading that `introSegment` and
|
|
|
|
|
|
`creditsStartMs` both read. That is **the whole reason this is affordable** — adding a second
|
|
|
|
|
|
lookup anywhere in that path gives away the only performance claim the feature has. From the
|
|
|
|
|
|
result, `PlayerActivity` scales the picture into the left half, ramps it to 2× and puts what
|
|
|
|
|
|
is on next in the right half.
|
|
|
|
|
|
|
|
|
|
|
|
**Two sources, and the one it was built on does not exist.** `CreditsStart` is in Emby's
|
|
|
|
|
|
`MarkerType` enumeration, which is what this was originally written against — but a survey of
|
|
|
|
|
|
the 20,000-item library it ships to found `Chapter`, `IntroStart` and `IntroEnd` and **no
|
|
|
|
|
|
`CreditsStart` at all** on Emby 4.10. The enum having a value is not the detector populating
|
|
|
|
|
|
it. What that survey did find was 216 items carrying a chapter *named* like credits, clustered
|
|
|
|
|
|
at 90–98% of runtime and consistent within a show, and that is where every bit of the
|
|
|
|
|
|
feature's coverage comes from today (roughly 5% of items, but effectively all episodes of the
|
|
|
|
|
|
shows that have it). The marker is still read first, so the day a version writes one this
|
|
|
|
|
|
needs no change. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **The position floor is the load-bearing guard**
|
|
|
|
|
|
(`creditsMinimumPositionFraction` / `CREDITS_MINIMUM_POSITION_FRACTION`, 0.75). Chapter
|
|
|
|
|
|
names are not a vocabulary anybody agreed on, and real media carries **"Opening Credits"** —
|
|
|
|
|
|
Belfast at 1% of runtime, Game of Thrones at 0%. A name match without a position test starts
|
|
|
|
|
|
the pane in the *first minute* of a film and runs its opening at double speed, which is the
|
|
|
|
|
|
worst thing this feature could do. Three quarters is deliberately far below the evidence
|
|
|
|
|
|
rather than near it: every genuine roll in that survey began at 90% or later. The name
|
|
|
|
|
|
exclusions beside it are belt-and-braces — a position test catches wordings nobody thought
|
|
|
|
|
|
of, a word list only catches the listed ones.
|
|
|
|
|
|
- **The rule exists twice** — `creditsFromChapters` (Go) and `creditsStartFrom`
|
|
|
|
|
|
(`data/Credits.kt`) — pinned by deliberately parallel tests carrying the real library's
|
|
|
|
|
|
cases, the usual reason: with no gateway there is nobody to ask.
|
|
|
|
|
|
- **The two sources resolve in opposite directions, and that is not an inconsistency.** For a
|
|
|
|
|
|
*marker*, the last wins (as the intro rule takes the first): two starts mean the markers are
|
|
|
|
|
|
untrustworthy, so each rule picks whichever risks least, and the two features are damaged in
|
|
|
|
|
|
opposite directions — an intro skip firing late throws somebody past the story, while the
|
|
|
|
|
|
credits pane firing early runs the last scene past them at double speed. For a *name*, the
|
|
|
|
|
|
earliest qualifying chapter wins: several credits-named chapters are ordinary rather than
|
|
|
|
|
|
suspicious ("The Pitt" carries both "Credits" and "End Credits") and they describe one roll,
|
|
|
|
|
|
which begins at the first of them.
|
|
|
|
|
|
- **A marker below the floor gives way to a name; with no runtime at all, the marker is
|
|
|
|
|
|
honoured and the name is not.** An explicit marker is Emby asserting a position, so it gets
|
|
|
|
|
|
the benefit of the doubt on wording — but not on position, and never enough to skip the one
|
|
|
|
|
|
test that separates an opening sequence from a closing one.
|
|
|
|
|
|
- **`RunTimeTicks` rides the chapter lookup on both paths** for exactly that test. It is a
|
|
|
|
|
|
default field on the response, so it costs nothing; do not drop it to tidy the query.
|
|
|
|
|
|
- **The duration guard is a second, separate refusal.** `creditsWorthShowing` asks whether
|
|
|
|
|
|
enough of the roll is left to be worth moving the picture for (`CREDITS_MINIMUM_TAIL_MS`),
|
|
|
|
|
|
and lives client-side because the player has the decoder's exact duration.
|
|
|
|
|
|
- **2× is a ceiling that falls, never a speed that is defended.** Doubling the speed doubles
|
|
|
|
|
|
the bitrate pulled from Emby over HTTP, and a high-bitrate file on a remote server may not
|
|
|
|
|
|
sustain it. `STATE_BUFFERING` calls `stepDownCreditsSpeed`, `creditsCeilingAfterStall` only
|
|
|
|
|
|
ever goes down, and it is never re-armed inside one roll — a marginal stream that could
|
|
|
|
|
|
climb back would oscillate between stuttering and recovering for the length of the credits.
|
|
|
|
|
|
Reaching 1× leaves the pane up: what is on next is still worth showing, and only the
|
|
|
|
|
|
speeding up failed.
|
|
|
|
|
|
- **It is the next-up banner's replacement, not a second thing beside it.** Both shrink the
|
|
|
|
|
|
same picture and both say what is on next, so `updateNextUpFromPlayhead` returns early
|
|
|
|
|
|
while the pane is up and the countdown moves into it — that countdown only appears inside
|
|
|
|
|
|
the last minute, where it was always the banner's job. The pane also rides the banner's
|
|
|
|
|
|
250 ms tick and its `nextEpisode` guard, which is exactly the right gate: a film and the
|
|
|
|
|
|
last episode of a season both correctly get nothing. It therefore inherits auto-play's
|
|
|
|
|
|
switch, since nothing resolves a next episode when that is off — deliberate, because this
|
|
|
|
|
|
*is* the auto-advance experience.
|
|
|
|
|
|
- **The transform is a scale, never a reparent.** The pre-roll moves the `PlayerView` between
|
|
|
|
|
|
parents; doing that mid-playback tears the SurfaceView down and flashes black over
|
|
|
|
|
|
somebody's credits. `CREDITS_VIDEO_SCALE`/`CREDITS_VIDEO_SHIFT_X` are public so
|
|
|
|
|
|
`EndCreditsScreenshotTest` can place its stand-in picture at exactly the transform the
|
|
|
|
|
|
activity applies — a capture that guessed the split would prove nothing about whether the
|
|
|
|
|
|
two halves balance, which is the only thing worth looking at. Neither is a round number:
|
|
|
|
|
|
0.5 and 0.25 put the picture's left edge at exactly x=0, which is the first thing overscan
|
|
|
|
|
|
cuts.
|
|
|
|
|
|
- **`speedUpCredits` is a synced per-profile toggle**, defaulting to **on** — a different
|
|
|
|
|
|
trade from `skipIntroMode`'s, which defaults to the button rather than the automatic seek.
|
|
|
|
|
|
This one is visible, reversible and over in a minute, so somebody who dislikes it turns it
|
|
|
|
|
|
off having watched exactly what it does. `maskMarkers` withholds the half of a reading whose
|
|
|
|
|
|
feature an operator has turned off, on the way *out* rather than on the way in, so the cache
|
|
|
|
|
|
keeps the truth and a feature switched back on takes effect on the next playback.
|
|
|
|
|
|
- Screenshots are `EndCreditsScreenshotTest` → `build/screenshots/end-credits/`, over a
|
|
|
|
|
|
deliberately bright frame: there is no scrim between the credits and the panel.
|
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
|
**Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
`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.
|
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-10 20:24:22 +12:00
|
|
|
|
**R8 shrinks Kotlin; nothing shrinks a `.so`.** The APK is dominated by whatever native
|
|
|
|
|
|
code it carries, and native code is packaged **uncompressed** here (`minSdk` 23 means
|
|
|
|
|
|
`extractNativeLibs=false`), once per ABI. `io.github.abdallahmehiz:mpv-android-lib` was
|
|
|
|
|
|
added in v0.2.40 as a last-resort software video fallback and took the release APK from
|
|
|
|
|
|
3.1MB to **168MB**: a whole FFmpeg, `libc++_shared` and a 6MB subtitle font, times four
|
|
|
|
|
|
ABIs, for a path almost nobody ever reaches. It has been removed, along with
|
|
|
|
|
|
`MpvFallbackActivity`, `shouldUseLibmpvFallback` and the `is.xyz.mpv` keep rule. Two things
|
|
|
|
|
|
came out of it and both are cheap to keep:
|
|
|
|
|
|
|
|
|
|
|
|
- **`defaultConfig.ndk.abiFilters` is `arm64-v8a` + `armeabi-v7a`.** Android TV is ARM; the
|
|
|
|
|
|
x86 slices only ever served the emulator, which is not how this app is tested.
|
|
|
|
|
|
- **A new native dependency is a size decision, not a dependency decision.** Check what it
|
|
|
|
|
|
weighs across both ABIs before adding it — the Jellyfin FFmpeg *audio* decoder that
|
|
|
|
|
|
actually delivers DTS is 1.5MB per ABI because it links only the decoders it needs, which
|
|
|
|
|
|
is the shape to look for. Anything on mpv's scale belongs behind a separately downloaded
|
|
|
|
|
|
split, not in the base APK that every television sideloads on every update.
|
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
|
**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
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
|
|
## 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.
|
2026-07-29 15:26:27 +12:00
|
|
|
|
|
2026-08-11 12:08:51 +12:00
|
|
|
|
**A keyed lazy list must never be handed a repeated key.** `LazyRow`/`LazyColumn` throw on
|
|
|
|
|
|
one — *"Key … was already used"* — and every list on these screens is keyed by an id that
|
|
|
|
|
|
came off a wire, where nothing promises distinctness. Emby lists the same person twice on a
|
|
|
|
|
|
good fraction of a real cast; a "Because you watched" row built from two seeds can reach one
|
|
|
|
|
|
title by both; a search that falls back to Emby before the import finishes can return what
|
|
|
|
|
|
the library also matched; and a paging boundary is where a backend repeats a card by
|
|
|
|
|
|
definition. `ui/ListKeys.kt` is the one rule: **deduplicate, never disambiguate.** Folding
|
|
|
|
|
|
the index into the key would also stop the crash, but it keys an item by *where it is*, and
|
|
|
|
|
|
position is exactly what changes when a row reorders — which the return-focus and
|
|
|
|
|
|
scroll-restoration behaviour throughout this app depends on identity to survive. Apply it
|
|
|
|
|
|
where the data enters state (`HomeViewModel.sanitisedRows`, `EmbyRepository.loadRelated` and
|
|
|
|
|
|
`loadSeriesEpisodes`, the two genre pagers, `SearchViewModel.runSearch`) rather than in a
|
|
|
|
|
|
composable; where a composable is the only place, keep it inside a `remember(list)`. And
|
|
|
|
|
|
where a pager deduplicates, **how far it has read is counted in what the backend sent**, not
|
|
|
|
|
|
in the length of the list (`SearchUiState.genreOffset`, `GenreBrowseUiState.readOffset`) — a
|
|
|
|
|
|
dropped duplicate would otherwise make the next offset point before the end of the last
|
|
|
|
|
|
page, and the shelf would stop growing while re-requesting the same page for ever.
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
|
**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`/
|
2026-08-06 22:33:56 +12:00
|
|
|
|
`MembyQuietText`, `MembyRatingsSurface` behind the ratings strip, three corner radii
|
2026-08-02 22:10:19 +12:00
|
|
|
|
(`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.
|
|
|
|
|
|
|
2026-08-10 09:39:09 +12:00
|
|
|
|
**The eight slots a theme sends are not the vocabulary the screens paint with**, which is
|
|
|
|
|
|
why picking a colour scheme used to change almost nothing outside the detail pages. The
|
|
|
|
|
|
launcher, the cold-start screen, Settings, Search, the update and maintenance screens and
|
|
|
|
|
|
the two overlays were drawn with a hundred-odd literal hexes — a lighter green for a label,
|
|
|
|
|
|
a near-black ink for text on a green fill, three neutral steps for controls — none of which
|
|
|
|
|
|
the palette could reach. Those shades are now *derived* in `DesignTokens.kt`
|
|
|
|
|
|
(`MembyAccentBright`, `MembyAccentInk`, `MembyAccentMuted`, `MembyControlSurface`,
|
|
|
|
|
|
`MembyControlSurfaceRaised`, `MembyOutline`, `MembyDisabledText`, `MembySplashTint`).
|
|
|
|
|
|
Derived rather than added to the wire on purpose: a theme sends the *decisions* and the app
|
|
|
|
|
|
works out the shades around them, so a scheme invented on the gateway tomorrow arrives
|
|
|
|
|
|
complete rather than half-applied — the same reason the palette carries no radii. What
|
|
|
|
|
|
stays a literal is anything carrying meaning of its own: the red/amber/blue status colours,
|
|
|
|
|
|
the genre tiles, and the ratings providers' own brand colours. And a screen-local alias must
|
|
|
|
|
|
be `get()`, never `val` — `SettingsSheet`'s `Canvas`/`Panel`/`TextPrimary` were values, so
|
|
|
|
|
|
the settings page, which is where the theme is *chosen*, was the one screen that could never
|
|
|
|
|
|
repaint.
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
**Those colours are the server's answer, not constants.** Every token in `DesignTokens.kt`
|
|
|
|
|
|
is now a `get()` over one process-wide `mutableStateOf(MembyPalette)`, and `applyMembyPalette`
|
|
|
|
|
|
is what repaints the app. Two things follow and both are easy to undo: an alias must be a
|
|
|
|
|
|
getter too (`HomeComponents`' `EmbyGreen`, `DetailPageComponents`' `Detail*`, the settings
|
|
|
|
|
|
sheet's own `EmbyGreen`), because a `val` captures whichever theme was loaded when its class
|
|
|
|
|
|
initialised and never changes again; and `MembyTheme` builds its `darkColorScheme` per
|
|
|
|
|
|
composition rather than holding one, which is how a theme change reaches every component
|
|
|
|
|
|
that never names a colour. Shape and punctuation are deliberately *not* themeable — a
|
|
|
|
|
|
palette that could move a corner radius could make a layout wrong from the server, and the
|
|
|
|
|
|
whole safety of this feature is that the worst a bad theme does is look bad.
|
|
|
|
|
|
|
2026-08-10 12:23:23 +12:00
|
|
|
|
**The colour-scheme picker is currently withheld from Settings** —
|
|
|
|
|
|
`THEME_PICKER_ENABLED` in `ui/settings/SettingsSheet.kt`, one `const val` to put back.
|
|
|
|
|
|
Choosing a scheme does not reliably repaint the app, and a control that appears to do
|
|
|
|
|
|
nothing is read as a fault in the television rather than as an unfinished feature.
|
|
|
|
|
|
Everything below is otherwise untouched: seasonal themes still arrive and still apply, the
|
|
|
|
|
|
synced `themeId` preference is still carried, and the palette plumbing is unchanged. This
|
|
|
|
|
|
hides the question, not the answer.
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
**Themes** are `server/internal/api/themes.go`, and there are two kinds. A **selectable**
|
|
|
|
|
|
theme is the viewer's own choice, held as the ordinary synced preference `themeId` and
|
|
|
|
|
|
picked in Settings → Appearance. A **seasonal** theme (Halloween, Christmas, Easter) is not
|
|
|
|
|
|
a choice at all: it is in force for its dates and nothing on the television can decline it,
|
|
|
|
|
|
because a per-person opt-out is a thing somebody turns off in October and never reconsiders,
|
|
|
|
|
|
which is the same as the feature not existing. The only switch is the operator's
|
|
|
|
|
|
`seasonal_themes` feature flag, for the whole house. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **`resolveTheme` is the whole rule and it is pure**: a season outranks the viewer, the
|
|
|
|
|
|
viewer outranks the default, and the operator's per-user allowlist narrows the *choice* but
|
|
|
|
|
|
never a season. There is no argument a television can send that suppresses one, which is
|
|
|
|
|
|
what "cannot be controlled by the user" means in code. The viewer's own pick is still
|
|
|
|
|
|
reported as `chosen` underneath a season, or the picker would show nothing selected for a
|
|
|
|
|
|
fortnight and read as having forgotten it.
|
|
|
|
|
|
- **The two windows resolve their edges deliberately.** Halloween opens on 25 October (a
|
|
|
|
|
|
theme nobody sees until the evening of the 31st is one nobody sees) and Christmas closes on
|
|
|
|
|
|
Boxing Day (the tree is down; red-and-green on the 30th reads as a server nobody maintains).
|
|
|
|
|
|
Easter is the anonymous Gregorian computus in `easterSunday` — computed, because a
|
|
|
|
|
|
hard-coded table is a feature with an expiry date on it — over Good Friday to Easter Monday.
|
|
|
|
|
|
`seasonalThemeFor` takes the time rather than reading the clock, so every edge is tested.
|
|
|
|
|
|
- **The revision is a hash of the resolved theme, not a counter.** There is no write to
|
|
|
|
|
|
attach a counter to: nobody writes anything at midnight on 1 December, the answer simply
|
|
|
|
|
|
becomes different. `/v1/status` carries `theme: {id, revision, locked, seasonal}` — the
|
|
|
|
|
|
`preferencesRevision` precedent — and the TV fetches `/v1/theme` only when it moves. It
|
|
|
|
|
|
rides the poll rather than the sign-in because that *is* the feature: a season has to reach
|
|
|
|
|
|
a set that is already switched on.
|
|
|
|
|
|
- **The allowlist is `user_themes`, and absence is permissive.** No row exists for anybody
|
|
|
|
|
|
until an operator restricts somebody, so an empty list means "unrestricted"; reading it the
|
|
|
|
|
|
other way would empty every picker in the house on the day it shipped.
|
|
|
|
|
|
`normalizeThemeAllowlist` stores "every box ticked" as the empty list for the same reason —
|
|
|
|
|
|
they are the same decision — and drops seasonal ids, which are not grantable per person.
|
|
|
|
|
|
It is a separate table from `user_preferences` because that document is the viewer's own
|
|
|
|
|
|
choices and every television they own writes it; this is policy *about* them and only the
|
|
|
|
|
|
console writes it.
|
|
|
|
|
|
- **The available list is per viewer and comes from the server.** `/v1/theme` sends only the
|
|
|
|
|
|
themes that person may pick, so a withheld scheme is a row the television was never sent
|
|
|
|
|
|
rather than a greyed one — the client has no catalogue of its own to fall back to, and the
|
|
|
|
|
|
picker is simply not drawn when fewer than two arrive.
|
|
|
|
|
|
- **Nothing repaints from the local choice.** `setThemeId` writes the preference and stops;
|
|
|
|
|
|
the palette arrives through `ThemeSync` when the gateway has resolved it. That is what makes
|
|
|
|
|
|
a choice a season covers, or one the operator has since withdrawn, visibly not take effect
|
|
|
|
|
|
rather than take effect and be yanked back a second later.
|
|
|
|
|
|
- **`ThemeSync` paints from the cached palette before any request**, the promise `HomeCache`
|
|
|
|
|
|
makes about the rows, and clears the cache on a profile switch (a different person, a
|
|
|
|
|
|
different scheme). The palette cache is device state; only `themeId` syncs.
|
|
|
|
|
|
- **There is no second copy of the rule on the direct path**, unlike subtitles, intros and
|
|
|
|
|
|
Continue Watching. Those exist twice because the direct path would otherwise *behave*
|
|
|
|
|
|
differently; here it behaves as it always did — the default palette. A television deciding
|
|
|
|
|
|
from its own clock that it is Halloween, while the household's gateway has seasons switched
|
|
|
|
|
|
off, would be the feature failing rather than degrading. `data/Themes.kt` is only hex
|
|
|
|
|
|
parsing, and it refuses anything it cannot read so the app's own token stands in.
|
|
|
|
|
|
**Seasonal decorations** are `ui/seasonal/SeasonalDecorations.kt`: snow, bats or blossom
|
|
|
|
|
|
drifting over the launcher for the few days a season is on. A palette on its own is a thin
|
|
|
|
|
|
idea of Christmas — the colours change and nothing says why — and this is the half that
|
|
|
|
|
|
does. It is also the most expensive thing in the app, the only animation that runs
|
|
|
|
|
|
continuously while somebody is merely browsing, so:
|
|
|
|
|
|
|
|
|
|
|
|
- **Nothing about it recomposes.** One `Canvas`, one animated `State<Float>` never read in a
|
|
|
|
|
|
composable body, every position derived arithmetically inside the draw lambda. A full field
|
|
|
|
|
|
costs zero recompositions and one draw pass. The palette colours *are* read in composition,
|
|
|
|
|
|
deliberately, so a theme change repaints this node.
|
|
|
|
|
|
- **A particle is `index` and `progress` and nothing else** — no array, no per-particle state
|
|
|
|
|
|
to allocate or re-seed. `drawSeasonalField` is therefore a pure function of one number,
|
|
|
|
|
|
which is what lets a screenshot capture an exact frame with no animation clock.
|
|
|
|
|
|
- **Every cycle count is a whole number**, so at the instant the driving value rolls 1 → 0
|
|
|
|
|
|
the entire field is exactly where it was. Without that it visibly jumps once a minute.
|
|
|
|
|
|
`decoration-snow-0.png` and `decoration-snow-100.png` must be identical; that is what they
|
|
|
|
|
|
are for.
|
|
|
|
|
|
- **Placement is stratified, not hashed.** Each particle owns a slice of the axis and the
|
|
|
|
|
|
hash only jitters within it. Twenty-six samples is far too few for a hash to look evenly
|
|
|
|
|
|
spread — the first version put visible bands and a bare patch through the middle, and the
|
|
|
|
|
|
eye finds a clump in a snowfield instantly.
|
|
|
|
|
|
- **The slug comes from the gateway** (`decoration` on the theme), never derived from the
|
|
|
|
|
|
theme id: an operator turning `seasonal_decorations` off sends an empty one, and a set
|
|
|
|
|
|
holding a cached Christmas palette must stop snowing. It is a *second* switch from
|
|
|
|
|
|
`seasonal_themes` because the palette and the animation have quite different costs on a
|
|
|
|
|
|
weak box. An unknown slug draws nothing.
|
|
|
|
|
|
- **Launcher only.** Not over playback — a film is the one thing nothing may drift across —
|
|
|
|
|
|
and not over the settings sheet. It is a sibling of `HomeScreen` rather than inside it, so
|
|
|
|
|
|
an arriving theme cannot invalidate the rows.
|
|
|
|
|
|
- **The platform's "remove animations" setting is honoured.** A season is deliberately not
|
|
|
|
|
|
the viewer's to decline, but an accessibility choice is not a preference.
|
|
|
|
|
|
- **The alpha was tuned by looking, not reasoned about.** The layer sits over an opaque
|
|
|
|
|
|
surface, so a flake occasionally lands on the Play button; at this value that reads as snow
|
|
|
|
|
|
in front of the screen and a few points higher it reads as a rendering fault.
|
|
|
|
|
|
`decoration-over-content.png` exists for exactly that judgement.
|
|
|
|
|
|
|
|
|
|
|
|
- **Screenshots are the only real test this feature has.** A unit test can check that a hex
|
|
|
|
|
|
string parses; it cannot check whether Easter's pale accent is legible on its own
|
|
|
|
|
|
near-black or whether Forest still has a hairline. `ThemeScreenshotTest` renders one series
|
|
|
|
|
|
page under every palette → `build/screenshots/themes/`, deliberately one screen across nine
|
|
|
|
|
|
themes rather than nine screens on one, so the images differ in nothing but colour. It
|
|
|
|
|
|
composes once and repaints by assigning the palette, which is both the only thing
|
|
|
|
|
|
`setContent` allows and the more honest picture — that is exactly what a set already
|
|
|
|
|
|
showing the page does when a season begins. The palettes are a *fixture* copied from the
|
|
|
|
|
|
catalogue, not a second copy of the rule: nothing derives a colour from it. It caught the
|
|
|
|
|
|
chip row overflowing at six themes, which is why that row is a `FlowRow` where every other
|
|
|
|
|
|
choice row on the page is a `Row`, and it is why the blossom is a five-petal flower — a
|
|
|
|
|
|
single petal rendered as a grey seed, recognisable as *something falling* and nothing else.
|
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-11 12:08:51 +12:00
|
|
|
|
**Up out of the top of a page returns to the page list.** Nothing sits above a pane's first
|
|
|
|
|
|
control, so that press did nothing at all on every page — and a remote that stops responding
|
|
|
|
|
|
is not read as a list that has run out. About is where it was reported, its pane being a
|
|
|
|
|
|
changelog long enough that walking back up it is the ordinary way to leave. The escape is an
|
|
|
|
|
|
`onKeyEvent` on the content column that makes the move the default handler would have made
|
|
|
|
|
|
and falls back to the rail only when it fails, so a page's own vertical navigation is
|
|
|
|
|
|
untouched: `focusProperties { up = … }` is inherited by every row and would take that
|
|
|
|
|
|
navigation away, and `exit` is never consulted when the search finds nothing anywhere, which
|
|
|
|
|
|
is the whole case. The rail item for the page being *drawn* carries a second requester for
|
|
|
|
|
|
it — the selection rather than the highlight, so a press arriving mid-settle still lands on
|
|
|
|
|
|
the page that is on screen. `SettingsRailFocusTest` pins both halves, the escape and the
|
|
|
|
|
|
navigation between rows it must not disturb.
|
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
|
**One runtime formatter, one 4K threshold.** `detail/DetailFacts.kt` owns `formatRuntime`,
|
2026-08-06 22:33:56 +12:00
|
|
|
|
`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`.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
|
|
|
|
|
|
**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
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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
|
2026-08-07 10:44:17 +12:00
|
|
|
|
that broke the button — the featured card says why in a sentence instead (see below).
|
2026-08-06 22:33:56 +12:00
|
|
|
|
|
|
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-07 10:44:17 +12:00
|
|
|
|
**The hero is composed by the gateway** (`server/internal/api/hero.go`), because the three
|
|
|
|
|
|
things worth ranking it by are three things the television cannot see. **Radarr knows when a
|
|
|
|
|
|
film actually came out** — `digitalRelease` is the date the household could first have
|
|
|
|
|
|
watched it, where Emby's `PremiereDate` is the theatrical date when it is right at all and a
|
|
|
|
|
|
metadata agent's guess when it is not, so ranking "new releases" by it produced an order
|
|
|
|
|
|
with nothing to do with when anything became watchable. **Sonarr knows a premiere from an
|
|
|
|
|
|
ordinary episode**, so a new show or a returning season can lead where before a series could
|
|
|
|
|
|
only reach the hero as a random card off a shelf. And **the review scores are already
|
|
|
|
|
|
attached to the cards** by `decorateHomeRatings`, so a well-received release can outrank a
|
|
|
|
|
|
fresher one nobody liked at no cost. `rankHeroCandidates` is the pure rule
|
|
|
|
|
|
(`heroRecencyWeight`/`heroRatingWeight`, `hero_test.go`); `selectHomeHeroMovies`'s original
|
|
|
|
|
|
row-interleaving rule survives underneath as the **direct path's** hero and the fallback for
|
|
|
|
|
|
a gateway older than the feature, which is why `serverHeroPicks` is consulted first and
|
|
|
|
|
|
returns nothing rather than throwing. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **It asks Emby for nothing.** The movie candidates are the rows already assembled and
|
|
|
|
|
|
their ratings are already attached, so the expensive half of the launcher is reused
|
|
|
|
|
|
rather than repeated. The two *arr calendars it does read are cached for the day behind a
|
|
|
|
|
|
shared lock, like the schedule rows' — one household pays one miss each per day — and the
|
|
|
|
|
|
three lookups run concurrently, because this is the tail of a response every television
|
|
|
|
|
|
in the house is waiting on.
|
|
|
|
|
|
- **Every card it produces is playable.** A premiere the household has not downloaded, a
|
|
|
|
|
|
film Radarr is still waiting on, a synthetic schedule card — all are news for the schedule
|
|
|
|
|
|
row, and a lead card that does nothing when pressed is worse than no lead card at all.
|
|
|
|
|
|
`sonarrPremieres` requires `HasFile` *and* an Emby series id for exactly this.
|
|
|
|
|
|
- **A premiere is the first episode of a season**, S01E01 or S05E01 alike, and season 0 is
|
|
|
|
|
|
specials rather than a premiere. One card per series, the newest season winning, or a show
|
|
|
|
|
|
that premiered and returned inside one window appears twice.
|
|
|
|
|
|
- **An unrated title is not a bad title** (`heroUnratedScore`, deliberately mid-scale). On a
|
|
|
|
|
|
household that has not configured MDBList that is every title, and burying them would
|
|
|
|
|
|
empty the hero; `heroRatingOf` falls back to Emby's `CommunityRating`, which the client is
|
|
|
|
|
|
still forbidden from *drawing* — ordering four cards by a score claims nothing to anybody,
|
|
|
|
|
|
where printing it beside a provider's name that was never asked is a lie.
|
|
|
|
|
|
- **The captions and the reason are the gateway's wording** (`MembyHeroLabel`,
|
|
|
|
|
|
`MembyHeroReason`), the `MembyAirLabel` precedent, so a kind of hero card invented
|
|
|
|
|
|
tomorrow reads correctly on today's build. `labelTint` matches them as strings for the
|
|
|
|
|
|
same reason, and an unknown one gets the neutral wash rather than nothing.
|
|
|
|
|
|
- **A label is a claim that has been earned**, and `heroReason` returns empty rather than
|
|
|
|
|
|
inventing one — the captions this replaced were the card's *slot*, which is how a 2019
|
|
|
|
|
|
film came to be announced as new.
|
|
|
|
|
|
- **The row is consumed, never drawn.** `serverHomeRows` drops `kind == "hero"`; without
|
|
|
|
|
|
that the four featured titles print a second time as an unnamed row of posters directly
|
|
|
|
|
|
beneath the hero they are already in. `supportsHomeHero` gates it at 0.2.27 for the same
|
|
|
|
|
|
reason — an older television has no idea the kind is special. That floor is the version
|
|
|
|
|
|
the feature shipped *in* rather than one after it, so a 0.2.27 build predating it would
|
|
|
|
|
|
draw the duplicate row; moving the floor up is the fix if that ever bites.
|
2026-08-10 08:54:23 +12:00
|
|
|
|
- **The row is a draw from merit bands, re-made four times a day.** Ranking straight to the
|
|
|
|
|
|
row's length was the original design, on the reasoning that the facts behind it change
|
|
|
|
|
|
daily — and they do not: a digital release date does not move, a premiere aired when it
|
|
|
|
|
|
aired, a score settles within a week, so the same two cards led the launcher for five days
|
|
|
|
|
|
at a stretch. `rotateHeroCandidates` ranks a pool of `heroPoolLimit` instead, cuts it into
|
|
|
|
|
|
as many bands as there are cards to send, and draws one from each by
|
|
|
|
|
|
`heroVariationSeed(userID, heroRotationSlot(now, location))` — the `selectSeeds` shape, and
|
|
|
|
|
|
for the same reason it is not a shuffle: merit still decides which *band* a title is in, so
|
|
|
|
|
|
the best-reviewed release of the week can never land in the fourth slot, and variation only
|
|
|
|
|
|
picks between titles the scorer could not separate. The slot is the household's local part
|
|
|
|
|
|
of the day and carries the date, the seed is per viewer, and a pool with no spare
|
|
|
|
|
|
candidates goes out in merit order untouched. Home is cached for a minute and rebuilt
|
|
|
|
|
|
constantly behind it, which is why one slot must always yield the same draw.
|
|
|
|
|
|
The rotation further down belongs to the direct path, which has no merit to rank by.
|
2026-08-07 10:44:17 +12:00
|
|
|
|
|
|
|
|
|
|
**The reason sits above the ratings strip**, and that order is load-bearing. The featured
|
|
|
|
|
|
card's text column is what gives way when a title wraps onto two lines, so whatever is last
|
|
|
|
|
|
in it is cut — with the reason below the strip, the one line explaining why this card leads
|
|
|
|
|
|
the launcher was silently dropped on exactly the long-titled films most likely to be leading
|
|
|
|
|
|
it. The scores are also on the detail page the card opens; the reason is nowhere else. It
|
|
|
|
|
|
takes the synopsis's place rather than adding a line, so preferring it can only make the
|
|
|
|
|
|
card shorter, and there is still no eyebrow above the title.
|
|
|
|
|
|
|
|
|
|
|
|
**The direct path's hero changes daily, at local midnight.** `selectHomeHeroMovies(rows, day)`
|
|
|
|
|
|
takes a count of local days and rotates the starting point of each candidate list; `MainActivity`
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
|
|
|
|
|
|
**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.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
- **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.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
- 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.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
- **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.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
- `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.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**"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.
|
|
|
|
|
|
|
2026-08-11 12:08:51 +12:00
|
|
|
|
**The TV calendar is the schedule row's other shape.** The launcher's row answers "what is
|
|
|
|
|
|
on this week"; `GET /v1/calendar` (`server/internal/api/calendar.go` → `ui/calendar/`)
|
|
|
|
|
|
answers "what is on this month, and when does it come back", which is a question no shelf
|
|
|
|
|
|
has a form for — so it is a rail destination with a weekly TV guide: seven days in a rail
|
|
|
|
|
|
and the selected day's artwork-led programme list beside it. It reuses
|
|
|
|
|
|
`toSonarrScheduleItem`, so a calendar card and a
|
|
|
|
|
|
schedule card are the same card, with the same availability badges, lifecycle tag and Emby
|
|
|
|
|
|
series link; pressing one makes the same substitution the row does (`scheduleSeriesStub` +
|
|
|
|
|
|
`airingNoticeFor`), because an episode that has not aired has no page of its own. Things to
|
|
|
|
|
|
preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **The television does no calendar arithmetic.** The gateway sends `firstWeekday`,
|
|
|
|
|
|
`dayCount` and its own `today`; `calendarWeeks` lays out the month from those alone and
|
|
|
|
|
|
`calendarAgendaWeeks` only pages those cells seven at a time. A
|
|
|
|
|
|
set working out for itself which years are leap years, in its own zone rather than the
|
|
|
|
|
|
household's, would be a second calendar free to disagree with the days the episodes were
|
|
|
|
|
|
grouped into — which on the wrong side of midnight it would. The one thing the set does
|
|
|
|
|
|
read its own clock for is *when to ask again*: `CalendarViewModel` drops every cached
|
|
|
|
|
|
month once the device's local day changes, because everything else about a month is fixed
|
|
|
|
|
|
and only `today` goes stale. Being wrong about that by an hour costs one request, where
|
|
|
|
|
|
being wrong about the layout would draw a calendar that disagrees with itself.
|
|
|
|
|
|
- **A month is claimed by what was asked for, not by what arrives.** Cancelling a coroutine
|
|
|
|
|
|
already past its last suspension point does not stop it, and a held D-pad on a month arrow
|
|
|
|
|
|
is exactly how two requests come to be in flight — so a response is dropped unless it is
|
|
|
|
|
|
still the month `requestedMonth` names. The cache is bounded for the same reason: "held
|
|
|
|
|
|
for the life of the page" and "grows while somebody holds the D-pad" are otherwise the
|
|
|
|
|
|
same sentence, and a month is a list of episodes with artwork behind it.
|
|
|
|
|
|
- **Focus is selection**, the stance the detail page's tab strip takes. A remote has no
|
|
|
|
|
|
hover, and a calendar needing a press per day to say what is on it is one nobody reads. A
|
|
|
|
|
|
press moves *into* the day panel; Back steps out of the panel before leaving the page.
|
|
|
|
|
|
- **Month and week travel are explicit controls.** Left and Right inside the guide already
|
|
|
|
|
|
mean moving between the day rail and its programmes, so those keys cannot also change the
|
|
|
|
|
|
date range. An arrow at the end of either range is not drawn rather than drawn dead.
|
|
|
|
|
|
`calendarMonthRange` (12) is what stops a
|
|
|
|
|
|
held D-pad walking Sonarr into the 2050s one request at a time; `parseCalendarMonth` refuses
|
|
|
|
|
|
an out-of-range month rather than clamping, or the header would disagree with the grid.
|
|
|
|
|
|
- **The day rail summarises; the programme pane explains.** A day names its count and first
|
|
|
|
|
|
show only. The pane has the space for Sonarr fanart (or a graphical monogram fallback), an
|
|
|
|
|
|
Emby title logo when the series was matched, episode details, availability and a prominent
|
|
|
|
|
|
season-premiere/finale label. Finale wording comes from Sonarr's `finaleType`; an absent
|
|
|
|
|
|
value makes no claim. `CalendarScreenshotTest` renders a crowded day and the artwork-free
|
|
|
|
|
|
fallback because only a screenshot can check that hierarchy at television distance.
|
|
|
|
|
|
- **The rail entry is a server feature** (`tv_calendar`, capability `tv_calendar_v1`), because
|
|
|
|
|
|
a household running no Sonarr would otherwise carry a destination that only ever opens an
|
|
|
|
|
|
apology. A set standing on the page when it is switched off is moved to Home, or it is left
|
|
|
|
|
|
somewhere nothing can navigate back to.
|
|
|
|
|
|
- **A failed month is an empty month, not an error.** The page is informational, and somebody
|
|
|
|
|
|
who pressed Right past a Sonarr hiccup must be able to press Left back out of it.
|
|
|
|
|
|
- **There is no second implementation on the direct path.** Unlike subtitles or Continue
|
|
|
|
|
|
Watching, the answer is Sonarr's, which a television holds no credential for and Emby knows
|
|
|
|
|
|
nothing about — with no gateway there is genuinely no calendar, and the rail says so by
|
|
|
|
|
|
omitting the entry.
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**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
|
2026-07-29 15:26:27 +12:00
|
|
|
|
— 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
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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
|
2026-07-29 15:26:27 +12:00
|
|
|
|
tried first and discovers zero previews on AGP 8.13.2 — don't re-litigate it without
|
|
|
|
|
|
checking that upstream.
|