0.2.48 - Tv calender fixes

This commit is contained in:
ponzischeme89
2026-08-11 12:08:51 +12:00
parent dd2dd497f2
commit f46d6596c5
47 changed files with 3415 additions and 125 deletions
+123
View File
@@ -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.