Commit Graph
3181 Commits
Author SHA1 Message Date
edde746 0fbf2e205c fix(profiles): skip the resume profile prompt during a live companion-remote session
With "Ask for profile on app open" enabled, every screen-off or app
switch on a phone re-pushed the profile picker and PIN on resume, burying
a companion-remote session that survives backgrounding since the
reconnect cycle was made lifecycle-aware. Suppress the resume prompt
while a companion session is live, mirroring the active-playback
exemption — the session already belongs to the profile that started it.

close #2087
2026-08-24 10:06:00 +02:00
edde746 a9f2c1ff99 fix(player): shape ffmpeg demuxer audio extradata into media3 csd layouts
VP9/WebM-origin files stalled at 0ms in ExoPlayer on 2.17.0 and fell
back to MPV after ~40s. The ffmpeg demuxer published raw extradata as
the only initializationData entry; Android's Opus decoders consume
their first three input buffers as OpusHead/codec-delay/seek-pre-roll,
so they ate the first two real packets as configuration and silently
discarded every decoded sample, pinning the audio-driven clock at 0.
Vorbis (unsplit Xiph lacing) and FLAC (unmarked STREAMINFO) had the
same shape divergence.

Shape audio extradata into the layouts media3's own extractors emit
before publishing the track format.

close #2088
2026-08-24 09:36:10 +02:00
github-actions[bot] fe4e53f912 chore: update cask to 2.17.0 2026-08-24 05:28:32 +00:00
edde746 fed572d19e chore: bump version to 2.17.0 2.17.0 2026-08-24 06:16:29 +02:00
edde746 adef523894 chore(build): add a SwiftPM refresh script for stale MPVKit pins
After set_mpvkit_revision.sh moves the MPVKit pin, macOS builds die in the
SwiftPM integration migration with "could not find the commit <sha>": Flutter
runs the macOS Xcode steps with -skipPackageUpdates, and its one fetch-capable
step only ever runs for iOS, so the local mirror never learns the new revision.

scripts/refresh_apple_spm.sh runs a single -resolvePackageDependencies per
platform to update the mirror without touching the tracked locks, with --reset
for an internally inconsistent SourcePackages directory. The header of
set_mpvkit_revision.sh now points at it.
2026-08-24 06:12:06 +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 ead637cc92 ci(linux): build plane_render_executor_test so ctest can run it
Both Linux sanitizer jobs have failed since 974806fe. It registered
plane_render_executor_test with ctest, but the reliability job compiles an
explicit target list and the new target was never added to it, so ctest found
no executable and reported the test as Not Run.
2026-08-24 02:13:32 +02:00
edde746 eed6071b94 test(jellyfin): assert DateCreated on the music latest hub row fields
The cross-server aggregation suite still pinned the music Latest row's
`Fields` to the pre-DateCreated set, so the suite has failed on every commit
since 24d7681d added the field. That commit updated the assertion in
jellyfin_client_urls_test.dart but missed the second copy of the same
expectation here, leaving CI red rather than signalling a real regression.
2026-08-24 02:13:28 +02:00
edde746 6da1f83891 feat(player): switch the display to the video's native resolution on Android TV
Sub-4K content on a 4K TV was always upscaled by the playback device, so the TV's own (usually better) upscaler never received the native signal and could not apply resolution-specific processing.

Adds a Match Content Resolution setting on Android TV: playback picks the smallest display mode that still contains the video — never downscaling it — and rate-matches within that resolution when frame rate matching is also enabled. A resolution-only switch keeps the refresh rate as close to the current one as possible, and the settle watchdog now verifies the requested mode id so such switches still trigger the Android MPV decoder refresh. Transcodes get their target from decoded dimensions on the post-first-frame path. Mode selection is extracted into a pure, JVM-tested DisplayModeSelector.

close #2073
2026-08-24 02:10:35 +02:00
edde746 8b5ee4f3dd fix(subtitles): apply downloaded subtitles instead of polling the stale metadata cache
In-player subtitle search always ended with "Subtitle downloaded, but it
could not be selected": the apply flow polls getVideoPlaybackData for the
new external stream, but the fresh-cache-first fast path (be9197ed) served
the pre-download /library/metadata row for every iteration — playback
start leaves a fresh row, and the first network poll re-stamps it, so the
new stream was never observed and the flow timed out.

