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
|
|
|
|
|
2026-08-12 13:08:53 +12:00
|
|
|
|
**The header is not the only thing Emby writes down.** The **device profile** sent with
|
|
|
|
|
|
`PlaybackInfo` is named in Emby's playback device list too, and it read `Memby Android TV`
|
|
|
|
|
|
on both paths — the product name, in the one place it must never be. It is now `MbyATV`
|
|
|
|
|
|
(`deviceProfileName` in `internal/emby/device_profile.go`, the literal in
|
|
|
|
|
|
`DeviceProfile.embyAndroidTv`), and like the header the two must agree or one television
|
|
|
|
|
|
playing both ways appears as two clients.
|
|
|
|
|
|
|
|
|
|
|
|
**And the gateway is not a television.** `Credentials.Gateway` marks a request the server
|
|
|
|
|
|
makes on its own behalf, and Emby records those under `MEMBY_GATEWAY_CLIENT_NAME`
|
|
|
|
|
|
(**`MbyGateway`**, `emby.DefaultGatewayClientName`) with a device name to match, so the
|
|
|
|
|
|
sync, the health probe, device cleanup and an operator signing into the admin console or
|
|
|
|
|
|
the web installer are separable from the sets in the house. Two things to preserve: the
|
|
|
|
|
|
flag is **stated, never inferred** from a missing token or version — an old APK reports
|
|
|
|
|
|
neither, and reading one as the server would file a television under the wrong name — and
|
|
|
|
|
|
a gateway request with no device name falls back to the gateway's name rather than to
|
|
|
|
|
|
`store.DefaultDeviceName`, the placeholder that made the server read as somebody's unnamed
|
|
|
|
|
|
TV.
|
|
|
|
|
|
|
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
|
2026-08-17 13:13:10 +12:00
|
|
|
|
.\deploy-server.ps1 -SkipAppRelease # server/admin-only; no APK is built
|
2026-07-29 15:26:27 +12:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-17 13:13:10 +12:00
|
|
|
|
preserved — it never runs `compose down -v`. For a server/admin-only change, pass
|
|
|
|
|
|
`-SkipAppRelease`: this skips the local APK build so only the gateway and admin console are
|
|
|
|
|
|
replaced.
|
2026-07-29 15:26:27 +12:00
|
|
|
|
|
|
|
|
|
|
**`.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.
|
|
|
|
|
|
|
2026-08-17 13:13:10 +12:00
|
|
|
|
**Releases.** APKs are self-hosted (NAS or any web server), not on a store. An APK is built
|
|
|
|
|
|
on a workstation or by CI and published to the gateway through `POST /admin/api/release`,
|
|
|
|
|
|
which stores it in the shared `memby-releases` volume — either by `deploy-server.ps1` (which
|
|
|
|
|
|
builds, signs, verifies and publishes in the same operation) or by
|
|
|
|
|
|
`.gitea/workflows/release.yml` on a pushed semantic tag. The gateway itself builds nothing;
|
|
|
|
|
|
its release-publish token is a read-only Compose secret under `/run/secrets`.
|
2026-08-15 09:23:26 +12:00
|
|
|
|
|
|
|
|
|
|
The existing `release.ps1` compatibility path builds a signed APK and assembles
|
|
|
|
|
|
`dist/out/` — `index.html` (landing page from `dist/template/`), `latest.json` (the manifest
|
|
|
|
|
|
the app polls), the versioned APK and its `.sha256` checksum. Local signing reads
|
|
|
|
|
|
`memby.keystore` and friends from `local.properties`; with no keystore the build still
|
|
|
|
|
|
succeeds but emits an unsigned APK and logs a warning. The key matters more than the code:
|
|
|
|
|
|
Android identifies an app by
|
|
|
|
|
|
applicationId **plus** signing key, so a changed key forces every user to uninstall and
|
|
|
|
|
|
reinstall.
|
2026-07-27 08:22:55 +12:00
|
|
|
|
|
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-18 14:59:29 +12:00
|
|
|
|
**But asking Emby is no longer how the gateway finds out.** Sonarr and Radarr are the
|
|
|
|
|
|
things that put files on disk, so they are what the catalogue learns from: both post to
|
|
|
|
|
|
`/hooks/sonarr` and `/hooks/radarr`, `internal/library/events.go` turns a notification into
|
|
|
|
|
|
a piece of work, and `ingest.go`'s single worker reads that one title out of Emby a minute
|
|
|
|
|
|
later. An episode imported at 19:05 is searchable at 19:06 rather than as late as 20:00,
|
|
|
|
|
|
and the hourly sweep becomes reconciliation for what the *arrs do not manage — a file
|
|
|
|
|
|
dropped in by hand, a title edited in Emby, a webhook that arrived while the container was
|
|
|
|
|
|
down. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **`library_ingest_queue` is durable, and it is the only queue in the schema that is.** A
|
|
|
|
|
|
Tracearr-derived credits candidate is rebuilt from one query on restart; "Sonarr imported
|
|
|
|
|
|
this at 19:05" cannot be rederived from anything, so a container restarted during the
|
|
|
|
|
|
settle delay must still read the file. The key names the **file** rather than the
|
|
|
|
|
|
delivery, which is what makes `ON CONFLICT` the whole of the repeat-delivery defence —
|
|
|
|
|
|
both *arrs re-notify on retry — while a file deleted and re-imported is a different file
|
|
|
|
|
|
and its own work.
|
|
|
|
|
|
- **Both hooks sit outside the quiet-time gate**, which the Radarr one previously sat
|
|
|
|
|
|
inside. That gate answers 503 and neither *arr re-delivers, so a quiet hour silently
|
|
|
|
|
|
discarded every import that happened during it. The hook records at any hour and the
|
|
|
|
|
|
*worker* is where quiet time is honoured — which is only possible because the queue is
|
|
|
|
|
|
durable.
|
|
|
|
|
|
- **An upgrade is silent as news and still refreshes the row.** Those are two judgements
|
|
|
|
|
|
made in two places: `AnnounceLibraryIngest` refuses to announce anything that is not
|
|
|
|
|
|
`ReasonImport` — the title was already there — and the catalogue re-reads it because the
|
|
|
|
|
|
file genuinely changed. A rename is a
|
|
|
|
|
|
refresh and never an invalidation — the Emby item id survives a move, and so does the
|
|
|
|
|
|
credits marker measured against it. A delete only counts when the media went with it: a
|
|
|
|
|
|
series unfollowed in Sonarr with its files left on disk is still in the library.
|
|
|
|
|
|
- **Emby not having scanned yet is the expected first answer**, not a fault. One
|
|
|
|
|
|
`RefreshItem` nudge at the parent, then a widening backoff (`IngestRetryDelay`) out to an
|
|
|
|
|
|
attempt limit — because past the last step the cause is not timing, and a row retrying for
|
|
|
|
|
|
ever is one nobody looks at.
|
|
|
|
|
|
- **The lookup asks for `syncFields`**, the scheduled import's own set, for the reason
|
|
|
|
|
|
`Syncer.Find` does: a thinner query leaves an event-imported title without People,
|
|
|
|
|
|
MediaStreams or ProviderIds — no cast, no ratings lookup, no format badges — until Emby
|
|
|
|
|
|
next reports it changed, which for a film nobody edits again is never.
|
|
|
|
|
|
- **A refresh resolves through Emby; a delete resolves through the catalogue.** That
|
|
|
|
|
|
asymmetry is deliberate. The local series index answers for every show ever imported and
|
|
|
|
|
|
Emby is asked only when it misses, which is exactly the case the feature exists for — a
|
|
|
|
|
|
brand-new show whose first episode has just landed, whose series row is then written
|
|
|
|
|
|
beside its episode. A delete is the other way round because the file is gone and Emby is
|
|
|
|
|
|
the least likely thing to still be able to name it.
|
|
|
|
|
|
- **The sweep interval is an operator override** (`librarySyncMinutes`, the
|
|
|
|
|
|
`embyHealthInterval` pattern) and `Syncer.Schedule` takes a *function* rather than a
|
|
|
|
|
|
value, because a setting read once at start-up is not a setting: lengthening the sweep to
|
|
|
|
|
|
6h after wiring the webhooks up must not need a restart.
|
|
|
|
|
|
- **`store.DeleteLibraryItem` takes the credits marker with the row.** `credits_markers` is
|
|
|
|
|
|
keyed on the item id and nothing else prunes it, so a deleted title would otherwise leave
|
|
|
|
|
|
a Skip Credits position behind for a file that no longer exists.
|
|
|
|
|
|
|
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
|
|
|
|
|
2026-08-18 14:59:29 +12:00
|
|
|
|
- `AnnounceLibraryIngest` in `api/ingest_alerts.go` — a film or an episode whose scan has
|
|
|
|
|
|
**finished**. `POST /hooks/radarr` and `POST /hooks/sonarr` are what push *into* the
|
|
|
|
|
|
gateway, guarded by `MEMBY_RADARR_WEBHOOK_TOKEN` / `MEMBY_SONARR_WEBHOOK_TOKEN` (unset
|
|
|
|
|
|
⇒ 404, the stance `/admin` takes) and mounted outside both the auth middleware and the
|
|
|
|
|
|
maintenance gate, because an event dropped during maintenance is lost rather than
|
|
|
|
|
|
delayed — but the *hook* no longer announces anything. It records, and the announcement
|
|
|
|
|
|
is hung off `Ingester.Announce` in `main.go`, the `SetAfterSync` arrangement, so
|
|
|
|
|
|
`library` stays ignorant of what an alert is. Things to preserve:
|
|
|
|
|
|
- **The webhook is not the news.** Both *arrs fire the moment they have moved a file and
|
|
|
|
|
|
Emby has not scanned it in yet, which is why the banner published from the hook could
|
|
|
|
|
|
only ever promise a film would be available "shortly" and why an episode could not be
|
|
|
|
|
|
announced at all — there was nothing true to say about one until it was there. Behind
|
|
|
|
|
|
the scan the banner says the title is **ready to watch**, and a title Emby never
|
|
|
|
|
|
manages to scan is never announced, which is the right way round.
|
|
|
|
|
|
- **A quality upgrade is still silent, and so are a rename and a delete.** Only
|
|
|
|
|
|
`ReasonImport` is news; the file genuinely changed, so the row is still re-read. That
|
|
|
|
|
|
judgement lives in `api` rather than in the worker: the worker's business is that the
|
|
|
|
|
|
row moved, this is the separate question of whether anybody should be told.
|
|
|
|
|
|
- **A season pack is one banner.** `ingestRuns` tallies a season's arrivals within
|
|
|
|
|
|
`ingestRunWindow` and every later one replaces the same alert, because the id is
|
|
|
|
|
|
anchored on the run's **first** episode. The anchor is what makes both halves work:
|
|
|
|
|
|
within the window a burst collapses, and next week's episode — arriving after it has
|
|
|
|
|
|
closed — starts a run of its own rather than reusing an id every television in the
|
|
|
|
|
|
house has already dismissed as seen. Bounded and lossy in memory, the `playbackTitles`
|
|
|
|
|
|
arrangement; a gateway restarted mid-pack announces the rest as a second run.
|
|
|
|
|
|
- **One arrival is named, several are counted.** Naming the last of six would be
|
|
|
|
|
|
arbitrary — nothing makes it the one worth mentioning — where the count is what the
|
|
|
|
|
|
viewer wants. `episodeSummary` drops the episode title when Emby has recorded it as
|
|
|
|
|
|
the show's own name, since "S03E05 — The Bear" reads as a mistake.
|
|
|
|
|
|
- **Emby's names outrank the *arr's.** The result carries what was actually written to
|
|
|
|
|
|
the catalogue, so the banner and the card underneath it cannot name one thing two ways.
|
|
|
|
|
|
- **The two windows still switch their own half off**: films answer to
|
|
|
|
|
|
`MEMBY_RADARR_ALERT_WINDOW`, episodes to `MEMBY_SONARR_ALERT_WINDOW`. `sonarr-import`
|
|
|
|
|
|
is its own kind, distinct from `sonarr-aired` — one says an episode has been broadcast
|
|
|
|
|
|
and is *not* here, the other that it is.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
- `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
|
2026-08-22 21:47:54 +12:00
|
|
|
|
say why when the household permits playback overlays. `MEMBY_EMBY_HEALTH_INTERVAL=0`
|
|
|
|
|
|
turns the probe and both banners off.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
|
|
|
|
|
|
`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.
|
|
|
|
|
|
|
2026-08-22 21:47:54 +12:00
|
|
|
|
**Where the bar may appear is the server's decision.** `GatewaySettings.NotificationDisplay`
|
|
|
|
|
|
is `everywhere`, `home_only` (the default) or `off`, and `/v1/status` carries the effective
|
|
|
|
|
|
value on every poll. `NotificationDisplayPolicy.fromWire` defaults missing and unknown
|
|
|
|
|
|
values to Home only, and `notificationEligible` is the single rule every renderer uses:
|
|
|
|
|
|
Home only admits browsing and refuses playback, Everywhere admits both, and Off admits
|
|
|
|
|
|
neither. The server also omits `alerts` under Off, but the client still applies the rule so
|
|
|
|
|
|
an alert already offered cannot remain visible after an operator changes the setting.
|
|
|
|
|
|
Only the call sites identify their `NotificationSurface`; no screen owns policy logic.
|
|
|
|
|
|
|
|
|
|
|
|
When Everywhere is selected, `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).
|
2026-08-02 22:10:19 +12:00
|
|
|
|
`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
|
2026-08-22 21:47:54 +12:00
|
|
|
|
`alertShown` exists to prevent. Because it can cover somebody's film, the bar is
|
2026-08-02 22:10:19 +12:00
|
|
|
|
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-11 23:41:10 +12:00
|
|
|
|
**Notifications belong to a person, so they live in the user picker.** A service alert is the
|
2026-08-07 10:44:17 +12:00
|
|
|
|
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.
|
2026-08-19 06:57:59 +12:00
|
|
|
|
- **Inbox and Seen, and there is no off switch.** The page is laid out as My Requests is —
|
|
|
|
|
|
the marked heading, a tab strip, one pane at a time — because the two answer the same shape
|
|
|
|
|
|
of question about a person's own list. `AlertsTab` (pure, tested) is the whole of it:
|
|
|
|
|
|
membership is the *read* flag and nothing else, so both panes and both counts derive from
|
|
|
|
|
|
the one list the caller already holds and there is no third state to keep in step. The
|
|
|
|
|
|
count is on the tab because it is the question a viewer opens the page with, and zero is
|
|
|
|
|
|
printed rather than hidden — a count that disappeared when it emptied would read as one
|
|
|
|
|
|
that had failed. Turning notifications off used to be a chip on this page, which was the
|
|
|
|
|
|
page offering a way to make itself permanently useless; the reason to reach for it was a
|
|
|
|
|
|
single list mixing new news with everything already read, which is what the split fixes.
|
|
|
|
|
|
The stored preference is still honoured and still says so in the empty state — it is
|
|
|
|
|
|
simply no longer the viewer's to switch from here.
|
2026-08-07 10:44:17 +12:00
|
|
|
|
- **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
|
2026-08-19 06:57:59 +12:00
|
|
|
|
worth opening. **Nothing moves under the remote by being looked at**: focus used to mark a
|
|
|
|
|
|
row read, which was harmless while the list was one list and would now empty the Inbox
|
|
|
|
|
|
merely by somebody scrolling it, so Seen is a state a viewer puts a row into. "Dismiss
|
|
|
|
|
|
all" is the same per-alert call in a loop — the gateway has no bulk route — and takes
|
|
|
|
|
|
**only the pane on screen**, or emptying Seen would throw away an Inbox nobody had read;
|
|
|
|
|
|
it empties optimistically, or a row lingers under a thumb that will press it again.
|
|
|
|
|
|
- **A row holds two focus targets, and that is what makes a seen toggle possible.** A remote
|
|
|
|
|
|
has one confirm key and it was already spent on dismissing, so the body keeps that press
|
|
|
|
|
|
and Right reaches a toggle beside it (`alertSeenActionLabel`, `POST
|
|
|
|
|
|
/v1/notifications/{id}/unread` → `store.MarkNotificationUnread`). Down still moves to the
|
|
|
|
|
|
next row from either, so the second target costs nothing to somebody who never wants it.
|
|
|
|
|
|
Three things to preserve: the toggle **names the action, not the state** — a button
|
|
|
|
|
|
labelled with the state it is in reads as a claim rather than as something to press; the
|
|
|
|
|
|
lit surface is the *row's*, driven by `hasFocus` rather than `isFocused`, or a row goes
|
|
|
|
|
|
dark the moment the remote steps sideways into its own control; and marking unread is a
|
|
|
|
|
|
plain assignment where marking read is a `COALESCE`, because read is set by merely looking
|
|
|
|
|
|
at a row and must not move on a second glance, while unread is only ever somebody saying so.
|
|
|
|
|
|
- **The toggle is what moves a row between the two halves, so focus comes back to the
|
|
|
|
|
|
toggle.** Marking a row seen takes it out of the pane it was in, exactly as a dismissal
|
|
|
|
|
|
does, and the page re-aims focus the same way — but onto the *toggle* of the row that took
|
|
|
|
|
|
its place rather than onto the body, because a run of "Mark as seen" presses that landed on
|
|
|
|
|
|
a body would be a run that ended in a dismissal. That is why a row carries two focus
|
|
|
|
|
|
requesters and `pendingFocusToggle` records which of them the change was aimed at.
|
|
|
|
|
|
- **The list is paged on the television, not on the wire** (`ui/alerts/AlertsPaging.kt`,
|
|
|
|
|
|
pure and tested). The gateway answers with the whole undismissed list and the page cuts it
|
|
|
|
|
|
up locally, which is what lets a page flip cost nothing on a weak box, lets the locally
|
|
|
|
|
|
held update notice merge into page one without making a server's page boundaries lie about
|
|
|
|
|
|
it, and keeps a dismissal optimistic instead of needing the page it left refetched.
|
|
|
|
|
|
`AlertsPageSize` is **four**, measured against a 540dp television that spends a third of
|
|
|
|
|
|
its height on the heading and the controls — `my-alerts-paged-crowded.png` is the capture
|
|
|
|
|
|
that figure is answerable to. Two rules: `alertPageAfterChange` **clamps rather than
|
|
|
|
|
|
resets**, because the common change here is the last row of the last page going away and a
|
|
|
|
|
|
viewer sent back to page one for it would lose their place every time they finished a
|
|
|
|
|
|
page; and an arrow at either end is **not drawn** rather than drawn dead, the stance the TV
|
|
|
|
|
|
calendar's month controls take, with both ends keeping a fixed-width slot so the page
|
|
|
|
|
|
indicator is not the one thing on the row that moves.
|
2026-08-07 10:44:17 +12:00
|
|
|
|
- **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
|
2026-08-19 06:57:59 +12:00
|
|
|
|
the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`. What
|
|
|
|
|
|
the page does hold is only ever about the remote in the room — which tab is open, which
|
|
|
|
|
|
page of it, and which row a dismissal or a seen toggle aimed focus at.
|
2026-08-11 23:41:10 +12:00
|
|
|
|
- **A cancellation is a transition, not a status read in isolation.**
|
|
|
|
|
|
`sonarr_series_status_history` stores the first daily Sonarr reading as a quiet baseline
|
|
|
|
|
|
and appends only changes after it; `WatchSonarrLifecycle` creates a notification for every
|
|
|
|
|
|
known user when any show moves from continuing/upcoming to ended/deleted. Cancellation
|
|
|
|
|
|
news is household-wide and does not depend on whether a viewer followed the show. Without
|
|
|
|
|
|
the baseline,
|
|
|
|
|
|
enabling the scanner would announce every show that had already ended as new news, and
|
|
|
|
|
|
without durable history a gateway restart could announce the same change again.
|
2026-08-07 10:44:17 +12:00
|
|
|
|
|
2026-08-19 06:57:59 +12:00
|
|
|
|
**Every outbound notification leaves through one door.** `server/internal/notify` is that
|
|
|
|
|
|
door and `internal/api/notifications.go` is the Memby half of it, so the flow is
|
|
|
|
|
|
`feature/event → notify.Service → Deliverer → notification log`. Before it, each feature
|
|
|
|
|
|
both decided to notify somebody *and* performed the delivery: the Sonarr lifecycle scanner
|
|
|
|
|
|
wrote into `user_notifications`, the ingester pushed a banner into Redis, the integrations
|
|
|
|
|
|
dispatcher posted to Discord. Each knew how to deliver and none knew the others existed, so
|
|
|
|
|
|
"what did Memby send, to whom, and did it work" could only be answered by reading three
|
|
|
|
|
|
subsystems' log lines and hoping each had logged. The console reads the trail at
|
|
|
|
|
|
`/admin/notifications` over `admin_notification_log.go` and `store/notifications.go`. Things
|
|
|
|
|
|
to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **Logging never blocks delivery, and that is an ordering rather than a promise.** `Send`
|
|
|
|
|
|
delivers *first* and records afterwards, on a context detached from the caller's, and a
|
|
|
|
|
|
write that fails is logged and swallowed. A history written first would be a claim rather
|
|
|
|
|
|
than a record; one written inside the delivery path could fail the delivery.
|
|
|
|
|
|
- **A skip is the most useful row on the page.** "Memby never told me" and "Memby decided
|
|
|
|
|
|
not to tell you" are the same silence from outside, so a viewer's preferences declining a
|
|
|
|
|
|
notification, a source key that had already been used, and an operator's alert window set
|
|
|
|
|
|
to zero are all *recorded* with their reason (`declineUser`, `StatusSkipped`) rather than
|
|
|
|
|
|
being an early return. That is why `announceImportedMovie` no longer short-circuits on a
|
|
|
|
|
|
zero window — `deliverBroadcast` records it instead.
|
|
|
|
|
|
- **Sent and Delivered are different answers.** Most channels here are stores, finished the
|
|
|
|
|
|
moment they return with nobody to acknowledge them; `Delivered` is reserved for a provider
|
|
|
|
|
|
that actually confirmed, which today means a webhook answering 2xx. Collapsing them would
|
|
|
|
|
|
have the console claim an acknowledgement nothing gave.
|
|
|
|
|
|
- **Nothing secret is ever recorded.** A webhook URL is the credential, so `Notification`
|
|
|
|
|
|
carries `Target` — a destination's *name* — and never its address, and `Redact` drops any
|
|
|
|
|
|
metadata key that looks like one on the way in. It is a match on the *key*, because a
|
|
|
|
|
|
token is not recognisable by looking at it and what a caller reliably gets right is what
|
|
|
|
|
|
they called the field.
|
|
|
|
|
|
- **The integrations dispatcher reports rather than being driven.** It is the one producer
|
|
|
|
|
|
that calls `notify.Log` instead of `Send`: it is a bus subscriber with its own queue,
|
|
|
|
|
|
pacing and transport registry, and routing its posts through the audit trail would make
|
|
|
|
|
|
the trail the thing deciding what Discord receives.
|
|
|
|
|
|
- **`UpsertNotification` reports whether it inserted.** The digest job fires hourly and
|
|
|
|
|
|
re-sends the same weekly key all evening on purpose, so without that boolean every
|
|
|
|
|
|
catch-up pass would read as a second summary nobody received.
|
|
|
|
|
|
- **The facets are built from what has been sent**, not from a list of constants, and over
|
|
|
|
|
|
the whole retention window rather than the current filter — a dropdown whose options
|
|
|
|
|
|
vanish as you narrow the table is one you cannot use to widen the question again.
|
|
|
|
|
|
- **`store.NotificationRetention` (90 days) is the one figure.** The housekeeping task
|
|
|
|
|
|
prunes to it and the console derives its widest window from it, so a page can never offer
|
|
|
|
|
|
a range the prune has already emptied.
|
|
|
|
|
|
|
2026-08-18 08:41:48 +12:00
|
|
|
|
**Watch time is Tracearr's, and it is never counted twice.** `store.watchedMsExpr` in
|
|
|
|
|
|
`internal/store/watch_time.go` is the one definition of "how long was this actually watched"
|
|
|
|
|
|
— the greater of Tracearr's `durationMs` and `progressMs`, capped at the title's own length —
|
|
|
|
|
|
and the console's figure and the viewer's summary are both queries over `tracearr_sessions`
|
|
|
|
|
|
rather than a second table of minutes. A table counting watching separately would be a copy
|
|
|
|
|
|
of a copy, wrong the moment Tracearr corrects a session. It is read two ways:
|
|
|
|
|
|
|
|
|
|
|
|
- **The console reads it beside the person.** `/admin/api/accounts` carries a `watchTime`
|
|
|
|
|
|
per account — week, month, lifetime, and when they last watched — from one grouped query
|
|
|
|
|
|
for the whole household, because that page grows with the family. `matched` is the
|
|
|
|
|
|
load-bearing field: a household running no Tracearr and a viewer Tracearr has never seen
|
|
|
|
|
|
both arrive as zeroes, and a console drawing those as "0 min this week" would have an
|
|
|
|
|
|
operator investigating a person rather than an integration. A watch-time read that fails
|
|
|
|
|
|
costs the figures and never the account list.
|
|
|
|
|
|
- **`attributeWatchTime` joins the two systems on the username**, which is the identity they
|
|
|
|
|
|
genuinely share, and prefers the Tracearr id `recommendation_user_profiles` recorded where
|
|
|
|
|
|
there is one — so a viewer renamed in one system keeps their figures instead of silently
|
|
|
|
|
|
reporting zero. It is pure, so the console and the digest cannot attribute the same rows
|
|
|
|
|
|
differently.
|
|
|
|
|
|
- **The weekly summary is a personal notification, not a service alert.** A service alert is
|
|
|
|
|
|
the house being told something; how long somebody watched is nobody else's news, so it
|
|
|
|
|
|
lands in My Alerts (`watch-time-week` / `watch-time-month`, which an app that predates them
|
|
|
|
|
|
renders with the fallback icon) and follows the person to every set. `RegisterWatchTimeTasks`
|
|
|
|
|
|
registers it as an ordinary scheduler job, so an operator can see when it last ran and send
|
|
|
|
|
|
one by hand — which for a job that fires once a week is the difference between "it has sent
|
|
|
|
|
|
nothing" and "it has not run".
|
|
|
|
|
|
- **The source key is the only thing preventing a repeat.** It runs hourly and sends from the
|
|
|
|
|
|
appointed hour to the end of that day, because `watch-time:weekly:2026-W33` is written
|
|
|
|
|
|
`ON CONFLICT DO NOTHING`: a container restarted three times on a Sunday evening delivers one
|
|
|
|
|
|
summary, and a gateway that was off all evening still delivers it the next hour it is up.
|
|
|
|
|
|
The monthly summary is the same trick over a `YYYY-MM` key, which is why it catches up
|
|
|
|
|
|
rather than being skipped for ever by a gateway that was down on the first.
|
|
|
|
|
|
- **Sunday evening, not Monday morning**, because the figure sent is week-to-date: on a Monday
|
|
|
|
|
|
it would summarise almost nothing. Every boundary is a household-local *calendar* date
|
|
|
|
|
|
(`weekStartIn`, `monthStartIn`, `previousMonth`), never `now.Add(-7*24*time.Hour)` — a week
|
|
|
|
|
|
containing a daylight-saving change is 23 or 25 hours short or long, and subtracting hours
|
|
|
|
|
|
puts the boundary an hour inside the previous Sunday twice a year. `watch_time_test.go` pins
|
|
|
|
|
|
the clock-change week.
|
|
|
|
|
|
- **Two switches, and they answer different questions.** `watch_time_digest` in the
|
|
|
|
|
|
`featureCatalogue` is the household's and carries no capability — nothing on the television
|
|
|
|
|
|
has to understand this. `NotificationPreferences.WatchTimeDigest` is the viewer's own, kept
|
|
|
|
|
|
apart from `SystemAlerts` because this is the only notification there that is about *them*.
|
|
|
|
|
|
Turning it off also withdraws the summaries already sitting in their list
|
|
|
|
|
|
(`filterStoredNotifications`): switching a weekly notice off is a statement about the ones
|
|
|
|
|
|
already there as much as about the next one.
|
|
|
|
|
|
- **Nothing under `watchTimeDigestFloor` is sent**, and a preference that will not load is
|
|
|
|
|
|
read as "not now" rather than as consent. A digest reporting four minutes is a notification
|
|
|
|
|
|
about a title somebody abandoned, and a feed carrying those is one nobody opens.
|
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
|
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
|
2026-08-22 06:29:31 +12:00
|
|
|
|
6×6 on-screen keyboard on the left, with `SearchGenres` above `SearchResults` on the right.
|
|
|
|
|
|
The results list updates as you type and uses the same shared `MediaResultCard` presentation
|
|
|
|
|
|
as My Requests; request status and actions remain feature-owned slots around that shared row.
|
2026-07-29 15:26:27 +12:00
|
|
|
|
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
|
2026-08-19 21:30:44 +12:00
|
|
|
|
from a build that predates the call was never written down at all — and the table the
|
|
|
|
|
|
console reads back was a partial record of what the household looks for. `handleSearch`
|
|
|
|
|
|
now calls `recordSearchQuery` itself, before the Redis lookup, so a cached answer counts
|
|
|
|
|
|
the same as one that reached Emby. Things to preserve:
|
2026-08-08 16:56:18 +12:00
|
|
|
|
|
|
|
|
|
|
- **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
|
2026-08-22 06:29:31 +12:00
|
|
|
|
pasted into the middle of the list. A page that fails part way down keeps what is on
|
2026-08-08 16:56:18 +12:00
|
|
|
|
screen and simply stops paging.
|
|
|
|
|
|
- **The scroll trigger is a `snapshotFlow`, not a composable read.** The last visible index
|
2026-08-22 06:29:31 +12:00
|
|
|
|
changes on every frame of a scroll, and reading it in the body would recompose the list
|
2026-08-08 16:56:18 +12:00
|
|
|
|
the whole way down a genre — the same rule as the animation one above. It asks
|
2026-08-22 06:29:31 +12:00
|
|
|
|
several items early, since a request that starts when the viewer arrives at
|
2026-08-08 16:56:18 +12:00
|
|
|
|
the end is one they watch.
|
2026-08-22 06:29:31 +12:00
|
|
|
|
- **Genre and result focus targets are separate.** The genre strip stays mounted above the
|
|
|
|
|
|
results; a genre result claims its own requester when the first page lands, Back restores
|
|
|
|
|
|
the strip, and an empty or failed genre hands focus back deterministically rather than
|
|
|
|
|
|
leaving a television with nothing focused.
|
2026-08-08 16:56:18 +12:00
|
|
|
|
- **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-20 07:54:03 +12:00
|
|
|
|
- **The rail is ordered by what this viewer watches, and the catalogue itself never
|
|
|
|
|
|
changes.** `GET /v1/genres/affinity` (`server/internal/api/genre_affinity.go` →
|
|
|
|
|
|
`store.TracearrGenreAffinity` → `ui/genre/GenrePersonalisation.kt`) is a weight per Emby
|
|
|
|
|
|
genre label, and `personaliseGenreCategories` is what the television does with it. Things
|
|
|
|
|
|
to preserve:
|
|
|
|
|
|
- **The evidence is the server's and the catalogue is the set's.** Nothing is stored:
|
|
|
|
|
|
Tracearr's sessions and the imported catalogue's genres are already in Postgres, so the
|
|
|
|
|
|
reading is a join over two tables the gateway keeps for other reasons — the trade
|
|
|
|
|
|
`watchedMsExpr` makes for watch time, and for the same reason (a per-user genre table
|
|
|
|
|
|
would be a copy of both, wrong the moment either changed). The gateway sends Emby's own
|
|
|
|
|
|
spellings verbatim and never a category: folding "Science Fiction", "Sci-Fi" and
|
|
|
|
|
|
"Sci-Fi & Fantasy" into one shelf needs the alias table, which lives on the television,
|
|
|
|
|
|
and a second copy of it on the server would disagree the first time a category gained a
|
|
|
|
|
|
spelling — which is the failure the aliases exist to fix.
|
|
|
|
|
|
- **The order is settled before the rail is drawn and never re-ordered under a viewer.**
|
|
|
|
|
|
`EmbyRepository.genreAffinitySnapshot()` is a volatile read, the `snapshot` arrangement,
|
|
|
|
|
|
consulted once in `GenreBrowseViewModel`'s constructor; `warmGenreAffinity()` is what
|
|
|
|
|
|
fills it in, from `HomeViewModel`'s init, because the browser is never the first screen
|
|
|
|
|
|
a television draws. A set whose reading has not landed gets the catalogue order and is
|
|
|
|
|
|
personalised the next time the page is opened, which is the right way round: a rail that
|
|
|
|
|
|
reshuffled under a D-pad already travelling it is worse than one that never personalised.
|
|
|
|
|
|
- **Nothing is ever hidden, and the unpersonalised half stays in product order.** The sort
|
|
|
|
|
|
is stable, so a category with no evidence scores zero and keeps its place below the ones
|
|
|
|
|
|
the viewer watches. `MIN_GENRE_AFFINITY_SESSIONS` refuses to personalise at all below a
|
|
|
|
|
|
floor — one Western on a wet Sunday is not a taste — and `GENRE_AFFINITY_FLOOR` is a
|
|
|
|
|
|
*share* of the top genre rather than a count, so the long tail cannot reshuffle on noise
|
|
|
|
|
|
and a household that watches nightly and one that watches on Sundays order the same way.
|
|
|
|
|
|
- **An episode counts as its series, and an abandoned title counts as nothing.** The
|
|
|
|
|
|
session joins on `emby_series_id` before `emby_item_id`, so a household watching one
|
|
|
|
|
|
crime drama nightly reads as watching crime; `genreAffinityEngagement` requires half the
|
|
|
|
|
|
title, because a session that stopped four minutes in is evidence somebody did *not*
|
|
|
|
|
|
want it and counting those is how a rail comes to lead with what a viewer keeps
|
|
|
|
|
|
abandoning. Recency is three flat bands over 180 days rather than a decay — a half-life
|
|
|
|
|
|
nobody could defend would also make the answer move between two readings taken the same
|
|
|
|
|
|
evening.
|
|
|
|
|
|
- **Every failure is the default order.** The direct path has nobody to ask, a gateway
|
|
|
|
|
|
predating the route answers 404, a household running no Tracearr has no history, and the
|
|
|
|
|
|
operator's Tracearr switch is honoured even though the rows are already in Postgres —
|
|
|
|
|
|
switched off means Memby may not read their viewing, and old rows are still their
|
|
|
|
|
|
viewing. There is deliberately no second implementation on the direct path, the stance
|
|
|
|
|
|
the TV calendar takes: the answer is Tracearr's, which a television holds no credential
|
|
|
|
|
|
for, and the catalogue's own order is a perfectly good rail.
|
|
|
|
|
|
- **Six hours on both sides.** `genreAffinityTTL` and `GENRE_AFFINITY_REFRESH_MS` are
|
|
|
|
|
|
matched so a set refetching past it asks a question that has actually been recomputed
|
|
|
|
|
|
rather than paying a round trip for a cached repeat; an empty reading is kept for an
|
|
|
|
|
|
hour instead, because a new account genuinely acquires a history and a six-hour no would
|
|
|
|
|
|
hide their first evening of watching until the morning.
|
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
|
2026-08-22 06:29:31 +12:00
|
|
|
|
rightmost goes to Genres, Down enters Search Results, and every result goes back to the
|
|
|
|
|
|
*last key used* (a `FocusRequester` attached to whichever key that is). Back closes an open
|
|
|
|
|
|
genre shelf before moving results → keyboard → 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.
|
2026-08-12 09:57:56 +12:00
|
|
|
|
That ring is restored from a bounded JSONL archive in the persistent `memby-logs`
|
|
|
|
|
|
volume, so a deployment replaces the process without erasing the operator's history.
|
2026-08-15 09:23:26 +12:00
|
|
|
|
History compaction materialises the ordered ring only once per ringful; do not move
|
|
|
|
|
|
that copy back onto every append. Cursor reads calculate their ring offset directly.
|
|
|
|
|
|
The Admin Console retains and filters the full delivered window but virtualises the
|
|
|
|
|
|
display, caches each record's formatted/searchable form and stops polling while hidden.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
- `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.
|
|
|
|
|
|
|
2026-08-19 18:08:00 +12:00
|
|
|
|
**A slow request says where its time went.** `internal/timing` is a per-request trace
|
|
|
|
|
|
carried in the context by pointer, the `requestIdentity` arrangement, and every layer
|
|
|
|
|
|
under the handler records against it: an `http.RoundTripper` wrapper on all eight upstream
|
|
|
|
|
|
clients (Emby, both *arrs, Tracearr, MDBList, Bazarr, OpenSubtitles, the integrations
|
|
|
|
|
|
dispatcher), a `pgx.QueryTracer` on the pool, and the cache's own `Get`/`Set`/`Delete`.
|
|
|
|
|
|
`withLogging` attaches the result as a `breakdown` field once the request passes
|
|
|
|
|
|
`slowRequestThreshold` — an operator override over `MEMBY_SLOW_REQUEST_THRESHOLD`, 500ms
|
|
|
|
|
|
deployed — and the console gives it a heading of its own in the log drawer and a second
|
|
|
|
|
|
line on the row. A duration column could only ever say that something was slow; this is
|
|
|
|
|
|
what makes it actionable:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
GET /v1/home 9.51s breakdown=emby.favourites=7.80s fanout=7.81s rows=1.40s db=18ms encode=31ms miss=1
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **It measures stages, not spans in a tree.** Most of what matters here is concurrent —
|
|
|
|
|
|
Home fans five Emby queries out at once — so a total of wall-clock spans would exceed
|
|
|
|
|
|
the request's own duration and mean nothing. What a stage reports is summed busy time
|
|
|
|
|
|
and a **call count**, and the count is the half that finds duplicate work: `emby=7.8s
|
|
|
|
|
|
×14` on a page that should make two lookups is the finding whatever the seconds say.
|
|
|
|
|
|
- **The transport times the body too**, not only `RoundTrip`. Headers arrive while several
|
|
|
|
|
|
hundred kilobytes of row JSON is still on the wire, so timing the round trip alone would
|
|
|
|
|
|
under-report exactly the largest responses.
|
|
|
|
|
|
- **`timing.WithLabel` is what makes a fan-out legible.** Five concurrent Emby queries
|
|
|
|
|
|
under one `emby=` stage say the launcher waited on Emby without saying which of them it
|
|
|
|
|
|
waited on, which is the whole of the next question. Each home row, and each leg of the
|
|
|
|
|
|
season-finale chain, names its own stage. A label is only ever a finer name for a stage
|
|
|
|
|
|
the client would have recorded anyway, so its absence changes nothing.
|
|
|
|
|
|
- **`elsewhere` is the duration less the *largest* stage**, never less their sum, and is
|
|
|
|
|
|
clamped at zero. It is deliberately not called "gateway processing": with overlapping
|
|
|
|
|
|
stages no such figure exists, and what this is instead is a lower bound on time nothing
|
|
|
|
|
|
accounted for.
|
|
|
|
|
|
- **Everything is inert without a trace.** A scheduled sync, a health probe and every unit
|
|
|
|
|
|
test call the same helpers and pay one nil check.
|
|
|
|
|
|
|
|
|
|
|
|
**And the fixes the trace found.** They are all one shape — work on the critical path that
|
|
|
|
|
|
did not have to be there — and each is easy to reintroduce:
|
|
|
|
|
|
|
|
|
|
|
|
- **The automatic My Shows check ran a hundred times per episode.** It hangs off playback
|
|
|
|
|
|
reports, one arrives every ten seconds, and its own guard (half watched) is true for
|
|
|
|
|
|
every one of those after the halfway mark — so the second half of every episode ran two
|
|
|
|
|
|
serial Emby lookups, a Sonarr catalogue read and a Postgres write per tick and threw all
|
|
|
|
|
|
but the first away at the durable insert. `followChecks.claim` is a map lookup that comes
|
|
|
|
|
|
*first*, before the feature check and long before Emby. Deduplicating on the insert was
|
|
|
|
|
|
correct and was never the problem; what it could not do is prevent the work leading up to
|
|
|
|
|
|
it.
|
|
|
|
|
|
- **`InvalidateUser` walked the whole keyspace.** Redis SCAN's cost is a property of the
|
|
|
|
|
|
keyspace rather than of the pattern, so dropping one viewer's dozen cached rows meant
|
|
|
|
|
|
paging through every item lookup the household had ever cached — on the playback stop
|
|
|
|
|
|
report, which is the request a television makes as somebody presses Back out of a film.
|
|
|
|
|
|
`Cache.Set` now indexes user-scoped keys into `idx:u:<user>` in the same pipeline as the
|
|
|
|
|
|
write, and invalidation reads that set. The SCAN survives as the fallback for a user with
|
|
|
|
|
|
no index, which is the honest answer for a gateway upgraded with older keys still live.
|
|
|
|
|
|
- **Stable metadata lived where invalidation could reach it.** A person's biography, a
|
|
|
|
|
|
film's extras and whether an episode closes its season carry no user data and cannot
|
|
|
|
|
|
change when somebody finishes an episode, yet all three sat under `u:<user>:` and were
|
|
|
|
|
|
destroyed several times an evening by playback stops — then re-read from Emby by the next
|
|
|
|
|
|
card anybody looked at. `cache.MetadataKey` is where they live now: outside the
|
|
|
|
|
|
invalidated namespace and unkeyed by viewer, so one lookup answers for the household. The
|
|
|
|
|
|
rule for putting something there is narrow and worth stating: **the value must contain
|
|
|
|
|
|
nothing derived from a viewer.** Anything carrying `UserData` belongs in `UserKey`, where
|
|
|
|
|
|
invalidation can reach it — which is why the *filmography* stayed behind while the
|
|
|
|
|
|
biography moved.
|
|
|
|
|
|
- **Identical concurrent misses each asked upstream.** `s.cachedRead` / `buildCached`
|
|
|
|
|
|
(`coalesce.go`) put a `singleflight` between the cache miss and the upstream call, keyed
|
|
|
|
|
|
by the cache key. The log showed one person lookup answering in 2.2s, then 4.6s, then
|
|
|
|
|
|
7.7s within a few seconds; nothing was getting slower, three televisions had missed the
|
|
|
|
|
|
same key at the same moment and Emby was serving three copies of one expensive query.
|
|
|
|
|
|
The cache could not help — nothing had been written when the second and third arrived.
|
|
|
|
|
|
Two properties are load-bearing: **the shared work does not inherit the caller's
|
|
|
|
|
|
cancellation** (singleflight hands every joiner the first caller's result, so a D-pad
|
|
|
|
|
|
moving off a card would otherwise fail everybody queued behind it), and **a joiner is
|
|
|
|
|
|
counted** on the breakdown, because "slow" and "queued behind itself" are different
|
|
|
|
|
|
problems with different fixes. It is applied to the person biography and filmography,
|
|
|
|
|
|
related titles, extras, series episodes and item detail — every route the launcher warms
|
|
|
|
|
|
on focus and then asks for again on the press.
|
|
|
|
|
|
- **Serial chains that were never chains.** The season-finale route fetched Sonarr's series
|
|
|
|
|
|
catalogue *after* both Emby lookups it does not depend on; the extras route made its two
|
|
|
|
|
|
lookups in turn; `filterRecommendationPermissions` walked its hundred-id batches one at a
|
|
|
|
|
|
time on the home tail. All three now run together, and the permission check keeps its
|
|
|
|
|
|
original refusal — one failed batch hides every candidate, because a partial answer is
|
|
|
|
|
|
not evidence that the rest is permitted.
|
|
|
|
|
|
- **Home read its ranking evidence after the rows arrived.** `rankingContext` is seven
|
|
|
|
|
|
Postgres reads and `UserRowStats` an eighth, all of them functions of the viewer's id and
|
|
|
|
|
|
nothing else — so they ran with nothing else in flight, on the critical path, for answers
|
|
|
|
|
|
that were available before Emby was asked anything. They are issued *beside* the Emby
|
|
|
|
|
|
fan-out now (`rankingInputs`, `personalizeTitlesWith`), and the seven inside
|
|
|
|
|
|
`rankingContext` are issued together. The two that are genuinely a chain stay one, but
|
|
|
|
|
|
their *writes* are ordered rather than their reads: `ApplyOnboarding` and
|
|
|
|
|
|
`ApplyExplicitPreference` are not commutative and the ranking must never depend on which
|
|
|
|
|
|
query answered first.
|
|
|
|
|
|
- **Two documents were queried per request and could not change per request.** The feature
|
|
|
|
|
|
policy is read from sixteen places plus `/v1/status`, which every open television polls
|
|
|
|
|
|
every ten seconds; the household completion scores are a six-month aggregate whose answer
|
|
|
|
|
|
is *the same for every viewer in the house*. Both are cached in memory — five seconds and
|
|
|
|
|
|
a minute — and the operator's own write invalidates the first outright, so the window is
|
|
|
|
|
|
"how long until another instance notices", not "how long until my change takes effect".
|
|
|
|
|
|
A failed household read returns the previous reading rather than caching an empty one.
|
|
|
|
|
|
- **The connection pool was four.** `pgxpool`'s default is `max(4, GOMAXPROCS)`, which on
|
|
|
|
|
|
the NAS is fewer than one household's televisions — and a query waiting for a connection
|
|
|
|
|
|
is invisible, because the query time stays honest while the request is slow anyway.
|
|
|
|
|
|
`store.Open` floors it at 16 with 4 kept warm, and leaves alone any deployment that
|
|
|
|
|
|
stated `pool_max_conns` for itself.
|
|
|
|
|
|
|
|
|
|
|
|
Two things the trace found and did **not** change, because both are behaviour rather than
|
|
|
|
|
|
waste. `fieldsRow` asks Emby for `People` and `RecursiveItemCount`, which are the two
|
|
|
|
|
|
expensive fields on a row query — but the weighted ranker scores person affinity from the
|
|
|
|
|
|
first and a series card prints its episode count from the second, so dropping either is a
|
|
|
|
|
|
feature decision. And the home cache key carries the device id, which multiplies the
|
|
|
|
|
|
rebuild by the number of televisions; that is there because a recommendation's reason names
|
|
|
|
|
|
"this TV", although the recommendations it decorates come from a cache that is not
|
|
|
|
|
|
device-keyed. Both are worth revisiting deliberately.
|
|
|
|
|
|
|
2026-08-17 11:41:36 +12:00
|
|
|
|
**Emby's own version rides the reachability probe**, and the television prints it beside the
|
|
|
|
|
|
gateway's on Settings → About: `0.1.50 (4.10.0.21)`. `emby.Client.Ping` already asks
|
|
|
|
|
|
`/System/Info/Public`, which carries `Version`, so reading it costs nothing — a second call
|
|
|
|
|
|
per probe to learn a string that moves a few times a year would be the wrong trade. Three
|
|
|
|
|
|
things to preserve: the last known version is **kept through an outage**, because About is
|
|
|
|
|
|
exactly the page somebody opens when the server has stopped answering and a version that
|
|
|
|
|
|
blanked itself would replace a fact that is still true with nothing; `version` is **omitted
|
|
|
|
|
|
rather than sent empty**, so a client can tell "no probe has answered yet" from "Emby
|
|
|
|
|
|
answered without one"; and `gatewayVersionLabel` (pure, tested) prints the gateway's version
|
|
|
|
|
|
alone in every unknown case rather than empty brackets, which read as a fault. A gateway that
|
|
|
|
|
|
is not answering prints "Not connected" with no brackets at all — it cannot vouch for what
|
|
|
|
|
|
Emby is running either.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-08-15 21:11:43 +12:00
|
|
|
|
**Who may open it is Emby's answer, not the gateway's.** The sign-in behind the console is
|
|
|
|
|
|
the same discreet Emby password check the public installer uses, so on its own it admitted
|
|
|
|
|
|
anybody in the household — and opening `/admin/` then handed them the admin token cookie.
|
|
|
|
|
|
`embyAdministrator` requires `Policy.IsAdministrator`, which Emby returns *in the
|
|
|
|
|
|
authentication response*, so the check costs no request; a response carrying no policy at
|
|
|
|
|
|
all is asked again directly and an Emby that will not answer refuses the sign-in, because
|
|
|
|
|
|
silence is not permission and reading it as "no" would lock an operator out of their own
|
|
|
|
|
|
console. Three things to preserve: the refusal is worded exactly as a wrong password —
|
|
|
|
|
|
somebody who may not administer the server has no business learning that their password
|
|
|
|
|
|
was right — and the reason goes to the log instead; the *installer* is deliberately not
|
|
|
|
|
|
gated this way, since a viewer setting up a new television is who it is for; and the two
|
|
|
|
|
|
sign-ins issue **one cookie**, so they are separated by being signed with their own
|
|
|
|
|
|
purpose (`installerSessionPurpose` / `adminSessionPurpose`) rather than by a claim inside
|
|
|
|
|
|
the payload. An admin session satisfies the installer's gate; an installer session can
|
|
|
|
|
|
never satisfy the console's. `adminSessionTTL` is 90 days of idle time, matching
|
|
|
|
|
|
`MEMBY_SESSION_IDLE_EXPIRY` so a browser and a television are forgotten on the same
|
|
|
|
|
|
schedule, and only a request an operator actually made slides it forward — see
|
|
|
|
|
|
`operatorPresent`.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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.
|
2026-08-14 09:40:03 +12:00
|
|
|
|
- **The console is addressable by name as well as by menu.** Twenty-odd pages is more than a
|
|
|
|
|
|
rail six groups deep is read down, so the top bar carries a search field (Shift+S, and the
|
|
|
|
|
|
shortcut is *printed* on it — it is the only thing saying the console can be driven from
|
|
|
|
|
|
the keyboard). Its list is server-rendered from `.Nav` in `shell.html`, so it reads from
|
|
|
|
|
|
`adminNav` like everything else and cannot offer a page that does not exist or miss one
|
|
|
|
|
|
that does. Two rules in `prepareSearch`: a page's own **name outranks the sentence under
|
|
|
|
|
|
it**, or typing "se" answers with every page whose intro happens to contain those letters
|
|
|
|
|
|
and buries Searches, with ties keeping the rail's order so equally good matches never
|
|
|
|
|
|
reshuffle; and what the arrows walk is read **out of the DOM**, not out of the array the
|
|
|
|
|
|
markup was built from, because ranking re-orders the list and the two disagree the moment
|
|
|
|
|
|
it does. The shortcut stands down inside a field — the console is full of them, and one
|
|
|
|
|
|
that ate a capital S would be worse than none.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
- **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-19 14:25:44 +12:00
|
|
|
|
**"25 goroutines" was the one figure on the console nobody could act on**, and
|
|
|
|
|
|
`server/internal/runtimestats` is what replaced it. A count says nothing about what those
|
|
|
|
|
|
goroutines are doing, which part of Memby they belong to, whether it is normal, or whether
|
|
|
|
|
|
it has been climbing all week — so the overview's Process card now prints a *verdict* and
|
|
|
|
|
|
signposts `/admin/runtime`, where the detail lives. The package is organised entirely by
|
|
|
|
|
|
what a thing costs, and that is the design:
|
|
|
|
|
|
|
|
|
|
|
|
- **The registry is free**, so it runs always. A long-running worker is started through
|
|
|
|
|
|
`runtimestats.Go(name, component, fn)` rather than a bare `go`, at every launch site in
|
|
|
|
|
|
`main.go` plus the scheduler's loop and the integrations dispatcher, and the console can
|
|
|
|
|
|
therefore *name* "Library ingest" instead of inferring it. `Starts` is on the record
|
|
|
|
|
|
because a worker being restarted in a loop reads exactly like a healthy one from a single
|
|
|
|
|
|
snapshot. Nothing in the package recovers a panic — reporting must never change what it
|
|
|
|
|
|
is reporting on.
|
|
|
|
|
|
- **The sampler is cheap**, so it runs on a minute's tick: a ring of `Sample`s four hours
|
|
|
|
|
|
deep. A single instantaneous number cannot show a leak and this can. `trendOf` is pure and
|
|
|
|
|
|
compares the median of the window's oldest quarter with its newest rather than fitting a
|
|
|
|
|
|
line, because a burst — an import, a household arriving home — is exactly the shape
|
|
|
|
|
|
least-squares reports as a trend; the threshold is proportional *with an absolute floor*,
|
|
|
|
|
|
or a busy gateway is permanently "rising" and a quiet one calls one extra goroutine a leak.
|
|
|
|
|
|
`DirectionUnknown` is a real answer and the honest one for the first quarter of an hour.
|
|
|
|
|
|
- **The breakdown is expensive**, so it is a button. `CollectGoroutines` walks every stack,
|
|
|
|
|
|
which stops the world, and the answer says when it was taken and what it cost.
|
|
|
|
|
|
`parseGoroutines` is pure and pinned by a fixture carrying a real dump, and the categories
|
|
|
|
|
|
must always **partition the total** — a breakdown whose parts do not add up is worse than
|
|
|
|
|
|
none, which is what the unrecognised-state bucket and both admin tests exist for. The
|
|
|
|
|
|
`componentRules` order is load-bearing: Memby's own packages are tested before the
|
|
|
|
|
|
libraries they call, or every query in the gateway files under "Database pool".
|
|
|
|
|
|
`?format=text` hands over the raw dump, which is the alternative to leaving a profiler
|
|
|
|
|
|
endpoint permanently mounted.
|
|
|
|
|
|
- **The verdict is the gateway's wording, not the console's** (`assess`, pure and tested),
|
|
|
|
|
|
the stance every label the gateway prints takes: the threshold and the sentence explaining
|
|
|
|
|
|
it belong together, and an older console must not be the thing deciding what "watch"
|
|
|
|
|
|
means. Every note carries an **area** — a number that has moved is only actionable once it
|
|
|
|
|
|
points somewhere — and a rise slower than `goroutineLeakPerHour` is not a note at all, or
|
|
|
|
|
|
the card cries wolf on every busy evening.
|
|
|
|
|
|
- **`Process` is what /proc knows and the runtime does not** — processor time, open sockets,
|
|
|
|
|
|
the descriptor limit. `CPUKnown`/`FilesKnown` are separate from the figures because "no
|
|
|
|
|
|
open sockets" and "could not look" are different answers, and a developer machine is the
|
|
|
|
|
|
second.
|
|
|
|
|
|
|
2026-08-17 19:09:17 +12:00
|
|
|
|
**The gateway's own settings are `/admin/settings`**, reached from the account menu in the
|
|
|
|
|
|
top bar rather than from the rail — every other page decides what the *televisions* do, and
|
|
|
|
|
|
this one is about the server process. It is `store.GatewaySettings` (one `app_settings` row)
|
|
|
|
|
|
over `internal/api/gateway_settings.go`, and it holds the household timezone, the log level,
|
|
|
|
|
|
the idle sign-out, the two alert windows and the Emby health probe. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **Every field is an override, and `.env` is still the configuration.** Blank means
|
|
|
|
|
|
"whatever this container was started with", which the page prints beside each field, so
|
|
|
|
|
|
clearing a setting is a real undo rather than a value the operator has to remember. A
|
|
|
|
|
|
setting that can legitimately be *off* therefore needs a third value: **-1 is off, 0 is
|
|
|
|
|
|
deployed** (`store.GatewaySettingsOff`), because a plain zero would make "turn this off"
|
|
|
|
|
|
indistinguishable from "leave it alone".
|
|
|
|
|
|
- **Nothing reads the document directly.** The effective-value helpers in
|
|
|
|
|
|
`gateway_settings.go` — `householdLocation`, `sessionIdleExpiry`, `sonarrAlertWindow`,
|
|
|
|
|
|
`radarrAlertWindow`, `embyHealthInterval` — are the only readers, so adding a setting is
|
|
|
|
|
|
one helper beside its config field rather than teaching every call site that an override
|
|
|
|
|
|
exists. `s.cfg.SonarrLocation` in particular should not be read directly any more:
|
|
|
|
|
|
`householdLocation()` is what makes a timezone change reach the schedule rows, the hero
|
|
|
|
|
|
rotation and the sign-in history.
|
|
|
|
|
|
- **A setting that is read once at start-up is not a setting.** `WatchEmbyReachability`
|
|
|
|
|
|
re-reads its cadence every tick and keeps ticking (slowly) while the probe is off, so
|
|
|
|
|
|
switching it back on does not need a restart; the idle sweep reads the expiry inside
|
|
|
|
|
|
`Run` rather than closing over it; and the log level is a `*slog.LevelVar` threaded from
|
|
|
|
|
|
`main` through `Deps.LogLevel`, applied on save rather than waited for — an operator who
|
|
|
|
|
|
has just turned debug on and gone to look at the log must not spend thirty seconds
|
|
|
|
|
|
believing it did not work. `deployedLogLevel` is remembered because clearing the
|
|
|
|
|
|
override has to restore *something*, and the variable itself has by then been moved.
|
|
|
|
|
|
|
2026-08-19 14:25:44 +12:00
|
|
|
|
**Integrations are their own area, and they are one axis over the scheduler rather than a
|
|
|
|
|
|
second system.** `/admin/integrations` is the overview, `/admin/integrations/{id}` is one
|
|
|
|
|
|
service, and `internal/api/integrations_catalogue.go` is the only place a service is
|
|
|
|
|
|
declared — the `featureCatalogue` shape. Before it, MDBList's settings were on a Movie
|
|
|
|
|
|
ratings page, the Sonarr and Radarr switches were on a page about Discord webhooks, their
|
|
|
|
|
|
request policies were beside those, and Tracearr could not be reached from the console at
|
|
|
|
|
|
all: "is everything Memby depends on working" was four pages and one impossible question.
|
|
|
|
|
|
Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **A run history is not a second store.** An integration run *is* a
|
|
|
|
|
|
`scheduled_task_runs` row: `scheduler.Task` carries an `Integration` id, the row records
|
|
|
|
|
|
it, and `store.IntegrationRuns` reads the same table along the other axis. Giving the
|
|
|
|
|
|
area a table of its own would mean two schedulers, two retention jobs and two places one
|
|
|
|
|
|
piece of work could be recorded as having failed. What was added is four counters
|
|
|
|
|
|
(`store.RunCounts` — processed, changed, skipped, failed) and `scheduler.Work`, the
|
|
|
|
|
|
counting form of `Run`; a task declares one or the other and `Register` panics on both.
|
|
|
|
|
|
The counters exist because "412 films checked, 7 updated" is the question an operator
|
|
|
|
|
|
has and a sentence is a poor place to keep numbers — they cannot be compared between
|
|
|
|
|
|
runs or sorted.
|
|
|
|
|
|
- **Runs and Logs answer different questions and say so.** Logs are the technical events
|
|
|
|
|
|
behind a failure; this is the operational record of what Memby attempted and what came of
|
|
|
|
|
|
it. A failing service's page links to `/admin/logs?q=<id>` — which is why the log viewer
|
|
|
|
|
|
seeds its text filter from `?q=` — so the two are joined rather than merged.
|
|
|
|
|
|
- **The work is scheduler tasks now, and that is the half that changed behaviour.** The
|
|
|
|
|
|
Sonarr lifecycle scan, the Radarr catalogue refresh, the Tracearr import and the daily
|
|
|
|
|
|
For You rebuild were four goroutines with tickers of their own in `main`. A ticker could
|
|
|
|
|
|
report nothing to an operator, could not be started by hand, and could not be counted
|
|
|
|
|
|
against a service. `foryou.Service.Schedule` and `nextDailyRebuild` are gone with them;
|
|
|
|
|
|
the "is the rebuild due" rule moved to `api.forYouRebuildDue`, which is pure and tested
|
|
|
|
|
|
and compares **local calendar days** rather than subtracting 24 hours.
|
|
|
|
|
|
- **A switched-off integration reports a *skipped* run, never silence and never an error.**
|
|
|
|
|
|
Silence is indistinguishable from a scheduler that has stopped, and an error would put a
|
|
|
|
|
|
red row in the console for a state the operator chose.
|
|
|
|
|
|
- **One stored truth per integration, and one reader.** The switch lives where that
|
|
|
|
|
|
service's configuration already lives — Sonarr and Radarr in `ArrIntegrationPolicy`,
|
|
|
|
|
|
MDBList in `MDBListSettings.Enabled`, Tracearr in the new `integration_policy` document
|
|
|
|
|
|
— and `integrationEnabled`/`SetEnabled` are the only ways in. Consolidating them would
|
|
|
|
|
|
be a migration with a window in which two documents disagreed about whether a service
|
|
|
|
|
|
was on. Absence means **on**, or the day this shipped would have switched every
|
|
|
|
|
|
household's imports off.
|
|
|
|
|
|
- **`integrationSuppressed` is not the negation of `integrationEnabled`.** The latter is
|
|
|
|
|
|
also false for a service this deployment never configured, and a household can perfectly
|
|
|
|
|
|
well point Sonarr's *webhook* at Memby without giving Memby Sonarr's API key. The import
|
|
|
|
|
|
hooks use the former, and answer 200 while ignoring the payload — neither *arr
|
|
|
|
|
|
re-delivers a rejection, so refusing would read as Memby losing imports rather than as
|
|
|
|
|
|
the switch the operator set.
|
|
|
|
|
|
- **A switch consulted once at start-up is not a switch.** `recommend.Engine.TracearrAllowed`
|
|
|
|
|
|
is a function read at call time, wired in `main` after the server exists, and nil means
|
|
|
|
|
|
allowed so every test behaves as it did. Switching Tracearr off costs the extra signal
|
|
|
|
|
|
and never the row — the same degradation an unreachable Tracearr already produced.
|
|
|
|
|
|
- **Health is probed on a schedule, not on page load.** The console polls; probing per
|
|
|
|
|
|
request would make every open tab its own request against somebody's Sonarr. One
|
|
|
|
|
|
`integration-health` task every five minutes fills `integrationHealthCache`, and the
|
|
|
|
|
|
console's Test button calls the *same* `probeIntegration` — a test taking a different
|
|
|
|
|
|
path could report a service as working while the row beside it stayed red. A switched-off
|
|
|
|
|
|
service is not probed at all, which is what the switch promises.
|
|
|
|
|
|
- **MDBList has no probe, and the page says so rather than drawing "never checked".**
|
|
|
|
|
|
Its allowance is bought by the day, and spending a request of it to colour a status dot
|
|
|
|
|
|
would have the console competing with the televisions for the thing it is reporting on.
|
|
|
|
|
|
Its health comes from its own run history.
|
|
|
|
|
|
- **The status word is the gateway's** (`integrationStatus`, pure and tested), the stance
|
|
|
|
|
|
every label Memby prints takes. It resolves in priority order — unconfigured, disabled,
|
|
|
|
|
|
running, then the evidence — and a failure followed by a success is *history rather than
|
|
|
|
|
|
a verdict*, or one bad night would leave a service red until the retention job removed
|
|
|
|
|
|
the row. `idle` is a real answer for a service that has not done anything yet; claiming
|
|
|
|
|
|
either verdict would be a guess.
|
|
|
|
|
|
- **Switching one off states what goes with it.** `Powers` in the catalogue is rendered on
|
|
|
|
|
|
the service's page and repeated in the activity event, because "Sonarr disabled" tells an
|
|
|
|
|
|
operator nothing they did not just do — what they may not have thought about is the
|
|
|
|
|
|
television calendar going with it.
|
|
|
|
|
|
- **`/admin/ratings` redirects** to the MDBList page rather than 404ing: it was
|
|
|
|
|
|
bookmarkable and appears in older activity links. The old Integrations page survives as
|
|
|
|
|
|
`/admin/integrations/webhooks`, which answers the opposite question from the rest of the
|
|
|
|
|
|
area — those are services Memby *depends on*, that is a place Memby *posts to*, and
|
|
|
|
|
|
removing every webhook leaves the gateway unchanged.
|
|
|
|
|
|
|
2026-08-17 19:09:17 +12:00
|
|
|
|
**Two things a television does are notifications now**, both in `internal/api/device_activity.go`.
|
|
|
|
|
|
`TypeDeviceFirstUse` announces the first time a set opened Memby on a household-local day
|
|
|
|
|
|
and `TypeDeviceUpdated` announces one that finished updating itself. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **First use is anchored on `/v1/home`**, not on the auth middleware every call passes
|
|
|
|
|
|
through: a set left on overnight polls `/v1/status` every ten seconds, so any-request
|
|
|
|
|
|
would announce it at midnight — an event nobody did, at the hour nobody is reading. It is
|
|
|
|
|
|
marked *before* the cached response is served, because a household whose rows are still
|
|
|
|
|
|
warm from another set has still just been opened by this one.
|
|
|
|
|
|
- **The answer comes from the insert.** `store.MarkDeviceDay` is `ON CONFLICT DO NOTHING`
|
|
|
|
|
|
on `device_activity_days`, so two televisions racing on the same row cannot both be told
|
|
|
|
|
|
they were first. The marks are retired with the device and pruned by their own
|
|
|
|
|
|
housekeeping task.
|
|
|
|
|
|
- **An update needs a previous version to be an update.** `announceDeviceUpdate` fires from
|
|
|
|
|
|
`captureClientIdentity` — the only place a set that updated in place is ever seen, since
|
|
|
|
|
|
it never signs in again — and an empty previous version is a build this gateway had never
|
|
|
|
|
|
been told the version of, not a version that moved. A *downgrade* is still announced: a
|
|
|
|
|
|
sideloaded step backwards is news too.
|
|
|
|
|
|
|
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
|
2026-08-12 09:57:56 +12:00
|
|
|
|
presses a card, the item record and its "why you might enjoy it" have been fetched — and now
|
|
|
|
|
|
so have its episode list and its trailer, which were the two things
|
2026-08-08 16:56:18 +12:00
|
|
|
|
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:
|
|
|
|
|
|
|
2026-08-12 09:57:56 +12:00
|
|
|
|
- **Playback itself is never warmed on focus.** Emby's PlaybackInfo negotiation creates a
|
|
|
|
|
|
playback session, so asking for it while somebody merely browses pollutes server history
|
|
|
|
|
|
with titles they never played. Stream resolution begins only after a Play action.
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
- **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.
|
|
|
|
|
|
|
2026-08-12 11:05:07 +12:00
|
|
|
|
**Trailers are a provider chain, not ordinary title playback.** `GET /v1/items/{id}/trailers`
|
|
|
|
|
|
is the cheap availability answer used while a detail page is warmed; the Play press opens
|
2026-08-12 13:08:53 +12:00
|
|
|
|
`PlayerActivity` immediately and `POST /v1/items/{id}/trailers/resolve` selects a candidate.
|
|
|
|
|
|
Official Apple and labelled official YouTube sources come first, followed by local Emby media
|
|
|
|
|
|
and then the remaining recognised remote trailers. Local media is returned directly; Apple
|
|
|
|
|
|
and YouTube pages are resolved and validated on the television, then played natively. That
|
|
|
|
|
|
boundary is deliberate: provider requests and any IP-bound media URL must originate from the
|
|
|
|
|
|
viewer's real client address, never the gateway. The player keeps the subject and rejected
|
|
|
|
|
|
candidate ids: an error or an eight-second stall before the first frame asks for the next
|
|
|
|
|
|
provider behind the same loading surface. Exhausting the chain closes the player and returns
|
|
|
|
|
|
to the still-composed detail page; it never shows the ordinary playback error pane.
|
2026-08-12 11:05:07 +12:00
|
|
|
|
Keep these boundaries:
|
|
|
|
|
|
|
|
|
|
|
|
- Metadata discovery may be warmed and cached, but local `PlaybackInfo` and remote stream
|
|
|
|
|
|
resolution begin only after the viewer presses Trailer.
|
|
|
|
|
|
- Provider details stay behind the resolver interface. Compose knows only whether a trailer
|
2026-08-12 13:08:53 +12:00
|
|
|
|
exists, and the player knows only how to ask for the next candidate. The client resolver
|
|
|
|
|
|
caches only mappings that passed a media probe; a failed player source is evicted before
|
|
|
|
|
|
fallback.
|
2026-08-12 11:05:07 +12:00
|
|
|
|
- A trailer never uses the Memby pre-roll. Back and natural completion return to the screen
|
|
|
|
|
|
that launched it, while the normal native controls and aspect-ratio handling remain intact.
|
2026-08-12 13:08:53 +12:00
|
|
|
|
The player's title treatment receives the subject's logo URL, with text only as its fallback.
|
2026-08-12 11:05:07 +12:00
|
|
|
|
- Direct-to-Emby mode retains local trailers. Remote Apple and YouTube resolution belongs to
|
2026-08-12 13:08:53 +12:00
|
|
|
|
the gateway-assisted path because it owns metadata discovery and candidate ordering, but
|
|
|
|
|
|
provider resolution itself stays on the television. `POST /v1/items/{id}/trailers/report`
|
|
|
|
|
|
records started, failed and completed attempts with the forwarded client address; successful
|
|
|
|
|
|
candidates become the gateway's next first choice only after a real first frame.
|
2026-08-12 11:05:07 +12:00
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
**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-22 15:32:51 +12:00
|
|
|
|
- **Client-side it is a second screen, not a third section.** The drop-up is 440dp by about
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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
|
2026-08-22 15:32:51 +12:00
|
|
|
|
somebody's film, so there is no scrim and the panel is near-black. It uses the shared
|
|
|
|
|
|
`TvSettingsComponents` rows: focus is a raised control with a white ring, the choice in force
|
|
|
|
|
|
keeps an accent wash and a CURRENT badge after focus moves, and every colour comes from the
|
|
|
|
|
|
active Memby palette. Every other row has no background. The margins are measured off the
|
2026-08-06 22:33:56 +12:00
|
|
|
|
transport row — 44dp end, 112dp bottom to clear its 72dp strip and the controls' 28dp
|
2026-08-22 15:32:51 +12:00
|
|
|
|
padding — and the top margin is what caps the track list before its `ScrollView` starts
|
|
|
|
|
|
scrolling instead of growing up the screen.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
`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
|
2026-08-16 22:08:43 +12:00
|
|
|
|
three times. PlaybackInfo and media bytes use a dedicated dispatcher, so a launcher full
|
|
|
|
|
|
of queued artwork cannot occupy the stream's per-host slots; it still shares the same
|
|
|
|
|
|
connection pool, retaining the warm connection. It also enables constant-bitrate seeking,
|
|
|
|
|
|
so a container with no usable seek table computes the offset instead of reading its way
|
|
|
|
|
|
there. The connection reuse and seeking choices are borrowed from
|
2026-08-06 22:33:56 +12:00
|
|
|
|
[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
|
2026-08-16 22:08:43 +12:00
|
|
|
|
actionable; `event=first_frame … play_clicked=0 … source_resolved=… player_prepared=…
|
|
|
|
|
|
ready=… first_frame=…` is. Marks are cumulative from the Play press, a repeated stage keeps the
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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.
|
2026-08-16 22:08:43 +12:00
|
|
|
|
- **Startup is bounded even when Media3 never throws.** Source resolution has a 15-second
|
|
|
|
|
|
deadline and one fresh foreground retry. After `prepare()`, first frame has a 20-second
|
|
|
|
|
|
deadline; the first expiry stops the player, clears its media items, negotiates a fresh
|
|
|
|
|
|
playback session and prepares again. A second expiry becomes the ordinary retry/exit
|
|
|
|
|
|
error screen. The foreground resolution discards a stale focus-prefetch after a short
|
|
|
|
|
|
grace period rather than awaiting a process-scoped deferred indefinitely. These bounds
|
|
|
|
|
|
are cancelled while the activity is stopped and reinstated on return, so backgrounding
|
|
|
|
|
|
the app is not itself treated as a playback failure.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-17 11:41:36 +12:00
|
|
|
|
pays DNS, TCP, TLS and Emby's file open. 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.
|
|
|
|
|
|
|
|
|
|
|
|
**`StreamWarmer` is what closes that gap** (`data/remote/StreamWarmer.kt`). It opens a
|
|
|
|
|
|
connection to the machine video comes from before anybody asks for video, which is only
|
|
|
|
|
|
worth anything because `HttpStack` shares one connection pool: a connection opened by an
|
|
|
|
|
|
unrelated HEAD request is the connection ExoPlayer picks up later. Nothing is handed over
|
|
|
|
|
|
but the address. Four things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **It warms the host, never the title.** The obvious version asks for the first byte of the
|
|
|
|
|
|
film, which would also warm Emby's file cache — and would put a delivery for a title
|
|
|
|
|
|
nobody watched into somebody's server history. This app already refuses to warm
|
|
|
|
|
|
`PlaybackInfo` on focus for that reason. Any answer establishes the connection, so the
|
|
|
|
|
|
request is a bare HEAD of the origin and a 404 or a 405 is as good as a 200.
|
|
|
|
|
|
- **The address outlives the process.** On the gateway path a resolved stream URL is the only
|
|
|
|
|
|
thing that ever names Emby, so a television that has just started has no idea where the
|
|
|
|
|
|
video lives. Remembering it is what moves the saving to the *first* playback after a cold
|
|
|
|
|
|
start, which is exactly the one that was slowest. It is kept in its own small
|
|
|
|
|
|
`SharedPreferences` file rather than in the settings DataStore, which rewrites and fsyncs
|
|
|
|
|
|
everything it holds on every edit.
|
|
|
|
|
|
- **It is warmed from two places and needs both.** `MembyApp.onCreate` covers the cold start,
|
|
|
|
|
|
and `HomeViewModel.warmDetailPage` covers a viewer who has been browsing for a while —
|
|
|
|
|
|
focus settling on a playable card is the best warning of a Play press this app gets, and
|
|
|
|
|
|
the three-minute interval is shorter than the pool's five-minute keep-alive so that press
|
|
|
|
|
|
meets a live connection rather than one the pool has just evicted.
|
|
|
|
|
|
- **Failure is silent and costs nothing.** It is a performance hint: a server asleep, an
|
|
|
|
|
|
address that has moved, or no network at all each leave playback exactly as slow as it was
|
|
|
|
|
|
before. A failed warm clears the interval so the next attempt is not held off for minutes.
|
|
|
|
|
|
`originOf` is the pure half and is unit-tested — a warm aimed at the wrong host is worse
|
|
|
|
|
|
than none, since it opens a connection nothing will use and leaves the one that matters
|
|
|
|
|
|
cold.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
|
2026-08-18 08:41:48 +12:00
|
|
|
|
**An advance opens the next episode's stream before anybody asks for it.** `StreamWarmer`
|
|
|
|
|
|
warms the *host*; this warms the title, which it can only do here because an advance is the
|
|
|
|
|
|
one case where the app knows what is next minutes ahead — `NextUpResolver` has the answer
|
|
|
|
|
|
five minutes before the credits (`NEXT_UP_STREAM_WARM_LEAD_MS`). Media3's
|
|
|
|
|
|
`DefaultPreloadManager` does the work; `ui/player/NextEpisodePreloader.kt` owns the two
|
|
|
|
|
|
decisions it cannot make for itself, and `PreloadPlan.kt` holds them as pure functions so
|
|
|
|
|
|
they can be pinned by plain JUnit. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **The player and the manager are built from one builder.**
|
|
|
|
|
|
`DefaultPreloadManager.Builder.buildExoPlayer` *overwrites* the media source factory,
|
|
|
|
|
|
renderers, load control, bandwidth meter, track selector and playback looper on whatever
|
|
|
|
|
|
`ExoPlayer.Builder` it is handed, so everything shared is set on the manager's builder and
|
|
|
|
|
|
the player's carries only what the manager has no opinion about. The looper is the one
|
|
|
|
|
|
that would actually break: a source prepared on one playback thread and played on another
|
|
|
|
|
|
is a crash, not a slow start.
|
|
|
|
|
|
- **Ranking data is a position in the journey, not a playlist index.** Nothing here is a
|
|
|
|
|
|
playlist — the player is handed one episode at a time and the next is discovered while it
|
|
|
|
|
|
plays — so a rank is assigned when an answer arrives and only ever moves forward. Exactly
|
|
|
|
|
|
one episode ahead is preloaded (`preloadTargetFor`); two would double the cost for a
|
|
|
|
|
|
viewer who has two episodes' worth of time to walk away.
|
|
|
|
|
|
- **Eviction never touches the episode playing.** On an advance the player has just been
|
|
|
|
|
|
handed that episode's `MediaSource`, and `remove` releases the source underneath the
|
|
|
|
|
|
decoder using it. `obsoletePreloadRanks` is strictly-behind for that reason, `advanceTo`
|
|
|
|
|
|
is called *after* `startMedia` and not before it, and the entry for the playing episode is
|
|
|
|
|
|
left in the manager to be quietened by its target status turning to
|
|
|
|
|
|
`PRELOAD_STATUS_NOT_PRELOADED` on the next `invalidate`.
|
|
|
|
|
|
- **The bound is a memory ceiling first.** `PRELOAD_RANGE_MS` is five seconds because the
|
|
|
|
|
|
expensive half of starting a stream is the connection, the container header and the seek
|
|
|
|
|
|
index — which `specifiedRangeLoaded` pays by preparing the source and selecting tracks —
|
|
|
|
|
|
not the bytes. A 4K direct play runs past 30 Mbps, so every second held ahead is megabytes
|
|
|
|
|
|
on a box with none spare, for a title the viewer may not go on to.
|
|
|
|
|
|
- **Registration hangs off the resolver, not off one call site.** `NextUpResolver`'s
|
|
|
|
|
|
`onResolved` fires for the first lookup *and* for every re-negotiation of a stale stream,
|
|
|
|
|
|
and a re-negotiation is exactly when preloaded work stops matching the URL the player will
|
|
|
|
|
|
be handed. It fires only for the episode still playing, or an answer that arrived after
|
|
|
|
|
|
the viewer moved on would have the preloader open a connection for a journey that no
|
|
|
|
|
|
longer exists.
|
|
|
|
|
|
- **Every part of it degrades to what came before.** A manager that will not build leaves
|
|
|
|
|
|
the preloader unattached, `sourceFor` answers null and `startMedia` takes the ordinary
|
|
|
|
|
|
`setMediaItem` path — which is also what a cold start, the direct-to-Emby path, a retry
|
|
|
|
|
|
that re-negotiated, and an unfinished preload all take. That fallback is *logged*
|
|
|
|
|
|
(`event=preload_unavailable`), because it is invisible from the viewer's side and a set
|
|
|
|
|
|
that never preloads anything otherwise looks exactly like one where the feature works and
|
|
|
|
|
|
never happens to save time.
|
|
|
|
|
|
- **`PRELOADING_ENABLED` and `DYNAMIC_SCHEDULING_ENABLED` are separate switches** in
|
|
|
|
|
|
`PlayerEngine`, the `THEME_PICKER_ENABLED` precedent, so a television that misbehaves on
|
|
|
|
|
|
Media3's experimental scheduling can have that taken away without losing preloading or the
|
|
|
|
|
|
version bump underneath both.
|
|
|
|
|
|
- **`event=first_frame` says which start it is measuring** (`start=cold` / `start=preloaded`).
|
|
|
|
|
|
The whole feature is a claim about one of two latencies, and a log that could not separate
|
|
|
|
|
|
them could not show whether it worked. `event=preload_ready` carries how long the preload
|
|
|
|
|
|
itself took and the range it was bounded to.
|
|
|
|
|
|
|
|
|
|
|
|
**Media3 is one version across every artifact, and the Jellyfin FFmpeg extension pins which
|
|
|
|
|
|
one that can be.** That extension is compiled against `media3-exoplayer` and reached
|
|
|
|
|
|
*reflectively* through `EXTENSION_RENDERER_MODE_ON`, so a core from a different minor line
|
|
|
|
|
|
fails at renderer construction rather than at compile time — and `PlayerEngine`'s
|
|
|
|
|
|
`LinkageError` fallback would swallow it, silently withdrawing surround software decode with
|
|
|
|
|
|
nothing in the log to say why. Jellyfin publishes up to the 1.9 line, so `media3Version` in
|
|
|
|
|
|
`app/build.gradle.kts` is on it. `enablePerStreamMediaProgression` arrived in 1.11 and is
|
|
|
|
|
|
therefore not available here; `experimentalSetDynamicSchedulingEnabled` is the part of that
|
|
|
|
|
|
same work which is. Moving the core past 1.9 means finding a matching extension first, or
|
|
|
|
|
|
deciding to do without DTS.
|
|
|
|
|
|
|
2026-08-19 06:57:59 +12:00
|
|
|
|
**Where a title resumes from is the television's own answer, not the card's.** The launcher
|
|
|
|
|
|
hands `PlaybackRequest` the position it read off the card, and *nothing downstream corrects
|
|
|
|
|
|
it*: the direct path resolves no position at all, and the gateway takes `resumePositionMs`
|
|
|
|
|
|
as a hint (`playbackHint`) and echoes it back rather than paying a second Emby lookup. So a
|
|
|
|
|
|
card is the whole answer, and a card is only as fresh as the last home refresh — which is
|
|
|
|
|
|
how a short session came to be lost. Somebody watched twenty seconds, left, and pressed Play
|
|
|
|
|
|
again inside the second or two the stop report and the refresh take, and was sent back to
|
|
|
|
|
|
where they had started. `EmbyRepository.localResume` is what closes that window: the playhead
|
|
|
|
|
|
is recorded **before** the report is attempted, from `enqueuePlaybackStopped` synchronously
|
|
|
|
|
|
and from every progress report, and `launchResumePositionMs` consults it at
|
|
|
|
|
|
`playbackRequest()` — the one funnel every launch passes through, and therefore also what
|
|
|
|
|
|
opens the player and what the gateway is given as its hint. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **It is a *greatest*, never a preference**, which is what retires the record with no
|
|
|
|
|
|
bookkeeping at all: once a refresh brings the card back carrying that position — or a later
|
|
|
|
|
|
one, watched on another set — the card is at least as current and the local record can no
|
|
|
|
|
|
longer change the answer. `LOCAL_RESUME_MAX_AGE_MS` is only a backstop for a set that
|
|
|
|
|
|
recorded a position and was then left alone.
|
|
|
|
|
|
- **A completed title is forgotten rather than remembered.** The server resets a finished
|
|
|
|
|
|
title's position, so a record kept past that would drop somebody into the closing minutes of
|
|
|
|
|
|
something they had deliberately started again. That is the only reason `durationMs` is
|
|
|
|
|
|
carried through `PlaybackStopWorker.enqueue` — zero means the runtime was not known, never
|
|
|
|
|
|
that the title is zero long. Marking watched or unwatched, and hiding a title from the
|
|
|
|
|
|
shelf, forget it for the same reason.
|
|
|
|
|
|
- **The launcher is told before the server is.** `repository.playbackPositions` is emitted as
|
|
|
|
|
|
the player exits and `HomeViewModel.applyPlaybackPosition` moves the card's progress bar
|
|
|
|
|
|
immediately, ahead of the `playbackStops` refresh that replaces it with the server's own
|
|
|
|
|
|
answer — and it never moves a card backwards, because a stop and the ten-second report
|
|
|
|
|
|
before it can arrive in either order. It is also what puts the new position into the
|
|
|
|
|
|
persisted `HomeCache`, so a cold start does not draw the old one.
|
|
|
|
|
|
- **`clearPlayableCache` deliberately does not touch it.** A playback stop clears that cache,
|
|
|
|
|
|
and outliving it to answer the launch that follows is the entire point. Session changes call
|
|
|
|
|
|
`clearLocalResume` beside it — another viewer's playheads are not this one's.
|
|
|
|
|
|
|
2026-08-17 07:34:23 +12:00
|
|
|
|
**Playback position has one ordered exit path.** Ten-second progress updates, pause/seek
|
|
|
|
|
|
updates and the final Stop all pass through `EmbyRepository`'s `playbackReportMutex`, so a
|
|
|
|
|
|
slow older Progress request cannot complete after Stop and move Emby's saved playhead back.
|
|
|
|
|
|
`PlaybackStopWorker.enqueue` still writes the final position to WorkManager first, but also
|
|
|
|
|
|
sends it immediately from the repository's process scope — leaving the activity no longer
|
|
|
|
|
|
means waiting for WorkManager before Emby Web can resume at the right frame. A successful
|
|
|
|
|
|
immediate delivery cancels that exact fallback request, not the unique work name, because a
|
|
|
|
|
|
newer stop for the same playback session may already have replaced it. The worker drops a
|
|
|
|
|
|
report after 60 seconds: a late retry overwriting progress made in another Emby client is
|
|
|
|
|
|
worse than losing an old fallback. `PlaybackStopWorkerTest` pins that freshness boundary.
|
|
|
|
|
|
|
|
|
|
|
|
**Playback keeps the selected title visible while it starts.** The launcher hands
|
|
|
|
|
|
`PlayerActivity` the backdrop it already has, and `player_loading.xml` holds that artwork
|
|
|
|
|
|
under a dark wash rather than replacing it with an almost-black field. This is intent
|
|
|
|
|
|
metadata, not another playback-path request; it is saved across recreation and replaced by
|
|
|
|
|
|
the next episode's landscape artwork during auto-advance. On the station-style pre-roll, a
|
|
|
|
|
|
television episode replaces the Memby mark at top-left with the programme's own logo and
|
|
|
|
|
|
puts its episode code and title directly beneath it. The same hierarchy is repeated by the
|
|
|
|
|
|
five-second ident once the first frame lands. `seriesName` and `episodeCode` travel with the
|
|
|
|
|
|
resolved `Playable`; a missing or failed logo falls back to the series name instead of
|
|
|
|
|
|
leaving an empty corner. `PrerollScreenshotTest` and `PlaybackIdentityScreenshotTest`
|
|
|
|
|
|
record the pre-roll, the first-frame ident and the backdrop loading state under
|
|
|
|
|
|
`build/screenshots/sonarr-preroll/`, `build/screenshots/playback-identity/` and
|
|
|
|
|
|
`build/screenshots/playback-loading/`.
|
|
|
|
|
|
|
2026-08-19 06:57:59 +12:00
|
|
|
|
**The station ident is one announcement, and the corner it uses has one owner.** It read as
|
|
|
|
|
|
duplicated or overlapping — for films and episodes alike, intermittently — and nothing was
|
|
|
|
|
|
ever drawn twice: the ident (`player_playback_identity`, activity-owned, above the
|
|
|
|
|
|
`PlayerView`, top-start at 48/34dp) and the transport controller's own
|
|
|
|
|
|
`player_now_playing_group` (inside `memby_player_controls.xml`, top-start at 48/30dp) are the
|
|
|
|
|
|
same logo and the same title, four density pixels apart, bound by two methods
|
|
|
|
|
|
(`setUpPlaybackIdentity` and `bindTitleArtwork`) that had never heard of each other. Whenever
|
|
|
|
|
|
the transport happened to be up inside the ident's five seconds — a remote press, a pause,
|
|
|
|
|
|
closing the cast or subtitle overlay, media3's own `auto_show` — both drew, which is exactly
|
|
|
|
|
|
why it was intermittent. `ui/player/PlaybackIdentity.kt` holds the rule instead. Things to
|
|
|
|
|
|
preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **`playerIdentitySlot` is the whole of the priority**, pure and tested: paused outranks the
|
|
|
|
|
|
transport, the transport outranks the ident. Pause is `NONE` because the pause overlay
|
|
|
|
|
|
already carries the poster, the title and the synopsis — a logo above it is the same
|
|
|
|
|
|
programme said twice in two type sizes. `applyIdentityRegion` is the only thing that sets
|
|
|
|
|
|
`nowPlayingGroup`'s visibility; `updatePauseOverlay` setting it directly is what the defect
|
|
|
|
|
|
looked like from the transport's side.
|
|
|
|
|
|
- **The transport's arrival *ends* the ident** rather than moving or fading it. Of the three
|
|
|
|
|
|
possible rules that is the only one that never has two answers to "what is playing" on
|
|
|
|
|
|
screen at once, and it needs no measured choreography between two layouts that know nothing
|
|
|
|
|
|
about each other. `PlayerView.ControllerVisibilityListener` is the only honest source of
|
|
|
|
|
|
that state — media3 raises the transport for reasons the activity never hears about.
|
|
|
|
|
|
- **The ident is a phase, never a boolean** (`PlaybackIdentityPhase`, `shouldRaiseIdent`).
|
|
|
|
|
|
"Has not opened yet" and "has already had its turn" are different answers to an arriving
|
|
|
|
|
|
playback-started event, and every launch reports one at least once — first frame, pre-roll
|
|
|
|
|
|
hand-off, a recovery re-prepare. A withheld ident is *spent*, not deferred, or it would
|
|
|
|
|
|
appear seconds into the programme when the controls timed out.
|
|
|
|
|
|
- **`setUpPlaybackIdentity` binds and never shows.** `adoptPlayable` re-binds on every gateway
|
|
|
|
|
|
launch once the server settles which episode it is, so a bind that could also raise the
|
|
|
|
|
|
ident is a second one.
|
|
|
|
|
|
- **`resetPlaybackIdentity` is called wherever the *subject* changes inside a player the
|
|
|
|
|
|
viewer never left** — an episode advance, a trailer resolving, a next-episode preview and
|
|
|
|
|
|
the return from a failed one. Those are the paths that would otherwise carry the outgoing
|
|
|
|
|
|
title's spent ident into the incoming title, or leave the outgoing one's fade running over
|
|
|
|
|
|
it.
|
|
|
|
|
|
- **The logo and its text fallback both start hidden.** The fallback was `visible` in the
|
|
|
|
|
|
layout and only hidden when Coil reported success, so a title with a logo showed its name
|
|
|
|
|
|
and then its logo — a swap in the same corner that reads as the ident drawing twice.
|
|
|
|
|
|
- **The episode line is subordinate and fixed in place.** The logo sits in a fixed 82dp box
|
|
|
|
|
|
aligned to its bottom, so `S01E01 — Bob Smith` lands at the same height whatever the
|
|
|
|
|
|
artwork's proportions are and whether there is artwork at all; the line itself is a compact
|
|
|
|
|
|
white-on-near-black plate, one line, ellipsised, so a long episode title cannot reach the
|
|
|
|
|
|
middle of the screen. A film has no line at all — nothing is reserved for one.
|
|
|
|
|
|
|
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-22 15:32:51 +12:00
|
|
|
|
**Cold start is a local poster snapshot, never a live grid.**
|
|
|
|
|
|
`data/StartupPosterSnapshotCache.kt` takes a balanced sample of films and series from the
|
|
|
|
|
|
active viewer's Continue Watching, favourites, server-composed recommendation rows and
|
|
|
|
|
|
latest films *after* Home's refresh has settled. On `Dispatchers.IO` it asks Coil for bounded
|
|
|
|
|
|
220×330 poster requests, composes them into one 1280×720 angled wall and atomically writes a
|
|
|
|
|
|
small JPEG under `filesDir/startup_posters/`, keyed by server, Emby user and Memby viewer.
|
|
|
|
|
|
The next cold start performs only a file existence check and one asynchronous local decode;
|
|
|
|
|
|
no URL is ever handed to `StartupPosterBackdrop`, and no list of poster bitmaps exists in
|
|
|
|
|
|
composition. Snapshots refresh at most daily and the directory retains eight profiles.
|
2026-08-10 09:39:09 +12:00
|
|
|
|
|
2026-08-22 15:32:51 +12:00
|
|
|
|
The bundled `drawable-nodpi/startup_poster_fallback.png` is a rights-safe wall of fictional
|
|
|
|
|
|
artwork. `Theme.Memby.Launcher` draws it as MainActivity's window background before Compose
|
|
|
|
|
|
exists, while `MembyLoadingScreen` uses the same resource as Coil's placeholder and gently
|
|
|
|
|
|
crossfades a personalised file over it. One ten-second value supplies a tiny pan and scale
|
|
|
|
|
|
inside `graphicsLayer`, then holds its final frame; frame-rate reads invalidate the layer
|
|
|
|
|
|
rather than recomposing the wall. It has no focus targets and no launch hold: `AppRoot`
|
|
|
|
|
|
calls it from one opening call site and uncovers the next screen the instant update, session
|
|
|
|
|
|
and onboarding state allow it. The local video pre-roll remains playback-only; startup
|
|
|
|
|
|
never borrows its
|
|
|
|
|
|
decoder and never holds the launcher for branding.
|
|
|
|
|
|
|
|
|
|
|
|
**Welcome copy lives in `ui/WelcomeQuotes.kt`.** Twenty-five lines per tone, because five
|
|
|
|
|
|
per tone meant a household saw the same sentence roughly every fifth time it appeared.
|
2026-08-10 09:39:09 +12:00
|
|
|
|
`WelcomeQuotesTest` counts the distinct lines a pool yields: a pool that shrank back, or
|
2026-08-22 15:32:51 +12:00
|
|
|
|
gained a duplicate on a copy-paste, looks identical to one that did not. The greeting is
|
|
|
|
|
|
assembled separately so the authenticated name is trimmed once; the player also draws from
|
|
|
|
|
|
the same tone-specific pool while it prepares a programme.
|
2026-08-10 09:39:09 +12:00
|
|
|
|
|
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-17 11:41:36 +12:00
|
|
|
|
**One next-item pipeline.** `ui/player/NextUpPipeline.kt` owns the answer to "what plays
|
|
|
|
|
|
after this?", and everything that wants to know reads it from there: the manual Next Episode
|
|
|
|
|
|
button, the next-up banner, the credits pane, the countdown and the ended frame. Before it,
|
|
|
|
|
|
each of those read one `nextEpisode` field which was populated only when
|
|
|
|
|
|
`Settings.autoPlayNextEpisode` was on — so with the setting off the field was permanently
|
|
|
|
|
|
null and the credits pane, the banner and the completion handler were code nothing could
|
|
|
|
|
|
reach. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **Resolution and automatic advance are two decisions, not one.** `NextUpResolver` resolves
|
|
|
|
|
|
unconditionally for episodic content and `shouldAutoAdvance` is the *only* place the
|
|
|
|
|
|
viewer's setting is read. Somebody who has turned automatic advance off has said they want
|
|
|
|
|
|
to press something, not that they want to be returned to the launcher and made to find the
|
|
|
|
|
|
next episode by hand. Putting that setting back on the lookup restores the original defect.
|
|
|
|
|
|
- **Every trigger goes through `startNextEpisode`**, which claims `advanceRequested`
|
|
|
|
|
|
synchronously. `advancing` cannot do that job: it is not set until the coroutine that
|
|
|
|
|
|
re-negotiates the stream returns, and the credits marker, a 250 ms progress tick and a
|
|
|
|
|
|
button press all land well inside that window — which is how one press used to be able to
|
|
|
|
|
|
produce two advances.
|
|
|
|
|
|
- **The stream is re-negotiated before it is used, never after it fails.** The URL and play
|
|
|
|
|
|
session a lookup returns were obtained when the *current* episode started, which on a
|
|
|
|
|
|
full-length episode is long enough ago for Emby to have expired the session — the
|
|
|
|
|
|
intermittent failure to establish the stream on an advance. `NextUpResolver.playable`
|
|
|
|
|
|
re-fetches past `STREAM_FRESHNESS_MS`, and `warm()` does it ahead of the credits so the
|
|
|
|
|
|
press itself waits on nothing. The metadata is not re-fetched with it: the banner and the
|
|
|
|
|
|
button draw from the stale copy immediately, because only the stream goes off.
|
|
|
|
|
|
- **The resolver is single-flight and keyed on the subject.** Repeated callers join the
|
|
|
|
|
|
outstanding request rather than starting another, and `begin` discards the outgoing
|
|
|
|
|
|
episode's answer so it can never be offered against the incoming one. `resolvedAt` is
|
|
|
|
|
|
nullable rather than a zero sentinel, because `elapsedRealtime` is time since boot and is
|
|
|
|
|
|
legitimately near zero on a television that has just been switched on.
|
|
|
|
|
|
- **The two optional transport controls are removed, not disabled.** `player_next_episode`
|
|
|
|
|
|
and `player_magic` in `memby_player_controls.xml` are `gone` until they mean something: a
|
|
|
|
|
|
television is driven by a D-pad, and a greyed button is a stop on the way to the one the
|
|
|
|
|
|
viewer wanted. Their rules are the pure `shouldOfferNextEpisodeButton` /
|
|
|
|
|
|
`shouldOfferMagicButton`, unit-tested in `NextUpPipelineTest`.
|
|
|
|
|
|
|
|
|
|
|
|
**Magic is "put something on and don't ask me what".** `POST /v1/magic` →
|
|
|
|
|
|
`recommend.MagicPick`, which was written in 0.2.68 and had no route, no client and no way of
|
|
|
|
|
|
being pressed until now. It is the movie player's control and Next Episode is the episode
|
|
|
|
|
|
player's: the picker is films only (`onlyMovies`), on the reasoning that the button plays
|
|
|
|
|
|
something immediately and a series is a question about which episode — and beside an explicit
|
|
|
|
|
|
Next Episode action, a second "play something else" control is two answers to one question.
|
|
|
|
|
|
Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **The exclusions are the client's.** The film playing now and the last `MAGIC_MEMORY` picks
|
|
|
|
|
|
travel with the request, because it is the player that knows what it has already put in
|
|
|
|
|
|
front of somebody. That is why it is a POST: a query string that lengthens with every press
|
|
|
|
|
|
is one something in the middle eventually truncates, and the failure would be silent
|
|
|
|
|
|
repetition.
|
|
|
|
|
|
- **It is never cached.** The whole point is that pressing it twice gives two answers.
|
|
|
|
|
|
- **Every way it can fail is the same answer to the viewer.** The direct path has nobody to
|
|
|
|
|
|
ask, a gateway predating the route answers 404, and a household with nothing unseen left
|
|
|
|
|
|
answers 404 too — all three withdraw the button rather than showing an error over somebody's
|
|
|
|
|
|
film. Hence `magicAvailable` starting at `ServerConfig.isGateway` and switching off on the
|
|
|
|
|
|
first unanswerable press.
|
|
|
|
|
|
- **The pick is announced before the picture changes.** A button that silently replaces the
|
|
|
|
|
|
programme is one nobody presses twice.
|
|
|
|
|
|
- **A film is a new subject, not the next step of this one**, so it launches through the
|
|
|
|
|
|
ordinary `PlaybackRequest` intent rather than through `playNext`: a fresh session, a fresh
|
|
|
|
|
|
pre-roll decision, exactly as pressing Play on its detail page gives. Nothing is negotiated
|
|
|
|
|
|
before the viewer has been shown what was chosen, the same rule that keeps `PlaybackInfo`
|
|
|
|
|
|
off the focus-warming path.
|
|
|
|
|
|
|
2026-08-12 13:08:53 +12:00
|
|
|
|
The optional **next-episode recap or preview** extends that same auto-advance path. Once the
|
|
|
|
|
|
next episode is known, the television searches YouTube in the background using series name,
|
|
|
|
|
|
episode code and title, ranks official previews and recaps above reactions, reviews and
|
|
|
|
|
|
breakdowns, resolves the best playable native stream, and caches the result. Playback never
|
2026-08-21 09:54:44 +12:00
|
|
|
|
waits for this work. If **both** settings are on and the playhead naturally crosses two
|
|
|
|
|
|
minutes remaining, the preview replaces the closing credits and then advances to the episode.
|
|
|
|
|
|
Seeking straight into that window does not trigger it. A failure before the first preview
|
|
|
|
|
|
frame silently restores the current episode at its saved position; a failure after that frame
|
|
|
|
|
|
moves on to the next episode, because the outgoing episode has already been closed. Preview
|
|
|
|
|
|
time is never reported to Emby as episode progress, and no YouTube account or embedded web
|
|
|
|
|
|
player is involved.
|
|
|
|
|
|
|
|
|
|
|
|
**Both settings**, and that is the one thing to preserve here. This is the exception to
|
|
|
|
|
|
`shouldAutoAdvance` being the only reader of `autoPlayNextEpisode`: a preview is not an offer
|
|
|
|
|
|
a viewer can decline the way the banner and the credits pane are — it *takes over playback*
|
|
|
|
|
|
two minutes from the end and rolls into the next episode on its own — so it is part of the
|
|
|
|
|
|
automatic transition and answers to the same switch. It read only
|
|
|
|
|
|
`playNextEpisodePreview`, which meant a viewer who had turned automatic advance off still had
|
|
|
|
|
|
their episode cut away two minutes early and the next one started for them, which is exactly
|
|
|
|
|
|
what "auto-play is off and it still plays the next episode" looks like from the sofa; the
|
|
|
|
|
|
toggle's own description in Settings had promised the dependency all along and nothing
|
|
|
|
|
|
enforced it. It is checked in three places for three different reasons and all three are
|
|
|
|
|
|
needed: the *prefetch*, so a provider search is not spent on something that must never play;
|
|
|
|
|
|
`shouldStartNextEpisodePreview`, at the moment of use, because this is a synced per-profile
|
|
|
|
|
|
preference and a document pushed from another television or from the admin console arrives
|
|
|
|
|
|
mid-episode; and both ways a preview *ends* (`completeNextEpisodePreview`,
|
|
|
|
|
|
`resumeEpisodeAfterPreviewFailure` past the outgoing report), which is the setting having been
|
|
|
|
|
|
switched off while the preview played — the transition it was the opening of has to go with
|
|
|
|
|
|
it. `autoPlayNextEpisodeEnabled` is the one live reader, never a value captured when playback
|
|
|
|
|
|
started.
|
2026-08-12 13:08:53 +12:00
|
|
|
|
|
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
|
2026-08-16 22:08:43 +12:00
|
|
|
|
enough to throw somebody minutes down a film they meant to nudge. `DiscreteSeekPresses`
|
|
|
|
|
|
remembers the physical DOWN until its matching UP because some remotes report every held
|
|
|
|
|
|
repeat with `repeatCount == 0`; all repeats are consumed rather than acted on, so letting
|
|
|
|
|
|
go does not open the transport either.
|
2026-08-06 22:33:56 +12:00
|
|
|
|
- **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
|
2026-08-17 11:41:36 +12:00
|
|
|
|
last episode of a season both correctly get nothing. It does **not** inherit auto-play's
|
|
|
|
|
|
switch — see "One next-item pipeline" below. It used to, because nothing resolved a next
|
|
|
|
|
|
episode when that was off, which meant this pane and the banner were unreachable code on
|
|
|
|
|
|
every set whose viewer preferred to press something. The pane still appears and its Play
|
|
|
|
|
|
still works; only the countdown, which promises a transition that will happen by itself,
|
|
|
|
|
|
is conditional on the setting.
|
2026-08-08 16:56:18 +12:00
|
|
|
|
- **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-08-17 11:41:36 +12:00
|
|
|
|
**`StartupTrace` times the two waits either side of playback** — opening the app, and
|
|
|
|
|
|
opening a page — because "the launcher feels slow" is no more actionable than "playback is
|
|
|
|
|
|
slow" was, and `PlaybackTrace` already answers the second. Two shapes, because there are two
|
|
|
|
|
|
kinds of question: **launch milestones** (`home_visible`, `first_row_visible`,
|
|
|
|
|
|
`home_interactive`) are cumulative from process start, in the way playback's marks are
|
|
|
|
|
|
cumulative from the Play press, and are recorded once — a relaunch is a new process, and an
|
|
|
|
|
|
activity Android recreated behind somebody is not a second launch; **spans** are repeatable,
|
|
|
|
|
|
which is the shape a detail page needs, since a viewer opens many in a session and each is
|
|
|
|
|
|
its own wait. It is debug-only, like `PerformanceMonitor`, and every entry point returns
|
|
|
|
|
|
before allocating on a release build — a television has no log anybody reads, so in
|
|
|
|
|
|
production these would be pure cost. The milestones are recorded from `LaunchedEffect`s and
|
|
|
|
|
|
never from a composable body: measuring the launcher must never be a reason the launcher
|
|
|
|
|
|
recomposes. Read them with `adb logcat -s MembyStartup`, and the playback half with
|
|
|
|
|
|
`MembyPlayback`.
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-08-20 07:54:03 +12:00
|
|
|
|
**A logo is fetched once and judged once.** `ui/TitleLogo.kt` holds both halves, and both
|
|
|
|
|
|
were being repeated on every screen that drew a title treatment — visible to a viewer as a
|
|
|
|
|
|
detail page returned to a second time showing the show's *name* and then swapping to its
|
|
|
|
|
|
logo. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **The darkness probe must not use Coil's memory cache.** That cache is keyed on the
|
|
|
|
|
|
image's URL and **not** on the size it was decoded at, so the 64×64 inspection copy was
|
|
|
|
|
|
written over the full-size bitmap the launcher had just cached: every page that probed a
|
|
|
|
|
|
logo threw away the copy the previous page had paid for, and the next display request
|
|
|
|
|
|
decoded it again. `probeLogoDarkness` sets `memoryCachePolicy(CachePolicy.DISABLED)` and
|
|
|
|
|
|
leaves the *disk* cache on deliberately — the bytes are worth keeping, and they are what
|
|
|
|
|
|
the display request reads instead of the network.
|
|
|
|
|
|
- **The verdict is remembered, and `useTextTitleForLogo` reads it as its initial value.**
|
|
|
|
|
|
`produceState` starting from "use the text title" is the right default the first time a
|
|
|
|
|
|
title is seen and a visible flash every time after it, since the answer cannot change.
|
|
|
|
|
|
`TitleLogoCache` is process-scoped, access-ordered and capped; concurrent asks for one
|
|
|
|
|
|
logo share a single probe on the cache's *own* scope, because the callers are a detail
|
|
|
|
|
|
page's `produceState` and a focus warm and both die when the D-pad moves.
|
|
|
|
|
|
- **"Could not tell" is not remembered.** A failed or cancelled probe returns null and is
|
|
|
|
|
|
not recorded — a hiccup must not condemn a title to its text heading for the life of the
|
|
|
|
|
|
process. `TitleLogoCacheTest` pins that, the single flight and the eviction.
|
|
|
|
|
|
- **One width, `data/LOGO_MAX_WIDTH` (720).** It was four — 800 in the screensaver, 720 on
|
|
|
|
|
|
the detail page and in the player, 640 on the home hero, 420 in the TV calendar — and a
|
|
|
|
|
|
width is part of the URL, so one show's logo was four cache keys, four fetches and four
|
|
|
|
|
|
verdicts. Ask for the default; a new surface that names its own width quietly opts out of
|
|
|
|
|
|
every cache the others share.
|
|
|
|
|
|
- **Invalidation is free and must stay that way.** An Emby logo URL carries the item id
|
|
|
|
|
|
*and* the image tag, so artwork replaced on the server is a different key in the verdict
|
|
|
|
|
|
cache and in both of Coil's. Nothing is told about it and nothing needs to be.
|
|
|
|
|
|
- **It is warmed on focus** (`HomeViewModel.warmDetailPage`), which is where the bytes and
|
|
|
|
|
|
the verdict both come from by the time a press composes the page. Skipped outright when
|
|
|
|
|
|
the viewer has logos switched off, and `TitleLogoCache.install` in `MembyApp` is what
|
|
|
|
|
|
gives that warm an application context rather than making the ViewModel hold one.
|
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
|
## 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.
|
2026-08-18 14:59:29 +12:00
|
|
|
|
**Icons are the server's answer too.** `ui/theme/MembyIcons.kt` is `DesignTokens.kt` for
|
|
|
|
|
|
marks: an enum of ~70 **slots** named for what they mean (`Search`, `Drama`, `Sparkle`), one
|
|
|
|
|
|
process-wide `mutableStateOf(MembyIconPack)`, and `applyMembyIconPack` to repaint. Before it
|
|
|
|
|
|
the app held seventy literal `Icons.Default.*` across nineteen files, which put icons exactly
|
|
|
|
|
|
where the palette was before its own work: unreachable from the gateway, because the server
|
|
|
|
|
|
can only change what the television has a slot for. The packs are
|
|
|
|
|
|
`ui/theme/MembyIconPack*.kt` — Material (what the app shipped with), Lucide and Font Awesome
|
|
|
|
|
|
Solid, from `com.composables:icons-*-cmp` — and the gateway names one with `iconSet` on the
|
|
|
|
|
|
theme document. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **A slot is named for the job, never for the mark that fills it today.** Filing the
|
|
|
|
|
|
recommendation slot under `AutoAwesome` would describe Material's four stars, and a pack
|
|
|
|
|
|
whose answer is a wand would then sit under a name that lies about it.
|
|
|
|
|
|
- **Nothing may hold a resolved mark.** `MembyIcon.mark` reads process-wide state, so a mark
|
|
|
|
|
|
captured in an `enum` constant or a top-level `val` freezes whichever pack was loaded when
|
|
|
|
|
|
that class initialised — the same `val`-versus-`get()` trap that made `SettingsSheet` the
|
|
|
|
|
|
one screen a palette could never reach. `BrowseDestination`, `SettingsPage` and
|
|
|
|
|
|
`RequestCardAction` therefore carry the **slot** and resolve it where they draw.
|
|
|
|
|
|
- **The marks are lambdas, not vectors.** An `ImageVector` is built when it is first read, so
|
|
|
|
|
|
a map of them would build all seventy on the first frame that touched a pack — on the cold
|
|
|
|
|
|
start, which is the one thing in this app nothing may cost.
|
|
|
|
|
|
- **A pack may be partial, and an absent slot falls back to Material.** Lucide is stroke-only
|
|
|
|
|
|
and has no filled heart, so mapping `Favourite` and `FavouriteOutline` to one glyph would
|
|
|
|
|
|
make "this is a favourite" and "this is not" identical on screen — a pack must never cost
|
|
|
|
|
|
the app a distinction. Font Awesome Solid declines the outline halves for the same reason
|
|
|
|
|
|
from the other side. The mixture is small and is the honest answer.
|
|
|
|
|
|
- **The wire carries a slug and never geometry**, the line the palette already draws: a
|
|
|
|
|
|
gateway that could send paths could draw an unreadable rail, where the worst a pack slug
|
|
|
|
|
|
does is look unchanged. An unknown slug resolves to Material at *both* ends —
|
|
|
|
|
|
`membyIconPackFor` on the television and `knownIconPack` on the gateway, which refuses to
|
|
|
|
|
|
echo a pack nothing can draw.
|
|
|
|
|
|
- **It rides the theme revision**, so it costs no new field on the status poll and no second
|
|
|
|
|
|
sync loop: `ThemeSync` already refetches on a revision it does not hold. The slug is cached
|
|
|
|
|
|
beside the palette and applied *before* any request, and on its own evidence — a set whose
|
|
|
|
|
|
stored palette will not parse still opens wearing the marks it was told to wear.
|
|
|
|
|
|
- **Where it is chosen is the `iconSet` preference**, beside `themeId`, so an operator sets it
|
|
|
|
|
|
per viewer from the console's existing catalogue-driven editor and no admin page was needed.
|
|
|
|
|
|
A **season may replace the marks; a selectable theme may not** (`IconSet` on
|
|
|
|
|
|
`themeDefinition`, the `Decoration` shape) — a season is a look, where a scheme somebody
|
|
|
|
|
|
picked to live with all year taking their marks away leaves no way to tell which of the two
|
|
|
|
|
|
choices did it.
|
|
|
|
|
|
- **R8 is what makes three packs affordable.** Only the slots named in the maps survive out of
|
|
|
|
|
|
packs holding a thousand icons each: measured, two complete packs cost **+16 KB** on the
|
|
|
|
|
|
release APK. A slot nothing draws costs three vectors for nothing.
|
|
|
|
|
|
- **`IconPackScreenshotTest` is the only test that can judge this** (`build/screenshots/
|
|
|
|
|
|
icon-packs/`), the point `ThemeScreenshotTest` makes about palettes. A unit test can check
|
|
|
|
|
|
that a pack names a mark for a slot; it cannot check whether that mark *means* the slot —
|
|
|
|
|
|
it is what caught Lucide's Action genre drawing an award ribbon. Both sizes are captured
|
|
|
|
|
|
because a stroke set has least to spare at the 21dp the rail draws.
|
|
|
|
|
|
|
2026-08-08 16:56:18 +12:00
|
|
|
|
**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
|
2026-08-16 12:13:51 +12:00
|
|
|
|
nothing around them uses. `MembyArtworkPlayCue` is the fourth member and the same rule: the
|
|
|
|
|
|
circular Play mark over a focused artwork card, on the launcher's posters *and* on a detail
|
|
|
|
|
|
page's episode rows, which used to draw their own black disc at their own diameter.
|
|
|
|
|
|
|
|
|
|
|
|
**And one card-focus language on the detail pages**, `Modifier.detailCardFocus` in
|
|
|
|
|
|
`ui/detail/DetailCardFocus.kt`. The cast grid, the "More like this" posters and the Extras
|
|
|
|
|
|
thumbnails had three copies of the same lift-and-grow at 1.06, 1.06 and 1.05 over 110ms,
|
|
|
|
|
|
100ms and 100ms; nobody chose three figures, they were written on three days. The animated
|
|
|
|
|
|
value is read only inside the `graphicsLayer` block, so travelling a grid redraws two cards
|
|
|
|
|
|
rather than recomposing every card in it — which is why it is a modifier and not a wrapper.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
|
2026-08-21 23:15:42 +12:00
|
|
|
|
**"Exit Memby?" is the one full-stop dialog**, `ui/ExitMembyScreen.kt`, drawn when the
|
2026-08-16 09:02:17 +12:00
|
|
|
|
viewer has asked to be asked (`confirmExitMemby`). It is the panel that appears *over*
|
|
|
|
|
|
Memby, so it was the one thing on screen that must not look like somebody else's app — and
|
|
|
|
|
|
it was two identical raw `androidx.tv.material3.Button`s wearing Material's own colours,
|
|
|
|
|
|
which meant the destructive answer and the safe one were the same shape at three metres.
|
|
|
|
|
|
Things to preserve: the two actions **do not look alike** (Stay is the accent fill and takes
|
2026-08-21 23:15:42 +12:00
|
|
|
|
focus first, closing is a quiet outline), and **Back means stay** — it is the key that raised
|
|
|
|
|
|
the panel and pressing it again must not be what closes the app. The treatment stays plain:
|
|
|
|
|
|
a dimmed backdrop, a compact panel, one question and two actions. `focusedForCapture` exists
|
|
|
|
|
|
because Robolectric's window never takes focus and the focus ring is the whole of what says
|
|
|
|
|
|
which action a press would take; `ExitConfirmationScreenshotTest` renders both answers under
|
|
|
|
|
|
it, plus one capture under a foreign palette.
|
2026-08-16 09:02:17 +12:00
|
|
|
|
|
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
|
|
|
|
|
2026-08-20 07:54:03 +12:00
|
|
|
|
**An operator's hero change is live, and it rides the status poll to get there.**
|
|
|
|
|
|
`heroRevision` (`server/internal/api/hero_revision.go`) is the `themeRevision` shape — a
|
|
|
|
|
|
hash rather than a counter, because what a hero resolves to changes when nobody has
|
|
|
|
|
|
written anything: a schedule window opens, the rotation slot turns over. `/v1/status`
|
|
|
|
|
|
carries it as `hero: {revision}`, `MaintenanceMonitor.heroRevision` publishes it, and the
|
|
|
|
|
|
launcher refetches `/v1/heroes/active` when it moves. Before it, a pin reached a set only
|
|
|
|
|
|
at its next cold start: the section heroes were fetched once per session and never again,
|
|
|
|
|
|
and Home's hero arrives with rows the launcher does not re-request while somebody is
|
|
|
|
|
|
sitting on them. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **The revision hashes what would be *resolved*, not the stored document.** `UpdatedAt`
|
|
|
|
|
|
moves on every write, so hashing the row would repaint every launcher in the house for a
|
|
|
|
|
|
save that changed nothing. For the same reason a schedule added for tomorrow evening does
|
|
|
|
|
|
not move it — only the schedules `activeHeroScheduleIDs` says are in force now do, which
|
|
|
|
|
|
is also what makes a window opening at 20:00 arrive by itself.
|
|
|
|
|
|
- **It is the same value in the cache key as on the poll**, in `handleHome` (`:hr`) and in
|
|
|
|
|
|
`hero:active:v2:`. That is what leaves no state in which the poll says something moved and
|
|
|
|
|
|
the fetch that follows returns what the set already had — and it replaced the cache
|
|
|
|
|
|
*sweep*: saving a hero used to call `InvalidateUser` for every account in the house,
|
|
|
|
|
|
throwing away every cached item lookup, row and image the household held in order to
|
|
|
|
|
|
change four cards. Nothing is invalidated now; the entries built under the old policy are
|
|
|
|
|
|
simply no longer named, and age out on their own TTL.
|
|
|
|
|
|
- **The policy is read through `currentHeroPolicy`**, never `store.HeroPolicy` directly.
|
|
|
|
|
|
The status poll is every open television every ten seconds, so this document needed the
|
|
|
|
|
|
`featurePolicyCache` treatment; the admin save invalidates that copy, which is the only
|
|
|
|
|
|
thing that write now does besides writing.
|
|
|
|
|
|
- **Home is not refetched at launch, only when the revision has moved under a set already
|
|
|
|
|
|
looking at it.** Its hero arrives with the home payload, and fetching it separately on
|
|
|
|
|
|
every launch would replace a hero already on screen with a second composition of the same
|
|
|
|
|
|
thing. The refetch replaces one placement at a time and never clears first, so unchanged
|
|
|
|
|
|
cards keep the artwork already decoded for them and nothing blanks between the two states.
|
|
|
|
|
|
- **The revision is collected inside the effect, not read as state in the launcher's body.**
|
|
|
|
|
|
A number arriving on a poll must never be a reason the launcher recomposes — the same rule
|
|
|
|
|
|
the animation and flow-collection conventions state.
|
|
|
|
|
|
|
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`
|
2026-08-16 12:13:51 +12:00
|
|
|
|
in `ui/DetailPageComponents.kt` over the pure vocabulary in `ui/detail/DetailFacts.kt`,
|
|
|
|
|
|
`ui/detail/DetailTabs.kt` and `ui/detail/DetailHeroPhases.kt`. Movie, series and episode are
|
|
|
|
|
|
three sets of parameters to that one scaffold, never three layouts — the shared pieces are
|
|
|
|
|
|
`DetailHeroActions` (so a Continue Watching page cannot grow its own Play button metrics),
|
|
|
|
|
|
`DetailIdentity`, `DetailStripFrame` and `Modifier.detailCardFocus`, and anything that has to
|
|
|
|
|
|
differ between them is a scaffold parameter so the difference is stated in one place. It is a
|
|
|
|
|
|
full-bleed cinematic hero — backdrop under two scrims,
|
2026-08-16 09:02:17 +12:00
|
|
|
|
logo or title, `heroFacts` line (year · length · certificate) with the score and format
|
|
|
|
|
|
badges trailing it, genres, three lines of synopsis, one recommendation reason, then Play
|
|
|
|
|
|
and the circular secondary actions — with an uppercase tab strip on a hairline rule anchored
|
|
|
|
|
|
under it and the tab's content beginning below the fold. `MediaDetailContent` is the movie
|
|
|
|
|
|
page, `SeriesDetailContent` the series one. A movie's sections are Overview, Cast, Extras,
|
|
|
|
|
|
More Like This and Details; a series' are the same with **Episodes** in Overview's place,
|
|
|
|
|
|
because a show's synopsis is already in the hero and what a show is *for* is its episodes.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
|
2026-08-16 09:02:17 +12:00
|
|
|
|
- **The page is a `Column` and nothing scrolls it.** Hero, strip, pane, footer. It used to be
|
|
|
|
|
|
a `LazyColumn` of three items with the list pinned to offset 0 while focus was in the hero,
|
|
|
|
|
|
which meant the hero's own height decided where the strip ended up — so a two-line title
|
|
|
|
|
|
pushed the tabs down and the content with them, and every press into the content was a
|
|
|
|
|
|
scroll the viewer had to watch land.
|
|
|
|
|
|
- **The hero collapses instead.** `detailHeroCollapsed(zone)` is the whole rule and it is
|
|
|
|
|
|
pure and tested: whole while focus is on Play, out of the way the moment focus is below it.
|
|
|
|
|
|
A single `animateFloatAsState` drives it and is read **only inside layout and draw
|
2026-08-16 12:13:51 +12:00
|
|
|
|
lambdas** — `Modifier.collapsingHeight` for the band and `graphicsLayer` for everything
|
|
|
|
|
|
else — so a collapse costs a measure pass rather than recomposing the page sixty times a
|
|
|
|
|
|
second. Two details hold the promise that no focused thing ever resizes: the hero is
|
|
|
|
|
|
measured at its *full* height and slid upward under a clip, so nothing inside it re-wraps;
|
|
|
|
|
|
and the pane takes `weight(1f)`, so it simply receives what the hero gives up.
|
|
|
|
|
|
- **One number drives the whole transformation**, and what each part of it means is
|
|
|
|
|
|
`ui/detail/DetailHeroPhases.kt`. The height, the backdrop's opacity, the deepening wash,
|
|
|
|
|
|
the supporting text, the action row and the pinned header are **bands of that one value**
|
|
|
|
|
|
rather than animations of their own — which is what makes the collapse read as one
|
|
|
|
|
|
movement, and what stops the parts drifting out of step when it is interrupted half way.
|
|
|
|
|
|
`DetailHeroPhaseTest` pins the *ordering* rather than the figures: the supporting half is
|
|
|
|
|
|
always at least as far gone as the primary half (that is progressive disclosure, stated as
|
|
|
|
|
|
an assertion), the two headings are never both on screen, and the plate always leads the
|
|
|
|
|
|
pinned header in. It is a **critically damped spring**, not a tween: a held D-pad produces
|
|
|
|
|
|
a press every few frames, and a spring retargets from wherever it is rather than restarting
|
|
|
|
|
|
a duration — and it can never overshoot past 1, which on a value driving opacities would be
|
|
|
|
|
|
a flicker.
|
|
|
|
|
|
- **The backdrop recedes; it never disappears.** `heroArtworkAlpha` takes it down as the
|
|
|
|
|
|
content takes over and the wash over it deepens as a gradient, so the picture settles back
|
|
|
|
|
|
rather than a sheet being drawn across it. Behind the pinned header is a soft plate
|
|
|
|
|
|
(`pinnedScrimAlpha`) occupying exactly the band the header lands in and fading from nothing
|
|
|
|
|
|
at its top edge — a **gradient, never a bar**: the point is a perceptible separation
|
|
|
|
|
|
between a fixed header and scrolling content, and a solid block reads as a phone toolbar.
|
|
|
|
|
|
- **What is left is `DetailCollapsedHeader`** — the title, the fact line, and on an episode
|
|
|
|
|
|
the show and `S03E04`. Not a shrunken hero: the synopsis, the ratings, the reason and the
|
|
|
|
|
|
actions all answer "is this worth watching", which somebody down in the Cast grid has
|
|
|
|
|
|
already answered. What remains is only what stops the page becoming anonymous. The compact
|
|
|
|
|
|
eyebrow is its own parameter (`pinnedEyebrow`) because "SEASON 3 · EPISODE 4" spelled out
|
|
|
|
|
|
pushes the fact line off the end of a one-line header.
|
|
|
|
|
|
- **The hero is a budget, and the reading column is what pays.** The expanded content is
|
|
|
|
|
|
anchored to the bottom of its band and grows upward, so `DetailHeroMetrics.TopInset` is the
|
|
|
|
|
|
ceiling it may not cross — and with a ceiling, something has to give. What gives is the
|
|
|
|
|
|
**supporting block** (ratings, genres, synopsis, pace, reason), which is the Column's one
|
|
|
|
|
|
`weight(1f, fill = false)` child: weighted children are measured from what the unweighted
|
|
|
|
|
|
ones left over, so a two-line title costs prose rather than costing the primary action its
|
|
|
|
|
|
shape. Getting that inversion wrong is precisely the 0.2.67 regression — an episode page
|
|
|
|
|
|
reached from Continue Watching carries more than any other variant, and the Play button,
|
|
|
|
|
|
being last in the column, was handed whatever height was left and rendered as a squeezed
|
|
|
|
|
|
sliver. It is the same inversion the home hero already makes, and it is now asserted:
|
|
|
|
|
|
`EpisodeDetailScreenshotTest` renders the most crowded hero the app can produce and fails
|
|
|
|
|
|
the build if the primary action is under its natural height.
|
|
|
|
|
|
- **Prose gives way a whole line at a time.** `Modifier.wholeLines` reports a height rounded
|
|
|
|
|
|
down to the last complete line, with the clip *outside* it — a `Text` handed less room
|
|
|
|
|
|
still draws every line it was asked for, and without the clip the dropped lines painted
|
|
|
|
|
|
over whatever the column placed underneath. Two lines instead of three reads as nothing at
|
|
|
|
|
|
all; prose sliced through the middle of its letters reads as a rendering fault.
|
|
|
|
|
|
`DetailHeroMetrics.SynopsisLines` is **two**, not three: the whole description is one press
|
|
|
|
|
|
away in Overview, and the third line was the difference between the movie hero fitting its
|
|
|
|
|
|
band and overflowing it.
|
|
|
|
|
|
- **The identity block reserves its height only where a logo could actually appear.**
|
|
|
|
|
|
Deciding whether a logo is legible means fetching and decoding it, so every page begins on
|
|
|
|
|
|
the text fallback and changes its mind a moment later — a swap that used to move everything
|
|
|
|
|
|
under it on the opening frame. `logoUrl != null` is known synchronously from the image
|
|
|
|
|
|
tags, so a title Emby holds no logo for is drawn at its natural height and gives the space
|
|
|
|
|
|
to the synopsis. Very wide and very tall logos are both a letterboxed picture inside a
|
|
|
|
|
|
fixed box: a logo can never change the page's shape.
|
|
|
|
|
|
- **The ratings strip is the one place it does not reserve.** Most of a library has no
|
|
|
|
|
|
scores — an episode is rated as its series, a household with no MDBList key has none at all
|
|
|
|
|
|
— and 42dp held open for them was 42dp taken off the synopsis on every one of those pages.
|
|
|
|
|
|
It sits inside the flexible block, so a strip arriving late costs a line of prose rather
|
|
|
|
|
|
than moving anything the viewer is aiming at.
|
|
|
|
|
|
- **The circular actions name themselves.** A heart, a tick, a bookmark and a film reel are
|
|
|
|
|
|
guesses at three metres, so the focused one's description is printed on a **reserved** line
|
|
|
|
|
|
under the row — reserved, because a line that appeared when focus reached the second button
|
|
|
|
|
|
would move the whole hero on every press of Right.
|
2026-08-16 09:02:17 +12:00
|
|
|
|
- **A pane fits its slot, or is a grid.** Prose panes (Overview, Details) do not scroll — a
|
|
|
|
|
|
page that scrolls *and* has tabs gives the D-pad two meanings for Down — and now have
|
|
|
|
|
|
roughly three times the room they had, since `detailPaneHeight(viewportHeight)` is what the
|
|
|
|
|
|
collapsed hero and the strip leave rather than a 250dp budget. Cast, Extras and More Like
|
|
|
|
|
|
This *are* grids and scroll inside themselves, which is a different thing: Down inside a
|
|
|
|
|
|
grid still means "next row".
|
|
|
|
|
|
- **The strip keeps a safe-area inset.** `DetailFoldPeek` holds it off the bottom edge while
|
|
|
|
|
|
the hero is whole, where overscan was cutting the selection underline in half, and leaves
|
|
|
|
|
|
the top of the pane showing beneath it. That peek and the chevron at the end of the strip
|
|
|
|
|
|
are the only things on screen saying that Down reveals anything.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
- **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
|
2026-08-16 09:02:17 +12:00
|
|
|
|
would show content that contradicts the highlight. A *click* reports itself as arriving in
|
|
|
|
|
|
the strip too, or a mouse or a test could change the pane and leave the hero over it.
|
2026-08-16 12:13:51 +12:00
|
|
|
|
They are still **marked separately**, and that is not a contradiction: the accent underline
|
|
|
|
|
|
is *selection* — which pane is open — and the plate behind the label is *focus* — where the
|
|
|
|
|
|
remote is. One causes the other while the viewer is in the strip, and they come apart the
|
|
|
|
|
|
moment they press Down, which is the case the distinction exists for. The season scroller
|
|
|
|
|
|
on an episode page marks them the same two ways for the same reason.
|
|
|
|
|
|
- **Both bands wear `DetailStripFrame`** — the plate, the gutters, the hairline and the
|
|
|
|
|
|
chevron. The tab strip and the season scroller define the same fold, and two hand-written
|
|
|
|
|
|
copies of it is exactly how the two pages came to sit their content a couple of pixels
|
|
|
|
|
|
apart.
|
2026-08-16 09:02:17 +12:00
|
|
|
|
- **The strip is content-driven, and frozen once the viewer is inside it.** `detailTabs` is
|
|
|
|
|
|
pure over a `DetailTabAvailability`, and `rememberDetailTabs` recomputes it only while
|
|
|
|
|
|
focus is still in the hero. That is the seam between two real failures: a tab leading to an
|
|
|
|
|
|
apology is dead weight on every set in the house for ever, and a tab appearing a second
|
|
|
|
|
|
after the page opens shoves every tab to its right under a moving thumb. Three rules make
|
|
|
|
|
|
it safe — the landing pane is structural and always first, so the tab under focus on frame
|
|
|
|
|
|
one can never move; optional tabs are only ever *appended* in one order, so one arriving
|
|
|
|
|
|
cannot reorder the rest; and each flag's "still loading" value is chosen by which way it is
|
|
|
|
|
|
usually wrong (related titles exist for nearly everything, so the tab is there while the
|
|
|
|
|
|
request runs; extras exist for a small minority, so the tab waits until one is found).
|
|
|
|
|
|
- **`detailTab(key, available)` falls back to the landing pane, not to Overview.** Three
|
|
|
|
|
|
things arrive with a key that is not on offer and all are ordinary: a series' Episodes
|
|
|
|
|
|
carried to a movie, a tab whose content has gone away, and `cast-details` remembered by a
|
|
|
|
|
|
build that predates the Cast/Details split.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
- **One `FocusRequester` per pane**, never one shared between them. `AnimatedContent` keeps
|
2026-08-16 09:02:17 +12:00
|
|
|
|
the outgoing pane composed for its 80ms fade, so a requester attached by two panes is
|
|
|
|
|
|
attached to two live nodes, and a Down press landing in that window can focus the pane that
|
|
|
|
|
|
is disappearing.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
- **Position is remembered per item** in `ui/detail/DetailPosition.kt` — tab, season, which
|
2026-08-16 09:02:17 +12:00
|
|
|
|
band held focus, the episode rail's offset, and the cast grid's *two* numbers — in a
|
|
|
|
|
|
process-scoped, capped, LRU store outside the composition, because closing a detail overlay
|
|
|
|
|
|
destroys `rememberSaveable` with it. `RestoreDetailFocus` focuses Play first (it exists on
|
|
|
|
|
|
frame one, so the remote is live) and then restores the band, once, only if that band has
|
|
|
|
|
|
something placed to land on. Deliberately not persisted: a TV switched on the next morning
|
2026-08-16 12:13:51 +12:00
|
|
|
|
should open a show where the *show* is up to. The band is reported on the way *in* to it,
|
|
|
|
|
|
never on every focus move inside it — the pane's `hasFocus` fires for each card a viewer
|
|
|
|
|
|
walks past, and each of those was a write into the store, on the one press that must feel
|
|
|
|
|
|
free.
|
|
|
|
|
|
- **Returning from the player comes back to the page it was launched from.**
|
|
|
|
|
|
`MainActivity` holds a `DetailsReturn` — the item, the trail and the airing notice — across
|
|
|
|
|
|
the playback and reopens on `LifecycleResumeEffect`. Deliberately a *reopen* rather than
|
|
|
|
|
|
leaving the page composed under the player: the page has to tell the viewer the truth, and
|
|
|
|
|
|
the episode they just finished is now watched, the film is now part-way through, and the
|
|
|
|
|
|
next episode is a different one, so the item is re-requested on the way in. Reopening also
|
|
|
|
|
|
runs the same restore path a Back press through the trail already uses, which is what
|
|
|
|
|
|
brings back the tab, the season, the grid offset and the band that held focus — rather than
|
|
|
|
|
|
depending on Compose having held focus across another activity's window.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
- 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.
|
2026-08-16 09:02:17 +12:00
|
|
|
|
- **More Like This** is the same response's `items`, as its own tab, and a poster *grid*.
|
|
|
|
|
|
Selecting one opens *its* detail page, and `MainActivity.detailsTrail` walks Back home one
|
|
|
|
|
|
page at a time. On the direct path there is no engine, so `EmbyRepository.getRelated`
|
|
|
|
|
|
returns Emby's `Items/{id}/Similar` with no reasons at all — both halves are allowed to be
|
|
|
|
|
|
empty and the page still opens.
|
|
|
|
|
|
- **Cast is a grid, in `ui/detail/CastGrid.kt`.** It replaced a compact rail wedged into the
|
|
|
|
|
|
corner of a combined "Cast & Details" pane, which showed six faces of a forty-strong cast
|
|
|
|
|
|
and gave the other thirty-four no way of being reached. Things to preserve: the column
|
|
|
|
|
|
count is **fixed** rather than `GridCells.Adaptive`, because Up out of the first row has to
|
|
|
|
|
|
return to the tab strip and the only way to know which cards are in the first row is to
|
|
|
|
|
|
know how many there are — `castColumns` is that arithmetic, pure and tested at every
|
|
|
|
|
|
television width; the card width is tuned so the **second row peeks**, since one row filling
|
|
|
|
|
|
the pane with nothing under it reads as a shelf that failed rather than as a grid;
|
|
|
|
|
|
`castMembers` deduplicates rather than disambiguating, the `ui/ListKeys.kt` rule, because
|
|
|
|
|
|
position is what has to survive coming back to the page; the character line is **reserved,
|
|
|
|
|
|
not conditional**, or a card with no role recorded sits a line higher than the one beside
|
|
|
|
|
|
it; and the initials are drawn *behind* the portrait, the stance the player's cast panel
|
|
|
|
|
|
takes, so nothing has to decide in advance whether artwork will arrive. Selecting a card is
|
|
|
|
|
|
inert for now — a person page is the obvious next thing and the grid already remembers
|
|
|
|
|
|
which card to come back to.
|
|
|
|
|
|
- **Extras is `GET /v1/items/{id}/extras`**, and it is a *join*: Emby answers the question in
|
|
|
|
|
|
two places and neither includes the other, `SpecialFeatures` holding the featurettes and
|
|
|
|
|
|
deleted scenes and `LocalTrailers` holding the trailer. `server/internal/api/extras.go`
|
|
|
|
|
|
joins them (only *both* lookups failing is a failure — a title can perfectly well have one
|
|
|
|
|
|
and not the other) and `dedupeExtras` drops a file filed in both, because the television
|
|
|
|
|
|
renders these into a keyed grid and a repeated key throws. The direct path makes the same
|
|
|
|
|
|
two calls itself. Two client rules: the repository caches the **empty** answer, since most
|
|
|
|
|
|
of a library has none and every page open asks; and a 404 is read as "no extras" rather
|
|
|
|
|
|
than as an error, so a new APK against a gateway that predates the route simply does not
|
|
|
|
|
|
offer the tab. `extraKindLabel` names the kind from Emby's `Type`, spacing out anything it
|
|
|
|
|
|
does not recognise so a category added tomorrow still reads correctly.
|
|
|
|
|
|
- **Details is `detailRows(item)`** beside `technicalSpecs(item)` — catalogue facts on the
|
|
|
|
|
|
left, what the file is on the right. Its vocabulary (Studios, Taglines, PremiereDate,
|
|
|
|
|
|
OriginalTitle, ProductionLocations) is read nowhere else and had to be added to
|
|
|
|
|
|
`fieldsDetail`, which is why the gateway's item cache key moved to `item:v6:` — entries
|
|
|
|
|
|
written before it cannot hide a newly requested field. A row is omitted rather than printed
|
|
|
|
|
|
empty, and Studio is dropped from the file column when the catalogue column already said
|
|
|
|
|
|
it: printed twice side by side it reads as a page that cannot make up its mind.
|
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-16 09:02:17 +12:00
|
|
|
|
- **Nothing is loaded for a tab nobody has opened.** Each request is its own
|
|
|
|
|
|
`LaunchedEffect` in the overlay and none blocks the page: episodes, related, trailer,
|
|
|
|
|
|
extras and ratings all arrive independently, and every one of them is single-flighted and
|
|
|
|
|
|
cached in the repository, so warming a detail page on D-pad focus and then opening it costs
|
|
|
|
|
|
one request rather than two. The pane itself is the second half of that — while the hero is
|
|
|
|
|
|
whole the pane is 34dp, so a grid composes one row until the viewer actually goes there.
|
|
|
|
|
|
- `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer,
|
|
|
|
|
|
extras) and delegate; `SeriesDetailContent`/`MediaDetailContent` are parameter-driven so
|
|
|
|
|
|
they can be screenshotted (`DetailPageScreenshotTest`, which drives the tab strip by
|
|
|
|
|
|
clicking it and also renders each pane on its own at `detailPaneHeight`) without a server.
|
|
|
|
|
|
Because a click now reports itself as arriving in the strip, every tab capture is also the
|
|
|
|
|
|
check that the hero collapsed and the strip moved to the top of the usable area.
|
2026-08-02 22:10:19 +12:00
|
|
|
|
|
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-19 06:57:59 +12:00
|
|
|
|
**A film the household does not own has a page of its own.** The "Upcoming Movie releases"
|
|
|
|
|
|
row is Radarr's, and its cards were the one thing on the launcher that did nothing at all
|
|
|
|
|
|
when pressed: `MembyPlayable` is false, `scheduleSeriesStub` answers only for Sonarr, and
|
|
|
|
|
|
there is no Emby item behind them to open. `GET /v1/radarr/movies/{id}`
|
|
|
|
|
|
(`server/internal/api/radarr_detail.go` → `ui/RadarrMovieDetailsOverlay.kt`) is what they
|
|
|
|
|
|
open instead — the artwork, the description, the genres, the certificate, the studio, the
|
|
|
|
|
|
stored review scores, when it is expected, and a trailer. Things to preserve:
|
|
|
|
|
|
|
|
|
|
|
|
- **Nothing manufactures an Emby item to reuse the movie page.** `radarrMovieDetail` is its
|
|
|
|
|
|
own type on both sides, because the ordinary page's whole shape — Play, resume, a progress
|
|
|
|
|
|
bar, watched state, tabs of cast and extras — is built around a file that exists, and a
|
|
|
|
|
|
page carrying those over a film nobody can watch would be four lies arranged as furniture.
|
|
|
|
|
|
The card itself is passed in for artwork and for the title, which is what makes the page
|
|
|
|
|
|
appear on the press rather than after the request; it is the row's own card, not a stand-in.
|
|
|
|
|
|
- **Which page opens is the gateway's answer, not the television's.** `MembyMovieItemId` is
|
|
|
|
|
|
the `MembySeriesItemId` arrangement — resolved in `embyMovieIndex` by **TMDb id**, since
|
|
|
|
|
|
Radarr writes one and the library import already asks Emby for `ProviderIds`, so unlike the
|
|
|
|
|
|
Sonarr row there is nothing here to match by title. A card carrying one opens the ordinary
|
|
|
|
|
|
movie page through `scheduleMovieStub`; a card carrying none is `BaseItem.isRadarrOnly` and
|
|
|
|
|
|
opens this one. Neither is inert, which is what the card used to be.
|
|
|
|
|
|
- **The detail route resolves the link a second time, and that is not redundancy.** The home
|
|
|
|
|
|
row is cached for the day, so a film imported at lunchtime still arrives on a card with no
|
|
|
|
|
|
Emby id until midnight. `radarrEmbyStub` reads the live answer and hands the viewer to the
|
|
|
|
|
|
ordinary page — which is the whole of "once it is in Emby, the card follows the normal
|
|
|
|
|
|
path", with nothing on either side to invalidate.
|
|
|
|
|
|
- **The trailer joins the existing chain rather than starting a second one.**
|
|
|
|
|
|
`trailerManifest` recognises a `radarr:` id and builds its candidate list from Radarr's
|
|
|
|
|
|
`youTubeTrailerId`, so `/v1/items/{id}/trailers`, `/resolve`, `/report`, the client's
|
|
|
|
|
|
`hasTrailer` cache and the player's walk through candidates all work unchanged on a subject
|
|
|
|
|
|
Emby has never heard of. `trailerAvailable` rides the detail response for the same reason
|
|
|
|
|
|
`subtitleDownloadAvailable` rides the playback one: the button is decided before it is
|
|
|
|
|
|
drawn, so it can never be one that fails after being pressed.
|
|
|
|
|
|
- **Most of the release wording is about refusing to be precise.** `radarrExpectedLabel`
|
|
|
|
|
|
prints a published digital date to the day and the schedule row's cinema-plus-a-month
|
|
|
|
|
|
estimate only to the month — "Expected November 2026" is true where "Expected 14 November
|
|
|
|
|
|
2026" is a date somebody would plan an evening around — and a film with neither is told
|
|
|
|
|
|
plainly rather than guessed at. It is the gateway's wording, the stance every schedule
|
|
|
|
|
|
label takes, so a phrasing added next month reads correctly on today's build; the three
|
|
|
|
|
|
Radarr dates are listed beside it so a viewer can see which one the headline came from.
|
|
|
|
|
|
- **`radarrMovieState` answers about the household's copy, where the lifecycle tag answers
|
|
|
|
|
|
about the film.** Coming Soon, Not Yet Available, Awaiting Release, Not Tracked and Almost
|
|
|
|
|
|
Ready are five different reasons a viewer cannot watch this tonight, and the line under the
|
|
|
|
|
|
state must not repeat the date above it — an unannounced film said "Release date not yet
|
|
|
|
|
|
announced" twice before it said anything about downloading.
|
|
|
|
|
|
- **Ratings come from the household's own store**, keyed by the TMDb or IMDb id Radarr
|
|
|
|
|
|
already holds, rather than from Radarr's own ratings block: two different numbers for one
|
|
|
|
|
|
film under one provider's name is worse than no strip.
|
|
|
|
|
|
- **Radarr is asked for nothing the catalogue can answer.** `radarrMovie` reads the cached
|
|
|
|
|
|
household catalogue first — one request already shared by the whole house — and only falls
|
|
|
|
|
|
back to `/api/v3/movie/{id}` for a title added since. `HomeViewModel.warmDetailPage` warms
|
|
|
|
|
|
it on focus, which is the only warm a schedule card has any use for.
|
|
|
|
|
|
- **The long-press menu is built from a list now, not from hand-written indices.** A Radarr
|
|
|
|
|
|
card has no Emby record to favourite or mark watched and has a trailer where an ordinary
|
|
|
|
|
|
card does not, so what belongs on that menu varies — `QuickAction` derives the focus
|
|
|
|
|
|
indices from the entries rather than leaving four pieces of arithmetic to hold in step.
|
|
|
|
|
|
- **There is no second implementation on the direct path**, the stance the TV calendar takes
|
|
|
|
|
|
and for the same reason: the answer is Radarr's, which a television holds no credential for
|
|
|
|
|
|
and Emby knows nothing about. With no gateway the row does not exist either.
|
|
|
|
|
|
- Screenshots are `RadarrMovieDetailScreenshotTest` → `build/screenshots/radarr-movie/`.
|
|
|
|
|
|
The claim the page makes is that an unwatchable film reads as *deliberately* unavailable
|
|
|
|
|
|
rather than as a page whose Play button failed to load, which is not a thing a unit test
|
|
|
|
|
|
can check.
|
|
|
|
|
|
|
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.
|