App v0.2.27 and gateway 0.1.23

Skip Intro from Emby's own chapter markers, trickplay seek previews from
BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and
review scores, and My Alerts as its own page behind the user picker.

Related titles now degrade at every step instead of returning empty, and
the "+" is back on Manage users so a second viewer can be added from the
launcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-07 10:44:17 +12:00
co-authored by Claude Opus 5
parent 4a4df7a73c
commit 80c304d86b
62 changed files with 6095 additions and 255 deletions
+214 -4
View File
@@ -391,6 +391,30 @@ the alert that announced it has long since fallen out of its window. Things to p
— unauthenticated on purpose, since a probe needing a token would report a stale session
as a server outage.
**My Alerts belongs to a person, so it lives in the user picker.** A service alert is the
house being told something; these are one viewer's own news (a followed show returning),
stored per user on the gateway and following them to whichever television they sign into.
`ui/alerts/AlertsPage.kt` is the full page and the user menu in `UserSwitcherOverlay` is the
way in, beside Manage users. It replaced a bell in the corner of the launcher, which was
drawn only on Home and cost a focus target on every set whether or not there was anything
behind it. Things to preserve:
- **The badge counts alerts, not unread ones** (`alertBadgeLabel`, pure and tested). An
alert that has been read but not dismissed is still sitting there, and a badge that
cleared itself the moment somebody glanced at the page would never agree with the list
underneath it. `AlertBadgeMax` is what stops the pill growing wider than its row.
- **The badge is drawn twice on purpose** — on the "Switch user" rail item and on the My
Alerts row inside the picker. The page is one level in now, so without the mark out on the
rail nothing on the launcher would ever say there was news waiting.
- **A press dismisses, and the focused row says so.** This is the only page whose whole job
is emptying itself; a confirmation press per alert is what made the panel it replaced not
worth opening. Focus marks read, so nothing has to be pressed to clear the "new" flag.
"Dismiss all" is the same per-alert call in a loop — the gateway has no bulk route — and
empties the list optimistically, or a row lingers under a thumb that will press it again.
- **The page is stateless**, like `SignInContent` and the detail panes: `MainActivity` owns
the list and the requests, which is what lets `AlertsPageScreenshotTest` render it (and
the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`.
**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)` →
@@ -518,6 +542,22 @@ rows come from the `r:<userId>:rows` cache, and a miss triggers a deduplicated b
rebuild while home returns immediately. That key is intentionally outside the `u:`
namespace that mutations wipe; only a finished playback retires it.
**"Because you watched …" rotates, because the top of a watch history does not.** The rows
were anchored to the two most recent seeds, and the head of history is a resumable title
plus whichever series the household is part-way through — neither of which moves for weeks,
so the same two rows came back day after day. `selectSeeds` (pure, tested) instead cuts the
most recent `SeedPool` seeds into `MaxSimilarRows` equal bands and draws one from each by
`dailySeed(userID)`. Things to preserve: it is a rotation *within recency bands*, never a
shuffle of the window, so the first row is still anchored to something watched lately and
the rows below it reach further back; the same day always yields the same seeds, because
rows are rebuilt on every cache miss and a set that re-picked each time would change under
somebody browsing; the last band takes the remainder, so a pool that does not divide evenly
still reaches its oldest entry; and a history shorter than the pool falls back to plain
recency rather than pretending to rotate. `similarRow` also runs `diversifyRanked` over its
cards with the same daily variation, the way curated shelves do — a row that keeps its seed
across two days must not present the same posters in the same order. `MEMBY_RECOMMEND_TTL`
(24h) is what makes the rotation daily in practice: the seed only changes at a rebuild.
**`EmbyRepository`** is the only place that talks to Emby. It keeps a `@Volatile` `snapshot`
of `Settings` collected from DataStore so synchronous callers (URL builders,
`rotationIntervalMillis`) don't suspend, and it caches the Retrofit `EmbyApi` instance,
@@ -611,7 +651,7 @@ What syncs is a person's choices; what does not is anything identifying a *telev
device name, update source and token, the screensaver's rotation and ring colour.
`showTitleLogo` / `autoPlayNextEpisode` / `showTenMinuteReminder` moved from device-wide to
per-profile as part of this, because a device-wide value would push whoever signed in last
into everyone else's account. `SettingsStore.applyRemotePreferences` writes all seventeen
into everyone else's account. `SettingsStore.applyRemotePreferences` writes all eighteen
keys and the revision in **one** edit — DataStore rewrites the whole file per edit, and the
revision landing apart from the values it describes would leave a TV permanently believing
it was up to date while holding something else.
@@ -985,6 +1025,118 @@ gateway's catalogue. Things to preserve:
`SEEK_LOADING_GRACE_MS` puts the overlay up after all if the seek is still buffering
6 seconds later, because past that it is not a skip landing, it is a film that stopped.
**And it shows the frame it will land on.** The idea and the shape are borrowed from
[Wholphin](https://github.com/damontecres/Wholphin), under the same GPL-2.0 licence, the
way `PlayerEngine`'s HTTP stack was; what differs is the format underneath. Jellyfin serves
tile sheets, so Wholphin crops a sub-image out of a grid. **Emby serves BIF files**
(`/Videos/{id}/index.bif?Width=320`): a 64-byte header, one 8-byte (timestamp, offset)
entry per frame plus a terminator, then the JPEGs laid end to end — 320×172 every ten
seconds, about five megabytes for a two-hour film.
The index sitting at the *front* of the file is the whole reason this is affordable on a
television. Read the first few kilobytes and every frame's byte range is known, so one
thumbnail costs a ranged request of about seven kilobytes rather than a download nobody
would wait through mid-seek. Things to preserve:
- **Emby answers ranges on that route and does not say so.** The response carries
`Accept-Ranges: none` and a `Content-Length` borrowed from the media file. Trust the 206;
both `emby.TrickplayBytes` and `TrickplayClient` cap the read anyway, because being wrong
about that must not turn a press of Right into a five-megabyte download.
- **A zero-frame BIF is an answer, not a fault.** Emby returns a perfectly well-formed
72-byte file for a title whose thumbnails it has not generated, and for a width it does
not hold — which is why `trickplayWidth` is not a free parameter and the client's
`TRICKPLAY_WIDTH` must match it. The gateway caches that no for
`trickplayMissingTTL` (shorter than the index's day, since thumbnails are generated on a
schedule) or every press on such a title is a fresh round trip for the same answer.
- **The parsing exists twice**, in `data/Trickplay.kt` and `server/internal/trickplay`,
pinned by deliberately parallel tests (`TrickplayTest`, `bif_test.go`) — the usual reason:
with no gateway there is nobody to ask. What differs between the paths is *where the
reading happens*, not what is read. In gateway mode the television holds no Emby
credential, so the gateway reads the file and serves a frame at a time from
`/v1/items/{id}/trickplay/{n}.jpg`; on the direct path the TV range-reads Emby's file
itself. `Trickplay.bif` being null is what distinguishes them.
- **The manifest is its own request, deliberately not a field on `/v1/items/{id}/playback`.**
Reading the index costs the gateway a round trip to Emby, and that response is the one
thing standing between a Play press and a decoder starting. Only the boolean
`trickplayAvailable` rides there — the `subtitleDownloadAvailable` precedent — so an older
or deliberately-configured-off gateway is never asked. It is fetched from
`startPlaybackSession`, beside `loadCast()`, for the same reason that one is.
- **Nothing about it may be on the path of a press.** The chip has always said where the
skip lands and still says it with no thumbnail: a title with no previews, a server that
will not answer and the moment before the first frame arrives are all the same wordless
chip, which is what `SeekIndicatorScreenshotTest`'s empty case exists to hold. Every
failure is silent, and `handleTrickplay` answers trouble with "no previews" rather than an
error nobody could act on and everybody would log once per press.
- **Cancelling the in-flight frame is load-bearing**, the same property `collectLatest` gives
search: presses arrive faster than a fetch completes, and without it a slow response for a
frame already skipped past lands on screen after the one being waited for.
- **The cache holds JPEG bytes, not bitmaps**, and is the player's own rather than Coil's.
Decoded, forty frames would be most of a megabyte; as bytes they are a couple of hundred
kilobytes, and decoding one costs a millisecond off the main thread. Keeping them out of
Coil matters too — a burst of presses walks through dozens, and letting that churn through
the artwork cache would evict the backdrops the launcher is about to want back.
**Skipping the opening titles is Emby's own answer, not a detector.** Emby finds intros
itself and writes them into an episode's chapter list as two markers, `IntroStart` and
`IntroEnd`, interleaved with the ordinary chapters in playback order — so there is nothing
to detect on either end and nothing to store: reading them is one `Fields=Chapters` lookup.
`introFromChapters` (`server/internal/api/intro.go`) and `introSegmentFrom`
(`data/Intro.kt`) are the pure rule, pinned by deliberately parallel tests (`intro_test.go`,
`IntroTest`) for the usual reason — with no gateway there is nobody to ask, and a skip must
not land somewhere different depending on whether the container is up. Things to preserve:
- **Most of the rule is about refusing to answer.** Half a pair, a pair out of order, a
segment under 5 s or over 5 min all produce nothing, and nothing is a good answer: the
player simply never offers the button. A wrong skip costs somebody the opening of a scene,
which is far worse than not being offered one. The first `IntroStart` wins — two starts
mean the markers are already untrustworthy, and the later one is the larger, more damaging
skip.
- **The segment is its own request** (`/v1/items/{id}/intro`), the `trickplay` precedent:
reading it costs a round trip to Emby and the playback response is the one thing standing
between a Play press and a decoder starting. Only the boolean `skipIntroAvailable` rides
there, and the client default is **false**. It is fetched from `startPlaybackSession`
beside `loadCast()`, which is safe because the earliest intro in a typical library starts
a couple of minutes in. "No intro" is cached (server and client) — most of a library has
no markers, and without it the same no would be fetched on every playback.
- **`skipIntroMode` is a synced per-profile setting** (`prompt` / `auto` / `off`), with the
vocabulary duplicated in `data/SkipIntroPreference.kt` and the gateway's catalogue like
the seek interval's. It normalises to **`prompt`**, never `auto`: costing a set its button
because it cannot read a value is recoverable, jumping through somebody's episode on a
string this build cannot parse is not.
- **The ring counts the offer, not the title sequence.** `SkipIntroCountdownView` draws a
draining arc with the figure inside it, advanced from the playhead by
`updateSkipIntroCountdown` — so pausing during the titles holds it and seeking moves it,
neither of which a wall-clock timer could do. It runs from where the button appears to
where it goes, `SKIP_INTRO_TAIL_MS` short of the end of the intro. The two are seconds
apart and only one can be drawn honestly: a ring measuring the whole sequence would stop
with a sliver left and vanish mid-sweep, which reads as a broken countdown rather than as
a lapsed offer. Three things to preserve — the view takes its colours from its own
drawable state and the layout feeds it `duplicateParentState`, because the pill inverts to
white on focus and a ring that did not follow would draw white on white; it refuses to
redraw for movement under a degree, which over a two-minute opening is most of the ticks;
and `formatRemaining` switches to `1:58` over a minute with the text sized from the
string's length, since a title sequence is commonly long enough to be counted in minutes
and "118" would print over its own arc.
- **The button takes focus and the notice does not.** A remote has no other way to say
"press this", so it is focusable and `centrePausesPlayback` stands down while it is up —
otherwise the one button on screen is unpressable. It never appears over the transport,
the drop-up, the cast panel or the next-up banner, which already own the remote. An
automatic skip instead swaps the same view into "Intro skipped" wearing the timing cues'
quiet plate (`dressSkipIntro`), because a picture that jumps for no visible reason reads
as the stream glitching, and a notice that still looks like a button gets pressed. The
ring goes with it rather than freezing at zero: there is nothing left to press and nothing
left to run out.
- **`skipIntroTaken` is never re-armed within an episode**, unlike `skipIntroDismissed`.
Rewinding to before the titles offers the button again — somebody who went back there did
it on purpose — but in automatic mode re-arming would drag them forward again the moment
they reached the opening they had just returned for.
- **The seek goes through `seekBuffering`**, the same door a press of Right uses, so the
couple of seconds it takes to decode at the new position is treated as a skip landing
rather than as a film that has stopped.
- `SkipIntroScreenshotTest` renders it over a deliberately *bright* fake scene →
`build/screenshots/skip-intro/`. There is no scrim under this button, so a capture over
black would prove nothing.
**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
@@ -1149,7 +1301,7 @@ slot ("POPULAR", "NEW RELEASE", "TRENDING" by index) while the selection interle
and falls back to every movie in the response, so it routinely lied. Only the **minis** carry
that caption now: they have no fact line, so the label is the only reason the card gives,
where on the featured card it sat above a line already printing the year and cost the height
that broke the button.
that broke the button — the featured card says why in a sentence instead (see below).
**Play is measured before the words.** The featured card is a fixed height, and a Column
gives each child what the ones before it left — so the chip, being last, was handed the
@@ -1161,8 +1313,66 @@ Keep that inversion. The `titleLines == 1` rule that stands the synopsis down is
having — it means the give usually costs nothing visible — but it is a tidiness, not the
guarantee. `HomeMovieHeroScreenshotTest` renders the wrapping-title case for exactly this.
**The hero changes daily, at local midnight.** `selectHomeHeroMovies(rows, day)` takes a
count of local days and rotates the starting point of each candidate list; `MainActivity`
**The hero is composed by the gateway** (`server/internal/api/hero.go`), because the three
things worth ranking it by are three things the television cannot see. **Radarr knows when a
film actually came out** — `digitalRelease` is the date the household could first have
watched it, where Emby's `PremiereDate` is the theatrical date when it is right at all and a
metadata agent's guess when it is not, so ranking "new releases" by it produced an order
with nothing to do with when anything became watchable. **Sonarr knows a premiere from an
ordinary episode**, so a new show or a returning season can lead where before a series could
only reach the hero as a random card off a shelf. And **the review scores are already
attached to the cards** by `decorateHomeRatings`, so a well-received release can outrank a
fresher one nobody liked at no cost. `rankHeroCandidates` is the pure rule
(`heroRecencyWeight`/`heroRatingWeight`, `hero_test.go`); `selectHomeHeroMovies`'s original
row-interleaving rule survives underneath as the **direct path's** hero and the fallback for
a gateway older than the feature, which is why `serverHeroPicks` is consulted first and
returns nothing rather than throwing. Things to preserve:
- **It asks Emby for nothing.** The movie candidates are the rows already assembled and
their ratings are already attached, so the expensive half of the launcher is reused
rather than repeated. The two *arr calendars it does read are cached for the day behind a
shared lock, like the schedule rows' — one household pays one miss each per day — and the
three lookups run concurrently, because this is the tail of a response every television
in the house is waiting on.
- **Every card it produces is playable.** A premiere the household has not downloaded, a
film Radarr is still waiting on, a synthetic schedule card — all are news for the schedule
row, and a lead card that does nothing when pressed is worse than no lead card at all.
`sonarrPremieres` requires `HasFile` *and* an Emby series id for exactly this.
- **A premiere is the first episode of a season**, S01E01 or S05E01 alike, and season 0 is
specials rather than a premiere. One card per series, the newest season winning, or a show
that premiered and returned inside one window appears twice.
- **An unrated title is not a bad title** (`heroUnratedScore`, deliberately mid-scale). On a
household that has not configured MDBList that is every title, and burying them would
empty the hero; `heroRatingOf` falls back to Emby's `CommunityRating`, which the client is
still forbidden from *drawing* — ordering four cards by a score claims nothing to anybody,
where printing it beside a provider's name that was never asked is a lie.
- **The captions and the reason are the gateway's wording** (`MembyHeroLabel`,
`MembyHeroReason`), the `MembyAirLabel` precedent, so a kind of hero card invented
tomorrow reads correctly on today's build. `labelTint` matches them as strings for the
same reason, and an unknown one gets the neutral wash rather than nothing.
- **A label is a claim that has been earned**, and `heroReason` returns empty rather than
inventing one — the captions this replaced were the card's *slot*, which is how a 2019
film came to be announced as new.
- **The row is consumed, never drawn.** `serverHomeRows` drops `kind == "hero"`; without
that the four featured titles print a second time as an unnamed row of posters directly
beneath the hero they are already in. `supportsHomeHero` gates it at 0.2.27 for the same
reason — an older television has no idea the kind is special. That floor is the version
the feature shipped *in* rather than one after it, so a 0.2.27 build predating it would
draw the duplicate row; moving the floor up is the fix if that ever bites.
- **No daily rotation on the server's hero.** The facts behind it already change daily, and
rotating a merit ranking is exactly how the best-reviewed release of the week lands in the
fourth slot. The rotation below belongs to the direct path, which has no merit to rank by.
**The reason sits above the ratings strip**, and that order is load-bearing. The featured
card's text column is what gives way when a title wraps onto two lines, so whatever is last
in it is cut — with the reason below the strip, the one line explaining why this card leads
the launcher was silently dropped on exactly the long-titled films most likely to be leading
it. The scores are also on the detail page the card opens; the reason is nowhere else. It
takes the synopsis's place rather than adding a line, so preferring it can only make the
card shorter, and there is still no eyebrow above the title.
**The direct path's hero changes daily, at local midnight.** `selectHomeHeroMovies(rows, day)`
takes a count of local days and rotates the starting point of each candidate list; `MainActivity`
keys its `remember` on `rememberHomeHeroDay()`, which sleeps until the next local midnight
rather than polling. Three properties are load-bearing and unit-tested. It is a *rotation*,
not a shuffle: the server's ranking is still the order, so what it thinks is worth leading