Two cold-start findings from the same pass. They share a call site in
`MainScreen`'s post-frame block, so they land together.
## Cronet was 33% of time-to-interactive
`createPlatformClient()` built the shared `CronetEngine` inline, so whichever
consumer happened to create the first HTTP client paid for it — and that landed
between `database_ready` and `credentials_loaded`, i.e. squarely on the path to
the first usable screen.
Measured on the Amlogic SC2 box, phase marks relative to `dart_main`, by
temporarily forcing the existing `_cronetBroken` fallback so no engine is ever
built:
| phase | engine built inline | engine never built |
|---|---|---|
| database_ready | +455 | +456 |
| credentials_loaded | +1171 | +703 |
| binding_settled | +1331 | +827 |
| main_screen | +1394 | +932 |
So ~462 ms, fully serial. Logcat shows where it goes: `DynamiteModule
loadModule2NoCrashUtils` then `HttpFlagsLoader` reading
`com.google.android.gms/app_httpflags/flags.binarypb`. The cause is provider
*enumeration*, not selection — `CronetEngine.Builder(Context)` calls
`isEnabled()` on every registered provider, and `PlayServicesCronetProvider`
answers that by installing the Play services Dynamite module. `play-services-cronet`
arrives transitively through `media3-datasource-cronet`, and `package:cronet_http`
offers no way to choose a provider, so the only lever available in Dart is *when*
the cost is paid.
Android's `createPlatformClient()` now returns a client that resolves its
delegate per request: the tuned IOClient that already backstops a broken Cronet
until the shared engine exists, Cronet afterwards. Per-request matters — a
`MediaServerHttpClient` builds its client in a constructor initializer and lives
for the process, so deciding once at construction would have pinned primary
media-server traffic to HTTP/1.1 forever, which is a silent steady-state
regression rather than a fix. `warmUpPlatformHttpClient()` then builds the engine
from `MainScreen`'s post-frame block.
Result: `main_screen` +1394 -> +915 ms, and logcat carries both client lines
(`IOClient (Android fallback)` then `CronetClient`), proving the swap. The build
now runs from +1023 to +1419, entirely after the first screen, and produces no
Choreographer or Davey report — the UI is static waiting on hub content there, so
there are no frames to drop.
## Plex Home refresh raced the offline decision
`PlexHomeService.start()` conflated disk hydration with going live: it decoded
the cached `plex_home_users_*` entries *and* subscribed to connection changes,
installed a refresh timer and fired `_refreshAll()`. It was invoked straight from
a provider `create:`, so on a box with no network — or the flaky 2.4 GHz Wi-Fi
these devices typically have — it started requests that would time out during the
exact window the startup gate needs. Its immediate neighbour
`ActiveProfileBinder` is explicitly not auto-started for this reason and says so
in a comment; the same argument applied here and had simply not been followed.
`start()` is now the live/network entry point and `hydrate()` is the disk-only
half, coalesced and lifecycle-guarded like `start()` already was. The provider
`create:` hydrates; `_reloadSnapshot` and `reloadFromStorage` hydrate; the borrow
picker hydrates, because it reads `current` immediately and is reachable while
offline. Only `MainScreen` goes live, gated on `!_isOffline`, with
`_handleOfflineStatusChanged` picking it up if the session later regains network —
otherwise an airplane-mode launch would never refresh Plex Home again.
Hydration still `_emit()`s, so `stream`'s replay contract holds even when the
network side never starts, which is what keeps a late listener behind a
`combineLatest` off a permanent spinner.
Profile tiles and buttons drew the outline focus border while every
other settings surface highlights with a background fill. Tiles nest
the FocusableWrapper inside the Card so the fill paints above the
opaque card surface; buttons opt into useBackgroundFocus.
Long-pressing a PIN-protected local profile in the picker offered Manage and Delete with no verification, so any user could open its detail screen, clear its PIN, or delete it outright. Both actions now verify the target's PIN first (active profile and Plex Home profiles keep their existing flows).
profiles_view.dart rebuilt the exact merged-profile view ActiveProfileProvider already computes — the same four source streams, the same merge/avatar derivation, plus a hand-rolled combineLatest4 — and the profile switch screen was its only consumer while already reading the provider for activeId.
The screen now renders from the provider (new connectionsByProfile/connectionsById/plexHomeByConnectionId getters) and gates loading on provider initialization; visibleProfileConnections moved to profile_merge.dart for profile_detail_screen; profiles_view.dart is deleted. The switch-screen tests initialize the provider up front like boot does, using a timer-less PlexHomeService subclass so start()'s periodic refresh timer cannot trip the widget-test pending-timer invariant; the deleted pipeline's merge assertions were ported to profile_merge_test and active_profile_provider_test.
Android TV returns to the platform keyboard for single-line fields; the
Flutter overlay stays for multiline and explicit call sites. The bugs
that forced the overlay (#1051, #1079) were an engine show/bind ordering
race, now repaired at the app level:
- MainActivity retries a soft-input show the engine dropped while the
FlutterView was not yet served (flutter/flutter#177360), rebinds the
IME key session once at first show, and consumes leaked D-pad keys
while the keyboard is visible (bounded restartInput budget) so focus
cannot wander behind a stuck keyboard.
- The platform text-input hint is activation-based, so gamepad pause and
the pre-IME D-pad intercept track a live session instead of mere field
focus.
- While a session is live with the keyboard away, Back closes it and is
consumed once, Select re-raises the keyboard, and arrows keep
caret-aware edge-escape navigation instead of dead-ending.
A Plex account connection labels itself with the account owner's name.
Under a profile tile that reads as being signed in as the owner: the
Plex Home tile showed the owner beneath the Home user's own name, and a
local profile that borrowed a Home user out of someone else's account
showed only the lender.
Both halves of the relation now go through a single translated string,
so a locale orders them itself instead of inheriting the English
"user via account" — az, hu, ja, kk, ko, tr, uz, zh and zh-Hant put the
account first. When the Home cache cannot resolve the connection's uuid
the chip names the account alone rather than falling back to a bare
name. ProfilesView carries the Home user cache that resolution needs,
and chip labels ellipsize now that an account label can be an email.
fix(profiles): label a Plex Home parent connection as an account
Conflict resolution: regenerated the Slang outputs against main's
translation set, added the empty locale placeholders the translation
gate requires, and gave the new widget test the StorageService provider
the picker now reads for profile recency.
The picker resolved StorageService asynchronously and rebuilt its profiles
stream once it landed. Storage is what supplies profile recency, so the second
view arrived re-sorted a microtask after first paint. The sliver children
carried no keys, so that reorder handed each tile's Element the next profile's
focus node; detaching the old node dropped primary focus onto the enclosing
scope and took the D-pad highlight with it. The launch picker has no back
route on tvOS, so a user who can no longer see or move the selection has
nothing useful left to press.
Read StorageService from the provider graph, where it is already resolved
before any route exists, so the stream is built once and the first painted
frame is already recency-sorted. Key the tiles and add findChildIndexCallback
so a later re-sort from a refreshed profile source moves a tile instead of
destroying it: without the lookup the sliver re-inflates the tile, which keeps
primary focus but resets FocusableWrapper's chrome to unfocused.
close#1792
A Plex Home profile's chip rendered the parent connection's displayLabel,
which for a Plex account is the account owner's username. The owner's name
appeared directly beneath the Home user's own, reading as the wrong user
being signed in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fdzda9t7kQtq7LoQ2v5nVF
A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.
Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.
The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.
close#1667
71735354 made TvTextInputPresentation.automatic use native platform
input for single-line Apple TV fields, and 829d3745 migrated the search
and Add Jellyfin suites to it. These five cases still expected the
Flutter on-screen keyboard widget and failed looking for a panel that no
longer exists.
Drives the native path instead: each case asserts input is live
(readOnly false), raises the keyboard, then keeps its original
regression intent — the first Back deactivates input and is consumed so
it cannot also pop, and only the second Back pops or cancels.
- PaginatedCardGridTabState: the collections and playlists tabs were 95%
identical; they now supply only pageSize/fetchPage/idOf instead of each
duplicating the grid, memo, inflation budget and focus wiring.
- EtagCachedRemoteStore: the anime-lists and fribb mapping stores now share
one download/cache/isolate-parse/conditional-GET lifecycle.
- FocusableTileStateMixin manages its own initState/didUpdateWidget/dispose
instead of requiring every caller to forward three lifecycle hooks.
Also drops unused ServerCapabilities entries and dead code in
focusable_list_tile and music/track_row.
Initialize TvDetectionService on every platform so the existing
force_tv_mode setting drives the 10-foot TV interface on Windows,
macOS, and Linux, and surface the toggle in appearance settings there.
Desktop keeps real-mouse behavior in TV mode: InputModeTracker still
flips between pointer and keyboard modes (cursor hidden while
keyboard-driven), segmented controls keep their hover affordance, and
the settings backup section stays available (only Android TV lacks a
document picker).
Adds PlatformDetector.debugSetIsDesktopOSOverride so TV-device
simulations in widget tests don't inherit the desktop test host's
platform.
close#1409