170 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
Memby — an Android TV (Leanback) Emby client by ponzischeme89, written in Kotlin +
Compose for TV. It contains two surfaces over one shared data layer: the client app
(setup → profile chooser → home → player) and the system screensaver (DreamService,
labelled "Memby Screensaver"), which was the project's original purpose and still ships in
the same APK.
User-facing name is always Memby: app_name/screensaver_name/developer_name in
res/values/strings.xml, and on-screen copy.
Except on the wire to Emby. The X-Emby-Authorization header identifies the client as
MbyATV, not Memby — that header travels to whatever Emby does with its own logs, and
the product name has no business being what identifies a client to somebody else. It is
sent from two places and they must agree, or one television signing in both ways appears as
two clients: MEMBY_CLIENT_NAME in the gateway (internal/config) and the literal in
EmbyServiceFactory's auth interceptor on the direct path. The Version= beside it is the
television's app version, which on the gateway path means Credentials.ClientVersion
threaded from the session's X-Memby-Version; it was a hardcoded "1.0", so every device
in Emby's dashboard read as the same build and there was no way to tell which set was
behind. A request the gateway makes for itself (library sync, health probe, device cleanup)
carries no session, and reports the gateway's own buildinfo.Version() instead.
Emby* class names (EmbyRepository, EmbyApi, EmbyServiceFactory, EmbyModels) are
kept on purpose: those types model Emby's API, and renaming them would make the code
lie about what it talks to. App-identity types are Memby*.
Repository layout
This is a two-language monorepo. app/ and benchmark/ are the Gradle build; server/
is an independent Go module (the Memby gateway) that Gradle does not know about, built
and run through Docker. docker-compose.yml at the root wires the gateway to Postgres and
Redis. The two halves are coupled only by an HTTP contract — see "Gateway mode" below.
Build, install, test
Requires JDK 17 and the Android SDK. deploy-debug.ps1 sets JAVA_HOME to Android Studio's
bundled JBR; do the same when invoking Gradle directly if the shell JDK isn't 17.
.\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/):
go build ./... && go test ./... # add -buildvcs=false on Windows if .git is unusable
docker compose up -d --build # from the repo root; needs .env (see .env.example)
Deploying the gateway to the NAS is deploy-server.ps1 (PowerShell 7):
.\deploy-server.ps1 # local tree -> 10.0.0.213:/share/Docker/Memby
.\deploy-server.ps1 -SourceDirectory C:\src\memby -Destination /share/Docker/Memby-test
It tars the local server/, docker-compose.yml and .env.example, and streams them over
one SSH connection (interactive password; stdin carries the
archive, so OpenSSH prompts on the tty). The remote half stages into
<destination>.new.$$, builds, then swaps directories and waits for all three health
checks, restoring the previous release if anything fails. The named Postgres volume is
preserved — it never runs compose down -v.
.env.example is the configuration. It holds real values, and every deployment
overwrites the NAS's .env with the local copy (the old one is kept beside it as
.env.previous). The script requires MEMBY_PORT=32768, MEMBY_ADMIN_TOKEN,
MEMBY_EMBY_URL and POSTGRES_PASSWORD before activation, then confirms the admin token
reached the running container. The database volume is always preserved; deployment stops
before activation if the Postgres password differs from the deployed value, because a
credential change requires an explicit database migration. The local working tree is
deployed directly; no commit or push is required.
local.properties must contain sdk.dir=... when building from the CLI.
Lint has abortOnError = false (media3's @UnstableApi opt-in check would otherwise fail
the build), so lint failures do not surface at build time.
Unit tests are plain JUnit 4 with no Android/Robolectric dependency — logic that needs
testing must live in a pure function or a plain data class (mediaBadges, HomeUiState,
millisecondsToTicks, ringColorFromHex, EmbyProfile handling are the existing examples).
Identity
One name everywhere: com.ponzischeme89.memby is the Kotlin package, the Gradle
namespace and the applicationId. Identity types are Memby* (MembyApp,
MembyDreamService, Theme.Memby).
Historical note, because old APKs and TVs still carry it: through v0.1.52 the package was
com.mattcohen.embyscreensaver and the applicationId was com.mattcohen.embyclientsname.
Both changed in v0.1.53. applicationId is the install identity — changing it makes
every TV treat the build as a brand-new app: the old icon stays until uninstalled, the
DataStore session is gone, and users sign in again. Treat any future change to it as a
migration, not a rename. adb commands, the benchmark packageName and FileProvider
authorities all derive from it.
Versioning. versionCode is derived from versionName: major*10000 + minor*100 + patch (0.1.53 → 153). Bump both together — the in-app updater compares versionName,
while Android refuses an APK whose versionCode went backwards. release.ps1 -Version
rewrites both, so prefer it over editing the build file by hand.
Releases. APKs are self-hosted (NAS or any web server), not on a store. release.ps1
builds a signed APK and assembles dist/out/ — index.html (landing page from
dist/template/), latest.json (the manifest the app polls) and the versioned APK.
Release signing reads memby.keystore and friends from local.properties; with no
keystore the build still succeeds but emits an unsigned APK and logs a warning. The key
matters more than the code: Android identifies an app by applicationId plus signing
key, so a changed key forces every user to uninstall and reinstall.
Every version bump is also a publish operation: update the version and changelog together, commit the complete release, and push it to GitHub. Never leave a bumped version only in the local working tree.
For direct TV deployment without publishing a release, deploy-tv.ps1 builds and verifies
the signed release, connects over wireless ADB, installs it with -r, and launches the
Leanback activity. It reads the same signing settings from the current user's persistent
MEMBY_KEYSTORE* environment variables and defaults to the living-room Chromecast endpoint;
pass -Device host:port when Android rotates the wireless-debugging port.
UpdateChecker supports two sources, chosen by URL shape in isManifestUrl — a .json
URL is a static manifest, anything else is a Gitea host. resolveApkUrl lets a manifest
use a relative apkUrl. Both are unit-tested in UpdateSourceTest.
CHANGELOG.md is the version history the TV shows. It is read into
BuildConfig.CHANGELOG_TEXT at build time the way LICENSE and NOTICE are, parsed by
the pure parseChangelog in ui/settings/VersionHistory.kt, and rendered by Settings →
About as one collapsible release per entry. So a release edits one file and the history
stays readable offline. Keep the ## <version> — <date> / - bullet shape; anything else
in the file is skipped as prose, and a bullet wrapped onto a second line is rejoined.
An update is acknowledged once with a toast. ui/whatsnew/whatsNewDecision compares the
running build with Settings.whatsNewSeenVersion, and AppRoot briefly says “Memby has been
updated to version …” over the launcher before recording it. The record is device state,
deliberately not a synced preference: what is new is a property of the APK on this set.
Two quiet cases matter: a fresh install has not updated from anything, so setup records
the current build without announcing it; and a signed-out set with an older record waits
until somebody signs in, because the notice belongs over the launcher. The changelog is no
longer part of this decision — release history remains available in Settings → About, while
local or unreleased builds still receive the same one-time update acknowledgement.
Forced updates are server-controlled. server/internal/appupdate decides none /
optional / mandatory from the client's X-Memby-Version header against an
operator-set policy (admin page → App updates). HomeViewModel.checkForAppUpdate runs on
every launch and ui/UpdateScreen.kt renders the verdict — mandatory covers the whole
home screen with zIndex(10f), swallows Back and offers no dismiss. Two safeguards worth
preserving: a client with an unreadable version is never forced (it could not escape the
prompt), and the client ignores any verdict without a downloadUrl (isActionable), so a
half-configured policy cannot produce a blocking screen with a dead button. The verdict
must stay out of /v1/home, which is cached per user while this answer varies per client
build.
A retired build must be told why it was signed out. The destructive floor
(destructiveUpdateFloor) deletes the session on the next authenticated request and answers
401; a retired build's sign-in is then refused with 426. The television only saw the sign-out
— so it drew a sign-in form the gateway would refuse, and the mandatory update screen was up
to UPDATE_CHECK_INTERVAL_MS (an hour) away, force-closing the app being the only way
through, since a fresh launch checks for updates before it draws anything. Both refusals
carry X-Memby-Update-Required, so RequiredUpdateInterceptor on the gateway client
publishes it through update/RequiredUpdateSignal and AppRoot's check loop waits on
either the hourly interval or that signal. Things to preserve:
- It is an interceptor because the refusal lands on whichever request happened to be in flight — the status poll, a home refresh, a sign-in — and only one of those has any reason to know about update policy. The signal replays one value, because it is commonly reported before the check loop is waiting on it.
- The refusal is written to disk (
Settings.requiredUpdateVersion, viaRequiredUpdateGuardon 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 byUPDATE_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.
requiredUpdateSatisfiedis 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. RetiredBuildScreenis 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 everyUPDATE_REQUIRED_RETRY_MSforUPDATE_REQUIRED_FAST_ATTEMPTS, then settles ontoUPDATE_REQUIRED_BACKOFF_MS, and the screen's Try again wakes it through the same channel a refusal does.- Every refusal wakes the loop, including a repeat. The 401 that retires the session and the 426 that refuses the sign-in after it name the same version, and the second is the one a viewer is standing in front of — filtering it as already-handled is what left a television on a form it could not get through until the next hourly check.
Architecture
Manual DI. ServiceLocator (initialised in MembyApp) holds the single SettingsStore
and EmbyRepository. Activities, composables and MembyDreamService all read from it —
there is no DI framework and no per-screen repository construction.
Backend selection is build-time config. Two Gradle properties in gradle.properties
become BuildConfig fields, both read through data/ServerConfig.kt:
memby.gatewayUrl→MEMBY_GATEWAY_URL. Non-blank puts the app in gateway mode.memby.serverUrl→EMBY_SERVER_URL. The Emby address for the direct path; when set, the repository'sactiveServerUrlprefers it over the persistedSettings.serverUrlandSetupScreenhides 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
BaseItemis the single item model in both modes. Only the envelope differs (data/model/GatewayModels.kt). Settings.tokenholds the gateway token in gateway mode and the Emby token otherwise;Settings.serverUrllikewise holds whichever backend was signed into. No separate storage slots.supportsBatchHomedrivesHomeViewModel: gateway mode fetches all four rows with onegetHome()call, direct mode keeps the four-way parallel fan-out.- Rows are server-composed.
/v1/homereturns arowsarray (id, title, kind, items) andMainActivity.serverHomeRows()renders it verbatim, so a new row type ships without an app release — an unknownkindfalls back to poster cards rather than disappearing.state.rowsis empty on the direct path, wherehomeRowsFor()composes rows locally. Two things are easy to miss: rows hold their own copies of items, soHomeViewModel.updateUserDatamust map overrowstoo or an optimistic favourite won't show on a recommendation card; andloadBatchHomekeeps the previous rows when a response arrives with none, because the gateway omits recommendations while they build. - Continue Watching and Next Up are one row, merged by
api/continue_watching.go. They answered the same question — "what am I in the middle of?" — and splitting them meant a show moved between rows the moment an episode ended, which is exactly when somebody most wants the next one. Merging is not concatenation: both lists are ordered by recency, so the row is a merge of two sorted lists, never a sort of their union — Emby's order within each is the useful part. That needs a time per card, and a Next Up episode has none of its own (it is unwatched), sorecentlyPlayedSeriesasks 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, indata/ContinueWatching.ktfor 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 anextuprow 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 (
progressRowinapi/ranking.go). Every other row goes throughpersonalizeTitles; 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, itsTypehas 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 inWeightedRankcompound it.TestProgressRowsKeepEmbyOrderpins it. HomeCache.rowspersists them for cold start. New fields there need defaults — an existing install decodes a cache written by the previous build.- Image URLs are built by the private
imageUrl()helper. Coil fetches plain URLs with no interceptor, so the credential rides in the query string either way —t=for the gateway proxy,api_key=for Emby. - Video always direct-plays from Emby. The gateway returns a URL; it never proxies a stream. Don't route playback through it.
- Search is dual-path like the rest:
/v1/searchon the gateway (Postgres full-text, falling back to Emby before the first import),SearchTermonUsers/{id}/Itemsdirectly.ui/search/renders it — see "Search" below.
The wire contract is pinned from both ends: GatewayPayloadTest.kt / ServerHomeRowsTest.kt
(Kotlin) and internal/api/api_test.go (Go). Change a field name or a row kind and one
of them should fail.
Imported library. server/internal/library copies Emby's catalogue into
library_items (payload stored verbatim as JSONB, hot fields promoted to columns for
filtering plus a generated tsvector). Search and the recommendation candidate pool read
from it, falling back to Emby when it is empty — so both paths must keep working. It is
imported with EnableUserData=false on purpose: the table is shared by the whole
household, so watched/favourite/resume state must never be cached there and still comes
from Emby live. A full import mark-and-sweeps on synced_at; incremental uses
MinDateLastSaved with a minute of overlap.
External ratings (MDBList) are bought by the day, not by the request, so the design
question is never "how fast can we fetch" but "how few times must we ever ask". The answer
is that a title is fetched once and kept: external_media_ratings holds the raw provider
response permanently, ratingsNeedRefresh renews a scored title after 30 days and an
empty answer after 3 (a new release genuinely gains scores), and a stored value is always
served immediately — a refresh happens behind the viewer, never in front of one. Redis
still fronts it, but only to save the Postgres read. Things to preserve:
- A row carries its own ratings.
decorateItemRatingsinjectsMembyRatingsinto 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}/ratingsstill 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
ProviderIdslookup — a request per card.ProviderIdsis therefore in the library import'ssyncFields, anditem_rating_refsrecords what each Emby item turned out to be as televisions navigate, so the index fills in without waiting on a full re-import.ratingKeyForholds 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
claimRatingsBudgetcaps the day — a 429 stops it for an hour. A household's allowance is spent on titles it actually looks at, in the order it looks at them.
Maintenance mode gates the whole /v1 subtree (that's why Routes() builds a
separate v1 mux) with a 503 carrying maintenance: true. /healthz, /readyz and
/admin sit outside it deliberately. State lives in Postgres and is cached in memory,
re-read every 30s. Client side, parseMaintenanceMessage pulls the operator's message out
of the 503 body (trusting only the known message field, truncated) and
HomeUiState.maintenanceMessage — distinct from statusMessage, which is the ordinary
slow-connection banner — swaps the whole content area for ui/MaintenanceScreen.kt. The
navigation rail stays mounted beside it so Settings and Switch user still work, and the
retry button takes contentFocusRequester (with focusProperties { left = … } back to
the rail) because otherwise D-pad focus has nowhere to go once the rows are gone.
Service alerts. /v1/status is the only thing an open app polls continuously (10s,
MaintenanceMonitor), so it doubles as the push channel: alongside maintenance state it
carries an alerts array, and ui/ServiceAlertBanner.kt drops one in as a full-width bar
across the top of the screen, broadcast-notice style (it spans the navigation rail too).
Alerts come from two shapes of producer. Derived: api/alerts.go announces an episode
whose Sonarr air time has passed but which Emby has not imported yet ("aired, coming
soon"), recomputed per poll from the cached airing-today calendar, so polling clients
never cost a Sonarr request. Events, which are published into one shared Redis list
(publishAlert / publishedAlerts, also in alerts.go) and served from it until their
window closes — a list rather than a push because the gateway holds no connection to a
television, and a window is what lets a set that was off or in the screensaver at the time
still hear the news. Four publishers today:
api/radarr_alerts.go— a film Radarr just imported.POST /hooks/radarris the "On Import" webhook and the one thing that pushes into the gateway, guarded byMEMBY_RADARR_WEBHOOK_TOKEN(unset ⇒ 404, the stance/admintakes) and mounted outside both the auth middleware and the maintenance gate, because an event dropped during maintenance is lost rather than delayed. A quality upgrade is deliberately silent: the film was already there.AnnounceLibrarySyncinapi/server_alerts.go, hung offsyncer.SetAfterSyncinmain.go— "24 titles added or updated". Only a run that changed something is announced; the import is scheduled, most passes find nothing, and an hourly "no news" banner would train viewers to ignore the real ones.AnnounceDeployment, same file, published bydeploy-server.ps1throughPOST /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/statusat 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 noand no volume, so the swap discards anything published but not yet collected. It is best-effort on both sides: a first deployment has no previous token to announce with, and a deployment must never fail over a banner.WatchEmbyReachability, same file — "Emby has stopped communicating" and the matching "back online". Only transitions are announced, and only afterembyFailureThresholdconsecutive failures, because one timeout is a hiccup and repeating an outage every minute would bury everything else. This is the alert that earns the banner its place over playback: video direct-plays from Emby, so when Emby stops answering the film stalls with no explanation, and the gateway is still up to say why.MEMBY_EMBY_HEALTH_INTERVAL=0turns the probe and both banners off.
mergeAlerts interleaves derived and published alerts newest-first and caps at
maxAlerts, which is why every alert carries a timestamp. Alerts also carry their own
label (the banner's eyebrow — "JUST AIRED", "NEW MOVIE ADDED", "SERVER NOT RESPONDING"),
so a new kind of news reads correctly on an app that predates it; a client that receives
none falls back to the episode wording. Things to preserve:
the server has no idea which TVs saw what, so the client dedupes by id against
SettingsStore.markAlertSeen (persisted, or every relaunch replays yesterday's news); an
alert is only offered until the banner calls alertShown — nothing is persisted and no
dismissal timer runs before that, so one arriving behind the screensaver waits rather than
being consumed by nobody, and pendingAlertExpired drops it once the gateway stops
offering it. The status loop itself runs under
ProcessLifecycleOwner … repeatOnLifecycle(STARTED), so a backgrounded app stops polling
entirely instead of hitting the gateway every 10s at a TV nobody is watching. The banner is
never focusable and
times itself out after MaintenanceMonitor.ALERT_VISIBLE_MS (10s, with a ring counting it
down — take the duration from that constant, or the ring and the timer drift apart),
because stealing D-pad focus mid-browse is worse than a missed notice;
and alerts are suppressed under maintenance and under a mandatory update, which own the
screen.
The bar appears over playback too, not only over the launcher: PlayerActivity
mounts the same composable in a ComposeView (player_service_alerts in
activity_player.xml, declared before the loading and error overlays so those cover it).
alertsSuppressed starts true and is cleared only by hidePlaybackLoading, which is
reached once the preroll is over and the first frame is up — an alert composed behind an
overlay would be marked seen by a viewer who never saw it, which is the same failure
alertShown exists to prevent. Because it now covers somebody's film, the bar is
deliberately small (76dp), near-black, and eases in over ~680ms rather than snapping
down. It shows the Emby mark, not item artwork: a library refresh and an outage have
no artwork, and one constant mark reads as "your server is talking" where a poster made
every alert look like a different feature. The wire still carries itemId/imageTag;
the client just does not render them. MEMBY_SONARR_ALERT_WINDOW=0 turns them off without touching the schedule row,
and MEMBY_RADARR_ALERT_WINDOW=0 does the same for movie imports.
Emby outage bar. An alert is news; this is state, and both are needed. internal/api/ emby_health.go caches what the reachability probe found and /v1/status publishes it as
emby: {monitored, reachable, since, checkedAt, retrySeconds}; ui/EmbyOutageBanner.kt
renders it as a persistent red strip across the top, on the launcher and over playback,
counting down to the next attempt. The case it exists for is a television switched on
twenty minutes into an outage: it was never told anything, the film will not start, and
the alert that announced it has long since fallen out of its window. Things to preserve:
monitoredis load-bearing.MEMBY_EMBY_HEALTH_INTERVAL=0turns the probe off, and a client that trustedreachablealone would then show a permanent red bar on a server that is working. The server sendsreachable: truein 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.
nextOutageStateis the pure rule and is unit-tested. It counts inSystemClock.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)), inMainActivityand inPlayerActivityalike. 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.launchDirectEmbyProbepingsSystem/Info/Publicon the same minute — unauthenticated on purpose, since a probe needing a token would report a stale session as a server outage.
Notifications belong to a person, so they live in the user picker. A service alert is the
house being told something; these are one viewer's own news (a followed show returning),
stored per user on the gateway and following them to whichever television they sign into.
ui/alerts/AlertsPage.kt is the full page and the user menu in UserSwitcherOverlay is the
way in, beside Manage users. It replaced a bell in the corner of the launcher, which was
drawn only on Home and cost a focus target on every set whether or not there was anything
behind it. Things to preserve:
- The badge counts alerts, not unread ones (
alertBadgeLabel, pure and tested). An alert that has been read but not dismissed is still sitting there, and a badge that cleared itself the moment somebody glanced at the page would never agree with the list underneath it.AlertBadgeMaxis what stops the pill growing wider than its row. - The badge is drawn twice on purpose — on the "Switch user" rail item and on the My Alerts row inside the picker. The page is one level in now, so without the mark out on the rail nothing on the launcher would ever say there was news waiting.
- A press dismisses, and the focused row says so. This is the only page whose whole job is emptying itself; a confirmation press per alert is what made the panel it replaced not worth opening. Focus marks read, so nothing has to be pressed to clear the "new" flag. "Dismiss all" is the same per-alert call in a loop — the gateway has no bulk route — and empties the list optimistically, or a row lingers under a thumb that will press it again.
- The page is stateless, like
SignInContentand the detail panes:MainActivityowns the list and the requests, which is what letsAlertsPageScreenshotTestrender it (and the user menu carrying its badge) with no server →build/screenshots/my-alerts/. - A cancellation is a transition, not a status read in isolation.
sonarr_series_status_historystores the first daily Sonarr reading as a quiet baseline and appends only changes after it;WatchSonarrLifecyclecreates 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.
Search (ui/search/) is a two-pane instant-search destination on the rail: a fixed
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
Nothing is ever "submitted". SearchViewModel runs one pipeline — debounce(250) →
trim → distinctUntilChanged → collectLatest { repository.search(it) } — and
collectLatest is the load-bearing part: it cancels the in-flight request, so a slow
response for a prefix can never overwrite the results for what was typed after it.
Searching starts at two characters (shouldSearch); one letter matches half a library.
rankSearchResults is a pure, stable sort that only lifts exact/prefix/word-boundary
title matches above the backend's own relevance order — it never re-sorts alphabetically,
and it keeps weak matches rather than showing an empty pane. A small access-ordered map
caches results per query for the session, so backspacing is instant.
Every search the tab performs is recorded, and the gateway is what records it. It used
to depend entirely on the television posting to /v1/search/history after a result landed,
which meant a query answered from the client's own cache, one whose post failed, or one
from a build that predates the call was never written down at all — and the table feeding
the recent-searches row and future per-user ranking was a partial record of what the
household looks for. handleSearch now calls recordSearchQuery itself, before the Redis
lookup, so a cached answer counts the same as one that reached Emby. Things to preserve:
- The write is detached from the request context. Instant search cancels the in-flight
request on every keystroke, so a write hung off
r.Context()would be abandoned for exactly the searches somebody typed fastest. It is also fire-and-forget: a search whose record failed still returns results, and the failure is DEBUG for the same reason the search line is. store.SearchDedupeWindowis 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.searchQueryRecordableis the one rule both routes apply, so a query/v1/searchrecords is exactly one/v1/search/historywould 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.
searchWindowDaysis derived fromstore.SearchRetentionrather than written down, becauseRecordSearchprunes 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. SearchTotalsis its own query, not a sum of the table above it. The summary is capped atsearchTermLimit, 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.genresits 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;runSearchreturns 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,SortNamerather 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.genreTotalon the server is the same judgement from the other side, for an Emby that did not count. - A page is appended by its own offset, never by order of arrival: a response for an offset already scrolled past, or for a genre the viewer has left, is dropped rather than pasted into the middle of the grid. A page that fails part way down keeps what is on screen and simply stops paging.
- The scroll trigger is a
snapshotFlow, not a composable read. The last visible index changes on every frame of a scroll, and reading it in the body would recompose the grid the whole way down a genre — the same rule as the animation one above. It asksLOAD_MORE_ROWS_AHEADrows early, since a request that starts when the viewer arrives at the end is one they watch. - Something has to take the focus the chip was holding, because opening a shelf unmounts the whole discovery pane. The grid claims it when the first page lands, and an empty or failed genre hands it back to the keyboard rather than leaving a television with nothing focused.
- Episodes are excluded on both paths. An episode inherits its series' genres, so including them fills a page with twenty entries of one comedy and buries the rest.
- Movies and TV Series have their own full-width genre browser. A fixed row of colourful
mini cards is the first focus target on both destinations; it is deliberately a product
catalogue rather than whatever genre names happened to arrive in the home rows, so its
order and availability never jump around during refresh. Neighbouring Emby labels are
merged where they answer the same browsing intent (
Action|Adventure,Science Fiction|Sci-Fi|Sci Fi|Fantasy,War|History) using Emby's pipe-delimited genre filter.ui/genre/keeps the resulting pages in memory, preserves the active category behind a detail page, returns focus to the title that was opened, and consumesHomeViewModel.favoriteChangesso a Favourite press and its possible rollback reach the paged copy immediately. Itstype=Movie/type=Seriesquery is still enforced by the gateway and on the direct Emby path, so no category can mix the two grids. - The gateway path degrades to a keyword search on the first page only, so a set on a new build talking to a gateway that predates the route still shows something. A later page does not: a gateway that answered page one and failed on page two is having trouble, not missing the route, and search results pasted onto the end of a genre would be nonsense.
Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the
rightmost goes to the results grid, the grid's first column goes back to the last key
used (a FocusRequester attached to whichever key that is), and the grid has a
focusRestorer. Back moves results → keyboard → genre → leave, one step per press.
Physical keyboards and phone-remote apps feed the same state through one
onPreviewKeyEvent that consumes only printable characters and backspace — D-pad and Back
must fall through. The voice button needs the android.speech.RecognitionService entry in
the manifest's <queries>, or isRecognitionAvailable returns false on Android 11+ and
it hides itself on devices that actually support it.
Row analytics. data/analytics/RowAnalytics.kt buffers impression/focus/select events
with dwell timing (injectable clock, unit-tested) and HomeViewModel flushes every 20s,
on ON_STOP, and on dispose. Fire-and-forget by design — reportRowEvents swallows
failures, because telemetry must never surface on a TV. Aggregates are read at query time
in store.RowStats; raw events are pruned after 90 days.
Server logging answers "who did what, from which television, on which build". Three pieces make that true and each is easy to undo:
internal/loggingwrites an aligned console line — timestamp, level, message, fields — because a labelledtime=in front of the message is noise in every viewer that already has a timestamp column. Fields are ordered byfieldRank: identity (component,user,device,client) first so it can be read as a column, the constantversionand theerrorlast.MEMBY_LOG_FORMATswitches tologfmtorjson; the ring buffer the admin page reads is fed the same records in every format.internal/api/logcontext.gocarries a*requestIdentityin the request context.withLoggingcreates it from the route and the client headers;authedfills 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.WithContextin the handler would not reach it. Prefers.loggerFor(ctx)overs.loganywhere a request is in scope, or the line lands with no idea whose it was.componentFor(path)is the part of the app a call came from, derived from the route rather than declared by the client: the TV would have to thread a surface name through every repository method, and this way an old APK is attributed correctly too. Keep it a pure function with a case per area, and add to the test when a route lands.
The events that matter are logged as events, not inferred from request lines: sign-in
(and rejection), sign-out, device removed/renamed, playback requested / started /
stopped (with watched=), next episode resolved, update offered, media requests,
maintenance and feature changes, library syncs. Playback reports carry only an item id,
so playbackTitles (bounded, in-memory, lossy on restart) remembers what
/v1/items/{id}/playback called the thing, which is what lets a stop be logged by name.
Ten-second progress reports and per-keystroke searches are DEBUG on purpose.
internal/buildinfo/VERSION is embedded and appears on every line, on /healthz and in
the admin rail — bump it with a meaningful server change; nothing else identifies which
tree a container was deployed from.
Admin interface is server/internal/api/admin/ — a shell, a stylesheet, a shared
runtime and one fragment per page, all embedded and composed by admin_console.go at
start-up into finished bytes per URL. No build step and no CDN: a strict no-dependency
console is still the whole point, and serving a page is still a write of a []byte.
Guarded by MEMBY_ADMIN_TOKEN; unset means every /admin route 404s. It polls
/admin/api/status every 30s and stops entirely on a hidden tab.
It was one HTML file holding every screen at once, all but one of them hidden, which is why
opening /admin/logs also sent the accounts settings editor and the feature grid, and why
each page read as a pile of unrelated controls. Things to preserve:
adminNavis 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 plusadmin/pages/<id>.htmland<id>.js; a fragment with no entry panics at start-up rather than sitting there looking maintained.- Hidden pages are addressed by a route that carries something else in the path.
/admin/accounts/{userID}renders theaccountfragment;/admin/accountis a 404 andcleanInstallerDestinationrefuses it, because a sign-in returning there would land on a page about nobody. That route sends the login form back to/admin/accountsinstead. - 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 forstyle="..."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.jsand nowhere else — one stroked path on a 24×24 grid each, the same shape the rail's marks take. A fragment asks for one withdata-icon(plusdata-icon-tone) on any element andAdmin.decoratefills it in once, when the page has parsed; anything a poll redraws asks withui.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.jsowns the transport, the error banner and the refresh loop; a page registersAdmin.onStatus(called with each status poll) orAdmin.onRefresh(its own request alongside it).Admin.settled/fill/checkare the one rule that must not be dropped: never redraw markup the operator is working inside, or every poll takes a half-typed field or an open select away mid-edit.- Nothing on the overview page is editable. It answers "is anything wrong" and links to the page that can do something about it. A screen that both summarises and changes state is where an accidental click lives.
Explaining a recommendation is recommend/explain.go: Why(profile, item, limit) is a
pure function turning the learned weights into the phrases a detail page shows. It is kept
apart from Score on purpose — the scorer decides order and may be opaque, this decides
wording and must never invent an affinity, which is what reasonFloor and the
unit tests enforce. PersonWeights exists only for this: casting is a good reason to tell
someone about a title and a poor reason to rank by it. api/related.go serves it beside
Emby's similarity list at GET /v1/items/{id}/related, cached per user and item because
building the profile costs the same Emby fan-out the home rows pay for.
Recommendations live in server/internal/recommend: profile.go is pure scoring
(recency-weighted genre/studio affinity, exclusion of anything seen) and engine.go does
the Emby fan-out. Both are unit-tested without a network — engine.go takes a narrow
Source interface so tests inject a fake. The engine never runs on the home request path:
rows come from the r:<userId>:rows cache, and a miss triggers a deduplicated background
rebuild while home returns immediately. That key is intentionally outside the u:
namespace that mutations wipe; only a finished playback retires it.
"Because you watched …" rotates, because the top of a watch history does not. The rows
were anchored to the two most recent seeds, and the head of history is a resumable title
plus whichever series the household is part-way through — neither of which moves for weeks,
so the same two rows came back day after day. selectSeeds (pure, tested) instead cuts the
most recent SeedPool seeds into MaxSimilarRows equal bands and draws one from each by
dailySeed(userID). Things to preserve: it is a rotation within recency bands, never a
shuffle of the window, so the first row is still anchored to something watched lately and
the rows below it reach further back; the same day always yields the same seeds, because
rows are rebuilt on every cache miss and a set that re-picked each time would change under
somebody browsing; the last band takes the remainder, so a pool that does not divide evenly
still reaches its oldest entry; and a history shorter than the pool falls back to plain
recency rather than pretending to rotate. similarRow also runs diversifyRanked over its
cards with the same daily variation, the way curated shelves do — a row that keeps its seed
across two days must not present the same posters in the same order. MEMBY_RECOMMEND_TTL
(24h) is what makes the rotation daily in practice: the seed only changes at a rebuild.
EmbyRepository is the only place that talks to Emby. It keeps a @Volatile snapshot
of Settings collected from DataStore so synchronous callers (URL builders,
rotationIntervalMillis) don't suspend, and it caches the Retrofit EmbyApi instance,
rebuilding only when the base URL changes. All image and stream URLs are built here with
api_key appended. Errors reaching the UI go through friendlyEmbyError — never surface
raw HTTP bodies, which can contain tokens (the OkHttp logging interceptor is pinned at
Level.NONE for the same reason).
Emby query conventions. List endpoints request the narrowest Fields /
EnableImageTypes set that the row needs (getHomeItems enforces this); full metadata is
fetched only via getItemDetails after D-pad focus settles (140 ms debounce in
HomeViewModel.focusItem, with an LRU cache and cancellation of the in-flight job). Adding
fields to a home query is a startup-cost regression — extend the detail call instead.
Emby time values are 100-ns ticks; convert at the boundary (millisecondsToTicks,
resumePositionMs).
A detail page is warmed while its card is focused, in two waves. By the time somebody presses a card, the item record, its "why you might enjoy it" and its playable URL have all been fetched — and now so have its episode list and its trailer, which were the two things the page still opened cold. That mattered most on Continue Watching, where every card is an episode and pressing one opened a page with no season scroller and no episode list until the network answered. Things to preserve:
- The two waves have deliberately different delays. The metadata warm follows the D-pad
closely at
FOCUS_METADATA_DEBOUNCE_MS(140 ms) because it decides what the panel beside the row says;warmDetailPagewaitsDETAIL_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
getRelatedalready 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.getSeriesEpisodesandgetLocalTrailernow take the same shape. A prefetch added without it makes navigation slower, because the press that follows re-asks. getLocalTrailercaches its negative answer (CachedTrailer, theCachedTrickplayprecedent). Most of a library has no local trailer, so before this every detail page opened with a request the gateway answered 404 to, repeated on walking Back and again for every step of the "More like this" trail.- An episode is keyed on its series, not on itself — that is what its own page will ask for, and it is what makes one warm serve a whole row of Continue Watching.
A detail overlay seeds its settings from repository.currentSettings, never
Settings.EMPTY. Collecting a flow with an empty initial value draws the first frame under
default preferences and then recomposes the entire page — and restarts the effects keyed on
those preferences, which is a second ratings request — one frame later, at exactly the moment
the page is trying to appear.
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, apreferenceCataloguein the same shape asfeatureCatalogue, 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.normalizePreferencesreturns 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_preferencescarries one,/v1/statuscarries the current value, andPreferencesSyncfetches 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.ForceRevisionis how the admin push deliberately wins that race. PreferencesSync.lastSyncedis 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_fromrecording 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 labelledinitialrather than diffed against the defaults, which would claim decisions nobody made. user_preference_acksis 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 bypreferenceHistoryLimit, except that a device's most recent ack is never pruned — a set switched off for a year is exactly the one worth describing as "on revision 12" rather than as one that has never checked in.
Upgrading an existing install is schema 2 (SettingsMigrationLogic,
CURRENT_SETTINGS_SCHEMA). Nothing is lost: the flat keys already hold every value and are
what the app renders from, so a viewer sees their settings unchanged on first launch, and
preferencesRevision starts at 0 — which means the first sync pushes what the TV has
rather than pulling defaults down over it. The one thing that needed a migration step is
the three toggles that moved from device-wide to per-profile: an existing install has them
in the flat keys and in no profile, so they decode as the defaults, and the first profile
switch would have copied those defaults back over the flat keys (applyProfile writes
profile → flat) and then synced the result up as a deliberate choice. Step 1→2 folds the
device-wide value into every stored profile, which is exact rather than approximate —
while it was device-wide, that value really was in force for all of them. It cannot detect
its own work (the encoder omits default values, so a profile that chose true is
byte-identical to one that never chose), so the schema version is the only thing making it
run once; a test pins that.
What syncs is a person's choices; what does not is anything identifying a television —
device name, update source and token, the screensaver's rotation and ring colour.
showTitleLogo / autoPlayNextEpisode / showTenMinuteReminder moved from device-wide to
per-profile as part of this, because a device-wide value would push whoever signed in last
into everyone else's account. SettingsStore.applyRemotePreferences writes all nineteen
keys and the revision in one edit — DataStore rewrites the whole file per edit, and the
revision landing apart from the values it describes would leave a TV permanently believing
it was up to date while holding something else.
Multi-profile session state. SettingsStore stores a list of EmbyProfile (server,
token, userId) and mirrors the active profile into the flat top-level keys the rest of
the app reads. switchProfile/saveSession must keep both in sync; legacyProfile()
synthesises a profile from the flat keys for installs that predate the list. deviceId is
intentionally preserved across clearSession().
deviceId is the television's identity, in Emby's devices list and in Settings →
Devices alike: the gateway holds one session per (user, device_id) and Emby keys its own
device record on the same value, so a set that signs in with an id either list has seen
replaces its entry rather than adding one. It therefore has to outlive the app's own
storage, which on these sets it does not — every APK is sideloaded, an install that will
not go over the old one is done by hand as an uninstall and reinstall, and the DataStore's
ReplaceFileCorruptionHandler empties the file after a process killed mid-write. So
deviceIdFor derives it from ANDROID_ID (hashed, so the platform id is never sent
anywhere) rather than generating a UUID, and falls back to a random id only for the values
that identify nothing — null, blank, all zeroes, or the one a batch of early devices
shared, where two televisions would otherwise become one. An id already stored is kept:
changing it is the duplicate this avoids, which is why installs predating this keep theirs
and converge only when they are next reinstalled.
Removing a device is two deletions, not one. handleDeleteDevice (and the admin console's
equivalent) revokes the gateway session and then calls retireEmbyDevice, because logging
out only invalidates the token — Emby keeps the device row in its dashboard until the row
itself is deleted, so a TV removed from one list would stay visible in the other. It is
best-effort on purpose: the session is already gone, which is what ends that TV's access,
and it needs the sync credentials since the record belongs to the server rather than to
the viewer.
A television that changes its device id supersedes its old one. Sessions are unique per
(user, device_id), so a second row for one set can only mean the id itself moved — a
reinstall on a build that generated a random one, or an install predating the derived id.
Left alone each of those keeps a session row, an Emby device record and a build history of
its own, and one set in a living room reads as three. store.supersedeDevices runs inside
CreateSession's transaction, deletes the same user's other rows carrying the same device
name, and hands them back so retireSupersededDevices can take the cached session, the
build history and the Emby record with them. Two things hold it up: the match is on the
name because it is the only evidence there is — the token, the session and the Emby record
are all new — and supersedeName (pure, tested) refuses a blank name and
store.DefaultDeviceName, the compatibility placeholder an unnamed build sends, or the
second unnamed set in a household would sign the first out on every launch. It happens
after the sign-in has succeeded, because tidying a set's previous life must never be what
stops it getting in.
Build history is per device id, not per session (device_versions). A session row
carries only the version in force right now and is overwritten by the next call reporting a
different one, so on its own "what has this set been running" is one value deep. It is
written from two places and needs both: handleLogin for a fresh sign-in, and
captureClientIdentity for a set that updated itself and will therefore never sign in
again — guarded there on the version actually having moved, since every authenticated
request reaches that path. It is keyed on the device rather than the viewer because the
history belongs to the television, and it is deleted wherever a device row is.
The profiles blob deliberately does not carry cached home JSON. Preferences DataStore
rewrites and fsyncs the whole file on every edit, so embedding a several-hundred-KB cache
per profile meant every settings toggle rewrote all of them. writeProfiles is the single
writer and strips the field out into a per-profile home_cache::<userId>@<serverUrl> key,
which doubles as the migration for installs that still embed one — applyProfile reads
the dedicated key and falls back to the embedded copy. Add a profiles write and it must go
through writeProfiles.
That per-profile key is also the read path (activeHomeCache), and the only place the
cache is written. The flat home_cache key remains only for a store with no active profile
to key against, and for installs written before the split. It briefly held a second copy of
every cache alongside the per-profile one, which put the largest value in the store into the
file twice — on a format that rewrites and fsyncs the whole file per edit, and on a value
rewritten by every home refresh.
Home startup path. HomeCache (last successful home response) is persisted per profile
and used as the initial HomeUiState, so the launcher renders rows before the network
returns; sections then refresh in parallel under a Mutex and re-persist. Playback stops
are broadcast through repository.playbackStops and refresh only the Continue/Next-Up rows.
Three things protect that "before the network returns" promise, and all three are easy to
undo by accident:
- Nothing on the signed-in path may block on a request.
AppRootused to hold the launcher on the loading screen untilgetRecommendationOnboarding()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 byONBOARDING_CHECK_TIMEOUT_MSand 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. TheonboardingTokenguard 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
shareIned flow that completes exceptionally never emits again — so a single failed read shows up as "Opening Memby…" forever, surviving relaunch. Hence theReplaceFileCorruptionHandleron the DataStore and thecatchbeforeshareIn. 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.primeHomeCacheparses it on the store's IO scope as the settings flow emits, andhomeCache()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. setHomeCacheskips unchanged writes (lastPersistedHomeCache). It runs on every refresh and every playback stop, and most passes find nothing new.
Screensaver hosting. ScreensaverContent is shared by MembyDreamService and
ScreensaverActivity. A DreamService is not a ComponentActivity, so
DreamLifecycleOwner supplies the ViewTree lifecycle/ViewModelStore/SavedState owners
Compose requires; D-pad handling lives in the composable while the hardware Play/Pause key
is intercepted in dispatchKeyEvent and routed via the ScreensaverActions holder.
Playback from the dream finish()es first and starts PlayerActivity on a delayed main-
thread post to avoid the "activity behind the dream" race.
In-app updates. UpdateChecker polls a Gitea release
(/api/v1/repos/{owner}/{repo}/releases/latest, token auth for private repos) or a static
manifest, downloads and verifies the APK, then commits it to a PackageInstaller
session. Because replacing the APK kills a running Dream and leaves a black surface,
UpdateRecoveryReceiver catches MY_PACKAGE_REPLACED and relaunches MainActivity with
EXTRA_LAUNCH_UPDATED_SLIDESHOW.
There is no "check for updates" on the television. Settings had an Updates page with that
button on it, and the address, repository and token it needs could not be entered anywhere on
this app — so the only thing it could ever report was a failure, on the one screen a viewer
opens when they already suspect something is wrong. The single answer about updates is the
gateway's (server/internal/appupdate → ui/UpdateScreen.kt), which arrives on every launch
and carries its own downloadUrl; SettingsPage has no UPDATES entry and the version this
TV is running is stated once, on About. The updateBaseUrl/updateRepo/updateToken keys
remain in Settings and nothing writes them — they are what an install predating this still
has in its DataStore.
The session is not an implementation detail — it is why the updater works on a television.
The phone idiom, an ACTION_VIEW intent at the APK's content URI, fails three ways here: the
implicit intent is subject to package visibility on Android 11+, several TV builds expose no
activity for the package-archive MIME type at all, and nothing reports what the installer
then did. Sets that hit it had to be reinstalled by hand to move a version. So:
verifyApkmust 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 thatgetPackageArchiveInfoleavessigningInfonull on several Android versions wheregetPackageInfofills it in. Asking only forGET_SIGNING_CERTIFICATEStherefore 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, preferapkContentsSignersand fall back tosignatures, and letsignerVerdict(pure, unit-tested) returnUNVERIFIABLErather thanMISMATCH. 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 asSTATUS_FAILURE_CONFLICTwith a message saying to reinstall.- The outcome is a broadcast, not a return value.
downloadAndInstallsucceeding means the session was committed;InstallResultReceiverreceives what happened and publishes it onAppInstall.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.installStatusMessageis 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_ACTIONis 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.ktsits betweenFirstRunScreenandSetupScreenon 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 declareinstall_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.installPermissionPromptdefaults 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_ACTIONlets later updates apply silently once Memby is its own installer of record — never depended on, since the system falls back to asking.
Playback uses Emby's direct stream (/Videos/{id}/stream?static=true) — no
PlaybackInfo/transcode negotiation, so exotic codecs may fail. The
media3-exoplayer-hls dependency is already present for when that's added. Progress is
reported back to Emby via reportPlaybackStarted/Progress/Stopped.
Which subtitle comes on is the gateway's decision, not the television's. The server is
what calls PlaybackInfo and enumerates the streams, so selectSubtitle in
internal/api/subtitles.go picks one from the viewer's synced settings
(subtitlesEnabled, subtitleLanguage) and /v1/items/{id}/playback and /next return it
as selectedSubtitleId. Turning subtitles off, or choosing Italian, in the player's overlay
writes those two settings (SettingsStore.setSubtitlePreference), so the choice follows the
person to every set rather than staying in the room it was made in. Things to preserve:
- The rule exists twice on purpose.
selectSubtitleIdindata/SubtitleSupport.ktis 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: falsedisables 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.selectedSubtitleIdis matched onFormat.id, which is the id the sidecar'sSubtitleConfigurationwas built with. It can legitimately fail to match a container's embedded track, which is whypreferredTextTrackfalls back to running the same rule over the player's real tracks rather than giving up.
A subtitle the library does not have is fetched from the player, in
internal/api/subtitle_download.go over the provider layer in subtitle_providers.go.
Two backends answer and they are not the same shape, which is the one thing to hold on
to about this feature. Bazarr (server/internal/bazarr) writes the file beside the
media file, so the gateway stores nothing and serves nothing — it asks Bazarr to fetch,
calls emby.RefreshItem so Emby notices, waits embyRefreshSettleDelay, and re-reads the
streams; the new track then arrives down the ordinary PlaybackInfo path, which is why
playableSubtitle needed no new shape and selectSubtitle works on it with no special
case. OpenSubtitles (server/internal/opensubtitles) hands back bytes, and the
gateway has no reach into the media directory, so a file fetched there is stored in
downloaded_subtitles and served back as a sidecar from /v1/subtitles/{file}. That
difference is the entire reason the gateway now holds a subtitle at all, and it is
contained: mergeSubtitleTracks puts what the gateway holds beside Emby's tracks inside
playbackSubtitles, so a downloaded subtitle is an ordinary track on every later playback
rather than something that exists only in the response that produced it. Things to
preserve:
- The operator's switches are
store.SubtitlePolicy, not environment variables. A provider is offered when thesubtitle_downloadfeature 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
Sourceand 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'sepisodeid) and Emby knows neither, sobazarrMovieFor/bazarrSeriesFor/bazarrEpisodeFormatch 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 forProviderIdsso 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. resolveSubtitleTargetreads 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.providerSubtitlesthen 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
rankMergedCandidatessinks it below everything a person wrote rather than hiding it, andMachineOnlyis 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. Itsgw: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.resolveSubtitleUrlsputs the base and thet=token on it, exactly asimageUrldoes 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. subtitleDownloadAvailablerides the playback response, not/v1/status: the drop-up is the only thing that asks and it already holds that response, where the status poll is made by every open TV every ten seconds. The client default is false — a missing field must never conjure a row that leads to a request the backend cannot answer.- A title with no subtitles is told so, in the track section (
SubtitleTracksState). A list holding nothing but "Off" is indistinguishable from a menu that failed to load, and the row that could fix it sits below a rule under a heading the eye has no reason to travel to — so the notice goes under the SUBTITLES heading and the focus ring opens on the search row instead of on "Off", which is the state the viewer is already in. Where no provider can be asked it says that instead, once, rather than leaving somebody looking for an option that is not there.SubtitleMenuScreenshotTestcovers both. - Client-side it is a second screen, not a third section. The drop-up is 344dp by about
a third of a 720p screen; stacking a track list, the size chips and search results
squeezed the tracks to one visible row.
SubtitleDownloadState.expandedswapsplayer_subtitle_main_sectionout, and Back steps out of the download half before it closes the menu — one press per level. subtitleRequestInFlightis a flag, notjob?.isActive.lifecycleScopeuses the immediate main dispatcher, so a coroutine body runs synchronously up to its first suspension — beforesubtitleSearchJob =has been assigned. Reading the job would see the previous one on exactly the redraw meant to disable the rows.- A download resets
subtitleAutoSelectionAttempted. The new track only exists in a freshly built media item; without the reset the sidecar is attached and nothing turns it on.
Changing it mid-film is a drop-up, player_subtitle_overlay.xml, anchored over the
subtitle button it opens from rather than the full-height panel it used to be. It sits on
somebody's film, so there is no scrim and the panel is near-black: the option under focus is
the only fill on it (accent green), the choice in force is a quiet grey plate with a green
label, and every other row has no background at all. The margins are measured off the
transport row — 44dp end, 112dp bottom to clear its 72dp strip and the controls' 28dp
padding — and the top margin is what caps the track list before its ScrollView (which
takes the overflow via layout_weight) starts scrolling instead of growing up the screen.
bindSubtitleMenu in ui/player/SubtitleMenu.kt fills it from plain
SubtitleMenuEntry lists so SubtitleMenuScreenshotTest can render the real menu with no
player, server or decoder → build/screenshots/subtitles-menu/; deciding what the entries
are stays in PlayerActivity, the only thing that can read media3's tracks. Only the first
open animates — redrawing after a choice would slide the menu again under someone still
working down it.
Who is that? is a button, not a menu item. The cast panel has its own player_cast
control beside the subtitle button in memby_player_controls.xml, because the question is
asked mid-scene and one that has to survive a dialog and four menu rows is one nobody asks
twice. It is deliberately not also in showTrackMenu's list: one thing reachable two ways
is one thing whose two entry points drift apart. Things to preserve:
- The panel is a fade, not a card.
player_cast_scrimcarries 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 takesduplicateParentStatebecause 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. bindCastPanelinui/player/CastPanel.kttakes aCastPanelStateand an injected image loader, soCastPanelScreenshotTestrenders the real cards with no player, server or network →build/screenshots/cast-panel/.loadedis 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.ktbuilds the player, apart from the activity, because construction is on the critical path of every launch. It pulls media bytes throughHttpStackrather than media3's own HttpURLConnection client, so the header, index and offset requests a resume makes reuse one connection instead of repeating the handshake three times; and it enables constant-bitrate seeking, so a container with no usable seek table computes the offset instead of reading its way there. Both are borrowed from Wholphin, a Jellyfin TV client under the same GPL-2.0 licence.PlayerActivity.onCreateis 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 untilonCreatereturns. The service-alertComposeViewmounts on the first call tohidePlaybackLoadingand the cast lookup runs fromstartPlaybackSession: both used to run duringonCreate, 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.
PlaybackRequestcarries what the launcher already knew from the card,PlayerActivityresolves the stream while the activity, its layout and its decoder are starting, andadoptPlayabletakes 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.MainActivitykeeps two states for this —launchingItemis the gate that stops a second Play press stacking a second player,resolvingItemis the loading screen and belongs only to the route that waits. - The two launch forms are two places a
Playablehas to be unpacked, and only one of them is obvious.adoptPlayablehandles the request form; on the URL form — the cold start, and any launch wherereadyPlayableForLaunchhad a warm prefetch in hand — the intent is the only thing that will ever carry an answer, and a field with noputExtra/getExtrapair silently takes its default. That is howtrickplayAvailable,skipIntroAvailableandendCreditsAvailablewere 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/trickplayand zero/introcalls across seventy-seven playbacks. A new "is it worth asking the backend" boolean must be added in four places: thePlayable, the intent's parameter list, itsputExtra, andonCreate'sgetBooleanExtra.subtitleDownloadAvailableis the worked example of all four. ui/player/PlaybackTrace.ktsays where the time went. "Playback is slow" is not actionable;event=first_frame … activity=…(+…) player=… stream=… prepared=… ready=… first_frame=…is. Marks are cumulative from the Play press, a repeated stage keeps the first time it was reached, and a stage that never happened is absent rather than zero.PlaybackTraceSections.ktnames the same two spans for a systrace so:benchmarkcan measure what the log can only report — see "Benchmarks" below.
Measured on a Chromecast with Google TV against the NAS gateway, the shape is:
prepare() → first frame is over 90% of a resume, the stream negotiation is ~120 ms
and the app's own startup ~365 ms. Within that, the largest single term is cold versus
warm connection to Emby — the same file, same seek, was 4177 ms on the first playback of
a session and 2004 ms on the second. Artwork and API traffic go to the gateway host, so
the pool has nothing open to Emby when the first playback starts, and that first resume
pays DNS, TCP, TLS and Emby's file open. Pre-warming that connection is the open
opportunity. Two things that look like causes and are not: the subtitle auto-selection
costs 20–200 ms, not seconds, and the seek itself is about 900 ms.
The local Memby preroll is prepared while Home is idle. PrerollPreloader owns one
process-scoped ExoPlayer for res/raw/emby_preroll.mp4; MembyApp queues its first prepare
on the main queue's idle handler, so decoder construction and the local resource read never
sit in Application.onCreate or in front of launcher composition. When a fresh playback
requires the preroll, PlayerActivity borrows that player and shows it in the pre-roll video
frame while the requested title negotiates and prepares, paused at zero, behind the overlay.
Three details preserve the performance claim: the clip's actual duration owns the hand-off
rather than a stale configured estimate; the old paused-content-frame path remains the
failure fallback; and the preroll player is stopped and parked at hand-off so it releases its
hardware decoder while HEVC content plays. Returning to Home prepares the same instance again
in the next idle window rather than constructing one per title.
The same clip plays behind the cold-start screen (ui/LaunchPreroll.kt). It is the one
screen every launch shows and the clip was the one thing Memby owns that nobody ever saw
there. It borrows the same cached instance — no second decoder, no second copy of the
file — and hands it back on dispose, so the next playback still opens on a prepared player.
It now gates the launcher, which is the whole point of it: it was decoration, uncovered
whenever the app happened to be ready, and on a warm start that was a fraction of a second —
so the one thing Memby owns was in practice never seen. The clip plays once from the
beginning, its last frame is held for LAUNCH_INTRO_HOLD_MS (2s), and only then is the home
screen composed. Things to preserve: it gates but it can never trap — no player, a
decoder error, or no rendered frame within three seconds all report finished immediately,
and LAUNCH_INTRO_MAX_MS (9s, the clip's four plus the hold plus room for a weak box) is
AppRoot's outer bound on top of that, because a viewer must never be held on a black
screen by a branding clip; LaunchIntro.played is process-scoped, not saved, so an
activity Android recreates behind somebody is not a second launch and a profile switch does
not replay it; the pulsing mark and the welcome line fade out under the clip and back in
if the app is still opening when it ends, since the waiting screen and a four-second sting
said two things at once; and it no longer loops — looping existed so a slow cold start never
froze on the last frame, which the hold and the fade-out do instead, and a clip that never
ends cannot gate anything. It is still muted, because it now runs on every single app open,
where the pre-roll before a programme is audible and must end — PrerollPreloader.acquire
/recycle normalise volume and repeat mode so a borrower cannot leave the next one wedged.
It is acquired after withFrameNanos, because a cold start has nothing cached and
constructing an ExoPlayer inside the first composition of the screen that must appear
immediately is the cost the idle-handler prepare exists to avoid. And
AppRoot calls MembyLoadingScreen from one call site: the three states meaning "still
opening" were three, and Compose identifies a composable by where it is called from, so
moving between them disposed the screen and rebuilt it — which now means returning and
re-borrowing the player twice during the busiest stretch of a launch.
What it says while it is opening is ui/WelcomeQuotes.kt. Twenty-five lines per tone
plus eight headlines, because this is the most-read copy in the app and five per tone meant
a household saw the same sentence roughly every fifth time they switched the set on.
WelcomeQuotesTest counts the distinct lines a pool yields: a pool that shrank back, or
gained a duplicate on a copy-paste, looks identical to one that did not. The headline is
kept apart from the quotes and is not keyed on the tone — it names what the app is doing,
and rerolling it when the settings flow arrives would change the line mid-launch.
Next up / auto-advance. 30 s before an episode ends, PlayerActivity slides up
player_next_up_banner.xml and rolls into the next episode when it reaches zero (Settings
→ Playback turns it off; Settings.autoPlayNextEpisode). Which episode that is comes from
repository.nextEpisode, dual-path like everything else: /v1/items/{id}/next on the
gateway, Shows/{seriesId}/Episodes?AdjacentTo= directly. Both rely on Emby returning
[previous, current, next] in running order, so it is the position of the current
episode that identifies the next one — never the length of the list, which shrinks at both
ends of a season (episodeAfter in playback.go, unit-tested). Three things are easy to
break: the countdown is driven off the playhead, not a timer of its own, so pausing holds
it and seeking backwards out of the window re-arms it; advancing swaps the MediaItem
inside the running player instead of relaunching the activity, so itemId/playbackStarted
/stopReported must all be reset together or the outgoing episode is never reported
stopped; and a movie simply resolves to null, which is why nothing special-cases item type.
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.
SeekPreviewaccumulates and the seek is committedSEEK_COMMIT_DELAY_MSafter 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 ascentrePausesPlayback). With the controls up, Left and Right belong to whatever holds focus, and taking them would leave the subtitle and cast buttons unreachable. A stream that is not seekable falls through to media3 instead: nothing errors and nothing claims to have skipped. - Only discrete presses count. A held key repeats at the platform's rate, which is fast enough to throw somebody minutes down a film they meant to nudge — the repeats are consumed rather than acted on, so letting go does not open the transport either.
- A pending skip is committed in
onStopand dropped byresetSeekControlswhen 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:seekControlsActiverefuses 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 —showPlaybackLoadingclears the flag, so a retry, an error or the next episode is never withheld on a skip's account, andSEEK_LOADING_GRACE_MSputs the overlay up after all if the seek is still buffering 6 seconds later, because past that it is not a skip landing, it is a film that stopped.
And it shows the frame it will land on. The idea and the shape are borrowed from
Wholphin, under the same GPL-2.0 licence, the
way PlayerEngine's HTTP stack was; what differs is the format underneath. Jellyfin serves
tile sheets, so Wholphin crops a sub-image out of a grid. Emby serves BIF files
(/Videos/{id}/index.bif?Width=320): a 64-byte header, one 8-byte (timestamp, offset)
entry per frame plus a terminator, then the JPEGs laid end to end — 320×172 every ten
seconds, about five megabytes for a two-hour film.
The index sitting at the front of the file is the whole reason this is affordable on a television. Read the first few kilobytes and every frame's byte range is known, so one thumbnail costs a ranged request of about seven kilobytes rather than a download nobody would wait through mid-seek. Things to preserve:
- Trust the 206, not the headers. Emby 4.10.0.21 answers ranges on that route properly
and says so — a 206 with
Accept-Ranges: bytesand aContent-Rangenaming the BIF's own length — but earlier builds were reported to serve the range while advertisingAccept-Ranges: noneand aContent-Lengthborrowed from the media file. So bothemby.TrickplayBytesandTrickplayClientcap the read at what was asked for regardless, because being wrong about that must not turn a press of Right into a five-megabyte download. - A zero-frame BIF is an answer, not a fault. Emby returns a perfectly well-formed
72-byte file for a title whose thumbnails it has not generated, and for a width it does
not hold — which is why
trickplayWidthis not a free parameter and the client'sTRICKPLAY_WIDTHmust match it. The gateway caches that no fortrickplayMissingTTL(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.ktandserver/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.bifbeing 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 booleantrickplayAvailablerides there — thesubtitleDownloadAvailableprecedent — so an older or deliberately-configured-off gateway is never asked. It is fetched fromstartPlaybackSession, besideloadCast(), 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, andhandleTrickplayanswers 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
collectLatestgives search: presses arrive faster than a fetch completes, and without it a slow response for a frame already skipped past lands on screen after the one being waited for. - Scrubbing the transport shows them too, not only the Left/Right chip. Those are the two
ways to move through a film, and previews that stopped the moment somebody pressed a button
to look at the controls read as the feature having broken rather than as a deliberate line.
With the transport up the presses are the time bar's —
seekControlsActivestands down — sobindScrubPreviewhangs aTimeBar.OnScrubListeneroffexo_progressand draws intoplayer_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 sameTrickplayPreviewinstance 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 ononScrubStoprather than on a timer, so it can never outlive the scrub that raised it. - The cache holds JPEG bytes, not bitmaps, and is the player's own rather than Coil's. Decoded, forty frames would be most of a megabyte; as bytes they are a couple of hundred kilobytes, and decoding one costs a millisecond off the main thread. Keeping them out of Coil matters too — a burst of presses walks through dozens, and letting that churn through the artwork cache would evict the backdrops the launcher is about to want back.
Skipping the opening titles is Emby's own answer, not a detector. Emby finds intros
itself and writes them into an episode's chapter list as two markers, IntroStart and
IntroEnd, interleaved with the ordinary chapters in playback order — so there is nothing
to detect on either end and nothing to store: reading them is one Fields=Chapters lookup.
introFromChapters (server/internal/api/intro.go) and introSegmentFrom
(data/Intro.kt) are the pure rule, pinned by deliberately parallel tests (intro_test.go,
IntroTest) for the usual reason — with no gateway there is nobody to ask, and a skip must
not land somewhere different depending on whether the container is up. Things to preserve:
- Most of the rule is about refusing to answer. Half a pair, a pair out of order, a
segment under 5 s or over 5 min all produce nothing, and nothing is a good answer: the
player simply never offers the button. A wrong skip costs somebody the opening of a scene,
which is far worse than not being offered one. The first
IntroStartwins — 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), thetrickplayprecedent: 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 booleanskipIntroAvailablerides there, and the client default is false. It is fetched fromstartPlaybackSessionbesideloadCast(), 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. skipIntroModeis a synced per-profile setting (prompt/auto/off), with the vocabulary duplicated indata/SkipIntroPreference.ktand the gateway's catalogue like the seek interval's. It normalises toprompt, neverauto: 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.
SkipIntroCountdownViewdraws a draining arc with the figure inside it, advanced from the playhead byupdateSkipIntroCountdown— 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_MSshort 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 itduplicateParentState, 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; andformatRemainingswitches to1:58over 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
centrePausesPlaybackstands 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. skipIntroTakenis never re-armed within an episode, unlikeskipIntroDismissed. 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. SkipIntroScreenshotTestrenders it over a deliberately bright fake scene →build/screenshots/skip-intro/. There is no scrim under this button, so a capture over black would prove nothing.
The closing credits are read out of the same chapter list as the intro. markersFor in
server/internal/api/intro.go reads one Fields=Chapters response and answers for both
features, and EmbyRepository.chapterMarkers caches one reading that introSegment and
creditsStartMs both read. That is the whole reason this is affordable — adding a second
lookup anywhere in that path gives away the only performance claim the feature has. From the
result, PlayerActivity scales the picture into the left half, ramps it to 2× and puts what
is on next in the right half.
Two sources, and the one it was built on does not exist. CreditsStart is in Emby's
MarkerType enumeration, which is what this was originally written against — but a survey of
the 20,000-item library it ships to found Chapter, IntroStart and IntroEnd and no
CreditsStart at all on Emby 4.10. The enum having a value is not the detector populating
it. What that survey did find was 216 items carrying a chapter named like credits, clustered
at 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) andcreditsStartFrom(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.
RunTimeTicksrides 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.
creditsWorthShowingasks 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_BUFFERINGcallsstepDownCreditsSpeed,creditsCeilingAfterStallonly 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
updateNextUpFromPlayheadreturns 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 itsnextEpisodeguard, which is exactly the right gate: a film and the last episode of a season both correctly get nothing. It therefore inherits auto-play's switch, since nothing resolves a next episode when that is off — deliberate, because this is the auto-advance experience. - The transform is a scale, never a reparent. The pre-roll moves the
PlayerViewbetween parents; doing that mid-playback tears the SurfaceView down and flashes black over somebody's credits.CREDITS_VIDEO_SCALE/CREDITS_VIDEO_SHIFT_Xare public soEndCreditsScreenshotTestcan 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. speedUpCreditsis a synced per-profile toggle, defaulting to on — a different trade fromskipIntroMode'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.maskMarkerswithholds 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.
Performance instrumentation. PerformanceMonitor (JankStats) is debug-only and logs to
tag EmbyClientPerf. benchmark/ is a com.android.test macrobenchmark module targeting
the release variants the androidx.baselineprofile plugin generates, so its numbers are
real rather than debug-influenced. HomeBenchmark deliberately measures cold start twice —
CompilationMode.None() and Partial() — because the only way to know the baseline
profile is earning its keep is to see both numbers.
PlaybackBenchmark measures time to first frame off the Memby.playback* trace spans,
with resumeFromContinueWatching and coldStartFromHomeHero as a pair — a resume differs
from a cold start in one way that matters, so it takes both to say whether a change helped
seeking or helped everything. Unlike the rest of the module it needs a signed-in TV and a
reachable Emby, since most of what it measures is network and decoder; its numbers only
compare against other runs on the same TV, server and title. It exists because a handful of
hand-timed launches could not settle anything — identical runs of one file varied by 70%.
Things to preserve: browsing happens in setupBlock so only the Play press is timed;
openResumableDetailPage searches the Continue Watching row for a Resume button rather
than assuming a position, because the row reorders as the household watches, and skips the
run rather than silently measuring a cold start and calling it a resume. The section names
are duplicated between PlaybackTraceSections and the benchmark because a com.android.test
module cannot link against the app — PlaybackTraceNamesTest is what stops a rename turning
the benchmark into one that finds no slices and cheerfully reports zero.
Two task names to get right, both of which fail confusingly rather than obviously. To check
it compiles use :benchmark:compileBenchmarkReleaseKotlin — :benchmark:assemble triggers
baseline-profile generation against a connected device. To run a benchmark use
:benchmark:connectedBenchmarkReleaseAndroidTest, not connectedCheck, which also runs the
nonMinifiedRelease variant and measures everything a second time. And JAVA_HOME must
point at the JBR, as for any direct Gradle invocation here.
Release builds are minified. isMinifyEnabled/isShrinkResources are on, which takes
the APK from ~14.9MB to ~3.1MB and the dex from 49MB across four files to 4.9MB in one —
no multidex, which matters because minSdk is 23. proguard-rules.pro is what keeps that
safe: it matches @kotlinx.serialization.Serializable on the annotation rather than
listing packages, because the previous rules named com.mattcohen.embyscreensaver.data.model
and had silently matched nothing since v0.1.53. Lint still has abortOnError = false, so
R8 warnings do not fail the build — check the task output after changing dependencies.
R8 shrinks Kotlin; nothing shrinks a .so. The APK is dominated by whatever native
code it carries, and native code is packaged uncompressed here (minSdk 23 means
extractNativeLibs=false), once per ABI. io.github.abdallahmehiz:mpv-android-lib was
added in v0.2.40 as a last-resort software video fallback and took the release APK from
3.1MB to 168MB: a whole FFmpeg, libc++_shared and a 6MB subtitle font, times four
ABIs, for a path almost nobody ever reaches. It has been removed, along with
MpvFallbackActivity, shouldUseLibmpvFallback and the is.xyz.mpv keep rule. Two things
came out of it and both are cheap to keep:
defaultConfig.ndk.abiFiltersisarm64-v8a+armeabi-v7a. Android TV is ARM; the x86 slices only ever served the emulator, which is not how this app is tested.- A new native dependency is a size decision, not a dependency decision. Check what it weighs across both ABIs before adding it — the Jellyfin FFmpeg audio decoder that actually delivers DTS is 1.5MB per ABI because it links only the decoders it needs, which is the shape to look for. Anything on mpv's scale belongs behind a separately downloaded split, not in the base APK that every television sideloads on every update.
Baseline profile. androidx.profileinstaller plus a profile generated by
benchmark/BaselineProfileGenerator.kt. Regenerate against a real television with
.\gradlew.bat :app:generateReleaseBaselineProfile; the result is checked in under
app/src/release/generated/baselineProfiles, so an ordinary assembleRelease needs no
device. A stale profile is not harmful, only progressively less useful.
One HTTP stack. data/remote/HttpStack.kt owns the single OkHttpClient that the
Emby API, the gateway API, Coil's artwork loader and the video stream itself all derive
from with newBuilder(), so they share one connection pool and dispatcher. This matters
most for artwork: in gateway mode the images are proxied by the same HTTPS host that serves
/v1/home, so a separate client would repeat the TLS handshake for every poster. It
matters again for a resume, which opens the same file three times over before the first
frame. Don't construct a bare OkHttpClient.Builder() — derive from HttpStack.base. The
stream's derived client raises the read timeout and sets no call timeout, which would
cap the length of a film.
Language
Everything a person reads is New Zealand English. No American spellings: -ise/
-isation (personalise, synchronisation, organise), -our (colour, favourite, behaviour),
-re (centre, theatre), licence the noun and license the verb, programme for a
broadcast, and grey, catalogue, cancelled, labelled, travelling. This covers on-screen copy
in Kotlin and res/values/strings.xml, CHANGELOG.md (which the TV renders twice — Settings
→ About and the what's-new panel), the admin console, the release landing page in
dist/template/, every string the gateway sends the client to display (row titles, alert
labels, the preference and feature catalogues, error messages), and this repository's own
prose and comments.
It does not apply to identifiers or anything on a wire. Emby's API is American
(favorites, IsFavorite), so are Android and Compose (Color, fontSize,
TheaterComedy, RecognizerIntent), Go and Kotlin (synchronized, and the literal
"request canceled" in images.go, which matches net/http's own error text), SPDX and
GPL names ("GNU General Public License"), and CSS. Renaming any of those breaks the wire or
the build. The rule is about words a person reads, never tokens a machine matches.
The boundary sits at the render, and the favourites row is the worked example: its id and
kind stay favorites on both sides, personalisedFavouritesTitle puts "Favourites" on
the screen. When a new string is both, spell the display half and leave the key alone.
UI conventions
Use androidx.tv.material3 components (Button, Card, Text) rather than the phone
Material 3 ones. MainActivity.kt, HomeComponents.kt and ScreensaverContent.kt are the
three large files — new screens generally belong in ui/<feature>/ rather than growing
them further. Focus handling is explicit (FocusRequester, focusRestorer, focusGroup);
everything must be reachable by D-pad only.
A keyed lazy list must never be handed a repeated key. LazyRow/LazyColumn throw on
one — "Key … was already used" — and every list on these screens is keyed by an id that
came off a wire, where nothing promises distinctness. Emby lists the same person twice on a
good fraction of a real cast; a "Because you watched" row built from two seeds can reach one
title by both; a search that falls back to Emby before the import finishes can return what
the library also matched; and a paging boundary is where a backend repeats a card by
definition. ui/ListKeys.kt is the one rule: deduplicate, never disambiguate. Folding
the index into the key would also stop the crash, but it keys an item by where it is, and
position is exactly what changes when a row reorders — which the return-focus and
scroll-restoration behaviour throughout this app depends on identity to survive. Apply it
where the data enters state (HomeViewModel.sanitisedRows, EmbyRepository.loadRelated and
loadSeriesEpisodes, the two genre pagers, SearchViewModel.runSearch) rather than in a
composable; where a composable is the only place, keep it inside a remember(list). And
where a pager deduplicates, how far it has read is counted in what the backend sent, not
in the length of the list (SearchUiState.genreOffset, GenreBrowseUiState.readOffset) — a
dropped duplicate would otherwise make the next offset point before the end of the last
page, and the shelf would stop growing while re-requesting the same page for ever.
Animations must not recompose. This app ships to weak TV boxes, so an animated value
read in a composable body — val x by animateFloat(...) then using x in the layout — is
a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and
read it inside a Canvas/drawBehind block (draw phase only), and derive any text from it
with derivedStateOf so it recomposes when the displayed value changes, not when the
float does. ServiceAlertBanner's countdown ring and pulse are the worked example: ~10
recompositions of one number over ten seconds instead of ~600 of the whole bar. The same
rule applies to collecting flows — collect in the smallest composable that needs the value,
not at the top of MainActivity, or every emission recomposes the launcher.
Where a composable is too large to split, narrow the state instead. HomeScreen is
the case: it cannot reasonably collect HomeUiState in one place, because reading the
whole object there meant an arriving update verdict, a slow-connection banner or any one
of the four section loads invalidated the launcher and rebuilt every row with it. So
HomeViewModel exposes three distinctUntilChanged projections — content (rows and
their loading flags), status (connection health) and appUpdate — and the screen
subscribes to each where it is rendered. contentSlice() blanks the non-row fields rather
than introducing a separate type, which is what lets homeRowsFor keep taking a
HomeUiState and the tests that pin it keep working; read only rows and loading from it.
Design tokens. ui/theme/DesignTokens.kt is the one vocabulary both surfaces read:
MembySurface (the near-black), MembyAccent, MembyOnSurface/MembyMutedText/
MembyQuietText, MembyRatingsSurface behind the ratings strip, three corner radii
(MembyChipCorner 8dp, MembyCardCorner 10dp, MembyPanelCorner 14dp), and the two
separators — FactSeparator between facts, ValueSeparator inside a fact that holds a
list. HomeComponents' EmbyGreen/MutedText/QuietText and DetailPageComponents'
Detail* colours are aliases of these; they had drifted into four near-blacks, four greens
and two secondary greys, which is visible the moment a detail page opens from a row. A new
colour or radius belongs in the token file, or is a considered exception — not a fifth
value.
The eight slots a theme sends are not the vocabulary the screens paint with, which is
why picking a colour scheme used to change almost nothing outside the detail pages. The
launcher, the cold-start screen, Settings, Search, the update and maintenance screens and
the two overlays were drawn with a hundred-odd literal hexes — a lighter green for a label,
a near-black ink for text on a green fill, three neutral steps for controls — none of which
the palette could reach. Those shades are now derived in DesignTokens.kt
(MembyAccentBright, MembyAccentInk, MembyAccentMuted, MembyControlSurface,
MembyControlSurfaceRaised, MembyOutline, MembyDisabledText, MembySplashTint).
Derived rather than added to the wire on purpose: a theme sends the decisions and the app
works out the shades around them, so a scheme invented on the gateway tomorrow arrives
complete rather than half-applied — the same reason the palette carries no radii. What
stays a literal is anything carrying meaning of its own: the red/amber/blue status colours,
the genre tiles, and the ratings providers' own brand colours. And a screen-local alias must
be get(), never val — SettingsSheet's Canvas/Panel/TextPrimary were values, so
the settings page, which is where the theme is chosen, was the one screen that could never
repaint.
Those colours are the server's answer, not constants. Every token in DesignTokens.kt
is now a get() over one process-wide mutableStateOf(MembyPalette), and applyMembyPalette
is what repaints the app. Two things follow and both are easy to undo: an alias must be a
getter too (HomeComponents' EmbyGreen, DetailPageComponents' Detail*, the settings
sheet's own EmbyGreen), because a val captures whichever theme was loaded when its class
initialised and never changes again; and MembyTheme builds its darkColorScheme per
composition rather than holding one, which is how a theme change reaches every component
that never names a colour. Shape and punctuation are deliberately not themeable — a
palette that could move a corner radius could make a layout wrong from the server, and the
whole safety of this feature is that the worst a bad theme does is look bad.
The colour-scheme picker is currently withheld from Settings —
THEME_PICKER_ENABLED in ui/settings/SettingsSheet.kt, one const val to put back.
Choosing a scheme does not reliably repaint the app, and a control that appears to do
nothing is read as a fault in the television rather than as an unfinished feature.
Everything below is otherwise untouched: seasonal themes still arrive and still apply, the
synced themeId preference is still carried, and the palette plumbing is unchanged. This
hides the question, not the answer.
Themes are server/internal/api/themes.go, and there are two kinds. A selectable
theme is the viewer's own choice, held as the ordinary synced preference themeId and
picked in Settings → Appearance. A seasonal theme (Halloween, Christmas, Easter) is not
a choice at all: it is in force for its dates and nothing on the television can decline it,
because a per-person opt-out is a thing somebody turns off in October and never reconsiders,
which is the same as the feature not existing. The only switch is the operator's
seasonal_themes feature flag, for the whole house. Things to preserve:
-
resolveThemeis 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 aschosenunderneath 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.seasonalThemeFortakes 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/statuscarriestheme: {id, revision, locked, seasonal}— thepreferencesRevisionprecedent — and the TV fetches/v1/themeonly 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.normalizeThemeAllowliststores "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 fromuser_preferencesbecause 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/themesends 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.
setThemeIdwrites the preference and stops; the palette arrives throughThemeSyncwhen 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. -
ThemeSyncpaints from the cached palette before any request, the promiseHomeCachemakes about the rows, and clears the cache on a profile switch (a different person, a different scheme). The palette cache is device state; onlythemeIdsyncs. -
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.ktis only hex parsing, and it refuses anything it cannot read so the app's own token stands in. Seasonal decorations areui/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 animatedState<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
indexandprogressand nothing else — no array, no per-particle state to allocate or re-seed.drawSeasonalFieldis 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.pnganddecoration-snow-100.pngmust 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 (
decorationon the theme), never derived from the theme id: an operator turningseasonal_decorationsoff sends an empty one, and a set holding a cached Christmas palette must stop snowing. It is a second switch fromseasonal_themesbecause 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
HomeScreenrather 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.pngexists 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.
ThemeScreenshotTestrenders 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 thingsetContentallows 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 aFlowRowwhere every other choice row on the page is aRow, and it is why the blossom is a five-petal flower — a single petal rendered as a grey seed, recognisable as something falling and nothing else.
One button language. ui/MembyButtons.kt — MembyPlayButton (focusable),
MembyPlayChip (the same surface as decoration inside an already-focusable parent, for the
home hero) and MembyChoiceChip. There were three: this one, a hand-rolled copy in the hero
with the same look and different metrics, and raw androidx.tv.material3.Buttons with
glyphs typed into their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours
nothing around them uses.
Settings is black, flat, and says one thing once. ui/settings/SettingsSheet.kt had four
stacked surfaces to show two switches — the page, the rail, a titled section card, and the
rows inside it — and the card's title repeated the page header, which repeated the rail item
already highlighted beside it. It is now a black canvas with the rail separated by a single
right-edge hairline, and SettingsGroup lays rows flat with SettingDivider between them:
no card, no icon chip, no section heading. SettingsGroup(label = …) exists only for a page
with genuinely two groups (About), and is a quiet caption rather than a second heading. Three
things to keep: the controls are untouched (StatusToggle, SettingsChoiceChip, the badge
pill) because they are what makes the screen read as Memby and they look better on black than
they did on a card; the row under focus is the only lit surface on the page, so nothing else
may grow a background; and everything shares a 16dp left inset — header, rows, dividers,
notices — because with the card gone that inset is the only thing holding the column
together. Copy is plain-language and second person ("Ten minutes left", "Hide films you have
seen"), not feature names.
Up out of the top of a page returns to the page list. Nothing sits above a pane's first
control, so that press did nothing at all on every page — and a remote that stops responding
is not read as a list that has run out. About is where it was reported, its pane being a
changelog long enough that walking back up it is the ordinary way to leave. The escape is an
onKeyEvent on the content column that makes the move the default handler would have made
and falls back to the rail only when it fails, so a page's own vertical navigation is
untouched: focusProperties { up = … } is inherited by every row and would take that
navigation away, and exit is never consulted when the search finds nothing anywhere, which
is the whole case. The rail item for the page being drawn carries a second requester for
it — the selection rather than the highlight, so a press arriving mid-settle still lands on
the page that is on screen. SettingsRailFocusTest pins both halves, the escape and the
navigation between rows it must not disturb.
One runtime formatter, one 4K threshold. detail/DetailFacts.kt owns formatRuntime,
heroFacts, dynamicRangeLabel and UHD_MIN_WIDTH; the home hero and the card metadata
call them rather than carrying private copies. That is why the hero and the card directly
beneath it agree on "2h 4m", and why mediaBadges and the spec row's (4K) suffix fire at
the same width.
A score is only ever shown by the ratings strip. Emby's CommunityRating used to be
drawn as a gold ★ 8.4 on the detail fact row, the home card metadata panel and the home
hero, beside the strip that was already showing IMDb and Rotten Tomatoes — two ratings in
one panel, one of which named no provider at all. The client no longer renders it anywhere
outside the screensaver (which has no strip), and EmbyRepository.getRatings no longer
falls back to it either: standing it in for a real source meant a card claiming a TMDb score
TMDb had never been asked for. A title MDBList cannot answer for now shows no strip, which
is the honest answer. Scores are formatted by the gateway alone (formatRatingScore), by
the scale they are measured on — a fractional scale always keeps its decimal, so IMDb 7
goes out as 7.0 rather than a bare 7 beside somebody else's 8.2.
Home rows all lead with the same header: HomeRowHeaderIcon + HomeRowHeaderIconGap,
HomeRowHeaderSpacing. A header that skips the icon chip starts its title 38dp left of
every other row, and the launcher's row titles are read as one column.
The home hero (ui/HomeMovieHero.kt) is a featured card plus three minis, each a
HomeHeroPick — the item and the row it was drawn from. The caption used to be the card's
slot ("POPULAR", "NEW RELEASE", "TRENDING" by index) while the selection interleaves sources
and falls back to every movie in the response, so it routinely lied. Only the minis carry
that caption now: they have no fact line, so the label is the only reason the card gives,
where on the featured card it sat above a line already printing the year and cost the height
that broke the button — the featured card says why in a sentence instead (see below).
Play is measured before the words. The featured card is a fixed height, and a Column
gives each child what the ones before it left — so the chip, being last, was handed the
remainder after a two-line title and rendered as a green sliver with its label squeezed out.
Compressed, not clipped, which is why it read as malformed rather than missing. The text now
sits in a weight(1f, fill = false) child, and weighted children are measured from what is
left over: the spacer and the chip take their natural size first and the prose gives way.
Keep that inversion. The titleLines == 1 rule that stands the synopsis down is still worth
having — it means the give usually costs nothing visible — but it is a tidiness, not the
guarantee. HomeMovieHeroScreenshotTest renders the wrapping-title case for exactly this.
The hero is composed by the gateway (server/internal/api/hero.go), because the three
things worth ranking it by are three things the television cannot see. Radarr knows when a
film actually came out — digitalRelease is the date the household could first have
watched it, where Emby's PremiereDate is the theatrical date when it is right at all and a
metadata agent's guess when it is not, so ranking "new releases" by it produced an order
with nothing to do with when anything became watchable. Sonarr knows a premiere from an
ordinary episode, so a new show or a returning season can lead where before a series could
only reach the hero as a random card off a shelf. And the review scores are already
attached to the cards by decorateHomeRatings, so a well-received release can outrank a
fresher one nobody liked at no cost. rankHeroCandidates is the pure rule
(heroRecencyWeight/heroRatingWeight, hero_test.go); selectHomeHeroMovies's original
row-interleaving rule survives underneath as the direct path's hero and the fallback for
a gateway older than the feature, which is why serverHeroPicks is consulted first and
returns nothing rather than throwing. Things to preserve:
- It asks Emby for nothing. The movie candidates are the rows already assembled and their ratings are already attached, so the expensive half of the launcher is reused rather than repeated. The two *arr calendars it does read are cached for the day behind a shared lock, like the schedule rows' — one household pays one miss each per day — and the three lookups run concurrently, because this is the tail of a response every television in the house is waiting on.
- Every card it produces is playable. A premiere the household has not downloaded, a
film Radarr is still waiting on, a synthetic schedule card — all are news for the schedule
row, and a lead card that does nothing when pressed is worse than no lead card at all.
sonarrPremieresrequiresHasFileand 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;heroRatingOffalls back to Emby'sCommunityRating, 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), theMembyAirLabelprecedent, so a kind of hero card invented tomorrow reads correctly on today's build.labelTintmatches 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
heroReasonreturns 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.
serverHomeRowsdropskind == "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.supportsHomeHerogates it at 0.2.27 for the same reason — an older television has no idea the kind is special. That floor is the version the feature shipped in rather than one after it, so a 0.2.27 build predating it would draw the duplicate row; moving the floor up is the fix if that ever bites. - The row is a draw from merit bands, re-made four times a day. Ranking straight to the
row's length was the original design, on the reasoning that the facts behind it change
daily — and they do not: a digital release date does not move, a premiere aired when it
aired, a score settles within a week, so the same two cards led the launcher for five days
at a stretch.
rotateHeroCandidatesranks a pool ofheroPoolLimitinstead, cuts it into as many bands as there are cards to send, and draws one from each byheroVariationSeed(userID, heroRotationSlot(now, location))— theselectSeedsshape, and for the same reason it is not a shuffle: merit still decides which band a title is in, so the best-reviewed release of the week can never land in the fourth slot, and variation only picks between titles the scorer could not separate. The slot is the household's local part of the day and carries the date, the seed is per viewer, and a pool with no spare candidates goes out in merit order untouched. Home is cached for a minute and rebuilt constantly behind it, which is why one slot must always yield the same draw. The rotation further down belongs to the direct path, which has no merit to rank by.
The reason sits above the ratings strip, and that order is load-bearing. The featured card's text column is what gives way when a title wraps onto two lines, so whatever is last in it is cut — with the reason below the strip, the one line explaining why this card leads the launcher was silently dropped on exactly the long-titled films most likely to be leading it. The scores are also on the detail page the card opens; the reason is nowhere else. It takes the synopsis's place rather than adding a line, so preferring it can only make the card shorter, and there is still no eyebrow above the title.
The direct path's hero changes daily, at local midnight. selectHomeHeroMovies(rows, day)
takes a count of local days and rotates the starting point of each candidate list; MainActivity
keys its remember on rememberHomeHeroDay(), which sleeps until the next local midnight
rather than polling. Three properties are load-bearing and unit-tested. It is a rotation,
not a shuffle: the server's ranking is still the order, so what it thinks is worth leading
with comes round again and yesterday's hero is one place down rather than somewhere
arbitrary. The same day always yields the same four cards — the launcher rebuilds on every
home refresh and focus change, and a hero that re-picked each time would churn under
someone walking past. And the day is local: "resets at midnight" means the viewer's
midnight, which is why the zone offset is a parameter to the pure localEpochDay /
millisUntilNextLocalDay rather than read inside them. Math.floorDiv/floorMod for longs
arrived in API 24 and this app ships to 23, so that arithmetic is written out by hand.
Detail pages are one editorial layout shared by movies and series: DetailPageScaffold
in ui/DetailPageComponents.kt over the pure vocabulary in ui/detail/DetailFacts.kt. It
is a full-bleed cinematic hero — backdrop under two scrims, logo or title, heroFacts line
(year · length · certificate) with the score and format badges trailing it, genres, three
lines of synopsis, one recommendation reason, then Play and the circular secondary actions —
with an uppercase tab strip on a hairline rule anchored under it and the tab's content
beginning below the fold. MediaDetailContent is the movie page, SeriesDetailContent the
series one; they differ only in which tabs they offer. The tabs are Overview, Episodes
(series only), More Like This and Cast & Details.
- The page is a
LazyColumnof exactly three items — hero, tabs, content — and the hero owns the opening frame: while focus is in it the list is pinned to offset 0 (seedetailHeroScrollTargetand thesnapshotFlowbeside it), because LazyColumn's own focus relocation would otherwise leave Play visible with the title scrolled off the top. Moving to the strip or the content releases the pin. - A pane never scrolls. Every section is a tab and every tab fits its slot, so adding
content means adding a tab: a page that scrolls and has tabs gives the D-pad two
meanings for Down. The slot is
detailPaneHeight(viewportHeight), derived from the screen rather than fixed — it was a hard 250dp, and everythingtechnicalSpecs()produces fell off the bottom of Cast & Details, which is the whole reason that tab exists. If a pane needs more than the budget, cut rows; do not add a scroller. - The strip keeps a safe-area inset.
DetailFoldPeekholds it off the bottom edge, where overscan was cutting the selection underline in half, and leaves the top of the pane showing beneath it. That peek and the chevron at the end of the strip are the only things on screen saying that Down reveals anything. - Focus is selection in the tab strip. A remote has no hover, so a strip that highlights one tab while a different one stays open would need a second press to mean anything and would show content that contradicts the highlight.
- The strip is decided by what the item is, never by what has loaded.
detailTabs(isSeries)returns a fixed list, and a section with nothing in it yet says so in its own pane. It used to offer only the sections that already had content, which meant a movie opened with one tab and grew two more when its detail record landed, moving the strip under the viewer's thumb.detailTab(key, available)still resolves a remembered key, but now only has to catch a key carried over from the other kind of item. - One
FocusRequesterper pane, never one shared between them.AnimatedContentkeeps the outgoing pane composed for its 80ms fade, so a requester attached by both the Overview and the Cast & Details pane is attached to two live nodes, and a Down press landing in that window can focus the pane that is disappearing. - Position is remembered per item in
ui/detail/DetailPosition.kt— tab, season, which band held focus, and both rails' scroll offsets — in a process-scoped, capped, LRU store outside the composition, because closing a detail overlay destroysrememberSaveablewith it.RestoreDetailFocusfocuses Play first (it exists on frame one, so the remote is live) and then restores the band, once, only if that band has something placed to land on. Deliberately not persisted: a TV switched on the next morning should open a show where the show is up to. - Every
focusProperties { up/down/left/right = … }target must be attached on the current frame. Season chips and episode cards do not exist while the episode request is in flight, on a one-season show, or on any tab but Episodes — pointing at theirFocusRequesteranyway throws the moment the viewer presses that direction.SeriesDetailContentresolves each target toFocusRequester.Defaultwhen its destination is off screen; keep that. - Moving between the three bands is stated as intent, not as one destination. Hero →
strip → pane is the page's whole navigation and it must never be dead, so the scaffold
routes those presses through
Modifier.onVerticalNavigationandfocusFirstAvailable, 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 isonKeyEvent, 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 keepsfocusProperties { up = … }rather than a key handler: panes navigate vertically inside themselves and override that property where they do (EpisodeCardsetsup = FocusRequester.Defaulton every card but the first), which a blanket handler would take away. The tab strip anchors that requester to the first tab when the selected one is not in the list, so the property always names something real. - The backdrop is held under two gradients before any text is drawn. The reference is flat black and that flatness is most of why it reads as modern; the artwork is there for tone, not as a picture.
- A tab item is
Modifier.width(IntrinsicSize.Max). Without it the underline'sfillMaxWidthclaims the whole strip and pushes every later tab off the screen. - The hero honours
Settings.showTitleLogoanduseTextTitleForLogo(ui/TitleLogo.kt, shared with the screensaver): transparent Emby logos are commonly black, and a black title treatment on this scrim is an invisible heading. - Why you might enjoy it is the single accent line above the actions, from
GET /v1/items/{id}/related. It comes from the same profile the home rows are built from (recommend.Why), so the page can only claim a taste the engine actually learned; a cold profile falls back to catalogue facts. Never focusable. - More Like This is the same response's
items, as its own tab. Selecting one opens its detail page, andMainActivity.detailsTrailwalks Back home one page at a time. On the direct path there is no engine, soEmbyRepository.getRelatedreturns Emby'sItems/{id}/Similarwith no reasons at all — both halves are allowed to be empty and the page still opens. - Nothing about related titles is allowed to fail. It is asked for on focus
(
HomeViewModel.focusItemwarms 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.RelatedTonow degrades at every step instead: a taste profile that cannot be built costs the reasons and not the carousel (Whyfalls back to catalogue facts,FilterUnseenkeeps everything), a failed or emptySimilarlookup falls through togenreNeighbours— 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 — andrelatedSubjectreads the item itself fromlibrary_itemswhen 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 forrelatedEmptyTTLrather than the item lifetime, because a ten-minute answer must not outlive the minute of trouble that produced it; and client-sidegetRelatedis 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. SeriesDetailsOverlayandMediaDetailsOverlayonly load (episodes, related, trailer) and delegate;SeriesDetailContent/MediaDetailContentare parameter-driven so they can be screenshotted (DetailPageScreenshotTest, which drives the tab strip by clicking it and also renders each pane on its own atdetailPaneHeight) without a server.
"Estimated finish: 18 August" is data/SeriesPace.kt, a quiet line under the series
hero's progress bar. It is derived entirely from the episode list the detail page already
holds — Emby's per-episode UserData.Played and LastPlayedDate — which is the whole
design: there is no new storage, nothing to invalidate, and no second implementation on
the direct path, unlike the subtitle and Continue Watching rules. It is per viewer and
per series by construction, since that user data is Emby's and is keyed that way, and it
recalculates for free because finishing an episode, marking one watched, a history sync
from another client and a newly imported episode all change that list and nothing else.
Things to preserve:
- Most of the function is about refusing to answer. A wrong date is worse than none, so every guard returns null: fewer than three completions (two only when they are on separate days and within a week of each other), nothing watched in a month, one or zero episodes left, a horizon past a year. The load-bearing one is that the window must span at least two distinct local days — three episodes in one evening is a sitting, not a rate, and reading a daily pace off it is exactly how the opening of a binge promises a finish this week.
- The window ends at the present and stops at the last break (
sinceLastBreak), rather than averaging a viewer's whole history with the show. Somebody who took a year over season one and is now watching nightly is watching nightly; including the silence predicts a finish years out. - Estimating and wording are separate functions.
estimateSeriesPaceanswers in numbers andseriesPaceLabelturns 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.isOngoingSeriesprefers Sonarr's lifecycle where the gateway attached one and falls back to Emby'sStatus, which is the only source the direct path has; both absent means "finish", the weaker claim.Statusis infieldsDetailon both paths for this, and the gateway's item cache key moved toitem:v5:so entries written before it cannot hide the field. - Quiet is the property a unit test cannot check, so
SeriesPaceScreenshotTestrenders 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.
formatPaceDatenames 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 becausejava.timeneeds API 26 and this app ships to 23 — the same reasondata/LocalDays.ktwrites outfloorDiv. That file is where the local-day arithmetic now lives; it was private toHomeMovieHerowhile 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 asubtitle(the episode's own title) under it, plus atitleoverride for the no-logo fallback — without that override the fallback heading printed the episode's name a second time. stripreplaces the tab strip in the band under the hero, keeping the sameDetailStripHeight, 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 inEpisodeDetailTest— 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.seriesProgressLabelcounts 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
EpisodeCardfrom 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.
MembySeriesItemIdis resolved inapi/sonarr.goby matching the Sonarr title (and year, which wins when both a remake and its original are in the library) againststore.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.detailsAiringNoticeis set only by that row'sonItemSelectedand 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 (
membyAirLabeland 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 fromHomeViewModel.focusItem, the same swapFocusedDetailsOverlayalready 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.
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,dayCountand its owntoday;calendarWeekslays out the month from those alone andcalendarAgendaWeeksonly 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:CalendarViewModeldrops every cached month once the device's local day changes, because everything else about a month is fixed and onlytodaygoes 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
requestedMonthnames. 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;parseCalendarMonthrefuses 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.CalendarScreenshotTestrenders 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, capabilitytv_calendar_v1), because a household running no Sonarr would otherwise carry a destination that only ever opens an apology. A set standing on the page when it is switched off is moved to Home, or it is left somewhere nothing can navigate back to. - A failed month is an empty month, not an error. The page is informational, and somebody who pressed Right past a Sonarr hiccup must be able to press Left back out of it.
- There is no second implementation on the direct path. Unlike subtitles or Continue Watching, the answer is Sonarr's, which a television holds no credential for and Emby knows nothing about — with no gateway there is genuinely no calendar, and the rail says so by omitting the entry.
Previews. ui/PreviewSupport.kt holds the one preview shape: @TvPreview (1080p TV,
landscape, launcher black) plus PreviewSurface { } for the real theme. Use those rather
than a bare @Preview, which defaults to a phone and misrepresents every layout here.
A preview does not run ServiceLocator, so only composables that take their state as
parameters are previewable — the same property that makes them unit-testable. Prefer
previewing the still inner composable over an animated wrapper (AlertBanner, not
ServiceAlertBanner): a frozen frame of a slide-in shows nothing useful.
Screenshots. OnboardingScreenshotTest covers everything a new television shows before
the launcher — first run, the install-permission step in both its states, and sign-in empty,
filled, rejected, connecting and adding-a-viewer — into build/screenshots/onboarding/. It
is the sequence nobody sees twice and the one that decides whether that TV can ever update
itself, so being able to look at it without reinstalling on hardware matters more here than
anywhere else. It is also why SignInContent and InstallPermissionContent are stateless:
SetupScreen keeps the authentication, the content takes parameters.
app/src/test/.../ServiceAlertBannerScreenshotTest.kt renders composables
to PNGs under app/build/screenshots/<feature-name>/ via Roborazzi + Robolectric, at TV 1080p qualifiers
— the way to look at a layout without a TV to hand. This is the only Android dependency
allowed in app/src/test; keep it confined to *ScreenshotTest.kt files so logic tests
stay pure JUnit. Recording is always on (roborazzi.test.record in testOptions): these
are artifacts to look at, not checked-in goldens, and a screenshot test that silently
captures nothing is worse than none. Give each feature its own kebab-case folder and keep
all variants of that feature together. AGP's own com.android.compose.screenshot plugin was
tried first and discovers zero previews on AGP 8.13.2 — don't re-litigate it without
checking that upstream.