Commit Graph
35 Commits
Author SHA1 Message Date
edde746 a615ec6b97 test(plex): pin that hydration installs no live machinery
The offline guarantee rests on hydration being genuinely network-free, not just
on the call sites picking the right entry point. Assert it directly: a hydrated
service must not react to a connection row appearing afterwards — which is what
the boot-time legacy migration does — and `start()` must then pick that row up,
proving the watch belongs to the live side.

Device verification of the airplane-mode cold start is not included: it needs
physical access to re-enable the test box's network. This test covers the same
invariant deterministically.
2026-08-23 18:13:39 +02:00
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 5d83d4833c fix(images): stop decoding artwork nothing ever displays
The image pipeline funnels through `MediaImageHelper.serverArtworkProvider`, which
sizes the request, bounds the decode and namespaces the disk key. Five places
escaped that funnel, and on the reduced tier they compete for a 64 MiB image cache.

- Jellyfin trickplay prefetch built a bare `CachedNetworkImageProvider` and called
  `resolve()` on it, fully decoding a sprite sheet -- roughly 22 MiB of RGBA for a
  10x10 grid of 320-wide tiles. None of it was ever painted: the render path wraps
  the same provider in `ResizeImage`, so it decodes again under a different cache
  key. The comment already said the intent was only to warm the disk cache, so it
  now does exactly that through the cache manager and resolves no image stream.
- `CyclingMediaBackdrop` re-fetched and re-decoded a full-screen backdrop every
  10 s forever, with no `DevicePerformance` term in `_canRotate` even though its
  own fade duration is tier-gated. At the reduced tier's 1280x720 art cap that is
  3.52 MiB per rotation, ~7 MiB live across the crossfade, churning while the
  viewer sits still and evicting the rail posters behind it. The fade is already
  zero-duration there, so the rotation bought variety and nothing else.
- Catalog detail passed `item.posterUrl`/`backdropUrl` with no client, which
  `getOptimizedImageUrl` deliberately returns unmodified -- so a 40x60 slot pulled
  a 600x900 TMDB asset and a 320-tall backdrop pulled 1920x800. `posterFor`/
  `backdropFor` already existed for this; the sites now use them.
- The season strip, the Live TV programme sheet and the profile avatar built
  `CachedNetworkImage` directly, keeping the sized request but losing the
  `plex_optimized_<sha1>` disk key, so the same artwork was cached twice and
  re-downloaded on detail open. The season strip also discarded its height bound.

Sources without published variants (Trakt, AniList, MAL, MDBList) still fall back
to the base URL.
2026-08-23 17:51:27 +02:00
edde746 6eb0805f8d refactor(profiles): delete dead client accessors, the write-only focus aspect, and test-only registry API
getServerBoundPlexClient/getClientForLibrary existed only for their own unit tests; MetadataEditAdapter.backend and Plex's unreachable prefKey fallback and music-kind branches had no production path (edit is double-gated on supportsKind); MainScreenFocusScope's focus aspect, isSidebarFocused, and focusContent were write-only; resolveActivePlexIdentity.preferredAccount was never passed; ProfileRegistry.get was unused outside tests — its removal exposed a stale test contract, now pinned correctly: list() deliberately never serves plex_home rows.
2026-08-17 19:02:24 +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 688bfdacb4 fix(profiles): keep offline downloads visible when a profile's servers are unreachable
Switching to a profile whose only server is offline verified the PIN,
failed the bind with zero reachable servers, and rolled back to the
previous profile — whose scope owns none of the downloads. The Downloads
UI then showed nothing while the files and pinned metadata sat intact on
disk. Startup offline mode only covered the cold-start bind, so such a
profile could never be entered while its server was away.

The binder now classifies a settled bind failure as connectivity-only
when the profile expected servers, reached none, and none were
auth-rejected (snapshotting auth markers before the visibility sweep,
which clears them via removeServer). On such a failure,
switchProfileFromUi keeps the profile active when it owns downloads
instead of rolling back; OfflineModeProvider drives the offline UI from
the empty visible-server set. Auth failures, PIN cancels, and
downloads-free profiles keep the existing rollback and error snackbar.

close #1927
2026-08-14 12:20:20 +02:00
edde746 df0570b861 style: apply dart format to 21 drifted test files 2026-08-09 17:50:58 +02:00
edde746 db4f7a643b test: prune low-value coverage 2026-08-06 05:33:18 +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 352b88109b refactor: extract shared mixins and helpers, drop dead abstractions
Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.

Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
2026-07-26 06:09:48 +02:00
edde746 61344f7862 test: extract shared fixtures and scaffolds
Collapse duplicated setup across the suite into six shared helpers under
test/test_helpers/ and rewrite the 28 suites that were open-coding it:

  http_fixtures.dart         jsonResponse() for http.Response JSON stubs
  library_tab_scaffold.dart  pumps library tabs under their required ancestors
  multi_server_fixtures.dart MultiServerProvider wiring for widget tests
  playback_report_fakes.dart PlaybackReportCall + fake report sinks
  profile_stack.dart         production-shaped profile dependency graph
  theme.dart                 testMonoTokens for fast-settling widget tests