Add forceRefresh to PlexClient.getVideoPlaybackData (mirroring
getPlaybackExtras) and pass it from the download poll loop. The forced
fetch still rewrites the cache row, so the subsequent source switch sees
the new track.
2026-08-24 01:52:13 +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 fff1b3090d build(apple): pin MPVKit by commit instead of by version
MPVKit publishes binaries for every commit on its main branch now, so a
version tag is no longer the only resolvable thing. Pinning the revision
means a fork fix is consumable as soon as its binaries land, with no release
ceremony there and no version bump here.

Pinned at 0097fea015fe, which carries the PiP subtitle fix: the iPadOS
Picture-in-Picture window no longer resizes when SRT subtitles appear and
disappear. Nine pin sites move together -- three project.pbxproj
requirements plus the six SwiftPM locks that check_apple_spm_locks.py keeps
in agreement.

tvos/scripts/wire_mpv.rb no longer hardcodes the version it writes; it reads
the revision out of the tvOS project's committed lock, so re-wiring the
tvOS target cannot silently revert the pin. Its test asserts the shape and
that all nine sites name one commit, rather than a literal sha, so moving
the pin stays one command:

    scripts/set_mpvkit_revision.sh <40-char sha>

close #2078
2026-08-24 01:15:28 +02:00
edde746 c3305d2919 fix(tv): paint the floating sidebar panel over content
In TV mode the sidebar menu floated unreadably on top of the home rows,
the artwork and the settings list — two overlapping sets of text with no
panel behind either.

The rail's surface is transparent on TV so the full-bleed backdrop
continues behind a docked rail, which is safe because the shell displaces
content around it. 2.16.0 turned hover/touch expansion into an M3E modal
panel that overlays content at the collapsed offset instead of pushing
it, so there the rail painted nothing and content showed straight
through the menu.

Keep the transparency rule for every docked shape and paint the surface
whenever the rail floats, reusing the predicate that already gates the
overlay corner radius and edge shadow. The fade now shares the width
morph's duration and curve, so a shorter fade cannot strand a fully
grown, fully transparent panel over the content mid-collapse.

Reproducing needs Force TV mode plus a pointer, which is why this
surfaced on a SteamOS box driving a TV and never on Android TV's D-pad
path.

close #2079
2026-08-24 01:15:28 +02:00
edde746 b457290a6b fix(player): stop the Dolby passthrough buzz after 30-40 minutes via MPVKit 1.0.26
With Audio Passthrough on, (E-)AC-3 Direct Play on Apple TV degraded
into a repeating buzz after roughly half an hour and never recovered
(a 31.25 Hz comb: one stale 32 ms frame looping). During preroll the
system's Dolby pipeline probes the fake endless resource at a fixed
~183 MiB offset; the AO satisfied it with fabricated copies of the
latest access unit, and CoreMedia cached that range by stream offset
and planned its sequential reads around it. Once playback reached the
cached bytes (33 min at 768 kb/s, 40 min at 640 kb/s) it played the
fabricated frames, and its next read landed past the write head, where
the loader kept fabricating frames forever. Seeks rebuild the item at
offset zero, which is why exit/resume bought another 30-40 minutes.

MPVKit 1.0.26 refuses loader reads past the write head instead of
fabricating data; the pipeline falls back to a plain sequential read
from the write head and prerolls normally with nothing poisonous
cached.

Verified on an Apple TV 4K gen 3 with an HDMI capture rig: the 1.0.25
sink audibly and loggably degrades at stream byte 0x0B770000 (8.3 min
into a 3072 kb/s accelerant stream, 40 min at 640 kb/s), while the
1.0.26 sink crosses the same byte with continuous audio on both the
accelerated stream and a full 40-minute 640 kb/s Plex Direct Play soak
through Plezy itself.

close #1776
2026-08-24 01:15:28 +02:00
edde746 a11da3fec8 feat(livetv): record live TV on Jellyfin and Emby via the timer APIs
Jellyfin and Emby users had no way to record live TV from Plezy at all: no
record button in the EPG guide's program menu, and nothing in the recordings
UI, because every recording surface was gated behind a Plex-only DVR adapter.

The recording surfaces are now backed by a MediaBrowser DVR adapter that
synthesizes the neutral (Plex-shaped) payload models from the timer APIs:

