Commit Graph
446 Commits
Author SHA1 Message Date
edde746 c84b12eacd fix(livetv): allow cold MediaBrowser tuners time to open
Emby and Jellyfin Live TV could fail before playback when opening a cold tuner took longer than ten seconds. Use the existing thirty-second tune timeout only for live PlaybackInfo requests that open a source, preserving other request budgets and transport aborts.

Cover delayed initial tuning and recovery, bounded cancellation without replay, and unchanged VOD and metadata timeouts.

close #2274
2026-09-07 20:32:16 +02:00
edde746 b904c11750 fix(network): use default ports for websocket connections
Watch Together could not reach relays when their URLs omitted a port because Dart passes port 0 to custom WebSocket connection factories.

Resolve port 0 to the HTTP/HTTPS default in the Happy Eyeballs factory while preserving explicit destination and proxy ports. Cover portless ws/wss connections with isolated regression tests.

close #2263
2026-09-07 01:30:08 +02:00
edde746 b6aa83af74 fix(watch-together): separate room authority from player bindings
Source reloads and role changes could reset room state or let obsolete player continuations change the current session.

Separate room authority from output bindings, fence selection and playback work with ownership leases, preserve room state across failed opens, and serialize native seek completion before newer intent.
2026-09-06 20:21:11 +02:00
edde746 4712414b3d fix(focus): reclaim detached grid nodes without stealing live focus
Historical focus contexts kept detached grid nodes alive, while stale menu captures could queue focus after their row was gone.

Track actual attachment lifetime, preserve pending and remembered destinations, and restore captured focus only to a live eligible leaf without overriding a covering route.
2026-09-06 20:21:11 +02:00
edde746 89b326e3f3 fix(catalog): retain native titles within the bounded matching budget
Native-title library copies could be missed when only display and romaji titles reached the bounded search budget.

Prefer typed Japanese titles, preserve row and detail aliases, and keep matching within the existing four-query budget and external-ID verification rules.
2026-09-06 20:21:11 +02:00
edde746 9a44f062a6 fix(network): own connection races and rearm exhausted push channels
One stalled DNS family or a cancelled WebSocket upgrade could keep a connection attempt alive. Push channels also stayed exhausted after the app returned to the foreground.

Race address families independently, own connection tasks through upgrade handoff, and rearm exhausted push channels on a genuine foreground resume.
2026-09-06 20:21:11 +02:00
edde746 c5ed666aa9 fix(navigation): leave a deleted detail through its own route, not whatever is on top
Deleting the last episode or season from a detail page popped the wrong route whenever something covered the detail: the download progress dialog (the deletion event fires before the delete returns), a player, a sheet fallback modal, or another detail. The cover disappeared and the empty detail stayed on screen.

The detail now leaves through the route it was pushed as: popped when it is current, removed from under the cover otherwise, with the same null result; a detail that is the navigator's only route stays, and a route already leaving is not touched twice. A delayed player launch checks that the route it was started from is still active before pushing, so a launch whose detail was deleted during its awaits does not land on an unrelated screen.
2026-09-06 15:06:08 +02:00
edde746 9ef3de985f fix(livetv): keep backed channel logos legible when adapting them to light surfaces
A light logo that carries its own dark ink — dark text on a white plate, white letters inside a dark outline — lost its contrast on light theme surfaces: the tone analysis granted the light-neutral remap to any mostly-light frame, and the remap then folded the plate and its ink into one dark tone.

A light majority no longer grants the remap. The analysis first decides whether the transformation is safe: on the sampled grid, four linear scans find neutral pixels enclosed on every side by the opposite neutral tone, and when most of either tone is enclosed the artwork is classified as backed and left untouched, like a dark disc already was. Dark ink that only touches the mark along an edge — an underline, a badge — stays open and keeps adapting, so accented and mixed-color marks remap as before. `LogoTone.dark` is renamed `backed` to name what the gate protects.
2026-09-06 15:00:09 +02:00
edde746 01750ad92b fix(network): own a WebSocket connection attempt through cancellation and TLS
Cancelling a WebSocket connect (deadline, stop, dispose, a newer attempt)
only dropped or timed out a future: the shared never-closed HttpClient kept
the address race and TLS handshake alive, and once SecureSocket.secure had
detached the raw socket even a real task cancel was a no-op, so after
ClientHello the peer stayed open and the connect future never settled.

The happy-eyeballs task now owns the transport through the TLS handoff:
cancel is idempotent, settles the task at once, destroys an undelivered raw
winner, and destroys whatever a handshake in flight eventually yields. Each
WebSocket upgrade runs on its own HttpClient inside a connection attempt
with completion plus cancellation; the library-event, companion-remote and
Watch Together relay sockets cancel it on stop, disposal, deadline and
supersession. dart:io offers no handle to abort an in-flight handshake, so
a black-holed ClientHello closes when the handshake settles, not at cancel.
2026-09-06 13:28:51 +02:00
edde746 c228a12d1e fix(media): preserve profile, source, and focus identity across refreshes
Library refreshes could move focus, catalog lookups could cross profile or query boundaries, and detail labels could describe a different source from playback.

Keep hub and grid focus with committed item identities. Pace pushed deletions without delaying local eviction. Bind push channels to committed authentication sessions and catalog completeness to the active profile and effective query. Resolve preview source and container defaults with playback selection rules.

