Publish current app and server
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user