Commit Graph
14 Commits
Author SHA1 Message Date
edde746 c4059d0ead fix(startup): stop Cronet and Plex Home from blocking time-to-interactive
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.
2026-08-23 18:13:39 +02:00
edde746 cb55134cef fix(startup): move optional work off the launch gate
Cold start on the target TV box reaches its first frame 21 ms after `dart_main`,
so nothing Dart-side gates the splash. Everything below is on the path to the
first *useful* frame, which is a strictly serial chain and where the viewer
actually waits.

- `monoTheme` is a pure function of two bools that builds a full `ColorScheme`, an
  applied-and-copied 15-style `TextTheme`, ~14 sub-themes and then clones the whole
  `ThemeData` again. It was rebuilt five times per cold start, two of them before
  `runApp`, and twice more per app-shell rebuild. It is memoized now, keyed by
  palette plus `TargetPlatform` -- the platform matters because `ThemeData()`
  derives tap target size, visual density and typography from
  `defaultTargetPlatform`, so a palette-only key would be wrong under a debug or
  test platform override.
- `initializeDateFormatting` ignores its locale argument and builds CLDR symbols
  and patterns for all 121 locales synchronously. It blocked the gate ahead of the
  database open for data that only content screens use.
- `DownloadStorageService.initialize` ended in a `path_provider` round trip plus
  mkdir at the tail of the gate, contradicting the comment above it that already
  explained offline artwork is not a launch requirement.
- `recoverInterruptedDownloads()` and `TrackerCoordinator.initialize()` ran from
  `initState` of the widget whose first build produces the first app frame, and
  the RSS watchdog installed a periodic timer there whose first useful sample is
  15 s away regardless.
- `CredentialVault` decrypted every token with pure-Dart AES-GCM on the main
  isolate, uncached, on every registry read and on every Drift re-emit -- and the
  binder writes tokens during the startup sweep, so writes re-triggered reads.
  Decryption is memoized by ciphertext, with `invalidateCache()` wired into the
  preference-store repair path so a repaired install cannot serve stale plaintext.
- `reloadFromStorage` now coalesces in-flight callers. The two serial awaits around
  the legacy migration are deliberately not merged; only genuinely concurrent
  callers share a snapshot.
- `_sameConnections` ran two `jsonEncode` calls per connection on every Drift emit
  purely to compare, allocating two maps and two strings each time.
- The splash rendered one `CircularProgressIndicator` per pending server on top of
  the aggregate one, so N+1 tickers scheduled a frame every vsync for the whole of
  `awaitBindingSettle` -- competing with the startup work they were reporting on.

Measured on device in a settled dexopt state: time to `main_screen` 1495 -> 1400 ms,
`credentials_loaded` 1238 -> 1128 ms, `database_ready` 501 -> 443 ms. First frame is
unchanged at ~18 ms, as expected.
2026-08-23 18:13:39 +02:00
edde746 ef2ab13abd refactor(profiles): render the profile picker from ActiveProfileProvider
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.
2026-08-16 16:48:02 +02:00
edde746 8624c37041 fix(profiles): notice a corrected connection creation time
ActiveProfileProvider diffed connections on toConfigJson alone, but
createdAt is a real column and now decides which connection lends a
profile its picture. A creation-time correction was therefore invisible
to the guard and left a stale avatar until the next launch.

Compare createdAt alongside the config. ConnectionRegistry pins
creation order across re-authentication, so this adds no notifications
in normal operation — it only stops an out-of-band correction, such as
a restore or a backfill, from being swallowed.
2026-08-04 02:22:44 +02:00
edde746 860ce1e11a feat(profiles): show the first linked connection's user picture
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
2026-08-04 02:22:44 +02:00
edde746 e0bf66eea8 fix(runtime): harden application service boundaries 2026-07-24 03:46:46 +02:00
edde746 a561777456 perf: reduce state notification fan-out 2026-07-12 18:59:58 +02:00
edde746 ef75887504 style: apply analyzer lint fixes across lib
Initializing formals, unawaited() on fire-and-forget futures, unused
import removal, wildcard params, and mounted vs context.mounted.
2026-07-06 15:56:56 +02:00
edde746 f9123eb40f fix(profiles): binder settle, active-id, and rebind correctness 2026-07-02 11:41:25 +02:00
edde746 145881318e fix: harden server-scoped state 2026-06-04 13:47:06 +02:00
edde746 5788d93e89 feat(profiles): sort by recent use
close #1151
2026-05-27 06:52:03 +02:00
edde746 209101796b refactor: use private named constructor params 2026-05-20 23:48:29 +02:00
edde746 8a8c2ecb87 fix: reload migrated profiles before startup 2026-05-01 23:08:21 +02:00
edde746 31d2d9dc98 feat: jellyfin 2026-05-01 01:20:36 +02:00