- Template comes from `GET /LiveTv/Timers/Defaults?programId=`, offering a
  "Record Episode" entry and, for series airings, "Record Series". The
  defaults DTO travels JSON-encoded in the template's opaque `parameters`
  and setting ids are DTO field names, so the existing template-driven
  record-options sheet renders and round-trips them unchanged.
- Create POSTs the mutated defaults whole to `/LiveTv/Timers` or
  `/LiveTv/SeriesTimers`; a duplicate one-off create answers 400, which the
  adapter rethrows as `RecordingConflictException` so the sheet can show
  "Already scheduled" without a backend check.
- Scheduled recordings read `/LiveTv/Timers` minus `Cancelled`/`Completed`
  tombstones, rules read `/LiveTv/SeriesTimers` with their child timers
  nested. Rule keys carry a `timer:`/`series:` prefix so cancel, edit and
  delete dispatch to the right timer space inside the adapter.

Guide programs now carry their recording state: the program id doubles as the
record seed, and `TimerId`/`SeriesTimerId` become the rule keys that drive the
guide's scheduled dot and the Manage action. The series key is stamped only
when an airing actually records, so an episode a series rule skips does not
show a false indicator.

Three neutrality fixes on the shared UI: the record-options sheet only demands
a target library when the template declares one (MediaBrowser records into its
own configured folder), and "Re-evaluate rules" is hidden unless a connected
DVR supports it, since only Plex has that endpoint.

`fetchDvrs` deliberately stays empty so the synthesized per-server Live TV
identity that channel fetches, favorites and playback key off is preserved.

Verified end-to-end against a disposable jellyfin/jellyfin:10.11.11 container
with an M3U tuner and XMLTV guide, driven through the real client: template,
create, duplicate conflict, cancel, series create with child grabs, edit
round-trip and delete.

close #1645
2026-08-24 00:08:11 +02:00
edde746 99c21ce6ae fix(remote): stop companion pairing hanging when a host address is unreachable
Tapping a discovered desktop in the phone's companion remote did nothing:
the device list emptied to "No devices found on your network" and no
connection was ever made. Manual IP:port entry silently did nothing too.

A host advertises every local IPv4 address, so a PC with virtual adapters
(WSL, Hyper-V) broadcasts addresses the phone cannot route to. The client
races them all, and cleanup of the losing candidates awaited
`sink.close()` on channels that never connected. That future never
completes in web_socket_channel: the close future waits on the local
stream's listener, which is only attached on the connect-success path. So
the race never finished, the winner was never joined, and the flow just
stopped with no error.

Cleanup now tracks whether a candidate ever connected: unconnected probes
get a deferred close armed on `ready` settling instead of a blocking
await, and they skip the terminal drain since they hold no host admission
slot. The managed join gets a real connect timeout that surfaces as a
typed timeout error, and disconnect during a pending connect closes the
socket once the connect settles rather than blocking on it.

Two paper cuts around the same flow: a failed attempt now restarts
discovery instead of stranding the cleared list on "No devices found",
and the desktop's server card shows its listen addresses so manual entry
is not guesswork.

close #2077
2026-08-24 00:08:10 +02:00
edde746 5b6016d88b fix(player): strip mpv.conf-style quotes from custom config values
A quoted value in the mpv.conf settings screen, copied verbatim from a
real mpv.conf (sub-font = 'NetflixSans-Bold'), reached mpv_set_property
with the quotes included: a string property silently selected a
nonexistent font family and fell back to the bundled font, and a
numeric property failed mpv's parse and was only logged. Either way the
line did nothing. mpv's own config-file parser strips one pair of
matching quotes around the whole value; parseMpvConfigText now does the
same before the startup pass applies the entries.

close #2025
2026-08-24 00:08:10 +02:00
edde746 6aaea847a7 fix(tv): stop dropping more than half the frames on D-pad browse
On a 32-bit Amlogic TV box, 57% of UI frames while moving focus along a rail
missed the 16.68 ms budget, and the median frame was already over it at 20.7 ms.
The GPU was idle throughout (raster p90 9.1 ms, zero frames over budget) -- all
of it was main-isolate work, split roughly evenly between layout and semantics.

Five separate causes, all measured:

