This commit is contained in:
ponzischeme89
2026-08-16 12:13:51 +12:00
parent bd3732fba5
commit b9374baaf1
47 changed files with 2793 additions and 364 deletions
+104 -13
View File
@@ -1936,7 +1936,16 @@ continuously while somebody is merely browsing, so:
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.
nothing around them uses. `MembyArtworkPlayCue` is the fourth member and the same rule: the
circular Play mark over a focused artwork card, on the launcher's posters *and* on a detail
page's episode rows, which used to draw their own black disc at their own diameter.
**And one card-focus language on the detail pages**, `Modifier.detailCardFocus` in
`ui/detail/DetailCardFocus.kt`. The cast grid, the "More like this" posters and the Extras
thumbnails had three copies of the same lift-and-grow at 1.06, 1.06 and 1.05 over 110ms,
100ms and 100ms; nobody chose three figures, they were written on three days. The animated
value is read only inside the `graphicsLayer` block, so travelling a grid redraws two cards
rather than recomposing every card in it — which is why it is a modifier and not a wrapper.
**"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*
@@ -2104,8 +2113,13 @@ 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` and
`ui/detail/DetailTabs.kt`. It is a full-bleed cinematic hero — backdrop under two scrims,
in `ui/DetailPageComponents.kt` over the pure vocabulary in `ui/detail/DetailFacts.kt`,
`ui/detail/DetailTabs.kt` and `ui/detail/DetailHeroPhases.kt`. Movie, series and episode are
three sets of parameters to that one scaffold, never three layouts — the shared pieces are
`DetailHeroActions` (so a Continue Watching page cannot grow its own Play button metrics),
`DetailIdentity`, `DetailStripFrame` and `Modifier.detailCardFocus`, and anything that has to
differ between them is a scaffold parameter so the difference is stated in one place. 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
@@ -2122,15 +2136,71 @@ because a show's synopsis is already in the hero and what a show is *for* is its
- **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.
lambdas** — `Modifier.collapsingHeight` for the band and `graphicsLayer` for everything
else — 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.
- **One number drives the whole transformation**, and what each part of it means is
`ui/detail/DetailHeroPhases.kt`. The height, the backdrop's opacity, the deepening wash,
the supporting text, the action row and the pinned header are **bands of that one value**
rather than animations of their own — which is what makes the collapse read as one
movement, and what stops the parts drifting out of step when it is interrupted half way.
`DetailHeroPhaseTest` pins the *ordering* rather than the figures: the supporting half is
always at least as far gone as the primary half (that is progressive disclosure, stated as
an assertion), the two headings are never both on screen, and the plate always leads the
pinned header in. It is a **critically damped spring**, not a tween: a held D-pad produces
a press every few frames, and a spring retargets from wherever it is rather than restarting
a duration — and it can never overshoot past 1, which on a value driving opacities would be
a flicker.
- **The backdrop recedes; it never disappears.** `heroArtworkAlpha` takes it down as the
content takes over and the wash over it deepens as a gradient, so the picture settles back
rather than a sheet being drawn across it. Behind the pinned header is a soft plate
(`pinnedScrimAlpha`) occupying exactly the band the header lands in and fading from nothing
at its top edge — a **gradient, never a bar**: the point is a perceptible separation
between a fixed header and scrolling content, and a solid block reads as a phone toolbar.
- **What is left is `DetailCollapsedHeader`** — the title, the fact line, and on an episode
the show and `S03E04`. 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. The compact
eyebrow is its own parameter (`pinnedEyebrow`) because "SEASON 3 · EPISODE 4" spelled out
pushes the fact line off the end of a one-line header.
- **The hero is a budget, and the reading column is what pays.** The expanded content is
anchored to the bottom of its band and grows upward, so `DetailHeroMetrics.TopInset` is the
ceiling it may not cross — and with a ceiling, something has to give. What gives is the
**supporting block** (ratings, genres, synopsis, pace, reason), which is the Column's one
`weight(1f, fill = false)` child: weighted children are measured from what the unweighted
ones left over, so a two-line title costs prose rather than costing the primary action its
shape. Getting that inversion wrong is precisely the 0.2.67 regression — an episode page
reached from Continue Watching carries more than any other variant, and the Play button,
being last in the column, was handed whatever height was left and rendered as a squeezed
sliver. It is the same inversion the home hero already makes, and it is now asserted:
`EpisodeDetailScreenshotTest` renders the most crowded hero the app can produce and fails
the build if the primary action is under its natural height.
- **Prose gives way a whole line at a time.** `Modifier.wholeLines` reports a height rounded
down to the last complete line, with the clip *outside* it — a `Text` handed less room
still draws every line it was asked for, and without the clip the dropped lines painted
over whatever the column placed underneath. Two lines instead of three reads as nothing at
all; prose sliced through the middle of its letters reads as a rendering fault.
`DetailHeroMetrics.SynopsisLines` is **two**, not three: the whole description is one press
away in Overview, and the third line was the difference between the movie hero fitting its
band and overflowing it.
- **The identity block reserves its height only where a logo could actually appear.**
Deciding whether a logo is legible means fetching and decoding it, so every page begins on
the text fallback and changes its mind a moment later — a swap that used to move everything
under it on the opening frame. `logoUrl != null` is known synchronously from the image
tags, so a title Emby holds no logo for is drawn at its natural height and gives the space
to the synopsis. Very wide and very tall logos are both a letterboxed picture inside a
fixed box: a logo can never change the page's shape.
- **The ratings strip is the one place it does not reserve.** Most of a library has no
scores — an episode is rated as its series, a household with no MDBList key has none at all
— and 42dp held open for them was 42dp taken off the synopsis on every one of those pages.
It sits inside the flexible block, so a strip arriving late costs a line of prose rather
than moving anything the viewer is aiming at.
- **The circular actions name themselves.** A heart, a tick, a bookmark and a film reel are
guesses at three metres, so the focused one's description is printed on a **reserved** line
under the row — reserved, because a line that appeared when focus reached the second button
would move the whole hero on every press of Right.
- **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
@@ -2145,6 +2215,15 @@ because a show's synopsis is already in the hero and what a show is *for* is its
one tab while a different one stays open would need a second press to mean anything and
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.
They are still **marked separately**, and that is not a contradiction: the accent underline
is *selection* — which pane is open — and the plate behind the label is *focus* — where the
remote is. One causes the other while the viewer is in the strip, and they come apart the
moment they press Down, which is the case the distinction exists for. The season scroller
on an episode page marks them the same two ways for the same reason.
- **Both bands wear `DetailStripFrame`** — the plate, the gutters, the hairline and the
chevron. The tab strip and the season scroller define the same fold, and two hand-written
copies of it is exactly how the two pages came to sit their content a couple of pixels
apart.
- **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
@@ -2169,7 +2248,19 @@ because a show's synopsis is already in the hero and what a show is *for* is its
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.
should open a show where the *show* is up to. The band is reported on the way *in* to it,
never on every focus move inside it — the pane's `hasFocus` fires for each card a viewer
walks past, and each of those was a write into the store, on the one press that must feel
free.
- **Returning from the player comes back to the page it was launched from.**
`MainActivity` holds a `DetailsReturn` — the item, the trail and the airing notice — across
the playback and reopens on `LifecycleResumeEffect`. Deliberately a *reopen* rather than
leaving the page composed under the player: the page has to tell the viewer the truth, and
the episode they just finished is now watched, the film is now part-way through, and the
next episode is a different one, so the item is re-requested on the way in. Reopening also
runs the same restore path a Back press through the trail already uses, which is what
brings back the tab, the season, the grid offset and the band that held focus — rather than
depending on Compose having held focus across another activity's window.
- 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