0.2.67 - Detail pages improvements
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user