Picker ordering could select another account for playback preferences, and delayed operations could publish data after the active account changed.
Resolve playback authority independently of the account picker and fence repository, controller, and inline settings operations to their original authenticated account.
One stalled DNS family or a cancelled WebSocket upgrade could keep a connection attempt alive. Push channels also stayed exhausted after the app returned to the foreground.
Race address families independently, own connection tasks through upgrade handoff, and rearm exhausted push channels on a genuine foreground resume.
A transport reconnect could recover the wrong room or retain obsolete host authority.
Authenticate retained membership and recover the relay-authoritative role without falling back to create or join. Release retained membership through authenticated teardown and document the coordinated relay rollout.
Minified MPV initialization aborted because callback keep rules retained obsolete JNI signatures. Android TV IME dismissal also left editing active, requiring an extra Back press to leave the screen.
Keep only the named JNI callbacks without duplicating argument signatures and exercise them through the permanent minified reachability gate. Centralize native editing-session termination, observe owning-view keyboard dismissal, and reject stale completion or reopening work.
Verified the minified gate on Galaxy Z Fold3, debug MPV lifecycle and log-level suites on SHIELD and Pixel 7, native Done/Previous and two-Back behavior on SHIELD and Fire TV, 76 focused Flutter tests, analyzer parity, and shrinker checks. Both new regressions reject the original implementations.
On a landscape phone, the app's mobile shell lets the navigation rail absorb the leading inset and leaves the trailing one for each screen to own — but several screens never did. The Plex-only alphabet scroll handle sat under the notch or rounded corner, the library grid's last column and the folder tree ran under it, the player's landscape header and timeline dropped horizontal insets entirely, and the catalog detail and Discover hero content used fixed margins. Automotive was already protected at the root.
Each region owner now applies a horizontal-only SafeArea to its foreground content: the browse tab's alpha overlay and content scroll view, the mobile video controls in landscape, the catalog detail's content column, and the Discover hero text and buttons. Backdrops, hero artwork, and the video surface stay full-bleed, and the root inset policy is unchanged.
A Seerr permission change reached the app only as a sign-in-time snapshot: a catalog detail decided once, when opened, whether it could show Request, and the request sheet only re-read the mask when something else rebuilt it. A grant left the action hidden until the screen was reopened, a revocation left it visible, and submitting then surfaced Seerr's raw English 403 body while the sheet stayed open with gates it no longer had. A disconnect while a detail was open left it holding a source wrapping a disposed client.
Permission changes now propagate through the existing provider graph without replacing clients: the client adopts the fresh `/auth/me` body it already fetches while telling a permission miss from a dead session, and a live-session denial is a typed `SeerrPermissionException`. The detail screen derives its Request eligibility from the account's current mask and source on every build; the sheet reconciles grants (loads destinations, shows 4K) and revocations (trims them, or closes with a localized reason the host surfaces) and re-checks authority locally before submitting.
Copying a drag or keyboard selection from the logs screen produced one run-on line: every log entry and the device header is its own paragraph, and Flutter's selection area concatenates adjacent paragraphs with no separator.
Each record is now its own selection container whose selected text ends with a newline, and the screen-level container drops the one dangling after the last selected record, so a partial drag copies exactly what was highlighted. Rows stay lazily built with their geometry unchanged, and the toolbar's copy-all export keeps its own format.
Deleting the last episode or season from a detail page popped the wrong route whenever something covered the detail: the download progress dialog (the deletion event fires before the delete returns), a player, a sheet fallback modal, or another detail. The cover disappeared and the empty detail stayed on screen.
The detail now leaves through the route it was pushed as: popped when it is current, removed from under the cover otherwise, with the same null result; a detail that is the navigator's only route stays, and a route already leaving is not touched twice. A delayed player launch checks that the route it was started from is still active before pushing, so a launch whose detail was deleted during its awaits does not land on an unrelated screen.
A library that a server push had marked stale stayed stale for good if the reload triggered on the next activation failed: the tab wrote the push's epoch down as consumed before starting the reload, so every later activation, tab switch, and visibility replay saw nothing to do until another push arrived. The same shared load-start snapshot was also read by whichever load committed last rather than the one that had taken it, and in folder grouping the epoch was credited from a paged fetch nobody renders while the displayed folder tree was never reloaded.
Each load now owns its epoch snapshot and commits it only when its data actually landed and it is still the current load; a failed or dropped load releases the snapshot without credit. An in-flight snapshot still dedupes an activation that lands mid-fetch. Folder grouping refreshes the displayed tree and credits the push only when that refresh succeeded, and switching libraries resets the previous library's credit.
A guest promoted to host while its player was detached — mid-reload, in the lobby, between episodes — took the room over with no epoch: the coordinator could not answer state requests, and when the player rebound it opened a fresh epoch that resumed a paused room, reset the room rate to the new host's saved speed, and published the reload position as the room's timeline. Separately, commands issued under the previous role kept landing after the role changed: a guest's end-of-file hard seek still played the player after promotion, leaving the new host broadcasting a moving position while gating on peers, and a demoted host's rate continuation still reported a room rate change.
The host coordinator now adopts the room — epoch identity, rate, intent, phase, and the inherited anchor — the moment the role changes, whether or not a player is bound; a player bound later for the same media rebinds into that epoch and keeps its pause, rate, and timeline. Authority over the player is a per-engine generation, revoked before every role change, epoch change, detach, and disposal, and every asynchronous follow-up captures and re-checks it; the attachment's acknowledgement ledger still survives role swaps, and the coordinator now watches consumed acks so a play its predecessor set in motion cannot run under a stopped room. The reconciler ends its epoch when the host leaves the player, so a promotion after that adopts nothing.
Every seek on Linux stalled the UI thread: the playback-restart handler read `time-pos` with a synchronous mpv_get_property from the GLib main context, which parks that thread on mpv's playloop for as long as the core takes — and the core's render path can itself be waiting on the main thread to post the next render job.
The position is now requested through the existing asynchronous property registry when the restart is dequeued, with the source identity captured at that moment, and the unchanged playback-restart envelope is sent when the reply lands. A source boundary — start-file or end-file — delivers any restart still waiting as a position-less envelope for its captured source and retires the reply, so no restart is ever reported after its source's boundary or attributed to the next source. No new event type or thread.
A catalog detail's library labels disappeared for the rest of the session after a re-lookup: the matcher replaced each answering server's copies with the raw items of the new wave, so a Jellyfin retry whose best-effort ancestors stamp failed overwrote a labeled copy with an unlabeled one, and the unlabeled copy was then memoized as the authoritative hit.
Membership stays the answering server's to define, but every copy it returns again is folded onto its predecessor through the shared merge policy, which now treats library id and title as a pair: a copy naming a different library takes the new id with its own (possibly null) title instead of inheriting the old library's name, one naming the same library fills a missing title, and one naming no library keeps the known pair.
On Android TV, pressing Back to dismiss the keyboard while editing a field with no back handler of its own — an mpv config row, for instance — also popped the screen. The shared native text-input host consumed only the Back key-down that closed the IME session; the matching key-up then reached an ancestor that pops on key-up, and the same press's platform back callback was never deduped.
The host now marks the back coordinator and arms the key-up suppressor when it closes the session, and consults the suppressor before the field's own onBack so a field without one still swallows its in-flight key-up. IME completion and a second, intentional Back behave as before.
A light logo that carries its own dark ink — dark text on a white plate, white letters inside a dark outline — lost its contrast on light theme surfaces: the tone analysis granted the light-neutral remap to any mostly-light frame, and the remap then folded the plate and its ink into one dark tone.
A light majority no longer grants the remap. The analysis first decides whether the transformation is safe: on the sampled grid, four linear scans find neutral pixels enclosed on every side by the opposite neutral tone, and when most of either tone is enclosed the artwork is classified as backed and left untouched, like a dark disc already was. Dark ink that only touches the mark along an edge — an underline, a badge — stays open and keeps adapting, so accented and mixed-color marks remap as before. `LogoTone.dark` is renamed `backed` to name what the gate protects.
Three ExoPlayerPluginTest fallback cases failed after commandForSource started rejecting a load with no native player: the test core seamed property writes but not commands, so every fallback loadfile reported OPEN_FAILED. The core now takes an optional command runner beside the property writer; with none, a command without a native player still fails. The tests assert what mpv was handed — the loadfile URI as open ownership and its pause option as playback intent — instead of reflecting private flags, and one sets up its held state through setPauseIntentForLoad. ExoPlayer production behavior is unchanged.
Tearing down the mpv player while a hook handler or property write was still in flight could reach the replacement player: the native side kept one process-global handle, the Kotlin wrapper was published before the native session existed, callbacks were routed to whichever wrapper was current, and hook continuations were validated only against a `closed` flag read before the native call. A retiring player's on_preloaded handler could therefore reconfigure the successor's tracks, its late `mpv_hook_continue` could land on the successor's core, and the successor could ingest the predecessor's end-file and property tail.
Every native session now has an immutable monotonic id minted by nativeCreate. Create/init/destroy hold a write lock; every other JNI entry takes a read lock and is refused when it names a retired session, so a call admitted under a live session keeps that handle alive until it returns and retirement waits for it. The event thread is bound to its session before it starts and stamps every callback with it; Kotlin dispatches a callback only to the wrapper published for that session and passes its session on every native call. create() publishes the wrapper only after the native session exists, and a superseded create fails instead of publishing a dead session.
Move the native pin to edde746/mpv-build@3fd89c2, whose android binaries carry
fork patch 0010 (read-only pending-vid: mpv's own default video selection,
readable inside on_preloaded). The Android decode-policy hook reads it to route
DV-P5 and Hi10 for the track mpv will actually select. Only the android group
moved (key 23f2764e9e97 -> d109720a9b8e); linux, windows and Apple bytes are
unchanged.
The relay-authority tests drive a real loopback relay; with the upgrade now
running on a per-attempt HttpClient, the widget binding's mock client
answered every upgrade with 400.
Live-TV clock generations were matched to mpv sources first-in-first-out: every
start-file popped the oldest registered open, on the assumption of exactly one
start-file per open in dispatch order. Opens without a generation on the same
player, an Android loadfile rejected silently by nativeCommand, and the
independent delivery of the command ack and the start-file event all broke
that, so a seek that reopened the stream could calibrate against the wrong
source.
The loadfile reply now carries mpv's playlist_entry_id on Android, Apple, Linux
and Windows, PlayerNative.open resolves with it, and the live session binds
each generation to that id explicitly. Source events that land before the
reply are buffered per id and replayed on binding; a rejected or unreachable
load fails its generation instead of leaving a phantom; opens with no
generation are invisible to clock binding. Android now reports a rejected mpv
command as COMMAND_FAILED like the other cores.
Cancelling a WebSocket connect (deadline, stop, dispose, a newer attempt)
only dropped or timed out a future: the shared never-closed HttpClient kept
the address race and TLS handshake alive, and once SecureSocket.secure had
detached the raw socket even a real task cancel was a no-op, so after
ClientHello the peer stayed open and the connect future never settled.
The happy-eyeballs task now owns the transport through the TLS handoff:
cancel is idempotent, settles the task at once, destroys an undelivered raw
winner, and destroys whatever a handshake in flight eventually yields. Each
WebSocket upgrade runs on its own HttpClient inside a connection attempt
with completion plus cancellation; the library-event, companion-remote and
Watch Together relay sockets cancel it on stop, disposal, deadline and
supersession. dart:io offers no handle to abort an in-flight handshake, so
a black-holed ClientHello closes when the handshake settles, not at cancel.
A JSON-bodied 401 or 403 from a gateway or auth proxy in front of Seerr was
read as a Seerr session rejection, so a valid Quick Connect session was
unlinked by a Cloudflare or forward-auth wall, and the /auth/me confirmation
probe was never checked for the same.
One endpoint-aware classifier now recognizes only the two rejection shapes
Seerr's middleware and error handler actually produce (403 with their exact
bodies; the forwarded Jellyfin 401 on /auth/jellyfin) and is applied to the
primary request, the identity probe, the post-reauth retry, login and the
post-login identity read. Anything else keeps the stored credentials and
surfaces as an intermediary failure; genuine expiry and credential rejection
keep their re-auth and unlink behavior.
Switching the destination server or the 4K variant while a previous server's
details were still loading let the late response install the old profiles,
root folders and tags, clear the loading state, and overwrite tags the user
had edited; adopting no server left the spinner stuck.
Every destination/variant adoption, including null, now advances a
selection generation; a detail load applies neither its success nor its
failure unless it still belongs to the accepted generation, defaults hydrate
once per generation, and an explicitly emptied tag list stays empty.
The Dolby Vision and Hi10 decode policies run in mpv's on_preloaded hook,
before track selection, and read the first demuxed video track. A file whose
first video track is not the one mpv picks (attached pictures, a
default-flagged second track, --vlang, an explicit vid) got the wrong
routing.
The hook now resolves one pending track for both policies: an explicit
vid=no or vid=N is authoritative on its own, and auto asks the fork's
pending-vid property, which runs mpv's own default selection ahead of time.
On a libmpv without that property the first-track guess stays, logged once.
The predecoder gate is unchanged (#2065). The fork patch lands separately
through the native pin flow.
A guest promoted to host published its own player position as the room's
anchor the moment its readiness gates passed, but readiness is not
alignment: that player could be mid-correction, drifted, or at a stale
pre-seek spot, and every guest was then pulled to the wrong position.
The controller now reads the room position on the old host's clock before
discarding it and hands it to the new coordinator as a transition anchor.
The promoted host broadcasts that anchor, gates the group start, and aligns
its own player to it through the normal seek path; only once the seek has
rendered (or cannot: live, or a render timeout) does its local position
become the room's. A player replaced mid-handover inherits the anchor, and
a paused room hands over its paused position unchanged.
A library tab sitting under an opaque route (detail page, player) has
TickerMode disabled, so a server push arriving then was dropped while the
provider still marked the library stale; nothing observed the route pop, and
the tab stayed stale until the user switched tabs.
The tab now listens to the same effective-visibility notifier its suppression
predicate reads and, when an active stale tab becomes visible, wakes the
existing paced in-place refresh — no clearing reload, so scroll and focus
stay put. Main-tab activation still consumes the epoch first, so no second
pass is scheduled.
Coalescing library-change frames could lose libraries: a frame that could
not name its libraries (whole server) merged with a frame naming {A} flushed
as {A}, and an event naming physical Jellyfin folders where only some
resolved to a loaded library bumped the resolved one and silently skipped the
grouped view the rest belonged to.
Whole-server scope is now absorbing in the socket's pending window, and the
provider widens to every library on the server whenever any named id fails
to resolve; a fully resolved id set stays precise.
A host transfer that lands while a peer is leaving made the exit lie: the
relay answers a role-mismatched leave or endSession with the same
peer_id_unavailable it uses for a lost identity, so a guest promoted
mid-teardown read the refusal as 'already released' and walked away as the
live host of a running room, while a demoted host's refused endSession
surfaced as a failed exit with its guest reservation leaked.
Release now tells re-admission failures (terminal: the token names no
identity here) apart from release failures. A refused release reauthenticates
through the existing token; the joined admission names the current host, and
the next pass sends the operation that role requires, inside the existing
bounded retry loop. Success still means an acknowledgment, room absence, or
established loss of identity.
A host that created a room started the room at 1.0x while its own player later
moved to the saved playback speed: the coordinator seeded the room rate from
the player at attach, but attach runs before the track-selection pass that
applies the saved speed, and rate intent is (deliberately) no longer inferred
from the player's rate stream. Guests then kept correcting against a rate the
host was not running.
The screen now resolves the saved speed up front and declares it through
attachment; the coordinator seeds a fresh epoch with it before the loading
broadcast and applies it to the host player, keeps the room's agreed rate on a
same-item reload, and a promoted host without a broadcast rate falls back to
the declared one. While a room owns playback, track-selection passes no longer
reapply the local saved speed underneath it.
iOS/tvOS playback could deadlock on PLAYBACK_RESTART: the event queue held
lifecycleLock across a synchronous mpv_get_property(time-pos) round-trip while
the core waited on the avfoundation VO, which itself waits on the main thread;
the main thread meanwhile blocked on lifecycleLock in isLifecycleActive.
Snapshot the handle under the lock and query without it. The read stays on the
serial event queue, which also serializes destruction, so the handle cannot be
torn down mid-read.
Library refreshes could move focus, catalog lookups could cross profile or query boundaries, and detail labels could describe a different source from playback.
Keep hub and grid focus with committed item identities. Pace pushed deletions without delaying local eviction. Bind push channels to committed authentication sessions and catalog completeness to the active profile and effective query. Resolve preview source and container defaults with playback selection rules.
The shared client contracts and all consumers migrate together. Include regression fixtures and document safe host-transfer compatibility.
Verified: 6826 Flutter tests passed, 5 skipped; aggregate quality checks and final analyzer/formatting checks passed. All 86 owned paths match the isolated validated snapshot.
The floating mini-player could overlap horizontal system padding on mobile layouts.
Compose directional safe insets with navigation width without counting the rail twice. Preserve suspended-navigation, RTL, and desktop placement.
Verified by the complete Flutter suite: 6826 passed, 5 skipped. Aggregate quality checks passed in the isolated validation checkout.
Production playback still carried recurring Windows HDR probes, Android subtitle profiling, and verbose mpv logs with debug logging disabled.
Remove the Windows probe, pin the published mpv-build profiler cleanup, and honor Android debug logging preferences for video and music while preserving warnings and errors.
The native source-lifecycle test fails when HDR diagnostics emit a log during file load. Exclude diagnostic messages from lifecycle assertions and remove the incidental total-event count while retaining signed source IDs, queued-event ownership, and finite-position checks.
Snapped windows on secondary displays can restore with the primary monitor's taskbar offset. Normalize the saved screen rect using its own monitor inset and reverse that conversion when checking restored placement. Keep missing-monitor fallback coordinates in the creation monitor's workspace.
The Win32 API model changes four-cycle drift from plus or minus 192 pixels to zero. Single-monitor native fullscreen and quit-fullscreen/relaunch roundtrips passed; physical multi-monitor and mixed-DPI verification remains unavailable in the disconnected session.
Playback event bursts make HDR diagnostics query mpv and the display far more often than intended. Schedule samples and display reads from steady-clock completion deadlines, and read both source metadata streams from one node.
The x64 60 fps smoke reduced property sample runs from 21.7/s to 3.25/s. A generated per-frame HDR metadata stream still produced diagnostic changes; these are sampled changes, not per-frame LUT invalidation counts.
Jellyfin 12 ships with EnableLegacyAuthorization=false, which drops the
legacy `api_key=` query spelling (jellyfin/jellyfin#15559). Every
authenticated URL Plezy self-authenticates via the query string then
fails with 401: trickplay sprite sheets (visible as missing scrub
thumbnails), transcoding/subtitle/Live TV URLs built through
_withApiKey, and the library-event websocket (403 on upgrade).
`ApiKey=` is read unconditionally by Jellyfin 10.8 through 12, while
Emby only accepts `api_key=`, so the parameter name now comes from
MediaBrowserDialect.tokenQueryParam. Emby output is byte-identical.
Image URLs keep `api_key`: Jellyfin serves item images without
authentication, and the artwork cache keys strip that exact name.
Fixes#2247
A title's copy disappeared from a catalog item's library matches when the
server holding it went offline, and the screen then claimed the title was not
in the library at all.
The reverse-lookup fan-out only reaches online clients, so a registered
server that is offline lands in neither the succeeded, failed nor cancelled
set. The fold read that absence as "left the account" and dropped the
server's verified copies. Worse, the surviving wave looked complete, so it
was memoized for the rest of the profile session and never asked again. With
no server online at all, every server's copies were erased and the detail
screen asserted "Not in your library" over servers nobody had queried.
LibraryLookupResult now names the registered servers a wave could not even
reach, which needs a registered-server set on MultiServerManager because an
auth-rejected Plex server holds no client at all. The fold's rule is stated
positively -- only a server that answered may replace its own entry -- a wave
that skipped a server is never memoized past the TTL, and the detail screen
counts those servers as unchecked alongside the ones that failed.
A live library refresh or a sort change moved the highlight to a different
title, and Select then opened that one instead of the one the viewer chose.
Grid focus nodes are keyed by index while the cards are keyed by item, so a
merge that inserts an item before the focused slot leaves the highlight
pinned to the slot rather than the title: Flutter parks the node across the
rebuild and the replacement card re-attaches it. The library refresh already
compensated the scroll offset for that same index shift; focus is equally
index-pinned and was not compensated at all.
GridFocusNodeMixin.remapGridFocus carries the highlight with the item, and
the three grids that mutate content in place now use it: library browse, the
paginated card grid behind collections and playlists, and hub detail -- where
a user changing the sort was enough, no server push needed. Hub cards also
gain a key; they had none, so the element was silently updated with a
different item. The key is the global one, because aggregated hubs such as
Continue Watching can hold colliding per-server ids.
A promoted host started playing while other participants were still loading,
and could run at a different speed than the one it told the room.
The coordinator resolves the first epoch's readiness synchronously inside
attach, so seeding the known-peer roster afterwards had already missed it:
the fresh epoch saw an empty room, solo-started, and marked the first start
complete, after which the still-loading peers no longer gated anything. The
roster now seeds in _createCoordinator, so no coordinator exists without the
room it has to wait for.
Adopting the room's rate on promotion only set the value the coordinator
broadcasts. A guest that was paused when the rate changed never applied it to
its player -- position is aligned while stopped, rate is not -- so the new
host advertised one speed and ran another, and every guest kept correcting
against the difference. The host player is the room clock, so an adopted rate
is now applied to it.
A guest joining while the host handed the room to someone else could end up
following the host everyone else had just left behind.
join installs the client into the room and snapshots HostPeerID under
room.mu, then released the lock before enqueueing joined. The client is a
hostChanged recipient and a legal transfer target from the moment it is
installed, so a transfer committing in that gap enqueued the newer authority
first and the stale joined frame overwrote it on the client. transferHost
already enqueues while holding the lock for exactly this reason; joined
carries the same authority field and did not.
The admission frame is now built and enqueued while the room is still locked,
and enqueueFrame never blocks, so nothing waits on network I/O under the
lock. A beforeJoinRoomAck barrier alongside the two existing test hooks pins
the ordering: a transfer cannot commit while an admission is unpublished.
A host that handed the room to someone else and then lost its connection
could not get back in, and a guest promoted while it was offline came back
as a guest.
The relay names the host in every admission and every hostChanged, but the
client also kept its own _isHost flag beside that identity. Reconnect adopted
the relay's host id without recomputing the flag, so the two disagreed: a
demoted host still required the response to name itself and rejected its own
legitimate re-admission as an invalid response, retry after retry; a promoted
guest updated the session but left the transport a guest, so ending the
session sent leave instead of endSession and room re-creation stayed off.
The role is now derived from the host identity rather than stored next to it.
Before the relay has admitted us there is no authority yet, so the role is
the one we announced, which is what releasing a possibly-committed setup has
to go by. The identity assertion moves from mutable role state to message
semantics: a created response must name us, a joined response is adopted.
The detail page's audio/subtitle preview could contradict playback on Jellyfin and Emby: a server default of -1 (subtitles off) previewed as a subtitle track, and a missing default with a container-default subtitle previewed as on where playback plays none (#1779 again).
The preview rebuilt a MediaSourceInfo from the item's version by hand, a third mapping beside the two the backends use for playback, and had already diverged twice. It now runs the ladder over the MediaSourceInfo the backend maps for playback, fetched from the metadata cache in the existing probe, and the hand-built mapping is deleted.