- The horizontal rail used `itemExtentBuilder`, so `RenderSliverVariedExtentList`
  walked every preceding index on each realized child, in every layout pass, and
  re-resolved the trailing slot inside the closure. Pinning the trailing cell to
  the card extent makes the list uniform, which restores O(1) offset, index and
  max-extent math.
- The sidebar-expand tween wrapped the whole shell with a `LayoutBuilder` inside
  its builder, so ~15 ticks per focus flip rebuilt `SideNavigationRail` -- and its
  non-virtualized child list -- during the layout phase. The tween now wraps only
  the content `Positioned`, matching `SideNavigationBleedBuilder`.
- Each nav item crossfaded through two `Opacity` subtrees, i.e. 14-18 offscreen
  save layers per frame for the whole 250 ms morph. It now fades colour alpha on
  the leaf, the same substitution `AnimatedDimScrim` already documents.
- `HorizontalScrollWithArrows` kept a scroll listener on platforms where its arrow
  chrome is compiled out, so crossing either scroll boundary `setState`d a whole
  rail row of cards. Two full-row rebuild storms per traversal, for arrows that
  cannot appear.
- The rail's semantic proxy rebuilt its label and six closures on every
  `_RailFocusModel` notification, including vertical-scroll flips.

Also: one merged listener per `SettingsBuilder` instead of six, and the media card
semantic label is cached rather than rebuilt and then discarded by the rail's
`ExcludeSemantics`.

