0.3.37
This commit is contained in:
@@ -855,6 +855,41 @@ at a time. Things to preserve:
|
||||
results; a genre result claims its own requester when the first page lands, Back restores
|
||||
the strip, and an empty or failed genre hands focus back deterministically rather than
|
||||
leaving a television with nothing focused.
|
||||
- **The four focus zones are one graph, and it is a pure function.** `ui/genre/GenreNavigation.kt`
|
||||
— `GenreZone` (`APP_RAIL` / `SERVICE_FILTERS` / `GENRE_LIST` / `RESULTS_GRID`), `ResultsOrigin`,
|
||||
and `genreFocusMove(zone, key, ctx)` returning a `GenreFocusTarget` — is the whole of the
|
||||
zone-boundary behaviour, unit-tested exhaustively (`GenreNavigationTest`) the way `detailTabs`
|
||||
and `calendarMonthFocusAnchor` are. Interior travel (the next icon, genre, poster) stays with
|
||||
Compose's per-item `focusProperties` and comes back as `Fallthrough`. `GenreBrowseScreen` was
|
||||
split into `GenreBrowseScreen` (view-model glue) and `GenreBrowseContent` (rendering + the
|
||||
graph), the `SettingsPanelContent` precedent, so `GenreBrowserFocusTest` drives the regression
|
||||
scenarios through real key events with a canned `GenreBrowseUiState`. Things to preserve:
|
||||
- **The grid follows the committed filter, not the D-pad.** Moving the marker across the
|
||||
rail or the services strip only changes the highlight (`activeCategoryId`); the results
|
||||
pane, its heading and the rail's own accent wash stay on `state.selectedCategoryId`,
|
||||
which changes only on OK or Right-into-grid. This is the contract's focus-vs-selection
|
||||
split, and it is the opposite of the old rule (which recomposed the pane on every focus
|
||||
move). `GenreRailItem`/`ServiceIconButton` draw the bright plate/ring from `hasFocus`
|
||||
and the quiet wash from the committed id. The first commit is seeded once from the
|
||||
filter the screen opens on, since a fresh browser has nothing selected.
|
||||
- **OK on a service or genre filters *and* enters the grid.** The grid can only be
|
||||
focused once its first page has arrived, so the press is held in `pendingGridEntry`
|
||||
and acted on when the results land — and dropped if they land empty, because focus must
|
||||
never move into an empty grid. Right out of a genre commits it and enters its grid;
|
||||
Right off the services strip's last icon is a predictable dead edge, never a jump.
|
||||
- **Left off the grid's first column returns to the *originating* filter**, tracked as
|
||||
`ResultsOrigin` rather than inferred from which focusable sits nearest the edge. Enter a
|
||||
Netflix-filtered grid and Left comes back to Netflix; enter it from Drama and Left comes
|
||||
back to Drama.
|
||||
- **The Services strip walks with an `onKeyEvent` on the `LazyRow`, not per-icon
|
||||
`focusProperties`.** More services than fit the 214dp rail is a matter of time, and an
|
||||
off-screen icon has no attached requester for a `focusProperties` target to name — the
|
||||
press scrolls it into composition first, the `GenreRail` Right-into-grid precedent. Right
|
||||
off the last icon is a predictable dead edge; a service never enters the grid by a Right
|
||||
press.
|
||||
- **Re-entry restores the last provider/genre and zone** via `GenreBrowseFocusMemory`
|
||||
(process-scoped, the `DetailPositionStore` precedent; deliberately not persisted — a set
|
||||
switched on the next morning opens at the top of the catalogue).
|
||||
- **Episodes are excluded on both paths.** An episode inherits its series' genres, so
|
||||
including them fills a page with twenty entries of one comedy and buries the rest.
|
||||
- **Movies and TV Series have their own full-width genre browser.** A fixed row of colourful
|
||||
@@ -2778,15 +2813,33 @@ recompositions of one number over ten seconds instead of ~600 of the whole bar.
|
||||
rule applies to collecting flows — collect in the smallest composable that needs the value,
|
||||
not at the top of `MainActivity`, or every emission recomposes the launcher.
|
||||
|
||||
**Where a composable is too large to split, narrow the state instead.** `HomeScreen` is
|
||||
the case: it cannot reasonably collect `HomeUiState` in one place, because reading the
|
||||
whole object there meant an arriving update verdict, a slow-connection banner or any one
|
||||
of the four section loads invalidated the launcher *and* rebuilt every row with it. So
|
||||
`HomeViewModel` exposes three `distinctUntilChanged` projections — `content` (rows and
|
||||
their loading flags), `status` (connection health) and `appUpdate` — and the screen
|
||||
subscribes to each where it is rendered. `contentSlice()` blanks the non-row fields rather
|
||||
than introducing a separate type, which is what lets `homeRowsFor` keep taking a
|
||||
`HomeUiState` and the tests that pin it keep working; read only rows and `loading` from it.
|
||||
**`HomeScreen` is split three ways, and it had to be.** It was one ~2670-line `@Composable`
|
||||
that exceeded ART's method compiler limit (20 926 instructions vs the 10 000 optimiser
|
||||
cutoff) — so it ran **fully interpreted** — and every rail-section switch or overlay toggle
|
||||
re-executed the whole tree, freezing a Chromecast for 1–2 s per press. It is now
|
||||
`HomeScreen` (view models, effects, the rail) → `HomeContentPane` (`HomeContentPane.kt`, the
|
||||
per-destination panes and row/hero machinery) and `HomeOverlays` (`HomeOverlayHost.kt`, the
|
||||
whole overlay stack: Settings, Profiles, the user picker, the detail page, My Shows,
|
||||
Requests, Alerts, the quick-actions menu, the loading overlay, the top banners), plus
|
||||
`rememberHomePlayback` (`HomePlayback.kt`, the `ActivityResultLauncher` and stream
|
||||
resolution). Things to preserve:
|
||||
- **Shared launcher state is `HomeScreenState`** (`HomeScreenState.kt`), a single `@Stable`
|
||||
holder `rememberSaveable`d keyed on `settings.userId` (matching the `remember(userId)`
|
||||
reset semantics it replaces; a `Saver` persists only the navigation fields). Passing it as
|
||||
one stable param is what keeps `HomeContentPane` and `HomeOverlays` independently
|
||||
skippable — each recomposes only for the properties it reads. Do not go back to threading
|
||||
40 value/setter pairs, and do not read the whole holder anywhere it isn't needed.
|
||||
- **The content area is not wrapped in `BoxWithConstraints`.** That was a `SubcomposeLayout`
|
||||
around the entire pane, so a section switch re-composed it *during the layout pass*
|
||||
(measure → recompose → measure) — 1.4 s of one frame on the Chromecast. Screen size comes
|
||||
from `LocalConfiguration` instead (`screenWidthDp - TvRailCollapsedWidth`); the window is
|
||||
the screen on a TV.
|
||||
- **Narrow the projection, not the split.** `HomeViewModel` still exposes
|
||||
`distinctUntilChanged` projections — `content` (rows + loading flags), `status`
|
||||
(connection health), `forYou`, `favoriteChanges`, `playedChanges` — collected where they
|
||||
are rendered so an arriving update verdict or a slow-connection banner does not rebuild
|
||||
every row. `contentSlice()` blanks the non-row fields rather than adding a type, so
|
||||
`homeRowsFor` keeps taking a `HomeUiState` and its tests keep working.
|
||||
|
||||
**Design tokens.** `ui/theme/DesignTokens.kt` is the one vocabulary both surfaces read:
|
||||
`MembySurface` (the near-black), `MembyAccent`, `MembyOnSurface`/`MembyMutedText`/
|
||||
|
||||
Reference in New Issue
Block a user