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
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
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.
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.
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.
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.
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
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.
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
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
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
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
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
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
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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
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
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.
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
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
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
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.
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.
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.