Emby and Jellyfin Live TV could fail before playback when opening a cold tuner took longer than ten seconds. Use the existing thirty-second tune timeout only for live PlaybackInfo requests that open a source, preserving other request budgets and transport aborts.
Cover delayed initial tuning and recovery, bounded cancellation without replay, and unchanged VOD and metadata timeouts.
close#2274
Emby Live TV can fail to parse fMP4 HLS fragments, and retrying the same negotiated URL cannot change the segment container.
Restrict Emby Live TV negotiation to the existing MPEG-TS profile on initial tune and direct-play recovery. Preserve direct play, stream copy, Jellyfin negotiation, and Emby VOD. Cover transport scoping and recovery with protocol regressions.
close#2273
Closing the app during video playback left the backend session alive until it timed out. Await shared shutdown completion and the terminal video report before closing server clients, while keeping exit bounded when teardown stalls.
close#2275
Jellyfin and Emby sessions expose Client and DeviceName but no platform,
so dashboards and session trackers (Tracearr's normalizeClient, for one)
keyword-match the Client string the way they do for "Jellyfin Android TV"
and "Swiftfin tvOS". Plezy sent Client="Plezy" on every platform, so every
install showed up as platform "Plezy", and an unresolvable device name
became Device="Plezy" as well.
Add jellyfinClientName, which appends the platform ("Plezy Android TV",
"Plezy tvOS", "Plezy iOS", ...), and jellyfinDeviceName, which falls back
through the hardware model and the platform before the app name, and use
both at the two Jellyfin/Emby header call sites. Every emitted client
string was checked against Tracearr's matcher. The Plex headers and the
DisplayPreferences client key are unchanged.
Claude-Session: https://claude.ai/code/session_0134eXvhukgvG6Szg5NTwAim
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Watch Together could not reach relays when their URLs omitted a port because Dart passes port 0 to custom WebSocket connection factories.
Resolve port 0 to the HTTP/HTTPS default in the Happy Eyeballs factory while preserving explicit destination and proxy ports. Cover portless ws/wss connections with isolated regression tests.
close#2263
Permission changes could be confused with expired authentication, and delayed session persistence could cross a profile boundary.
Refresh raw authority after route denials and foreground resumes without login or request replay. Fence refreshes to the current binding, serialize persisted session ownership, and reconcile open request surfaces after grants and revocations.
Deleting a detail item or its ancestor could leave stale controls visible and allow pending actions to complete.
Make exact and complete bulk deletion terminal for the detail subtree, invalidate asynchronous continuations, and retain a localized unavailable state when the first route cannot pop. Normalize translation sources and regenerate the affected output.
Source reloads and role changes could reset room state or let obsolete player continuations change the current session.
Separate room authority from output bindings, fence selection and playback work with ownership leases, preserve room state across failed opens, and serialize native seek completion before newer intent.
Historical focus contexts kept detached grid nodes alive, while stale menu captures could queue focus after their row was gone.
Track actual attachment lifetime, preserve pending and remembered destinations, and restore captured focus only to a live eligible leaf without overriding a covering route.
Native-title library copies could be missed when only display and romaji titles reached the bounded search budget.
Prefer typed Japanese titles, preserve row and detail aliases, and keep matching within the existing four-query budget and external-ID verification rules.
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.
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.
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.
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.
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.
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 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.
A catalog item that had a copy on server A lost it from the detail page when a later refresh could not reach A: partial results expire after the negative TTL and the fresh wave replaced the cache as a unit, so A failing while B answered empty left nothing, with no evidence A had removed anything.
The cache now holds per-server answers with one invariant: a server's answer is replaced only by that server. Servers that failed or were cancelled keep their last-known copies, servers named in no set have left the account, and the failed set still passes through so the outage stays visible and the wave is retried.
A library notification connection that timed out could still complete later and stay open after the owner was disposed. IOWebSocketChannel.connect applies connectTimeout with Future.timeout and drops the pending connect, so nothing could ever close a socket that finished late.
The channel factory now resolves only to established channels: it owns WebSocket.connect, applies its own deadline, closes a late upgrade, and returns a channel built from the resolved socket. The socket class no longer holds half-open channels, which also removes the connected-flag workaround around the library's close semantics.
A guest whose connection dropped during a host transfer could not get back in. The relay broadcasts hostChanged only to connected peers, and the guest's reconnect rejected the re-admission because the host differed from the one it had pinned, then left the room.
The pin predates transfers. The relay is the authority on host identity and verifies the reconnect token, so a valid re-admission naming a different host can only be a transfer: the client now adopts it and surfaces it through the same onHostChanged path.
On the relay, hostChanged is enqueued while the room lock is still held. Two transfers in quick succession run on different connections, and enqueueing after unlock could deliver the older authority change after the newer one; enqueueFrame never blocks, so holding the lock across it is safe.
Transferring the room to a 2.18.0 client left it with no host: that build speaks the same sync protocol version but does not handle the relay's hostChanged broadcast, so eligibility passed, the relay moved authority, the current host stepped down and the target never stepped up. A 2.18.0 bystander kept following the demoted host.
Join messages now advertise capabilities beside the version (cap: [hostTransfer]). A transfer requires the target and every same-version bystander to advertise it; peers on another version are already outside the room's sync and do not count. A version bump would have excluded every older peer from mixed rooms for a feature they may never use.
An older relay rejects transferHost as invalid_message; while a transfer is pending that is now a failed transfer, not a session error.