The shared client contracts and all consumers migrate together. Include regression fixtures and document safe host-transfer compatibility.

Verified: 6826 Flutter tests passed, 5 skipped; aggregate quality checks and final analyzer/formatting checks passed. All 86 owned paths match the isolated validated snapshot.
2026-09-06 04:34:58 +02:00
edde746 7aa1679036 fix(network): retain socket ownership through connection cancellation 2026-09-06 03:53:14 +02:00
edde746 bc13ff007a perf(explore): resolve library copies with one guid filter and a native-title search per server
The Explore library lookup spent up to eight search-index queries per
server per tap, plus one children fetch per candidate for sequels, and the
Trakt path added two alias/translation requests per detail open to reach
romaji and localized library titles.

Both backends index originalTitle on every copy of a foreign title whatever
language it is filed under, so the lookup now leads with the native title
and drops alternate titles as candidates: one query reaches the English,
romaji, localized and native copies alike. Plex's /library/all?guid= filter
is a literal prefix match with comma as OR, so every legacy-agent guid form
the external ids imply rides one indexed request alongside the Discover
guid, and the sequel gate asks for the season of every candidate in one
/library/all?type=3&show.id=…&season.index=N request. Worst case is five
concurrent requests per server, typically two or three, with no cloud call.

The Trakt alias/translation fetch is removed: the copies it reached are the
ones the native title reaches, and Trakt already sends original_title.

close #2098
2026-09-05 14:44:02 +02:00
edde746 6815850b61 feat(explore): match Trakt items to romaji and localized library titles via Trakt aliases
A Trakt item only carried its English title, so a Plex or Jellyfin library
filed under the romaji title (`Sousou no Frieren`), the user's language
(`Frieren: Tras finalizar el viaje`) or the native title stayed invisible to
the Explore library lookup.

The Trakt detail load now fetches /aliases and /translations/{app locale}
alongside people and related, picks one ASCII alias from the item's own
country plus the locale translation as alternate titles, and the matcher
reads original_title for every source. The lookup budget grows to four
title families, the matcher keys its cache on the candidate titles, and the
detail screen re-resolves when a detail load adds titles, not only ids.

close #2098
2026-09-05 14:44:02 +02:00
edde746 a2c9bea041 fix(explore): find library copies through the Plex search index and report servers that could not answer
Explore showed "Not in your library" for titles the user owns. Two causes:
Plex's /library/all?title= filter is an ordered word-prefix substring match,
so `Oshi no Ko` never found a library titled `[Oshi no Ko]` and a romaji or
localized title was unreachable once the first candidate hit; and a server
that answered slower than the 10s header budget was treated as a dead
endpoint, dropped from the wave, cached as a negative for ten minutes and
cascaded through its stale LAN candidates.

Plex title candidates now go through /hubs/search with includeGuids, the
full-text index behind Plex's own search, which also covers originalTitle.
Both backends search every candidate concurrently and union the id-verified
copies instead of stopping at the first title that hit; the candidate cap
becomes two title families so a sequel can reach its alternate title. Lookup
requests run under a dedicated deadline with endpoint failover off. The
aggregation layer reports per-server failures, the matcher only memoizes
complete positive waves, and the detail screen shows "Couldn't check N
servers" instead of claiming absence.

