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:
co-authored by
Claude Opus 5
parent
4a4df7a73c
commit
80c304d86b
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user