Measured on the device, median of three runs: frames over budget 120/212 -> 32/246,
total UI-thread work 4067 ms -> 1845 ms, layout p90 18.2 -> 8.1 ms, build p90
7.95 -> 2.42 ms. The sidebar scenario went from 121/148 frames over budget to
32/184.
2026-08-23 18:13:39 +02:00
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 f95465067e chore(tools): add the on-device frame and startup benchmark harness
The measurements behind the preceding commits needed a repeatable way to drive a
real Android TV box and read both sides of the frame. `dumpsys gfxinfo` alone is
not enough (it sees the HWUI composite, not Flutter's UI and raster threads), and
DevTools is not scriptable.

- `vmclient.mjs` -- Dart VM Service client over WebSocket, plus percentile and
  timeline helpers and a CPU self/total-time reducer over `getCpuSamples`.
- `scenarios.mjs` -- repeatable D-pad workloads. Keys are sent as one batched
  `input keyevent` invocation per burst, because a separate invocation per key
  costs more on the device than the interaction being measured.
- `bench.mjs` -- runs a scenario and correlates Flutter's own frame phases
  (`Animator::BeginFrame`, LAYOUT, SEMANTICS, BUILD, PAINT, `Rasterizer::DoDraw`)
  with HWUI framestats, reporting medians over repeats.
- `profile.mjs` -- CPU self/total time by function plus an allocation profile.
- `launch.sh` -- cold-launches the profile build, sets up its own port forward and
  prints a host-reachable VM Service URI. Passes `--ez enable-dart-profiling`,
  which only `flutter run` supplies by default, and wakes the display first
  because `am start -W` silently reports no `TotalTime` when the screen is off.

Not referenced by `lib/` and not packaged; `tools/` is outside the APK.
2026-08-23 18:13:39 +02:00
edde746 e6a0840fbe fix(player): stop rebuilding the whole controls tree on every tick
Nothing sat between the full-screen `RasterizedGradient` scrim and the
position-driven leaves, so `markNeedsPaint` from the timeline slider walked past
the gradient and re-rasterized the entire chrome -- top bar, button row,
timestamps, timeline -- on every 250 ms position tick. That also partly defeated
`RasterizedGradient`, which exists because Skia gradients cost ~10 ms per
full-screen pass on this GPU class.

Around that, four things drove root `setState` calls that only needed to change a
leaf:

- Held D-pad seek called a root `setState` per key repeat, even when the readout
  was already visible in the same direction and only the accumulated seconds had
  changed. The seek amount now flows through a `ValueNotifier` that
  `DoubleTapFeedback` reads, so a burst updates one `Text`.
- `DoubleTapFeedback`'s drift chevron ran `..repeat()` with no stop condition, and
  kept ticking at 60 Hz for the ~300 ms the readout spends at zero opacity after
  every burst. The controller is now gated on visibility.
- The auto-skip countdown ran a 5 Hz timer that root-`setState`d the controls tree
  for the whole intro/credits window, to animate one progress ring.
- The play-next and still-watching countdowns root-`setState`d the player screen
  once a second, which re-created `PlexVideoControls` with its ~50 props and
  rebuilt the entire chrome to change one digit. Prompt open/close deliberately
  stays on the state path, because `playNextFocusNode` is threaded through those
  props and D-pad focus depends on it.

Two of the four position subscribers existed only to recompute pointer-hover
tooltips that a D-pad viewer never sees, rebuilding a button subtree 4x/s and
rescanning the chapter list each time; the "ends at" readout reformatted a string
4x/s for a value that changes once a minute. All three now dedupe.

Finally, the buffer path did two sequential `copyWith` calls on a 15-field
`PlayerState` and handed `BufferRangePainter` a freshly allocated list every tick.
`List` has no value equality, so `shouldRepaint` was unconditionally true.

Measured on device during playback with the chrome raised: 6 of 344 UI frames over
budget, p50 4.39 ms, layout p90 1.09 ms. During a held D-pad seek -- the worst
frame-time window on the TV path, because the readout and the decoder flush land in
the same frames -- 1 of 354 frames over budget, p50 4.28 ms, zero raster frames over
budget.
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 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 f2bf43a8bc fix(windows): restore window placement onto a live monitor without a blank flash
The remembered window placement restored invisible when its monitor was
gone (undocked laptop, powered-off TV), briefly flashed a blank window at
the restored spot before the first Flutter frame, and could lose the
maximized state: the exit path hides the window before a multi-second
teardown, so a debounced save landing in that gap recorded SW_HIDE, and a
window closed while minimized-from-maximized restored as a normal window.

Validate the saved rect against current monitors and keep only the size on
a miss, apply the placement with SW_HIDE so the first-frame callback stays
the single show, skip persisting while the window is hidden, and honor
WPF_RESTORETOMAXIMIZED when deciding to relaunch maximized.
2026-08-23 10:57:42 +02:00
edde746 b4b36c7e18 style(native): clang-format the mpv executor test, display-mode manager, and demuxer policy 2026-08-23 10:30:43 +02:00
edde746 be01631cab chore(ratings): delete the unused MediaRatingBadgeGroup pill
The detail hero builds its own pill around InlineRatingBadges since the
hero-chip rework, leaving MediaRatingBadgeGroup without a caller and the
unused-code check red. Remove it and inline the style helper its removal
left with one call site.
2026-08-23 10:30:43 +02:00
edde746 02a5f8ad2c fix(settings): pass the connection dialect when listing known servers in discovery
PR #1666 merged without compiling: it maps existing connections into the
add-server discovered list, but DiscoveredJellyfinServer gained a required
dialect with the Emby backend. Carry the connection's dialect through and
list only connections matching the screen's dialect, so the Add Emby screen
does not offer existing Jellyfin servers.
2026-08-23 10:30:36 +02:00
edde746 0964ab8ba2 feat(libraries): open grouping, filters, and sort as anchored menus on desktop
The three browse chips opened bottom sheets on every form factor. On
desktop platforms they now anchor dropdown popups to the chip: grouping
is a radio menu, sort shows the direction on the active field
(re-selecting toggles it) plus Clear, and filters use a two-level menu
with per-category values and an All row to clear one category. Phones
and TV keep the existing sheets.
2026-08-23 10:12:27 +02:00
edde746 89b8af62ea fix(settings): use background focus chrome on the add connection cards
The backend cards drew the outline focus border; the wrapper now sits
inside the opaque Material with useBackgroundFocus so the fill paints
above the surface, matching the other settings surfaces.
2026-08-23 10:12:27 +02:00
edde746 5842d9c1ec fix(profile): replace the profile tile chips with inline metadata
The Active badge and connection chips used opaque primaryContainer /
surfaceContainerHighest - both resolve to the card surface color in the
mono scheme, so they punched dark holes in the focused tile's background
highlight, and boxed pills fight that fill however they are tinted. The
Active badge becomes a muted check icon + label next to the name, and
connection chips become plain muted icon + text entries, matching the
app's other meta rows.
2026-08-23 10:12:27 +02:00
edde746 07463eea04 fix(explore): keep the page at the top when a tab switch focuses a hub
Switching to the Explore tab hands focus to the first hub, and
HubSection unconditionally ran Scrollable.ensureVisible, scrolling the
app bar and search field off-screen in touch mode. The scroll is now
gated on keyboard/D-pad input mode, mirroring FocusableWrapper's
autoScroll.
2026-08-23 10:12:27 +02:00
edde746 627374d4b5 fix(detail): fit hero chips on one row and even out the mobile sections
The hero metadata strip wrapped to a second run, leaving the Rate chip
alone on its own line (or clipping it away on short heroes). The strip
now measures chips and sheds them by usefulness - scores pill first,
then quality labels, edition, certification, runtime - never the year
or the interactive Rate chip, mirroring the TV FittedMetadataLine.

Episode rows replace the 'E0' primary-container chip with an inline
'3. Title' prefix that mirrors the server-provided number, including a
genuine episode 0. Phone section gaps drop from 24px to 12px so Cast,
Extras, and the related hubs share one rhythm; TV keeps its own values.
2026-08-23 10:12:27 +02:00
edde746 c4b043cba9 fix(ui): present the library switcher and profile menu as bottom sheets on mobile
Both anchored popups were small tap targets on phones. AppMenuButton
gains an opt-in adaptiveSheet flag that routes through
showAdaptiveAppMenu: full-width, untitled sheet rows on touch platforms
(phones plus Android TV / tvOS), the same anchored popup on desktop.
2026-08-23 10:12:26 +02:00
edde746 aa9118a799 fix(tv): hold the hub focus glow while the rail scrolls vertically
Moving UP between hubs showed the focused card's glow immediately: the
glow paints in the root overlay, unclipped by the rail viewport, so it
flashed over the spotlight while the target row was still offscreen.
The rail now suppresses only the glow for the 250 ms vertical scroll
and lets it fade in once the row settles. FocusGlowOverlay defers
attached-portal shows out of build, which OverlayPortalController
asserts against.
2026-08-23 10:12:26 +02:00
edde746 fe954eef5f fix(profile): use background focus chrome on the profile screens
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.
2026-08-23 10:12:26 +02:00
edde746 66d0ef054e fix(auth): lay out the plex QR retry and cancel buttons inline on TV
The QR sign-in screen stacked Retry above Cancel with intrinsic widths,
reading as sparse on a TV canvas and pushing Cancel off the bottom edge.
Both wait states now share one centered row, matching the player's
initialization-error surface.
2026-08-23 10:12:26 +02:00
Thomas LupinandGitHub 8f13a56e1a Add existing Jellyfin servers to discovered servers (#1666) 2026-08-23 09:29:04 +02:00
edde746 8061c4f5f2 docs(contributing): require AI-assisted PRs to disclose models used 2026-08-23 09:19:55 +02:00
edde746 802d47e980 fix(windows): preserve Dynamic Refresh Rate when restoring display mode
On a Dynamic Refresh Rate display (e.g. Surface 120Hz panels), stopping
playback with Match Refresh Rate enabled left Windows pinned to a fixed
60Hz with DRR disabled: the legacy EnumDisplaySettingsW capture only sees
the DRR base rate, and a DEVMODE ChangeDisplaySettingsExW restore cannot
express the CCD-level DISPLAYCONFIG_PATH_BOOST_REFRESH_RATE selection.

Capture a virtual-refresh-rate-aware QueryDisplayConfig snapshot of the
topology before the first display mutation and restore it through
SetDisplayConfig (saving to the database to repair the 24/48/60Hz
registry-workaround write-back), keeping the DEVMODE path as fallback.
The HDR toggle mode dances re-apply the same kind of snapshot, and crash
recovery returns to the persisted display configuration via
SDC_USE_DATABASE_CURRENT, verified against the recorded mode before the
legacy restore takes over.

close #2055
2026-08-23 07:34:56 +02:00
edde746 1a9d2686c9 fix(tv): declutter the detail page with a slimmer rail and spaced hero
The TV media detail page felt cramped on 1080p TVs (540 logical px): the
bottom rail's title strip, card label bands, and next-hub peek consumed
nearly half the screen and cut the next hub's posters mid-card at the
screen edge, while the hero packed logo, metadata, genres, summary, and
action row into 5-10 px gaps against the back arrow, shedding summary
lines to fit.

Slim the shared rail metrics (hub strip 36->30, next-hub peek 30->20,
label band 42->36, person labels 58->52, height slack 14->10) and the
detail page's rail card scale (0.8->0.72), then spend the freed height
on the hero: larger gaps between metadata/genres/summary/actions, a top
offset that clears the back button, and an info column widened from 57%
to 60% of the screen. Movie and show details now keep three spaced
summary lines, and the wider metadata line fits one more rating badge
before shedding.

The #1893 metadata-line regression test pinned the exact badge-shed
count at its test viewport; it now asserts rendered badges stay fully
on screen while keeping the quality-label guards.
2026-08-23 07:08:27 +02:00
edde746 95800d8a72 fix(tv): open a full details sheet from the detail hero info block
On TV the detail hero caps the description at three lines with no way to
read the rest, and the fitted metadata line sheds rating badges and
quality labels when they do not fit, leaving that information
unreachable by D-pad.

The hero information block is now a focusable target: D-pad up from the
action row lands on it, and select (or tap) opens a sheet with the
complete metadata fields, every rating badge, the genres, and the
untruncated description. Long descriptions page with D-pad up/down.

close #2042
2026-08-23 05:51:36 +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 c201bc2f03 feat(player): default every container to the ffmpeg demuxer
The Auto demuxer mode routed only media3's weak container families
(AVI, ASF/WMV, MPEG-PS, Matroska/WebM) through FFmpeg and kept MP4/TS
on media3's extractors, with a second any-container FFmpeg instance
behind media3's list. With the goal of fully switching to the ffmpeg
demuxer, the split served no purpose and the catch-all could never add
coverage once the primary accepts everything.

Collapse the preference to two modes: FFmpeg (default), which demuxes
every progressive container ahead of media3's list, and media3 only as
the user-facing escape hatch in case a file misbehaves. media3's
extractors stay behind FFmpeg, so anything FFmpeg cannot sniff still
reaches them. The catch-all role and the Auto container list are
removed; unknown persisted wire values (including the retired "auto")
resolve to FFmpeg.

Verified on a Pixel 7: MP4, TS, and MKV all demux through FFmpeg under
the default ("sniff accepted mov,mp4 / mpegts / matroska,webm") with
forward and backward seeks landing; JVM suites and analyzer pass.
2026-08-23 01:42:36 +02:00
edde746 75c095a5b2 feat(player): demux direct-played containers with ffmpeg on Android
Direct play on Android kept accumulating container patches: AVI with
XviD packed-bitstream timestamps and missing VOL csd played broken,
ASF/WMV and MPEG-PS/VOB had no media3 extractor at all, and MKV needed
a custom extractor stack for zlib-compressed subtitles, LOAS/LATM
audio, cueless seeking, and font attachments.

Demux progressive containers with libavformat behind media3's
extractor API. An AVIO bridge serves libavformat from the
ExtractorInput: every position divergence defers into a RESULT_SEEK
round trip, header reads replay from a block cache while
avformat_open_input restarts, and avformat_seek_file executes seeks
with bounded loader round trips. Packets feed media3's TrackOutputs,
so decoders, passthrough carriers, Dolby Vision RPU/EL conversion
(dvh1 codecs string surfaced from DOVI side data), and the subtitle
pipeline are untouched: embedded fonts feed AssHandler over JNI, ASS
reaches libass as per-sample dialogue, SRT/VTT render as cues, VobSub
maps through media3's VobsubParser, and LOAS/LATM AAC unwraps via
LatmTrackOutput.

Under the default Auto preference FFmpeg demuxes AVI, ASF/WMV,
MPEG-PS, and Matroska/WebM and sits behind media3's list as an
any-container fallback; MP4/TS keep media3's extractors. A playback
setting exposes auto / FFmpeg first / media3 only, and the JNI
load-failure path falls back to stock media3. The custom Matroska
extractor stack and its reflection keeps are retired.

Verified on Pixel 7 (API 36), Box R 4K Plus TV (API 34), and SHIELD
TV (API 30): instrumentation playback suites, the minified R8
reachability gate, and an on-device container matrix with forward and
backward seek landings plus visual subtitle checks.

close #2052
2026-08-23 01:22:24 +02:00
edde746 974806fe80 fix(linux): render the mpv video plane off the GTK main thread
The player UI was choppy and slow while video played, and scrub-bar
thumbnails rarely appeared until the video was paused (worst with 4K
HDR content, whose per-frame tone-map render is expensive). The cause:
mpv's render and the plane's eglSwapBuffers ran on the GTK main thread,
which also rasters Flutter's UI and dispatches input, so every UI
repaint and pointer event waited out the video frame render.

Move the render + swap onto a dedicated plane render thread
(PlaneRenderExecutor). All Wayland protocol state stays on the main
thread: Present() splits into PreparePresent() (gates + frame-callback
request) and CompletePresent() (first-frame scale flush, ack watchdog,
mid-flight hide/rect-loss re-detach). The plugin serializes one job at
a time, defers rect application and HDR transaction starts to the job
completion so a resize never races the swap and a staged colour
transition can never pair an old-colour buffer with a new description,
and drains the worker before disposal - which is what lets
RenderToSurface render without holding native_mutex_. A worker wedged
inside a driver call is abandoned after a bounded wait and the
session's player and plane are deliberately leaked instead of freed
under it. PLEZY_PLANE_RENDER_MAIN_THREAD=1 restores the old inline
behaviour as a temporary escape hatch.

Measured in a headless-sway container with a 4K test file (llvmpipe
inflates render cost the way DV tone-mapping does on real hardware):
idle-playing UI commits went from ~354 ms to the keep-alive's ~100 ms,
and pointer reads from ~185 ms bursts back to input rate; the #2067
hide/backoff/recover log signature is byte-identical.

close #2057
2026-08-23 00:13:43 +02:00
edde746 6f64ca2952 fix(linux): recover the wayland video plane after the compositor stops acking frames
Switching workspaces on KDE or a wlroots compositor hides the toplevel
without any GTK visibility change, and the compositor stops answering
the video plane's frame callbacks. Once the ack watchdog spent its miss
budget it stopped dead: the giveup destroyed the only outstanding
wl_callback, mpv's edge-triggered redraw latch was already saturated by
a render that bailed on frame_pending(), and no visibility change ever
comes - so video stayed frozen on the last buffer until a resize or
fullscreen toggle, while audio kept playing.

Back off to a slow re-present timer instead of stopping. A present only
happens when mpv actually has a new frame, so a hidden playing plane
settles at one commit per ~1.5 s and recovers within a second of being
shown again; a paused hidden plane goes dormant and is revived by mpv's
next update edge.

close #2067
2026-08-22 15:31:34 +02:00
edde746 a9a7498991 fix(plex): remove comma-containing tags one request per tag
Plex's tag.tag- removes comma separated tags and has no escape syntax, so
removing a tag containing a literal comma ('Action, Comedy') split it at the
separator and could over-remove siblings named 'Action' or 'Comedy'.

Fields whose removals contain a comma now defer to one removal per request
after the main edit; every request restates the kept tags and the lock, so
the sequence converges regardless of order. Removals without commas keep the
exact single-request wire shape.
2026-08-21 19:23:45 +02:00
edde746 37446e2d8e fix(android): fail loud on non-literal JVM reflection in the shrinker check
The JVM-reflection scan only recognized string-literal lookups, so a
Class.forName or getDeclaredField written with a constant, variable, or
concatenated name was silently skipped and a release build could lose the
member while the check stayed green — inverting the fail-loud behavior the
checker applies to every native lookup it cannot trace.

Unclaimed reflective occurrences now error like the cannot-trace paths, and
the forName pattern requires the closing paren so a concatenated argument is
not half-claimed as a bare literal prefix. Production reflection stays
all-literal, so repository validation is unchanged.
2026-08-21 19:23:45 +02:00
edde746 72f68bdfa7 fix(player): mark chrome opaque only after the fade-in completes
Hiding the chrome in the frame between the post-frame opaque mark and the
next build trusted an opaque flag the renderer never realized: hide() kept
controlsPresented while the fade-in target never rendered, so no fade-out
ran, markControlsHidden never arrived, and Back/Escape stayed swallowed
until a later show/hide cycle repaired the flag.

The controller's opaque flag now follows the real fade-in completion via
AnimatedOpacity.onEnd, so any hide it observes can rely on a fade-out that
will actually run. Branches that mount the chrome directly at full opacity
keep marking immediately, since those insertions fire no onEnd.
2026-08-21 19:23:45 +02:00