0.2.48 - Tv calender fixes
This commit is contained in:
@@ -158,6 +158,45 @@ half-configured policy cannot produce a blocking screen with a dead button. The
|
||||
must stay out of `/v1/home`, which is cached per user while this answer varies per client
|
||||
build.
|
||||
|
||||
**A retired build must be told why it was signed out.** The destructive floor
|
||||
(`destructiveUpdateFloor`) deletes the session on the next authenticated request and answers
|
||||
401; a retired build's sign-in is then refused with 426. The television only saw the sign-out
|
||||
— so it drew a sign-in form the gateway would refuse, and the mandatory update screen was up
|
||||
to `UPDATE_CHECK_INTERVAL_MS` (an hour) away, force-closing the app being the only way
|
||||
through, since a fresh launch checks for updates before it draws anything. Both refusals
|
||||
carry `X-Memby-Update-Required`, so `RequiredUpdateInterceptor` on the gateway client
|
||||
publishes it through `update/RequiredUpdateSignal` and `AppRoot`'s check loop waits on
|
||||
*either* the hourly interval or that signal. Things to preserve:
|
||||
|
||||
- **It is an interceptor** because the refusal lands on whichever request happened to be in
|
||||
flight — the status poll, a home refresh, a sign-in — and only one of those has any reason
|
||||
to know about update policy. The signal replays one value, because it is commonly reported
|
||||
before the check loop is waiting on it.
|
||||
- **The refusal is written to disk** (`Settings.requiredUpdateVersion`, via
|
||||
`RequiredUpdateGuard` on the service locator), because it is announced exactly *once*: the
|
||||
401 that deletes the session carries the header and every 401 after it is an ordinary
|
||||
missing session. In-memory only, a television told and then restarted had nothing left to
|
||||
learn it from but the launch check — which is bounded by `UPDATE_CHECK_TIMEOUT_MS` (2.5s)
|
||||
and, on missing it, put the viewer back on the welcome and sign-in screens. It is written
|
||||
by the locator rather than by a screen for the same reason the interceptor exists: nothing
|
||||
that knows about update policy is necessarily composed when the refusal arrives.
|
||||
- **Only the gateway may withdraw it.** A verdict that is not a required update clears the
|
||||
flag; a failed check never does, because an unreachable server is no evidence about which
|
||||
builds it accepts. `requiredUpdateSatisfied` is the one exception and it is pure and
|
||||
tested — the build the refusal demanded is now the build running, which is what the moment
|
||||
after a successful self-update looks like.
|
||||
- **`RetiredBuildScreen` is what the television shows meanwhile**, ahead of sign-in,
|
||||
profiles, the launcher and the null-settings case alike. There is no attempt budget any
|
||||
more: giving up used to hand the viewer a sign-in form as the *final* answer, and every
|
||||
screen underneath this one is something the gateway would refuse. The loop asks every
|
||||
`UPDATE_REQUIRED_RETRY_MS` for `UPDATE_REQUIRED_FAST_ATTEMPTS`, then settles onto
|
||||
`UPDATE_REQUIRED_BACKOFF_MS`, and the screen's Try again wakes it through the same channel
|
||||
a refusal does.
|
||||
- **Every refusal wakes the loop, including a repeat.** The 401 that retires the session and
|
||||
the 426 that refuses the sign-in after it name the same version, and the second is the one
|
||||
a viewer is standing in front of — filtering it as already-handled is what left a
|
||||
television on a form it could not get through until the next hourly check.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Manual DI.** `ServiceLocator` (initialised in `MembyApp`) holds the single `SettingsStore`
|
||||
@@ -1586,6 +1625,24 @@ three large files — new screens generally belong in `ui/<feature>/` rather tha
|
||||
them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`);
|
||||
everything must be reachable by D-pad only.
|
||||
|
||||
**A keyed lazy list must never be handed a repeated key.** `LazyRow`/`LazyColumn` throw on
|
||||
one — *"Key … was already used"* — and every list on these screens is keyed by an id that
|
||||
came off a wire, where nothing promises distinctness. Emby lists the same person twice on a
|
||||
good fraction of a real cast; a "Because you watched" row built from two seeds can reach one
|
||||
title by both; a search that falls back to Emby before the import finishes can return what
|
||||
the library also matched; and a paging boundary is where a backend repeats a card by
|
||||
definition. `ui/ListKeys.kt` is the one rule: **deduplicate, never disambiguate.** Folding
|
||||
the index into the key would also stop the crash, but it keys an item by *where it is*, and
|
||||
position is exactly what changes when a row reorders — which the return-focus and
|
||||
scroll-restoration behaviour throughout this app depends on identity to survive. Apply it
|
||||
where the data enters state (`HomeViewModel.sanitisedRows`, `EmbyRepository.loadRelated` and
|
||||
`loadSeriesEpisodes`, the two genre pagers, `SearchViewModel.runSearch`) rather than in a
|
||||
composable; where a composable is the only place, keep it inside a `remember(list)`. And
|
||||
where a pager deduplicates, **how far it has read is counted in what the backend sent**, not
|
||||
in the length of the list (`SearchUiState.genreOffset`, `GenreBrowseUiState.readOffset`) — a
|
||||
dropped duplicate would otherwise make the next offset point before the end of the last
|
||||
page, and the shelf would stop growing while re-requesting the same page for ever.
|
||||
|
||||
**Animations must not recompose.** This app ships to weak TV boxes, so an animated value
|
||||
read in a composable body — `val x by animateFloat(...)` then using `x` in the layout — is
|
||||
a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and
|
||||
@@ -1775,6 +1832,19 @@ notices — because with the card gone that inset is the only thing holding the
|
||||
together. Copy is plain-language and second person ("Ten minutes left", "Hide films you have
|
||||
seen"), not feature names.
|
||||
|
||||
**Up out of the top of a page returns to the page list.** Nothing sits above a pane's first
|
||||
control, so that press did nothing at all on every page — and a remote that stops responding
|
||||
is not read as a list that has run out. About is where it was reported, its pane being a
|
||||
changelog long enough that walking back up it is the ordinary way to leave. The escape is an
|
||||
`onKeyEvent` on the content column that makes the move the default handler would have made
|
||||
and falls back to the rail only when it fails, so a page's own vertical navigation is
|
||||
untouched: `focusProperties { up = … }` is inherited by every row and would take that
|
||||
navigation away, and `exit` is never consulted when the search finds nothing anywhere, which
|
||||
is the whole case. The rail item for the page being *drawn* carries a second requester for
|
||||
it — the selection rather than the highlight, so a press arriving mid-settle still lands on
|
||||
the page that is on screen. `SettingsRailFocusTest` pins both halves, the escape and the
|
||||
navigation between rows it must not disturb.
|
||||
|
||||
**One runtime formatter, one 4K threshold.** `detail/DetailFacts.kt` owns `formatRuntime`,
|
||||
`heroFacts`, `dynamicRangeLabel` and `UHD_MIN_WIDTH`; the home hero and the card metadata
|
||||
call them rather than carrying private copies. That is why the hero and the card directly
|
||||
@@ -2102,6 +2172,59 @@ availability badge above it answers a different question (has the household's co
|
||||
downloaded), which is why they occupy opposite corners. `myShowBadge` puts CANCELLED ahead
|
||||
of everything else on a followed show: nothing else on that card matters as much.
|
||||
|
||||
**The TV calendar is the schedule row's other shape.** The launcher's row answers "what is
|
||||
on this week"; `GET /v1/calendar` (`server/internal/api/calendar.go` → `ui/calendar/`)
|
||||
answers "what is on this month, and when does it come back", which is a question no shelf
|
||||
has a form for — so it is a rail destination with a weekly TV guide: seven days in a rail
|
||||
and the selected day's artwork-led programme list beside it. It reuses
|
||||
`toSonarrScheduleItem`, so a calendar card and a
|
||||
schedule card are the same card, with the same availability badges, lifecycle tag and Emby
|
||||
series link; pressing one makes the same substitution the row does (`scheduleSeriesStub` +
|
||||
`airingNoticeFor`), because an episode that has not aired has no page of its own. Things to
|
||||
preserve:
|
||||
|
||||
- **The television does no calendar arithmetic.** The gateway sends `firstWeekday`,
|
||||
`dayCount` and its own `today`; `calendarWeeks` lays out the month from those alone and
|
||||
`calendarAgendaWeeks` only pages those cells seven at a time. A
|
||||
set working out for itself which years are leap years, in its own zone rather than the
|
||||
household's, would be a second calendar free to disagree with the days the episodes were
|
||||
grouped into — which on the wrong side of midnight it would. The one thing the set does
|
||||
read its own clock for is *when to ask again*: `CalendarViewModel` drops every cached
|
||||
month once the device's local day changes, because everything else about a month is fixed
|
||||
and only `today` goes stale. Being wrong about that by an hour costs one request, where
|
||||
being wrong about the layout would draw a calendar that disagrees with itself.
|
||||
- **A month is claimed by what was asked for, not by what arrives.** Cancelling a coroutine
|
||||
already past its last suspension point does not stop it, and a held D-pad on a month arrow
|
||||
is exactly how two requests come to be in flight — so a response is dropped unless it is
|
||||
still the month `requestedMonth` names. The cache is bounded for the same reason: "held
|
||||
for the life of the page" and "grows while somebody holds the D-pad" are otherwise the
|
||||
same sentence, and a month is a list of episodes with artwork behind it.
|
||||
- **Focus is selection**, the stance the detail page's tab strip takes. A remote has no
|
||||
hover, and a calendar needing a press per day to say what is on it is one nobody reads. A
|
||||
press moves *into* the day panel; Back steps out of the panel before leaving the page.
|
||||
- **Month and week travel are explicit controls.** Left and Right inside the guide already
|
||||
mean moving between the day rail and its programmes, so those keys cannot also change the
|
||||
date range. An arrow at the end of either range is not drawn rather than drawn dead.
|
||||
`calendarMonthRange` (12) is what stops a
|
||||
held D-pad walking Sonarr into the 2050s one request at a time; `parseCalendarMonth` refuses
|
||||
an out-of-range month rather than clamping, or the header would disagree with the grid.
|
||||
- **The day rail summarises; the programme pane explains.** A day names its count and first
|
||||
show only. The pane has the space for Sonarr fanart (or a graphical monogram fallback), an
|
||||
Emby title logo when the series was matched, episode details, availability and a prominent
|
||||
season-premiere/finale label. Finale wording comes from Sonarr's `finaleType`; an absent
|
||||
value makes no claim. `CalendarScreenshotTest` renders a crowded day and the artwork-free
|
||||
fallback because only a screenshot can check that hierarchy at television distance.
|
||||
- **The rail entry is a server feature** (`tv_calendar`, capability `tv_calendar_v1`), because
|
||||
a household running no Sonarr would otherwise carry a destination that only ever opens an
|
||||
apology. A set standing on the page when it is switched off is moved to Home, or it is left
|
||||
somewhere nothing can navigate back to.
|
||||
- **A failed month is an empty month, not an error.** The page is informational, and somebody
|
||||
who pressed Right past a Sonarr hiccup must be able to press Left back out of it.
|
||||
- **There is no second implementation on the direct path.** Unlike subtitles or Continue
|
||||
Watching, the answer is Sonarr's, which a television holds no credential for and Emby knows
|
||||
nothing about — with no gateway there is genuinely no calendar, and the rail says so by
|
||||
omitting the entry.
|
||||
|
||||
**Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV,
|
||||
landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather
|
||||
than a bare `@Preview`, which defaults to a phone and misrepresents every layout here.
|
||||
|
||||
Reference in New Issue
Block a user