close #2098
2026-09-05 14:44:01 +02:00
edde746 44599549e1 fix(focus): keep root-navigator routes from losing the remote to the covered profile session
On tvOS the profile picker shown after a background/standby resume lost
its focus highlight and the Siri Remote went dead until a force-quit.
Android TV hit the same shape with the player (#2034).

The picker and PIN dialog are pushed on the root navigator, above the
nested profile-session navigator. Nested routes still report
`isCurrent == true` under that cover, and Flutter leaves a covered
route's scope focusable, so any focus self-heal under MainScreen —
the sidebar reveal on an offline/online flip, a Libraries grid reload,
the TV browse rail — won the remote behind the picker. With menu
passthrough pinned off while the picker is up, Menu was swallowed too.

Restore the invariant once at the navigator boundary instead of at
every reclaim site: CoveredRouteFocusBoundary wraps the session in an
ExcludeFocus keyed on the root route's currency, so requests below a
cover are no-ops, and re-requests its own scope on uncover so focus
walks back to the leaf that had it (the covering route's pop culls the
excluded scope from the route scope's history before the exclusion
lifts, which is why Flutter's own restoration cannot). This makes
isRouteChainCurrent redundant; the player's guards return to plain
nested currency.

close #2239
2026-09-03 23:47:27 +02:00
edde746 e46b3dab7c fix(seerr): diagnose an auth proxy in front of Seerr instead of misreporting it
A Seerr instance behind forward-auth (Authelia, Authentik, Cloudflare
Access) or HTTP Basic failed setup with "No Seerr instance at … (HTTP
200)": the probe followed the proxy's redirect to its login page and
decoded the HTML as "not JSON". A live session that later hit the same
wall took the proxy's 401 as Seerr's, re-authed through the wall, failed,
and unlinked a perfectly good stored session.

Seerr's API never redirects and always answers with JSON, so a 3xx or a
non-JSON 401/403 is the proxy talking. Seerr requests no longer follow
redirects, that shape maps to a SeerrProxyException with a message that
says what to do, the probe reports it in place of "no instance", and the
client neither re-auths nor unlinks on it.

close #1877
2026-09-03 21:42:47 +02:00
edde746 691d10b3c6 feat(navigation): landscape rail for the mobile shell and phone rotation
Phones were locked to portrait outside the video player, and the mobile
shell had no answer for a wide, short viewport: car head units (which use
the mobile layout) and tablets in landscape spent their scarce height on
a bottom navigation bar and a 500dp hero.

In landscape the mobile shell now puts the bottom bar's destinations on a
leading Material NavigationRail (MobileNavigationRail) and keeps every
other mobile layout decision as it is. The rail keeps the bottom bar's
extras: the offline reconnect affordance as its leading action and the
long-press library quick picker on the Libraries destination. Labels
follow the existing nav-bar-labels setting but give way automatically
when the destinations cannot fit (a landscape phone is ~410dp tall), and
the rail scrolls as a last resort. The content beside it drops the leading
system inset the rail already absorbs.

Phones now allow every orientation; OrientationHelper.restoreDefaultOrientations
is context-free and the player exit path uses it instead of its own copy
of the phone lock. The Discover hero fills the viewport in mobile landscape
and compacts its logo and bottom offset when short, so its content stays
below the top bar.

The music mini-player learns a start inset (MiniPlayerInsetController.setNavInsets)
so it floats beside the rail in landscape and above the bar in portrait;
route suspension zeroes both.

Known follow-up: the media detail header is not yet laid out for a
landscape phone (logo runs under the status bar and back button).
2026-09-03 20:29:08 +02:00
edde746 fa054153ae fix(plex): surface a stale-endpoint failure instead of cascading after a mid-probe promotion 2026-09-03 18:22:13 +02:00
edde746 a230d51598 fix(plex): keep a LAN session on its local endpoint after device sleep
Apple TV (and any suspended process) wakes with dead keep-alive sockets in
the HTTP pool. The first request after resume — the health probe — failed
with a connection error, and the failover cascade treated that as a dead
endpoint: it validated the remote candidate, switched to it, and persisted
it as preferred. Nothing walked the session back to the local endpoint,
because the only re-optimization trigger is a connectivity event and a
same-interface sleep/wake never produces one. Only killing the app fixed it.

FailoverHttpClient now runs the existing candidate trust gate against the
current endpoint on a connection error and retries in place when it answers;
timeouts and 5xx still cascade directly. MultiServerManager gains
reoptimizeDemotedServers, called from the resume probe, which re-races any
online Plex server sitting on a remote or relay endpoint while a local one
is published.

close #2056
2026-09-03 18:13:28 +02:00
edde746 f967b9b2f2 feat(detail): preview and choose the audio and subtitle tracks before Play
The detail screen gave no hint of what pressing Play would do with the
file: which audio track and which subtitles the player would start with,
or the picture format. Changing tracks meant starting playback first.

The action row now ends with a status line — picture labels, then the
audio and subtitle rows the player will select — computed by the player's
own selection ladder (TrackSelectionService) over the server's stream
rows, so it agrees with what the player then does. Select opens a
pre-play chooser with the track sheet's two columns; Play and rail
activation pass the pick to the player as its navigation-tier preference,
and a pick carries across episodes by semantics, forced-ness included.
Plex listings carry no stream rows, so a focused episode is probed once,
debounced, and cached for the screen.

Along the way: MediaStream keeps the server's `selected` apart from the
container `default` flag (they were OR'd, so the account's pick was
indistinguishable from the default track); the overlay sheet host no
longer overrides a row that claimed focus itself.
2026-09-03 13:03:13 +02:00
edde746 b43cd81102 chore(profiles): let AccountPreferencesController own the active user's playback preferences
UserProfileProvider kept a second cache of the active user's audio/subtitle
defaults beside AccountPreferencesRepository, re-implemented the active
profile/connection listener wiring and Plex Home token resolution, and for
Plex Home profiles bypassed the shared cache entirely (fetching /user with
its own client), so a write in the Account preferences screen never reached
Home-profile playback.

The controller now resolves the active account (the head of its sorted
account list), loads it through the one repository on attach, on profile
switch, and when the account's token changes, and exposes it as
activePreferences; ensureActiveLoaded replaces initialize() for the startup
gate. Playback, offline waiting, and logout storage clearing read the
controller directly; UserProfileProvider, the per-backend user-profile DTOs,
PlexAuthService.getUserProfile, and JellyfinClient.fetchUserProfile are
deleted.

The ranked defaultAudioLanguages/defaultSubtitleLanguages lists go with
them: Plex Web never reads them, PMS applies them when it stamps `selected`
on streams, and Plezy already trusts that flag ahead of the profile. They
only refined a best-effort audio fallback for Plex items with no media
info, which the primary language covers; on that path a second preferred
language is no longer tried before the file's default track.

Plex Home profiles now read /user/profile with the switched token through
the same cache as local profiles.
2026-09-03 11:36:56 +02:00
edde746 59f44d9ae1 chore(trackers,downloads): share rating request matching and download queue feedback
MDBList and Trakt repeated the rating type, entry matching, and request
body construction; tracker_rating_match.dart now owns them with the
service label passed in. The collection detail screen re-implemented
fetchAndQueueListDownload minus its cellular-blocked branch and now uses
it, and the three showDownloadOptionsAndQueue call sites share
queueDownloadWithFeedback, so the detail action buttons surface a queue
failure as an error snackbar instead of an unhandled async error.
2026-09-03 11:36:29 +02:00
edde746 d371bfed3a fix(networking): connect in resolver address order with a Happy Eyeballs race
On a dual-stack network Plezy pinned API traffic and the notification
websocket to IPv4 while the native Plex apps and the media stream used
IPv6. dart:io looks up A before AAAA and connects in arrival order, so
the AAAA address was never tried when the IPv4 path answered within
250 ms.

Install an HttpClient.connectionFactory that does one lookup (keeping
the resolver's RFC 6724 order), interleaves families per RFC 8305 and
races candidates 250 ms apart. The task is returned before the lookup
starts so connectionTimeout and cancel cover resolution. TLS is done in
the factory since the SDK skips it once one is installed; proxied
requests stay plain. The library-event and companion-remote websockets
get the same client because WebSocket.connect otherwise builds its own.

Supersedes #2233.
2026-09-03 07:35:34 +02:00
edde746 c79ff68a7b perf(tv): shape spotlight text once per swap instead of twice
Every D-pad step on the TV home ends with a spotlight swap, and on a
low-end box that frame ran over budget: 12 % of UI-thread CPU during
navigation was paragraph layout, most of it shaping strings a second time
to measure them before the render tree shaped them for real.

- FittingTitleText no longer lays the title out to learn whether it fits:
  Text wraps and ellipsizes at maxLines, so the box can only overflow
  vertically, and one cached one-glyph line height settles that. Only a
  box shorter than maxLines lines still searches, now bracketed by the
  proportional shrink of the base layout and stopped at a 0.25 px
  tolerance instead of a fixed 12 bisections.
- FittedMetadataLine and inlineRatingBadgeWidth measure through a small
  bounded cache keyed by text, style, scaler and direction, so separators,
  type labels, certifications, runtimes and years measure once per
  session.
- MediaHub.isContinueWatchingHub/usesContinueWatchingAction memoize their
  regex tokenisation per key; every card build and step re-asked them.

Cold navigation on the Android 14 box (30 steps over three hubs): build
312-316 ms -> 284-285 ms, layout 530-540 ms -> 515 ms, UI frame p90
7.5-7.9 ms -> 6.8-7.2 ms.
2026-09-02 22:38:11 +02:00
edde746 7e1f18f93f perf(android): skip the semantics tree when no enabled accessibility service can read it
Android enables Flutter semantics for any bound accessibility service, so a
TV running the Projectivy Launcher (whose service only wants foreground-app
events) paid for a full semantics tree on every frame that touched a node:
on an Android 14 box, 2 ms per frame, ~20 ms on each spotlight swap, and a
third of the navigation-time GC churn.

The Android side now reports whether any enabled service can consume the
tree (touch exploration, an accessibility tool, or spoken/braille/audible/
visual feedback; an empty list means UiAutomation, which reads it too), and
AssistiveTechnologyService closes a SemanticsTreeGate on the app binding
when none can. The gate sits under SemanticsBinding.semanticsEnabled, so the
pipeline owner drops the semantics owner exactly as if the platform had
turned accessibility off; explicit ensureSemantics clients (the debug handle
for Maestro) always win. Re-evaluated on platform toggles, service-list
changes (API 33+) and resume.

Cold navigation on the box with Projectivy's service bound, 30 D-pad steps
over three hubs: SEMANTICS phase 700-770 ms -> 0, UI frame max 31-37 ms ->
25 ms, GC 3.0 s -> 2.2-2.6 s.
2026-09-02 22:38:11 +02:00
edde746 5a666fd10f feat(discover): refresh home and library content from server library push events
New media added server-side was invisible while the app was open - the
client had no channel for library-change notifications, so the home
screen and library tabs sat stale until an app restart.

Each online server now runs a reconnecting websocket push channel
(Plex `/:/websockets/notifications`; Jellyfin/Emby `/socket` with the
dialect deltas, including Emby's capabilities registration), owned per
server by a LibraryEventService supervisor that follows server
online/offline/replaced-client transitions and app lifecycle. Channels
emit one coalesced LibraryChangeEvent per burst on a leading-edge
throttle modeled on Plex Web's repopulate pacing: the first settled
change surfaces immediately and a library scan's flood merges behind
it. Connection failure degrades silently to the stale-refresh paths -
bounded reconnect backoff, re-armed on every status sync.

Consumers pace their reaction through one shared RefreshPacer: burst
debounce, blocked retry while video playback or an active scroll owns
the surface, a cooldown bounding pass frequency during bulk imports,
and credit for committed pull passes so a push landing right after a
fresh load defers to the cooldown's trailing edge.

- Discover runs a debounced full pass and swaps the result in place;
  the hero carousel resets only when a pass lands first content, so a
  push never yanks a screen the user is reading. Pushed removals drop
  from every visible list immediately via the deletion bus, scoped to
  the emitting server.
- The visible library tab swaps its data in place: transaction tabs
  reload without clearing, and the browse and paginated card grids
  refetch their loaded span (Plex Web's repopulateRange) with the
  scroll offset anchored on the first visible item and the span
  clamped after alpha jumps. Hidden tabs - and tabs behind another
  main tab - mark per-library staleness epochs instead and reload when
  next shown; epochs are snapshotted at load start so a push racing an
  in-flight fetch stays stale.
- The stale-resume and tab-shown paths refetch home hubs (previously
  Continue Watching only), covering setups where the socket cannot
  connect.

close #1646
2026-08-31 18:52:03 +02:00
edde746 56312a9eb2 fix(ui): keep clear logos with significant color untouched on heroes
Remapping a mark whose color is part of its identity - a red-outline
wordmark with white fill, a colored badge - fixes legibility but changes
the logo's character, which reads worse on hero surfaces than the
partial invisibility it cures.

The light tone class now splits on measured colored-pixel fraction:
light-dominant marks with incidental color (<=15% saturated pixels,
lightAccented) always remap, while lightMixed marks remap only when the
new ToneMappedLogoImage.remapMixed policy allows it. Heroes pass false
on the network, offline, and TV spotlight logo paths; the Live TV
guide's channel cells keep remapping mixed marks. The policy joins the
image-cache key. The threshold comes from a real clear-logo set:
remap-friendly marks measured at <=0.11 colored, identity-colored marks
at >=0.28.
2026-08-31 18:51:46 +02:00
edde746 976bdbd021 fix(ui): keep hero titles and clear logos legible in light theme
On the light theme, the detail hero's fallback title was hard-coded
white over a scrim that washes artwork toward the near-white background,
and white-on-transparent clear logos vanished into the same wash on the
detail, home, and TV spotlight heroes.

The title fallback now defaults to the theme foreground with a
background-side halo, matching what the TV hero already did. Clear logos
run through the channel-logo tone remap: light-toned marks recolor
toward the theme foreground on light backdrops (network and downloaded
artwork alike), colored marks keep their pixels, and dark themes render
the original artwork. The backdrop-luminance gate is shared with the
Live TV guide as logoToneTargetFor.
2026-08-31 18:51:46 +02:00
edde746 274b31ebac fix(livetv): recolor light channel logos for legibility in light theme
White-on-transparent broadcast logos (CBS, FOX, the NBC wordmark) vanish
on the light theme's white guide cards, making channels hard to identify.

Channel logo decodes now classify the frame's tone once per image-cache
entry and bake light-neutral pixels toward the theme foreground:
monochrome white marks recolor fully, mixed marks (colored peacock plus
white wordmark) keep their colored pixels, and self-backed or dark
artwork decodes unchanged. Applied in the guide channel column, guide
search, and favorite reordering whenever the backdrop is light,
including the dark theme's inverted focus card; dark backdrops keep the
original artwork.

close #2197
2026-08-31 18:51:46 +02:00
edde746 a1d8ab5210 fix(plex): surface instant mix failures instead of silently doing nothing
Tapping Instant Mix on Plex often did nothing: the /playQueues POST used no
endpoint failover, createPlayQueue swallowed every error into null,
fetchInstantMix mapped that to an empty list, playInstantMix returned
silently on empty tracks, and the errors stream's only listener is the
now-playing screen, which is not mounted at tap time (#2141).

The play-queue POST now rides the shared endpoint failover (replaying it is
safe: an orphaned duplicate queue on the server is inert), createPlayQueue
and fetchInstantMix propagate typed errors, playInstantMix reports
started/empty/superseded and throws fetch failures, and the tap site shows
a translated snackbar for a failed or empty mix. A failed collection or
playlist queue launch now also reaches the real failure snackbar instead of
the misleading "no items" path.
2026-08-27 16:59:08 +02:00
edde746 9ac8002b53 feat(player): apply separate default quality on cellular connections
On mobile data the single default streaming quality either burns
through the user's data plan or permanently caps quality at home.
Playback start now applies a dedicated cellular default when the
device is on a cellular-only connection, read from the app's existing
single connectivity subscription. The new setting defaults to
following the general default, ships in Settings > Playback for
phones/tablets only, and explicit per-play quality picks still win.

'Metered connection' had been spelled out independently in the offline
provider, the download WiFi-only gate and the sync-rule cooldown.
Rather than add a fourth copy, all four now share
ConnectivityLinkType. Network *presence* is deliberately left alone:
its callers disagree on whether an empty snapshot counts as
connected, so folding that in would change behavior.

showSelectionDialog now returns the picked DialogOption instead of its
raw value so the tile's null-valued 'Same as Default Quality' option
stays distinguishable from dismissing the dialog.

close #2147
2026-08-27 14:14:01 +02:00
edde746 0c5308cfa1 refactor(networking): drop Cronet and NSURLSession for the tuned dart:io client
Plezy carried three native HTTP clients on the assumption that they beat
Dart's own. Benchmarked against real Plex and Jellyfin servers on macOS,
Windows, Linux and three Android devices, two of them do not.

Cronet loses on every request shape the app issues: 8.1 vs 83.8 MiB/s on a LAN
body read, 84 vs 150 req/s on an artwork fan-out, 14.3 vs 10.7 ms on
sequential API calls. It also fails a 60-way fan-out outright with
net::ERR_CACHE_WRITE_FAILURE under the 2 MiB memory cache we configured, and
cost 70-705 ms of CronetEngine.build on first use. Paying that build off the
critical path is the only reason AndroidPlatformHttpClient,
warmUpPlatformHttpClient and the per-request delegate swap existed; all three
go away with it.

CupertinoClient had no measured advantage either, losing the TLS fan-out 44 vs
60 req/s, and no reported issue ever justified it. tvOS already shipped the
dart:io client, so Apple platforms now agree with it.

WinHttpClient stays. WINHTTP_OPTION_IPV6_FAST_FALLBACK (#1128) has no dart:io
equivalent, and it brings the system proxy and the Schannel trust store.

The pool tuning becomes unconditional. It was opt-in behind usePlexApiClient
so generic tracker and auth clients stayed disposable, but every dart:io client
has carried connectionTimeout and forceCloseOnDrainTimeout since #1972, so
tuned and untuned already share shutdown semantics and the flag only cost
throughput: 12 connections per host with a 90s idle measured ~4x the dart:io
default on a 60-way fan-out on Linux and ~2x on Android.

media3-datasource-cronet and cronet-embedded stay. They back ExoPlayer's
CronetDataSource independently of package:cronet_http.

Refs #2140.
2026-08-27 09:21:48 +02:00
edde746 75279ca196 refactor(discovery): expand server url candidates through one shared helper
Turning what a user typed into URLs worth probing lived inside
JellyfinEndpointDiscovery, next to a private copy of the scheme-detection
regex that url_utils already had. A second backend needs the same rules
with a different default port, and the existing copy carried two latent
defects: input with no host came back unchanged, so a candidate that can
never resolve went to the prober, and a query string survived into the
base URL that request paths are appended to.

expandBaseUrlCandidates takes the ordered guesses a backend wants — a
scheme plus the port to try when the user typed none — and owns the rest.
A typed port beats a guessed one, so host:8096 collapses to one candidate
per scheme and the same guess list covers both bare hosts and hosts with
ports. Candidates are built field by field rather than through
Uri.replace, whose null query means "keep mine", so query and fragment
are dropped for real. Blank input and input with no host expand to
nothing.

JellyfinEndpointDiscovery passes its existing order, so its probe and
persistence behaviour is unchanged, and hasUrlScheme replaces the regex
copy.
2026-08-25 03:07:14 +02:00
edde746 f357be4077 fix(tvos): match Siri Remote navigation to measured native focus-engine physics
Siri Remote navigation felt sluggish and then over-sensitive next to
native tvOS apps (issue #2006): swipes were priced at a fixed travel
per step, a single flick could glide into a second focus step, hard
lifts coasted several extrapolated steps, and rail scrolls snapped in
65-250ms where native glides.

Retuned the whole path against two hardware instrumentation passes on
an Apple TV 4K: committed-move telemetry through an experimental
UIFocusItem bridge (branch feat/tvos-native-focus-bridge), then a
dedicated native probe app logging every touch sample, pan velocity,
engine hint, focus step, and scroll tick across 101 swipe sessions on
160/230/300pt tiles. What the data showed, now encoded:

- step pricing follows geometry: one step costs the focused item's
  extent along the swipe axis plus ~155pt (measured 314/391/410pt on
  160/230/300pt tiles), not a fixed distance. Thresholds derive per
  axis from the primary focus rect, normalized so a wide-flat control
  steps vertically once the finger covers its height. Locked-focus
  rows (hub rows, the TV browse rail) vend their selected card's rect
  through the new LockedFocusRowNode so the row-wide focus node's
  screen-sized rect never prices the step. Scopes, the player's
  catch-all surfaces, and unbuilt cards fall back to a fixed 400pt.
- a lift never coasts more than one step: sessions with lift
  velocities up to ~11400pt/s never produced a second coast step. The
  glide is gated on a sustained drag (two consecutive same-direction
  steps), cancelled by reversal pivots and new touches, so a discrete
  flick moves exactly one item.
- the native 'inertia' feel is the scroll animation, not focus
  physics: the engine's scrollable containers settle over ~450-900ms
  of ease-out. TV rail and hub-row navigation scrolls now retarget a
  500ms easeOutCubic animation per step, so drags and hold-repeats
  chain into one continuous glide that catches up on release.
2026-08-24 13:41:44 +02:00
edde746andGitHub cc4eff98c0 build: migrate to Flutter 3.47.1 (#2092) 2026-08-24 12:32:05 +02:00
edde746 84bc4b2618 fix(i18n): translate the strings that still reached the UI in English
Non-English users saw English text in a dozen places and blank labels in
sixteen more.

The English came from sites that produce their copy away from the widget
that renders it, which is what the structural hardcoded-string check
cannot see: picture-in-picture refused with a raw literal instead of the
pipErrors.notSupported key that already existed; the two Jellyfin/Emby
auth throws missing display: rendered their developer message on the
add-server form; ServerParsingException.toString() fed its English into
the localized "Failed to load servers" wrapper; Watch Together
interpolated the whole PeerError, so a failed create read "Failed to
create session: PeerError(PeerErrorType.timeout): Timed out creating
session" and join printed its prefix twice; the hub and playlist
continuation footers rendered exception.toString(); shader rows showed an
English title over an already translated subtitle; the player queue fell
back to the raw Dart enum name; a Plex home user with no title showed
"Unknown"; a failed player start showed "Exception: Failed to initialize
player"; and the tvOS top-shelf header was hardcoded in an extension that
has no Flutter engine.

The blanks came from three recent features that added English keys
without translations. clean_translations.py filled all 21 siblings with
empty strings, so the Android TV resolution switch, every Jellyfin/Emby
recording-rule field, the demuxer row, the Companion Remote address
caption and the Seerr blocklist pill rendered nothing at all.

Two fixes are structural rather than key swaps. ContinuationStatusSliver
now takes an errorContext and calls a new non-logging
localizedLoadErrorText, so no future throw can leak through it. lib/mpv
stays free of user-facing copy: it raises a PlayerInitializationException
sentinel and a PlayerError.playerInitFailed cause tag that the player
screen resolves to localized text. The tvOS section title travels in the
shelf payload, additively, so an older cache still renders.
2026-08-24 05:23:05 +02:00
edde746 a6cca37174 fix(ui): keep pages scrollable when the wheel lands on a hub row
On Linux the Home page, a library's Recommended tab and movie/show detail
pages could not be scrolled at all, while Browse, Collections, Playlists,
search, downloads and settings scrolled normally. Those three screens are
exactly the ones whose viewport is covered by horizontal hub rows.

A wheel, trackball, trackpoint or button-scroll event expresses one-axis
intent, but the host reports both axes in the same event: on Linux only
touchpad-sourced scrolling becomes a pan/zoom sequence that resolves in the
gesture arena, and everything else arrives as one PointerScrollEvent
carrying whatever dx the device produced. A Scrollable claims a scroll
signal as soon as the delta along its own axis is non-zero, and the deepest
claimant wins the PointerSignalResolver, so a tenth of a pixel of dx handed
the whole event to the horizontal row and the page behind it never moved.

Collapse a scroll signal onto its dominant axis in the binding, before hit
testing - the last point where the delta can still be corrected, because a
Scrollable has already registered with the resolver by the time it sees the
event. Pan/zoom events are left alone, so trackpad panning keeps both axes,
and a horizontal wheel or tilt still scrolls the row it points at.

close #2081
2026-08-24 01:49:43 +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 3f55cc461e fix(player): show audio codec and bitrate in the performance overlay on ExoPlayer
The Android performance overlay showed only sample rate and channels for
EAC3, FLAC, DTS, TrueHD, and Opus tracks. The overlay reads the codec
from media3's Format.codecs, an RFC 6381 string only MP4/HLS provide
(hence AAC working), and the bitrate from Format.bitrate, which Matroska
carries only when the muxer wrote BPS statistics tags.

Fall back to the already-transmitted sample MIME type for the codec name
(audio and video), and measure the audio bitrate in the FFmpeg demuxer
from packet sizes over their pts span — the same source mpv uses for its
audio-bitrate property — when the container declares none.

close #2063
2026-08-23 03:44:45 +02:00
edde746 e5dc9c3f89 fix(networking): surface cancelled responses as cancellation and stop crediting a failed Plex hub fetch
A response cancelled before or during body consumption completed as a successful empty body, so cancellation-triggered aggregation could overwrite valid state and a cancelled download could commit an empty file. The managed client now delivers an abort error, and a request whose own abort has fired classifies secondary teardown errors as cancelled (timeouts keep their type). A Plex server whose /hubs leg failed was also counted as succeeded when only the optional music leg returned, letting Discover replace good hubs with a music-only result.
2026-08-21 19:23:40 +02:00
edde746 fcb167e661 fix(library): size hub detail posters like home rows and library grids
Home hub rows showed ~3 large posters per row while the hub's "see all"
page packed 5 small ones on the same 360dp phone. Hub detail was the
only surface on the padding-aware target-count formula, which resolves
ceil(lerp(5, 2, f)) columns regardless of screen width (and 9+ columns
on TV). Drop the flag so hub detail shares the fixed-extent formula
with every other grid, and delete the now-unused
getMaxCrossAxisExtentWithPadding and its usePaddingAware plumbing.

close #2039
2026-08-20 14:34:27 +02:00
edde746 2f47ed23cd chore: dedupe resolution display labels and fold single-use helpers
Two private formatters mapped the canonical server resolution label to
display text (media_quality_labels, media_version) with edge-case
drift, and several single-consumer helpers added indirection without
callers:

- add shared resolutionDisplayLabel in resolution_label.dart and
  migrate MediaVersion.displayLabel and the quality-label builder to it
- drop the resolutionLabelFromHeight compat re-export from
  jellyfin_mappers (no remaining consumers) and its stale doc note
- delete TraktCatalogSource.membershipKeysFor, byte-identical to the
  CatalogWatchlistMachinery default
- derive the rating sheet backend label from
  MediaBrowserDialect.productName instead of a hardcoded switch
- inline EndpointFailoverManager into failover_http_client.dart, its
  only importer, and remove endpoint_failover_interceptor.dart
2026-08-20 02:01:12 +02:00
edde746 20896a3d48 fix(player): keep the wake-time profile picker responsive on Android TV
Sleeping the device mid-video with "Ask for profile on app open" enabled
left the app wedged on an unresponsive Choose Profile screen (Nvidia
Shield). The picker is pushed on the root navigator, but the player's
focus self-heal only consulted its own nested profile-session route, so
it yanked D-pad focus back behind the picker whenever it lost it.

Route currency now walks every enclosing navigator (isRouteChainCurrent)
before the player reclaims focus, primes loading-phase navigation focus,
or claims the surface on window focus. The resume-time prompt is also
skipped entirely while a video player is active: waking mid-stream
resumes the stream instead of stacking the picker over the session.

close #2034
2026-08-19 23:08:32 +02:00
edde746 3fb90a4948 fix(images): fetch full-resolution tile artwork on low-end hardware
Posters and episode thumbnails looked noticeably soft on Fire TV sticks and
similar boxes: the auto-detected reduced tier capped artwork DPR at 1.5 and
tightened tile decode caps, so every tile was fetched at 56% of its pixels
and upscaled twice on large 4K panels.

Drop the reduced-tier DPR cap and the poster/square/thumb decode caps so
tiles fetch and decode at full TV density on every tier; raise the reduced
image-cache budget to the 64MB TV baseline to absorb the larger tiles.
Backdrops keep their scrim-masked ~720p reduced caps, and the display budget
factor stays pinned to 1.0 there, so the low-RAM art ceiling is unchanged.

close #2020
2026-08-19 13:16:36 +02:00
edde746 304d8a5e33 fix(plex): keep relay out of the phase-1 race, preferred persistence, and pinned sessions
A relay connection can win the phase-1 race (plex.tv edge answers fast),
gets saved as the preferred endpoint, and the cached URL then wins the
deterministic head-start probe at every bind — pinning future sessions to
relay's 2 Mbps cap even after direct connectivity returns, surfacing as
HTTP 500s on Original quality. A session that lands on relay legitimately
(direct down at connect time) stays there all evening: re-optimization
only fired on connectivity events.

Relay is now a fallback tier in the race: all probes still start at once,
but a relay success is held until every direct candidate has failed, and a
cached relay URL gets no head start. A single persist gate refuses relay
at all four endpoint-save sites, classified against both the live server
and the connect-time capture so rotated relay URIs cannot slip through.
While the active endpoint is relay, a bounded-backoff re-probe (30s/60s/
120s) re-races the candidates so a returning direct endpoint promotes
away without a restart.
2026-08-18 09:23:04 +02:00
edde746 8461e6ba5f feat(detail): split play button with version picker segment when multiple versions exist
There was no way to tell from the detail screen that a movie or episode
has multiple versions (theatrical/extended cuts, 1080p/4K encodes); the
only entry point was the hidden Play Version item in the overflow menu.

The Play button now becomes a Material 3 Expressive split button when
the item carries more than one version and its server is reachable: the
main segment keeps plain Play (which already resumes the remembered
version), and a narrower chevron segment runs the existing Play Version
flow (version picker, quality picker when the backend can transcode,
preference save). The flow itself is extracted from the context menu
into a shared promptAndPlayVersion helper, and FocusableActionBar gains
per-action spacingBefore so the joined pair can sit tighter than the
rest of the row. Transcode-only quality picking stays in the overflow
menu so the chevron keeps signaling a real version choice.

close #1881
2026-08-18 07:41:54 +02:00
edde746 1bdeca3b5f fix(libraries): give square music grids an inter-card gutter
Square-card (music) grids had zero delegate spacing - the only
separation was each card's internal 3px padding, and square shapes are
excluded from the full-card spacing path, so playlist and album tiles
sat nearly edge to edge. Square grids now get an 8px cross/main gutter
(automotive keeps its larger spacing); poster, list, and full-card
layouts are unchanged.
2026-08-17 22:44:12 +02:00
edde746 5fe1e6dc65 refactor(ui): delete unwired log output, dead notifier filters, and never-passed widget params
MemoryLogOutput extended LogOutput but was never wired as a Logger output (storage happens in the printer); DeletionNotifier/WatchStateNotifier's forServer/forItem filtered streams and WatchStateEvent.mediaType had no production consumers (tests now filter .stream directly); context.hiddenLibraries/profileSettings and toPlexUrl had zero call sites; MonoTokens.splashFactory was never read; OptimizedMediaImage's enableTranscoding/cacheKey chains, FocusableMediaCard.width/height/forceGridMode, TvBrowseRail's constant-zero gap functions, and media_image_helper's scaleFactor were never varied by any caller. The sha1 LRU stays (rebuild-hot artwork URLs); TvRailTrailing.none and the mono-theme copyWith stay live (lerp delegates to copyWith). Also carries the MusicPlayContext.id argument drops in the music screens and remaining sweep test updates.
2026-08-17 19:02:25 +02:00
edde746 78fa2c29e3 refactor(input): compile the disabled diagnostics and screenshot-blur subsystems out of normal builds
TextInputDiagnostics.enabled and kBlurArtwork were mutable/const-false flags nothing ever set, yet the diagnostics side eagerly built log strings on the gamepad and key-simulator hot paths on every event. Both flags are now const bool.fromEnvironment (PLEZY_TEXT_INPUT_DIAGNOSTICS / PLEZY_BLUR_ARTWORK) so the branches const-fold away, every eager interpolation sits behind a guard, and screenshot blurring stays one dart-define away. The vowel-rotation title obfuscation had no consumer need beyond artwork blur and is gone.
2026-08-17 19:02:22 +02:00
edde746 f5c7b95b02 refactor(tv): read TV detection through the PlatformDetector facade everywhere
TV state had two interchangeable entry points — PlatformDetector.isTV/isAppleTV and the raw TvDetectionService.*Sync accessors — with call sites split arbitrarily between them. App code now goes through the facade; the raw accessors document that they exist for the facade and tests.
2026-08-17 19:02:21 +02:00