24 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).
The only producer today is api/alerts.go — an episode whose Sonarr air time has passed
but which Emby has not imported yet ("aired, coming soon"). It reads the cached
airing-today calendar, so polling clients never cost a Sonarr request. 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. MEMBY_SONARR_ALERT_WINDOW=0 turns them off without touching the schedule row.
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.
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, plus that profile's cached home JSON) 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().
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.
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 currently
targeting the debug build (suppressErrors = DEBUGGABLE), so its numbers are
debug-influenced.
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.
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.