41 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, the MediaBrowser Client="Memby" auth header Emby shows in its
devices list, and on-screen copy.
Emby* class names (EmbyRepository, EmbyApi, EmbyServiceFactory, EmbyModels) are
kept on purpose: those types model Emby's API, and renaming them would make the code
lie about what it talks to. App-identity types are Memby*.
Repository layout
This is a two-language monorepo. app/ and benchmark/ are the Gradle build; server/
is an independent Go module (the Memby gateway) that Gradle does not know about, built
and run through Docker. docker-compose.yml at the root wires the gateway to Postgres and
Redis. The two halves are coupled only by an HTTP contract — see "Gateway mode" below.
Build, install, test
Requires JDK 17 and the Android SDK. deploy-debug.ps1 sets JAVA_HOME to Android Studio's
bundled JBR; do the same when invoking Gradle directly if the shell JDK isn't 17.
.\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.
For direct TV deployment without publishing a release, deploy-tv.ps1 builds and verifies
the signed release, connects over wireless ADB, installs it with -r, and launches the
Leanback activity. It reads the same signing settings from the current user's persistent
MEMBY_KEYSTORE* environment variables and defaults to the living-room Chromecast endpoint;
pass -Device host:port when Android rotates the wireless-debugging port.
UpdateChecker supports two sources, chosen by URL shape in isManifestUrl — a .json
URL is a static manifest, anything else is a Gitea host. resolveApkUrl lets a manifest
use a relative apkUrl. Both are unit-tested in UpdateSourceTest.
Forced updates are server-controlled. server/internal/appupdate decides none /
optional / mandatory from the client's X-Memby-Version header against an
operator-set policy (admin page → App updates). HomeViewModel.checkForAppUpdate runs on
every launch and ui/UpdateScreen.kt renders the verdict — mandatory covers the whole
home screen with zIndex(10f), swallows Back and offers no dismiss. Two safeguards worth
preserving: a client with an unreadable version is never forced (it could not escape the
prompt), and the client ignores any verdict without a downloadUrl (isActionable), so a
half-configured policy cannot produce a blocking screen with a dead button. The verdict
must stay out of /v1/home, which is cached per user while this answer varies per client
build.
Architecture
Manual DI. ServiceLocator (initialised in MembyApp) holds the single SettingsStore
and EmbyRepository. Activities, composables and MembyDreamService all read from it —
there is no DI framework and no per-screen repository construction.
Backend selection is build-time config. Two Gradle properties in gradle.properties
become BuildConfig fields, both read through data/ServerConfig.kt:
memby.gatewayUrl→MEMBY_GATEWAY_URL. Non-blank puts the app in gateway mode.memby.serverUrl→EMBY_SERVER_URL. The Emby address for the direct path; when set, the repository'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. 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.
Maintenance mode gates the whole /v1 subtree (that's why Routes() builds a
separate v1 mux) with a 503 carrying maintenance: true. /healthz, /readyz and
/admin sit outside it deliberately. State lives in Postgres and is cached in memory,
re-read every 30s. Client side, parseMaintenanceMessage pulls the operator's message out
of the 503 body (trusting only the known message field, truncated) and
HomeUiState.maintenanceMessage — distinct from statusMessage, which is the ordinary
slow-connection banner — swaps the whole content area for ui/MaintenanceScreen.kt. The
navigation rail stays mounted beside it so Settings and Switch user still work, and the
retry button takes contentFocusRequester (with focusProperties { left = … } back to
the rail) because otherwise D-pad focus has nowhere to go once the rows are gone.
Service alerts. /v1/status is the only thing an open app polls continuously (10s,
MaintenanceMonitor), so it doubles as the push channel: alongside maintenance state it
carries an alerts array, and ui/ServiceAlertBanner.kt drops one in as a full-width bar
across the top of the screen, broadcast-notice style (it spans the navigation rail too).
Alerts come from two shapes of producer. Derived: api/alerts.go announces an episode
whose Sonarr air time has passed but which Emby has not imported yet ("aired, coming
soon"), recomputed per poll from the cached airing-today calendar, so polling clients
never cost a Sonarr request. Events, which are published into one shared Redis list
(publishAlert / publishedAlerts, also in alerts.go) and served from it until their
window closes — a list rather than a push because the gateway holds no connection to a
television, and a window is what lets a set that was off or in the screensaver at the time
still hear the news. Three publishers today:
api/radarr_alerts.go— a film Radarr just imported.POST /hooks/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.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.
Search (ui/search/) is a two-pane instant-search destination on the rail: a fixed
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
Nothing is ever "submitted". SearchViewModel runs one pipeline — debounce(250) →
trim → distinctUntilChanged → collectLatest { repository.search(it) } — and
collectLatest is the load-bearing part: it cancels the in-flight request, so a slow
response for a prefix can never overwrite the results for what was typed after it.
Searching starts at two characters (shouldSearch); one letter matches half a library.
rankSearchResults is a pure, stable sort that only lifts exact/prefix/word-boundary
title matches above the backend's own relevance order — it never re-sorts alphabetically,
and it keeps weak matches rather than showing an empty pane. A small access-ordered map
caches results per query for the session, so backspacing is instant.
Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the
rightmost goes to the results grid, the grid's first column goes back to the last key
used (a FocusRequester attached to whichever key that is), and the grid has a
focusRestorer. Back moves results → keyboard → clear query → leave, one step per press.
Physical keyboards and phone-remote apps feed the same state through one
onPreviewKeyEvent that consumes only printable characters and backspace — D-pad and Back
must fall through. The voice button needs the android.speech.RecognitionService entry in
the manifest's <queries>, or isRecognitionAvailable returns false on Android 11+ and
it hides itself on devices that actually support it.
Row analytics. data/analytics/RowAnalytics.kt buffers impression/focus/select events
with dwell timing (injectable clock, unit-tested) and HomeViewModel flushes every 20s,
on ON_STOP, and on dispose. Fire-and-forget by design — reportRowEvents swallows
failures, because telemetry must never surface on a TV. Aggregates are read at query time
in store.RowStats; raw events are pruned after 90 days.
Admin interface is server/internal/api/admin.html, a single embedded page (no build
step, no CDN — a strict no-dependency page is the whole point). It polls
/admin/api/status every 5s. Guarded by MEMBY_ADMIN_TOKEN; unset means every /admin
route 404s.
Explaining a recommendation is recommend/explain.go: Why(profile, item, limit) is a
pure function turning the learned weights into the phrases a detail page shows. It is kept
apart from Score on purpose — the scorer decides order and may be opaque, this decides
wording and must never invent an affinity, which is what reasonFloor and the
unit tests enforce. PersonWeights exists only for this: casting is a good reason to tell
someone about a title and a poor reason to rank by it. api/related.go serves it beside
Emby's similarity list at GET /v1/items/{id}/related, cached per user and item because
building the profile costs the same Emby fan-out the home rows pay for.
Recommendations live in server/internal/recommend: profile.go is pure scoring
(recency-weighted genre/studio affinity, exclusion of anything seen) and engine.go does
the Emby fan-out. Both are unit-tested without a network — engine.go takes a narrow
Source interface so tests inject a fake. The engine never runs on the home request path:
rows come from the r:<userId>:rows cache, and a miss triggers a deduplicated background
rebuild while home returns immediately. That key is intentionally outside the u:
namespace that mutations wipe; only a finished playback retires it.
EmbyRepository is the only place that talks to Emby. It keeps a @Volatile snapshot
of Settings collected from DataStore so synchronous callers (URL builders,
rotationIntervalMillis) don't suspend, and it caches the Retrofit EmbyApi instance,
rebuilding only when the base URL changes. All image and stream URLs are built here with
api_key appended. Errors reaching the UI go through friendlyEmbyError — never surface
raw HTTP bodies, which can contain tokens (the OkHttp logging interceptor is pinned at
Level.NONE for the same reason).
Emby query conventions. List endpoints request the narrowest Fields /
EnableImageTypes set that the row needs (getHomeItems enforces this); full metadata is
fetched only via getItemDetails after D-pad focus settles (140 ms debounce in
HomeViewModel.focusItem, with an LRU cache and cancellation of the in-flight job). Adding
fields to a home query is a startup-cost regression — extend the detail call instead.
Emby time values are 100-ns ticks; convert at the boundary (millisecondsToTicks,
resumePositionMs).
Multi-profile session state. SettingsStore stores a list of EmbyProfile (server,
token, userId) and mirrors the active profile into the flat top-level keys the rest of
the app reads. switchProfile/saveSession must keep both in sync; legacyProfile()
synthesises a profile from the flat keys for installs that predate the list. deviceId is
intentionally preserved across clearSession().
The profiles blob deliberately does not carry cached home JSON. Preferences DataStore
rewrites and fsyncs the whole file on every edit, so embedding a several-hundred-KB cache
per profile meant every settings toggle rewrote all of them. writeProfiles is the single
writer and strips the field out into a per-profile home_cache::<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 user-configured Gitea release
(/api/v1/repos/{owner}/{repo}/releases/latest, token auth for private repos), downloads the
APK and hands it to the system installer via FileProvider. Because replacing the APK kills
a running Dream and leaves a black surface, UpdateRecoveryReceiver catches
MY_PACKAGE_REPLACED and relaunches MainActivity with
EXTRA_LAUNCH_UPDATED_SLIDESHOW.
Playback uses Emby's direct stream (/Videos/{id}/stream?static=true) — no
PlaybackInfo/transcode negotiation, so exotic codecs may fail. The
media3-exoplayer-hls dependency is already present for when that's added. Progress is
reported back to Emby via reportPlaybackStarted/Progress/Stopped.
Next up / auto-advance. 30 s before an episode ends, PlayerActivity slides up
player_next_up_banner.xml and rolls into the next episode when it reaches zero (Settings
→ Playback turns it off; Settings.autoPlayNextEpisode). Which episode that is comes from
repository.nextEpisode, dual-path like everything else: /v1/items/{id}/next on the
gateway, Shows/{seriesId}/Episodes?AdjacentTo= directly. Both rely on Emby returning
[previous, current, next] in running order, so it is the position of the current
episode that identifies the next one — never the length of the list, which shrinks at both
ends of a season (episodeAfter in playback.go, unit-tested). Three things are easy to
break: the countdown is driven off the playhead, not a timer of its own, so pausing holds
it and seeking backwards out of the window re-arms it; advancing swaps the MediaItem
inside the running player instead of relaunching the activity, so itemId/playbackStarted
/stopReported must all be reset together or the outgoing episode is never reported
stopped; and a movie simply resolves to null, which is why nothing special-cases item type.
Performance instrumentation. PerformanceMonitor (JankStats) is debug-only and logs to
tag EmbyClientPerf. benchmark/ is a com.android.test macrobenchmark module targeting
the release variants the androidx.baselineprofile plugin generates, so its numbers are
real rather than debug-influenced. HomeBenchmark deliberately measures cold start twice —
CompilationMode.None() and Partial() — because the only way to know the baseline
profile is earning its keep is to see both numbers.
Release builds are minified. isMinifyEnabled/isShrinkResources are on, which takes
the APK from ~14.9MB to ~3.1MB and the dex from 49MB across four files to 4.9MB in one —
no multidex, which matters because minSdk is 23. proguard-rules.pro is what keeps that
safe: it matches @kotlinx.serialization.Serializable on the annotation rather than
listing packages, because the previous rules named com.mattcohen.embyscreensaver.data.model
and had silently matched nothing since v0.1.53. Lint still has abortOnError = false, so
R8 warnings do not fail the build — check the task output after changing dependencies.
Baseline profile. androidx.profileinstaller plus a profile generated by
benchmark/BaselineProfileGenerator.kt. Regenerate against a real television with
.\gradlew.bat :app:generateReleaseBaselineProfile; the result is checked in under
app/src/release/generated/baselineProfiles, so an ordinary assembleRelease needs no
device. A stale profile is not harmful, only progressively less useful.
One HTTP stack. data/remote/HttpStack.kt owns the single OkHttpClient that the
Emby API, the gateway API and Coil's artwork loader all derive from with newBuilder(),
so they share one connection pool and dispatcher. This matters most for artwork: in
gateway mode the images are proxied by the same HTTPS host that serves /v1/home, so a
separate client would repeat the TLS handshake for every poster. Don't construct a bare
OkHttpClient.Builder() — derive from HttpStack.base.
UI conventions
Use androidx.tv.material3 components (Button, Card, Text) rather than the phone
Material 3 ones. MainActivity.kt, HomeComponents.kt and ScreensaverContent.kt are the
three large files — new screens generally belong in ui/<feature>/ rather than growing
them further. Focus handling is explicit (FocusRequester, focusRestorer, focusGroup);
everything must be reachable by D-pad only.
Animations must not recompose. This app ships to weak TV boxes, so an animated value
read in a composable body — val x by animateFloat(...) then using x in the layout — is
a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and
read it inside a Canvas/drawBehind block (draw phase only), and derive any text from it
with derivedStateOf so it recomposes when the displayed value changes, not when the
float does. ServiceAlertBanner's countdown ring and pulse are the worked example: ~10
recompositions of one number over ten seconds instead of ~600 of the whole bar. The same
rule applies to collecting flows — collect in the smallest composable that needs the value,
not at the top of MainActivity, or every emission recomposes the launcher.
Where a composable is too large to split, narrow the state instead. HomeScreen is
the case: it cannot reasonably collect HomeUiState in one place, because reading the
whole object there meant an arriving update verdict, a slow-connection banner or any one
of the four section loads invalidated the launcher and rebuilt every row with it. So
HomeViewModel exposes three distinctUntilChanged projections — content (rows and
their loading flags), status (connection health) and appUpdate — and the screen
subscribes to each where it is rendered. contentSlice() blanks the non-row fields rather
than introducing a separate type, which is what lets homeRowsFor keep taking a
HomeUiState and the tests that pin it keep working; read only rows and loading from it.
Design tokens. ui/theme/DesignTokens.kt is the one vocabulary both surfaces read:
MembySurface (the near-black), MembyAccent, MembyOnSurface/MembyMutedText/
MembyQuietText, MembyScore for the community rating, three corner radii
(MembyChipCorner 8dp, MembyCardCorner 10dp, MembyPanelCorner 14dp), and the two
separators — FactSeparator between facts, ValueSeparator inside a fact that holds a
list. HomeComponents' EmbyGreen/MutedText/QuietText and DetailPageComponents'
Detail* colours are aliases of these; they had drifted into four near-blacks, four greens
and two secondary greys, which is visible the moment a detail page opens from a row. A new
colour or radius belongs in the token file, or is a considered exception — not a fifth
value.
One button language. ui/MembyButtons.kt — MembyPlayButton (focusable),
MembyPlayChip (the same surface as decoration inside an already-focusable parent, for the
home hero) and MembyChoiceChip. There were three: this one, a hand-rolled copy in the hero
with the same look and different metrics, and raw androidx.tv.material3.Buttons with
glyphs typed into their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours
nothing around them uses.
One runtime formatter, one 4K threshold. detail/DetailFacts.kt owns formatRuntime,
heroFacts, ratingLabel, dynamicRangeLabel and UHD_MIN_WIDTH; the home hero and the
card metadata call them rather than carrying private copies. That is why the hero and the
card directly beneath it agree on "2h 4m", and why mediaBadges and the spec row's (4K)
suffix fire at the same width.
Home rows all lead with the same header: HomeRowHeaderIcon + HomeRowHeaderIconGap,
HomeRowHeaderSpacing. A header that skips the icon chip starts its title 38dp left of
every other row, and the launcher's row titles are read as one column.
The home hero (ui/HomeMovieHero.kt) is a featured card plus three minis, each a
HomeHeroPick — the item and the row it was drawn from. The caption used to be the card's
slot ("POPULAR", "NEW RELEASE", "TRENDING" by index) while the selection interleaves sources
and falls back to every movie in the response, so it routinely lied. The featured card is a
fixed height with its content centred, so anything over budget is lost top and bottom and
Play, being last, goes first: a wrapped title stands the synopsis down rather than the
button.
Detail pages are one editorial layout shared by movies and series: DetailPageScaffold
in ui/DetailPageComponents.kt over the pure vocabulary in ui/detail/DetailFacts.kt. It
is a full-bleed cinematic hero — backdrop under two scrims, logo or title, heroFacts line
(year · length · certificate) with the score and format badges trailing it, genres, three
lines of synopsis, one recommendation reason, then Play and the circular secondary actions —
with an uppercase tab strip on a hairline rule anchored under it and the tab's content
beginning below the fold. MediaDetailContent is the movie page, SeriesDetailContent the
series one; they differ only in which tabs they offer. The tabs are Overview, Episodes
(series only), More Like This and Cast & Details.
- The page is a
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. - 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. 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.
Previews. ui/PreviewSupport.kt holds the one preview shape: @TvPreview (1080p TV,
landscape, launcher black) plus PreviewSurface { } for the real theme. Use those rather
than a bare @Preview, which defaults to a phone and misrepresents every layout here.
A preview does not run ServiceLocator, so only composables that take their state as
parameters are previewable — the same property that makes them unit-testable. Prefer
previewing the still inner composable over an animated wrapper (AlertBanner, not
ServiceAlertBanner): a frozen frame of a slide-in shows nothing useful.
Screenshots. app/src/test/.../ServiceAlertBannerScreenshotTest.kt renders composables
to PNGs under app/build/screenshots/ via Roborazzi + Robolectric, at TV 1080p qualifiers
— the way to look at a layout without a TV to hand. This is the only Android dependency
allowed in app/src/test; keep it confined to *ScreenshotTest.kt files so logic tests
stay pure JUnit. Recording is always on (roborazzi.test.record in testOptions): these
are artifacts to look at, not checked-in goldens, and a screenshot test that silently
captures nothing is worse than none. AGP's own com.android.compose.screenshot plugin was
tried first and discovers zero previews on AGP 8.13.2 — don't re-litigate it without
checking that upstream.