Net -1245 lines with no change in coverage or assertions.
2026-07-26 06:09:47 +02:00
edde746 e0bf66eea8 fix(runtime): harden application service boundaries 2026-07-24 03:46:46 +02:00
edde746 1bdd76d95a fix(plex): wait for shared server resource tokens 2026-07-14 00:15:46 +02:00
edde746 e03edad824 fix: prevent async state and platform regressions 2026-07-13 00:03:52 +02:00
edde746 a561777456 perf: reduce state notification fan-out 2026-07-12 18:59:58 +02:00
edde746 2a128777f1 refactor(profiles): centralize Plex token policy 2026-07-12 08:42:24 +02:00
edde746 877e8880cf refactor(profiles): centralize Plex Home cache codec 2026-07-12 08:42:20 +02:00
edde746 7a0e838017 fix(profiles): remint empty Plex resource tokens 2026-07-12 08:42:19 +02:00
edde746 96510f8aac fix(plex): tolerate account-API schema drift in profile and switch parsing
Around July 3 plex.tv started returning the profile language-list fields
(defaultAudioLanguages, defaultSubtitleLanguages, mediaReviewsLanguages)
as comma-separated strings instead of arrays. The generated cast threw on
the successful 201 /switch response, dropping the freshly minted Home
user token: every rebind failed, the binder retried a /switch mint every
1-2s, and the app sat permanently in offline mode even after re-signing
in. Accounts without language prefs set were unaffected, which is why
the breakage looked sporadic.

Parse the language lists with a CSV-aware coercion, and make
UserSwitchResponse.fromJson strict only about authToken: decorative
fields now coerce tolerantly and a broken profile blob falls back to
defaults, so account-API drift can never brick token minting again.

close #1488
2026-07-05 06:23:34 +02:00
edde746 1059658515 refactor(profiles): remove dead registry surface
Delete unused registry members (ProfileConnectionRegistry.insertIfAbsent /
removeAllForProfile, ProfileRegistry.reorder, ProfilesView.countFor,
ConnectionRegistry.getDefault) and their orphan tests, refreshing the stale
docs that named removeAllForProfile as the profile-delete cleanup path.

Repair the "one default per profile" join-row invariant: the connectionId FK
cascade (foreign_keys=ON) silently drops a profile's default row while its
other rows survive, leaving it defaultless. Add promoteMissingDefaults, share
a deterministic re-promotion helper with remove(), and re-promote in
removeAllForConnection.
2026-07-02 11:41:25 +02:00
edde746 f9123eb40f fix(profiles): binder settle, active-id, and rebind correctness 2026-07-02 11:41:25 +02:00
edde746 2025e1f9fd fix(profiles): clean up orphaned connections on Plex sign-out
close #1423
2026-07-02 11:41:25 +02:00
edde746 6e8320f782 fix(profiles): parse real Plex Home uuids + migrate pref scopes 2026-07-02 11:41:25 +02:00
edde746 4cd289b4b8 fix(profiles): forget removed Jellyfin connections
close #1355
2026-06-16 20:08:47 +02:00
edde746 3599b0b0e1 fix(startup): no-servers flash on fresh login + faster connect splash
- start binder before navigating from auth, mark isBinding synchronously
- overlap plex.tv resource refresh with optimistic cached-metadata connect,
  reconcile tokens/membership in background once it lands
- cached endpoint probe gets a head start on the race instead of serially
  blocking it
- defer phase-1 HTTPS upgrade off the splash critical path
- per-server connect progress stream for incremental splash checkmarks
- startup timing instrumentation (bind/fetch/race/connect elapsedMs)
2026-06-12 01:46:43 +02:00
edde746 74b8dc4561 refactor: type server identifiers 2026-06-01 11:06:03 +02:00
edde746 5788d93e89 feat(profiles): sort by recent use
close #1151
2026-05-27 06:52:03 +02:00
edde746 b6d23ed089 fix(startup): speed offline fallback
close #1108
2026-05-23 16:00:00 +02:00
edde746 cfd4aa307d fix(plex): tolerate transient resource failures 2026-05-16 22:54:53 +02:00
edde746 d0bd919ff4 refactor: migrate models to freezed + json_serializable 2026-05-11 12:54:26 +02:00
edde746 43242673fb fix(profile): avoid PIN prompt on restart 2026-05-02 01:23:21 +02:00
edde746 8a8c2ecb87 fix: reload migrated profiles before startup 2026-05-01 23:08:21 +02:00
edde746 7057ee93ac fix: defer profile binding before selection 2026-05-01 03:16:32 +02:00
edde746 31d2d9dc98 feat: jellyfin 2026-05-01 01:20:36 +02:00