Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+248 -11
View File
@@ -203,9 +203,38 @@ the rail) because otherwise D-pad focus has nowhere to go once the rows are gone
`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:
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/radarr` is the "On
Import" webhook and the one thing that pushes *into* the gateway, guarded by
`MEMBY_RADARR_WEBHOOK_TOKEN` (unset ⇒ 404, the stance `/admin` takes) and mounted
outside both the auth middleware and the maintenance gate, because an event dropped
during maintenance is lost rather than delayed. A quality upgrade is deliberately
silent: the film was already there.
- `AnnounceLibrarySync` in `api/server_alerts.go`, hung off `syncer.SetAfterSync` in
`main.go` — "24 titles added or updated". Only a run that *changed* something is
announced; the import is scheduled, most passes find nothing, and an hourly "no news"
banner would train viewers to ignore the real ones.
- `WatchEmbyReachability`, same file — "Emby has stopped communicating" and the matching
"back online". Only transitions are announced, and only after
`embyFailureThreshold` consecutive failures, because one timeout is a hiccup and
repeating an outage every minute would bury everything else. This is the alert that
earns the banner its place over playback: video direct-plays from Emby, so when Emby
stops answering the film stalls with no explanation, and the gateway is still up to
say why. `MEMBY_EMBY_HEALTH_INTERVAL=0` turns the probe and both banners off.
`mergeAlerts` interleaves derived and published alerts newest-first and caps at
`maxAlerts`, which is why every alert carries a timestamp. Alerts also carry their own
`label` (the banner's eyebrow — "JUST AIRED", "NEW MOVIE ADDED", "SERVER NOT RESPONDING"),
so a new kind of news reads correctly on an app that predates it; a client that receives
none falls back to the episode wording. Things to preserve:
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
@@ -219,7 +248,21 @@ times itself out after `MaintenanceMonitor.ALERT_VISIBLE_MS` (10s, with a ring c
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.
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.
@@ -254,6 +297,15 @@ 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
@@ -279,15 +331,58 @@ 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()`.
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.** `AppRoot` used to hold the
launcher on the loading screen until `getRecommendationOnboarding()` answered, which on a
slow connection meant the cached rows could not be drawn until the connect timeout
expired. Onboarding completion is now persisted (`Settings.hasCompletedOnboarding`,
`markOnboardingCompleted`) and consulted first; the gateway is still asked in the
background and stays authoritative for anyone not yet recorded. For a profile with no
record yet the ask is still on the critical path, so it is bounded by
`ONBOARDING_CHECK_TIMEOUT_MS` and fails open to the launcher — and only a verdict the
gateway actually returned is persisted, or a single slow response would retire the rating
screen for someone who has never seen it. The `onboardingToken` guard that stops repeat
asks must always be paired with a null check on the verdict itself: on its own it can
match a restarted effect's own token and strand the launcher on the loading screen
permanently.
- **The settings flow must never be able to terminate.** It is the gate everything waits
behind, and a `shareIn`ed flow that completes exceptionally never emits again — so a
single failed read shows up as "Opening Memby…" forever, surviving relaunch. Hence the
`ReplaceFileCorruptionHandler` on the DataStore and the `catch` before `shareIn`. Do not
remove either: this store is rewritten on every home refresh, so a process killed
mid-write is an ordinary event on a TV.
- **The cache is decoded off the main thread.** `SettingsStore.primeHomeCache` parses it on
the store's IO scope as the settings flow emits, and `homeCache()` returns that memoized
copy — `HomeViewModel`'s constructor runs during composition, so decoding there parsed
the whole blob on the main thread at exactly the wrong moment.
- **`setHomeCache` skips unchanged writes** (`lastPersistedHomeCache`). It runs on every
refresh *and* every playback stop, and most passes find nothing new.
**Screensaver hosting.** `ScreensaverContent` is shared by `MembyDreamService` and
`ScreensaverActivity`. A `DreamService` is not a `ComponentActivity`, so
@@ -324,9 +419,32 @@ inside the running player instead of relaunching the activity, so `itemId`/`play
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.
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
@@ -346,6 +464,125 @@ recompositions of one number over ten seconds instead of ~600 of the whole bar.
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.Button`s 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 `LazyColumn` of exactly three items** — hero, tabs, content — and the hero
owns the opening frame: while focus is in it the list is pinned to offset 0 (see
`detailHeroScrollTarget` and the `snapshotFlow` beside it), because LazyColumn's own focus
relocation would otherwise leave Play visible with the title scrolled off the top. Moving
to the strip or the content releases the pin.
- **A pane never scrolls.** Every section is a tab and every tab fits its slot, so adding
content means adding a tab: a page that scrolls *and* has tabs gives the D-pad two
meanings for Down. The slot is `detailPaneHeight(viewportHeight)`, derived from the screen
rather than fixed — it was a hard 250dp, and everything `technicalSpecs()` produces fell
off the bottom of Cast & Details, which is the whole reason that tab exists. If a pane
needs more than the budget, cut rows; do not add a scroller.
- **The strip keeps a safe-area inset.** `DetailFoldPeek` holds it off the bottom edge,
where overscan was cutting the selection underline in half, and leaves the top of the pane
showing beneath it. That peek and the chevron at the end of the strip are the only things
on screen saying that Down reveals anything.
- **Focus is selection** in the tab strip. A remote has no hover, so a strip that highlights
one tab while a different one stays open would need a second press to mean anything and
would show content that contradicts the highlight.
- **The strip is decided by what the item is, never by what has loaded.**
`detailTabs(isSeries)` returns a fixed list, and a section with nothing in it yet says so
in its own pane. It used to offer only the sections that already had content, which meant
a movie opened with one tab and grew two more when its detail record landed, moving the
strip under the viewer's thumb. `detailTab(key, available)` still resolves a remembered
key, but now only has to catch a key carried over from the other kind of item.
- **One `FocusRequester` per pane**, never one shared between them. `AnimatedContent` keeps
the outgoing pane composed for its 80ms fade, so a requester attached by both the Overview
and the Cast & Details pane is attached to two live nodes, and a Down press landing in that
window can focus the pane that is disappearing.
- **Position is remembered per item** in `ui/detail/DetailPosition.kt` — tab, season, which
band held focus, and both rails' scroll offsets — in a process-scoped, capped, LRU store
outside the composition, because closing a detail overlay destroys `rememberSaveable` with
it. `RestoreDetailFocus` focuses Play first (it exists on frame one, so the remote is live)
and then restores the band, once, only if that band has something placed to land on.
Deliberately not persisted: a TV switched on the next morning should open a show where the
*show* is up to.
- Every `focusProperties { up/down/left/right = … }` target must be attached **on the
current frame**. Season chips and episode cards do not exist while the episode request is
in flight, on a one-season show, or on any tab but Episodes — pointing at their
`FocusRequester` anyway throws the moment the viewer presses that direction.
`SeriesDetailContent` resolves each target to `FocusRequester.Default` when its
destination is off screen; keep that.
- The backdrop is held under two gradients before any text is drawn. The reference is flat
black and that flatness is most of why it reads as modern; the artwork is there for tone,
not as a picture.
- A tab item is `Modifier.width(IntrinsicSize.Max)`. Without it the underline's
`fillMaxWidth` claims the whole strip and pushes every later tab off the screen.
- The hero honours `Settings.showTitleLogo` and `useTextTitleForLogo` (`ui/TitleLogo.kt`,
shared with the screensaver): transparent Emby logos are commonly black, and a black title
treatment on this scrim is an invisible heading.
- **Why you might enjoy it** is the single accent line above the actions, from
`GET /v1/items/{id}/related`. It comes from the same profile the home rows are built from
(`recommend.Why`), so the page can only claim a taste the engine actually learned; a cold
profile falls back to catalogue facts. Never focusable.
- **More Like This** is the same response's `items`, as its own tab. Selecting one opens
*its* detail page, and `MainActivity.detailsTrail` walks Back home one page at a time. On
the direct path there is no engine, so `EmbyRepository.getRelated` returns Emby's
`Items/{id}/Similar` with no reasons at all — both halves are allowed to be empty and the
page still opens.
- `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer) and
delegate; `SeriesDetailContent`/`MediaDetailContent` are parameter-driven so they can be
screenshotted (`DetailPageScreenshotTest`, which drives the tab strip by clicking it and
also renders each pane on its own at `detailPaneHeight`) without a server.
**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.