0.2.67 - Detail pages improvements

This commit is contained in:
ponzischeme89
2026-08-16 09:02:17 +12:00
parent 4bc4b075ec
commit bd3732fba5
31 changed files with 2479 additions and 562 deletions
+127 -48
View File
@@ -1938,6 +1938,22 @@ with the same look and different metrics, and raw `androidx.tv.material3.Button`
glyphs typed into their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours
nothing around them uses.
**"Close Memby?" is the one full-stop dialog**, `ui/ExitConfirmation.kt`, drawn when the
viewer has asked to be asked (`confirmExitMemby`). It is the panel that appears *over*
Memby, so it was the one thing on screen that must not look like somebody else's app — and
it was two identical raw `androidx.tv.material3.Button`s wearing Material's own colours,
which meant the destructive answer and the safe one were the same shape at three metres.
Things to preserve: the two actions **do not look alike** (Stay is the accent fill and takes
focus first, closing is a quiet outline), the entrance is read only inside `graphicsLayer`
lambdas so nothing recomposes while the panel arrives, and **Back means stay** — it is the
key that raised the panel and pressing it again must not be what closes the app. The scrim
is a wash rather than a flat black, so the library underneath stays faintly readable and
this reads as a question asked over it. `focusedForCapture` exists because Robolectric's
window never takes focus and the focus ring is the whole of what says which action a press
would take; `ExitConfirmationScreenshotTest` renders both answers under it, plus one
capture under a foreign palette — this is the only dialog drawn entirely from the tokens,
so a theme that could not reach it would show here and nowhere else.
**Settings is black, flat, and says one thing once.** `ui/settings/SettingsSheet.kt` had four
stacked surfaces to show two switches — the page, the rail, a titled section card, and the
rows inside it — and the card's title repeated the page header, which repeated the rail item
@@ -2088,50 +2104,72 @@ midnight, which is why the zone offset is a parameter to the pure `localEpochDay
arrived in API 24 and this app ships to 23, so that arithmetic is written out by hand.
**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.
in `ui/DetailPageComponents.kt` over the pure vocabulary in `ui/detail/DetailFacts.kt` and
`ui/detail/DetailTabs.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. A movie's sections are Overview, Cast, Extras,
More Like This and Details; a series' are the same with **Episodes** in Overview's place,
because a show's synopsis is already in the hero and what a show is *for* is its episodes.
- **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.
- **The page is a `Column` and nothing scrolls it.** Hero, strip, pane, footer. It used to be
a `LazyColumn` of three items with the list pinned to offset 0 while focus was in the hero,
which meant the hero's own height decided where the strip ended up — so a two-line title
pushed the tabs down and the content with them, and every press into the content was a
scroll the viewer had to watch land.
- **The hero collapses instead.** `detailHeroCollapsed(zone)` is the whole rule and it is
pure and tested: whole while focus is on Play, out of the way the moment focus is below it.
A single `animateFloatAsState` drives it and is read **only inside layout and draw
lambdas** — `Modifier.collapsingHeight` for the band and `graphicsLayer` for the cross-fade
— so a collapse costs a measure pass rather than recomposing the page sixty times a second.
Two details hold the promise that no focused thing ever resizes: the hero is measured at
its *full* height and slid upward under a clip, so nothing inside it re-wraps; and the pane
takes `weight(1f)`, so it simply receives what the hero gives up.
- **What is left is `DetailCollapsedHeader`** — the title and the fact line and nothing else.
Not a shrunken hero: the synopsis, the ratings, the reason and the actions all answer "is
this worth watching", which somebody down in the Cast grid has already answered. What
remains is only what stops the page becoming anonymous.
- **A pane fits its slot, or is a grid.** Prose panes (Overview, Details) do not scroll — a
page that scrolls *and* has tabs gives the D-pad two meanings for Down — and now have
roughly three times the room they had, since `detailPaneHeight(viewportHeight)` is what the
collapsed hero and the strip leave rather than a 250dp budget. Cast, Extras and More Like
This *are* grids and scroll inside themselves, which is a different thing: Down inside a
grid still means "next row".
- **The strip keeps a safe-area inset.** `DetailFoldPeek` holds it off the bottom edge while
the hero is whole, 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.
would show content that contradicts the highlight. A *click* reports itself as arriving in
the strip too, or a mouse or a test could change the pane and leave the hero over it.
- **The strip is content-driven, and frozen once the viewer is inside it.** `detailTabs` is
pure over a `DetailTabAvailability`, and `rememberDetailTabs` recomputes it only while
focus is still in the hero. That is the seam between two real failures: a tab leading to an
apology is dead weight on every set in the house for ever, and a tab appearing a second
after the page opens shoves every tab to its right under a moving thumb. Three rules make
it safe — the landing pane is structural and always first, so the tab under focus on frame
one can never move; optional tabs are only ever *appended* in one order, so one arriving
cannot reorder the rest; and each flag's "still loading" value is chosen by which way it is
usually wrong (related titles exist for nearly everything, so the tab is there while the
request runs; extras exist for a small minority, so the tab waits until one is found).
- **`detailTab(key, available)` falls back to the landing pane, not to Overview.** Three
things arrive with a key that is not on offer and all are ordinary: a series' Episodes
carried to a movie, a tab whose content has gone away, and `cast-details` remembered by a
build that predates the Cast/Details split.
- **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.
the outgoing pane composed for its 80ms fade, so a requester attached by two panes 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.
band held focus, the episode rail's offset, and the cast grid's *two* numbers — 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
@@ -2165,11 +2203,44 @@ series one; they differ only in which tabs they offer. The tabs are Overview, Ep
`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.
- **More Like This** is the same response's `items`, as its own tab, and a poster *grid*.
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.
- **Cast is a grid, in `ui/detail/CastGrid.kt`.** It replaced a compact rail wedged into the
corner of a combined "Cast & Details" pane, which showed six faces of a forty-strong cast
and gave the other thirty-four no way of being reached. Things to preserve: the column
count is **fixed** rather than `GridCells.Adaptive`, because Up out of the first row has to
return to the tab strip and the only way to know which cards are in the first row is to
know how many there are — `castColumns` is that arithmetic, pure and tested at every
television width; the card width is tuned so the **second row peeks**, since one row filling
the pane with nothing under it reads as a shelf that failed rather than as a grid;
`castMembers` deduplicates rather than disambiguating, the `ui/ListKeys.kt` rule, because
position is what has to survive coming back to the page; the character line is **reserved,
not conditional**, or a card with no role recorded sits a line higher than the one beside
it; and the initials are drawn *behind* the portrait, the stance the player's cast panel
takes, so nothing has to decide in advance whether artwork will arrive. Selecting a card is
inert for now — a person page is the obvious next thing and the grid already remembers
which card to come back to.
- **Extras is `GET /v1/items/{id}/extras`**, and it is a *join*: Emby answers the question in
two places and neither includes the other, `SpecialFeatures` holding the featurettes and
deleted scenes and `LocalTrailers` holding the trailer. `server/internal/api/extras.go`
joins them (only *both* lookups failing is a failure — a title can perfectly well have one
and not the other) and `dedupeExtras` drops a file filed in both, because the television
renders these into a keyed grid and a repeated key throws. The direct path makes the same
two calls itself. Two client rules: the repository caches the **empty** answer, since most
of a library has none and every page open asks; and a 404 is read as "no extras" rather
than as an error, so a new APK against a gateway that predates the route simply does not
offer the tab. `extraKindLabel` names the kind from Emby's `Type`, spacing out anything it
does not recognise so a category added tomorrow still reads correctly.
- **Details is `detailRows(item)`** beside `technicalSpecs(item)` — catalogue facts on the
left, what the file is on the right. Its vocabulary (Studios, Taglines, PremiereDate,
OriginalTitle, ProductionLocations) is read nowhere else and had to be added to
`fieldsDetail`, which is why the gateway's item cache key moved to `item:v6:` — entries
written before it cannot hide a newly requested field. A row is omitted rather than printed
empty, and Studio is dropped from the file column when the catalogue column already said
it: printed twice side by side it reads as a page that cannot make up its mind.
- **Nothing about related titles is allowed to fail.** It is asked for on *focus*
(`HomeViewModel.focusItem` warms it while the card is highlighted), so it is the most
frequently made request on the launcher and was by some way the loudest thing in the
@@ -2186,10 +2257,18 @@ series one; they differ only in which tabs they offer. The tabs are Overview, Ep
client-side `getRelated` is single-flighted on the repository's own scope, so the
cancelled focus prefetch neither aborts the request the detail page is about to want nor
caches its own failure as an answer.
- `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.
- **Nothing is loaded for a tab nobody has opened.** Each request is its own
`LaunchedEffect` in the overlay and none blocks the page: episodes, related, trailer,
extras and ratings all arrive independently, and every one of them is single-flighted and
cached in the repository, so warming a detail page on D-pad focus and then opening it costs
one request rather than two. The pane itself is the second half of that — while the hero is
whole the pane is 34dp, so a grid composes one row until the viewer actually goes there.
- `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer,
extras) 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.
Because a click now reports itself as arriving in the strip, every tab capture is also the
check that the hero collapsed and the strip moved to the top of the usable area.
**"Estimated finish: 18 August"** is `data/SeriesPace.kt`, a quiet line under the series
hero's progress bar. It is derived entirely from the episode list the detail page already