Compare commits

...
Author SHA1 Message Date
edde746 82ae3609f6 chore: bump version to 2.19.0 2026-09-06 22:11:54 +02:00
edde746 32f72c6dd2 chore(deps): normalize lockfile git revision quoting 2026-09-06 22:08:01 +02:00
edde746 8c5d0b4213 fix(seerr): refresh authority without crossing session ownership
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.
2026-09-06 20:21:11 +02:00
edde746 3dd5b5d48e fix(details): invalidate deleted items and pending actions
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.
2026-09-06 20:21:11 +02:00
edde746 b6aa83af74 fix(watch-together): separate room authority from player bindings
Source reloads and role changes could reset room state or let obsolete player continuations change the current session.

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

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

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

Race address families independently, own connection tasks through upgrade handoff, and rearm exhausted push channels on a genuine foreground resume.
2026-09-06 20:21:11 +02:00
edde746 178634e943 fix(watch-together): authenticate retained room recovery
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.
2026-09-06 20:21:11 +02:00
edde746 99f4fda55a fix(android): retire mpv sessions without blocking hook callbacks 2026-09-06 19:11:25 +02:00
edde746 908ccbb233 fix(android): retain mpv callbacks and end dismissed TV input sessions
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.
2026-09-06 17:53:30 +02:00
edde746 2b176861b7 fix(mobile): keep landscape foreground controls clear of the trailing system inset
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.
2026-09-06 15:33:36 +02:00
edde746 647971a5d6 fix(seerr): follow permission grants and revocations on open request surfaces
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.
2026-09-06 15:25:49 +02:00
edde746 a18c54f5ce fix(logs): keep record boundaries in copied log selections
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.
2026-09-06 15:16:37 +02:00
edde746 c5ed666aa9 fix(navigation): leave a deleted detail through its own route, not whatever is on top
Deleting the last episode or season from a detail page popped the wrong route whenever something covered the detail: the download progress dialog (the deletion event fires before the delete returns), a player, a sheet fallback modal, or another detail. The cover disappeared and the empty detail stayed on screen.

The detail now leaves through the route it was pushed as: popped when it is current, removed from under the cover otherwise, with the same null result; a detail that is the navigator's only route stays, and a route already leaving is not touched twice. A delayed player launch checks that the route it was started from is still active before pushing, so a launch whose detail was deleted during its awaits does not land on an unrelated screen.
2026-09-06 15:06:08 +02:00
edde746 6580c4cf97 fix(library): credit a content push only when the reload it triggered lands
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.
2026-09-06 15:04:51 +02:00
edde746 3ca565baa9 fix(watch-together): adopt the room on promotion independently of the player, and fence old-role continuations
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.
2026-09-06 15:04:39 +02:00
edde746 bc1a715411 fix(linux): read the restart position off the GTK main thread
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.
2026-09-06 15:00:49 +02:00
edde746 c20a6cf162 fix(catalog): keep a library copy's label through a retry that lost its stamp
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.
2026-09-06 15:00:27 +02:00
edde746 ab1c9b9afd fix(tv): claim the whole Back press when the text-input host closes an IME session
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.
2026-09-06 15:00:27 +02:00
edde746 9ef3de985f fix(livetv): keep backed channel logos legible when adapting them to light surfaces
A light logo that carries its own dark ink — dark text on a white plate, white letters inside a dark outline — lost its contrast on light theme surfaces: the tone analysis granted the light-neutral remap to any mostly-light frame, and the remap then folded the plate and its ink into one dark tone.

A light majority no longer grants the remap. The analysis first decides whether the transformation is safe: on the sampled grid, four linear scans find neutral pixels enclosed on every side by the opposite neutral tone, and when most of either tone is enclosed the artwork is classified as backed and left untouched, like a dark disc already was. Dark ink that only touches the mark along an edge — an underline, a badge — stays open and keeps adapting, so accented and mixed-color marks remap as before. `LogoTone.dark` is renamed `backed` to name what the gate protects.
2026-09-06 15:00:09 +02:00
edde746 54438c8d7a test(android): drive fallback opens through an explicit native-command seam
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.
2026-09-06 14:53:05 +02:00
edde746 1d5b175cc8 fix(android): bind every libmpv JNI call and callback to its native session
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.
2026-09-06 14:50:51 +02:00
edde746 b80aa1916e chore(watch-together): regenerate the recent-rooms serializer with the pinned toolchain 2026-09-06 14:43:03 +02:00
edde746 5c40ba0505 build(android): pin mpv-build with the pending-vid property
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.
2026-09-06 13:49:41 +02:00
edde746 cbc47790c3 test(watch-together): lift the widget-test HTTP mock for loopback relay tests
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.
2026-09-06 13:33:39 +02:00
edde746 baa31742cb fix(player): bind live TV clock generations to the source the load reports
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.
2026-09-06 13:29:37 +02:00
edde746 01750ad92b fix(network): own a WebSocket connection attempt through cancellation and TLS
Cancelling a WebSocket connect (deadline, stop, dispose, a newer attempt)
only dropped or timed out a future: the shared never-closed HttpClient kept
the address race and TLS handshake alive, and once SecureSocket.secure had
detached the raw socket even a real task cancel was a no-op, so after
ClientHello the peer stayed open and the connect future never settled.

The happy-eyeballs task now owns the transport through the TLS handoff:
cancel is idempotent, settles the task at once, destroys an undelivered raw
winner, and destroys whatever a handshake in flight eventually yields. Each
WebSocket upgrade runs on its own HttpClient inside a connection attempt
with completion plus cancellation; the library-event, companion-remote and
Watch Together relay sockets cancel it on stop, disposal, deadline and
supersession. dart:io offers no handle to abort an in-flight handshake, so
a black-holed ClientHello closes when the handshake settles, not at cancel.
2026-09-06 13:28:51 +02:00
edde746 5de2d28166 fix(seerr): only a rejection Seerr itself emits may invalidate a session
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.
2026-09-06 13:24:52 +02:00
edde746 88e6d33221 fix(seerr): drop stale destination detail loads in the request sheet
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.
2026-09-06 13:24:52 +02:00
edde746 ac7fb87ade fix(android): route decode policy by the video track mpv will select
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.
2026-09-06 13:22:20 +02:00
edde746 ce3a3b177c fix(watch-together): keep the room position across a host promotion
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.
2026-09-06 13:22:01 +02:00
edde746 b795ef1604 fix(library): replay a push dropped under a route once the tab is visible again
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.
2026-09-06 13:17:40 +02:00
edde746 deeec6810d fix(library): never let merged library events narrow their refresh coverage
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.
2026-09-06 13:16:51 +02:00
edde746 d27cb61a2b fix(watch-together): re-authenticate a release the relay rejects for a changed role
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.
2026-09-06 13:16:34 +02:00
edde746 e660a06499 fix(watch-together): seed a fresh host's room rate from its saved speed
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.
2026-09-06 13:11:28 +02:00
edde746 2fb04a14ad fix(player): read the restart position without holding the Apple lifecycle lock
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.
2026-09-06 13:07:41 +02:00
edde746 c228a12d1e fix(media): preserve profile, source, and focus identity across refreshes
Library refreshes could move focus, catalog lookups could cross profile or query boundaries, and detail labels could describe a different source from playback.

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

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

Verified: 6826 Flutter tests passed, 5 skipped; aggregate quality checks and final analyzer/formatting checks passed. All 86 owned paths match the isolated validated snapshot.
2026-09-06 04:34:58 +02:00
edde746 d47bcdec12 fix(music): respect horizontal system insets in the mini-player
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.
2026-09-06 04:33:30 +02:00
edde746 bd9fb38413 fix(watch-together): preserve player ownership and enforce atomic host transfers 2026-09-06 04:03:14 +02:00
edde746 dca8029a99 fix(settings): normalize legacy skip modes across persistence boundaries 2026-09-06 03:54:43 +02:00
edde746 7aa1679036 fix(network): retain socket ownership through connection cancellation 2026-09-06 03:53:14 +02:00
edde746 9ae34473af build(apple): refresh pod locks for media-controls SwiftPM migration 2026-09-06 03:51:44 +02:00
edde746 3fb8794619 fix(player): dispatch newer live seeks after a failed reopen 2026-09-06 03:50:26 +02:00
edde746 98c6a09b41 chore(player): remove temporary native playback diagnostics
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.
2026-09-06 02:30:08 +02:00
edde746 621b50141c test(windows): decouple source lifecycle assertions from diagnostic logs
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.
2026-09-06 01:37:54 +02:00
edde746 02dc57bd55 fix(windows): restore snapped windows in their monitor workspace
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.
2026-09-06 01:37:54 +02:00
edde746 38f00f51c4 fix(windows): throttle HDR probing by elapsed time
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.
2026-09-06 01:37:54 +02:00
edde746 77422f2e6e style(android): format GL capability and surface helpers
Native formatting rejects the release baseline's GL capability and surface helpers. Apply the repository ktlint formatting without changing behavior.
2026-09-06 01:37:54 +02:00
AlbertandGitHub d2d73f35f8 fix(jellyfin): send the token as ApiKey so Jellyfin 12 accepts trickplay, subtitle and socket URLs (#2252)
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
2026-09-06 01:21:51 +02:00
edde746 d6f0aa8936 fix(catalog): keep library copies held by a server that was never asked
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.
2026-09-06 00:53:55 +02:00
edde746 e35f6b3a64 fix(navigation): keep the D-pad highlight on its title when content shifts
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.
2026-09-06 00:53:55 +02:00
edde746 80bc37eb34 fix(watch-together): gate a promoted host on its roster and its player rate
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.
2026-09-06 00:53:40 +02:00
edde746 0490de5b5b fix(relay): publish admission authority under the room lock
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.
2026-09-06 00:53:40 +02:00
edde746 f738e74994 fix(watch-together): derive the host role from the relay's authority
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.
2026-09-06 00:53:28 +02:00
edde746 65af57dd93 fix(playback): preview tracks from the same media source the player will use
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.
2026-09-05 23:14:12 +02:00
edde746 bc483a8206 fix(catalog): keep a server's verified library copies when it sits out a later lookup wave
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.
2026-09-05 23:14:11 +02:00
edde746 c0d090079b fix(library-events): close a websocket whose upgrade lands after the connect deadline
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.
2026-09-05 23:14:11 +02:00
edde746 6147736bba build(android): compile the MPV lifecycle device test only for the debug instrumentation variant
The minified instrumentation variant, which the R8 reachability gate builds, no longer compiled: MpvLifecycleDeviceTest references MpvLifecycleTestActivity, which exists only in the debug source set. The test moves to androidTestDebug so it is compiled only when instrumentation targets debug.
2026-09-05 23:14:11 +02:00
edde746 7885bea016 fix(watch-together): let a reconnecting guest adopt a host transfer it was offline for
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.
2026-09-05 23:14:11 +02:00
edde746 05598f1708 fix(watch-together): gate host transfer on a join capability instead of the sync version
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.
2026-09-05 23:14:11 +02:00
edde746 dc06b2e181 fix(watch-together): bound self-stall recovery by remaining media and a wait deadline
A host that stalled near the end of a file kept the whole room paused for good. Recovery demanded three times the stall in buffered headroom, and with ten seconds of media left a four-second stall asked for twelve that could never arrive; the known-cache branch re-checked every half second forever.

The two limits are now explicit: how much headroom to want (scaled by the stall, capped by what is left to buffer) and how long to wait for it (a deadline from the stall's end, shared with the no-cache branch). selfRecoveryMaxHoldMs was a headroom cap and is renamed to say so.
2026-09-05 23:14:11 +02:00
edde746 dbf8bf54c5 fix(tv): keep the Discover rail focus claim through an explicit sidebar handoff
Selecting Home from the sidebar on TV left focus on the collapsed sidebar item: the content appeared, but the remote went nowhere. MainScreen hands a tab over while focus still sits on the sidebar item that selected it, and the guard added to lapse stale rail-focus claims read that as the user having navigated away.

Where focus sits cannot tell a live handoff from a stale claim. What distinguishes a stale claim is that focus moved after it was armed, so the claim now records the primary focus at arm time and lapses only once focus has changed and rests off-screen. A handoff made while hubs are still loading is honored when they land; a claim armed on a bare scope still lapses when the user moves to the sidebar.
2026-09-05 23:14:11 +02:00
edde746 36e898067a fix(subtitles): honor Render Resolution on the Android mpv OSD plane
The subtitle "Render Resolution" setting was shown to every Android user
but only reached the ExoPlayer overlay; the mpv vo=mediacodec OSD plane
always rasterized at the full surface size. On a Fire TV Stick 4K Max
Gen 2 the edde746/plezy#2242 phone sign then cost up to 2 s of libass
render plus composite per frame at 1080p, showing late and lingering past
the cut.

Pass the fraction to the mpv core on initialize and give the OSD
SurfaceView a fixed buffer size below its view size; mpv rasterizes at
that size and the compositor scales the plane. At 1/2 the same sign
tracks its zoom at 12-24 updates/s and clears on the cut.
2026-09-05 23:12:28 +02:00
edde746 a475e92d7e fix(logs): let D-pad leave the app-bar back button on the logs screen
On TV, pressing Up on the Logs screen put focus on the back button and no
direction key could move it again, so the upload/copy actions were out of
reach (edde746/plezy#2242). The SelectionArea added in 8fe7e4bcf installs
always-enabled caret-movement actions that Android's default text-editing
shortcuts map plain arrow keys to; the keys were consumed as no-op
selection moves and never reached DirectionalFocusIntent.

Override those two intents above the region with an action that is
disabled for the collapsing (unshifted) variants and defers to the
region's own handler otherwise, so Shift+Arrow selection keeps working.
2026-09-05 23:12:28 +02:00
edde746 55985b8f9c build(player): bump mpv-build to 847fa34e8f9e 2026-09-05 21:47:39 +02:00
259 changed files with 21241 additions and 4254 deletions
+5
View File
@@ -135,6 +135,11 @@ sudo moss it plezy
### <img src="assets/readme_icons/watch-together.svg" height="20" alt="" align="center" /> Watch Together
- Synchronized playback with friends
- Real-time play / pause / seek sync
- Host handoff when the relay and every connected peer support safe transfers
- Automatic reconnect authenticates retained room membership; it never silently joins a reused room code.
Self-hosted relays must support `authenticatedResume` for recovery. Deploy the updated relay before updating clients.
Older relays still accept explicit create/join, but failed recovery requires joining or creating a room again.
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
- Discord Rich Presence[^desktop]
@@ -0,0 +1,84 @@
package com.edde746.plezy.mpv
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.edde746.plezy.shared.PlayerDelegate
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Exercises MPV's JNI reachability against the production-shrunk app with
* `-Pplezy.testBuildType=minified`. Native initialization resolves all nine static
* callback descriptors, so a stale or missing keep fails before initialization completes.
*
* Only calls the core APIs used by production. Referencing MpvPlayer's callbacks
* directly here would let the harness mask missing retention in the app APK.
*/
@RunWith(AndroidJUnit4::class)
class MpvPlayerReachabilityTest {
@Test
fun productionCoreInitializesDeliversNativeLogAndDisposes() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val core = AtomicReference<MpvPlayerCore>()
val initialized = CountDownLatch(1)
val initializationSucceeded = AtomicBoolean()
val commandCompleted = CountDownLatch(1)
val commandSucceeded = AtomicBoolean()
val nativeLogReceived = CountDownLatch(1)
instrumentation.runOnMainSync {
// Pass every constructor argument: production uses this constructor, not
// the default-argument bridge that R8 may legitimately remove.
core.set(MpvPlayerCore(instrumentation.targetContext, true, true, 1f, "v"))
core.get().delegate = object : PlayerDelegate {
override fun onPropertyChange(name: String, value: Any?) = Unit
override fun onEvent(name: String, data: Map<String, Any>?) {
if (name == "log-message" && data?.get("level") == "info" && data["text"] == LOG_MARKER) {
nativeLogReceived.countDown()
}
}
}
}
try {
instrumentation.runOnMainSync {
core.get().initialize {
initializationSucceeded.set(it)
initialized.countDown()
}
}
assertCompletes(initialized, "initialization")
assertTrue("Native MPV initialization failed", initializationSucceeded.get())
// initialize completes after the production flow collectors subscribe.
// This marker must cross the real native event thread and reach the delegate.
instrumentation.runOnMainSync {
core.get().command(arrayOf("print-text", LOG_MARKER)) {
commandSucceeded.set(it)
commandCompleted.countDown()
}
}
assertCompletes(commandCompleted, "print-text")
assertTrue("Native MPV print-text failed", commandSucceeded.get())
assertCompletes(nativeLogReceived, "native log callback")
} finally {
val disposed = CountDownLatch(1)
instrumentation.runOnMainSync { core.get().dispose(disposed::countDown) }
assertCompletes(disposed, "native teardown")
}
}
private fun assertCompletes(latch: CountDownLatch, operation: String) {
assertTrue("Timed out during $operation", latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
}
private companion object {
const val TIMEOUT_SECONDS = 15L
const val LOG_MARKER = "plezy-mpv-r8-native-log"
}
}
@@ -0,0 +1,146 @@
package com.edde746.plezy.mpv
import android.app.Instrumentation
import android.os.SystemClock
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.edde746.plezy.shared.PlayerDelegate
import java.io.File
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MpvLogLevelDeviceTest {
@Test
fun normalLoggingPreservesErrorsAndCanToggleVerboseOffAgain() = withCore { instrumentation, core, logs ->
assertLogPhase(instrumentation, core, logs, "normal", expectInfo = false)
setLogLevel(instrumentation, core, "v")
assertLogPhase(instrumentation, core, logs, "verbose", expectInfo = true)
setLogLevel(instrumentation, core, "warn")
assertLogPhase(instrumentation, core, logs, "normal-again", expectInfo = false)
}
@Test
fun verboseLoggingCanBeSelectedBeforeNativeInitialization() = withCore(initialLogLevel = "v") { instrumentation, core, logs ->
assertLogPhase(instrumentation, core, logs, "initial-verbose", expectInfo = true)
}
@Test
fun rejectedInitialLogLevelDoesNotBlockTheNextPlayer() {
withCore(initialLogLevel = "not-a-level", initializationSucceeds = false) { _, _, _ -> }
withCore { instrumentation, core, logs ->
assertLogPhase(instrumentation, core, logs, "after-rejected-init", expectInfo = false)
}
}
private fun withCore(
initialLogLevel: String = "warn",
initializationSucceeds: Boolean = true,
block: (Instrumentation, MpvPlayerCore, LinkedBlockingQueue<Pair<String, String>>) -> Unit
) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val logs = LinkedBlockingQueue<Pair<String, String>>()
val initialized = CountDownLatch(1)
val success = AtomicReference<Boolean>()
val core = AtomicReference<MpvPlayerCore>()
instrumentation.runOnMainSync {
core.set(MpvPlayerCore(instrumentation.targetContext, audioOnly = true, initialLogLevel = initialLogLevel))
core.get().delegate = object : PlayerDelegate {
override fun onPropertyChange(name: String, value: Any?) = Unit
override fun onEvent(name: String, data: Map<String, Any>?) {
if (name == "log-message") {
logs.add((data?.get("level") as? String ?: "") to (data?.get("text") as? String ?: ""))
}
}
}
core.get().initialize {
success.set(it)
initialized.countDown()
}
}
try {
assertCompletes(initialized, "initialization")
assertEquals("Native MPV initialization result", initializationSucceeds, success.get())
block(instrumentation, core.get(), logs)
} finally {
val disposed = CountDownLatch(1)
instrumentation.runOnMainSync { core.get().dispose(disposed::countDown) }
assertCompletes(disposed, "teardown")
}
}
private fun setLogLevel(instrumentation: Instrumentation, core: MpvPlayerCore, level: String) {
val completed = CountDownLatch(1)
val result = AtomicReference<Result<Unit>>()
instrumentation.runOnMainSync {
core.setLogLevel(level) {
result.set(it)
completed.countDown()
}
}
assertCompletes(completed, "setLogLevel($level)")
result.get().getOrThrow()
}
private fun command(instrumentation: Instrumentation, core: MpvPlayerCore, vararg args: String) {
val completed = CountDownLatch(1)
val success = AtomicReference<Boolean>()
instrumentation.runOnMainSync {
core.command(arrayOf(*args)) {
success.set(it)
completed.countDown()
}
}
assertCompletes(completed, args.first())
assertTrue("MPV command failed: ${args.first()}", success.get())
}
private fun assertLogPhase(
instrumentation: Instrumentation,
core: MpvPlayerCore,
logs: LinkedBlockingQueue<Pair<String, String>>,
phase: String,
expectInfo: Boolean
) {
val token = "mpv-log-$phase-${UUID.randomUUID()}"
val infoMarker = "$token-info"
val errorMarker = "$token-missing"
val missingFile = File(instrumentation.targetContext.cacheDir, errorMarker)
command(instrumentation, core, "print-text", infoMarker)
command(instrumentation, core, "loadfile", missingFile.absolutePath, "replace")
// The failed open is an error-level barrier in the same ordered log stream.
// Seeing it proves the preceding informational message was either delivered
// or filtered; no sleep is needed to assert that a quiet log stayed quiet.
val deadline = SystemClock.elapsedRealtime() + TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)
var sawInfo = false
while (true) {
val remaining = deadline - SystemClock.elapsedRealtime()
assertTrue("Missing native error log in $phase", remaining > 0)
val log = logs.poll(remaining, TimeUnit.MILLISECONDS)
assertTrue("Missing native error log in $phase", log != null)
if (log!!.second.contains(infoMarker)) {
assertEquals("info", log.first)
sawInfo = true
}
if (log.first == "error" && log.second.contains(errorMarker)) break
}
assertEquals("Informational log visibility in $phase", expectInfo, sawInfo)
}
private fun assertCompletes(latch: CountDownLatch, operation: String) {
assertTrue("Timed out during $operation", latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
}
private companion object {
const val TIMEOUT_SECONDS = 15L
}
}
@@ -48,6 +48,29 @@ internal object GpuVoPolicy {
*/
fun needsSoftwareDecode(codec: String?, codecProfile: String?, hardwareHigh10: Boolean): Boolean = !hardwareHigh10 && codec == "h264" && codecProfile?.startsWith("High 10") == true
/**
* The video track the per-file policies ([needsDvReshaping],
* [needsSoftwareDecode]) decide for, or null when no video track will be
* selected. They run inside on_preloaded, where the track list is complete
* but nothing is selected yet, so "the selected track" does not exist:
* `vid` still reads the option value, and mpv's own selection
* (default/forced flags, --vlang, attached pictures skipped) only runs
* after the hook. [vid] is the `vid` property: `no` and an explicit id are
* authoritative on their own; `auto` defers to [pendingVid], the fork's
* `pending-vid` property, which runs that selection ahead of time. A null
* [pendingVid] means the libmpv has no such property, and the first track
* in [videoTrackIds] (track-list order) is the best guess left — wrong for
* files whose first video track is not the one mpv picks.
*/
fun pendingVideoTrackId(vid: String?, pendingVid: String?, videoTrackIds: List<Long>): Long? {
val requested = when {
vid == null || vid == "auto" -> pendingVid ?: return videoTrackIds.firstOrNull()
else -> vid
}
val id = requested.toLongOrNull() ?: return null
return id.takeIf { it in videoTrackIds }
}
/**
* Whether a GL vo session should drop to the cheap render tier: bilinear
* scalers and no dither. Keyed on `GL_EXT_texture_norm16` being absent,
@@ -44,21 +44,40 @@ class MpvPlayerCore private constructor(
private val context: Context,
private val audioOnly: Boolean,
private val hardwareDecoding: Boolean,
/** Subtitle "Render Resolution" as a fraction of the OSD plane's view size; see [OsdPlanePolicy]. */
private val osdRenderScale: Float,
private val initialLogLevel: String,
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
/**
* Test seam standing in for the native player's command path: returns what
* [MpvPlayer.command] would (the playlist entry id of a `loadfile`, else
* null) or throws to reject the command. Null keeps the production rule
* that a command without a native player fails.
*/
private val commandRunnerOverride: (suspend (Array<String>) -> Long?)?,
initializedForTesting: Boolean
) : SurfaceHolder.Callback,
SurfacePlayerCore {
constructor(
context: Context,
audioOnly: Boolean = false,
hardwareDecoding: Boolean = true
) : this(context, audioOnly, hardwareDecoding, null, false)
hardwareDecoding: Boolean = true,
osdRenderScale: Float = 1f,
initialLogLevel: String = "warn"
) : this(context, audioOnly, hardwareDecoding, osdRenderScale, initialLogLevel, null, null, false)
internal constructor(
context: Context,
audioOnly: Boolean,
propertyWriter: (suspend (String, String) -> Unit)?
) : this(context, audioOnly, true, propertyWriter, true)
) : this(context, audioOnly, true, 1f, "warn", propertyWriter, null, true)
internal constructor(
context: Context,
audioOnly: Boolean,
propertyWriter: (suspend (String, String) -> Unit)?,
commandRunner: suspend (Array<String>) -> Long?
) : this(context, audioOnly, true, 1f, "warn", propertyWriter, commandRunner, true)
companion object {
private const val TAG = "MpvPlayerCore"
@@ -134,6 +153,9 @@ class MpvPlayerCore private constructor(
private val parkedHwdec = java.util.concurrent.atomic.AtomicReference<String?>()
/** Whether the missing `pending-vid` property was logged; hook-serial. */
private var pendingVidUnavailableLogged = false
/** mpv option -> the default it carried before the cheap render tier
* replaced it; empty while the tier is off. See [applyRenderTier]. */
private val cheapRenderRestore = LinkedHashMap<String, String>()
@@ -391,7 +413,7 @@ class MpvPlayerCore private constructor(
surfaceView = PlayerSurfaceHost.createVideoSurface(activity, this@MpvPlayerCore)
surfaceContainer!!.addView(surfaceView)
if (usesMediaCodecVo) {
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback)
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback, osdRenderScale)
surfaceContainer!!.addView(osdSurfaceView)
}
@@ -422,6 +444,7 @@ class MpvPlayerCore private constructor(
(context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.largeMemoryClass ?: 0
)
val p = MpvPlayer.create(context.applicationContext) {
setLogLevel(initialLogLevel)
if (audioOnly) {
// Pure audio core (all set before mpv_initialize, mirroring the
// Windows/Linux audio instances): vid=no keeps embedded cover
@@ -492,12 +515,15 @@ class MpvPlayerCore private constructor(
// Per-file decode routing runs inside mpv's on_preloaded hook:
// the demuxer has opened the file, no decoder exists yet, and
// mpv waits for the answer. file-loaded would be too late — the
// MediaCodec decoder is already created by then (#2065).
// MediaCodec decoder is already created by then (#2065). Nothing
// is selected yet either, so the track both policies decide for
// is resolved once here, from mpv's pending selection.
p.hookHandler = { name ->
if (name == "on_preloaded" && !disposing) {
withContext(mpvWriteDispatcher) {
applyDvReshapePolicy(p)
applySoftwareDecodePolicy(p)
val track = pendingVideoTrack(p)
applyDvReshapePolicy(p, track)
applySoftwareDecodePolicy(p, track)
}
}
}
@@ -841,12 +867,14 @@ class MpvPlayerCore private constructor(
/**
* Per-file Dolby Vision routing, decided from the bitstream: mpv exports
* the DOVI configuration record's profile on the track list (never trust
* server metadata for this — it mis-tags DV routinely). Re-evaluated on
* every file-loaded, so a following non-P5 file restores hardware decode
* and returns to the video plane.
* server metadata for this — it mis-tags DV routinely; mpv omits the
* field when the bitstream carries no record). Re-evaluated on every
* file, so a following non-P5 file restores hardware decode and returns
* to the video plane. [track] is the pending video track, see
* [pendingVideoTrack].
*/
private suspend fun applyDvReshapePolicy(p: MpvPlayer) {
val profile = selectedVideoDvProfile(p)
private suspend fun applyDvReshapePolicy(p: MpvPlayer, track: org.json.JSONObject?) {
val profile = track?.takeIf { it.has("dolby-vision-profile") }?.getLong("dolby-vision-profile")
val needs = GpuVoPolicy.needsDvReshaping(
dvProfile = profile,
conversionMode = currentDvConversionMode,
@@ -865,9 +893,9 @@ class MpvPlayerCore private constructor(
* The profile comes from the container's avcC record (fork patch), so it
* is known inside on_preloaded. Distinct from [GpuVoPolicy.REASON_SW_DECODE]:
* that one follows `hwdec-current`, which is still blank at this point.
* [track] is the pending video track, see [pendingVideoTrack].
*/
private suspend fun applySoftwareDecodePolicy(p: MpvPlayer) {
val track = videoTracks(p).firstOrNull()
private suspend fun applySoftwareDecodePolicy(p: MpvPlayer, track: org.json.JSONObject?) {
val codec = track?.optString("codec")
val codecProfile = track?.optString("codec-profile")
val hardwareHigh10 = MediaCodecQuery.hardwareAvcHigh10Support()
@@ -948,13 +976,29 @@ class MpvPlayerCore private constructor(
}
/**
* Selected video track's Dolby Vision profile, or null for non-DV content
* (mpv omits the field when the bitstream carries no DOVI configuration
* record).
* The video track mpv is about to select, resolved inside on_preloaded
* where nothing is selected yet. `vid=no` and an explicit `vid=N` answer
* on their own (the hook never re-selects: an explicit choice stays the
* user's); `auto` asks the fork's `pending-vid`, which runs mpv's own
* default selection ahead of time. Without that property (a libmpv
* predating the fork patch) the first track is the only guess left; the
* gap is logged once so a wrong policy on a multi-video file is traceable.
* Decision in [GpuVoPolicy.pendingVideoTrackId].
*/
private suspend fun selectedVideoDvProfile(p: MpvPlayer): Long? = videoTracks(p).firstOrNull()
?.takeIf { it.has("dolby-vision-profile") }
?.getLong("dolby-vision-profile")
private suspend fun pendingVideoTrack(p: MpvPlayer): org.json.JSONObject? {
val tracks = videoTracks(p)
if (tracks.isEmpty()) return null
val vid = p.getString("vid")
val auto = vid == null || vid == "auto"
val pendingVid = if (auto) p.getString("pending-vid") else null
if (auto && pendingVid == null && !pendingVidUnavailableLogged) {
pendingVidUnavailableLogged = true
Log.w(TAG, "libmpv has no pending-vid property; decode routing assumes the first video track")
}
val id = GpuVoPolicy.pendingVideoTrackId(vid, pendingVid, tracks.map { it.optLong("id") })
Log.d(TAG, "Pending video track: vid=$vid pending-vid=$pendingVid -> ${id ?: "none"} of ${tracks.size}")
return tracks.firstOrNull { it.optLong("id") == id }
}
/**
* Observed rather than derived from the hardware-decoding setting because
@@ -1556,6 +1600,35 @@ class MpvPlayerCore private constructor(
}
}
fun setLogLevel(level: String, onComplete: (Result<Unit>) -> Unit) {
if (!isInitialized || disposing || !scope.isActive) {
onComplete(Result.failure(CancellationException("MPV core unavailable")))
return
}
// ATOMIC starts even if dispose cancels a queued write, so its channel
// result is completed; ensureActive prevents that write reaching JNI.
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val outcome = try {
ensureActive()
val p = player ?: throw CancellationException("MPV player unavailable")
p.setLogLevel(level)
Result.success(Unit)
} catch (error: Exception) {
Result.failure(error)
}
withContext(NonCancellable + Dispatchers.Main) {
onComplete(
if (disposing || !isInitialized) {
Result.failure(CancellationException("MPV core unavailable"))
} else {
outcome
}
)
}
}
}
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
@@ -1823,21 +1896,34 @@ class MpvPlayerCore private constructor(
}
fun command(args: Array<String>, onComplete: ((Boolean) -> Unit)? = null) {
commandForSource(args) { onComplete?.invoke(it.isSuccess) }
}
/**
* Runs an mpv command on the ordered writer. Completes on the main thread with the playlist
* entry id a `loadfile` created (null for every other command), or with the failure mpv
* reported — a rejected load never starts a source, so it must not be reported as one.
*/
fun commandForSource(args: Array<String>, onComplete: (Result<Long?>) -> Unit) {
if (!isInitialized || disposing || args.isEmpty() || !scope.isActive) {
onComplete?.invoke(false)
onComplete(Result.failure(IllegalStateException("MPV player unavailable")))
return
}
scope.launch(mpvWriteDispatcher) {
var success = false
try {
player?.command(*args)
success = true
val outcome: Result<Long?> = try {
val runner = commandRunnerOverride
if (runner != null) {
Result.success(runner(args))
} else {
val p = player ?: throw IllegalStateException("MPV player unavailable")
Result.success(p.command(*args))
}
} catch (e: Exception) {
Log.w(TAG, "command failed", e)
} finally {
withContext(NonCancellable + Dispatchers.Main) {
onComplete?.invoke(success)
}
Result.failure(e)
}
withContext(NonCancellable + Dispatchers.Main) {
onComplete(outcome)
}
}
}
@@ -206,6 +206,11 @@ open class MpvPlayerPlugin(
// (MpvPlayerCore.initialVideoOutput). Absent on the audio-only core and
// from older callers; hardware decode is the setting's default.
val hardwareDecoding = call.argument<Boolean>("hardwareDecoding") ?: true
// Subtitle "Render Resolution" for the vo=mediacodec OSD plane (Full / ¾ / ½ /
// ⅓ / ¼ of the surface); the same fraction the ExoPlayer overlay applies.
// Absent from older callers and the audio-only core; full is the default.
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1f
val logLevel = call.argument<String>("logLevel") ?: "warn"
// Video cores need the Activity (surface/view hierarchy); the audio-only
// core is built on the application context so it can outlive it.
val coreContext: Context? = if (audioOnly) applicationContext else activity
@@ -264,7 +269,7 @@ open class MpvPlayerPlugin(
}
gen = ++sessionGeneration
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding).apply {
core = MpvPlayerCore(coreContext, audioOnly, hardwareDecoding, subtitleRenderScale, logLevel).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
@@ -469,25 +474,42 @@ open class MpvPlayerPlugin(
result.error("NOT_INITIALIZED", "Player not initialized", null)
return
}
core.command(args.toTypedArray()) { success ->
if (success) {
result.success(null)
} else {
result.error("COMMAND_FAILED", "mpv command failed", args)
}
// `loadfile` answers with the playlist entry it created so Dart can tie
// the load to that source's start-file/playback-restart/end-file events;
// every other command answers null.
core.commandForSource(args.toTypedArray()) { outcome ->
outcome.fold(
onSuccess = { playlistEntryId ->
result.success(playlistEntryId?.let { mapOf("playlistEntryId" to it) })
},
onFailure = { error ->
result.error("COMMAND_FAILED", error.message ?: "mpv command failed", args)
}
)
}
}
private fun handleSetLogLevel(call: MethodCall, result: MethodChannel.Result) {
if (call.argument<String>("level") == null) {
result.error("INVALID_ARGS", "Missing 'level'", null)
val level = call.argument<Any>("level") as? String
if (level == null) {
result.error("INVALID_ARGS", "Missing or invalid 'level'", null)
return
}
result.error(
"UNSUPPORTED",
"Runtime mpv log level changes are not supported on Android",
null
)
val core = playerCore
if (core?.isInitialized != true) {
completeMpvPropertyNotInitialized(result)
return
}
core.setLogLevel(level) { outcome ->
when (val failure = outcome.exceptionOrNull()) {
null -> result.success(null)
is CancellationException -> completeMpvPropertyNotInitialized(result)
else -> {
Log.w(tag, "MPV rejected log level change", failure)
result.error("SET_LOG_LEVEL_FAILED", "MPV log level change was rejected", null)
}
}
}
}
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
@@ -0,0 +1,41 @@
package com.edde746.plezy.mpv
import kotlin.math.roundToInt
/**
* Pure geometry for the `vo=mediacodec` subtitle/OSD plane.
*
* mpv rasterizes subtitles at whatever size the OSD Surface reports, so the
* "Render Resolution" setting is applied by giving the OSD SurfaceView a
* fixed buffer size below its view size; SurfaceFlinger scales the plane back
* up. Every stage of the OSD pass (libass rasterization, the software
* composite, the Surface copy) is proportional to that pixel count, which is
* what makes the setting a throughput knob on render-bound TV hardware. The
* ExoPlayer path applies the same fraction to its libass overlay.
*/
internal object OsdPlanePolicy {
data class Size(val width: Int, val height: Int)
/**
* Fixed buffer size for an OSD plane whose view measures [viewWidth] x
* [viewHeight], or null when the plane should track the view (full
* resolution, or a view that has not been laid out yet).
*
* Dimensions are rounded to even values: the 4:2:0-derived alignment
* assumptions in the compositor stack are cheap to honor here and an odd
* OSD width buys nothing.
*/
fun fixedSizeFor(viewWidth: Int, viewHeight: Int, renderScale: Float): Size? {
if (viewWidth <= 0 || viewHeight <= 0) return null
if (!(renderScale > 0f) || renderScale >= 1f) return null
return Size(
width = scaledEven(viewWidth, renderScale),
height = scaledEven(viewHeight, renderScale)
)
}
private fun scaledEven(value: Int, scale: Float): Int {
val scaled = (value * scale).roundToInt().coerceAtLeast(2)
return scaled and 1.inv()
}
}
@@ -71,11 +71,18 @@ internal object GlCapabilities {
if (!EGL14.eglChooseConfig(display, configAttributes, 0, configs, 0, 1, count, 0) || count[0] < 1) return null
val config = configs[0] ?: return null
surface = EGL14.eglCreatePbufferSurface(
display, config, intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE), 0
display,
config,
intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE),
0
)
if (surface == EGL14.EGL_NO_SURFACE) return null
context = EGL14.eglCreateContext(
display, config, EGL14.EGL_NO_CONTEXT, intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE), 0
display,
config,
EGL14.EGL_NO_CONTEXT,
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE),
0
)
if (context == EGL14.EGL_NO_CONTEXT) return null
if (!EGL14.eglMakeCurrent(display, surface, surface, context)) return null
@@ -7,6 +7,7 @@ import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
import android.widget.FrameLayout
import com.edde746.plezy.mpv.OsdPlanePolicy
/** Shared Android view scaffold beneath the ExoPlayer and mpv cores. */
internal object PlayerSurfaceHost {
@@ -63,8 +64,13 @@ internal object PlayerSurfaceHost {
* Transparent plane directly above the video surface for the mpv
* `vo=mediacodec` subtitle/OSD output. Media-overlay z-order keeps it above
* the video SurfaceView but still beneath the Flutter window content.
*
* [renderScale] < 1 gives the plane a fixed buffer size below its view size
* (see [OsdPlanePolicy]); mpv then rasterizes at that size and the
* compositor scales the plane to the view. Re-applied on every layout so a
* container resize keeps the ratio.
*/
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback, renderScale: Float = 1f): SurfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
@@ -74,6 +80,13 @@ internal object PlayerSurfaceHost {
setZOrderOnTop(false)
setZOrderMediaOverlay(true)
FlutterOverlayHelper.applyCompositionOrder(this, -1)
if (renderScale < 1f) {
addOnLayoutChangeListener { view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
if (right - left == oldRight - oldLeft && bottom - top == oldBottom - oldTop) return@addOnLayoutChangeListener
val size = OsdPlanePolicy.fixedSizeFor(right - left, bottom - top, renderScale) ?: return@addOnLayoutChangeListener
(view as SurfaceView).holder.setFixedSize(size.width, size.height)
}
}
}
fun attachToContent(activity: Activity, container: FrameLayout): ViewGroup {
+9
View File
@@ -18,3 +18,12 @@ add_executable(mpv_utf8_convert_test mpv_utf8_convert_test.cpp)
target_compile_features(mpv_utf8_convert_test PRIVATE cxx_std_17)
add_test(NAME mpv_utf8_convert_test COMMAND mpv_utf8_convert_test)
find_package(Threads REQUIRED)
add_executable(mpv_lifecycle_test mpv_lifecycle_test.cpp)
target_compile_features(mpv_lifecycle_test PRIVATE cxx_std_17)
target_include_directories(mpv_lifecycle_test PRIVATE fakes ../../../../libmpv/src/main/cpp/include)
target_link_libraries(mpv_lifecycle_test PRIVATE Threads::Threads)
add_test(NAME mpv_lifecycle_test COMMAND mpv_lifecycle_test)
set_tests_properties(mpv_lifecycle_test PROPERTIES TIMEOUT 30)
@@ -2,6 +2,7 @@
#define ANDROID_LOG_INFO 4
#define ANDROID_LOG_WARN 5
#define ANDROID_LOG_ERROR 6
#ifdef __cplusplus
extern "C" {
+47
View File
@@ -1,5 +1,6 @@
#pragma once
#include <cstdarg>
#include <cstdint>
#include <cstring>
#include <string>
@@ -14,6 +15,10 @@ using jboolean = uint8_t;
using jbyte = int8_t;
using jint = int32_t;
using jsize = jint;
using jlong = int64_t;
using jdouble = double;
using jobject = void*;
using jmethodID = void*;
struct _jclass {};
using jclass = _jclass*;
@@ -28,11 +33,53 @@ struct _jstring {
};
using jstring = _jstring*;
struct _jobjectArray {
std::vector<jobject> objects;
};
using jobjectArray = _jobjectArray*;
class JavaVM {
public:
void (*on_detach)() = nullptr;
jint DetachCurrentThread() {
if (on_detach) on_detach();
return 0;
}
};
class JNIEnv {
public:
bool exception_pending = false;
bool fail_next_write = false;
JavaVM* vm = nullptr;
jobject (*on_new_global_ref)(jobject) = nullptr;
void (*on_delete_global_ref)(jobject) = nullptr;
void (*on_static_void_method)(jmethodID, va_list) = nullptr;
jint GetJavaVM(JavaVM** result) {
*result = vm;
return 0;
}
jobject NewGlobalRef(jobject object) { return on_new_global_ref ? on_new_global_ref(object) : object; }
void DeleteGlobalRef(jobject object) {
if (on_delete_global_ref) on_delete_global_ref(object);
}
void DeleteLocalRef(jobject) {}
void CallStaticVoidMethod(jclass, jmethodID method, ...) {
va_list args;
va_start(args, method);
if (on_static_void_method) on_static_void_method(method, args);
va_end(args);
}
jsize GetArrayLength(jobjectArray array) { return static_cast<jsize>(array->objects.size()); }
jobject GetObjectArrayElement(jobjectArray array, jsize index) { return array->objects.at(index); }
jsize GetArrayLength(jbyteArray array) { return static_cast<jsize>(array->bytes.size()); }
void GetByteArrayRegion(jbyteArray array, jsize offset, jsize length, jbyte* destination) {
@@ -0,0 +1,521 @@
#include <pthread.h>
#include <cerrno>
#include <chrono>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
static int tracked_mutex_lock(pthread_mutex_t* mutex);
static int tracked_write_lock(pthread_rwlock_t* lock);
static int controlled_thread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*entry)(void*), void* arg);
// Compile the real JNI entries, admission guards, event loop and surface
// cleanup. Only external dependencies are controlled; no copy of the lock
// algorithm or direct assignment to production lifecycle globals is used.
#define UTIL_EXTERN
#define pthread_mutex_lock tracked_mutex_lock
#define pthread_rwlock_wrlock tracked_write_lock
#define pthread_create controlled_thread_create
// Android's two-argument thread naming API is not available on macOS.
#define pthread_setname_np(thread, name) ((void)0)
#include "../../../../libmpv/src/main/cpp/main.cpp"
#undef pthread_setname_np
#undef pthread_create
#undef pthread_rwlock_wrlock
#undef pthread_mutex_lock
#include "../../../../libmpv/src/main/cpp/event.cpp"
#include "../../../../libmpv/src/main/cpp/render.cpp"
struct mpv_handle {
bool initialized = false;
bool terminated = false;
bool event_started = false;
bool event_exited = false;
bool woken = false;
bool hook_pending = false;
bool reject_hook_during_destroy = false;
bool callback_entered = false;
bool callback_returned = false;
bool command_active = false;
bool termination_entered = false;
int initialize_result = 0;
int hook_continuations = 0;
int commands = 0;
jlong callback_session = 0;
jobject video = nullptr;
jobject osd = nullptr;
mpv_event_hook hook{"on_preloaded", 17};
mpv_event event{};
};
namespace {
std::mutex gate;
std::condition_variable changed;
JavaVM vm;
JNIEnv jni;
int app_context;
mpv_handle* active_handle = nullptr;
// Retain fake allocations after termination so any erroneous late MPV access
// fails explicitly rather than depending on allocator reuse or undefined UAF.
std::vector<std::unique_ptr<mpv_handle>> handles;
std::vector<std::unique_ptr<_jstring>> callback_strings;
std::map<jobject, int> global_refs;
mpv_handle* held_termination = nullptr;
bool allow_termination = false;
bool allow_command = false;
bool successor_waiting = false;
bool reader_draining = false;
bool fail_thread_create = false;
thread_local mpv_handle* event_handle = nullptr;
enum class Operation { ordinary, successor, replacing_reader };
thread_local Operation operation = Operation::ordinary;
void require(bool condition, const char* message) {
if (condition) return;
std::fprintf(stderr, "%s\n", message);
std::abort();
}
template <typename Predicate>
void await(std::unique_lock<std::mutex>& lock, Predicate predicate, const char* message) {
require(changed.wait_for(lock, std::chrono::seconds(5), predicate), message);
}
void require_live(mpv_handle* handle) {
require(handle && handle == active_handle && !handle->terminated, "MPV accessed outside its native lifetime");
}
void require_surfaces(mpv_handle* handle) {
if (handle->video) require(global_refs[handle->video] == 1, "video Surface released before termination completed");
if (handle->osd) require(global_refs[handle->osd] == 1, "OSD Surface released before termination completed");
}
jobject new_global_ref(jobject object) {
std::lock_guard<std::mutex> lock(gate);
if (object) ++global_refs[object];
return object;
}
void delete_global_ref(jobject object) {
if (!object) return;
std::lock_guard<std::mutex> lock(gate);
require(global_refs[object] > 0, "JNI global reference released more than once");
if (active_handle && (active_handle->video == object || active_handle->osd == object)) {
require(active_handle->terminated, "Surface cleanup preceded native termination");
}
--global_refs[object];
}
void detach_event_thread() {
std::lock_guard<std::mutex> lock(gate);
require_live(event_handle);
event_handle->event_exited = true;
changed.notify_all();
}
void on_static_void_method(jmethodID method, va_list args) {
require(method == mpv_MpvPlayer_onHook, "unexpected callback in lifecycle scenario");
const jlong session = va_arg(args, jlong);
const jstring name = va_arg(args, jstring);
const jlong hook = va_arg(args, jlong);
require(name->value == "on_preloaded", "wrong hook callback delivered");
{
std::unique_lock<std::mutex> lock(gate);
require_live(event_handle);
event_handle->callback_session = session;
event_handle->callback_entered = true;
changed.notify_all();
if (event_handle->reject_hook_during_destroy) {
// The callback is already inside Java when teardown starts. Kotlin's
// rejected-channel path continues synchronously on this event thread.
await(lock, [] { return event_handle->woken; }, "destroy never woke the overlapping hook callback");
}
}
jni_func_name(nativeHookContinue)(&jni, nullptr, session, hook);
{
std::lock_guard<std::mutex> lock(gate);
event_handle->callback_returned = true;
changed.notify_all();
}
}
jlong create_player() {
const jlong session = jni_func_name(nativeCreate)(&jni, nullptr, &app_context);
require(session > 0, "nativeCreate failed");
return session;
}
jlong command(jlong session, const char* name = "play") {
_jstring argument{name};
_jobjectArray arguments{{&argument}};
return jni_func_name(nativeCommand)(&jni, nullptr, session, &arguments);
}
void initialize_player(jlong session) {
require(jni_func_name(nativeInit)(&jni, nullptr, session) == 0, "nativeInit failed");
}
void destroy_player(jlong session) { jni_func_name(nativeDestroy)(&jni, nullptr, session); }
void reset_dependencies() {
std::lock_guard<std::mutex> lock(gate);
require(active_handle == nullptr, "previous test left a live MPV instance");
for (const auto& entry : global_refs) {
require(entry.first == &app_context || entry.second == 0, "terminal Surface cleanup leaked a global reference");
}
handles.clear();
callback_strings.clear();
global_refs.clear();
held_termination = nullptr;
allow_termination = false;
allow_command = false;
successor_waiting = false;
reader_draining = false;
fail_thread_create = false;
jni.exception_pending = false;
}
void rejected_hook_and_successor_retirement() {
reset_dependencies();
const jlong old_session = create_player();
mpv_handle* old = active_handle;
int video, osd;
jni_func_name(nativeAttachSurface)(&jni, nullptr, old_session, &video);
jni_func_name(nativeAttachOsdSurface)(&jni, nullptr, old_session, &osd);
{
std::lock_guard<std::mutex> lock(gate);
old->hook_pending = true;
old->reject_hook_during_destroy = true;
held_termination = old;
}
initialize_player(old_session);
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [&] { return old->callback_entered; }, "event loop did not deliver the hook");
}
std::thread retiring([&] { destroy_player(old_session); });
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [&] { return old->termination_entered; }, "retirement deadlocked with the synchronous hook callback");
require(old->callback_returned && old->event_exited, "termination preceded event-thread completion");
require(old->callback_session == old_session, "retiring callback lost its bound session");
require(old->hook_continuations == 0, "revoked hook reached the retiring MPV handle");
require_surfaces(old);
}
jlong successor_session = 0;
std::thread creating([&] {
operation = Operation::successor;
successor_session = create_player();
});
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [] { return successor_waiting; }, "successor did not wait for terminal retirement");
}
// S must be available while termination holds L. These calls must reject
// without touching the retiring core, surfaces, or a future successor.
require(command(old_session) == MPV_ERROR_UNINITIALIZED, "revoked command was admitted during termination");
jni_func_name(nativeHookContinue)(&jni, nullptr, old_session, old->hook.id);
jni_func_name(nativeDetachSurface)(&jni, nullptr, old_session);
jni_func_name(nativeDetachOsdSurface)(&jni, nullptr, old_session);
{
std::lock_guard<std::mutex> lock(gate);
require_surfaces(old);
allow_termination = true;
changed.notify_all();
}
retiring.join();
creating.join();
require(successor_session > old_session, "successor did not receive a new session");
mpv_handle* successor = active_handle;
{
std::lock_guard<std::mutex> lock(gate);
successor->hook_pending = true;
}
jni_func_name(nativeAttachSurface)(&jni, nullptr, successor_session, &video);
jni_func_name(nativeAttachOsdSurface)(&jni, nullptr, successor_session, &osd);
initialize_player(successor_session);
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [&] { return successor->callback_returned; }, "successor event loop did not serve its own hook");
require(successor->callback_session == successor_session, "successor inherited the old event binding");
require(successor->hook_continuations == 1, "live hook was not continued exactly once");
}
destroy_player(old_session);
require(
jni_func_name(nativeInit)(&jni, nullptr, old_session) == MPV_ERROR_UNINITIALIZED, "stale init reached successor");
require(command(old_session) == MPV_ERROR_UNINITIALIZED, "stale command reached successor");
jni_func_name(nativeHookContinue)(&jni, nullptr, old_session, old->hook.id);
jni_func_name(nativeDetachSurface)(&jni, nullptr, old_session);
jni_func_name(nativeDetachOsdSurface)(&jni, nullptr, old_session);
require(command(successor_session) == 0, "stale teardown retired the successor");
{
std::lock_guard<std::mutex> lock(gate);
require_surfaces(successor);
require(successor->hook_continuations == 1, "old hook id was forwarded to successor");
}
destroy_player(successor_session);
}
void admitted_command_survives_replacement() {
reset_dependencies();
const jlong old_session = create_player();
mpv_handle* old = active_handle;
initialize_player(old_session);
jlong result = MPV_ERROR_GENERIC;
std::thread reader([&] { result = command(old_session, "hold"); });
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [&] { return old->command_active; }, "command was not admitted");
}
jlong successor_session = 0;
std::thread replacing([&] {
operation = Operation::replacing_reader;
successor_session = create_player();
});
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [] { return reader_draining; }, "replacement did not drain the admitted command");
require(!old->termination_entered && !old->woken, "retirement overtook an admitted command");
allow_command = true;
changed.notify_all();
}
reader.join();
replacing.join();
require(result == 0 && old->commands == 1, "admitted command lost its handle before returning");
require(command(old_session) == MPV_ERROR_UNINITIALIZED, "late old-session command was admitted");
destroy_player(old_session);
initialize_player(successor_session);
require(command(successor_session) == 0, "replacement was affected by stale owner traffic");
destroy_player(successor_session);
}
void partial_initialization_can_retire() {
enum class Failure { configuration, initialization, thread_start };
for (Failure failure : {Failure::configuration, Failure::initialization, Failure::thread_start}) {
reset_dependencies();
const jlong session = create_player();
mpv_handle* failed = active_handle;
if (failure == Failure::configuration) {
_jstring invalid{"not-a-level"};
require(
jni_func_name(nativeSetLogLevel)(&jni, nullptr, session, &invalid) < 0, "invalid configuration succeeded");
} else {
failed->initialize_result = failure == Failure::initialization ? MPV_ERROR_GENERIC : 0;
fail_thread_create = failure == Failure::thread_start;
require(jni_func_name(nativeInit)(&jni, nullptr, session) < 0, "injected initialization failure was lost");
if (failure == Failure::thread_start)
require(jni.ExceptionCheck(), "thread-start failure lost its JNI exception");
}
destroy_player(session);
require(
failed->terminated && !failed->event_started && !failed->woken, "partial init used an unstarted event thread");
require(command(session) == MPV_ERROR_UNINITIALIZED, "failed session retained public admission");
jni.exception_pending = false;
const jlong successor = create_player();
initialize_player(successor);
require(command(successor) == 0, "partial init prevented the next player from working");
destroy_player(successor);
}
}
} // namespace
// Observe real contention to arrange overlap without sleeps or a guessed
// scheduling delay. The production guards still acquire the real pthread
// locks; the notification only lets the test release its external MPV gates.
static int tracked_mutex_lock(pthread_mutex_t* mutex) {
if (operation != Operation::successor) return pthread_mutex_lock(mutex);
const int result = pthread_mutex_trylock(mutex);
if (result != EBUSY) return result;
{
std::lock_guard<std::mutex> lock(gate);
successor_waiting = true;
changed.notify_all();
}
return pthread_mutex_lock(mutex);
}
static int tracked_write_lock(pthread_rwlock_t* lock) {
if (operation != Operation::replacing_reader) return pthread_rwlock_wrlock(lock);
const int result = pthread_rwlock_trywrlock(lock);
if (result != EBUSY) return result;
{
std::lock_guard<std::mutex> guard(gate);
reader_draining = true;
changed.notify_all();
}
return pthread_rwlock_wrlock(lock);
}
static int controlled_thread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*entry)(void*), void* arg) {
std::lock_guard<std::mutex> lock(gate);
if (fail_thread_create) {
fail_thread_create = false;
return EAGAIN;
}
const int result = pthread_create(thread, attr, entry, arg);
if (result == 0) active_handle->event_started = true;
return result;
}
extern "C" mpv_handle* mpv_create() {
std::lock_guard<std::mutex> lock(gate);
require(active_handle == nullptr, "successor MPV creation overlapped predecessor termination");
for (const auto& entry : global_refs) {
require(entry.first == &app_context || entry.second == 0, "successor creation preceded retiring Surface cleanup");
}
handles.push_back(std::make_unique<mpv_handle>());
active_handle = handles.back().get();
return active_handle;
}
extern "C" int mpv_initialize(mpv_handle* handle) {
std::lock_guard<std::mutex> lock(gate);
require_live(handle);
if (handle->initialize_result < 0) return handle->initialize_result;
handle->initialized = true;
return 0;
}
extern "C" void mpv_wakeup(mpv_handle* handle) {
std::lock_guard<std::mutex> lock(gate);
require_live(handle);
require(!handle->command_active, "wake overtook an admitted command");
require(handle->event_started, "wake targeted an unstarted event thread");
handle->woken = true;
changed.notify_all();
}
extern "C" void mpv_terminate_destroy(mpv_handle* handle) {
std::unique_lock<std::mutex> lock(gate);
require_live(handle);
require(!handle->command_active, "termination overtook an admitted command");
require(!handle->event_started || handle->event_exited, "termination overtook the bound event thread");
require_surfaces(handle);
handle->termination_entered = true;
changed.notify_all();
if (handle == held_termination)
await(lock, [] { return allow_termination; }, "test did not release native termination");
require_surfaces(handle);
handle->terminated = true;
active_handle = nullptr;
}
extern "C" mpv_event* mpv_wait_event(mpv_handle* handle, double) {
std::unique_lock<std::mutex> lock(gate);
event_handle = handle;
require_live(handle);
await(lock, [&] { return handle->hook_pending || handle->woken; }, "event loop was not woken for teardown");
handle->event = {};
if (handle->hook_pending) {
handle->hook_pending = false;
handle->event.event_id = MPV_EVENT_HOOK;
handle->event.data = &handle->hook;
}
return &handle->event;
}
extern "C" int mpv_hook_add(mpv_handle* handle, uint64_t, const char*, int) {
std::lock_guard<std::mutex> lock(gate);
require_live(handle);
return 0;
}
extern "C" int mpv_hook_continue(mpv_handle* handle, uint64_t id) {
std::lock_guard<std::mutex> lock(gate);
require_live(handle);
require(id == handle->hook.id && handle->hook_continuations == 0, "hook continued twice or on the wrong handle");
++handle->hook_continuations;
return 0;
}
extern "C" int mpv_command_ret(mpv_handle* handle, const char** args, mpv_node* result) {
std::unique_lock<std::mutex> lock(gate);
require_live(handle);
require(handle->initialized, "command reached an uninitialized core");
handle->command_active = true;
changed.notify_all();
if (std::strcmp(args[0], "hold") == 0)
await(lock, [] { return allow_command; }, "test did not release admitted command");
require_live(handle);
handle->command_active = false;
++handle->commands;
*result = {};
return 0;
}
extern "C" int mpv_request_log_messages(mpv_handle* handle, const char* level) {
std::lock_guard<std::mutex> lock(gate);
require_live(handle);
return std::strcmp(level, "not-a-level") == 0 ? MPV_ERROR_INVALID_PARAMETER : 0;
}
extern "C" int mpv_set_option(mpv_handle* handle, const char* name, mpv_format format, void* data) {
std::lock_guard<std::mutex> lock(gate);
require_live(handle);
require(format == MPV_FORMAT_INT64, "unexpected Surface option format");
jobject object = reinterpret_cast<jobject>(static_cast<intptr_t>(*static_cast<int64_t*>(data)));
if (std::strcmp(name, "wid") == 0) {
handle->video = object;
} else {
require(std::strcmp(name, "vo-mediacodec-osd-surface") == 0, "unexpected Surface option");
handle->osd = object;
}
return 0;
}
extern "C" int mpv_get_property(mpv_handle*, const char*, mpv_format, void*) {
require(false, "unexpected property read in lifecycle scenario");
return MPV_ERROR_GENERIC;
}
extern "C" const char* mpv_error_string(int) { return "controlled MPV failure"; }
extern "C" void mpv_free_node_contents(mpv_node*) {}
extern "C" int av_jni_set_java_vm(void*, void*) { return 0; }
extern "C" int av_jni_set_android_app_ctx(void*, void*) { return 0; }
extern "C" int __android_log_print(int, const char*, const char*, ...) { return 0; }
bool acquire_jni_env(JavaVM* supplied_vm, JNIEnv** env) {
require(supplied_vm == &vm, "event thread acquired the wrong Java VM");
*env = &jni;
return true;
}
void init_methods_cache(JNIEnv*) {
std::lock_guard<std::mutex> lock(gate);
require(active_handle == nullptr, "JNI environment rewritten before predecessor retirement");
mpv_MpvPlayer_onHook = reinterpret_cast<jmethodID>(1);
}
jstring new_java_string(JNIEnv*, const char* value) {
std::lock_guard<std::mutex> lock(gate);
callback_strings.push_back(std::make_unique<_jstring>(_jstring{value ? value : ""}));
return callback_strings.back().get();
}
std::string java_string_to_utf8(JNIEnv*, jstring value) { return value ? value->value : ""; }
void die(const char*) { jni.exception_pending = true; }
int main() {
jni.vm = &vm;
jni.on_new_global_ref = new_global_ref;
jni.on_delete_global_ref = delete_global_ref;
jni.on_static_void_method = on_static_void_method;
vm.on_detach = detach_event_thread;
rejected_hook_and_successor_retirement();
admitted_command_survives_replacement();
partial_initialization_can_retire();
reset_dependencies();
std::puts("MPV lifecycle: callback overlap, reader lifetime, successor isolation and partial init passed");
return 0;
}
@@ -236,7 +236,8 @@ class ExoPlayerPluginTest {
fun openDuringFallbackDispatchesOnlyTheNewestMediaGeneration() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val exoCore = ExoPlayerCore(activity)
val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit }
val loads = ConcurrentLinkedQueue<List<String>>()
val mpvCore = MpvPlayerCore(activity, true, { _, _ -> Unit }, recordingLoads(loads))
val plugin = ExoPlayerPlugin()
val sink = RecordingEventSink()
plugin.onListen(null, sink)
@@ -273,15 +274,30 @@ class ExoPlayerPluginTest {
assertEquals(1, superseded.completionCount)
assertEquals("OPEN_SUPERSEDED", superseded.errorCode)
assertEquals(0, active.completionCount)
assertTrue(loads.isEmpty())
initializeCallback!!(true)
mpvCore.delegate?.onEvent("file-loaded", null)
awaitCompletion(active)
assertEquals(null, active.errorCode)
assertEquals(true, getField(plugin, "usingMpvFallback"))
assertEquals(false, getField(plugin, "fallbackInProgress"))
assertNull(getField(plugin, "pendingOpen"))
// mpv received exactly the newest open, with its play intent; neither the
// failed Exo URI nor the superseded episode was ever loaded.
assertEquals(listOf("https://example.test/episode-3.mkv"), loads.map { it[1] })
assertEquals("pause=no", loads.single()[4].split(",")[1])
assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames)
// The fallback owns subsequent opens: they reach mpv directly, without
// another backend switch.
val next = RecordingResult()
plugin.onMethodCall(
MethodCall("open", mapOf("uri" to "https://example.test/episode-4.mkv", "autoPlay" to false)),
next
)
awaitCompletion(next)
assertEquals(null, next.errorCode)
assertEquals("https://example.test/episode-4.mkv", loads.last()[1])
assertEquals("pause=yes", loads.last()[4].split(",")[1])
assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames)
val disposeResult = RecordingResult()
@@ -643,7 +659,9 @@ class ExoPlayerPluginTest {
fun reusedHeldFallbackSynchronouslyBlocksAutoResumeWithoutPausePropertyWrite() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
val loads = ConcurrentLinkedQueue<List<String>>()
val core = MpvPlayerCore(activity, true, { name, value -> writes += name to value }, recordingLoads(loads))
// A reused core that lost its surface mid-playback with a resume still deferred.
core.setPrivateField("desiredPaused", false)
core.setPrivateField("cachedPaused", false)
core.setPrivateField("resumeBlockedByPublicPause", false)
@@ -664,11 +682,10 @@ class ExoPlayerPluginTest {
assertEquals(1, result.completionCount)
assertNull(result.errorCode)
assertEquals(true, core.getPrivateField("desiredPaused"))
assertEquals(true, core.getPrivateField("cachedPaused"))
assertEquals(true, core.getPrivateField("resumeBlockedByPublicPause"))
assertEquals(false, core.getPrivateField("pausedForSurfaceLoss"))
assertEquals(false, core.getPrivateField("deferredResumeRequested"))
// The load itself carries the hold; the stale deferred resume must not
// undo it with a late pause write.
assertEquals("https://example.test/video.mkv", loads.single()[1])
assertEquals("pause=yes", loads.single()[4].split(",")[1])
assertFalse(awaitPauseWriteCount(writes, 1))
core.dispose()
}
@@ -677,10 +694,10 @@ class ExoPlayerPluginTest {
fun reusedAutoplayFallbackClearsIntentBeforeLoadWithoutLatePausePropertyWrite() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
core.setPrivateField("desiredPaused", true)
core.setPrivateField("cachedPaused", true)
core.setPrivateField("resumeBlockedByPublicPause", true)
val loads = ConcurrentLinkedQueue<List<String>>()
val core = MpvPlayerCore(activity, true, { name, value -> writes += name to value }, recordingLoads(loads))
// A reused core whose previous load was explicitly held.
core.setPauseIntentForLoad(true)
val plugin = reusedFallbackPlugin(activity, core)
val result = RecordingResult()
@@ -695,9 +712,10 @@ class ExoPlayerPluginTest {
assertEquals(1, result.completionCount)
assertNull(result.errorCode)
assertEquals(false, core.getPrivateField("desiredPaused"))
assertEquals(false, core.getPrivateField("cachedPaused"))
assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
// The new load carries the play intent; the earlier hold is not replayed
// as a separate pause write before or after it.
assertEquals("https://example.test/video.mkv", loads.single()[1])
assertEquals("pause=no", loads.single()[4].split(",")[1])
assertFalse(awaitPauseWriteCount(writes, 1))
assertEquals(emptyList<Pair<String, String>>(), writes.filter { it.first == "pause" })
core.dispose()
@@ -955,6 +973,14 @@ class ExoPlayerPluginTest {
}
}
/** Accepts every native command, recording each one; a `loadfile` yields playlist entry 1. */
private fun recordingLoads(
loads: ConcurrentLinkedQueue<List<String>>
): suspend (Array<String>) -> Long? = { args ->
loads += args.toList()
if (args.firstOrNull() == "loadfile") 1L else null
}
private fun awaitQueueEntry(
queue: ConcurrentLinkedQueue<Pair<String, String>>,
expected: Pair<String, String>
@@ -101,6 +101,40 @@ class GpuVoPolicyTest {
assertFalse(GpuVoPolicy.needsSoftwareDecode(null, "High 10", hardwareHigh10 = false))
}
// The per-file policies run inside on_preloaded, before mpv selects a
// track, so the track they decide for comes from the pending selection.
@Test
fun `auto selection follows mpv's pending choice, not track-list order`() {
// Cover art first, the default-flagged feature second: mpv picks 2.
assertEquals(2L, GpuVoPolicy.pendingVideoTrackId("auto", "2", listOf(1L, 2L)))
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("auto", "1", listOf(1L, 2L)))
}
@Test
fun `explicit vid answers on its own`() {
assertEquals(2L, GpuVoPolicy.pendingVideoTrackId("2", pendingVid = null, videoTrackIds = listOf(1L, 2L)))
// A user's explicit choice is never re-selected, even when mpv would
// pick differently.
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("1", "2", listOf(1L, 2L)))
// No such track: mpv selects nothing, so nothing is routed.
assertNull(GpuVoPolicy.pendingVideoTrackId("7", "2", listOf(1L, 2L)))
}
@Test
fun `no video selection yields no track`() {
assertNull(GpuVoPolicy.pendingVideoTrackId("no", "1", listOf(1L, 2L)))
assertNull(GpuVoPolicy.pendingVideoTrackId("auto", "no", listOf(1L, 2L)))
assertNull(GpuVoPolicy.pendingVideoTrackId("auto", "1", emptyList()))
}
@Test
fun `without the pending-vid property the first track is the fallback`() {
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("auto", null, listOf(1L, 2L)))
assertEquals(1L, GpuVoPolicy.pendingVideoTrackId(null, null, listOf(1L, 2L)))
assertNull(GpuVoPolicy.pendingVideoTrackId("auto", null, emptyList()))
}
@Test
fun `cheap render tier needs a GL vo on a driver without norm16`() {
assertTrue(GpuVoPolicy.needsCheapRenderTier(glVoActive = true, textureNorm16 = false))
@@ -49,6 +49,22 @@ class MpvPlayerPluginTest {
assertNull(result.successValue)
}
@Test
fun commandWithoutNativePlayerReportsFailureInsteadOfSilentSuccess() {
// A load that never reached mpv produces no source; answering success
// would leave Dart waiting on a start-file that never comes.
val plugin = MpvPlayerPlugin()
installCore(plugin, testCore(null))
val result = RecordingResult()
plugin.onMethodCall(MethodCall("command", mapOf("args" to listOf("loadfile", "x", "replace"))), result)
awaitCompletion(result)
assertEquals("COMMAND_FAILED", result.errorCode)
assertEquals(1, result.completionCount)
assertNull(result.successValue)
}
@Test
fun audioSpdifCodecsWithoutContextAnswersEmptySoMpvDecodes() {
// mpv force-passthroughs every codec named in audio-spdif with no decode fallback, so
@@ -758,15 +774,53 @@ class MpvPlayerPluginTest {
}
@Test
fun setLogLevelReportsUnsupported() {
fun setLogLevelWithoutCoreReportsNotInitializedForVideoAndAudio() {
for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) {
val result = RecordingResult()
plugin.onMethodCall(MethodCall("setLogLevel", mapOf("level" to "warn")), result)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(1, result.completionCount)
assertNull(result.successValue)
}
}
@Test
fun setLogLevelRejectsMissingOrNonStringLevel() {
for (level in listOf(null, 42)) {
val result = RecordingResult()
MpvPlayerPlugin().onMethodCall(MethodCall("setLogLevel", mapOf("level" to level)), result)
assertEquals("INVALID_ARGS", result.errorCode)
assertEquals(1, result.completionCount)
}
}
@Test
fun disposeCompletesQueuedLogLevelChangeOnceWithoutAnActiveNativePlayer() {
val blockerStarted = CountDownLatch(1)
val releaseBlocker = CountDownLatch(1)
val core = testCore { _, _ ->
blockerStarted.countDown()
check(releaseBlocker.await(2, TimeUnit.SECONDS))
}
val plugin = MpvPlayerPlugin()
installCore(plugin, core)
val result = RecordingResult()
try {
core.setProperty("block", "value")
assertTrue(blockerStarted.await(1, TimeUnit.SECONDS))
plugin.onMethodCall(MethodCall("setLogLevel", mapOf("level" to "v")), result)
core.dispose()
} finally {
releaseBlocker.countDown()
}
awaitCompletion(result)
MpvPlayerPlugin().onMethodCall(
MethodCall("setLogLevel", mapOf("level" to "warn")),
result
)
assertEquals("UNSUPPORTED", result.errorCode)
assertEquals("NOT_INITIALIZED", result.errorCode)
assertEquals(1, result.completionCount)
assertNull(result.successValue)
}
@@ -0,0 +1,32 @@
package com.edde746.plezy.mpv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class OsdPlanePolicyTest {
@Test
fun `full resolution leaves the plane tracking its view`() {
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 1f))
}
@Test
fun `fractions shrink both dimensions by the same scale`() {
assertEquals(OsdPlanePolicy.Size(1440, 810), OsdPlanePolicy.fixedSizeFor(1920, 1080, 0.75f))
assertEquals(OsdPlanePolicy.Size(960, 540), OsdPlanePolicy.fixedSizeFor(1920, 1080, 0.5f))
assertEquals(OsdPlanePolicy.Size(640, 360), OsdPlanePolicy.fixedSizeFor(1920, 1080, 1f / 3f))
}
@Test
fun `odd results round down to even`() {
// 1/3 of 1000 is 333.3: rounds to 333, then to the even 332.
assertEquals(OsdPlanePolicy.Size(332, 250), OsdPlanePolicy.fixedSizeFor(1000, 750, 1f / 3f))
}
@Test
fun `unlaid-out views and nonsense scales are ignored`() {
assertNull(OsdPlanePolicy.fixedSizeFor(0, 0, 0.5f))
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 0f))
assertNull(OsdPlanePolicy.fixedSizeFor(1920, 1080, 1.5f))
}
}
+8 -9
View File
@@ -6,14 +6,13 @@
# jni_utils.cpp caches MpvPlayer with FindClass and resolves these static callbacks
# with GetStaticMethodID on the native event thread. R8 sees no reference to the
# class name or the member names, so both must stay alive and un-renamed.
# Match callback names, return type and static access without duplicating JNI
# argument descriptors here: adding the session token made the old signatures
# silently stop matching. Native initialization resolves every exact descriptor.
-keep class com.edde746.plezy.libmpv.MpvPlayer {
public static void onPropertyChanged(java.lang.String, long, boolean);
public static void onPropertyChanged(java.lang.String, boolean, long, boolean);
public static void onPropertyChanged(java.lang.String, long, long, boolean);
public static void onPropertyChanged(java.lang.String, double, long, boolean);
public static void onPropertyChanged(java.lang.String, java.lang.String, long, boolean);
public static void onEvent(int, long, boolean, double, boolean);
public static void onEndFile(int, long, boolean);
public static void onLogMessage(java.lang.String, int, java.lang.String);
public static void onHook(java.lang.String, long);
public static void onPropertyChanged(...);
public static void onEvent(...);
public static void onEndFile(...);
public static void onLogMessage(...);
public static void onHook(...);
}
+36 -20
View File
@@ -7,34 +7,50 @@
#include "jni_utils.h"
#include "log.h"
// The session every callback below names, bound once before the thread
// starts (event_thread_bind). Kotlin drops a callback whose session is not
// the wrapper it published for, so a retiring core's tail — end-file,
// property changes, a hook raised just before teardown — can never be read
// as the successor's. L keeps this binding immutable through join, including
// the interval after admission is revoked; callbacks never acquire L.
static mpv_handle* thread_mpv;
static uint64_t thread_session;
void event_thread_bind(mpv_handle* mpv, uint64_t session) {
thread_mpv = mpv;
thread_session = session;
}
static void sendPropertyUpdateToJava(JNIEnv* env, mpv_event_property* prop, int64_t source_id, bool has_source_id) {
jstring jprop = new_java_string(env, prop->name);
jstring jvalue = NULL;
const jboolean jhas_source_id = has_source_id ? JNI_TRUE : JNI_FALSE;
const jlong jsession = (jlong)thread_session;
switch (prop->format) {
case MPV_FORMAT_NONE:
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJZ, jprop, (jlong)source_id, jhas_source_id);
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJZ, jsession, jprop, (jlong)source_id, jhas_source_id);
break;
case MPV_FORMAT_FLAG:
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SZJZ, jprop, (jboolean) * (int*)prop->data, (jlong)source_id,
jhas_source_id);
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SZJZ, jsession, jprop, (jboolean) * (int*)prop->data,
(jlong)source_id, jhas_source_id);
break;
case MPV_FORMAT_INT64:
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJJZ, jprop, (jlong) * (int64_t*)prop->data, (jlong)source_id,
jhas_source_id);
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJJZ, jsession, jprop, (jlong) * (int64_t*)prop->data,
(jlong)source_id, jhas_source_id);
break;
case MPV_FORMAT_DOUBLE:
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SDJZ, jprop, (jdouble) * (double*)prop->data, (jlong)source_id,
jhas_source_id);
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SDJZ, jsession, jprop, (jdouble) * (double*)prop->data,
(jlong)source_id, jhas_source_id);
break;
case MPV_FORMAT_STRING:
jvalue = new_java_string(env, *(const char**)prop->data);
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SSJZ, jprop, jvalue, (jlong)source_id, jhas_source_id);
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SSJZ, jsession, jprop, jvalue, (jlong)source_id,
jhas_source_id);
break;
default:
break;
@@ -47,8 +63,8 @@ static void sendEventToJava(
JNIEnv* env, int event, int64_t source_id, bool has_source_id, double position_seconds = 0.0,
bool has_position_seconds = false) {
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onEvent, (jint)event, (jlong)source_id, has_source_id ? JNI_TRUE : JNI_FALSE,
(jdouble)position_seconds, has_position_seconds ? JNI_TRUE : JNI_FALSE);
mpv_MpvPlayer, mpv_MpvPlayer_onEvent, (jlong)thread_session, (jint)event, (jlong)source_id,
has_source_id ? JNI_TRUE : JNI_FALSE, (jdouble)position_seconds, has_position_seconds ? JNI_TRUE : JNI_FALSE);
}
static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
@@ -56,24 +72,28 @@ static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
const int reason = end_file ? end_file->reason : -1;
const int64_t source_id = end_file ? end_file->playlist_entry_id : 0;
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jint)reason, (jlong)source_id, end_file ? JNI_TRUE : JNI_FALSE);
mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jlong)thread_session, (jint)reason, (jlong)source_id,
end_file ? JNI_TRUE : JNI_FALSE);
}
static void sendLogMessageToJava(JNIEnv* env, mpv_event_log_message* msg) {
jstring jprefix = new_java_string(env, msg->prefix);
jstring jtext = new_java_string(env, msg->text);
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onLogMessage, jprefix, (jint)msg->log_level, jtext);
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onLogMessage, (jlong)thread_session, jprefix, (jint)msg->log_level, jtext);
if (jprefix) env->DeleteLocalRef(jprefix);
if (jtext) env->DeleteLocalRef(jtext);
}
// A hook holds mpv (playback does not proceed) until Kotlin answers with
// nativeHookContinue(id); MpvPlayer guarantees that answer for every hook.
// nativeHookContinue(session, id); MpvPlayer guarantees that answer for every
// hook it is handed, and mpv itself continues any hook still held when the
// handle is destroyed.
static void sendHookToJava(JNIEnv* env, mpv_event_hook* hook) {
jstring jname = new_java_string(env, hook->name);
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onHook, jname, (jlong)hook->id);
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onHook, (jlong)thread_session, jname, (jlong)hook->id);
if (jname) env->DeleteLocalRef(jname);
}
@@ -90,7 +110,7 @@ void* event_thread(void* arg) {
mpv_event_property* mp_property;
mpv_event_log_message* msg;
mp_event = mpv_wait_event(g_mpv, -1.0);
mp_event = mpv_wait_event(thread_mpv, -1.0);
if (g_event_thread_request_exit) break;
@@ -99,7 +119,6 @@ void* event_thread(void* arg) {
switch (mp_event->event_id) {
case MPV_EVENT_LOG_MESSAGE:
msg = (mpv_event_log_message*)mp_event->data;
ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text);
sendLogMessageToJava(env, msg);
break;
case MPV_EVENT_PROPERTY_CHANGE:
@@ -113,12 +132,10 @@ void* event_thread(void* arg) {
mpv_event_start_file* start_file = (mpv_event_start_file*)mp_event->data;
has_source_id = start_file != NULL;
source_id = start_file ? start_file->playlist_entry_id : 0;
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
sendEventToJava(env, mp_event->event_id, source_id, has_source_id);
break;
}
case MPV_EVENT_FILE_LOADED:
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
sendEventToJava(env, mp_event->event_id, source_id, has_source_id);
break;
case MPV_EVENT_HOOK:
@@ -127,9 +144,8 @@ void* event_thread(void* arg) {
case MPV_EVENT_PLAYBACK_RESTART: {
double position_seconds = 0.0;
const bool has_position_seconds =
mpv_get_property(g_mpv, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0 &&
mpv_get_property(thread_mpv, "time-pos", MPV_FORMAT_DOUBLE, &position_seconds) >= 0 &&
std::isfinite(position_seconds);
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
sendEventToJava(env, mp_event->event_id, source_id, has_source_id, position_seconds, has_position_seconds);
break;
}
+10
View File
@@ -1,3 +1,13 @@
#pragma once
#include <mpv/client.h>
#include <cstdint>
// Names the session the next event_thread serves. Called under L and S before
// pthread_create publishes the binding. L prevents rebinding until that thread
// is joined; the thread never reads the live admission slots and can safely
// finish a callback after revocation. Callbacks must never acquire L.
void event_thread_bind(mpv_handle* mpv, uint64_t session);
void* event_thread(void* arg);
+37
View File
@@ -2,9 +2,46 @@
#include <jni.h>
#include <mpv/client.h>
#include <pthread.h>
#include <atomic>
#include <cstdint>
extern JavaVM* g_vm;
// The process-global native session. One mpv_handle lives at a time; each is
// identified by a monotonic session id handed to Kotlin by nativeCreate. Every
// JNI entry names the session it was issued for, every callback carries the
// session it originated from, so a retired wrapper can neither touch nor be
// fed by its successor.
//
// g_session_lock (S) guards admission: write-held for publication, init and
// revocation, read-held by every other JNI entry through its last handle use.
// Retirement drains admitted readers and clears these slots under S, then
// releases S BEFORE wake/join/terminate so callbacks can reenter and reject.
// A separate lifecycle mutex (L, private to main.cpp) serializes create/init/
// destroy through termination AND surface cleanup. Lock order is L -> S;
// readers and event callbacks never acquire L. The event thread borrows its
// immutable bound handle until joined, even after the admission slots clear.
extern mpv_handle* g_mpv;
extern uint64_t g_session;
extern pthread_rwlock_t g_session_lock;
extern std::atomic<bool> g_event_thread_request_exit;
// Read-locked admission of one JNI entry to the live session. `mpv` is NULL
// when `session` is not the live one (retired, superseded, or never created);
// the caller then reports MPV_ERROR_UNINITIALIZED / null and touches nothing.
// A refused entry is an expected outcome of teardown racing in-flight work,
// not a programming error, so it never throws into Java.
class SessionGuard {
public:
explicit SessionGuard(jlong session) {
pthread_rwlock_rdlock(&g_session_lock);
mpv = (g_mpv && g_session == (uint64_t)session) ? g_mpv : NULL;
}
~SessionGuard() { pthread_rwlock_unlock(&g_session_lock); }
SessionGuard(const SessionGuard&) = delete;
SessionGuard& operator=(const SessionGuard&) = delete;
mpv_handle* mpv;
};
+10 -9
View File
@@ -50,21 +50,22 @@ void init_methods_cache(JNIEnv* env) {
mpv_MpvPlayer = env->FindClass("com/edde746/plezy/libmpv/MpvPlayer");
mpv_MpvPlayer = reinterpret_cast<jclass>(env->NewGlobalRef(mpv_MpvPlayer));
// Every callback leads with the origin session (J); see event.cpp.
mpv_MpvPlayer_onPropertyChanged_SJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JZ)V");
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;JZ)V");
mpv_MpvPlayer_onPropertyChanged_SZJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;ZJZ)V");
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;ZJZ)V");
mpv_MpvPlayer_onPropertyChanged_SJJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JJZ)V");
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;JJZ)V");
mpv_MpvPlayer_onPropertyChanged_SDJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;DJZ)V");
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;DJZ)V");
mpv_MpvPlayer_onPropertyChanged_SSJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Ljava/lang/String;JZ)V");
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(IJZDZ)V");
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(IJZ)V");
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(JLjava/lang/String;Ljava/lang/String;JZ)V");
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(JIJZDZ)V");
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(JIJZ)V");
mpv_MpvPlayer_onLogMessage =
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(Ljava/lang/String;ILjava/lang/String;)V");
mpv_MpvPlayer_onHook = env->GetStaticMethodID(mpv_MpvPlayer, "onHook", "(Ljava/lang/String;J)V");
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(JLjava/lang/String;ILjava/lang/String;)V");
mpv_MpvPlayer_onHook = env->GetStaticMethodID(mpv_MpvPlayer, "onHook", "(JLjava/lang/String;J)V");
methods_initialized = true;
}
-7
View File
@@ -2,15 +2,8 @@
#include <android/log.h>
#define DEBUG 1
#define LOG_TAG "mpv"
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#if DEBUG
#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
#else
#define ALOGV(...) (void)0
#endif
void die(const char* msg);
+146 -54
View File
@@ -6,8 +6,8 @@
#include <clocale>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <mutex>
#include <string>
#include <vector>
@@ -16,6 +16,7 @@ extern "C" {
}
#include "event.h"
#include "globals.h"
#include "jni_utils.h"
#include "log.h"
@@ -24,20 +25,48 @@ extern "C" {
void render_cleanup(JNIEnv* env);
extern "C" {
jni_func(void, nativeCreate, jobject appctx);
jni_func(void, nativeInit);
jni_func(void, nativeDestroy);
jni_func(jlong, nativeCreate, jobject appctx);
jni_func(jint, nativeInit, jlong session);
jni_func(void, nativeDestroy, jlong session);
jni_func(void, nativeCommand, jobjectArray jarray);
jni_func(void, nativeHookContinue, jlong id);
jni_func(jint, nativeSetLogLevel, jlong session, jstring level);
jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray);
jni_func(void, nativeHookContinue, jlong session, jlong id);
};
JavaVM* g_vm;
mpv_handle* g_mpv;
uint64_t g_session;
pthread_rwlock_t g_session_lock = PTHREAD_RWLOCK_INITIALIZER;
std::atomic<bool> g_event_thread_request_exit(false);
// Lifecycle serialization (L) is separate from session admission (S).
// Only nativeCreate/nativeInit/nativeDestroy acquire L, always before S.
static pthread_mutex_t lifecycle_lock = PTHREAD_MUTEX_INITIALIZER;
static uint64_t next_session = 0;
static pthread_t event_thread_id;
static std::mutex g_lifecycle_mutex;
static bool event_thread_started = false;
// Held through retirement, termination and surface cleanup, so a successor
// cannot overlap or rebind the retiring event thread. Callbacks never take L.
class LifecycleGuard {
public:
LifecycleGuard() { pthread_mutex_lock(&lifecycle_lock); }
~LifecycleGuard() { pthread_mutex_unlock(&lifecycle_lock); }
LifecycleGuard(const LifecycleGuard&) = delete;
LifecycleGuard& operator=(const LifecycleGuard&) = delete;
};
// Bounded admission-write scope (S), always nested inside L. Never hold this
// while joining or terminating: an event callback can reenter SessionGuard.
class SessionWriteGuard {
public:
SessionWriteGuard() { pthread_rwlock_wrlock(&g_session_lock); }
~SessionWriteGuard() { pthread_rwlock_unlock(&g_session_lock); }
SessionWriteGuard(const SessionWriteGuard&) = delete;
SessionWriteGuard& operator=(const SessionWriteGuard&) = delete;
};
static void prepare_environment(JNIEnv* env, jobject appctx) {
setlocale(LC_NUMERIC, "C");
@@ -50,40 +79,69 @@ static void prepare_environment(JNIEnv* env, jobject appctx) {
init_methods_cache(env);
}
jni_func(void, nativeCreate, jobject appctx) {
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
prepare_environment(env, appctx);
// Caller holds L and S(write). Acquiring S drained every admitted JNI reader;
// revoke further admission before letting an in-flight callback finish.
// The lifecycle owner retains the handle and immutable event-thread binding.
static mpv_handle* revoke_locked() {
mpv_handle* local_mpv = g_mpv;
g_mpv = NULL;
g_session = 0;
if (event_thread_started) g_event_thread_request_exit = true;
return local_mpv;
}
mpv_handle* leaked_mpv = NULL;
if (g_mpv) {
ALOGE("destroying leaked mpv instance");
leaked_mpv = g_mpv;
g_event_thread_request_exit = true;
mpv_wakeup(leaked_mpv);
// Caller holds L, but NOT S. The event thread is the revoked handle's only
// remaining borrower; a rejected hook can take S and return during this join.
static void destroy_locked(JNIEnv* env, mpv_handle* local_mpv) {
// Configuration (including an invalid initial log level) can fail before
// nativeInit starts the event thread.
if (event_thread_started) {
mpv_wakeup(local_mpv);
pthread_join(event_thread_id, NULL);
g_mpv = NULL;
mpv_terminate_destroy(leaked_mpv);
render_cleanup(env);
event_thread_started = false;
}
// The MediaCodec VO can retain the Surface until final decoder teardown.
// Keep its JNI refs alive for the entire blocking termination.
mpv_terminate_destroy(local_mpv);
render_cleanup(env);
}
jni_func(jlong, nativeCreate, jobject appctx) {
LifecycleGuard lock;
mpv_handle* predecessor;
{
SessionWriteGuard admission;
predecessor = revoke_locked();
}
if (predecessor) {
ALOGE("destroying leaked mpv instance");
destroy_locked(env, predecessor);
}
// Do not rewrite process-wide JNI state while the predecessor can callback.
prepare_environment(env, appctx);
SessionWriteGuard admission;
g_mpv = mpv_create();
if (!g_mpv) {
die("context init failed");
return;
return 0;
}
g_session = ++next_session;
mpv_request_log_messages(g_mpv, "v");
mpv_request_log_messages(g_mpv, "warn");
return (jlong)g_session;
}
jni_func(void, nativeInit) {
if (!g_mpv) {
die("mpv is not created");
return;
}
jni_func(jint, nativeInit, jlong session) {
LifecycleGuard lock;
SessionWriteGuard admission;
if (!g_mpv || g_session != (uint64_t)session) return MPV_ERROR_UNINITIALIZED;
if (mpv_initialize(g_mpv) < 0) {
die("mpv init failed");
return;
const int result = mpv_initialize(g_mpv);
if (result < 0) {
ALOGE("mpv_initialize returned error %s", mpv_error_string(result));
return result;
}
// Per-file decode routing (Dolby Vision P5, H.264 High 10) has to land
@@ -93,41 +151,53 @@ jni_func(void, nativeInit) {
mpv_hook_add(g_mpv, 0, "on_preloaded", 0);
g_event_thread_request_exit = false;
event_thread_bind(g_mpv, g_session);
if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) {
die("thread create failed");
return;
return MPV_ERROR_GENERIC;
}
event_thread_started = true;
pthread_setname_np(event_thread_id, "event_thread");
return 0;
}
jni_func(void, nativeDestroy) {
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
if (!g_mpv) {
ALOGV("mpv destroy called but it's already destroyed");
return;
jni_func(void, nativeDestroy, jlong session) {
LifecycleGuard lock;
mpv_handle* local_mpv;
{
SessionWriteGuard admission;
// A wrapper whose session a later nativeCreate already retired has nothing
// left to destroy; the successor is not its to touch.
if (!g_mpv || g_session != (uint64_t)session) return;
local_mpv = revoke_locked();
}
mpv_handle* local_mpv = g_mpv;
g_event_thread_request_exit = true;
mpv_wakeup(local_mpv);
pthread_join(event_thread_id, NULL);
g_mpv = NULL;
// The MediaCodec VO can retain the Surface until final decoder teardown.
// Keep its JNI refs alive for the entire blocking termination.
mpv_terminate_destroy(local_mpv);
render_cleanup(env);
destroy_locked(env, local_mpv);
}
jni_func(void, nativeCommand, jobjectArray jarray) {
CHECK_MPV_INIT();
jni_func(jint, nativeSetLogLevel, jlong session, jstring jlevel) {
SessionGuard guard(session);
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
const std::string level = java_string_to_utf8(env, jlevel);
if (env->ExceptionCheck()) return MPV_ERROR_NOMEM;
const int result = mpv_request_log_messages(guard.mpv, level.c_str());
if (result < 0) ALOGE("mpv_request_log_messages returned error %s", mpv_error_string(result));
return result;
}
// Runs a command synchronously. Returns the negative mpv error on failure,
// the `playlist_entry_id` a `loadfile` created (always > 0), or 0 for a
// command that succeeded without one. A retired session reports
// MPV_ERROR_UNINITIALIZED like any other rejected command.
jni_func(jlong, nativeCommand, jlong session, jobjectArray jarray) {
SessionGuard guard(session);
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
const char* arguments[128] = {0};
int len = env->GetArrayLength(jarray);
if (len >= (int)ARRAYLEN(arguments)) {
die("too many command arguments");
return;
return MPV_ERROR_INVALID_PARAMETER;
}
std::vector<std::string> storage;
@@ -139,10 +209,32 @@ jni_func(void, nativeCommand, jobjectArray jarray) {
env->DeleteLocalRef(jarg);
}
mpv_command(g_mpv, arguments);
mpv_node result{};
const int status = mpv_command_ret(guard.mpv, arguments, &result);
if (status < 0) {
ALOGE("mpv_command(%s) returned error %s", len > 0 ? arguments[0] : "", mpv_error_string(status));
return status;
}
jlong playlist_entry_id = 0;
const mpv_node_list* map = result.format == MPV_FORMAT_NODE_MAP ? result.u.list : nullptr;
if (map && map->keys && map->values) {
for (int i = 0; i < map->num; ++i) {
if (map->keys[i] && strcmp(map->keys[i], "playlist_entry_id") == 0 && map->values[i].format == MPV_FORMAT_INT64) {
playlist_entry_id = (jlong)map->values[i].u.int64;
break;
}
}
}
mpv_free_node_contents(&result);
return playlist_entry_id;
}
jni_func(void, nativeHookContinue, jlong id) {
if (!g_mpv) return;
mpv_hook_continue(g_mpv, (uint64_t)id);
// A continuation for a revoked session is dropped, even while its handle is
// still retiring. Destruction releases any outstanding hooks; the successor
// must never receive an old hook id.
jni_func(void, nativeHookContinue, jlong session, jlong id) {
SessionGuard guard(session);
if (!guard.mpv) return;
mpv_hook_continue(guard.mpv, (uint64_t)id);
}
+45 -38
View File
@@ -9,49 +9,55 @@
#include "log.h"
extern "C" {
jni_func(jint, nativeSetOptionString, jstring option, jstring value);
jni_func(jint, nativeSetOptionString, jlong session, jstring option, jstring value);
jni_func(jobject, nativeGetPropertyInt, jstring property);
jni_func(void, nativeSetPropertyInt, jstring property, jint value);
jni_func(jobject, nativeGetPropertyDouble, jstring property);
jni_func(void, nativeSetPropertyDouble, jstring property, jdouble value);
jni_func(jobject, nativeGetPropertyBoolean, jstring property);
jni_func(void, nativeSetPropertyBoolean, jstring property, jboolean value);
jni_func(jstring, nativeGetPropertyString, jstring jproperty);
jni_func(void, nativeSetPropertyString, jstring jproperty, jstring jvalue);
jni_func(jobject, nativeGetPropertyInt, jlong session, jstring property);
jni_func(void, nativeSetPropertyInt, jlong session, jstring property, jint value);
jni_func(jobject, nativeGetPropertyDouble, jlong session, jstring property);
jni_func(void, nativeSetPropertyDouble, jlong session, jstring property, jdouble value);
jni_func(jobject, nativeGetPropertyBoolean, jlong session, jstring property);
jni_func(void, nativeSetPropertyBoolean, jlong session, jstring property, jboolean value);
jni_func(jstring, nativeGetPropertyString, jlong session, jstring jproperty);
jni_func(void, nativeSetPropertyString, jlong session, jstring jproperty, jstring jvalue);
jni_func(void, nativeObserveProperty, jstring property, jint format);
jni_func(void, nativeObserveProperty, jlong session, jstring property, jint format);
}
jni_func(jint, nativeSetOptionString, jstring joption, jstring jvalue) {
CHECK_MPV_INIT_RET(0);
jni_func(jint, nativeSetOptionString, jlong session, jstring joption, jstring jvalue) {
SessionGuard guard(session);
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
const char* option = env->GetStringUTFChars(joption, NULL);
const std::string value = java_string_to_utf8(env, jvalue);
int result = mpv_set_option_string(g_mpv, option, value.c_str());
int result = mpv_set_option_string(guard.mpv, option, value.c_str());
env->ReleaseStringUTFChars(joption, option);
return result;
}
static int common_get_property(JNIEnv* env, jstring jproperty, mpv_format format, void* output) {
CHECK_MPV_INIT_RET(-1);
// A retired session reads as "no value" and writes nowhere: the in-flight
// hook handler of a torn-down player must not learn about, or reconfigure,
// its successor.
static int common_get_property(JNIEnv* env, jlong session, jstring jproperty, mpv_format format, void* output) {
SessionGuard guard(session);
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
const char* prop = env->GetStringUTFChars(jproperty, NULL);
int result = mpv_get_property(g_mpv, prop, format, output);
int result = mpv_get_property(guard.mpv, prop, format, output);
if (result < 0) ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
env->ReleaseStringUTFChars(jproperty, prop);
return result;
}
static int common_set_property(JNIEnv* env, jstring jproperty, mpv_format format, void* value) {
CHECK_MPV_INIT_RET(-1);
static int common_set_property(JNIEnv* env, jlong session, jstring jproperty, mpv_format format, void* value) {
SessionGuard guard(session);
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
const char* prop = env->GetStringUTFChars(jproperty, NULL);
int result = mpv_set_property(g_mpv, prop, format, value);
int result = mpv_set_property(guard.mpv, prop, format, value);
if (result < 0)
ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result));
env->ReleaseStringUTFChars(jproperty, prop);
@@ -59,57 +65,58 @@ static int common_set_property(JNIEnv* env, jstring jproperty, mpv_format format
return result;
}
jni_func(jobject, nativeGetPropertyInt, jstring jproperty) {
jni_func(jobject, nativeGetPropertyInt, jlong session, jstring jproperty) {
int64_t value = 0;
if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
if (common_get_property(env, session, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
return env->NewObject(java_Integer, java_Integer_init, (jint)value);
}
jni_func(jobject, nativeGetPropertyDouble, jstring jproperty) {
jni_func(jobject, nativeGetPropertyDouble, jlong session, jstring jproperty) {
double value = 0;
if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
if (common_get_property(env, session, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
return env->NewObject(java_Double, java_Double_init, (jdouble)value);
}
jni_func(jobject, nativeGetPropertyBoolean, jstring jproperty) {
jni_func(jobject, nativeGetPropertyBoolean, jlong session, jstring jproperty) {
int value = 0;
if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
if (common_get_property(env, session, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
}
jni_func(jstring, nativeGetPropertyString, jstring jproperty) {
jni_func(jstring, nativeGetPropertyString, jlong session, jstring jproperty) {
char* value;
if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
if (common_get_property(env, session, jproperty, MPV_FORMAT_STRING, &value) < 0) return NULL;
jstring jvalue = new_java_string(env, value);
mpv_free(value);
return jvalue;
}
jni_func(void, nativeSetPropertyInt, jstring jproperty, jint jvalue) {
jni_func(void, nativeSetPropertyInt, jlong session, jstring jproperty, jint jvalue) {
int64_t value = static_cast<int64_t>(jvalue);
common_set_property(env, jproperty, MPV_FORMAT_INT64, &value);
common_set_property(env, session, jproperty, MPV_FORMAT_INT64, &value);
}
jni_func(void, nativeSetPropertyDouble, jstring jproperty, jdouble jvalue) {
jni_func(void, nativeSetPropertyDouble, jlong session, jstring jproperty, jdouble jvalue) {
double value = static_cast<double>(jvalue);
common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value);
common_set_property(env, session, jproperty, MPV_FORMAT_DOUBLE, &value);
}
jni_func(void, nativeSetPropertyBoolean, jstring jproperty, jboolean jvalue) {
jni_func(void, nativeSetPropertyBoolean, jlong session, jstring jproperty, jboolean jvalue) {
int value = jvalue == JNI_TRUE ? 1 : 0;
common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value);
common_set_property(env, session, jproperty, MPV_FORMAT_FLAG, &value);
}
jni_func(void, nativeSetPropertyString, jstring jproperty, jstring jvalue) {
jni_func(void, nativeSetPropertyString, jlong session, jstring jproperty, jstring jvalue) {
const std::string value = java_string_to_utf8(env, jvalue);
const char* value_ptr = value.c_str();
common_set_property(env, jproperty, MPV_FORMAT_STRING, &value_ptr);
common_set_property(env, session, jproperty, MPV_FORMAT_STRING, &value_ptr);
}
jni_func(void, nativeObserveProperty, jstring property, jint format) {
CHECK_MPV_INIT();
jni_func(void, nativeObserveProperty, jlong session, jstring property, jint format) {
SessionGuard guard(session);
if (!guard.mpv) return;
const char* prop = env->GetStringUTFChars(property, NULL);
int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format);
int result = mpv_observe_property(guard.mpv, 0, prop, (mpv_format)format);
if (result < 0) ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result));
env->ReleaseStringUTFChars(property, prop);
}
+23 -17
View File
@@ -6,16 +6,17 @@
#include "log.h"
extern "C" {
jni_func(void, nativeAttachSurface, jobject surface_);
jni_func(void, nativeDetachSurface);
jni_func(void, nativeAttachOsdSurface, jobject surface_);
jni_func(void, nativeDetachOsdSurface);
jni_func(void, nativeAttachSurface, jlong session, jobject surface_);
jni_func(void, nativeDetachSurface, jlong session);
jni_func(void, nativeAttachOsdSurface, jlong session, jobject surface_);
jni_func(void, nativeDetachOsdSurface, jlong session);
};
static jobject surface;
jni_func(void, nativeAttachSurface, jobject surface_) {
CHECK_MPV_INIT();
jni_func(void, nativeAttachSurface, jlong session, jobject surface_) {
SessionGuard guard(session);
if (!guard.mpv) return;
surface = env->NewGlobalRef(surface_);
if (!surface) {
@@ -23,15 +24,16 @@ jni_func(void, nativeAttachSurface, jobject surface_) {
return;
}
int64_t wid = reinterpret_cast<intptr_t>(surface);
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
int result = mpv_set_option(guard.mpv, "wid", MPV_FORMAT_INT64, &wid);
if (result < 0) ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
}
jni_func(void, nativeDetachSurface) {
CHECK_MPV_INIT();
jni_func(void, nativeDetachSurface, jlong session) {
SessionGuard guard(session);
if (!guard.mpv) return;
int64_t wid = 0;
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
int result = mpv_set_option(guard.mpv, "wid", MPV_FORMAT_INT64, &wid);
if (result < 0) ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
env->DeleteGlobalRef(surface);
@@ -42,8 +44,9 @@ static jobject osd_surface;
// The OSD plane of vo=mediacodec. Same lifetime rules as the video surface:
// the global ref must outlive the VO, so detach only after vo has been unset.
jni_func(void, nativeAttachOsdSurface, jobject surface_) {
CHECK_MPV_INIT();
jni_func(void, nativeAttachOsdSurface, jlong session, jobject surface_) {
SessionGuard guard(session);
if (!guard.mpv) return;
osd_surface = env->NewGlobalRef(surface_);
if (!osd_surface) {
@@ -51,22 +54,25 @@ jni_func(void, nativeAttachOsdSurface, jobject surface_) {
return;
}
int64_t wid = reinterpret_cast<intptr_t>(osd_surface);
int result = mpv_set_option(g_mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
int result = mpv_set_option(guard.mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
if (result < 0) ALOGE("mpv_set_option(vo-mediacodec-osd-surface) returned error %s", mpv_error_string(result));
}
jni_func(void, nativeDetachOsdSurface) {
CHECK_MPV_INIT();
if (!osd_surface) return;
jni_func(void, nativeDetachOsdSurface, jlong session) {
SessionGuard guard(session);
if (!guard.mpv || !osd_surface) return;
int64_t wid = 0;
int result = mpv_set_option(g_mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
int result = mpv_set_option(guard.mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
if (result < 0) ALOGE("mpv_set_option(vo-mediacodec-osd-surface) returned error %s", mpv_error_string(result));
env->DeleteGlobalRef(osd_surface);
osd_surface = NULL;
}
// Caller holds L after revoking admission, draining JNI readers, joining the
// event thread and terminating mpv. S is not held. L prevents a successor from
// publishing new surfaces until these retiring references have been released.
void render_cleanup(JNIEnv* env) {
if (surface) {
env->DeleteGlobalRef(surface);
@@ -20,7 +20,19 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
class MpvPlayer private constructor() : AutoCloseable {
/**
* Kotlin face of the process-global native player. Each instance is bound to
* one immutable native [session]; the JNI layer refuses any call that names
* a session it has retired and Kotlin drops any callback stamped with a
* session other than the published wrapper's. Together they make a retired
* wrapper's in-flight work (a hook handler still reshaping tracks, a queued
* property write, a late hook continuation) inert against its successor,
* and keep a retiring core's tail events out of the successor's flows.
*/
class MpvPlayer private constructor(
/** Identity of the native session this wrapper owns; see nativeCreate. */
val session: Long
) : AutoCloseable {
companion object {
/** Upper bound for a hook handler; longer stalls playback start. */
@@ -43,64 +55,76 @@ class MpvPlayer private constructor() : AutoCloseable {
configure: MpvPlayerConfig.() -> Unit = {}
): MpvPlayer = withContext(Dispatchers.IO) {
checkNotMainThread("MPV initialization")
val player = MpvPlayer()
// Atomically replace; mark old as closed so its background close() skips nativeDestroy
instance.getAndSet(player)?.also { it.closed = true }
// nativeCreate's safety net handles any leaked native session
// Retires any leaked predecessor natively and mints the new session.
val player = MpvPlayer(nativeCreate(context.applicationContext))
synchronized(instance) {
val current = instance.get()
// Sessions are monotonic: a concurrent create that already published a
// newer one has retired this native session underneath us.
if (current != null && current.session > player.session) {
player.closed = true
throw MpvException("MPV session ${player.session} was superseded before it initialized")
}
instance.set(player)
// The predecessor's native session is gone; its close() finds nothing to destroy.
current?.closed = true
}
try {
nativeCreate(context.applicationContext)
MpvPlayerConfig().apply(configure)
nativeInit()
MpvPlayerConfig(player.session).apply(configure)
val result = nativeInit(player.session)
if (result < 0) throw MpvException("Failed to initialize mpv: error $result")
ensureActive()
player
} catch (e: Throwable) {
instance.compareAndSet(player, null)
try {
nativeDestroy()
} catch (_: Throwable) {}
player.close()
throw e
}
}
// JNI callbacks — called from native event thread
// JNI callbacks — called from the native event thread. Each names the
// session it originated from; only the wrapper published for that
// session may receive it.
private fun target(session: Long): MpvPlayer? = instance.get()?.takeIf { it.session == session }
@JvmStatic
fun onPropertyChanged(name: String, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
fun onPropertyChanged(session: Long, name: String, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawPropertyChanges?.trySend(
PropertyChange.None(name, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
fun onPropertyChanged(session: Long, name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawPropertyChanges?.trySend(
PropertyChange.Flag(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(name: String, value: Long, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
fun onPropertyChanged(session: Long, name: String, value: Long, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawPropertyChanges?.trySend(
PropertyChange.Int64(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(name: String, value: Double, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
fun onPropertyChanged(session: Long, name: String, value: Double, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawPropertyChanges?.trySend(
PropertyChange.Double(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(name: String, value: String, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
fun onPropertyChanged(session: Long, name: String, value: String, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawPropertyChanges?.trySend(
PropertyChange.Str(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onEvent(
session: Long,
eventId: Int,
sourceId: Long,
hasSourceId: Boolean,
@@ -112,12 +136,12 @@ class MpvPlayer private constructor() : AutoCloseable {
sourceId.takeIf { hasSourceId },
positionSeconds.takeIf { hasPositionSeconds && it.isFinite() }
) ?: return
instance.get()?.rawEvents?.trySend(event)
target(session)?.rawEvents?.trySend(event)
}
@JvmStatic
fun onEndFile(reason: Int, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawEvents?.trySend(
fun onEndFile(session: Long, reason: Int, sourceId: Long, hasSourceId: Boolean) {
target(session)?.rawEvents?.trySend(
MpvEvent.EndFile(
EndFileReason.fromId(reason),
sourceId.takeIf { hasSourceId }
@@ -126,17 +150,18 @@ class MpvPlayer private constructor() : AutoCloseable {
}
@JvmStatic
fun onLogMessage(prefix: String, level: Int, text: String) {
fun onLogMessage(session: Long, prefix: String, level: Int, text: String) {
val logLevel = LogLevel.fromNative(level) ?: return
instance.get()?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
target(session)?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
}
@JvmStatic
fun onHook(name: String, id: Long) {
val player = instance.get()
fun onHook(session: Long, name: String, id: Long) {
val player = target(session)
if (player == null || player.closed || !player.rawHooks.trySend(Hook(name, id)).isSuccess) {
// Nobody will answer: release mpv rather than leave it waiting.
nativeHookContinue(id)
// Nobody will answer: release mpv rather than leave it waiting. The
// native side drops this if the session has since been retired.
nativeHookContinue(session, id)
}
}
@@ -146,47 +171,62 @@ class MpvPlayer private constructor() : AutoCloseable {
}
}
// JNI native declarations — private to avoid internal name mangling
// JNI native declarations — private to avoid internal name mangling.
// Every entry after nativeCreate names the session it acts for; the
// native side refuses a retired one.
@JvmStatic private external fun nativeCreate(appctx: Context)
/** Retires any leaked native session and returns the new session's identity. */
@JvmStatic private external fun nativeCreate(appctx: Context): Long
@JvmStatic private external fun nativeInit()
/** 0 on success, otherwise a negative mpv error. */
@JvmStatic private external fun nativeInit(session: Long): Int
@JvmStatic private external fun nativeDestroy()
@JvmStatic private external fun nativeDestroy(session: Long)
@JvmStatic private external fun nativeCommand(cmd: Array<out String>)
/** Negative mpv error, the playlist entry id a `loadfile` created, or 0 when the command returned none. */
@JvmStatic private external fun nativeCommand(session: Long, cmd: Array<out String>): Long
@JvmStatic private external fun nativeHookContinue(id: Long)
@JvmStatic private external fun nativeSetLogLevel(session: Long, level: String): Int
@JvmStatic private external fun nativeSetOptionString(name: String, value: String): Int
@JvmStatic private external fun nativeHookContinue(session: Long, id: Long)
@JvmStatic private external fun nativeAttachSurface(surface: Surface)
@JvmStatic private external fun nativeSetOptionString(session: Long, name: String, value: String): Int
@JvmStatic private external fun nativeDetachSurface()
@JvmStatic private external fun nativeAttachSurface(session: Long, surface: Surface)
@JvmStatic private external fun nativeAttachOsdSurface(surface: Surface)
@JvmStatic private external fun nativeDetachSurface(session: Long)
@JvmStatic private external fun nativeDetachOsdSurface()
@JvmStatic private external fun nativeAttachOsdSurface(session: Long, surface: Surface)
@JvmStatic private external fun nativeGetPropertyInt(name: String): Int?
@JvmStatic private external fun nativeDetachOsdSurface(session: Long)
@JvmStatic private external fun nativeGetPropertyDouble(name: String): Double?
@JvmStatic private external fun nativeGetPropertyInt(session: Long, name: String): Int?
@JvmStatic private external fun nativeGetPropertyBoolean(name: String): Boolean?
@JvmStatic private external fun nativeGetPropertyDouble(session: Long, name: String): Double?
@JvmStatic private external fun nativeGetPropertyString(name: String): String?
@JvmStatic private external fun nativeGetPropertyBoolean(session: Long, name: String): Boolean?
@JvmStatic private external fun nativeSetPropertyInt(name: String, value: Int)
@JvmStatic private external fun nativeGetPropertyString(session: Long, name: String): String?
@JvmStatic private external fun nativeSetPropertyDouble(name: String, value: Double)
@JvmStatic private external fun nativeSetPropertyInt(session: Long, name: String, value: Int)
@JvmStatic private external fun nativeSetPropertyBoolean(name: String, value: Boolean)
@JvmStatic private external fun nativeSetPropertyDouble(session: Long, name: String, value: Double)
@JvmStatic private external fun nativeSetPropertyString(name: String, value: String)
@JvmStatic private external fun nativeSetPropertyBoolean(session: Long, name: String, value: Boolean)
@JvmStatic private external fun nativeObserveProperty(name: String, format: Int)
@JvmStatic private external fun nativeSetPropertyString(session: Long, name: String, value: String)
internal fun setOptionString(name: String, value: String): Int = nativeSetOptionString(name, value)
@JvmStatic private external fun nativeObserveProperty(session: Long, name: String, format: Int)
internal fun setOptionString(session: Long, name: String, value: String): Int = nativeSetOptionString(session, name, value)
internal fun requestLogMessages(session: Long, level: String) {
checkNotMainThread("MPV log level change")
val result = nativeSetLogLevel(session, level)
if (result < 0) {
throw MpvException("Failed to set log level: error $result")
}
}
}
// The native event thread hands everything to unbounded channels: trySend
@@ -222,7 +262,7 @@ class MpvPlayer private constructor() : AutoCloseable {
pumpScope.launch {
for (hook in rawHooks) {
try {
val handler = hookHandler
val handler = if (closed) null else hookHandler
if (handler != null) {
withTimeoutOrNull(HOOK_TIMEOUT_MS) { handler(hook.name) }
?: android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler overran; continuing playback")
@@ -230,7 +270,10 @@ class MpvPlayer private constructor() : AutoCloseable {
} catch (e: Exception) {
android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler failed; continuing playback", e)
} finally {
if (!closed) nativeHookContinue(hook.id)
// Validated natively against this session, atomically with its
// retirement: a continuation that lost the race is dropped, never
// delivered to a successor's core.
nativeHookContinue(session, hook.id)
}
}
}
@@ -244,89 +287,103 @@ class MpvPlayer private constructor() : AutoCloseable {
// Commands
suspend fun command(vararg args: String) {
/**
* Runs an mpv command. `loadfile` returns the id of the playlist entry it created — the
* `sourceId` carried by that source's start-file / playback-restart / end-file events; every
* other command returns null. A command mpv rejects throws [MpvException]: a rejected load
* never produces a source, so the caller must not wait for one.
*/
suspend fun command(vararg args: String): Long? {
checkNotClosed()
withContext(Dispatchers.IO) { nativeCommand(args) }
val status = withContext(Dispatchers.IO) { nativeCommand(session, args) }
if (status < 0) throw MpvException("Command '${args.firstOrNull() ?: ""}' failed: error $status")
return if (status > 0) status else null
}
/** Called on the core's ordered IO writer, without suspending between writes. */
fun setLogLevel(level: String) {
checkNotClosed()
requestLogMessages(session, level)
}
// Surface — not suspend, called from SurfaceHolder.Callback
fun attachSurface(surface: Surface) {
checkNotClosed()
nativeAttachSurface(surface)
nativeAttachSurface(session, surface)
}
fun detachSurface() {
checkNotClosed()
nativeDetachSurface()
nativeDetachSurface(session)
}
/** OSD/subtitle plane for `vo=mediacodec`; attach before selecting the VO. */
fun attachOsdSurface(surface: Surface) {
checkNotClosed()
nativeAttachOsdSurface(surface)
nativeAttachOsdSurface(session, surface)
}
fun detachOsdSurface() {
checkNotClosed()
nativeDetachOsdSurface()
nativeDetachOsdSurface(session)
}
// Property getters
suspend fun getInt(name: String): Int? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyInt(name) }
return withContext(Dispatchers.IO) { nativeGetPropertyInt(session, name) }
}
suspend fun getDouble(name: String): Double? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(name) }
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(session, name) }
}
suspend fun getFlag(name: String): Boolean? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(name) }
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(session, name) }
}
suspend fun getString(name: String): String? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyString(name) }
return withContext(Dispatchers.IO) { nativeGetPropertyString(session, name) }
}
// Property setters
suspend fun setProperty(name: String, value: Int) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyInt(name, value) }
withContext(Dispatchers.IO) { nativeSetPropertyInt(session, name, value) }
}
suspend fun setProperty(name: String, value: Double) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyDouble(name, value) }
withContext(Dispatchers.IO) { nativeSetPropertyDouble(session, name, value) }
}
suspend fun setProperty(name: String, value: Boolean) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(name, value) }
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(session, name, value) }
}
suspend fun setProperty(name: String, value: String) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyString(name, value) }
withContext(Dispatchers.IO) { nativeSetPropertyString(session, name, value) }
}
// Property observation
fun observeProperty(name: String, format: PropertyFormat): Flow<PropertyChange> {
checkNotClosed()
nativeObserveProperty(name, format.nativeValue)
nativeObserveProperty(session, name, format.nativeValue)
return propertyFlow.filter { it.name == name }
}
fun observeFlag(name: String): Flow<Boolean> {
checkNotClosed()
nativeObserveProperty(name, PropertyFormat.Flag.nativeValue)
nativeObserveProperty(session, name, PropertyFormat.Flag.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Flag>()
.filter { it.name == name }
@@ -335,7 +392,7 @@ class MpvPlayer private constructor() : AutoCloseable {
fun observeInt(name: String): Flow<Long> {
checkNotClosed()
nativeObserveProperty(name, PropertyFormat.Int64.nativeValue)
nativeObserveProperty(session, name, PropertyFormat.Int64.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Int64>()
.filter { it.name == name }
@@ -344,7 +401,7 @@ class MpvPlayer private constructor() : AutoCloseable {
fun observeDouble(name: String): Flow<Double> {
checkNotClosed()
nativeObserveProperty(name, PropertyFormat.Double.nativeValue)
nativeObserveProperty(session, name, PropertyFormat.Double.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Double>()
.filter { it.name == name }
@@ -353,7 +410,7 @@ class MpvPlayer private constructor() : AutoCloseable {
fun observeString(name: String): Flow<String> {
checkNotClosed()
nativeObserveProperty(name, PropertyFormat.String.nativeValue)
nativeObserveProperty(session, name, PropertyFormat.String.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Str>()
.filter { it.name == name }
@@ -370,16 +427,18 @@ class MpvPlayer private constructor() : AutoCloseable {
* Android main thread so a slow vendor decoder cannot stall the UI.
*/
override fun close() {
if (closed) return
checkNotMainThread("MPV destruction")
closed = true
// Only destroy native if we're still the active player.
// If create() already replaced us, nativeCreate's safety net handles native cleanup.
if (instance.compareAndSet(this, null)) {
nativeDestroy()
synchronized(instance) {
if (closed) return
checkNotMainThread("MPV destruction")
closed = true
hookHandler = null
instance.compareAndSet(this, null)
}
// After nativeDestroy no callback can produce: closing the channels
// lets each pump drain what is already queued and then complete.
// A no-op natively when a later create() already retired this session;
// the successor is never ours to destroy.
nativeDestroy(session)
// After nativeDestroy no callback can produce for this session: closing
// the channels lets each pump drain what is already queued and complete.
rawEvents.close()
rawHooks.close()
rawPropertyChanges.close()
@@ -1,10 +1,15 @@
package com.edde746.plezy.libmpv
class MpvPlayerConfig internal constructor() {
/** Pre-initialize configuration of one native session; every write names it. */
class MpvPlayerConfig internal constructor(private val session: Long) {
fun setOption(name: String, value: String) {
val result = MpvPlayer.setOptionString(name, value)
val result = MpvPlayer.setOptionString(session, name, value)
if (result < 0) {
throw MpvException("Failed to set option '$name' to '$value': error $result")
}
}
fun setLogLevel(level: String) {
MpvPlayer.requestLogMessages(session, level)
}
}
-6
View File
@@ -1,26 +1,20 @@
PODS:
- Flutter (1.0.0)
- os_media_controls (0.2.4):
- Flutter
- universal_gamepad (1.5.8):
- Flutter
DEPENDENCIES:
- Flutter (from `Flutter`)
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
- universal_gamepad (from `.symlinks/plugins/universal_gamepad/ios`)
EXTERNAL SOURCES:
Flutter:
:path: Flutter
os_media_controls:
:path: ".symlinks/plugins/os_media_controls/ios"
universal_gamepad:
:path: ".symlinks/plugins/universal_gamepad/ios"
SPEC CHECKSUMS:
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
os_media_controls: 048eb9a75974191b2496d85575b6edcefc572d4e
universal_gamepad: 7c0cc0c2e3909dfb803d325387e42963b3ed842c
PODFILE CHECKSUM: 0adb115f74a3cde46cf0e15649428c9b4e6975a0
+1 -1
View File
@@ -802,7 +802,7 @@
repositoryURL = "https://github.com/edde746/mpv-build";
requirement = {
kind = revision;
revision = 0601b034da501abc0ce84d2f8fba9e5c9520ad79;
revision = 3fd89c259b6ff6ef9a4c25e377d1287fb1407c9a;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -32,7 +32,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/mpv-build",
"state" : {
"revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79"
"revision" : "3fd89c259b6ff6ef9a4c25e377d1287fb1407c9a"
}
},
{
@@ -32,7 +32,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/mpv-build",
"state" : {
"revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79"
"revision" : "3fd89c259b6ff6ef9a4c25e377d1287fb1407c9a"
}
},
{
+195 -47
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -232,6 +234,14 @@ KeyEventResult _handleInputKey({
if (result != KeyEventResult.ignored) return finish(result, 'custom-tv-hardware-keyboard');
}
// A TV back press this field already claimed on KeyDown (its own onBack
// below, or the host closing a native session) leaves the matching KeyUp
// in flight: swallow it here regardless of onBack, so a field without one
// never lets the KeyUp bubble to an ancestor that acts on KeyUp.
if (event.logicalKey.isBackKey && PlatformDetector.isTV() && BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return finish(KeyEventResult.handled, 'suppressed-back');
}
if (onBack != null && event.logicalKey.isBackKey) {
// On TV the native text-input path can swallow the matching KeyUp (the
// closing IME session eats it), so back fires on KeyDown — the same
@@ -239,7 +249,6 @@ KeyEventResult _handleInputKey({
// mark included so a parallel back dispatch still dedupes. Elsewhere the
// shared handler's KeyUp semantics apply.
if (PlatformDetector.isTV()) {
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) return finish(KeyEventResult.handled, 'onBack');
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
onBack();
@@ -792,7 +801,29 @@ class _FocusableTextInputHost extends StatefulWidget {
State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState();
}
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
enum _NativeTextInputEndReason {
dismissed,
completed,
explicitClose,
restart,
focusLost,
configurationChanged,
disposed,
}
/// Kept after dismissal so an IME action already in flight can still complete
/// this session, but never a replacement session on the same field.
class _NativeTextInputSession {
final FocusNode focusNode;
_NativeTextInputEndReason? endReason;
bool keyboardWasVisible = false;
_NativeTextInputSession(this.focusNode);
bool get isActive => endReason == null;
}
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> with WidgetsBindingObserver {
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
FocusNode? _installedFocusNode;
FocusOnKeyEventCallback? _previousOnKeyEvent;
@@ -806,10 +837,13 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
bool _suppressTvKeyboardAutoOpen = false;
bool _hasSeenTvKeyboardFocus = false;
bool _suppressTvKeyboardForCurrentFocus = false;
bool _nativeTextInputActivated = false;
_NativeTextInputSession? _nativeTextInputSession;
int _nativeTextInputGeneration = 0;
ui.FlutterView? _nativeTextInputView;
bool _hasSeenNativeTextInputFocus = false;
bool _suppressNativeTextInputForCurrentFocus = false;
bool _nativeTextInputCompletionHandled = false;
bool get _nativeTextInputActivated => _nativeTextInputSession?.isActive == true;
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
@@ -820,6 +854,40 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
widget.input.tvTextInputController?._attach(this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_nativeTextInputView != null) {
final view = View.of(context);
if (!identical(view, _nativeTextInputView)) {
_nativeTextInputView = view;
_nativeTextInputSession?.keyboardWasVisible = view.viewInsets.bottom > 0;
_nativeTextInputGeneration++;
}
}
}
@override
void didChangeMetrics() {
final session = _nativeTextInputSession;
if (!mounted || session == null || !session.isActive || !session.focusNode.hasFocus) return;
final view = View.of(context);
if (!identical(view, _nativeTextInputView)) {
_nativeTextInputView = view;
session.keyboardWasVisible = view.viewInsets.bottom > 0;
_nativeTextInputGeneration++;
return;
}
if (view.viewInsets.bottom > 0) {
session.keyboardWasVisible = true;
} else if (session.keyboardWasVisible) {
// Leanback can consume Back and hide without closing the connection or
// sending performAction. Zero before any visible metrics is not a hide:
// the IME may still be opening, or this may be a hardware keyboard.
_endNativeTextInput(_NativeTextInputEndReason.dismissed);
}
}
@override
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -836,13 +904,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_tvKeyboardOpenScheduled = false;
_hasSeenTvKeyboardFocus = false;
_suppressTvKeyboardForCurrentFocus = false;
_nativeTextInputActivated = false;
_endNativeTextInput(_NativeTextInputEndReason.configurationChanged, rebuild: false);
_hasSeenNativeTextInputFocus = false;
_suppressNativeTextInputForCurrentFocus = false;
}
if (oldWidget.input.tvTextInputPresentation != widget.input.tvTextInputPresentation ||
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior) {
_nativeTextInputActivated = false;
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior ||
oldWidget.input.enabled != widget.input.enabled ||
oldWidget.input.controller != widget.input.controller ||
oldWidget.input.keyboardType != widget.input.keyboardType ||
oldWidget.input.maxLines != widget.input.maxLines) {
_endNativeTextInput(_NativeTextInputEndReason.configurationChanged, rebuild: false);
_hasSeenNativeTextInputFocus = false;
_suppressNativeTextInputForCurrentFocus = false;
}
@@ -854,6 +926,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
@override
void dispose() {
widget.input.tvTextInputController?._detach(this);
_endNativeTextInput(_NativeTextInputEndReason.disposed, rebuild: false);
_restoreInstalledHandler();
// The keyboard is a navigator route — it must not outlive the field that
// opened it (e.g. a form section swapped out while the keyboard is up).
@@ -895,11 +968,12 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
final scope = node.enclosingScope;
if (scope == null || !identical(FocusManager.instance.primaryFocus, scope)) return;
_setNativeTextInputActivated(false);
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
final generation = _nativeTextInputGeneration;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!mounted || generation != _nativeTextInputGeneration) return;
final target = _installedFocusNode;
if (target == null || target.hasFocus || !target.canRequestFocus) return;
if (!identical(target, node) || target == null || target.hasFocus || !target.canRequestFocus) return;
if (!identical(FocusManager.instance.primaryFocus, scope)) return;
// Set before requesting focus so the resulting focus-change callback
// cannot reopen the keyboard we were just dismissed out of.
@@ -913,7 +987,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
if (!focused) {
_suppressNativeTextInputForCurrentFocus = false;
_setNativeTextInputActivated(false);
_endNativeTextInput(_NativeTextInputEndReason.focusLost);
return;
}
if (_suppressNativeTextInputForCurrentFocus) return;
@@ -929,47 +1003,109 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
case TvTextInputAutoOpenBehavior.automatic:
if (PlatformDetector.isAppleTV() && _hasSeenNativeTextInputFocus) return;
_hasSeenNativeTextInputFocus = true;
_setNativeTextInputActivated(true);
_beginNativeTextInput();
case TvTextInputAutoOpenBehavior.afterFirstFocus:
if (!_hasSeenNativeTextInputFocus) {
_hasSeenNativeTextInputFocus = true;
_suppressNativeTextInputForCurrentFocus = true;
return;
}
_setNativeTextInputActivated(true);
_beginNativeTextInput();
case TvTextInputAutoOpenBehavior.never:
return;
}
}
void _setNativeTextInputActivated(bool activated) {
if (_nativeTextInputActivated == activated) return;
if (activated) _nativeTextInputCompletionHandled = false;
if (!mounted) {
_nativeTextInputActivated = activated;
void _beginNativeTextInput() {
final node = _installedFocusNode;
if (_nativeTextInputActivated || node == null || !node.hasFocus) return;
_nativeTextInputGeneration++;
_nativeTextInputSession = _NativeTextInputSession(node);
if (!PlatformDetector.isAppleTV() && defaultTargetPlatform == TargetPlatform.android) {
// Observe the owning view, not MediaQuery: Scaffold removes the bottom
// inset before building its body. A focus handoff can keep the same IME
// visible, so sample the current view rather than wait for another show.
_nativeTextInputView = View.of(context);
_nativeTextInputSession!.keyboardWasVisible = _nativeTextInputView!.viewInsets.bottom > 0;
WidgetsBinding.instance.addObserver(this);
}
_syncNativeTextInputFocus();
setState(() {});
}
void _endNativeTextInput(_NativeTextInputEndReason reason, {bool rebuild = true}) {
final session = _nativeTextInputSession;
if (session == null) {
if (reason == _NativeTextInputEndReason.explicitClose ||
reason == _NativeTextInputEndReason.configurationChanged ||
reason == _NativeTextInputEndReason.disposed) {
_nativeTextInputGeneration++;
}
return;
}
setState(() => _nativeTextInputActivated = activated);
if (session.endReason == reason) return;
final wasActive = session.isActive;
session.endReason = reason;
session.keyboardWasVisible = false;
_nativeTextInputGeneration++;
if (_nativeTextInputView != null) {
WidgetsBinding.instance.removeObserver(this);
_nativeTextInputView = null;
}
if (reason == _NativeTextInputEndReason.dismissed ||
reason == _NativeTextInputEndReason.completed ||
reason == _NativeTextInputEndReason.explicitClose ||
reason == _NativeTextInputEndReason.restart) {
_suppressNativeTextInputForCurrentFocus = true;
}
_setNativeTextInputFocused(false);
if (wasActive && rebuild && mounted) setState(() {});
}
void _scheduleNativeTextInputReactivation() {
final generation = _nativeTextInputGeneration;
final node = _installedFocusNode;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted ||
generation != _nativeTextInputGeneration ||
!identical(node, _installedFocusNode) ||
node?.hasFocus != true) {
return;
}
_activateNativeTextInput();
});
}
void _activateNativeTextInput() {
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
_hasSeenNativeTextInputFocus = true;
_suppressNativeTextInputForCurrentFocus = false;
_setNativeTextInputActivated(true);
_beginNativeTextInput();
}
VoidCallback? get _effectiveOnEditingComplete {
final input = widget.input;
if (!input._usesNativeTvKeyboard) return input._effectiveOnEditingComplete;
return _handleNativeEditingComplete;
final session = _nativeTextInputSession;
return () => _handleNativeEditingComplete(session);
}
void _handleNativeEditingComplete() {
if (_nativeTextInputCompletionHandled) return;
_nativeTextInputCompletionHandled = true;
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
void _handleNativeEditingComplete(_NativeTextInputSession? session) {
if (!mounted ||
session == null ||
!identical(session, _nativeTextInputSession) ||
!session.focusNode.hasFocus ||
!widget.input.enabled ||
!widget.input._usesNativeTvKeyboard) {
return;
}
// Hide and performAction are separate platform notifications. A hide is
// not completion, but must not consume a real Done/Previous delivered for
// this same connection before EditableText detaches it at rebuild. Accept
// that action once even if metrics won the race; every other terminal
// reason (or a successor session) makes the callback stale.
if (!session.isActive && session.endReason != _NativeTextInputEndReason.dismissed) return;
_endNativeTextInput(_NativeTextInputEndReason.completed);
final input = widget.input;
final callback = input._effectiveOnEditingComplete;
@@ -1139,9 +1275,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
/// that must show results instead of the keyboard). Suppresses auto-reopen
/// so a field that keeps or regains focus does not relaunch it.
void _dismissTvKeyboard() {
if (widget.input._usesNativeTvKeyboard && _nativeTextInputActivated) {
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
if (widget.input._usesNativeTvKeyboard) {
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
}
// No-op for the Flutter presentation when no overlay is up: setting its
@@ -1161,12 +1296,20 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_suppressNativeTextInputForCurrentFocus = true;
_tvKeyboardOpenScheduled = false;
if (widget.input._usesNativeTvKeyboard) {
_setNativeTextInputActivated(false);
_endNativeTextInput(_NativeTextInputEndReason.explicitClose);
}
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
final generation = _nativeTextInputGeneration;
focusNode.requestFocus();
scheduleMicrotask(() {
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
if (!mounted ||
generation != _nativeTextInputGeneration ||
!identical(focusNode, _installedFocusNode) ||
focusNode.hasFocus ||
_tvKeyboardOpen ||
_tvKeyboardOpenScheduled) {
return;
}
_suppressTvKeyboardAutoOpen = false;
_suppressNativeTextInputForCurrentFocus = false;
});
@@ -1184,11 +1327,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
return;
}
focusNode.requestFocus();
final generation = _nativeTextInputGeneration;
// Focus lands in FocusManager's microtask. Activating before that would be
// undone by the focus sync this frame's build already scheduled, which
// deactivates an unfocused field.
scheduleMicrotask(() {
if (mounted && focusNode.hasFocus) _openTextInputForFocusedField();
if (mounted &&
generation == _nativeTextInputGeneration &&
identical(focusNode, _installedFocusNode) &&
focusNode.hasFocus) {
_openTextInputForFocusedField();
}
});
}
@@ -1238,8 +1387,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
// keyboard while Flutter focus stayed on the field. Restore the
// read-only gate so this press navigates Flutter instead of reopening
// the input connection.
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
_endNativeTextInput(
event.isTvSelectEvent ? _NativeTextInputEndReason.restart : _NativeTextInputEndReason.dismissed,
);
activateNativeTextInput = true;
if (event.logicalKey.isBackKey) {
// This is the Menu press that dismissed UIKit's keyboard. Consume its
@@ -1247,30 +1397,28 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
return KeyEventResult.handled;
}
if (event.isTvSelectEvent) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _activateNativeTextInput();
});
_scheduleNativeTextInputReactivation();
return KeyEventResult.handled;
}
} else if (event.logicalKey.isBackKey) {
// Android: a healthy IME consumes Back to dismiss itself before the
// app ever sees it. One arriving here means the keyboard is already
// gone (or its key session is broken and MainActivity's repair budget
// ran out): close the session and consume the press so it cannot also
// pop the route underneath.
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
// ran out): close the session and claim the whole press — coordinator
// mark so a same-press platform popRoute dedupes, suppressor armed so
// the matching KeyUp (delivered to this node with the session closed,
// or to an ancestor acting on KeyUp) cannot pop the route underneath.
_endNativeTextInput(_NativeTextInputEndReason.dismissed);
BackKeyCoordinator.markHandled();
BackKeyUpSuppressor.suppressBackUntilKeyUp();
return KeyEventResult.handled;
} else if (event.isTvSelectEvent) {
// Android: Select on a field whose keyboard was dismissed re-raises
// it (EditText parity). Toggle the connection so the engine issues a
// fresh TextInput.show; MainActivity's show-retry covers the
// served-view race (#1051/#1079).
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _activateNativeTextInput();
});
_endNativeTextInput(_NativeTextInputEndReason.restart);
_scheduleNativeTextInputReactivation();
return KeyEventResult.handled;
}
// Android arrows fall through deliberately: a healthy visible IME
@@ -1311,7 +1459,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
void _restoreInstalledHandler() {
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
_setNativeTextInputFocused(false);
_nativeTextInputActivated = false;
_endNativeTextInput(_NativeTextInputEndReason.focusLost, rebuild: false);
_suppressNativeTextInputForCurrentFocus = false;
final node = _installedFocusNode;
if (node != null) {
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Server yanlış oynatma məlumatı qaytardı.",
"playbackCancelled": "Oynatma ləğv edildi.",
"playbackFailed": "Oynatma başladılarkən xəta.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fayl məlumatı yüklənərkən xəta: ${error}",
"errorLoadingSeries": "Serial yüklənərkən xəta",
"musicNotSupported": "Musiqi oynatması hələ dəstəklənmir",
@@ -1660,7 +1661,8 @@
"timedOut": "Rele serveri vaxtında cavab vermədi",
"connectionLost": "Bağlantı seans hazır olmamış kəsildi",
"invalidRelayResponse": "Rele serveri gözlənilməz cavab göndərdi",
"sessionEnded": "Təşkilatçı seansı bitirdi"
"sessionEnded": "Təşkilatçı seansı bitirdi",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr sessiya kukisi yaratmadı",
"freshCookieRejected": "Seerr yeni sessiya kukisini rədd etdi",
"noUserInformation": "Seerr istifadəçi məlumatlarını qaytarmadı",
"sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi"
"sessionRejectedAfterReauth": "Yenidən daxil olduqdan sonra sessiya rədd edildi",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Xidmətlər",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.",
"playbackCancelled": "Възпроизвеждането беше отменено.",
"playbackFailed": "Възпроизвеждането не можа да бъде стартирано.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
"errorLoadingSeries": "Грешка при зареждане на сериала",
"musicNotSupported": "Възпроизвеждането на музика все още не се поддържа",
@@ -1660,7 +1661,8 @@
"timedOut": "Релейният сървър не отговори навреме",
"connectionLost": "Връзката се затвори, преди сесията да е готова",
"invalidRelayResponse": "Релейният сървър изпрати неочакван отговор",
"sessionEnded": "Организаторът прекрати сесията"
"sessionEnded": "Организаторът прекрати сесията",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr не издаде бисквитка за сесията",
"freshCookieRejected": "Seerr отхвърли новата бисквитка за сесията",
"noUserInformation": "Seerr не върна информация за потребителя",
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход"
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Услуги",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Serveren returnerede ugyldige afspilningsoplysninger.",
"playbackCancelled": "Afspilningen blev annulleret.",
"playbackFailed": "Afspilningen kunne ikke startes.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fejl ved indlæsning af filinfo: ${error}",
"errorLoadingSeries": "Fejl ved indlæsning af serie",
"musicNotSupported": "Musikafspilning understøttes endnu ikke",
@@ -1660,7 +1661,8 @@
"timedOut": "Relayserveren svarede ikke i tide",
"connectionLost": "Forbindelsen blev lukket, før sessionen var klar",
"invalidRelayResponse": "Relayserveren sendte et uventet svar",
"sessionEnded": "Værten afsluttede sessionen"
"sessionEnded": "Værten afsluttede sessionen",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr udstedte ikke en sessionscookie",
"freshCookieRejected": "Seerr afviste den nye sessionscookie",
"noUserInformation": "Seerr returnerede ikke brugeroplysninger",
"sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen"
"sessionRejectedAfterReauth": "Sessionen blev afvist efter at være logget ind igen",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Tjenester",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Der Server hat ungültige Wiedergabeinformationen zurückgegeben.",
"playbackCancelled": "Die Wiedergabe wurde abgebrochen.",
"playbackFailed": "Die Wiedergabe konnte nicht gestartet werden.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
"errorLoadingSeries": "Fehler beim Laden der Serie",
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
@@ -1660,7 +1661,8 @@
"timedOut": "Der Relay-Server hat nicht rechtzeitig geantwortet",
"connectionLost": "Die Verbindung wurde geschlossen, bevor die Sitzung bereit war",
"invalidRelayResponse": "Der Relay-Server hat eine unerwartete Antwort gesendet",
"sessionEnded": "Der Host hat die Sitzung beendet"
"sessionEnded": "Der Host hat die Sitzung beendet",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr hat kein Sitzungscookie ausgestellt",
"freshCookieRejected": "Seerr hat das neue Sitzungscookie abgelehnt",
"noUserInformation": "Seerr hat keine Benutzerinformationen zurückgegeben",
"sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt"
"sessionRejectedAfterReauth": "Die Sitzung wurde nach der erneuten Anmeldung abgelehnt",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Dienste",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "The server returned invalid playback information.",
"playbackCancelled": "Playback was canceled.",
"playbackFailed": "Playback could not be started.",
"mediaUnavailable": "This content is no longer available.",
"errorLoadingFileInfo": "Error loading file info: ${error}",
"errorLoadingSeries": "Error loading series",
"musicNotSupported": "Music playback is not yet supported",
@@ -1660,7 +1661,8 @@
"timedOut": "The relay did not respond in time",
"connectionLost": "The connection closed before the session was ready",
"invalidRelayResponse": "The relay sent an unexpected response",
"sessionEnded": "The host ended the session"
"sessionEnded": "The host ended the session",
"sessionUnavailable": "Unable to resume this session. Join or create a room to continue."
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr did not issue a session cookie",
"freshCookieRejected": "Seerr rejected the new session cookie",
"noUserInformation": "Seerr did not return user information",
"sessionRejectedAfterReauth": "The session was rejected after signing in again"
"sessionRejectedAfterReauth": "The session was rejected after signing in again",
"permissionDenied": "Seerr denied this action: your account no longer has the required permission",
"permissionRevoked": "You no longer have permission to request this"
},
"services": {
"title": "Services",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "El servidor devolvió información de reproducción no válida.",
"playbackCancelled": "Se canceló la reproducción.",
"playbackFailed": "No se pudo iniciar la reproducción.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Error al cargar la información del archivo: ${error}",
"errorLoadingSeries": "Error al cargar la serie",
"musicNotSupported": "La reproducción de música aún no es compatible",
@@ -1660,7 +1661,8 @@
"timedOut": "El servidor de retransmisión no respondió a tiempo",
"connectionLost": "La conexión se cerró antes de que la sesión estuviera lista",
"invalidRelayResponse": "El servidor de retransmisión envió una respuesta inesperada",
"sessionEnded": "El anfitrión finalizó la sesión"
"sessionEnded": "El anfitrión finalizó la sesión",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr no proporcionó una cookie de sesión",
"freshCookieRejected": "Seerr rechazó la nueva cookie de sesión",
"noUserInformation": "Seerr no devolvió información del usuario",
"sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión"
"sessionRejectedAfterReauth": "La sesión fue rechazada después de volver a iniciar sesión",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Servicios",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Le serveur a renvoyé des informations de lecture non valides.",
"playbackCancelled": "La lecture a été annulée.",
"playbackFailed": "Impossible de démarrer la lecture.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Erreur lors du chargement des informations sur le fichier : ${error}",
"errorLoadingSeries": "Erreur lors du chargement de la série",
"musicNotSupported": "La lecture de musique n'est pas encore prise en charge",
@@ -1660,7 +1661,8 @@
"timedOut": "Le serveur relais n’a pas répondu à temps",
"connectionLost": "La connexion s’est fermée avant que la session ne soit prête",
"invalidRelayResponse": "Le serveur relais a renvoyé une réponse inattendue",
"sessionEnded": "L’hôte a mis fin à la session"
"sessionEnded": "L’hôte a mis fin à la session",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr n’a pas fourni de cookie de session",
"freshCookieRejected": "Seerr a refusé le nouveau cookie de session",
"noUserInformation": "Seerr n’a renvoyé aucune information sur l’utilisateur",
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion"
"sessionRejectedAfterReauth": "La session a été refusée après la reconnexion",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Services",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "A szerver érvénytelen lejátszási adatokat küldött.",
"playbackCancelled": "A lejátszás megszakítva.",
"playbackFailed": "Nem sikerült elindítani a lejátszást.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Hiba a fájlinformációk betöltésekor: ${error}",
"errorLoadingSeries": "Hiba a sorozat betöltésekor",
"musicNotSupported": "A zenelejátszás még nem támogatott",
@@ -1660,7 +1661,8 @@
"timedOut": "A relészerver nem válaszolt időben",
"connectionLost": "A kapcsolat lezárult, mielőtt a munkamenet elkészült volna",
"invalidRelayResponse": "A relészerver váratlan választ küldött",
"sessionEnded": "A házigazda befejezte a munkamenetet"
"sessionEnded": "A házigazda befejezte a munkamenetet",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "A Seerr nem adott ki munkamenet-sütit",
"freshCookieRejected": "A Seerr elutasította az új munkamenet-sütit",
"noUserInformation": "A Seerr nem adott vissza felhasználói adatokat",
"sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították"
"sessionRejectedAfterReauth": "A munkamenetet az újbóli bejelentkezés után elutasították",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Szolgáltatások",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Il server ha restituito informazioni di riproduzione non valide.",
"playbackCancelled": "Riproduzione annullata.",
"playbackFailed": "Impossibile avviare la riproduzione.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Errore durante il caricamento delle informazioni sul file: ${error}",
"errorLoadingSeries": "Errore durante il caricamento della serie",
"musicNotSupported": "La riproduzione musicale non è ancora supportata",
@@ -1660,7 +1661,8 @@
"timedOut": "Il server relay non ha risposto in tempo",
"connectionLost": "La connessione si è chiusa prima che la sessione fosse pronta",
"invalidRelayResponse": "Il server relay ha inviato una risposta imprevista",
"sessionEnded": "L’host ha terminato la sessione"
"sessionEnded": "L’host ha terminato la sessione",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr non ha generato un cookie di sessione",
"freshCookieRejected": "Seerr ha rifiutato il nuovo cookie di sessione",
"noUserInformation": "Seerr non ha restituito le informazioni sull'utente",
"sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso"
"sessionRejectedAfterReauth": "La sessione è stata rifiutata dopo aver effettuato nuovamente l'accesso",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Servizi",
+6 -2
View File
@@ -775,6 +775,7 @@
"playbackDataInvalid": "サーバーから無効な再生情報が返されました。",
"playbackCancelled": "再生がキャンセルされました。",
"playbackFailed": "再生を開始できませんでした。",
"mediaUnavailable": "",
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
"errorLoadingSeries": "シリーズの読み込みエラー",
"musicNotSupported": "音楽の再生はまだサポートされていません",
@@ -1649,7 +1650,8 @@
"timedOut": "リレーサーバーが時間内に応答しませんでした",
"connectionLost": "セッションの準備が整う前に接続が閉じられました",
"invalidRelayResponse": "リレーサーバーから予期しない応答が返されました",
"sessionEnded": "ホストがセッションを終了しました"
"sessionEnded": "ホストがセッションを終了しました",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2105,7 +2107,9 @@
"noSessionCookie": "Seerrからセッションクッキーが発行されませんでした",
"freshCookieRejected": "Seerrが新しいセッションクッキーを拒否しました",
"noUserInformation": "Seerrからユーザー情報が返されませんでした",
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました"
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "サービス",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.",
"playbackCancelled": "Ойнатудан бас тартылды.",
"playbackFailed": "Ойнатуды іске қосу қатесі.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}",
"errorLoadingSeries": "Сериалды жүктеу қатесі",
"musicNotSupported": "Музыка ойнату әлі қолдау таппайды",
@@ -1660,7 +1661,8 @@
"timedOut": "Реле сервері уақытында жауап бермеді",
"connectionLost": "Сеанс дайын болмай тұрып байланыс үзілді",
"invalidRelayResponse": "Реле сервері күтпеген жауап жіберді",
"sessionEnded": "Ұйымдастырушы сеансты аяқтады"
"sessionEnded": "Ұйымдастырушы сеансты аяқтады",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr сеанс cookie файлын бермеді",
"freshCookieRejected": "Seerr жаңа сеанс cookie файлын қабылдамады",
"noUserInformation": "Seerr пайдаланушы туралы мәліметтерді қайтармады",
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады"
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Қызметтер",
+6 -2
View File
@@ -775,6 +775,7 @@
"playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.",
"playbackCancelled": "재생이 취소되었습니다.",
"playbackFailed": "재생을 시작할 수 없습니다.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
"errorLoadingSeries": "시리즈 로딩 중 오류",
"musicNotSupported": "음악 재생 미지원",
@@ -1649,7 +1650,8 @@
"timedOut": "릴레이 서버가 제시간에 응답하지 않았습니다",
"connectionLost": "세션이 준비되기 전에 연결이 종료되었습니다",
"invalidRelayResponse": "릴레이 서버가 예기치 않은 응답을 보냈습니다",
"sessionEnded": "호스트가 세션을 종료했습니다"
"sessionEnded": "호스트가 세션을 종료했습니다",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2105,7 +2107,9 @@
"noSessionCookie": "Seerr에서 세션 쿠키를 발급하지 않았습니다",
"freshCookieRejected": "Seerr에서 새 세션 쿠키를 거부했습니다",
"noUserInformation": "Seerr에서 사용자 정보를 반환하지 않았습니다",
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다"
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "서비스",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Serveren returnerte ugyldig avspillingsinformasjon.",
"playbackCancelled": "Avspillingen ble avbrutt.",
"playbackFailed": "Kunne ikke starte avspillingen.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Feil ved lasting av filinformasjon: ${error}",
"errorLoadingSeries": "Feil ved lasting av serie",
"musicNotSupported": "Musikkavspilling støttes ikke ennå",
@@ -1660,7 +1661,8 @@
"timedOut": "Reléserveren svarte ikke i tide",
"connectionLost": "Tilkoblingen ble lukket før økten var klar",
"invalidRelayResponse": "Reléserveren sendte et uventet svar",
"sessionEnded": "Verten avsluttet økten"
"sessionEnded": "Verten avsluttet økten",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr utstedte ingen øktinformasjonskapsel",
"freshCookieRejected": "Seerr avviste den nye øktinformasjonskapselen",
"noUserInformation": "Seerr returnerte ingen brukerinformasjon",
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging"
"sessionRejectedAfterReauth": "Økten ble avvist etter ny innlogging",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Tjenester",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "De server heeft ongeldige afspeelinformatie geretourneerd.",
"playbackCancelled": "Het afspelen is geannuleerd.",
"playbackFailed": "Het afspelen kon niet worden gestart.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fout bij laden van bestandsinformatie: ${error}",
"errorLoadingSeries": "Fout bij laden van serie",
"musicNotSupported": "Muziek afspelen wordt nog niet ondersteund",
@@ -1660,7 +1661,8 @@
"timedOut": "De relayserver heeft niet op tijd gereageerd",
"connectionLost": "De verbinding is verbroken voordat de sessie gereed was",
"invalidRelayResponse": "De relayserver heeft een onverwacht antwoord verzonden",
"sessionEnded": "De host heeft de sessie beëindigd"
"sessionEnded": "De host heeft de sessie beëindigd",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr heeft geen sessiecookie verstrekt",
"freshCookieRejected": "Seerr heeft de nieuwe sessiecookie geweigerd",
"noUserInformation": "Seerr heeft geen gebruikersgegevens geretourneerd",
"sessionRejectedAfterReauth": "De sessie is na het opnieuw inloggen geweigerd"
"sessionRejectedAfterReauth": "De sessie is na het opnieuw inloggen geweigerd",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Diensten",
+6 -2
View File
@@ -787,6 +787,7 @@
"playbackDataInvalid": "Serwer zwrócił nieprawidłowe informacje o odtwarzaniu.",
"playbackCancelled": "Odtwarzanie zostało anulowane.",
"playbackFailed": "Nie udało się rozpocząć odtwarzania.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Błąd ładowania informacji o pliku: ${error}",
"errorLoadingSeries": "Błąd ładowania serialu",
"musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane",
@@ -1682,7 +1683,8 @@
"timedOut": "Serwer pośredniczący nie odpowiedział w wymaganym czasie",
"connectionLost": "Połączenie zostało zamknięte, zanim sesja była gotowa",
"invalidRelayResponse": "Serwer pośredniczący wysłał nieoczekiwaną odpowiedź",
"sessionEnded": "Gospodarz zakończył sesję"
"sessionEnded": "Gospodarz zakończył sesję",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2138,7 +2140,9 @@
"noSessionCookie": "Seerr nie utworzył pliku cookie sesji",
"freshCookieRejected": "Seerr odrzucił nowy plik cookie sesji",
"noUserInformation": "Seerr nie zwrócił informacji o użytkowniku",
"sessionRejectedAfterReauth": "Sesja została odrzucona po ponownym zalogowaniu"
"sessionRejectedAfterReauth": "Sesja została odrzucona po ponownym zalogowaniu",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Usługi",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "O servidor retornou informações de reprodução inválidas.",
"playbackCancelled": "A reprodução foi cancelada.",
"playbackFailed": "Não foi possível iniciar a reprodução.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Erro ao carregar as informações do arquivo: ${error}",
"errorLoadingSeries": "Erro ao carregar série",
"musicNotSupported": "A reprodução de música ainda não é compatível",
@@ -1660,7 +1661,8 @@
"timedOut": "O servidor de retransmissão não respondeu a tempo",
"connectionLost": "A conexão foi encerrada antes de a sessão ficar pronta",
"invalidRelayResponse": "O servidor de retransmissão enviou uma resposta inesperada",
"sessionEnded": "O anfitrião encerrou a sessão"
"sessionEnded": "O anfitrião encerrou a sessão",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "O Seerr não forneceu um cookie de sessão",
"freshCookieRejected": "O Seerr rejeitou o novo cookie de sessão",
"noUserInformation": "O Seerr não retornou informações do usuário",
"sessionRejectedAfterReauth": "A sessão foi rejeitada após entrar novamente"
"sessionRejectedAfterReauth": "A sessão foi rejeitada após entrar novamente",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Serviços",
+6 -2
View File
@@ -787,6 +787,7 @@
"playbackDataInvalid": "Сервер вернул некорректные сведения для воспроизведения.",
"playbackCancelled": "Воспроизведение отменено.",
"playbackFailed": "Не удалось начать воспроизведение.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}",
"errorLoadingSeries": "Ошибка загрузки сериала",
"musicNotSupported": "Воспроизведение музыки пока не поддерживается",
@@ -1682,7 +1683,8 @@
"timedOut": "Сервер ретрансляции не ответил вовремя",
"connectionLost": "Соединение закрылось до того, как сессия была готова",
"invalidRelayResponse": "Сервер ретрансляции прислал неожиданный ответ",
"sessionEnded": "Организатор завершил сессию"
"sessionEnded": "Организатор завершил сессию",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2138,7 +2140,9 @@
"noSessionCookie": "Seerr не выдал cookie-файл сеанса",
"freshCookieRejected": "Seerr отклонил новый cookie-файл сеанса",
"noUserInformation": "Seerr не вернул сведения о пользователе",
"sessionRejectedAfterReauth": "Сеанс был отклонён после повторного входа"
"sessionRejectedAfterReauth": "Сеанс был отклонён после повторного входа",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Сервисы",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 43683 (1985 per locale)
/// Strings: 43687 (1985 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+19 -3
View File
@@ -2272,6 +2272,9 @@ class Translations$messages$en {
/// en: 'Playback could not be started.'
String get playbackFailed => 'Playback could not be started.';
/// en: 'This content is no longer available.'
String get mediaUnavailable => 'This content is no longer available.';
/// en: 'Error loading file info: ${error}'
String errorLoadingFileInfo({required Object error}) => 'Error loading file info: ${error}';
@@ -5335,6 +5338,12 @@ class Translations$seerr$en {
/// en: 'The session was rejected after signing in again'
String get sessionRejectedAfterReauth => 'The session was rejected after signing in again';
/// en: 'Seerr denied this action: your account no longer has the required permission'
String get permissionDenied => 'Seerr denied this action: your account no longer has the required permission';
/// en: 'You no longer have permission to request this'
String get permissionRevoked => 'You no longer have permission to request this';
}
// Path: services
@@ -6559,6 +6568,9 @@ class Translations$watchTogether$errors$en {
/// en: 'The host ended the session'
String get sessionEnded => 'The host ended the session';
/// en: 'Unable to resume this session. Join or create a room to continue.'
String get sessionUnavailable => 'Unable to resume this session. Join or create a room to continue.';
}
// Path: downloads.backgroundWarning
@@ -7737,6 +7749,7 @@ extension on Translations {
'messages.playbackDataInvalid' => 'The server returned invalid playback information.',
'messages.playbackCancelled' => 'Playback was canceled.',
'messages.playbackFailed' => 'Playback could not be started.',
'messages.mediaUnavailable' => 'This content is no longer available.',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}',
'messages.errorLoadingSeries' => 'Error loading series',
'messages.musicNotSupported' => 'Music playback is not yet supported',
@@ -8032,9 +8045,9 @@ extension on Translations {
'libraries.folders' => 'folders',
'libraries.tabs.recommended' => 'Recommended',
'libraries.tabs.browse' => 'Browse',
'libraries.tabs.collections' => 'Collections',
_ => null,
} ?? switch (path) {
'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Grouping',
'libraries.groupings.all' => 'All',
@@ -8511,6 +8524,7 @@ extension on Translations {
'watchTogether.errors.connectionLost' => 'The connection closed before the session was ready',
'watchTogether.errors.invalidRelayResponse' => 'The relay sent an unexpected response',
'watchTogether.errors.sessionEnded' => 'The host ended the session',
'watchTogether.errors.sessionUnavailable' => 'Unable to resume this session. Join or create a room to continue.',
'downloads.title' => 'Downloads',
'downloads.manage' => 'Manage',
'downloads.tvShows' => 'TV Shows',
@@ -8545,10 +8559,10 @@ extension on Translations {
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloading ${files}',
'downloads.noDownloadsTree' => 'No downloads',
'downloads.pauseAll' => 'Pause all',
'downloads.resumeAll' => 'Resume all',
'downloads.deleteAll' => 'Delete all',
_ => null,
} ?? switch (path) {
'downloads.resumeAll' => 'Resume all',
'downloads.deleteAll' => 'Delete all',
'downloads.selectVersion' => 'Select Version',
'downloads.allEpisodes' => 'All episodes',
'downloads.unwatchedOnly' => 'Unwatched only',
@@ -8936,6 +8950,8 @@ extension on Translations {
'seerr.freshCookieRejected' => 'Seerr rejected the new session cookie',
'seerr.noUserInformation' => 'Seerr did not return user information',
'seerr.sessionRejectedAfterReauth' => 'The session was rejected after signing in again',
'seerr.permissionDenied' => 'Seerr denied this action: your account no longer has the required permission',
'seerr.permissionRevoked' => 'You no longer have permission to request this',
'services.title' => 'Services',
'services.hubSubtitle' => 'Sync watch progress and request new titles.',
'services.integrations' => 'Integrations',
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Servern returnerade ogiltig uppspelningsinformation.",
"playbackCancelled": "Uppspelningen avbröts.",
"playbackFailed": "Det gick inte att starta uppspelningen.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}",
"errorLoadingSeries": "Fel vid laddning av serie",
"musicNotSupported": "Musikuppspelning stöds inte ännu",
@@ -1660,7 +1661,8 @@
"timedOut": "Reläservern svarade inte i tid",
"connectionLost": "Anslutningen stängdes innan sessionen var klar",
"invalidRelayResponse": "Reläservern skickade ett oväntat svar",
"sessionEnded": "Värden avslutade sessionen"
"sessionEnded": "Värden avslutade sessionen",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr skapade ingen sessionscookie",
"freshCookieRejected": "Seerr avvisade den nya sessionscookien",
"noUserInformation": "Seerr returnerade ingen användarinformation",
"sessionRejectedAfterReauth": "Sessionen avvisades efter den nya inloggningen"
"sessionRejectedAfterReauth": "Sessionen avvisades efter den nya inloggningen",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Tjänster",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Sunucu geçersiz oynatma bilgisi döndürdü.",
"playbackCancelled": "Oynatma iptal edildi.",
"playbackFailed": "Oynatma başlatılamadı.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Dosya bilgisi yüklenirken hata: ${error}",
"errorLoadingSeries": "Dizi yüklenirken hata",
"musicNotSupported": "Müzik oynatma henüz desteklenmiyor",
@@ -1660,7 +1661,8 @@
"timedOut": "Aktarıcı sunucusu zamanında yanıt vermedi",
"connectionLost": "Oturum hazır olmadan bağlantı kapandı",
"invalidRelayResponse": "Aktarıcı sunucusu beklenmeyen bir yanıt gönderdi",
"sessionEnded": "Kurucu oturumu sonlandırdı"
"sessionEnded": "Kurucu oturumu sonlandırdı",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr bir oturum çerezi oluşturmadı",
"freshCookieRejected": "Seerr yeni oturum çerezini reddetti",
"noUserInformation": "Seerr kullanıcı bilgilerini döndürmedi",
"sessionRejectedAfterReauth": "Yeniden giriş yapıldıktan sonra oturum reddedildi"
"sessionRejectedAfterReauth": "Yeniden giriş yapıldıktan sonra oturum reddedildi",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Servisler",
+6 -2
View File
@@ -779,6 +779,7 @@
"playbackDataInvalid": "Server notoʻgʻri ijro maʼlumotlarini qaytardi.",
"playbackCancelled": "Ijro bekor qilindi.",
"playbackFailed": "Ijroni ishga tushirishda xatolik.",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fayl maʼlumotlarini yuklashda xatolik: ${error}",
"errorLoadingSeries": "Serialni yuklashda xatolik",
"musicNotSupported": "Musiqa ijrosi hali qoʻllab-quvvatlanmaydi",
@@ -1660,7 +1661,8 @@
"timedOut": "Rele serveri oʻz vaqtida javob bermadi",
"connectionLost": "Seans tayyor boʻlmasidan ulanish uzildi",
"invalidRelayResponse": "Rele serveri kutilmagan javob yubordi",
"sessionEnded": "Tashkilotchi seansni tugatdi"
"sessionEnded": "Tashkilotchi seansni tugatdi",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2116,7 +2118,9 @@
"noSessionCookie": "Seerr seans cookie-faylini bermadi",
"freshCookieRejected": "Seerr yangi seans cookie-faylini rad etdi",
"noUserInformation": "Seerr foydalanuvchi maʼlumotlarini qaytarmadi",
"sessionRejectedAfterReauth": "Qayta kirilgandan keyin seans rad etildi"
"sessionRejectedAfterReauth": "Qayta kirilgandan keyin seans rad etildi",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Xizmatlar",
+6 -2
View File
@@ -775,6 +775,7 @@
"playbackDataInvalid": "伺服器傳回的播放資訊無效。",
"playbackCancelled": "播放已取消。",
"playbackFailed": "無法開始播放。",
"mediaUnavailable": "",
"errorLoadingFileInfo": "載入檔案資訊時發生錯誤:${error}",
"errorLoadingSeries": "載入影集時發生錯誤",
"musicNotSupported": "目前不支援播放音樂",
@@ -1649,7 +1650,8 @@
"timedOut": "中繼伺服器未及時回應",
"connectionLost": "工作階段就緒前連線已中斷",
"invalidRelayResponse": "中繼伺服器傳回了非預期的回應",
"sessionEnded": "主持人已結束工作階段"
"sessionEnded": "主持人已結束工作階段",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2105,7 +2107,9 @@
"noSessionCookie": "Seerr 未發出工作階段 Cookie",
"freshCookieRejected": "Seerr 拒絕了新的工作階段 Cookie",
"noUserInformation": "Seerr 未傳回使用者資訊",
"sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕"
"sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "外部服務",
+6 -2
View File
@@ -775,6 +775,7 @@
"playbackDataInvalid": "服务器返回了无效的播放信息。",
"playbackCancelled": "播放已取消。",
"playbackFailed": "无法开始播放。",
"mediaUnavailable": "",
"errorLoadingFileInfo": "加载文件信息时出错:${error}",
"errorLoadingSeries": "加载剧集时出错",
"musicNotSupported": "尚不支持播放音乐",
@@ -1649,7 +1650,8 @@
"timedOut": "中继服务器未及时响应",
"connectionLost": "会话准备就绪前连接已断开",
"invalidRelayResponse": "中继服务器返回了意外的响应",
"sessionEnded": "主持人已结束会话"
"sessionEnded": "主持人已结束会话",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2105,7 +2107,9 @@
"noSessionCookie": "Seerr 未签发会话 Cookie",
"freshCookieRejected": "Seerr 拒绝了新的会话 Cookie",
"noUserInformation": "Seerr 未返回用户信息",
"sessionRejectedAfterReauth": "重新登录后会话被拒绝"
"sessionRejectedAfterReauth": "重新登录后会话被拒绝",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "服务",
+4 -4
View File
@@ -13,7 +13,7 @@ class AccountPreferenceTarget {
required this.ref,
required this.label,
this.subtitle,
this.isActiveProfileAccount = false,
this.isDefaultConnection = false,
});
final AccountRef ref;
@@ -24,9 +24,9 @@ class AccountPreferenceTarget {
/// Secondary row label: user · URL (MediaBrowser) or Home user (Plex).
final String? subtitle;
/// Whether this is the account the active profile is currently browsing
/// with. Used to order the picker; not a permission.
final bool isActiveProfileAccount;
/// Whether the profile designated this connection as its picker default.
/// Presentation only: this does not establish playback authority.
final bool isDefaultConnection;
MediaBackend get backend => ref.backend;
}
+8 -1
View File
@@ -16,7 +16,8 @@ class LibraryChangeEvent {
final ServerId serverId;
/// Backend-native ids of the affected libraries (Plex section ids,
/// MediaBrowser collection-folder ids). Empty when the backend did not say.
/// MediaBrowser collection-folder ids). Empty when the backend did not say,
/// which consumers must read as [targetsWholeServer] — never as "nothing".
final Set<String> libraryIds;
/// Backend-native ids of removed items, when the backend named them (Plex
@@ -41,6 +42,12 @@ class LibraryChangeEvent {
bool get hasChanges => itemsAdded || itemsRemoved || itemsUpdated;
/// Whether the change's scope is unidentified and therefore covers every
/// library on [serverId]. Absorbing under coalescing: merging any event
/// with a whole-server one must yield a whole-server event, never a
/// narrower union of the named ids.
bool get targetsWholeServer => libraryIds.isEmpty;
@override
String toString() =>
'LibraryChangeEvent($serverId, libraries: $libraryIds, '
+10 -2
View File
@@ -5,7 +5,7 @@ import 'media_backend.dart';
/// Jellyfin forked from Emby 3.5.2, so the two still share almost their entire
/// wire contract: identical `BaseItemDto` shapes, the same `/Items` query
/// grammar, the `MediaBrowser` Authorization scheme, the `X-Emby-Token` header
/// and `api_key=` query fallback. Plezy therefore drives both through one
/// and a token query fallback. Plezy therefore drives both through one
/// client stack ([JellyfinClient]) and keeps every delta in this one type.
///
/// Verified against Jellyfin 10.10.7/10.11 and Emby 4.9.5:
@@ -76,8 +76,16 @@ enum MediaBrowserDialect {
MediaBrowserDialect.emby => const [8920, 8096],
};
/// Jellyfin 12 rejects legacy `api_key` when `EnableLegacyAuthorization` is
/// false, while Emby requires it; `ApiKey` works across Jellyfin versions.
String get tokenQueryParam => switch (this) {
MediaBrowserDialect.jellyfin => 'ApiKey',
MediaBrowserDialect.emby => 'api_key',
};
/// Path of the realtime notification websocket. Same protocol on both
/// dialects (`?api_key=&deviceId=`, `ForceKeepAlive`/`KeepAlive`,
/// dialects (`?ApiKey=` on Jellyfin, `?api_key=` on Emby,
/// `ForceKeepAlive`/`KeepAlive`,
/// `LibraryChanged`); only the route differs. Verified against Jellyfin
/// 10.11 (`/socket`) and Emby 4.9.5 (`/embywebsocket`).
String get webSocketPath => switch (this) {
+57 -12
View File
@@ -2,15 +2,42 @@ import 'ids.dart';
import 'media_item.dart';
import 'media_version.dart';
/// The library a copy belongs to: [MediaItem.libraryId] with the
/// [MediaItem.libraryTitle] that labels it. The two travel as a pair, since a
/// title is only meaningful next to the id it names.
typedef _LibraryPair = ({String? libraryId, String? libraryTitle});
/// The pair [fresh] belongs to, backed by [known] only where [fresh] leaves
/// room for it.
///
/// A fresh copy naming a different library than [known] takes both fields
/// verbatim — a null title too, so the UI falls back to the server name rather
/// than labeling the new library with the old one's name. One naming the same
/// library fills a missing title from [known]. One naming no library at all
/// keeps the known pair: the stamp is best-effort on both backends, and its
/// absence is not a move.
_LibraryPair _libraryPair(MediaItem fresh, MediaItem? known) {
final freshId = fresh.libraryId;
final knownId = known?.libraryId;
if (freshId == null) {
return knownId == null
? (libraryId: null, libraryTitle: fresh.libraryTitle ?? known?.libraryTitle)
: (libraryId: knownId, libraryTitle: known!.libraryTitle);
}
return (libraryId: freshId, libraryTitle: fresh.libraryTitle ?? (freshId == knownId ? known!.libraryTitle : null));
}
/// Merge freshly fetched metadata with identity and library context already
/// known by the caller. The fetched item owns descriptive fields, while
/// existing context wins when the backend omits it.
/// existing context wins when the backend omits it. Library id and title
/// follow [_libraryPair].
MediaItem mergeFetchedMediaItem({required MediaItem fetched, required ServerId fallbackServerId, MediaItem? existing}) {
final library = _libraryPair(fetched, existing);
return fetched.copyWith(
serverId: existing?.serverId ?? fetched.serverId ?? fallbackServerId,
serverName: existing?.serverName ?? fetched.serverName,
libraryId: fetched.libraryId ?? existing?.libraryId,
libraryTitle: fetched.libraryTitle ?? existing?.libraryTitle,
libraryId: library.libraryId,
libraryTitle: library.libraryTitle,
);
}
@@ -40,26 +67,44 @@ int compareLibraryCopies(MediaItem a, MediaItem b) {
return a.globalKey.compareTo(b.globalKey);
}
/// Fold a re-resolved copy into the one already on screen.
/// Fold a re-resolved copy into the one already known for its
/// [MediaItem.globalKey].
///
/// The addition is fresher, but a degraded pass must not erase context. The
/// Jellyfin library stamp is a best-effort `/Items/{id}/Ancestors` call that
/// hands back an unstamped item when it fails, and a copy that lost its
/// library title is indistinguishable from its sibling in the same server's
/// other library — exactly the ambiguity the chooser exists to resolve. The
/// version list behind the resolution hint is treated the same way.
MediaItem _mergeCopy(MediaItem existing, MediaItem addition) {
/// library pair follows [_libraryPair]; the version list behind the
/// resolution hint is treated the same way as the stamp.
///
/// Shared by the on-screen union ([mergeLibraryCopies]) and the per-server
/// replacement in `CatalogLibraryMatcher`, so one policy decides what a
/// returning copy keeps from its predecessor. Returns [addition] itself when
/// it inherits nothing: [MediaItem] compares by identity, and a caller
/// holding on to the fresh copy must still recognize it.
MediaItem mergeLibraryCopy(MediaItem existing, MediaItem addition) {
final versions = addition.mediaVersions;
final library = _libraryPair(addition, existing);
final serverName = addition.serverName ?? existing.serverName;
final mediaVersions = versions == null || versions.isEmpty ? existing.mediaVersions : versions;
if (library.libraryId == addition.libraryId &&
library.libraryTitle == addition.libraryTitle &&
serverName == addition.serverName &&
identical(mediaVersions, versions)) {
return addition;
}
return addition.copyWith(
libraryId: addition.libraryId ?? existing.libraryId,
libraryTitle: addition.libraryTitle ?? existing.libraryTitle,
serverName: addition.serverName ?? existing.serverName,
mediaVersions: versions == null || versions.isEmpty ? existing.mediaVersions : versions,
libraryId: library.libraryId,
libraryTitle: library.libraryTitle,
serverName: serverName,
mediaVersions: mediaVersions,
);
}
/// Union [additions] into [current] by [MediaItem.globalKey], then re-sort
/// with [compareLibraryCopies]. A key on both sides is folded by [_mergeCopy].
/// with [compareLibraryCopies]. A key on both sides is folded by
/// [mergeLibraryCopy].
///
/// Never removes a copy, and never downgrades one. The cross-server fan-out
/// behind these lists logs and skips per-server failures, so a later pass can
@@ -69,7 +114,7 @@ List<MediaItem> mergeLibraryCopies(Iterable<MediaItem> current, Iterable<MediaIt
final byKey = <String, MediaItem>{for (final item in current) item.globalKey: item};
for (final item in additions) {
final existing = byKey[item.globalKey];
byKey[item.globalKey] = existing == null ? item : _mergeCopy(existing, item);
byKey[item.globalKey] = existing == null ? item : mergeLibraryCopy(existing, item);
}
return byKey.values.toList()..sort(compareLibraryCopies);
}
+25 -10
View File
@@ -114,6 +114,11 @@ abstract class MediaServerClient {
MediaBackend get backend;
ServerCapabilities get capabilities;
/// Opaque identity of the committed authentication session, independent of
/// the endpoint. Compare by identity: successful effective credential or
/// profile changes install a fresh identity; failed and no-op updates do not.
Object get authenticationSessionId;
/// Release HTTP resources and any other long-lived state. Idempotent.
void close();
@@ -622,9 +627,9 @@ abstract class MediaServerClient {
///
/// Every entry of [titles] is searched, and the union of their id-verified
/// candidates is returned. The caller (`CatalogLibraryMatcher.lookupTitles`)
/// leads with the native title — both search indexes cover `originalTitle`,
/// so it reaches a copy filed under any display language — and bounds the
/// list; that cap is the request budget. A title that hit MUST NOT stop the
/// prioritizes original/display titles and bounds best-effort alternatives;
/// original titles may be native or romanized depending on the source.
/// The cap is the title-search budget. A title that hit MUST NOT stop the
/// others: id verification means an extra title can only add genuine
/// copies. A sequel entry's own title never matches its parent show, which
/// is why the list carries season-stripped forms. [year] is a hint for
@@ -644,12 +649,14 @@ abstract class MediaServerClient {
/// dataset supplies, so a disagreeing ref is left ungated rather than gated
/// on a guess.
///
/// Returns an empty list when this server has no match or [kind] is not
/// movie/show, and throws when the server could not be asked — a slow or
/// unreachable server is not evidence of absence, and the aggregation layer
/// reports it separately. Used to match external catalog items (Explore
/// tab) back to the user's libraries.
Future<List<MediaItem>> findByExternalIds(
/// Returns null when this backend cannot execute the supplied query (for
/// example, Jellyfin/Emby need external ids and a title, whereas Plex can
/// query a Plex guid or legacy-agent id without titles). Unsupported kinds
/// also return null. An empty list means a supported lookup completed and
/// found no match. Failures and cancellation throw rather than supplying
/// negative membership evidence. Used to match external catalog items
/// (Explore tab) back to the user's libraries.
Future<List<MediaItem>?> findByExternalIds(
ExternalIds ids, {
required MediaKind kind,
List<String> titles = const [],
@@ -685,7 +692,15 @@ abstract class MediaServerClient {
/// playback path to recover audio/subtitle track info (track ids, language
/// codes, displayTitles) without hitting the network. Returns `null` when
/// the row isn't cached or carries no usable media source.
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId);
/// Uses playback's source-selection order: stable [mediaSourceId], then a
/// sibling [preferredVersionSignature], then [mediaIndex], subject to the
/// backend's playable-source rules.
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(
String itemId, {
int mediaIndex = 0,
String? mediaSourceId,
String? preferredVersionSignature,
});
/// Build a scrub preview source for [item] using [mediaSource]. Plex
/// downloads + parses BIF bytes; Jellyfin assembles a sprite-sheet
+13
View File
@@ -15,6 +15,10 @@ class MediaSourceInfo {
/// `MediaSources` id.
final String? mediaSourceId;
/// Effective version/part positions after backend selection and fallback.
final int? mediaIndex;
final int? partIndex;
/// Jellyfin default stream indexes for this source. A subtitle index of -1
/// is an explicit server/user decision to start with subtitles off.
final int? defaultAudioStreamIndex;
@@ -36,6 +40,8 @@ class MediaSourceInfo {
this.partId,
this.displayCriteria,
this.mediaSourceId,
this.mediaIndex,
this.partIndex,
this.defaultAudioStreamIndex,
this.defaultSubtitleStreamIndex,
this.trickplayByWidth,
@@ -54,6 +60,8 @@ class MediaSourceInfo {
partId: partId,
displayCriteria: displayCriteria,
mediaSourceId: mediaSourceId,
mediaIndex: mediaIndex,
partIndex: partIndex,
defaultAudioStreamIndex: defaultAudioStreamIndex,
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
trickplayByWidth: trickplayByWidth,
@@ -108,6 +116,9 @@ class MediaAudioTrack with _TrackLabelMixin {
final String? displayTitle;
final int? channels;
final bool selected;
/// Container fallback, independent of the server/user-selected row.
final bool isDefault;
final bool external;
MediaAudioTrack({
@@ -120,6 +131,7 @@ class MediaAudioTrack with _TrackLabelMixin {
this.displayTitle,
this.channels,
required this.selected,
this.isDefault = false,
this.external = false,
});
@@ -137,6 +149,7 @@ class MediaAudioTrack with _TrackLabelMixin {
displayTitle: displayTitle,
channels: channels,
selected: selected,
isDefault: isDefault,
external: external,
);
}
+122 -29
View File
@@ -1,27 +1,66 @@
import 'package:flutter/material.dart';
/// Manages a map of grid-item [FocusNode]s with focus-tracking and restoration.
///
/// Provides:
/// - Lazy creation of per-index focus nodes via [getGridItemFocusNode].
/// - Focus tracking ([lastFocusedGridIndex], [gridContentVersion]) so callers
/// can restore focus after rebuilds.
/// - [cleanupGridFocusNodes] to prune nodes for indices beyond the current count.
/// - [disposeGridFocusNodes] for full teardown.
/// A detached FocusNode retains its last context, even after that host unmounts.
/// Keep the attachment token so eviction can distinguish history from borrowers.
class _GridFocusNode extends FocusNode {
_GridFocusNode({super.debugLabel});
FocusAttachment? _gridAttachment;
bool _hasPendingFocusRequest = false;
bool get _isAttached => _gridAttachment?.isAttached ?? false;
@override
FocusAttachment attach(
BuildContext? context, {
FocusOnKeyEventCallback? onKeyEvent,
// ignore: deprecated_member_use
FocusOnKeyCallback? onKey,
}) {
// Forward the legacy argument because this still implements FocusNode.attach.
// ignore: deprecated_member_use
final attachment = super.attach(context, onKeyEvent: onKeyEvent, onKey: onKey);
_gridAttachment = attachment;
_hasPendingFocusRequest = false;
return attachment;
}
@override
void requestFocus([FocusNode? node]) {
if (node == null && !_isAttached && parent == null && canRequestFocus) {
_hasPendingFocusRequest = true;
}
super.requestFocus(node);
}
@override
void dispose() {
_hasPendingFocusRequest = false;
super.dispose();
_gridAttachment = null;
}
}
/// Owns grid nodes independently of their current spatial index. A content
/// commit reconciles every realized item's node, including scope history and
/// references captured by menus; it never requests focus behind a cover.
mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
final Map<int, FocusNode> gridItemFocusNodes = {};
final Map<int, Object> _gridItemIdentities = {};
final Set<FocusNode> _retiredGridFocusNodes = {};
bool _gridRetirementScheduled = false;
int? lastFocusedGridIndex;
int gridContentVersion = 0;
int lastFocusedGridContentVersion = 0;
/// Get or create a focus node for a grid item at [index].
FocusNode getGridItemFocusNode(int index, {String prefix = 'grid_item'}) {
return gridItemFocusNodes.putIfAbsent(index, () => FocusNode(debugLabel: '${prefix}_$index'));
FocusNode getGridItemFocusNode(int index, {String prefix = 'grid_item', String? debugLabel}) {
return gridItemFocusNodes.putIfAbsent(index, () => _GridFocusNode(debugLabel: debugLabel ?? '${prefix}_$index'));
}
/// Get the focus node for [index], routing index 0 through [firstNode] when
/// the grid pins a dedicated node for the first item (e.g. `firstItemFocusNode`).
FocusNode focusNodeForIndex(int index, FocusNode firstNode, {required String prefix}) {
/// [firstNode] must resolve the current index-zero node owned by this mixin,
/// not a permanently slot-bound node owned by the caller.
FocusNode focusNodeForIndex(int index, FocusNode firstNode, {required String prefix, Object? itemIdentity}) {
if (itemIdentity != null) _gridItemIdentities[index] = itemIdentity;
return index == 0 ? firstNode : getGridItemFocusNode(index, prefix: prefix);
}
@@ -32,39 +71,88 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
}
}
/// Whether the last-focused index is still valid for restoration.
/// Atomically reassign spatial indices after an accepted content commit.
/// Surviving items keep the same node and attachment. Removed items retire
/// after their cards unmount, so captured references cannot focus a new item.
void reconcileGridFocusNodes(Map<Object, int> indices) {
final nodes = <int, FocusNode>{};
final identities = <int, Object>{};
final rememberedIndex = lastFocusedGridIndex;
int? restoredIndex;
for (final entry in gridItemFocusNodes.entries) {
final identity = _gridItemIdentities[entry.key];
final index = identity == null ? null : indices[identity];
if (index == null) {
_retireGridFocusNode(entry.value);
} else {
nodes[index] = entry.value;
identities[index] = identity!;
if (entry.key == rememberedIndex) restoredIndex = index;
}
}
gridItemFocusNodes
..clear()
..addAll(nodes);
_gridItemIdentities
..clear()
..addAll(identities);
lastFocusedGridIndex = restoredIndex;
lastFocusedGridContentVersion = gridContentVersion;
}
bool get shouldRestoreGridFocus =>
lastFocusedGridIndex != null && lastFocusedGridContentVersion == gridContentVersion && lastFocusedGridIndex! >= 0;
void _retireGridFocusNode(FocusNode node) {
// Disable stale menu/route destinations immediately, without issuing a
// replacement request. The owning scope supplies its normal fallback.
node.canRequestFocus = false;
if (node is _GridFocusNode) node._hasPendingFocusRequest = false;
_retiredGridFocusNodes.add(node);
if (_gridRetirementScheduled) return;
_gridRetirementScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_gridRetirementScheduled = false;
while (_retiredGridFocusNodes.isNotEmpty) {
final retired = _retiredGridFocusNodes.first;
_retiredGridFocusNodes.remove(retired);
retired.dispose();
}
});
}
void cleanupGridFocusNodes(int itemCount) {
final keysToRemove = gridItemFocusNodes.keys.where((i) => i >= itemCount).toList();
for (final key in keysToRemove) {
gridItemFocusNodes[key]?.dispose();
gridItemFocusNodes.remove(key);
_retireGridFocusNode(gridItemFocusNodes.remove(key)!);
_gridItemIdentities.remove(key);
}
if (lastFocusedGridIndex != null && lastFocusedGridIndex! >= itemCount) lastFocusedGridIndex = null;
}
/// Evict focus nodes far from [centerIndex], keeping at most [keepCount] around it.
/// Evict detached nodes far from the viewport, never live attachments or
/// remembered/pending destinations. The budget is soft while those survive.
void evictDistantFocusNodes(int centerIndex, {int keepCount = 200}) {
if (gridItemFocusNodes.length <= keepCount) return;
final halfKeep = keepCount ~/ 2;
final keepStart = centerIndex - halfKeep;
final keepEnd = centerIndex + halfKeep;
final keysToRemove = <int>[];
for (final key in gridItemFocusNodes.keys) {
if (key < keepStart || key > keepEnd) {
keysToRemove.add(key);
for (final entry in gridItemFocusNodes.entries) {
final node = entry.value;
if ((entry.key < keepStart || entry.key > keepEnd) &&
node is _GridFocusNode &&
!node._isAttached &&
!node._hasPendingFocusRequest &&
!(shouldRestoreGridFocus && entry.key == lastFocusedGridIndex) &&
!node.hasFocus &&
node.enclosingScope?.focusedChild != node) {
keysToRemove.add(entry.key);
}
}
for (final key in keysToRemove) {
final node = gridItemFocusNodes[key];
// A focused node is still borrowed by its mounted card. Keep ownership
// and indexed identity until a later eviction or final teardown.
if (node == null || node.hasFocus) continue;
gridItemFocusNodes.remove(key);
node.dispose();
_retireGridFocusNode(gridItemFocusNodes.remove(key)!);
_gridItemIdentities.remove(key);
}
}
@@ -72,6 +160,11 @@ mixin GridFocusNodeMixin<T extends StatefulWidget> on State<T> {
for (final node in gridItemFocusNodes.values) {
node.dispose();
}
for (final node in _retiredGridFocusNodes) {
node.dispose();
}
gridItemFocusNodes.clear();
_gridItemIdentities.clear();
_retiredGridFocusNodes.clear();
}
}
+5 -18
View File
@@ -1,28 +1,15 @@
import 'package:flutter/material.dart';
import 'grid_focus_node_mixin.dart';
/// Mixin that provides focus management for library tabs.
/// Handles the lifecycle of a focus node for the first item and provides
/// a method to request focus on that item.
mixin LibraryTabFocusMixin<T extends StatefulWidget> on State<T> {
/// Focus node for the first item (for programmatic focus)
late final FocusNode firstItemFocusNode;
/// Tab-entry focus resolves the current first item's grid-owned node. Reorders
/// may move the former first node elsewhere without invalidating menu restores.
mixin LibraryTabFocusMixin<T extends StatefulWidget> on State<T>, GridFocusNodeMixin<T> {
FocusNode get firstItemFocusNode => getGridItemFocusNode(0, debugLabel: focusNodeDebugLabel);
String get focusNodeDebugLabel;
int get itemCount;
@override
void initState() {
super.initState();
firstItemFocusNode = FocusNode(debugLabel: focusNodeDebugLabel);
}
@override
void dispose() {
firstItemFocusNode.dispose();
super.dispose();
}
/// Focus the first item in the grid/list (for tab activation)
void focusFirstItem() {
if (itemCount > 0) {
+7 -1
View File
@@ -424,7 +424,13 @@ class CatalogItem {
physicalReleaseDate: detail.physicalReleaseDate ?? physicalReleaseDate,
endDate: detail.endDate ?? endDate,
originalTitle: detail.originalTitle ?? originalTitle,
altTitles: altTitles.isNotEmpty ? altTitles : detail.altTitles,
// Detail aliases get first chance at the bounded lookup budget, without
// losing names known only to the row.
altTitles: detail.altTitles.isEmpty
? altTitles
: altTitles.isEmpty
? detail.altTitles
: {...detail.altTitles, ...altTitles}.toList(),
tagline: detail.tagline ?? tagline,
broadcastSeason: detail.broadcastSeason ?? broadcastSeason,
format: detail.format ?? format,
+4
View File
@@ -74,6 +74,10 @@ abstract class Player {
///
/// [media] - The media source to open.
/// [play] - Whether to start playback immediately (default: true).
///
/// Backends that can identify the source they started resolve with its id
/// (mpv: the playlist entry id carried by that source's stream events, see
/// `PlayerNative.open`); the base contract promises nothing.
Future<void> open(
Media media, {
bool play = true,
+43 -7
View File
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/services.dart';
import '../../media/media_display_criteria.dart';
import '../../services/settings_service.dart';
import '../../utils/app_logger.dart';
import '../models.dart';
import 'audio_rendering_mode.dart';
@@ -238,6 +239,8 @@ class PlayerNative extends PlayerBase {
// to dispose-and-recreate the in-flight core, hanging playback (#930).
Future<void>? _initFuture;
Future<void> _audioStateTail = Future<void>.value();
String _requestedLogLevel = 'warn';
Future<void> _logLevelTail = Future<void>.value();
Future<void>? _disposeFuture;
bool _disposing = false;
@@ -259,11 +262,19 @@ class PlayerNative extends PlayerBase {
Future<void> _doInitialize() async {
try {
// The video core carries the session's decode intent so Android can
// choose its vo before mpv_initialize. `instanceId` names this Dart
// instance so a later `dispose` that lost the ownership race is
// provably stale; handlers that predate either argument ignore them.
// choose its vo before mpv_initialize, and the subtitle "Render
// Resolution" fraction for its vo=mediacodec OSD plane (the same knob the
// ExoPlayer overlay honors; other platforms size the OSD themselves).
// `instanceId` names this Dart instance so a later `dispose` that lost
// the ownership race is provably stale; handlers that predate any of
// these arguments ignore them.
final result = await invoke<Object>('initialize', {
if (!audioOnly) 'hardwareDecoding': _hardwareDecoding,
if (!audioOnly && Platform.isAndroid)
'subtitleRenderScale': SettingsService.instance
.read(SettingsService.subtitleRenderResolution)
.androidRenderScale,
if (Platform.isAndroid) 'logLevel': _requestedLogLevel,
'instanceId': nativeInstanceId,
});
if (result != true) {
@@ -359,17 +370,25 @@ class PlayerNative extends PlayerBase {
return ('fdclose://$fd', fd);
}
/// Opens [media] and resolves with the id of the mpv playlist entry the
/// `loadfile` created — the `sourceId` that entry's `start-file`,
/// `playback-restart` and `end-file` events carry — or null when the load
/// never reached mpv (core unavailable) or the core did not report one.
///
/// Method-channel contract: the `command` reply for `loadfile` is
/// `{'playlistEntryId': int}` on every native core; every other command
/// replies null.
@override
Future<void> open(
Future<int?> open(
Media media, {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
Duration? timelineDuration,
}) async {
if (_nativeCoreUnavailable) return;
if (_nativeCoreUnavailable) return null;
await _ensureInitialized();
if (_nativeCoreUnavailable) return;
if (_nativeCoreUnavailable) return null;
// `loadfile replace` (below) clears the native playlist, dropping any
// gapless entry armed via setNext — settle its content-fd claim first.
// No transition is surfaced: the caller is replacing playback anyway.
@@ -440,7 +459,11 @@ class PlayerNative extends PlayerBase {
loadfileArgs.addAll(['-1', loadfileOption]);
}
if (audioOnly) _expectOpenFileLoad = true;
await command(loadfileArgs);
// The core can be torn down while the awaits above were suspended; the
// `command` path makes the same re-check before dispatching.
if (_nativeCoreUnavailable) return null;
final loadfileReply = await invoke<Map>('command', {'args': loadfileArgs});
final playlistEntryId = loadfileReply?['playlistEntryId'];
// mpv's pause property survives loadfile; in-place reloads pause the old
// file before resolving, so explicitly unpause for the replacement. Set
@@ -449,6 +472,7 @@ class PlayerNative extends PlayerBase {
if (play) {
await setProperty('pause', 'no');
}
return playlistEntryId is int ? playlistEntryId : null;
}
@override
@@ -885,6 +909,18 @@ class PlayerNative extends PlayerBase {
@override
Future<void> setLogLevel(String level) async {
if (_nativeCoreUnavailable) return;
if (Platform.isAndroid) {
// Carry the preference into native creation, even if another operation
// starts initialization before this ordered runtime write gets its turn.
_requestedLogLevel = level;
final request = _logLevelTail.then((_) async {
if (_nativeCoreUnavailable) return;
await _ensureInitialized();
await invoke('setLogLevel', {'level': level});
});
_logLevelTail = request.catchError((Object _) {});
return request;
}
await _ensureInitialized();
await invoke('setLogLevel', {'level': level});
}
+5 -1
View File
@@ -209,7 +209,11 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
create: (context) => ExploreProvider(context.read<CatalogSourcesProvider>()),
lazy: true,
),
Provider(create: (context) => CatalogLibraryMatcher(context.read<MultiServerProvider>()), lazy: true),
Provider(
create: (context) => CatalogLibraryMatcher(context.read<MultiServerProvider>()),
dispose: (_, matcher) => matcher.dispose(),
lazy: true,
),
ChangeNotifierProvider(
create: (context) =>
HiddenLibrariesProvider(storageService: context.read<StorageService>(), profileId: activeId),
+4 -6
View File
@@ -16,7 +16,7 @@ class ActivePlexToken {
final String token;
}
/// THE resolver for the active profile's effective Plex credential.
/// Resolve the active profile's effective Plex credential for cloud callers.
///
/// One policy: the profile's per-user token (the `ProfileConnection` row
/// bound to the identity's account, minted via `/home/users/{uuid}/switch`)
@@ -24,11 +24,9 @@ class ActivePlexToken {
///
/// [allowAccountTokenForHomeUser] controls the owner-token fallback for Plex
/// Home profiles. Cloud Discover and Seerr pass `true` — any credential on
/// the account is acceptable there. User-settings resolution passes `false`
/// because the owner's token belongs to a *different* plex.tv user, so a
/// Home profile without its switched token resolves to `null` instead of
/// silently impersonating the owner. Local profiles always fall back: their
/// selected account *is* the profile's identity.
/// the account is acceptable there. Account preferences resolve separately:
/// their Home principal requires its exact parent's switched token, without
/// owner or other-account fallback. Local profiles retain owner-token fallback.
Future<ActivePlexToken?> resolveActivePlexToken({
required ActiveProfileProvider activeProfile,
required ConnectionRegistry connections,
+110 -42
View File
@@ -64,6 +64,8 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
StreamSubscription<List<ProfileConnection>>? _profileConnectionsSubscription;
Profile? _watchedProfile;
String? _lastSeenActiveProfileId;
int _generation = 0;
int _accountGeneration = 0;
/// Latest rows the registry watchers delivered, null until each has emitted
/// for the current registry/profile. Accounts resolve from these rather than
@@ -100,7 +102,11 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
/// awaits this before entering the session so the first playback does not
/// race the fetch; an already loaded value is kept rather than re-fetched.
Future<void> ensureActiveLoaded() async {
await _activeProfile?.awaitBindingSettle();
while (!isDisposed) {
final generation = _generation;
await _activeProfile?.awaitBindingSettle();
if (generation == _generation) break;
}
// Every watcher emission after attach supersedes the previous resolve;
// waiting for the chain to go quiet is what makes the cached check below
// meaningful.
@@ -125,12 +131,18 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
}) {
if (isDisposed) return;
_serverManager = serverManager;
final dependenciesChanged =
(_connections != null && !identical(_connections, connections)) ||
(_profileConnections != null && !identical(_profileConnections, profileConnections)) ||
(_activeProfile != null && !identical(_activeProfile, activeProfile));
if (dependenciesChanged) _resetScope();
if (!identical(_connections, connections)) {
_connections = connections;
_connectionRows = null;
_connectionsSubscription?.cancel();
_connectionsSubscription = connections.watchConnections().listen((rows) {
if (isDisposed || !identical(_connections, connections)) return;
_connectionRows = rows;
_resolve();
});
@@ -159,17 +171,31 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
final active = _activeProfile;
if (active == null) return;
final id = active.activeId;
if (id == _lastSeenActiveProfileId) return;
if (id == _lastSeenActiveProfileId) {
final profile = active.active;
if (_watchedProfile?.kind != profile?.kind ||
_watchedProfile?.parentConnectionId != profile?.parentConnectionId ||
_watchedProfile?.plexHomeUserUuid != profile?.plexHomeUserUuid) {
_resetScope();
}
_watchedProfile = profile;
_resolve();
return;
}
_lastSeenActiveProfileId = id;
// Another user's values must not survive the switch, not even for the
// duration of the next fetch.
_activeRef = null;
repository.clear();
safeNotifyListeners();
_resetScope();
_watchActiveProfile(active.active);
_resolve();
}
void _resetScope() {
_generation++;
_activeRef = null;
_accounts = const [];
repository.clear();
safeNotifyListeners();
}
void _watchActiveProfile(Profile? profile) {
final registry = _profileConnections;
if (_watchedProfile?.id == profile?.id && _profileConnectionsSubscription != null) return;
@@ -181,6 +207,7 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
if (registry == null || profile == null) return;
_profileConnectionsSubscription = registry.watchForProfile(profile.id).listen((rows) {
if (isDisposed || _watchedProfile?.id != profile.id || !identical(_profileConnections, registry)) return;
_profileConnectionRows = rows;
_resolve();
});
@@ -191,23 +218,24 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
/// [ensureActiveLoaded] has something to wait for. No active profile
/// resolves to no accounts at once.
void _resolve() {
if (isDisposed) return;
final profile = _watchedProfile;
final connectionRows = _connectionRows;
final profileRows = profile == null ? const <ProfileConnection>[] : _profileConnectionRows;
if (connectionRows == null || profileRows == null) {
if (profile != null && (connectionRows == null || profileRows == null)) {
_resolveTask = (_firstRows ??= Completer<void>()).future;
return;
}
List<AccountPreferenceAccount> resolved;
AccountPreferenceResolution resolved;
try {
resolved = resolveAccountPreferenceAccounts(
profile: profile,
profileConnections: profileRows,
connections: connectionRows,
profileConnections: profileRows ?? const [],
connections: connectionRows ?? const [],
);
} catch (error, stackTrace) {
appLogger.w('AccountPreferencesController: failed to resolve accounts', error: error, stackTrace: stackTrace);
resolved = const [];
resolved = const AccountPreferenceResolution();
}
final firstRows = _firstRows;
_firstRows = null;
@@ -215,30 +243,52 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
firstRows?.complete();
}
Future<void> _applyAccounts(List<AccountPreferenceAccount> resolved) async {
final changed = !_sameAccounts(_accounts, resolved);
if (changed) {
_accounts = resolved;
safeNotifyListeners();
Future<void> _applyAccounts(AccountPreferenceResolution resolved) async {
final accounts = resolved.accounts;
final byRef = {for (final account in accounts) account.ref: account};
final previousRefs = {for (final account in _accounts) account.ref};
for (final account in accounts) {
if (!previousRefs.contains(account.ref)) repository.invalidate(account.ref);
}
// Sorted best-first, so the head is the active profile's own account.
final ref = resolved.isEmpty ? null : resolved.first.ref;
var credentialsChanged = false;
for (final previous in _accounts) {
final current = byRef[previous.ref];
if (current == null || !_sameCredentials(previous, current)) {
credentialsChanged = true;
repository.invalidate(previous.ref);
}
}
if (credentialsChanged) _accountGeneration++;
final changed = !_sameAccounts(_accounts, accounts);
final ref = resolved.playbackRef;
final refChanged = ref != _activeRef;
_accounts = accounts;
_activeRef = ref;
if (ref == null) return;
// Re-read on any account change (a re-minted token must not serve the old
// cached value) and on a profile switch that resolves to the same account
// (the switch cleared the cache).
if (changed || refChanged) await _loadActive(ref);
// Consumers must observe the new list, authority and invalidated cache in
// the same notification, including transitions to no eligible principal.
if (changed || refChanged) safeNotifyListeners();
if (ref != null && (refChanged || repository.cached(ref) == null)) await _loadActive(ref);
}
static bool _sameCredentials(AccountPreferenceAccount a, AccountPreferenceAccount b) {
if (a.plexToken != b.plexToken) return false;
final left = a.connection;
final right = b.connection;
if (left is JellyfinConnection && right is JellyfinConnection) {
return left.accessToken == right.accessToken && left.userId == right.userId && left.deviceId == right.deviceId;
}
return true;
}
Future<void> _loadActive(AccountRef ref) async {
final generation = _generation;
final accountGeneration = _accountGeneration;
// The binder mints a Home token and connects servers after activation;
// reading before it settles would fail on an unminted token or an absent
// client. Concurrent loads for one account share a request in the
// repository, so re-entering here is cheap.
await _activeProfile?.awaitBindingSettle();
if (isDisposed || ref != _activeRef) return;
if (isDisposed || generation != _generation || accountGeneration != _accountGeneration || ref != _activeRef) return;
try {
await repository.load(ref, forceRefresh: true);
} on AccountPreferencesUnavailableException {
@@ -252,21 +302,27 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
}
}
Future<List<AccountPreferenceAccount>> _readAccounts() async {
Future<AccountPreferenceResolution> _readAccounts() async {
final generation = _generation;
final connections = _connections;
final profileConnections = _profileConnections;
final profile = _activeProfile?.active;
if (connections == null || profileConnections == null || profile == null) return const [];
if (connections == null || profileConnections == null || profile == null) {
return const AccountPreferenceResolution();
}
try {
final profileRows = await profileConnections.listForProfile(profile.id);
final connectionRows = await connections.list();
if (isDisposed || generation != _generation) return const AccountPreferenceResolution();
return resolveAccountPreferenceAccounts(
profile: profile,
profileConnections: await profileConnections.listForProfile(profile.id),
connections: await connections.list(),
profileConnections: profileRows,
connections: connectionRows,
);
} catch (error, stackTrace) {
appLogger.w('AccountPreferencesController: failed to resolve accounts', error: error, stackTrace: stackTrace);
return const [];
return const AccountPreferenceResolution();
}
}
@@ -274,7 +330,8 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i].ref != b[i].ref || a[i].target.label != b[i].target.label) return false;
if (a[i].target.subtitle != b[i].target.subtitle || a[i].plexToken != b[i].plexToken) return false;
if (a[i].target.subtitle != b[i].target.subtitle || !_sameCredentials(a[i], b[i])) return false;
if (a[i].target.isDefaultConnection != b[i].target.isDefaultConnection) return false;
}
return true;
}
@@ -282,7 +339,7 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
/// The account backing [connectionId] for the active profile, resolved fresh
/// so a caller during startup does not race the first snapshot.
Future<AccountPreferenceAccount?> accountForConnectionId(String connectionId) async {
for (final account in await _readAccounts()) {
for (final account in (await _readAccounts()).accounts) {
if (account.ref.connectionId == connectionId) return account;
}
return null;
@@ -292,30 +349,41 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN
/// unreachable. Resolved per call: a Plex Home token is minted lazily by the
/// binder and a MediaBrowser client only exists once its server is online.
Future<AccountPreferencesSource?> _sourceFor(AccountRef ref) async {
final generation = _generation;
AccountPreferenceAccount? selected;
for (final account in (await _readAccounts()).accounts) {
if (account.ref == ref) {
selected = account;
break;
}
}
if (isDisposed || generation != _generation || selected == null) return null;
for (final current in _accounts) {
if (current.ref == ref && !_sameCredentials(current, selected)) return null;
}
switch (ref.backend) {
case MediaBackend.jellyfin:
case MediaBackend.emby:
final client = _serverManager?.getJellyfinClientByCompoundId(ref.connectionId);
if (client is! JellyfinClient) return null;
final connection = selected.connection;
if (connection is! JellyfinConnection ||
client.connection.accessToken != connection.accessToken ||
client.connection.userId != connection.userId ||
client.connection.deviceId != connection.deviceId) {
return null;
}
return MediaBrowserAccountPreferencesSource(client);
case MediaBackend.plex:
final token = await _resolvePlexToken(ref);
final token = selected.plexToken;
if (token == null || token.isEmpty) return null;
return PlexAccountPreferencesSource(authToken: token, serviceFactory: _plexServiceFactory);
}
}
/// The token for [ref], re-resolved rather than read off the cached account
/// list so a freshly minted Home-user token is picked up immediately.
Future<String?> _resolvePlexToken(AccountRef ref) async {
for (final account in await _readAccounts()) {
if (account.ref == ref) return account.plexToken;
}
return null;
}
@override
void dispose() {
_generation++;
_activeProfile?.removeListener(_onActiveProfileChanged);
_connectionsSubscription?.cancel();
_profileConnectionsSubscription?.cancel();
+3 -1
View File
@@ -982,7 +982,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
}
if (changed) safeNotifyListeners();
unawaited(refreshContinueWatching());
if (event.origin == DeletionOrigin.local) {
unawaited(refreshContinueWatching());
}
}
void _onHiddenLibrariesChanged() {
+21 -10
View File
@@ -9,6 +9,7 @@ import '../mixins/disposable_change_notifier_mixin.dart';
import '../services/data_aggregation_service.dart';
import '../services/storage_service.dart';
import '../utils/app_logger.dart';
import '../utils/global_key_utils.dart';
import '../utils/library_content_notifier.dart';
import '../utils/coalesced_load_coordinator.dart';
import 'multi_server_provider.dart';
@@ -96,27 +97,37 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
void _onLibraryContentChanged(LibraryChangeEvent event) {
if (isDisposed || !event.hasChanges) return;
// Resolved once per event, not per library: the loop below is the only
// hot consumer and resolution walks the named ids.
final coversServer = _eventCoversServer(event);
for (final library in _libraries) {
if (!eventTargetsLibrary(event, library)) continue;
if (library.serverId != event.serverId.value) continue;
if (!coversServer && !event.libraryIds.contains(library.id)) continue;
_contentEpochByGlobalKey[library.globalKey] = (_contentEpochByGlobalKey[library.globalKey] ?? 0) + 1;
}
}
/// Single matcher for push events, shared with the visible tab's live pass
/// so epoch marking and live refreshes always agree (#1646). An event with
/// no library ids targets the whole server; ids that match no loaded
/// library on that server (a brand-new library, or a backend id the event
/// names differently) fall back to the whole server rather than nothing.
/// no library ids targets the whole server; so does one naming any id that
/// matches no loaded library on that server (a brand-new library, or a
/// backend id the app never sees as a library — Jellyfin reports physical
/// collection folders, and a grouped view's folders never appear as its
/// id). Partial resolution widens rather than narrows: the unresolved ids
/// may belong to any loaded library, so every one of them is marked
/// alongside the exact matches. Only a fully resolved id set stays precise.
bool eventTargetsLibrary(LibraryChangeEvent event, MediaLibrary library) {
if (library.serverId == null || library.serverId != event.serverId.value) return false;
if (event.libraryIds.isEmpty) return true;
if (event.libraryIds.contains(library.id)) return true;
return !_serverHasLibraryIn(event.serverId.value, event.libraryIds);
return event.libraryIds.contains(library.id) || _eventCoversServer(event);
}
bool _serverHasLibraryIn(String serverId, Set<String> libraryIds) {
for (final library in _libraries) {
if (library.serverId == serverId && libraryIds.contains(library.id)) return true;
/// Whether [event] must be read as touching every loaded library on its
/// server: unidentified scope, or a named id that resolves to nothing.
bool _eventCoversServer(LibraryChangeEvent event) {
if (event.targetsWholeServer) return true;
_ensureLookups();
for (final id in event.libraryIds) {
if (!_byGlobalKey.containsKey(buildGlobalKey(event.serverId, id))) return true;
}
return false;
}
+32 -16
View File
@@ -12,7 +12,7 @@ import '../services/seerr/seerr_auth_service.dart';
import '../services/seerr/seerr_client.dart';
import '../services/seerr/seerr_session_store.dart';
import '../utils/app_logger.dart';
import '../utils/serial_future_queue.dart';
import '../services/trackers/future_coalescer.dart';
/// Resolve the active profile's Plex token for Seerr sign-in/re-auth:
/// the profile's per-user token when a bind exists (a Home user's Seerr
@@ -49,12 +49,7 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
final SeerrAuthService authService;
SeerrPlexTokenSupplier? _plexTokenSupplier;
/// Store writes go through one queue: save() awaits an AES-GCM protect
/// step, so two rapid unawaited writes could otherwise persist
/// last-started-first (and a clear could lose to a still-pending save).
final SerialFutureQueue _persistence = SerialFutureQueue();
Future<void> _enqueuePersistence(Future<void> Function() op) => _persistence.run(op);
final FutureCoalescer<void> _userRefresh = FutureCoalescer();
void _logPersistenceFailure(Object e) => appLogger.w('Seerr: session persistence failed', error: e);
@@ -67,6 +62,13 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
bool get isConnected => _session != null;
String? get displayName => _session?.displayName;
/// The signed-in user's permission bitmask, null when disconnected. A
/// scalar for `context.select`: request surfaces re-derive their gates
/// from it, since permission changes are adopted in place (bind-time
/// refresh, silent re-auth, a denied request's `/auth/me` probe) and never
/// rebuild the client.
int? get permissions => _session?.permissions;
/// Client for the catalog/request surfaces; null when disconnected.
SeerrClient? get catalogClient => _catalogClient;
@@ -91,9 +93,11 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
final userUuid = newUserUuid ?? '';
final generation = ++_bindingGeneration;
_activeUserUuid = userUuid;
_setSessionAndRebind(userUuid, generation, null);
if (!_isCurrentBinding(userUuid, generation)) return;
final loaded = await _store.load(userUuid);
_setSessionAndRebind(userUuid, generation, loaded);
unawaited(_refreshUser(userUuid, generation));
if (_isCurrentBinding(userUuid, generation)) unawaited(refreshUser());
}
/// A stored session's permissions and display name date from sign-in;
@@ -102,8 +106,10 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
/// Request action without a reconnect. Best-effort: while the instance is
/// unreachable the session keeps working on its snapshot, and a rejected
/// cookie re-auths or unlinks through the client's own path.
Future<void> _refreshUser(String userUuid, int generation) async {
if (!_isCurrentBinding(userUuid, generation)) return;
Future<void> refreshUser() => _userRefresh.run(_refreshUser);
Future<void> _refreshUser() async {
if (isDisposed) return;
final client = _catalogClient;
if (client == null) return;
try {
@@ -115,22 +121,32 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
/// Persist and bind a session the connect screen established.
Future<void> adoptSession(SeerrSession session) async {
if (isDisposed) return;
final userUuid = _activeUserUuid;
await _enqueuePersistence(() => _store.save(userUuid, session));
_setSessionAndRebind(userUuid, ++_bindingGeneration, session);
final generation = ++_bindingGeneration;
// Retire the old client before persistence: its callbacks must not enqueue
// authority writes behind this explicit adoption.
_setSessionAndRebind(userUuid, generation, null);
if (!_isCurrentBinding(userUuid, generation)) return;
await _store.save(userUuid, session);
_setSessionAndRebind(userUuid, generation, session);
}
/// Sign out server-side (best effort) and clear local state.
Future<void> disconnect() async {
if (isDisposed) return;
final userUuid = _activeUserUuid;
final session = _session;
_setSessionAndRebind(userUuid, ++_bindingGeneration, null);
await _enqueuePersistence(() => _store.clear(userUuid));
final generation = ++_bindingGeneration;
_setSessionAndRebind(userUuid, generation, null);
if (!_isCurrentBinding(userUuid, generation)) return;
await _store.clear(userUuid);
if (session != null) await authService.signOut(session);
}
void _setSessionAndRebind(String userUuid, int generation, SeerrSession? session) {
if (!_isCurrentBinding(userUuid, generation)) return;
_userRefresh.reset();
_session = session;
_catalogClient?.dispose();
_catalogClient = session == null
@@ -155,7 +171,7 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
void _handleSessionUpdated(String userUuid, int generation, SeerrSession session) {
if (!_isCurrentBinding(userUuid, generation)) return;
_session = session;
unawaited(_enqueuePersistence(() => _store.save(userUuid, session)).catchError(_logPersistenceFailure));
unawaited(_store.save(userUuid, session).catchError(_logPersistenceFailure));
safeNotifyListeners();
}
@@ -164,7 +180,7 @@ class SeerrAccountProvider extends ChangeNotifier with DisposableChangeNotifierM
void _handleSessionInvalidated(String userUuid, int generation) {
if (!_isCurrentBinding(userUuid, generation)) return;
final nextGeneration = ++_bindingGeneration;
unawaited(_enqueuePersistence(() => _store.clear(userUuid)).catchError(_logPersistenceFailure));
unawaited(_store.clear(userUuid).catchError(_logPersistenceFailure));
_setSessionAndRebind(userUuid, nextGeneration, null);
}
+205 -170
View File
@@ -27,6 +27,8 @@ import '../models/catalog/catalog_item.dart';
import '../models/catalog/catalog_labels.dart';
import '../models/catalog/catalog_metadata.dart';
import '../providers/catalog_sources_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/seerr_account_provider.dart';
import '../services/catalog/catalog_library_matcher.dart';
import '../services/catalog/catalog_source.dart';
import '../services/catalog/seerr_catalog_source.dart';
@@ -93,7 +95,6 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
final Map<String, FocusNode> _libraryMatchNodesByKey = {};
List<FocusNode> _libraryMatchFocusNodes = const [];
CatalogSource? _watchlistSource;
SeerrCatalogSource? _requestSource;
bool _mutatingWatchlist = false;
CatalogItem? _detailItem;
@@ -101,11 +102,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
/// Library items matching this catalog item; null while resolving.
List<MediaItem>? _matches;
/// Servers that answered at least one resolution pass, and servers that
/// failed one and have never answered. A server in the second set is shown
/// as unchecked rather than counted as "not in your library" — a slow or
/// unreachable server is no evidence of absence (#2098).
final Set<String> _checkedServerIds = {};
/// Coverage belongs to the newest launched (detail-enriched) query, not the
/// union of historical successes. Verified copies are merged independently.
int _resolutionGeneration = 0;
bool _resolvingMatches = false;
final Set<String> _uncheckedServerIds = {};
/// Cast/characters from the item's own source; null while loading (the
@@ -129,16 +129,6 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
unawaited(_loadDetail());
final sources = context.read<CatalogSourcesProvider>();
_watchlistSource = sources.watchlistSourceFor(widget.item);
// Request needs a connected Seerr, the permission for this kind, and a
// tmdb id. The id is checked at build time from the detail-enriched item,
// not here: Trakt items carry one natively and MAL items get theirs from
// the Fribb mapping at row time, but Plex Discover's hub/search/related
// endpoints ignore includeGuids, so a Plex item's tmdb id only arrives
// with the detail fetch (issue #1959).
final seerr = sources.seerrSource;
if (seerr != null && seerr.canRequest(widget.item.kind)) {
_requestSource = seerr;
}
final source = _watchlistSource;
if (source != null) {
source.watchlistChanges.addListener(_onWatchlistChanged);
@@ -177,22 +167,32 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
}
Future<void> _resolveMatches(CatalogItem item) async {
final generation = ++_resolutionGeneration;
_resolvingMatches = true;
LibraryLookupResult result;
try {
result = await context.read<CatalogLibraryMatcher>().match(item);
} catch (e) {
appLogger.w('Catalog library match failed for ${item.identityKey}', error: e);
// A failed pass is no evidence about copies an earlier pass already
// found; only claim "not in your library" when nothing has resolved.
if (_matches == null) _mergeMatches(const []);
if (!mounted) return;
if (generation == _resolutionGeneration) {
_resolvingMatches = false;
_uncheckedServerIds
..clear()
..addAll(context.read<MultiServerProvider>().expectedServerIds);
}
_mergeMatches(const []);
return;
}
if (!mounted) return;
_checkedServerIds.addAll(result.succeededServerIds);
_uncheckedServerIds
..addAll(result.failedServerIds)
..addAll(result.cancelledServerIds)
..removeAll(_checkedServerIds);
if (generation == _resolutionGeneration) {
_resolvingMatches = false;
_uncheckedServerIds
..clear()
..addAll(result.failedServerIds)
..addAll(result.cancelledServerIds)
..addAll(result.unqueriedServerIds);
}
_mergeMatches(result.items);
}
@@ -319,8 +319,24 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
bool get _hasTrailer => _item.trailerUrl?.trim().isNotEmpty ?? false;
/// The Seerr source when the signed-in user may request this kind. Read
/// live rather than captured in initState: the account provider adopts
/// permission changes in place (bind-time refresh, silent re-auth, a
/// denied request's `/auth/me` probe) without rebuilding the client, and a
/// disconnect swaps the source for null — a captured source would outlive
/// its disposed client. `build` subscribes to both so the action bar
/// tracks grants and revocations.
SeerrCatalogSource? get _requestSource {
final seerr = context.read<CatalogSourcesProvider>().seerrSource;
return seerr != null && seerr.canRequest(widget.item.kind) ? seerr : null;
}
/// Whether the Request action can actually render: the tmdb id may only
/// arrive with the detail fetch (see initState), so read the enriched item.
/// arrive with the detail fetch (see [_loadDetail]), so read the enriched
/// item. Trakt items carry one natively and MAL items get theirs from the
/// Fribb mapping at row time, but Plex Discover's hub/search/related
/// endpoints ignore includeGuids, so a Plex item's tmdb id only arrives
/// with the detail fetch (issue #1959).
bool get _canRequest => _requestSource != null && _item.ids.tmdb != null;
bool get _hasActions => _watchlistSource != null || _canRequest || _hasTrailer;
@@ -660,7 +676,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
final mutedStyle = theme.textTheme.bodyMedium?.copyWith(color: mutedColor);
final matches = _matches;
if (matches == null) {
if (matches == null || (matches.isEmpty && _resolvingMatches)) {
return Row(
children: [
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
@@ -1289,6 +1305,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
final theme = Theme.of(context);
final onWatchlist = _isOnWatchlist;
final tmdbId = item.ids.tmdb;
// Subscriptions for [_requestSource]: the source itself (disconnect) and
// the permission mask (grant/revoke), both adopted in place upstream.
context.select<CatalogSourcesProvider, SeerrCatalogSource?>((sources) => sources.seerrSource);
context.select<SeerrAccountProvider?, int?>((account) => account?.permissions);
final artworkDpr = MediaImageHelper.effectiveDevicePixelRatio(context);
// The backdrop strip spans the full screen width (Positioned left/right: 0
// below), and backdropFor is width-keyed: target the rendered width, not
@@ -1343,161 +1363,176 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
child: Column(
crossAxisAlignment: .start,
children: [
Row(
crossAxisAlignment: .start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: OptimizedMediaImage.poster(imagePath: posterUrl, width: 140, height: 210),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
if (item.tagline?.trim() case final tagline? when tagline.isNotEmpty) ...[
Text(
tagline,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.75),
fontStyle: FontStyle.italic,
// Horizontal-only SafeArea: the backdrop above stays
// full-bleed; only the foreground content clears the
// landscape notch. Vertical insets are baked into the
// padding (see above).
SafeArea(
top: false,
bottom: false,
child: Padding(
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
child: Column(
crossAxisAlignment: .start,
children: [
Row(
crossAxisAlignment: .start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: OptimizedMediaImage.poster(imagePath: posterUrl, width: 140, height: 210),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
if (item.tagline?.trim() case final tagline? when tagline.isNotEmpty) ...[
Text(
tagline,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.75),
fontStyle: FontStyle.italic,
),
),
),
const SizedBox(height: 6),
],
Text(
item.title,
style: theme.textTheme.headlineMedium,
maxLines: 3,
overflow: .ellipsis,
),
if (_metaLine.isNotEmpty) ...[
const SizedBox(height: 8),
const SizedBox(height: 6),
],
Text(
_metaLine,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
item.title,
style: theme.textTheme.headlineMedium,
maxLines: 3,
overflow: .ellipsis,
),
],
if (item.genres?.isNotEmpty ?? false) ...[
const SizedBox(height: 8),
Text(
item.genres!.join(' • '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
if (_metaLine.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_metaLine,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
],
const SizedBox(height: 16),
if (_hasActions)
FocusableActionBar(
key: _actionBarKey,
onNavigateDown: _focusSectionBelowActions,
actions: [
if (_watchlistSource != null)
FocusableAction(
// Stable identities so the focused binding survives the
// list-shape changes of async enrichment (watchlist/request
// sources and trailer URL arrive at different times).
debugLabel: 'catalog_watchlist',
icon: onWatchlist ?? false
? Symbols.bookmark_added_rounded
: Symbols.bookmark_add_rounded,
tooltip: onWatchlist ?? false
? t.explore.removeFromWatchlist
: t.explore.addToWatchlist,
onPressed: () => unawaited(_toggleWatchlist()),
),
if (_requestSource case final SeerrCatalogSource seerr when tmdbId != null)
FocusableAction(
debugLabel: 'catalog_request',
icon: Symbols.download_rounded,
tooltip: t.seerr.request,
onPressed: () => unawaited(
showSeerrRequestSheet(
hostContext,
source: seerr,
kind: item.kind,
tmdbId: tmdbId,
title: item.title,
],
if (item.genres?.isNotEmpty ?? false) ...[
const SizedBox(height: 8),
Text(
item.genres!.join(' • '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
const SizedBox(height: 16),
if (_hasActions)
FocusableActionBar(
key: _actionBarKey,
onNavigateDown: _focusSectionBelowActions,
actions: [
if (_watchlistSource != null)
FocusableAction(
// Stable identities so the focused binding survives the
// list-shape changes of async enrichment (watchlist/request
// sources and trailer URL arrive at different times).
debugLabel: 'catalog_watchlist',
icon: onWatchlist ?? false
? Symbols.bookmark_added_rounded
: Symbols.bookmark_add_rounded,
tooltip: onWatchlist ?? false
? t.explore.removeFromWatchlist
: t.explore.addToWatchlist,
onPressed: () => unawaited(_toggleWatchlist()),
),
if (_requestSource case final SeerrCatalogSource seerr
when tmdbId != null)
FocusableAction(
debugLabel: 'catalog_request',
icon: Symbols.download_rounded,
tooltip: t.seerr.request,
onPressed: () => unawaited(
showSeerrRequestSheet(
hostContext,
source: seerr,
kind: item.kind,
tmdbId: tmdbId,
title: item.title,
),
),
),
),
if (item.trailerUrl?.trim() case final trailer? when trailer.isNotEmpty)
FocusableAction(
debugLabel: 'catalog_trailer',
icon: Symbols.play_circle_rounded,
tooltip: t.explore.detail.watchTrailer,
onPressed: () => unawaited(_openExternalUrl(trailer)),
),
],
),
],
if (item.trailerUrl?.trim() case final trailer? when trailer.isNotEmpty)
FocusableAction(
debugLabel: 'catalog_trailer',
icon: Symbols.play_circle_rounded,
tooltip: t.explore.detail.watchTrailer,
onPressed: () => unawaited(_openExternalUrl(trailer)),
),
],
),
],
),
),
],
),
if (_buildStatsChips() case final Widget chips) ...[const SizedBox(height: 20), chips],
if (item.overview?.trim() case final overview? when overview.isNotEmpty) ...[
const SizedBox(height: 24),
CollapsibleText(
text: overview,
maxLines: isMobile ? 6 : 4,
style: theme.textTheme.bodyLarge,
focusNode: _overviewFocusNode,
skipTraversal: false,
onNavigateUp: _hasActions ? _requestActionBarFocus : null,
onNavigateDown: _focusSectionBelowOverview,
),
],
),
if (_buildStatsChips() case final Widget chips) ...[const SizedBox(height: 20), chips],
if (item.overview?.trim() case final overview? when overview.isNotEmpty) ...[
if (item.background?.trim() case final background? when background.isNotEmpty) ...[
const SizedBox(height: 24),
_buildBackgroundSection(theme, background, isMobile: isMobile),
],
if (_buildFactsSection(theme) case final Widget facts) ...[
const SizedBox(height: 24),
facts,
],
if (_buildRecommendersSection(theme) case final Widget recommenders) ...[
const SizedBox(height: 24),
recommenders,
],
if (_buildRatingsSection(theme) case final Widget ratings) ...[
const SizedBox(height: 24),
ratings,
],
if (_buildScheduleSection(theme) case final Widget schedule) ...[
const SizedBox(height: 24),
schedule,
],
if (_buildCrewSection(theme) case final Widget crew) ...[
const SizedBox(height: 24),
crew,
],
if (_buildTagsSection(theme) case final Widget tags) ...[
const SizedBox(height: 24),
tags,
],
if (_streamingLinks.isNotEmpty) ...[
const SizedBox(height: 24),
_buildLinksSection(theme, t.explore.detail.watchOn, _streamingLinks, 0),
],
if (_otherLinks.isNotEmpty) ...[
const SizedBox(height: 24),
_buildLinksSection(theme, t.explore.detail.links, _otherLinks, _streamingLinks.length),
],
const SizedBox(height: 24),
CollapsibleText(
text: overview,
maxLines: isMobile ? 6 : 4,
style: theme.textTheme.bodyLarge,
focusNode: _overviewFocusNode,
skipTraversal: false,
onNavigateUp: _hasActions ? _requestActionBarFocus : null,
onNavigateDown: _focusSectionBelowOverview,
),
_buildLibrarySection(theme),
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
const SizedBox(height: 28),
_buildCastSection(theme, cast),
],
if (_hasRelations) ...[const SizedBox(height: 24), _buildRelationsSection(theme)],
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
const SizedBox(height: 20),
_buildRelatedSection(related),
],
],
if (item.background?.trim() case final background? when background.isNotEmpty) ...[
const SizedBox(height: 24),
_buildBackgroundSection(theme, background, isMobile: isMobile),
],
if (_buildFactsSection(theme) case final Widget facts) ...[
const SizedBox(height: 24),
facts,
],
if (_buildRecommendersSection(theme) case final Widget recommenders) ...[
const SizedBox(height: 24),
recommenders,
],
if (_buildRatingsSection(theme) case final Widget ratings) ...[
const SizedBox(height: 24),
ratings,
],
if (_buildScheduleSection(theme) case final Widget schedule) ...[
const SizedBox(height: 24),
schedule,
],
if (_buildCrewSection(theme) case final Widget crew) ...[const SizedBox(height: 24), crew],
if (_buildTagsSection(theme) case final Widget tags) ...[const SizedBox(height: 24), tags],
if (_streamingLinks.isNotEmpty) ...[
const SizedBox(height: 24),
_buildLinksSection(theme, t.explore.detail.watchOn, _streamingLinks, 0),
],
if (_otherLinks.isNotEmpty) ...[
const SizedBox(height: 24),
_buildLinksSection(theme, t.explore.detail.links, _otherLinks, _streamingLinks.length),
],
const SizedBox(height: 24),
_buildLibrarySection(theme),
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
const SizedBox(height: 28),
_buildCastSection(theme, cast),
],
if (_hasRelations) ...[const SizedBox(height: 24), _buildRelationsSection(theme)],
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
const SizedBox(height: 20),
_buildRelatedSection(related),
],
],
),
),
),
],
+119 -102
View File
@@ -108,6 +108,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
bool _initialLoadComplete = false;
bool _pendingTvBrowseRailFocus = false;
/// Primary focus when the rail claim was armed; see [_railClaimAbandoned].
FocusNode? _railClaimFocusOrigin;
GlobalKey<HubSectionState>? _continueWatchingHubKey;
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
@@ -255,6 +258,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
_pendingTvBrowseRailFocus = true;
_railClaimFocusOrigin = FocusManager.instance.primaryFocus;
if (immediate && _tvBrowseHubs.isNotEmpty) {
final rail = _tvBrowseRailKey.currentState;
if (rail != null) {
@@ -266,7 +270,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false) || _focusHasLeftScreen) {
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) {
_pendingTvBrowseRailFocus = false;
return;
}
@@ -280,12 +284,18 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// A rail-focus request stays armed while the rail has no hubs to focus
/// (empty first load), so hubs landing later still receive it. It must not
/// outlive the user's own navigation: once focus sits on a control off this
/// screen (a sidebar item), hubs arriving minutes later would yank the
/// remote back. A bare scope — MainScreen's content scope before any child
/// has focus — is "nowhere yet", not a destination, and keeps the request.
bool get _focusHasLeftScreen {
/// outlive the user's own navigation: hubs arriving minutes later would
/// yank the remote off a sidebar item the user has since moved to.
///
/// Where focus *sits* cannot tell those apart — MainScreen hands a tab over
/// while focus is still on the sidebar item that selected it, and that
/// request is as live as one made from a bare scope. What distinguishes a
/// stale claim is that focus *moved* after the request was armed and now
/// rests on a control off this screen. A bare scope — MainScreen's content
/// scope before any child has focus — is "nowhere yet", not a destination.
bool get _railClaimAbandoned {
final node = FocusManager.instance.primaryFocus;
if (identical(node, _railClaimFocusOrigin)) return false;
final focusContext = node?.context;
if (node == null || node is FocusScopeNode || focusContext == null) return false;
return !identical(focusContext.findAncestorStateOfType<_DiscoverScreenState>(), this);
@@ -293,7 +303,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
void _applyPendingTvBrowseRailFocus() {
if (!_pendingTvBrowseRailFocus) return;
if (_focusHasLeftScreen) {
if (_railClaimAbandoned) {
_pendingTvBrowseRailFocus = false;
return;
}
@@ -1403,112 +1413,119 @@ class _DiscoverScreenState extends State<DiscoverScreen>
: isLargeScreen
? 200
: 0,
child: Padding(
padding: .symmetric(
horizontal: isTv
? TvLayoutConstants.horizontalInset
: isLargeScreen
? 40
: 24,
),
child: Align(
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isTv ? TvLayoutConstants.heroContentMaxWidth : double.infinity,
),
child: Column(
crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center,
mainAxisSize: .min,
children: [
// Show logo, falling back to the name/title
ClearLogoImage(
client: heroClient,
logoPath: heroItem.clearLogoPath,
width: heroLogoWidth,
height: heroLogoHeight,
alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter,
// The hero scrim washes artwork toward the scaffold
// background; light themes recolor light-toned logos.
logoToneTarget: logoToneTargetFor(
surface: theme.scaffoldBackgroundColor,
foreground: colorScheme.onSurface,
),
fallbackBuilder: (context) => FittingTitleText(
showName,
style: heroTitleStyle,
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
),
),
// Metadata as dot-separated text with content type
if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[
const SizedBox(height: 16),
Text(
[
contentTypeLabel,
if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}',
if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!),
if (heroItem.year != null) heroItem.year.toString(),
].join(' • '),
style: TextStyle(
color: colorScheme.onSurface,
fontSize: isTv ? 18 : 14,
fontWeight: .w600,
// Horizontal-only SafeArea: the PageView artwork behind stays
// full-bleed; the foreground text/buttons clear the landscape
// notch. Vertical placement is handled by `bottom` above.
child: SafeArea(
top: false,
bottom: false,
child: Padding(
padding: .symmetric(
horizontal: isTv
? TvLayoutConstants.horizontalInset
: isLargeScreen
? 40
: 24,
),
child: Align(
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isTv ? TvLayoutConstants.heroContentMaxWidth : double.infinity,
),
child: Column(
crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center,
mainAxisSize: .min,
children: [
// Show logo, falling back to the name/title
ClearLogoImage(
client: heroClient,
logoPath: heroItem.clearLogoPath,
width: heroLogoWidth,
height: heroLogoHeight,
alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter,
// The hero scrim washes artwork toward the scaffold
// background; light themes recolor light-toned logos.
logoToneTarget: logoToneTargetFor(
surface: theme.scaffoldBackgroundColor,
foreground: colorScheme.onSurface,
),
fallbackBuilder: (context) => FittingTitleText(
showName,
style: heroTitleStyle,
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
),
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
),
],
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
// Metadata as dot-separated text with content type
if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[
const SizedBox(height: 16),
Text(
[
contentTypeLabel,
if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}',
if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!),
if (heroItem.year != null) heroItem.year.toString(),
].join(' • '),
style: TextStyle(
color: colorScheme.onSurface,
fontSize: isTv ? 18 : 14,
fontWeight: .w600,
),
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
),
],
if (heroItem.summary != null && !shouldHideSpoiler) ...[
const SizedBox(height: 12),
RichText(
maxLines: isTv ? 3 : 2,
overflow: .ellipsis,
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
text: TextSpan(
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
if (heroItem.summary != null && !shouldHideSpoiler) ...[
const SizedBox(height: 12),
RichText(
maxLines: isTv ? 3 : 2,
overflow: .ellipsis,
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
text: TextSpan(
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.7),
fontSize: isTv ? 18 : 14,
height: isTv ? 1.45 : 1.4,
),
children: [
if (isEpisode && heroItem.parentIndex != null && heroItem.index != null)
TextSpan(
text: 'S${heroItem.parentIndex}, E${heroItem.index}: ',
style: TextStyle(fontWeight: .bold, color: colorScheme.onSurface),
),
TextSpan(
text: heroItem.summary?.isNotEmpty == true
? heroItem.summary!
: t.messages.noDescriptionAvailable,
),
],
),
),
] else if (shouldHideSpoiler &&
isEpisode &&
heroItem.parentIndex != null &&
heroItem.index != null) ...[
const SizedBox(height: 12),
Text(
'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}',
maxLines: 2,
overflow: .ellipsis,
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.7),
fontSize: isTv ? 18 : 14,
height: isTv ? 1.45 : 1.4,
),
children: [
if (isEpisode && heroItem.parentIndex != null && heroItem.index != null)
TextSpan(
text: 'S${heroItem.parentIndex}, E${heroItem.index}: ',
style: TextStyle(fontWeight: .bold, color: colorScheme.onSurface),
),
TextSpan(
text: heroItem.summary?.isNotEmpty == true
? heroItem.summary!
: t.messages.noDescriptionAvailable,
),
],
),
),
] else if (shouldHideSpoiler &&
isEpisode &&
heroItem.parentIndex != null &&
heroItem.index != null) ...[
const SizedBox(height: 12),
Text(
'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}',
maxLines: 2,
overflow: .ellipsis,
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.7),
fontSize: isTv ? 18 : 14,
height: isTv ? 1.45 : 1.4,
),
),
],
],
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
],
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
],
),
),
),
),
@@ -26,8 +26,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
// Action bar key for accessing focus nodes
final GlobalKey<FocusableActionBarState> actionBarKey = GlobalKey<FocusableActionBarState>();
// Grid item focus
final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item');
// The first item shares grid ownership and can move to another index.
FocusNode get firstItemFocusNode => getGridItemFocusNode(0, debugLabel: 'detail_first_item');
// App bar focus state
bool isAppBarFocused = false;
@@ -44,7 +44,6 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
/// Dispose focus-related resources. Call this from your dispose() method.
void disposeFocusResources() {
scrollController.dispose();
firstItemFocusNode.dispose();
disposeGridFocusNodes();
}
+23 -1
View File
@@ -108,7 +108,12 @@ class _HubDetailScreenState extends State<HubDetailScreen>
_focusNodeForIndex(targetIndex).requestFocus();
}
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'hub_detail_item');
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(
index,
firstItemFocusNode,
prefix: 'hub_detail_item',
itemIdentity: _filteredItems[index].globalKey,
);
@override
void initState() {
@@ -230,6 +235,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
});
}
});
_remapFocusToFocusedItem();
}
void _showSortBottomSheet() {
@@ -444,6 +450,12 @@ class _HubDetailScreenState extends State<HubDetailScreen>
if (hasFocus && isLastRow) _requestNextHubPage();
}
/// Reconcile all realized items, including references captured under a cover.
void _remapFocusToFocusedItem() {
final indices = <String, int>{for (var i = 0; i < _filteredItems.length; i++) _filteredItems[i].globalKey: i};
reconcileGridFocusNodes(indices);
}
void _handleContinuationStateChanged() {
if (mounted) {
setState(() {});
@@ -556,6 +568,11 @@ class _HubDetailScreenState extends State<HubDetailScreen>
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: _filteredItems.length,
findChildIndexCallback: (key) {
final id = (key as ValueKey<String>).value;
final index = _filteredItems.indexWhere((item) => item.globalKey == id);
return index < 0 ? null : index;
},
density: libraryDensity,
padding: const EdgeInsets.all(8),
useWideAspectRatio: useWideLayout,
@@ -567,6 +584,11 @@ class _HubDetailScreenState extends State<HubDetailScreen>
final focusNode = _focusNodeForIndex(index);
return FocusableMediaCard(
// Keyed by item, not by slot: a re-sort must move
// the element with its item instead of silently
// updating it with a different one. Aggregated
// hubs mix servers, so the id alone can collide.
key: Key(item.globalKey),
focusNode: focusNode,
item: item,
disableScale: position.disableScale,
+3 -6
View File
@@ -14,6 +14,7 @@ import '../../mixins/context_menu_tap_mixin.dart';
import '../../services/settings_service.dart';
import '../../widgets/settings_builder.dart';
import '../../utils/formatters.dart';
import '../../utils/focus_utils.dart';
import '../../utils/provider_extensions.dart';
import '../../widgets/app_menu.dart';
import '../../widgets/media_context_menu.dart';
@@ -161,12 +162,8 @@ class _FolderTreeItemState extends State<FolderTreeItem> with ContextMenuTapMixi
if (selected == null) {
// Dismissed — restore focus to the row. Play/Shuffle hand off to the
// player instead (mirrors MediaContextMenu's didNavigate handling).
if (previousFocus != null && previousFocus.canRequestFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (previousFocus.canRequestFocus) {
previousFocus.requestFocus();
}
});
if (previousFocus != null) {
FocusUtils.restoreFocusAfterBuild(this, previousFocus);
}
return;
}
+9 -5
View File
@@ -47,8 +47,10 @@ class FolderTreeView extends StatefulWidget {
/// Public state so parents can trigger a refresh via GlobalKey.
class FolderTreeViewState extends State<FolderTreeView> {
/// Reload the root folders. Exposed for parent-driven pull-to-refresh.
Future<void> refresh() => _loadRootFolders();
/// Reload the root folders. Exposed for parent-driven refreshes; resolves
/// `true` only when the fresh root listing was applied under the current
/// load epoch (not superseded, unmounted, or failed).
Future<bool> refresh() => _loadRootFolders();
/// Folders/items returned by the backend's folder API and mapped to neutral
/// [MediaItem]s. Plex folder URLs survive in [MediaItem.raw]['key'];
@@ -90,7 +92,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
return epoch;
}
Future<void> _loadRootFolders() async {
Future<bool> _loadRootFolders() async {
final epoch = _supersedeInFlightLoads();
setState(() {
_isLoadingRoot = true;
@@ -110,7 +112,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
},
);
if (!mounted || epoch != _loadEpoch) return;
if (!mounted || epoch != _loadEpoch) return false;
setState(() {
_rootFolders = folders;
@@ -118,14 +120,16 @@ class FolderTreeViewState extends State<FolderTreeView> {
});
appLogger.d('Loaded ${folders.length} root folders');
return true;
} catch (e, stackTrace) {
if (!mounted || epoch != _loadEpoch) return;
if (!mounted || epoch != _loadEpoch) return false;
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.folders);
setState(() {
_errorMessage = message;
_isLoadingRoot = false;
});
return false;
}
}
+128 -41
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../focus/input_mode_tracker.dart';
@@ -93,13 +94,22 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
late final RefreshPacer _livePacer;
bool _inPlaceReload = false;
/// The effective [TickerMode] notifier this tab listens to for
/// hidden→visible transitions (a pushed opaque route popping). Rebound
/// when the ancestor changes; `null` until first bound.
ValueListenable<TickerModeData>? _tickerModeNotifier;
/// The library content epoch (see [LibrariesProvider.libraryContentEpoch])
/// this tab's data was loaded under.
/// this tab's data was committed under.
int _loadedLibraryContentEpoch = 0;
/// The provider epoch captured when the current load started, so a push
/// landing mid-fetch is not marked consumed by data that predates it.
int _libraryContentEpochAtLoadStart = 0;
/// The epoch snapshot of the load currently in flight, or `null` when no
/// load is pending. Its only job is dedupe: an activation or visibility
/// flip that lands mid-fetch must not start a second reload for the same
/// push. It is never committed — only a successful commit moves
/// [_loadedLibraryContentEpoch], so a failed load leaves the library stale
/// and the next activation retries.
int? _inFlightLibraryContentEpoch;
// Focus management
bool _hasLoadedData = false;
@@ -126,10 +136,11 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
@protected
int get libraryLoadGeneration => _loadGeneration;
/// Start a full load: bump the generation that gates late commits and
/// snapshot the epoch this operation will credit on success.
@protected
int beginLibraryLoad() {
snapshotLibraryContentEpoch();
return ++_loadGeneration;
({int generation, int epoch}) beginLibraryLoad() {
return (generation: ++_loadGeneration, epoch: snapshotLibraryContentEpoch());
}
@protected
@@ -170,21 +181,63 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
_liveLibrarySubscription = LibraryContentNotifier().stream.listen(_onLiveLibraryChange);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updateTickerModeNotifier();
}
@override
void activate() {
super.activate();
// Reparented: the TickerMode ancestor may have changed.
_updateTickerModeNotifier();
}
@override
void dispose() {
invalidateLibraryLoad();
_refreshSubscription?.cancel();
_liveLibrarySubscription?.cancel();
_tickerModeNotifier?.removeListener(_onSurfaceVisibilityChanged);
_tickerModeNotifier = null;
_livePacer.dispose();
super.dispose();
}
/// Mirrors [TickerProviderStateMixin]: [TickerMode.getValuesNotifier] reads
/// the ancestor without registering a dependency, so rebind on the
/// lifecycle hooks where the ancestor can change.
void _updateTickerModeNotifier() {
final notifier = TickerMode.getValuesNotifier(context);
if (identical(notifier, _tickerModeNotifier)) return;
_tickerModeNotifier?.removeListener(_onSurfaceVisibilityChanged);
_tickerModeNotifier = notifier..addListener(_onSurfaceVisibilityChanged);
}
/// A push that landed while this tab sat under an opaque route (or behind
/// another main tab) was dropped by [_onLiveLibraryChange], but the
/// provider still marked the epoch. Nothing else observes the route pop —
/// `isActive` never flips — so replay it here through the same paced
/// in-place path a live event takes. Main-tab activation is already in
/// hand: [LibrariesScreen.onTabShown] starts its reload synchronously
/// before the frame that flips [TickerMode], and that load's in-flight
/// snapshot keeps the epoch from reading stale here.
void _onSurfaceVisibilityChanged() {
if (!mounted || !_isLiveSurfaceVisible) return;
if (!_isLibraryContentStale) return;
_livePacer.schedule();
}
@override
void didUpdateWidget(W oldWidget) {
super.didUpdateWidget(oldWidget);
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
invalidateLibraryLoad();
// The previous library's credit must not vouch for this one's data.
_loadedLibraryContentEpoch = 0;
_inFlightLibraryContentEpoch = null;
// Reset focus state for new library
hasFocused = false;
_hasFocusedChromeFallback = false;
@@ -215,42 +268,61 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
}
}
/// Capture the provider's current epoch at load start. [beginLibraryLoad]
/// does this for transaction-based tabs; paginated pipelines that bypass it
/// call this at the top of their load instead.
/// Capture the provider's current epoch at the start of a load and mark it
/// in flight. [beginLibraryLoad] does this for transaction-based tabs;
/// paginated pipelines that bypass it call this at the top of their load
/// and hand the returned value to [recordLibraryContentEpoch] on commit.
@protected
void snapshotLibraryContentEpoch() {
final provider = _librariesProviderOrNull();
if (provider != null) {
_libraryContentEpochAtLoadStart = provider.libraryContentEpoch(widget.library.globalKey);
}
int snapshotLibraryContentEpoch() {
final epoch = _providerLibraryContentEpoch() ?? 0;
_inFlightLibraryContentEpoch = epoch;
return epoch;
}
/// Mark the load-start epoch snapshot consumed alongside a successful
/// commit, so [refreshIfLibraryContentStale] can tell whether a server push
/// has marked the library since. Deliberately the snapshot, not the current
/// epoch: a push that landed while the fetch was in flight stays stale.
/// Subclasses with their own load pipeline call this after committing
/// fresh data.
/// Commit [epoch] — the snapshot the committing operation took at its own
/// start — so [refreshIfLibraryContentStale] can tell whether a server
/// push has marked the library since. Deliberately that snapshot, not the
/// current epoch: a push that landed while the fetch was in flight stays
/// stale. Only call after fresh data actually landed on screen.
@protected
void recordLibraryContentEpoch() {
_loadedLibraryContentEpoch = _libraryContentEpochAtLoadStart;
void recordLibraryContentEpoch(int epoch) {
_loadedLibraryContentEpoch = epoch;
_inFlightLibraryContentEpoch = null;
// A committed load is as good as a live pass: defer a push event landing
// moments later to the cooldown's trailing edge.
_livePacer.notePass();
}
/// Settle a load that snapshotted the epoch but committed nothing (failure,
/// or a fetch whose result is not what this tab renders). Nothing is
/// credited; clearing the in-flight snapshot lets the next activation
/// retry. Superseded loads must not call this — the newer load owns the
/// snapshot.
@protected
void releaseLibraryContentEpoch() {
_inFlightLibraryContentEpoch = null;
}
/// The provider's current epoch for this library, or `null` without a
/// [LibrariesProvider] (nothing marks staleness then).
int? _providerLibraryContentEpoch() {
return _librariesProviderOrNull()?.libraryContentEpoch(widget.library.globalKey);
}
/// Whether a server push marked this library's content since the last
/// committed load, and no load started since is already fetching it.
bool get _isLibraryContentStale {
final epoch = _providerLibraryContentEpoch();
return epoch != null && epoch != _loadedLibraryContentEpoch && epoch != _inFlightLibraryContentEpoch;
}
/// Reload once when a server push marked this library's content stale since
/// the last load (#1646). Called when the tab is next shown — never live —
/// so scroll position and focus are undisturbed while the user is on it.
/// The epoch is credited by the load's own commit, never here: a reload
/// that fails must leave the library stale so the next activation retries.
void refreshIfLibraryContentStale() {
if (!mounted) return;
final provider = _librariesProviderOrNull();
if (provider == null) return;
final epoch = provider.libraryContentEpoch(widget.library.globalKey);
if (epoch == _loadedLibraryContentEpoch) return;
_loadedLibraryContentEpoch = epoch;
_libraryContentEpochAtLoadStart = epoch;
if (!mounted || !_isLibraryContentStale) return;
loadItems();
}
@@ -283,8 +355,10 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
void _onLiveLibraryChange(LibraryChangeEvent event) {
if (!mounted || !event.hasChanges || !_eventTargetsLibrary(event)) return;
// Only the active tab live-updates; hidden siblings reload on activation
// via [refreshIfLibraryContentStale] instead of fetching invisibly.
// Only the visible tab live-updates. Inactive siblings reload on
// activation via [refreshIfLibraryContentStale]; an active tab under a
// route or another main tab replays the drop when [TickerMode] re-enables
// it ([_onSurfaceVisibilityChanged]) — never fetch invisibly.
if (!_isLiveSurfaceVisible) return;
_livePacer.schedule();
}
@@ -371,12 +445,13 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
}
/// Post-load bookkeeping for tabs that replace [loadItems] with their own
/// (paginated) fetch: mark the tab loaded, take focus if it's due, and let
/// the parent know once the frame carrying the items is in.
/// (paginated) fetch: mark the tab loaded, commit [libraryContentEpoch]
/// (the load's own [snapshotLibraryContentEpoch]), take focus if it's due,
/// and let the parent know once the frame carrying the items is in.
@protected
void markItemsLoaded() {
void markItemsLoaded(int libraryContentEpoch) {
_hasLoadedData = true;
recordLibraryContentEpoch();
recordLibraryContentEpoch(libraryContentEpoch);
tryFocus();
if (widget.onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -417,9 +492,9 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
@protected
Future<void> runLoadTransaction(Future<List<T>> Function() fetch) async {
if (!mounted) return;
final inPlace = _inPlaceReload && _items.isNotEmpty;
final inPlace = _inPlaceReload && hasFocusableContent;
_inPlaceReload = false;
final loadGeneration = beginLibraryLoad();
final (generation: loadGeneration, :epoch) = beginLibraryLoad();
final libraryGlobalKey = widget.library.globalKey;
if (!inPlace) {
@@ -433,17 +508,27 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
try {
final loadedItems = await fetch();
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
final focusedBeforeCommit = FocusManager.instance.primaryFocus;
final contentOwnedFocus =
focusedBeforeCommit?.context?.findAncestorStateOfType<BaseLibraryTabState<T, W>>() == this;
setState(() {
_items = loadedItems;
_isLoading = false;
_hasLoadedData = true;
});
recordLibraryContentEpoch();
tryFocus();
recordLibraryContentEpoch(epoch);
// A populated live pass is not a tab-entry focus handoff. In particular,
// its completion must not reclaim focus from chrome or a covering route.
if (!inPlace) tryFocus();
if (inPlace && !hasFocusableContent && contentOwnedFocus && _isLiveSurfaceVisible) {
// Only an emptied, actively focused surface hands back to its chrome.
// A menu, route, or sidebar that already owns focus remains untouched.
focusEmptyState();
}
final onDataLoaded = widget.onDataLoaded;
if (onDataLoaded != null) {
if (!inPlace && onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) {
onDataLoaded();
@@ -452,6 +537,8 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
}
} catch (e, stackTrace) {
if (!isCurrentLibraryLoad(loadGeneration, libraryGlobalKey)) return;
// Nothing landed: the library stays stale for the next activation.
releaseLibraryContentEpoch();
if (inPlace) {
// Best-effort background refresh: keep the visible content.
appLogger.d('Live library refresh failed for ${widget.library.globalKey}', error: e);
@@ -100,8 +100,8 @@ class LibraryBrowseTab extends BaseLibraryTab<MediaItem> {
class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrowseTab>
with
ItemUpdatable,
LibraryTabFocusMixin,
GridFocusNodeMixin,
LibraryTabFocusMixin,
WatchStateAware,
DeletionAware,
DeletionMirrorsWatchState,
@@ -160,6 +160,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
if (matchEntry != null) {
setState(() {
removeLoadedItemAndShift(matchEntry.key);
reconcileGridFocusNodes({for (final entry in loadedItems.entries) entry.value.id: entry.key});
});
return;
}
@@ -175,6 +176,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
if (newLeafCount <= 0) {
setState(() {
removeLoadedItemAndShift(parentEntry.key);
reconcileGridFocusNodes({for (final entry in loadedItems.entries) entry.value.id: entry.key});
});
} else {
setState(() {
@@ -207,7 +209,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// place (Plex Web's `repopulateRange`) so new items materialize at their
/// sorted positions and metadata updates land, then keep the first visible
/// item stationary by compensating the scroll offset for any index shift
/// the merge caused. Folder grouping browses a tree, not the flat index
/// the merge caused, and carry the D-pad highlight to wherever the focused
/// item moved. Folder grouping browses a tree, not the flat index
/// space — the activation staleness path owns it there. An error or empty
/// grid falls back to the clearing reload: nothing visible to preserve,
/// and it is the only way a first item can appear live.
@@ -219,7 +222,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// The first visible slot may be an unloaded skeleton after a fast jump;
// anchor on it only when its item is known.
final anchorId = anchorIndex == null ? null : loadedItems[anchorIndex]?.id;
snapshotLibraryContentEpoch();
final epoch = snapshotLibraryContentEpoch();
// Bound the refetch: after an alpha jump the map holds disjoint clusters
// whose naive span is nearly the whole library. Keep per-index caches in
// lockstep with the dropped entries.
@@ -234,11 +237,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
maxSpan: maxSpan,
windowCenter: anchorIndex,
);
if (result == null || !mounted) return;
recordLibraryContentEpoch();
if (!mounted) return;
if (result == null) {
// Failed or superseded: nothing landed, so nothing is credited.
releaseLibraryContentEpoch();
return;
}
recordLibraryContentEpoch(epoch);
// Alpha-bar bucket counts shifted with the content; refresh is cheap and
// best-effort.
unawaited(_loadFirstCharacters());
reconcileGridFocusNodes({for (final entry in loadedItems.entries) entry.value.id: entry.key});
final oldIndex = result.anchorOldIndex;
final newIndex = result.anchorNewIndex;
@@ -256,6 +265,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
pos.jumpTo((pos.pixels + rowDelta * _scrollMetrics.rowHeight).clamp(0.0, pos.maxScrollExtent));
}
/// Index currently holding the item with [id], or null when [id] is null or
/// the item is no longer loaded.
int? _loadedIndexOfId(String? id) {
if (id == null) return null;
for (final entry in loadedItems.entries) {
if (entry.value.id == id) return entry.key;
}
return null;
}
// Browse-specific state (not in base class)
List<MediaFilter> _filters = [];
List<MediaSort> _sortOptions = [];
@@ -289,7 +308,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// never outlive the focus nodes its cached cards capture.
static const int _focusNodeKeepCount = 200;
double _effectiveTopPadding = _gridTopPadding;
final GlobalKey _firstListItemKey = GlobalKey(debugLabel: 'first_library_list_item');
double? _measuredListRowHeight;
int? _listMetricsDensity;
bool? _listMetricsUsesWideRatio;
@@ -346,6 +364,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
oldWidget.library.serverId != widget.library.serverId ||
oldWidget.library.isShared != widget.library.isShared) {
_alphaStrategy = _createAlphaStrategy();
cleanupGridFocusNodes(0);
_cardMemo.clear();
}
super.didUpdateWidget(oldWidget);
if (oldWidget.canGroupByFolders != widget.canGroupByFolders) {
@@ -564,7 +584,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
if (!mounted) return;
final library = widget.library;
final libraryGlobalKey = library.globalKey;
final generation = beginLibraryLoad();
final (:generation, :epoch) = beginLibraryLoad();
final firstCharactersGeneration = ++_firstCharactersRequestId;
_resetForFullReload();
@@ -642,11 +662,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// Load items and first characters in parallel.
await Future.wait([
_loadItems(loadGeneration: generation, libraryGlobalKey: libraryGlobalKey),
_loadItems(loadGeneration: generation, libraryGlobalKey: libraryGlobalKey, epoch: epoch),
_loadFirstCharacters(requestId: firstCharactersGeneration),
]);
// Folder grouping renders the tree, not the flat page: a full reload
// (activation staleness, library change) must refetch what is shown.
if (_selectedGrouping == 'folders' && isCurrentLibraryLoad(generation, libraryGlobalKey)) {
await _refreshFolderTree();
}
} catch (e, stackTrace) {
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
releaseLibraryContentEpoch();
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.content);
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
setState(() {
@@ -656,6 +682,25 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
}
/// Reload the mounted [FolderTreeView] and credit the epoch only when its
/// root listing actually landed. A tree that is not mounted yet loads
/// itself on mount; nothing is credited then, so a push that predates the
/// mount is still owed to the next activation.
Future<void> _refreshFolderTree() async {
final tree = _folderTreeKey.currentState;
if (tree == null) return;
final generation = libraryLoadGeneration;
final libraryGlobalKey = widget.library.globalKey;
final epoch = snapshotLibraryContentEpoch();
final refreshed = await tree.refresh();
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
if (refreshed) {
recordLibraryContentEpoch(epoch);
} else {
releaseLibraryContentEpoch();
}
}
void _loadMediaBrowserFiltersInBackground(int generation, String libraryGlobalKey, MediaLibrary library) {
final client = context.tryGetMediaClientForServer(serverIdOrNull(library.serverId));
if (client == null) return;
@@ -749,10 +794,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return filterParams;
}
Future<void> _loadItems({bool preserveFocus = false, int? loadGeneration, String? libraryGlobalKey}) async {
/// Fetch the first flat page. [epoch] is the snapshot of the full load that
/// owns this fetch; self-started reloads (filter, sort, grouping, alpha
/// prefix) snapshot at their own start.
Future<void> _loadItems({
bool preserveFocus = false,
int? loadGeneration,
String? libraryGlobalKey,
int? epoch,
}) async {
final generation = loadGeneration ?? libraryLoadGeneration;
final acceptedLibraryGlobalKey = libraryGlobalKey ?? widget.library.globalKey;
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
final contentEpoch = epoch ?? snapshotLibraryContentEpoch();
setState(() {
isLoading = true;
items = [];
@@ -774,9 +828,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
});
hasLoadedData = true;
// Consume the load-start epoch snapshot (and credit the live pacer) so
// a fresh full load isn't re-marked stale on the next activation.
recordLibraryContentEpoch();
// Credit the operation's own snapshot (and the live pacer) so a fresh
// full load isn't re-marked stale on the next activation. Folder
// grouping doesn't render this page — [_refreshFolderTree] credits
// the tree's reload instead.
if (_selectedGrouping == 'folders') {
releaseLibraryContentEpoch();
} else {
recordLibraryContentEpoch(contentEpoch);
}
if (!preserveFocus) {
tryFocus();
}
@@ -792,6 +852,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
} catch (e, stackTrace) {
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
releaseLibraryContentEpoch();
final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.content);
if (!isCurrentLibraryLoad(generation, acceptedLibraryGlobalKey)) return;
setState(() {
@@ -1377,15 +1438,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
? firstItemFocusNode
: getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item');
// Defer to a post-frame so the focus node has a chance to attach if the
// grid item is being built/rebuilt in the same frame.
if (target.context != null) {
target.requestFocus();
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) target.requestFocus();
});
}
// Flutter retains preattachment requests until the target is reparented.
target.requestFocus();
}
/// Navigate from the alpha jump bar to the nearest visible grid item.
@@ -1690,34 +1744,42 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// Select the derived letter rather than listening to the raw
// index: the index changes every scrolled row, but the bar only
// needs a rebuild when the letter itself flips.
child: _isPhone(context)
? ListenableSelector<String>(
listenable: _currentFirstVisibleIndex,
selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value),
builder: (context, currentLetter, _) => ValueListenableBuilder<bool>(
valueListenable: _isScrollActive,
builder: (context, scrolling, _) => AlphaScrollHandle(
// Horizontal-only SafeArea: on landscape phones the trailing
// inset (notch/rounded corner) is not consumed by the nav rail,
// so the handle must clear it itself. Vertical insets are
// already covered by overlayTopPadding and the parent scaffold.
child: SafeArea(
top: false,
bottom: false,
child: _isPhone(context)
? ListenableSelector<String>(
listenable: _currentFirstVisibleIndex,
selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value),
builder: (context, currentLetter, _) => ValueListenableBuilder<bool>(
valueListenable: _isScrollActive,
builder: (context, scrolling, _) => AlphaScrollHandle(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentLetter: currentLetter,
descending: _isTitleSortDescending,
isScrolling: scrolling,
),
),
)
: ListenableSelector<String>(
listenable: _currentFirstVisibleIndex,
selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value),
builder: (context, currentLetter, _) => AlphaJumpBar(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentLetter: currentLetter,
descending: _isTitleSortDescending,
isScrolling: scrolling,
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGridNearScroll,
onBack: _navigateToGridNearScroll,
),
),
)
: ListenableSelector<String>(
listenable: _currentFirstVisibleIndex,
selector: () => _alphaLetterFor(_currentFirstVisibleIndex.value),
builder: (context, currentLetter, _) => AlphaJumpBar(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentLetter: currentLetter,
descending: _isTitleSortDescending,
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGridNearScroll,
onBack: _navigateToGridNearScroll,
),
),
),
),
],
);
@@ -1727,6 +1789,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
Widget _buildScrollableContent() {
final isFolders = _selectedGrouping == 'folders';
// Horizontal-only SafeArea at the region owner: the nav rail consumes the
// leading inset, but the trailing one (landscape notch/cutout) would
// otherwise sit under the rightmost grid column and the folder tree.
Widget scrollView = NotificationListener<ScrollNotification>(
onNotification: (notification) {
// Track scroll activity for phone scroll handle and range-load gating
@@ -1780,15 +1845,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
),
);
scrollView = SafeArea(top: false, bottom: false, child: scrollView);
// Folders mode previously had its own RefreshIndicator inside FolderTreeView;
// it now lives at this level since FolderTreeView is a sliver.
if (isFolders) {
scrollView = RefreshIndicator(
onRefresh: () async {
await _folderTreeKey.currentState?.refresh();
},
child: scrollView,
);
scrollView = RefreshIndicator(onRefresh: _refreshFolderTree, child: scrollView);
}
return scrollView;
@@ -2089,15 +2151,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
);
}
Widget _buildMeasuredFirstListItem(Widget child) {
WidgetsBinding.instance.addPostFrameCallback((_) => _measureFirstListRowHeight());
return KeyedSubtree(key: _firstListItemKey, child: child);
}
void _measureFirstListRowHeight() {
if (!mounted) return;
if (SettingsService.instanceOrNull?.read(SettingsService.viewMode) != ViewMode.list) return;
final height = (_firstListItemKey.currentContext?.findRenderObject() as RenderBox?)?.size.height;
final height = (gridItemFocusNodes[0]?.context?.findRenderObject() as RenderBox?)?.size.height;
if (height == null || height <= 0) return;
if ((_measuredListRowHeight ?? 0) == height) return;
_measuredListRowHeight = height;
@@ -2145,6 +2202,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final hasAlphaBarReservation = rightPadding > 8.0;
return MediaCardSliverLayout(
findChildIndexCallback: (key) => _loadedIndexOfId((key as ValueKey<String>).value),
viewMode: viewMode,
itemCount: itemCount,
density: libraryDensity,
@@ -2198,7 +2256,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
itemCount: itemCount,
),
);
return index == 0 ? _buildMeasuredFirstListItem(child) : child;
if (index == 0) {
WidgetsBinding.instance.addPostFrameCallback((_) => _measureFirstListRowHeight());
}
return child;
}
return realizeBudgeted(
@@ -2240,9 +2301,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return const SkeletonMediaCard();
}
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
// All other items get managed focus nodes for restoration
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'browse_grid_item');
// Index 0 routes through firstItemFocusNode for the base class; all other
// items get managed focus nodes for restoration.
final focusNode = _cardFocusNode(index);
// Explicit row navigation. Default directional focus traversal becomes
// unreliable while items are mounting/unmounting under fast scrolling and
@@ -2290,18 +2351,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
);
}
FocusNode _cardFocusNode(int index) =>
focusNodeForIndex(index, firstItemFocusNode, prefix: 'browse_grid_item', itemIdentity: loadedItems[index]?.id);
/// Move focus to the grid item at [targetIndex] (or its skeleton's row).
/// Used by the explicit dpad navigation handlers.
void _focusGridItem(int targetIndex) {
if (targetIndex < 0 || targetIndex >= totalSize) return;
final node = targetIndex == 0 ? firstItemFocusNode : getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item');
if (node.context != null) {
node.requestFocus();
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) node.requestFocus();
});
}
final node = _cardFocusNode(targetIndex);
node.requestFocus();
}
}
@@ -48,8 +48,8 @@ class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
with ItemUpdatable, WatchStateAware, DeletionAware, DeletionMirrorsWatchState {
/// GlobalKeys for each hub section to enable vertical navigation
final List<GlobalKey<HubSectionState>> _hubKeys = [];
/// Navigation belongs to the rendered hub snapshot, never an in-flight load.
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
final TvSpotlightController _spotlight = TvSpotlightController();
HubFocusMemory _hubFocusMemory = HubFocusMemory();
@@ -60,6 +60,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
void didUpdateWidget(LibraryRecommendedTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.library.globalKey != widget.library.globalKey) {
_hubKeysByIdentity.clear();
_hubFocusMemory = HubFocusMemory();
}
}
@@ -76,6 +77,9 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
/// Every item on screen, across all hubs.
Iterable<MediaItem> get _visibleItems => items.expand((hub) => hub.items);
@override
bool get hasFocusableContent => _visibleItems.isNotEmpty;
// Deletion mirrors these via DeletionMirrorsWatchState: each visible item
// plus its parents, so deleting a season/show also matches the episodes it
// contains here.
@@ -135,17 +139,16 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
// item in place, so it must not evict anything here.
if (event.isDownloadOnly) return;
// Drop the item and any descendants (season/show deletions take their
// episodes with them) from every hub, then resync with the server for
// parent leaf counts and replacement on-deck items — same
// remove-in-place-then-reload shape as the removedFromContinueWatching
// path above.
// Eviction is immediate for both origins. Push reconciliation belongs to
// the coarse library event and its visibility/playback-aware pacer.
_removeItemsFromHubs(
hubMatches: (_) => true,
itemMatches: (item) =>
item.id == event.itemId || item.parentId == event.itemId || item.grandparentId == event.itemId,
);
unawaited(loadItems());
if (event.origin == DeletionOrigin.local) {
unawaited(loadItems());
}
}
void _removeItemsFromHubs({
@@ -161,6 +164,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
items[i] = hub.copyWith(items: newItems, size: newItems.length);
}
}
items.removeWhere((hub) => hub.items.isEmpty);
});
}
@@ -183,9 +187,6 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
Future<void> loadItems() => runLoadTransaction(_loadRecommendedHubs);
Future<List<MediaHub>> _loadRecommendedHubs() async {
// Clear hub keys before loading new hubs to prevent stale references
_hubKeys.clear();
// Backend-aware fetch: Plex hits /hubs/sections, Jellyfin synthesises
// Continue Watching + Next Up + Recently Added.
final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId));
@@ -199,6 +200,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
libraryKind: widget.library.kind,
),
);
hubs.removeWhere((hub) => hub.items.isEmpty);
// Move Continue Watching hub to the front if present
final cwIndex = hubs.indexWhere(_isContinueWatchingHub);
@@ -210,22 +212,30 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
return hubs;
}
/// Ensure we have enough GlobalKeys for all hubs
void _ensureHubKeys(int count) {
while (_hubKeys.length < count) {
_hubKeys.add(GlobalKey<HubSectionState>());
List<GlobalKey<HubSectionState>> _keysForHubs(List<MediaHub> hubs) {
final occurrences = <String, int>{};
final liveIdentities = <String>{};
final keys = <GlobalKey<HubSectionState>>[];
for (final hub in hubs) {
var identity = '${hub.serverId ?? ''}:${hub.identifier ?? hub.id}';
final occurrence = occurrences.update(identity, (n) => n + 1, ifAbsent: () => 0);
if (occurrence > 0) identity = '$identity#$occurrence';
liveIdentities.add(identity);
keys.add(_hubKeysByIdentity.putIfAbsent(identity, GlobalKey<HubSectionState>.new));
}
_hubKeysByIdentity.removeWhere((identity, _) => !liveIdentities.contains(identity));
return keys;
}
/// Handle vertical navigation between hubs
bool _handleVerticalNavigation(int hubIndex, bool isUp) {
bool _handleVerticalNavigation(List<GlobalKey<HubSectionState>> keys, int hubIndex, bool isUp) {
return navigateVerticalHubRows(
hubCount: items.length,
hubCount: keys.length,
hubIndex: hubIndex,
isUp: isUp,
propagateTopBoundary: true,
requestFocus: (targetIndex) {
_hubKeys[targetIndex].currentState?.requestFocusFromMemory();
keys[targetIndex].currentState?.requestFocusFromMemory();
},
);
}
@@ -250,8 +260,8 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
});
return;
}
if (_hubKeys.isNotEmpty && items.isNotEmpty) {
_hubKeys.first.currentState?.requestFocusAt(0);
if (items.isNotEmpty) {
_keysForHubs(items).first.currentState?.requestFocusAt(0);
}
}
@@ -265,7 +275,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
@override
Widget buildContent(List<MediaHub> items) {
_ensureHubKeys(items.length);
final hubKeys = _keysForHubs(items);
if (PlatformDetector.isTV()) {
return SettingsBuilder(
@@ -283,13 +293,17 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
padding: const EdgeInsets.fromLTRB(0, _focusDecorationPadding, 0, 8),
sliver: SliverList.builder(
itemCount: items.length,
findChildIndexCallback: (key) {
final index = hubKeys.indexOf(key as GlobalKey<HubSectionState>);
return index < 0 ? null : index;
},
itemBuilder: (context, index) {
final hub = items[index];
final isContinueWatching = _isContinueWatchingHub(hub);
final usesContinueWatchingAction = _usesContinueWatchingAction(hub);
return HubSection(
key: _hubKeys[index],
key: hubKeys[index],
hub: hub,
focusMemory: _hubFocusMemory,
icon: hubIconFor(hub),
@@ -297,7 +311,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
usesContinueWatchingAction: usesContinueWatchingAction,
onRefresh: updateItem,
onRemoveFromContinueWatching: isContinueWatching ? _refreshContinueWatching : null,
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
onVerticalNavigation: (isUp) => _handleVerticalNavigation(hubKeys, index, isUp),
onBack: widget.onBack,
onNavigateUp: index == 0 ? widget.onBack : null,
onNavigateToSidebar: _navigateToSidebar,
@@ -28,8 +28,8 @@ import 'base_library_tab.dart';
abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibraryTab<T>>
extends BaseLibraryTabState<T, W>
with
LibraryTabFocusMixin<W>,
GridFocusNodeMixin<W>,
LibraryTabFocusMixin<W>,
PaginatedItemLoader<T, W>,
StandardPaginatedView<T, W>,
SkeletonUpgradeScheduler<W> {
@@ -53,30 +53,53 @@ abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibrary
@override
Future<void> loadItems() {
// This pipeline bypasses [beginLibraryLoad]; capture the epoch here so
// [markItemsLoaded]'s record marks load-start data, not a mid-fetch push.
snapshotLibraryContentEpoch();
cleanupGridFocusNodes(0);
_cardMemo.clear();
// This pipeline bypasses [beginLibraryLoad]; snapshot here so the commit
// credits load-start data, not a mid-fetch push. A thrown fetch was the
// current request (superseded ones report `applied: false` instead), so
// the error hook is where a failed load settles without credit.
final epoch = snapshotLibraryContentEpoch();
return loadStandardPaginatedItems(
pageSize: pageSize,
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
onLoaded: (_, _) => markItemsLoaded(),
errorMessageFor: (error, stackTrace) {
releaseLibraryContentEpoch();
return localizedLoadErrorMessage(error, stackTrace, context: errorContext);
},
onLoaded: (_, _) => markItemsLoaded(epoch),
);
}
/// Server push while this grid is visible (#1646): refetch the loaded span
/// in place so the old cards stay rendered — no spinner, no scroll reset,
/// no focus churn. The clearing [loadItems] path is reserved for surfaces
/// no focus churn. Item-owned nodes retain active and covered restoration
/// focus across the merge. The clearing [loadItems] path is reserved for surfaces
/// with nothing visible to preserve (error or empty states, where it is
/// also the only way a first item can appear live).
@override
Future<void> performLiveLibraryRefresh() async {
if (isLoading) return;
if (!hasLoadedData || loadedItems.isEmpty || totalSize == 0) return loadItems();
snapshotLibraryContentEpoch();
final epoch = snapshotLibraryContentEpoch();
final result = await repopulateLoadedRange(idOf: idOf);
if (result == null || !mounted) return;
recordLibraryContentEpoch();
if (!mounted) return;
if (result == null) {
// Failed or superseded: nothing landed, so nothing is credited.
releaseLibraryContentEpoch();
return;
}
recordLibraryContentEpoch(epoch);
setState(() => items = loadedItems.values.toList());
reconcileGridFocusNodes({for (final entry in loadedItems.entries) idOf(entry.value): entry.key});
}
/// Index currently holding the item with [id], or null when it is no longer
/// loaded.
int? _loadedIndexOfId(String id) {
for (final entry in loadedItems.entries) {
if (idOf(entry.value) == id) return entry.key;
}
return null;
}
@override
@@ -111,6 +134,7 @@ abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibrary
viewMode: viewMode,
itemCount: totalSize,
density: density,
findChildIndexCallback: (key) => _loadedIndexOfId((key as ValueKey<String>).value),
padding: _effectivePadding,
fullBleedImage: useFullCardLayout,
shape: shape,
@@ -174,10 +198,16 @@ abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibrary
onNavigateLeft: navigateLeft,
onNavigateRight: navigateRight,
onBack: widget.onBack,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
);
}
FocusNode _cardFocusNode(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'paginated_grid_item');
FocusNode _cardFocusNode(int index) => focusNodeForIndex(
index,
firstItemFocusNode,
prefix: 'paginated_grid_item',
itemIdentity: loadedItems[index] == null ? null : idOf(loadedItems[index]!),
);
/// Move focus to the grid item at [targetIndex]. When the target card is
/// not yet mounted (being built this frame, or still an unloaded skeleton),
+6
View File
@@ -44,6 +44,7 @@ import '../providers/multi_server_provider.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/libraries_provider.dart';
import '../providers/playback_state_provider.dart';
import '../providers/seerr_account_provider.dart';
import '../widgets/settings_builder.dart';
import '../widgets/tv_virtual_keyboard.dart';
import '../services/api_cache.dart';
@@ -1131,6 +1132,11 @@ class _MainScreenState extends State<MainScreen>
// Always consume: the gates latch backgrounding on every lifecycle event.
final resumedFromBackground = _profileSelectionResumeGate.consumePromptOn(state);
final refreshStaleContent = _contentRefreshResumeGate.consumeRefreshOn(state);
// Seerr authority is independent of media-server reachability and stale
// content. A grant must recover hidden Request actions on any real resume.
if (resumedFromBackground && mounted) {
unawaited(context.read<SeerrAccountProvider?>()?.refreshUser());
}
if (shouldShowProfileSelectionOnResume(
resumedFromBackground: resumedFromBackground,
isOffline: _isOffline,
+44 -24
View File
@@ -2,6 +2,12 @@ part of '../media_detail_screen.dart';
extension _MediaDetailActionButtons on _MediaDetailScreenState {
Widget _buildActionButtons(MediaItem metadata) {
// Tie asynchronous playback prompts to the actionable subtree, not the
// route's State: a deleted first-route detail intentionally stays mounted.
return Builder(builder: (context) => _buildAvailableActionButtons(context, metadata));
}
Widget _buildAvailableActionButtons(BuildContext context, MediaItem metadata) {
final isTv = PlatformDetector.isTV();
final tvScale = TvLayoutConstants.scaleOf(context);
final actionSize = isTv ? _tvDetailActionSize * tvScale : 48.0;
@@ -42,6 +48,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
: null;
Future<void> onPlayPressed() async {
if (!_canUseDetail) return;
// For TV shows, play the episode the hero describes (focused on TV,
// otherwise on-deck); with neither, the first episode of the first season.
if (metadata.isShow) {
@@ -53,6 +60,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
metadata: episode,
isOffline: widget.isOffline,
onRefresh: _refreshWatchState,
isLaunchCurrent: () => _canUseDetail,
);
} else {
// No on deck episode, fetch first episode of first season
@@ -66,6 +74,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
metadata: _episodes.first,
isOffline: widget.isOffline,
onRefresh: _refreshWatchState,
isLaunchCurrent: () => _canUseDetail,
);
} else {
await _playFirstEpisode();
@@ -78,11 +87,13 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
metadata: metadata,
isOffline: widget.isOffline,
onRefresh: _refreshWatchState,
isLaunchCurrent: () => _canUseDetail,
);
}
}
Future<void> onPlayVersionPressed() async {
if (!_canUseDetail) return;
final didNavigate = await promptAndPlayVersion(context, metadata);
// Same post-playback refresh plain Play gets from
// navigateToVideoPlayerWithRefresh; the split segment is online-only.
@@ -142,7 +153,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
// ⋮ menu item so the trailer stays reachable when the row hides its button.
final VoidCallback? onPlayTrailer = primaryTrailer == null
? null
: () => unawaited(navigateToVideoPlayer(context, metadata: primaryTrailer));
: () =>
unawaited(navigateToVideoPlayer(context, metadata: primaryTrailer, isLaunchCurrent: () => _canUseDetail));
final gap = isTv ? 8.0 * tvScale : 12.0;
@@ -271,7 +283,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
final downloadAction = !widget.isOffline && !PlatformDetector.isAppleTV()
? FocusableAction(
debugLabel: 'detail_download',
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
builder: (context, state) =>
_buildDownloadButton(metadata, actionButtonStyle, tvScale, showFocus: state.showFocus),
)
@@ -433,6 +445,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
}
Future<void> _handleWatchlistTogglePressed(MediaItem metadata) async {
if (!_canUseDetail) return;
final candidates = _watchlistCandidates;
if (candidates.isEmpty || _watchlistMutationInFlight) return;
// Parity with the disabled pointer button: while every membership is
@@ -460,11 +473,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
anchorRect: renderBox.localToGlobal(Offset.zero) & renderBox.size,
focusFirstItem: true,
);
if (choice == null || !mounted) return;
if (choice == null || !_canUseDetail) return;
await _toggleWatchlistOn(metadata, choice);
}
Future<void> _toggleWatchlistOn(MediaItem metadata, WatchlistCandidate candidate) async {
if (!_canUseDetail) return;
final current = candidate.source.isOnWatchlist(metadata.kind, candidate.ids);
if (current == null || _watchlistMutationInFlight) return;
_watchlistMutationInFlight = true;
@@ -480,10 +494,11 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
}
Future<void> _handleWatchedTogglePressed(MediaItem metadata) async {
if (!_canUseDetail) return;
try {
final isWatched = metadata.isWatched;
final outcome = await WatchActions.setWatched(context, metadata, watched: !isWatched, offline: widget.isOffline);
if (!mounted) return;
if (!mounted || !_canUseDetail) return;
switch (outcome) {
case WatchMarkOutcome.queuedOffline:
showAppSnackBar(context, isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline);
@@ -532,6 +547,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
child: Builder(
builder: (buttonContext) => IconButton.filledTonal(
onPressed: () {
if (!_canUseDetail) return;
final renderBox = buttonContext.findRenderObject() as RenderBox?;
if (renderBox != null) {
final position = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
@@ -550,15 +566,17 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
/// restrictions, re-resolve the version, then replace the existing download
/// with a fresh queue entry.
Future<void> _retryDownload(DownloadProvider downloadProvider, MediaItem metadata, String globalKey) async {
if (!await confirmBackgroundDownloadRestrictions(context) || !mounted) return;
if (!_canUseDetail) return;
if (!await confirmBackgroundDownloadRestrictions(context) || !mounted || !_canUseDetail) return;
final client = _getMediaClientForMetadata(context);
if (client == null) return;
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
if (versionConfig == null || !mounted) return;
if (versionConfig == null || !_canUseDetail) return;
await downloadProvider.deleteDownload(globalKey);
if (!_canUseDetail) return;
try {
await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
if (mounted) showSuccessSnackBar(context, t.downloads.downloadQueued);
@@ -567,7 +585,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
}
}
Future<void> _handleDownloadButtonPressed(MediaItem metadata) async {
Future<void> _handleDownloadButtonPressed(BuildContext context, MediaItem metadata) async {
if (!_canUseDetail || !context.mounted) return;
final downloadProvider = context.read<DownloadProvider>();
final globalKey = metadata.globalKey;
final ruleKey = _syncRuleKeyForMetadata(context, downloadProvider, metadata);
@@ -583,7 +602,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
final client = _getMediaClientForMetadata(context);
if (client == null) return;
await downloadProvider.resumeDownload(globalKey, client);
if (mounted) showAppSnackBar(context, t.downloads.downloadResumed);
if (_canUseDetail && context.mounted) showAppSnackBar(context, t.downloads.downloadResumed);
return;
}
@@ -602,11 +621,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
cancelText: t.common.delete,
confirmText: t.common.retry,
);
if (!_canUseDetail || !context.mounted) return;
if (!retry && mounted) {
if (!retry) {
await downloadProvider.deleteDownload(globalKey);
if (mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
} else if (retry && mounted) {
if (_canUseDetail && context.mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
} else {
await _retryDownload(downloadProvider, metadata, globalKey);
}
return;
@@ -617,16 +637,16 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
await _showSyncRuleActions(context, downloadProvider, metadata, ruleKey: ruleKey, downloadGlobalKey: globalKey);
return;
}
if (!await confirmBackgroundDownloadRestrictions(context) || !mounted) return;
if (!await confirmBackgroundDownloadRestrictions(context) || !_canUseDetail || !context.mounted) return;
final client = _getMediaClientForMetadata(context);
if (client == null) return;
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
if (versionConfig == null || !mounted) return;
if (versionConfig == null || !_canUseDetail || !context.mounted) return;
final count = await downloadProvider.queueMissingEpisodes(metadata, client, versionConfig: versionConfig);
if (mounted) {
if (_canUseDetail && context.mounted) {
final message = count > 0 ? t.downloads.episodesQueued(count: count) : t.downloads.allEpisodesAlreadyDownloaded;
showAppSnackBar(context, message);
}
@@ -646,9 +666,9 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
title: t.downloads.deleteDownload,
message: t.downloads.deleteConfirm(title: metadata.displayTitle),
);
if (confirmed && mounted) {
if (confirmed && _canUseDetail && context.mounted) {
await downloadProvider.deleteDownload(globalKey);
if (mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
if (_canUseDetail && context.mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
}
}
@@ -741,7 +761,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
// State 4: Paused (can resume)
if (progress?.status == DownloadStatus.paused) {
return IconButton.filledTonal(
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1),
tooltip: t.downloads.resumeDownload,
iconSize: iconSize,
@@ -752,7 +772,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
// State 5: Failed (can retry)
if (progress?.status == DownloadStatus.failed) {
return IconButton.filledTonal(
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
icon: const AppIcon(Symbols.error_outline_rounded, fill: 1),
tooltip: t.downloads.retryDownload,
iconSize: iconSize,
@@ -763,7 +783,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
// State 6: Cancelled (can delete or retry)
if (progress?.status == DownloadStatus.cancelled) {
return IconButton.filledTonal(
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
tooltip: t.downloads.cancelledDownload,
iconSize: iconSize,
@@ -788,7 +808,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
: t.downloads.keepSynced;
return IconButton.filledTonal(
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
tooltip: tooltip,
icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1),
iconSize: iconSize,
@@ -801,7 +821,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
: t.downloads.partialDownloadClickToComplete;
return IconButton.filledTonal(
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
tooltip: tooltip,
icon: const AppIcon(Symbols.downloading_rounded, fill: 1),
iconSize: iconSize,
@@ -818,7 +838,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
final syncRule = downloadProvider.getSyncRule(ruleKey);
final isEnabled = syncRule?.enabled ?? true;
return IconButton.filledTonal(
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1),
tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'),
iconSize: iconSize,
@@ -830,7 +850,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
final canDownloadMore = metadata.isShow || metadata.isSeason;
return IconButton.filledTonal(
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
icon: const AppIcon(Symbols.download_rounded, fill: 1),
tooltip: canDownloadMore ? t.downloads.manage : t.downloads.deleteDownload,
iconSize: iconSize,
@@ -840,7 +860,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
// State 9: Not downloaded (default - can download)
return IconButton.filledTonal(
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
onPressed: () => unawaited(_handleDownloadButtonPressed(context, metadata)),
icon: const AppIcon(Symbols.download_rounded, fill: 1),
tooltip: t.downloads.downloadNow,
iconSize: iconSize,
@@ -10,6 +10,7 @@ extension _MediaDetailPlaybackTracksStatus on _MediaDetailScreenState {
/// episode for a season, the item itself otherwise. Null while a show has
/// not resolved any episode yet.
MediaItem? _playbackTargetItem(MediaItem metadata) {
if (!_canUseDetail) return null;
final MediaItem? target;
if (metadata.isShow) {
target = _showPlayEpisode();
@@ -18,53 +19,123 @@ extension _MediaDetailPlaybackTracksStatus on _MediaDetailScreenState {
} else {
target = metadata;
}
if (target == null) return null;
return _probedPlaybackItems[target.id] ?? target;
return target;
}
/// Plex listings describe a file only by its container summary, so an
/// episode reached through the rail has no stream rows until its own item
/// is fetched. One request per item, debounced past D-pad scrubbing, cached
/// for the screen's lifetime; the result reaches the hero through the
/// focused-episode notifier so the rail does not rebuild.
void _scheduleTargetProbe(BuildContext context, MediaItem target) {
if (widget.isOffline || _probedPlaybackItems.containsKey(target.id)) return;
final client = _getMediaClientForMetadata(context);
if (client == null) return;
Future<void> _listenForPlaybackVersionChanges() async {
final settings = await SettingsService.getInstance();
if (!_canUseDetail) return;
_playbackVersionPreferences = settings.listenableOf(SettingsService.mediaVersionPreferences);
_playbackVersionPreferences!.addListener(_onPlaybackVersionChanged);
}
void _onPlaybackVersionChanged() {
if (!_canUseDetail) return;
_invalidatePlaybackProbes(refreshItems: false);
}
void _invalidatePlaybackProbes({required bool refreshItems}) {
_playbackProbeGeneration++;
_playbackProbeTimer?.cancel();
_playbackProbeRequests.clear();
_playbackSources.clear();
if (refreshItems) _probedPlaybackItems.clear();
_playbackStatusRevision.value++;
}
/// Fetch metadata once, then resolve the saved selection independently.
/// Rendering never starts PlaybackInfo or a transcode. Offline only reads
/// the completed download's identity, never a server or track probe.
void _scheduleTargetProbe(BuildContext context, MediaItem target) {
if (!_canUseDetail) return;
final key = target.globalKey;
if (_playbackSources.containsKey(key) || _playbackProbeRequests.containsKey(key)) return;
final client = widget.isOffline ? null : _getMediaClientForMetadata(context);
if (!widget.isOffline && client == null) return;
final downloads = widget.isOffline ? context.read<DownloadProvider>() : null;
_playbackProbeTimer?.cancel();
final generation = _playbackProbeGeneration;
_playbackProbeTimer = Timer(const Duration(milliseconds: 350), () {
if (!mounted || _probedPlaybackItems.containsKey(target.id)) return;
unawaited(_probeTarget(client, target));
if (!mounted || generation != _playbackProbeGeneration || _playbackProbeRequests.containsKey(key)) return;
unawaited(_probeTarget(client, downloads, target, generation));
});
}
Future<void> _probeTarget(MediaServerClient client, MediaItem target) async {
final MediaItem? fetched;
Future<void> _probeTarget(
MediaServerClient? client,
DownloadProvider? downloads,
MediaItem target,
int generation,
) async {
final key = target.globalKey;
final request = Object();
_playbackProbeRequests[key] = request;
bool current() => _canUseDetail && generation == _playbackProbeGeneration && _playbackProbeRequests[key] == request;
try {
fetched = await client.fetchItem(target.id);
MediaItem item;
MediaSourceInfo? source;
int? mediaIndex;
if (downloads != null) {
final downloaded = await downloads.getCompletedDownload(key);
if (!current()) return;
item = target;
if (downloaded != null) {
final versions = item.mediaVersions;
final byId = downloaded.mediaSourceId == null
? -1
: versions?.indexWhere((version) => version.id == downloaded.mediaSourceId) ?? -1;
mediaIndex = byId >= 0 ? byId : downloaded.mediaIndex;
if (versions == null || mediaIndex < 0 || mediaIndex >= versions.length) mediaIndex = null;
}
} else {
final fetched = _probedPlaybackItems[key] ?? await client!.fetchItem(target.id);
if (!current() || fetched == null) return;
item = fetched.copyWith(
serverId: target.serverId ?? fetched.serverId,
serverName: target.serverName ?? fetched.serverName,
);
_probedPlaybackItems[key] = item;
final selection = await resolveSavedMediaVersionFor(item);
if (!current()) return;
source = await client!.fetchCachedMediaSourceInfo(
item.id,
mediaIndex: selection?.index ?? 0,
mediaSourceId: selection?.sourceId,
preferredVersionSignature: selection?.signature,
);
if (!current() || source == null) return;
mediaIndex = source.mediaIndex ?? selection?.index ?? 0;
}
if (!current()) return;
_playbackSources[key] = (item: item, source: source, mediaIndex: mediaIndex);
// Only the status repaints: do not rebuild the rail or replace its
// focused item with a cached probe snapshot.
_playbackStatusRevision.value++;
} catch (e, stackTrace) {
appLogger.d('Playback track probe failed for ${target.id}', error: e, stackTrace: stackTrace);
return;
}
if (!mounted || fetched == null) return;
final probed = fetched.copyWith(
serverId: target.serverId ?? fetched.serverId,
serverName: target.serverName ?? fetched.serverName,
);
// A summary-only answer is not worth caching: the next focus retries.
if (previewPlaybackTracks(probed) == null) return;
_probedPlaybackItems[target.id] = probed;
if (_tvDetailFocusedEpisode.value?.id == target.id) {
_tvDetailFocusedEpisode.value = probed;
} else {
setStateIfMounted(() {});
} finally {
if (_playbackProbeRequests[key] == request) _playbackProbeRequests.remove(key);
}
}
/// Null when there is nothing to say: no version at all, or a container
/// summary without a resolution.
Widget? _buildPlaybackTracksStatus(
/// The listener stays mounted while a source is resolving, without adding
/// a semantic or focus target when there is nothing to predict.
Widget _buildPlaybackTracksStatus(
BuildContext context,
MediaItem metadata, {
required bool isTv,
required double tvScale,
required double maxWidth,
}) {
return ValueListenableBuilder<int>(
valueListenable: _playbackStatusRevision,
builder: (context, _, _) =>
_playbackTracksStatus(context, metadata, isTv: isTv, tvScale: tvScale, maxWidth: maxWidth) ??
const SizedBox.shrink(),
);
}
Widget? _playbackTracksStatus(
BuildContext context,
MediaItem metadata, {
required bool isTv,
@@ -73,16 +144,30 @@ extension _MediaDetailPlaybackTracksStatus on _MediaDetailScreenState {
}) {
final target = _playbackTargetItem(metadata);
if (target == null) return null;
if (_playbackStatusTarget != target.globalKey) {
_playbackStatusTarget = target.globalKey;
final cached = _playbackSources[target.globalKey]?.source;
if (!widget.isOffline && cached != null && !mediaSourceHasTrackRows(cached)) {
_playbackSources.remove(target.globalKey);
}
}
// Profile-scoped in the app; nullable so a bare detail screen (widget
// tests) still previews with the ladder's non-profile tiers.
final preview = previewPlaybackTracks(
target,
profile: context.read<AccountPreferencesController?>()?.activePreferences,
);
final videoLabels = buildMediaVideoLabels(target);
final probed = _playbackSources[target.globalKey];
final source = probed?.source;
final preview = source == null
? null
: previewPlaybackTracks(
probed!.item,
source,
profile: context.watch<AccountPreferencesController?>()?.activePreferences,
);
final videoLabels = probed?.mediaIndex == null
? const <String>[]
: buildMediaVideoLabels(probed!.item, versionIndex: probed.mediaIndex!, partIndex: source?.partIndex);
if (probed == null) _scheduleTargetProbe(context, target);
if (preview == null && videoLabels.isEmpty) return null;
if (preview == null) _scheduleTargetProbe(context, target);
final audioLabel = preview?.audio?.label;
final subtitleLabel = preview?.subtitle?.label;
+169 -45
View File
@@ -27,6 +27,7 @@ import '../focus/input_mode_tracker.dart';
import '../widgets/cast_member_strip.dart';
import '../widgets/focus_builders.dart';
import '../media/library_query.dart';
import '../media/library_change_event.dart';
import '../media/media_hub.dart';
import '../utils/provider_extensions.dart';
import '../utils/plex_season_display.dart';
@@ -34,6 +35,7 @@ import '../media/media_item.dart';
import '../media/episode_collection.dart';
import '../media/media_item_types.dart';
import '../media/media_kind.dart';
import '../media/media_source_info.dart';
import '../media/media_role.dart';
import '../media/paged_media_list_state.dart';
import '../widgets/media_card.dart';
@@ -86,6 +88,7 @@ import '../mixins/mounted_set_state_mixin.dart';
import '../mixins/server_bound_media_mixin.dart';
import '../utils/watch_state_notifier.dart';
import '../utils/deletion_notifier.dart';
import '../utils/library_content_notifier.dart';
import '../utils/global_key_utils.dart';
import '../utils/tone_mapped_logo_image.dart';
import '../widgets/episode_card.dart';
@@ -315,13 +318,7 @@ PageRoute<bool> mediaDetailRoute({
}
class _MediaDetailScreenState extends State<MediaDetailScreen>
with
WatchStateAware,
DeletionAware,
DeletionMirrorsWatchState,
MountedSetStateMixin,
ServerBoundMediaMixin,
RouteAware {
with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin, RouteAware {
/// Public input alias — used as the live source of truth until the detail
/// fetch returns. Holds backend-neutral [MediaItem] data.
MediaItem get _metadata => _fullMetadata ?? widget.metadata;
@@ -336,6 +333,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
MediaItem? _fullMetadata;
MediaItem? _onDeckEpisode;
bool _isLoadingMetadata = true;
bool _isDeleted = false;
StreamSubscription<LibraryChangeEvent>? _libraryContentSubscription;
bool get _canUseDetail => mounted && !_isDeleted;
List<MediaItem>? _extras;
List<MediaHub> _relatedHubs = [];
List<GlobalKey<HubSectionState>> _relatedHubKeys = [];
@@ -362,10 +363,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final ValueNotifier<MediaItem?> _tvDetailFocusedEpisode = ValueNotifier(null);
bool _tvDetailActionRowHasFocus = false;
// Full items fetched for the action row's track status when a listing gave
// only the container summary; keyed by item id so the line upgrades after
// the fetch.
// Full item snapshots are reusable across preference changes; resolved
// sources are not. A generation retires probes after refresh or selection.
final Map<String, MediaItem> _probedPlaybackItems = {};
final Map<String, ({MediaItem item, MediaSourceInfo? source, int? mediaIndex})> _playbackSources = {};
final Map<String, Object> _playbackProbeRequests = {};
int _playbackProbeGeneration = 0;
final ValueNotifier<int> _playbackStatusRevision = ValueNotifier(0);
String? _playbackStatusTarget;
Listenable? _playbackVersionPreferences;
Timer? _playbackProbeTimer;
// Watchlist action (external catalog sources: Trakt, MAL). External ids
@@ -456,9 +462,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
@override
bool get isServerBoundOffline => widget.isOffline;
// WatchStateAware: watch the show/movie and all season/episode ratingKeys.
// DeletionMirrorsWatchState reuses these three getters for deletion events —
// the same items are on screen either way.
// Watch state follows displayed items; deletion also follows the detail's
// known ancestors, even before any children have loaded.
@override
Set<String>? get watchedIds {
final keys = <String>{_metadata.id};
@@ -489,8 +494,39 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return keys;
}
Set<String> get _terminalDeletionIds => {
widget.metadata.id,
...widget.metadata.parentChain.where((id) => id.isNotEmpty),
_metadata.id,
..._metadata.parentChain.where((id) => id.isNotEmpty),
};
@override
String? get deletionServerId => serverBoundServerId;
@override
Set<String> get deletionIds => {...?watchedIds, ..._terminalDeletionIds};
@override
Set<String>? get deletionGlobalKeys {
final serverId = serverBoundServerId;
if (serverId == null) return null;
return {
...?watchedGlobalKeys,
for (final id in _terminalDeletionIds) toServerBoundGlobalKey(id, serverId: ServerId(serverId)),
};
}
void _onLibraryContentChanged(LibraryChangeEvent event) {
if (!_canUseDetail || widget.isOffline || event.serverId != serverBoundServerId) return;
// Exact ids are authoritative even when the producer omits its per-item
// fanout. Advisory flags alone do not prove this detail was removed.
if (_terminalDeletionIds.any(event.removedItemIds.contains)) _markDetailDeleted();
}
@override
void onWatchStateChanged(WatchStateEvent event) {
if (!_canUseDetail) return;
_watchStateChanged = true;
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) return;
@@ -582,6 +618,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
Future<void> _refreshItemInPlace(MediaItem source) async {
if (!_canUseDetail) return;
final serverId = source.serverId;
if (serverId == null) return;
final client = context.tryGetMediaClientForServer(ServerId(serverId));
@@ -589,7 +626,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
try {
final refreshed = await client.fetchItem(source.id);
if (refreshed == null || !mounted) return;
if (refreshed == null || !_canUseDetail) return;
setStateIfMounted(() {
_patchItemEverywhere(source.globalKey, refreshed);
});
@@ -600,10 +637,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
@override
void onDeletionEvent(DeletionEvent event) {
if (!_canUseDetail) return;
// Download-only deletions should only remove items when viewing offline content
if (event.isDownloadOnly && !widget.isOffline) return;
if (!event.isDownloadOnly && widget.isOffline) return;
if (_terminalDeletionIds.contains(event.itemId)) {
_markDetailDeleted();
return;
}
// Drop the episode from any visible/cached list. This fires whether we're
// showing a flattened episode list or a season-tabs view of a show.
final epIndex = _episodes.indexWhere((e) => e.id == event.itemId);
@@ -621,7 +664,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (epIndex != -1 && _showEpisodesDirectly) {
if (_episodes.isEmpty && (_metadata.isSeason || _metadata.isShow) && mounted) {
Navigator.of(context).pop();
_markDetailDeleted();
}
return;
}
@@ -635,7 +678,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// If the show has no more seasons, navigate back up to the library
if (_seasons.isEmpty && mounted) {
Navigator.of(context).pop();
_markDetailDeleted();
return;
}
_refreshWatchState();
@@ -658,7 +701,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Otherwise we have no more seasons, so navigate up
if (_seasons.isEmpty && mounted) {
Navigator.of(context).pop();
_markDetailDeleted();
return;
}
} else {
@@ -673,8 +716,44 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
}
void _markDetailDeleted() {
if (!_canUseDetail) return;
setState(() {
_isDeleted = true;
// Retire in-flight pages and probes before navigation. A first route
// remains mounted, so mounted checks alone cannot fence late results.
_episodesLoadGeneration++;
_invalidatePlaybackProbes(refreshItems: true);
});
if (!(_seasonsCompleter?.isCompleted ?? true)) _seasonsCompleter?.complete();
_closeDeletedDetail();
}
/// The detail's content is gone; leave via the detail's OWN route. A blind
/// `Navigator.pop` takes whatever is topmost on the profile navigator — the
/// download progress dialog, a player, a hostless sheet fallback, another
/// detail pushed on top — and leaves this empty screen behind.
///
/// The terminal transition handles duplicate delivery; `isActive` also
/// avoids removing a route already leaving for another reason. `isFirst`
/// retains the unavailable state instead of stranding a blank navigator.
void _closeDeletedDetail() {
final route = _route ?? ModalRoute.of(context);
if (route == null || route.isFirst || !route.isActive) return;
final navigator = Navigator.of(context);
if (route.isCurrent) {
navigator.pop();
} else {
// Completes the push future with null, same as the pop above.
navigator.removeRoute(route);
}
}
/// Lightweight refresh for watch state changes - no loader, preserves scroll
Future<void> _refreshWatchState() async {
if (!_canUseDetail) return;
_invalidatePlaybackProbes(refreshItems: true);
if (widget.isOffline) return;
// Backend-neutral. Plex bundles metadata + on-deck in one round-trip
// (`?includeOnDeck=1`); Jellyfin's [fetchItemWithOnDeck] returns
// onDeckEpisode=null and on-deck repopulates from cached lists on
@@ -687,6 +766,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
try {
final result = await mediaClient.fetchItemWithOnDeck(_metadata.id);
if (!_canUseDetail) return;
final metadata = result.item;
final onDeckEpisode = result.onDeckEpisode;
if (metadata != null) {
@@ -701,6 +781,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
refreshedMetadata,
);
setStateIfMounted(() {
_invalidatePlaybackProbes(refreshItems: true);
_fullMetadata = refreshedMetadata;
_onDeckEpisode = refreshedOnDeck;
});
@@ -728,7 +809,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_overviewFocusNode = FocusNode(debugLabel: 'overview');
_tvDetailInfoFocusNode = FocusNode(debugLabel: 'tv_detail_info');
_infoRowsFocusNode = FocusNode(debugLabel: 'info_rows');
_libraryContentSubscription = LibraryContentNotifier().stream.listen(_onLibraryContentChanged);
_loadFullMetadata();
unawaited(_listenForPlaybackVersionChanges());
_initWatchlistState();
}
@@ -757,7 +840,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_metadata,
client: _getMediaClientForMetadata(context),
);
if (!mounted || candidates.isEmpty) return;
if (!_canUseDetail || candidates.isEmpty) return;
setState(() => _watchlistCandidates = candidates);
} catch (e) {
appLogger.d('Watchlist external-id resolution failed', error: e);
@@ -791,6 +874,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
@override
void didPopNext() {
_invalidatePlaybackProbes(refreshItems: true);
setStateIfMounted(() {});
_suppressBackAfterPop = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -857,14 +942,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_tvDetailRevealScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_canUseDetail) return;
setState(() {
_tvDetailStableRailHeight = _tvDetailPendingRailHeight ?? railHeight;
_tvDetailRevealScheduled = false;
_tvDetailRevealed = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_canUseDetail) return;
if (focusPrimaryAction) {
_playButtonFocusNode.requestFocus();
} else {
@@ -899,6 +984,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
@override
void dispose() {
_libraryContentSubscription?.cancel();
for (final source in _watchlistListenedSources) {
source.watchlistChanges.removeListener(_onWatchlistSourceChanged);
}
@@ -907,6 +993,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_scrollOffset.dispose();
_tvDetailFocusedEpisode.dispose();
_playbackProbeTimer?.cancel();
_playbackVersionPreferences?.removeListener(_onPlaybackVersionChanged);
_playbackStatusRevision.dispose();
_extrasScrollController.dispose();
_extrasFocusNode.removeListener(_handleExtrasFocusChange);
_extrasFocusNode.dispose();
@@ -1390,7 +1478,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
],
);
if (selected == null || !context.mounted) return;
if (selected == null || !_canUseDetail || !context.mounted) return;
switch (selected) {
case _SyncRuleAction.edit:
@@ -1433,6 +1521,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
Future<void> _loadFullMetadata() async {
if (!_canUseDetail) return;
_invalidatePlaybackProbes(refreshItems: true);
setState(() {
_isLoadingMetadata = true;
_hasLoadedExtras = false;
@@ -1445,7 +1535,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final cachedMetadata = serverId == null
? null
: await context.read<DownloadProvider>().lookupOfflineMetadata(serverId, _metadata.id);
if (!mounted) return;
if (!_canUseDetail) return;
setState(() {
_fullMetadata = cachedMetadata ?? _metadata;
_isLoadingMetadata = false;
@@ -1531,12 +1621,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final result = await client.fetchItemWithOnDeck(
_metadata.id,
onItemReady: (item) {
if (mounted) publish(item);
if (_canUseDetail) publish(item);
},
);
final metadata = result.item;
if (!mounted) return;
if (!_canUseDetail) return;
final base = publish(metadata ?? _metadata, onDeckEpisode: result.onDeckEpisode, onDeckSettled: true);
if (base.isShow) {
@@ -1553,7 +1643,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
unawaited(_loadRelatedHubs());
} catch (e) {
// Fallback to passed metadata on error
if (!mounted) return;
if (!_canUseDetail) return;
setState(() {
_fullMetadata = _metadata;
_isLoadingMetadata = false;
@@ -1572,6 +1662,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
Future<void> _loadSeasons() async {
if (!_canUseDetail) return;
_seasonsCompleter = Completer<void>();
setStateIfMounted(() {
_isLoadingSeasons = true;
@@ -1605,6 +1696,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
: Future.value(<String, dynamic>{});
final results = await Future.wait([seasonsFuture, prefsFuture]);
if (!_canUseDetail) return;
final seasons = results.first as List<MediaItem>;
final prefs = results[1] as Map<String, dynamic>;
@@ -1661,6 +1753,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
} catch (e, st) {
appLogger.w('Seasons load failed', error: e, stackTrace: st);
if (!_canUseDetail) return;
setStateIfMounted(() {
_isLoadingSeasons = false;
_seasonsLoadFailed = true;
@@ -1675,6 +1768,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// Load seasons from downloaded episodes (offline mode)
void _loadSeasonsFromDownloads() {
if (!_canUseDetail) return;
_seasonsCompleter = Completer<void>();
setState(() {
_isLoadingSeasons = true;
@@ -1759,6 +1853,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// Load episodes from downloaded content for a season
void _loadEpisodesFromDownloads() {
if (!_canUseDetail) return;
final downloadProvider = context.read<DownloadProvider>();
final seasonEpisodes = _downloadedEpisodesForSeason(downloadProvider, _metadata.parentId ?? '', _metadata.index);
@@ -1796,6 +1891,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// lazily via [_loadMoreSeasonEpisodes] as the user scrolls/navigates toward
/// the end, so a 1000+ episode season never blocks on one giant request.
Future<void> _fetchSeasonEpisodes(int seasonIndex) async {
if (!_canUseDetail) return;
if (seasonIndex < 0 || seasonIndex >= _seasons.length) return;
final season = _seasons[seasonIndex];
final seasonId = season.id;
@@ -1896,6 +1992,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
Future<void> _prefetchSeasonEpisodeFirstPage(int seasonIndex) async {
if (!_canUseDetail) return;
if (seasonIndex < 0 || seasonIndex >= _seasons.length) return;
final season = _seasons[seasonIndex];
final seasonId = season.id;
@@ -1912,7 +2009,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
start: 0,
size: _episodesPageSize,
);
if (!mounted || _showEpisodesDirectly || seasonIndex >= _seasons.length || _seasons[seasonIndex].id != seasonId) {
if (!_canUseDetail ||
_showEpisodesDirectly ||
seasonIndex >= _seasons.length ||
_seasons[seasonIndex].id != seasonId) {
return;
}
setStateIfMounted(() {
@@ -1926,7 +2026,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (_isSelectedSeason(seasonIndex, seasonId)) unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex));
} catch (e, st) {
appLogger.d('TV adjacent season episode prefetch failed', error: e, stackTrace: st);
if (mounted && _isSelectedSeason(seasonIndex, seasonId)) {
if (_canUseDetail && _isSelectedSeason(seasonIndex, seasonId)) {
setStateIfMounted(() {
_seasonEpisodePager.failFirstPage(seasonId);
});
@@ -1939,6 +2039,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// Load the next page of the selected season's episodes and append it.
/// No-op when nothing more remains, offline, or a page is already in flight.
Future<void> _loadMoreSeasonEpisodes() async {
if (!_canUseDetail) return;
final seasonIndex = _selectedSeasonIndex;
if (widget.isOffline || seasonIndex < 0 || seasonIndex >= _seasons.length) return;
final season = _seasons[seasonIndex];
@@ -2026,7 +2127,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// Load extras (trailers, featurettes, behind-the-scenes, etc.).
Future<void> _loadExtras() async {
if (!_canUseDetail) return;
void markLoaded() {
if (!_canUseDetail) return;
setStateIfMounted(() {
_hasLoadedExtras = true;
});
@@ -2052,6 +2155,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
final extras = await client.fetchExtras(_metadata.id);
if (!_canUseDetail) return;
// Preserve serverId for each extra (needed for multi-server setups).
final extrasWithServerId = extras
@@ -2077,7 +2181,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// Backend-neutral — both Plex and Jellyfin implement
/// [MediaServerClient.fetchRelatedHubs].
Future<void> _loadRelatedHubs() async {
if (!_canUseDetail) return;
void markLoaded() {
if (!_canUseDetail) return;
setStateIfMounted(() {
_hasLoadedRelatedHubs = true;
});
@@ -2102,6 +2208,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
try {
final relatedHubs = await client.fetchRelatedHubs(_metadata.id);
if (!_canUseDetail) return;
setStateIfMounted(() {
_relatedHubs = relatedHubs;
@@ -2207,7 +2314,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
void _applyInitialMobileFocus(FocusNode node, GlobalKey sectionKey) {
_initialDetailFocusApplied = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_canUseDetail) return;
node.requestFocus();
_scrollSectionIntoView(sectionKey);
});
@@ -2231,7 +2338,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_initialEpisodePagingInFlight = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _episodesContainInitialTarget) {
if (!_canUseDetail || _episodesContainInitialTarget) {
_initialEpisodePagingInFlight = false;
return;
}
@@ -2518,7 +2625,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
isOwnerActive: () => mounted,
onShortPress: () {
if (_focusedExtraIndex < extras.length) {
navigateToVideoPlayer(context, metadata: extras[_focusedExtraIndex]);
navigateToVideoPlayer(context, metadata: extras[_focusedExtraIndex], isLaunchCurrent: () => _canUseDetail);
}
},
onLongPress: () {
@@ -2733,6 +2840,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
metadata: episode,
isOffline: widget.isOffline,
onRefresh: () => unawaited(_refreshItemInPlace(episode)),
isLaunchCurrent: () => _canUseDetail,
);
},
onRefresh: widget.isOffline ? null : _refreshItemInPlace,
@@ -2825,6 +2933,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
Future<void> _fetchAllEpisodes() async {
if (!_canUseDetail) return;
final generation = ++_episodesLoadGeneration;
if (_seasons.isEmpty) {
setStateIfMounted(() {
@@ -2867,6 +2976,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
});
} catch (e, st) {
appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st);
if (!_canUseDetail || generation != _episodesLoadGeneration) return;
setStateIfMounted(() {
_allEpisodes = _allEpisodes.failInitialLoad();
_hasLoadedEpisodes = true;
@@ -2974,6 +3084,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// Load the next page of the flatten/all-episodes list (single-season show or
/// season detail) on demand and append it.
Future<void> _loadMoreAllEpisodes() async {
if (!_canUseDetail) return;
if (widget.isOffline || !_allEpisodes.hasMore || _allEpisodes.isLoadingMore) return;
final serverId = _metadata.serverId;
final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId));
@@ -3050,8 +3161,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// Load the next unwatched episode for offline mode (offline OnDeck)
Future<void> _loadOfflineOnDeckEpisode() async {
if (!_canUseDetail) return;
final offlineWatchProvider = context.read<OfflineWatchProvider>();
final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(_metadata.id);
if (!_canUseDetail) return;
setStateIfMounted(() {
_onDeckEpisode = nextEpisode;
@@ -3071,7 +3184,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
void _ensureFallbackOnDeckEpisode() {
// Reached via unawaited fetch continuations — the screen may be gone by
// now, and _freshAll reads providers through State.context.
if (!mounted) return;
if (!_canUseDetail) return;
if (_onDeckEpisode != null) return;
final next = firstUnwatchedEpisode(_freshAll(_episodes));
if (next == null) return;
@@ -3081,6 +3194,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
Future<void> _playFirstEpisode() async {
if (!_canUseDetail) return;
// Loading seasons and resolving the first episode cost network round
// trips; show the shared scoped loading dialog so Play gives immediate
// feedback. The offline branch reads local state only and needs none.
@@ -3103,7 +3217,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
await _seasonsCompleter!.future.timeout(const Duration(seconds: 10), onTimeout: () {});
}
if (!mounted) return;
if (!mounted || !_canUseDetail) return;
if (_seasons.isEmpty) {
if (mounted) {
@@ -3116,7 +3230,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// Get the first episode of the first season.
MediaItem? firstEpisode;
if (!mounted) return;
if (widget.isOffline) {
// In offline mode, get episodes from downloads (filtered to this season).
final downloadProvider = context.read<DownloadProvider>();
@@ -3132,6 +3245,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
);
}
if (!_canUseDetail) return;
if (firstEpisode == null) {
if (mounted) {
showErrorSnackBar(context, t.messages.noEpisodesFound);
@@ -3151,17 +3266,18 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
libraryId: firstEpisode.libraryId ?? _metadata.libraryId,
libraryTitle: firstEpisode.libraryTitle ?? _metadata.libraryTitle,
);
if (mounted) {
if (mounted && _canUseDetail) {
appLogger.d('Playing first episode: ${episodeWithServerId.title}');
await navigateToVideoPlayerWithRefresh(
context,
metadata: episodeWithServerId,
isOffline: widget.isOffline,
onRefresh: _refreshWatchState,
isLaunchCurrent: () => _canUseDetail,
);
}
} catch (e) {
if (mounted) {
if (mounted && _canUseDetail) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
} finally {
@@ -3173,6 +3289,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// so Plex uses its server-side `/playQueues` and Jellyfin builds a local
/// shuffled queue from `fetchClientSideEpisodeQueue`.
Future<void> _handleShufflePlayWithQueue(BuildContext context, MediaItem metadata) async {
if (!_canUseDetail) return;
if (widget.isOffline) {
if (context.mounted) {
showErrorSnackBar(context, t.mediaMenu.shuffleNotAvailableOffline);
@@ -3189,6 +3306,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
@override
Widget build(BuildContext context) {
if (_isDeleted) {
return Scaffold(
body: SafeArea(
child: EmptyStateWidget(message: t.messages.mediaUnavailable, icon: Symbols.block_rounded),
),
);
}
// Session-fresh hero: server snapshot resolved against the watch-state
// store (onWatchStateChanged rebuilds on relevant events).
final metadata = _fresh(_fullMetadata ?? _metadata);
@@ -3561,15 +3685,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
valueListenable: _tvDetailFocusedEpisode,
builder: (context, _, _) => Align(
alignment: .bottomRight,
child:
_buildPlaybackTracksStatus(
context,
metadata,
isTv: true,
tvScale: detailScale,
maxWidth: size.width * 0.40 - spotlightLeft * 2,
) ??
const SizedBox.shrink(),
child: _buildPlaybackTracksStatus(
context,
metadata,
isTv: true,
tvScale: detailScale,
maxWidth: size.width * 0.40 - spotlightLeft * 2,
),
),
),
),
@@ -4222,6 +4344,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
metadata: item,
isOffline: widget.isOffline,
onRefresh: () => unawaited(_refreshItemInPlace(item)),
isLaunchCurrent: () => _canUseDetail,
);
return true;
}
@@ -4742,7 +4865,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: () => navigateToVideoPlayer(context, metadata: extra),
onTap: () =>
navigateToVideoPlayer(context, metadata: extra, isLaunchCurrent: () => _canUseDetail),
delegateFocusBorder: true,
child: MediaCard(
key: cardKey,
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import '../../i18n/strings.g.dart';
import '../../media/account_preferences.dart';
import '../../media/account_preferences_target.dart';
import '../../media/account_ref.dart';
import '../../media/media_server_user_profile.dart';
import '../../services/account_preferences_repository.dart';
import '../../utils/app_logger.dart';
@@ -56,7 +57,7 @@ class AccountPreferencesDetailScreen extends StatelessWidget {
Widget build(BuildContext context) {
return SettingsPage.slivers(
title: Text(target.label),
slivers: [AccountPreferencesBody(target: target)],
slivers: [AccountPreferencesBody(key: ValueKey(target.ref), target: target)],
);
}
}
@@ -75,6 +76,8 @@ class AccountPreferencesBody extends StatefulWidget {
class _AccountPreferencesBodyState extends State<AccountPreferencesBody> {
late final AccountPreferencesRepository _repository;
late final StreamSubscription<AccountRef> _repositoryChanges;
int _loadGeneration = 0;
/// Loaded values; null while the first (or a retried) read is outstanding.
AccountPreferences? _preferences;
@@ -87,20 +90,62 @@ class _AccountPreferencesBodyState extends State<AccountPreferencesBody> {
void initState() {
super.initState();
_repository = context.read<AccountPreferencesRepository>();
_repositoryChanges = _repository.changes.listen(_onRepositoryChanged);
unawaited(_load());
}
@override
void didUpdateWidget(covariant AccountPreferencesBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.target.ref == widget.target.ref) return;
_preferences = null;
_error = null;
unawaited(_load());
}
@override
void dispose() {
_loadGeneration++;
unawaited(_repositoryChanges.cancel());
super.dispose();
}
void _onRepositoryChanged(AccountRef ref) {
if (!mounted || ref != widget.target.ref) return;
final preferences = _repository.cached(ref);
if (preferences == null) {
setState(() {
_preferences = null;
_error = null;
});
unawaited(_load());
} else if (!identical(preferences, _preferences) || _error != null) {
_loadGeneration++;
setState(() {
_preferences = preferences;
_error = null;
});
}
}
Future<void> _load({bool forceRefresh = false}) async {
final ref = widget.target.ref;
final generation = ++_loadGeneration;
try {
final preferences = await _repository.load(widget.target.ref, forceRefresh: forceRefresh);
if (!mounted) return;
final preferences = await _repository.load(ref, forceRefresh: forceRefresh);
if (!mounted ||
widget.target.ref != ref ||
generation != _loadGeneration ||
!identical(_repository.cached(ref), preferences)) {
return;
}
setState(() {
_preferences = preferences;
_error = null;
});
} on Exception catch (error, stackTrace) {
appLogger.e('Account preferences load failed', error: error, stackTrace: stackTrace);
if (!mounted) return;
if (!mounted || widget.target.ref != ref || generation != _loadGeneration) return;
setState(() => _error = error);
}
}
@@ -116,8 +161,15 @@ class _AccountPreferencesBodyState extends State<AccountPreferencesBody> {
/// Writes one key and adopts the account's state afterwards. Failures
/// propagate so the row that started the write reverts and reports them.
Future<void> _write(AccountPreferenceKey key, Object? value) async {
final updated = await _repository.update(widget.target.ref, AccountPreferencesPatch.of(key, value));
if (!mounted) return;
final ref = widget.target.ref;
final generation = _loadGeneration;
final updated = await _repository.update(ref, AccountPreferencesPatch.of(key, value));
if (!mounted ||
widget.target.ref != ref ||
generation != _loadGeneration ||
!identical(_repository.cached(ref), updated)) {
return;
}
setState(() => _preferences = updated);
}
@@ -46,17 +46,10 @@ class AccountPreferencesScreen extends StatelessWidget {
if (targets.length == 1) {
return SettingsPage.slivers(
title: title,
slivers: [AccountPreferencesBody(target: targets.first)],
slivers: [AccountPreferencesBody(key: ValueKey(targets.first.ref), target: targets.first)],
);
}
// The account the user is browsing with is the one they came here for;
// that is what AccountPreferenceTarget.isActiveProfileAccount is for. Both
// halves keep the caller's order.
final ordered = [
...targets.where((target) => target.isActiveProfileAccount),
...targets.where((target) => !target.isActiveProfileAccount),
];
final theme = Theme.of(context);
return SettingsPage(
@@ -71,7 +64,7 @@ class AccountPreferencesScreen extends StatelessWidget {
),
SettingsGroup(
children: [
for (final target in ordered)
for (final target in targets)
FocusableListTile(
key: ValueKey(target.ref.key),
leading: BackendBadge(backend: target.backend, size: 24),
+145 -61
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show SelectedContent;
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
@@ -31,6 +32,70 @@ import '../../widgets/system_bottom_inset.dart';
const previousStartupFailureKey = Key('logs-previous-startup-failure');
/// `SelectionArea` claims plain arrow keys as no-op caret moves, which starves
/// `DirectionalFocusIntent` and pins D-pad focus on the app-bar back button.
/// Only the collapsing (unshifted) variants are disabled; Shift+Arrow selection
/// still reaches the region's own handler via [callingAction].
class _CollapsingSelectionMoveAction<T extends DirectionalCaretMovementIntent> extends Action<T> {
@override
bool isEnabled(T intent) => !intent.collapseSelection && (callingAction?.isEnabled(intent) ?? false);
@override
bool consumesKey(T intent) => callingAction?.consumesKey(intent) ?? false;
@override
Object? invoke(T intent) => callingAction?.invoke(intent);
}
/// `SelectionArea` concatenates adjacent selectables with no separator; each
/// log row (and the device header) is its own paragraph, so a copied
/// drag-selection ran records together. The scrollable owns the delegate that
/// does the concatenation (and the lazy-row/autoscroll bookkeeping), so the
/// boundary is added per record: every selected record ends its text with
/// `\n`, and [_RecordSelectionDelegate] drops the one dangling after the last
/// record so a partial drag copies exactly what was highlighted.
class _RecordBoundaryDelegate extends StaticSelectionContainerDelegate {
@override
SelectedContent? getSelectedContent() {
final text = super.getSelectedContent()?.plainText;
// A drag ending at offset 0 of a row selects nothing in it: no boundary.
if (text == null || text.isEmpty) return null;
return SelectedContent(plainText: '$text\n');
}
}
class _RecordSelectionDelegate extends StaticSelectionContainerDelegate {
@override
SelectedContent? getSelectedContent() {
final text = super.getSelectedContent()?.plainText;
if (text == null) return null;
return SelectedContent(plainText: text.endsWith('\n') ? text.substring(0, text.length - 1) : text);
}
}
/// One selectable record: the device header or a single log entry.
class _LogRecord extends StatefulWidget {
const _LogRecord({required this.child});
final Widget child;
@override
State<_LogRecord> createState() => _LogRecordState();
}
class _LogRecordState extends State<_LogRecord> {
final _RecordBoundaryDelegate _boundary = _RecordBoundaryDelegate();
@override
void dispose() {
_boundary.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => SelectionContainer(delegate: _boundary, child: widget.child);
}
class LogsScreen extends StatefulWidget {
const LogsScreen({super.key, this.httpClient, this.deviceInfoPlugin});
@@ -48,6 +113,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
// skipTraversal: selection is pointer-driven; the region must not become a
// D-pad/Tab stop between the action bar and the scrollable body.
final FocusNode _selectionFocusNode = FocusNode(skipTraversal: true, debugLabel: 'logs-selection');
final _RecordSelectionDelegate _recordSelection = _RecordSelectionDelegate();
MediaServerHttpClient get _httpClient => widget.httpClient ?? httpClient;
@@ -113,6 +179,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
void dispose() {
_scrollController.dispose();
_selectionFocusNode.dispose();
_recordSelection.dispose();
super.dispose();
}
@@ -386,73 +453,90 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
child: IosStatusBarTapScrollToTop(
controller: _scrollController,
child: Scaffold(
body: SelectionArea(
focusNode: _selectionFocusNode,
child: CustomScrollView(
primary: true,
slivers: [
CustomAppBar(
// Chrome is not log content; keep it out of drag-selection.
title: SelectionContainer.disabled(child: Text(t.screens.logs)),
pinned: true,
actions: [
FocusableActionBar(
body: Actions(
actions: <Type, Action<Intent>>{
ExtendSelectionByCharacterIntent: _CollapsingSelectionMoveAction<ExtendSelectionByCharacterIntent>(),
ExtendSelectionVerticallyToAdjacentLineIntent:
_CollapsingSelectionMoveAction<ExtendSelectionVerticallyToAdjacentLineIntent>(),
},
child: SelectionArea(
focusNode: _selectionFocusNode,
child: SelectionContainer(
delegate: _recordSelection,
child: CustomScrollView(
primary: true,
slivers: [
CustomAppBar(
// Chrome is not log content; keep it out of drag-selection.
title: SelectionContainer.disabled(child: Text(t.screens.logs)),
pinned: true,
actions: [
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: t.common.refresh,
onPressed: _loadLogs,
),
FocusableAction(
icon: Symbols.upload_rounded,
tooltip: t.logs.uploadLogs,
onPressed: _hasDiagnostics ? _uploadLogs : null,
),
FocusableAction(
icon: Symbols.content_copy_rounded,
tooltip: t.logs.copyLogs,
onPressed: _hasDiagnostics ? _copyAllLogs : null,
),
FocusableAction(
icon: Symbols.delete_outline_rounded,
tooltip: t.logs.clearLogs,
onPressed: _hasDiagnostics ? _clearLogs : null,
FocusableActionBar(
actions: [
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: t.common.refresh,
onPressed: _loadLogs,
),
FocusableAction(
icon: Symbols.upload_rounded,
tooltip: t.logs.uploadLogs,
onPressed: _hasDiagnostics ? _uploadLogs : null,
),
FocusableAction(
icon: Symbols.content_copy_rounded,
tooltip: t.logs.copyLogs,
onPressed: _hasDiagnostics ? _copyAllLogs : null,
),
FocusableAction(
icon: Symbols.delete_outline_rounded,
tooltip: t.logs.clearLogs,
onPressed: _hasDiagnostics ? _clearLogs : null,
),
],
),
],
),
// A launch that failed the startup gate leaves nothing in the
// in-memory buffer — that process is gone. Show its record
// here, where the user can actually act on it (#1732).
?_buildPreviousFailureBanner(theme),
if (_logs.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
else ...[
if (_deviceInfo.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
sliver: SliverToBoxAdapter(
child: _LogRecord(
child: Text.rich(
TextSpan(style: _logTextStyle(theme), children: _buildDeviceInfoSpans()),
),
),
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(12, _deviceInfo.isEmpty ? 12 : 0, 12, 12),
// One widget per entry so only the visible slice is laid
// out. The buffer holds up to 5 MiB of text; as a single
// paragraph that was a multi-second frame and hundreds of
// MB of glyph data — an OOM kill on phones and TVs.
sliver: SliverList.builder(
itemCount: _logs.length,
itemBuilder: (context, index) => _LogRecord(
child: Text.rich(
TextSpan(style: _logTextStyle(theme), children: _buildEntrySpans(_logs[index])),
),
),
),
),
// Only the log body needs it: the empty state already fills
// the viewport, so a trailing inset would just add slack.
const SliverSystemBottomInset(),
],
],
),
// A launch that failed the startup gate leaves nothing in the
// in-memory buffer — that process is gone. Show its record
// here, where the user can actually act on it (#1732).
?_buildPreviousFailureBanner(theme),
if (_logs.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
else ...[
if (_deviceInfo.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
sliver: SliverToBoxAdapter(
child: Text.rich(TextSpan(style: _logTextStyle(theme), children: _buildDeviceInfoSpans())),
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(12, _deviceInfo.isEmpty ? 12 : 0, 12, 12),
// One widget per entry so only the visible slice is laid
// out. The buffer holds up to 5 MiB of text; as a single
// paragraph that was a multi-second frame and hundreds of
// MB of glyph data — an OOM kill on phones and TVs.
sliver: SliverList.builder(
itemCount: _logs.length,
itemBuilder: (context, index) =>
Text.rich(TextSpan(style: _logTextStyle(theme), children: _buildEntrySpans(_logs[index]))),
),
),
// Only the log body needs it: the empty state already fills
// the viewport, so a trailing inset would just add slack.
const SliverSystemBottomInset(),
],
],
),
),
),
),
@@ -16,6 +16,15 @@ class _LiveClockOpen {
bool canceled = false;
}
/// What the player has reported so far about one MPV source that no clock
/// open has claimed yet. The `loadfile` reply that names the source and the
/// source's own events travel on independent channels, so readiness or
/// failure can land before the open learns its id.
class _UnclaimedSourceEvents {
Duration? readyPosition;
bool failed = false;
}
/// Mutable runtime state for one live TV playback: the current
/// [LiveTvPlaybackSession] protocol handle, the timeline heartbeat
/// machinery, the capture buffer used for time-shifting, and the
@@ -72,10 +81,15 @@ class LiveTvSessionState {
int? _latestClockGeneration;
int? activeClockSourceId;
double? pendingStreamEpoch;
final List<_LiveClockOpen> _unboundClockOpens = [];
final Map<int, _LiveClockOpen> _clockOpensBySource = {};
final Map<int, _LiveClockOpen> _clockOpensByGeneration = {};
/// Recent source events no open has claimed, keyed by source id in arrival
/// order and bounded so opens that never register (live-edge re-opens,
/// VOD on the same player) cannot grow it.
final Map<int, _UnclaimedSourceEvents> _unclaimedSources = {};
static const int _maxUnclaimedSources = 8;
/// Fallback level for live TV stream errors (mirrors Plex web client
/// behavior). 0 = directStream+directStreamAudio, 1 = no directStream,
/// 2 = no DS + no DS audio.
@@ -91,45 +105,62 @@ class LiveTvSessionState {
/// The player route is closed instead of attempting to reuse that session.
bool exitOnResume = false;
/// Register an offset-based MPV open before dispatching `loadfile`.
///
/// Opens and MPV START_FILE events are ordered. Canceled entries stay in the
/// unbound queue until their START_FILE arrives so a late predecessor cannot
/// steal the next open's source identity.
/// Register an offset-based MPV open before dispatching `loadfile`. The
/// returned generation is the handle the caller binds to the source id the
/// load reports ([bindClockOpen]); until then the open is unbound and no
/// source event can reach it. Every earlier open is superseded.
int beginClockOpen(int targetEpoch) {
final previousOpens = <_LiveClockOpen>{
..._clockOpensByGeneration.values,
..._unboundClockOpens,
..._clockOpensBySource.values,
};
final previousOpens = <_LiveClockOpen>{..._clockOpensByGeneration.values, ..._clockOpensBySource.values};
for (final open in previousOpens) {
open.canceled = true;
if (!open.result.isCompleted) open.result.complete(false);
}
_clockOpensBySource.removeWhere((_, open) => open.canceled);
_clockOpensByGeneration.clear();
_clockOpensBySource.clear();
final open = _LiveClockOpen(generation: ++_nextClockGeneration, targetEpoch: targetEpoch);
_clockOpensByGeneration[open.generation] = open;
_unboundClockOpens.add(open);
_latestClockGeneration = open.generation;
pendingStreamEpoch = targetEpoch.toDouble();
return open.generation;
}
/// Bind the oldest dispatched live open to the source MPV started next.
bool bindClockSource(PlayerSourceStarted source) {
if (_unboundClockOpens.isEmpty) return false;
final open = _unboundClockOpens.removeAt(0);
open.sourceId = source.sourceId;
if (open.canceled) return false;
_clockOpensBySource[source.sourceId] = open;
/// Bind [generation] to the MPV source its `loadfile` reply named. Source
/// events that already arrived for [sourceId] apply immediately, so a
/// readiness or failure report that beat the reply is not lost. Returns
/// whether the open is still live and now keyed by the source.
bool bindClockOpen(int generation, int sourceId) {
final open = _clockOpensByGeneration[generation];
if (open == null) return false;
open.sourceId = sourceId;
if (open.canceled) {
_unclaimedSources.remove(sourceId);
return false;
}
_clockOpensBySource[sourceId] = open;
final observed = _unclaimedSources.remove(sourceId);
if (observed == null) return true;
if (observed.failed) {
_failClockOpen(open);
return false;
}
final readyPosition = observed.readyPosition;
if (readyPosition != null) {
calibrateClockSource(PlayerSourceReady(sourceId: sourceId, position: readyPosition));
}
return true;
}
/// Calibrate epoch time against the first decoded position of [source].
/// Readiness of a source no open has claimed yet is kept for a later
/// [bindClockOpen].
bool calibrateClockSource(PlayerSourceReady source) {
final open = _clockOpensBySource.remove(source.sourceId);
if (open == null || open.canceled || open.generation != _latestClockGeneration) return false;
if (open == null) {
_unclaimedSource(source.sourceId).readyPosition = source.position;
return false;
}
if (open.canceled || open.generation != _latestClockGeneration) return false;
streamStartEpoch = open.targetEpoch - source.position.inMilliseconds / 1000.0;
activeClockSourceId = source.sourceId;
@@ -141,7 +172,22 @@ class LiveTvSessionState {
void failClockSource(PlayerSourceFailed source) {
final open = _clockOpensBySource.remove(source.sourceId);
if (open != null) _failClockOpen(open);
if (open == null) {
_unclaimedSource(source.sourceId).failed = true;
return;
}
_failClockOpen(open);
}
_UnclaimedSourceEvents _unclaimedSource(int sourceId) {
final existing = _unclaimedSources.remove(sourceId);
if (existing != null) {
return _unclaimedSources[sourceId] = existing;
}
if (_unclaimedSources.length >= _maxUnclaimedSources) {
_unclaimedSources.remove(_unclaimedSources.keys.first);
}
return _unclaimedSources[sourceId] = _UnclaimedSourceEvents();
}
void failClockOpen(int generation) {
@@ -160,7 +206,6 @@ class LiveTvSessionState {
void _failClockOpen(_LiveClockOpen open) {
open.canceled = true;
_unboundClockOpens.remove(open);
final sourceId = open.sourceId;
if (sourceId != null && identical(_clockOpensBySource[sourceId], open)) {
_clockOpensBySource.remove(sourceId);
@@ -175,14 +220,13 @@ class LiveTvSessionState {
if (!open.result.isCompleted) open.result.complete(false);
}
/// Resolves true once the open's source is calibrated, false when it is
/// superseded, failed or timed out. A timed-out open stays registered so a
/// late `loadfile` reply or readiness event can still bind and calibrate it.
Future<bool> clockOpenResult(int generation) {
final open = _clockOpensByGeneration[generation];
if (open == null) return Future<bool>.value(false);
return open.result.future.whenComplete(() {
if (identical(_clockOpensByGeneration[generation], open)) {
_clockOpensByGeneration.remove(generation);
}
});
return open.result.future;
}
void cancelClockOpens() {
@@ -190,8 +234,8 @@ class LiveTvSessionState {
for (final generation in generations) {
failClockOpen(generation);
}
_unboundClockOpens.clear();
_clockOpensBySource.clear();
_unclaimedSources.clear();
_latestClockGeneration = null;
pendingStreamEpoch = null;
}
@@ -316,7 +316,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onSeekRequested: _seekPlayback,
onRateRequested: _setPlaybackRate,
onPlayPauseRequested: _handleControlsTransport,
onSeekCompleted: _notifyWatchTogetherSeek,
onBack: _handleBackButton,
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
_onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown),
@@ -37,8 +37,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_unfocusPlayNextPrompt();
_dismissStillWatching();
_notifyWatchTogetherMediaChange(metadata: _episode.next);
_setPlayerState(() {
_episode.isLoadingNext = true;
_episode.showPlayNextDialog = false;
@@ -54,8 +52,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
if (!_canNavigateMediaItems()) return;
if (_episode.previous == null || _episode.isLoadingPrevious) return;
_notifyWatchTogetherMediaChange(metadata: _episode.previous);
_setPlayerState(() {
_episode.isLoadingPrevious = true;
});
@@ -86,7 +82,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
await _seekPlayback(target);
if (!mounted || currentPlayer != player) return;
_notifyWatchTogetherSeek(target);
_mediaControls.pushPlaybackState();
}
@@ -95,14 +90,16 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
/// dispose skips the app-level player-exit side effects the replacement
/// route takes over (WT host-exit notify, sleep timer, system UI restore,
/// display mode).
Future<void> _replaceScreenWithPlayer(MediaItem metadata) async {
Future<void> _replaceScreenWithPlayer(MediaItem metadata, {WatchPlaybackLease? watchTogetherLease}) async {
_isReplacingWithVideo = true; // before any await — dispose can run mid-helper
watchTogetherLease ??= _activeWatchTogetherSession()?.capturePlaybackLease(selection: true);
try {
await navigateToVideoPlayer(
context,
metadata: metadata,
usePushReplacement: true,
isOffline: _offlineLibraryMode,
watchTogetherLease: watchTogetherLease,
);
} finally {
// Still mounted ⇒ no push happened (external-player branch or a
@@ -122,9 +119,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
/// [MediaReloadOutcome.rejected]: no in-place reload ran.
Future<MediaReloadOutcome> _navigateToEpisode(MediaItem episodeMetadata) async {
_episode.lastReloadFailureReason = null;
final watchTogether = _activeWatchTogetherSession();
final watchTogetherLease = watchTogether?.capturePlaybackLease(selection: true);
final currentPlayer = player;
if (currentPlayer == null) {
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata));
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata, watchTogetherLease: watchTogetherLease));
return MediaReloadOutcome.rejected;
}
@@ -144,6 +143,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// preferences, both audio and subtitles start at the server-selected
// priority (#1717).
final settingsService = await SettingsService.getInstance();
if (!mounted || currentPlayer != player || (watchTogetherLease != null && !watchTogetherLease.isCurrent)) {
return MediaReloadOutcome.superseded;
}
final followServerSelections = settingsService.read(SettingsService.followServerTrackSelections);
final committedSubtitleSelection = _playbackSession?.subtitleSelection;
final primarySubtitlePreference = followServerSelections
@@ -176,6 +178,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
preservedAudioTrack: _sessionAudioPreference,
preservedSubtitleTrack: primarySubtitlePreference,
preservedSecondarySubtitleTrack: secondarySubtitlePreference,
watchTogetherLease: watchTogetherLease,
reason: 'episode navigation',
);
} catch (e, stackTrace) {
+27 -14
View File
@@ -235,8 +235,10 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
/// Re-opens the current session's live stream at [streamUrl].
///
/// Offset-based MPV opens register their requested absolute [targetEpoch]
/// before `loadfile`. When [awaitClock] is true, success means the new
/// source's first rendered player position has been mapped to that epoch.
/// before `loadfile` and bind that registration to the source id the load
/// reports, so only that source's events can calibrate it. When [awaitClock]
/// is true, success means the new source's first rendered player position
/// has been mapped to that epoch.
Future<bool> _openLiveStream(
Player player,
String streamUrl, {
@@ -246,21 +248,32 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
bool applyOptions = true,
}) async {
_live.streamGeneration++;
final clockGeneration = targetEpoch != null && player is PlayerNative ? _live.beginClockOpen(targetEpoch) : null;
final clockResult = clockGeneration == null ? null : _live.clockOpenResult(clockGeneration);
try {
final media = Media(streamUrl, headers: const {'Accept-Language': 'en'});
final playNow = play ?? automotivePlaybackAllowedNow();
if (targetEpoch == null || player is! PlayerNative) {
if (applyOptions) await _setLiveStreamOptions(player);
await player.open(
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
play: play ?? automotivePlaybackAllowedNow(),
isLive: true,
);
} catch (_) {
if (clockGeneration != null) _live.failClockOpen(clockGeneration);
rethrow;
await player.open(media, play: playNow, isLive: true);
return true;
}
if (clockResult == null || clockGeneration == null) return true;
final clockGeneration = _live.beginClockOpen(targetEpoch);
final clockResult = _live.clockOpenResult(clockGeneration);
final int? sourceId;
try {
if (applyOptions) await _setLiveStreamOptions(player);
sourceId = await player.open(media, play: playNow, isLive: true);
} catch (_) {
_live.failClockOpen(clockGeneration);
rethrow;
}
if (sourceId == null) {
// The load never reached mpv (core unavailable), so no source will ever
// report for this open.
_live.failClockOpen(clockGeneration);
return false;
}
_live.bindClockOpen(clockGeneration, sourceId);
if (!awaitClock) {
unawaited(clockResult);
return true;
@@ -551,6 +551,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
showMessage: (message, {duration}) {
if (mounted) showAppSnackBar(context, message, duration: duration);
},
playbackRateOwnedExternally: _watchTogetherOwnsPlaybackRate,
);
}
@@ -357,6 +357,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
bool useCurrentAudioStreamSelection = true,
bool showErrorUi = true,
PlaybackTransitionLease? transitionLease,
WatchPlaybackLease? watchTogetherLease,
String reason = 'media reload',
}) async {
if (widget.isLive) {
@@ -368,6 +369,9 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
if (mounted) _clearEpisodeLoadingFlags();
return MediaReloadOutcome.rejected;
}
final initialWatchTogether = _activeWatchTogetherSession();
final roomLease = watchTogetherLease ?? initialWatchTogether?.capturePlaybackLease();
if (roomLease != null && !roomLease.isCurrent) return MediaReloadOutcome.superseded;
final reloadLease = transitionLease == null
? _transitionGate.tryAcquire(PlaybackTransition.reloadingMedia)
@@ -386,12 +390,16 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
try {
final currentPlayer = existingPlayer;
final attempt = _beginPlaybackAttempt(currentPlayer, isMediaReload: true);
bool isCurrentReload() => attempt.isCurrent && !_hasFatalPlaybackError && !_isExiting.value;
bool isCurrentReload() =>
attempt.isCurrent &&
!_hasFatalPlaybackError &&
!_isExiting.value &&
(roomLease == null || roomLease.isCurrent);
// The session itself swaps atomically at the open boundary, so the only
// rollback state is the eagerly-set identity (shown by the loading UI)
// and the first-frame flag.
final previousMetadata = _currentMetadata;
final previousMetadata = _playbackSession?.metadata ?? _currentMetadata;
final previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this);
final previousPartId = _currentMediaInfo?.partId;
final previousMediaSourceId = _currentMediaInfo?.mediaSourceId;
@@ -413,6 +421,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
SubtitlePreference.trackOrNull(currentPlayer.state.track.secondarySubtitle)
: null;
final wasPlayingBeforeReload = _playbackIntentShouldPlay;
Completer<void>? reloadStartupHold;
var didOpenReplacement = false;
// Capture context-dependent values before async gaps. The neutral
@@ -426,8 +435,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
late final AppDatabase database;
late final MultiServerManager serverManager;
late final WatchTogetherProvider? watchTogether;
late final bool watchTogetherWasAttached;
late final bool cycleWatchTogetherAttachment;
late final Object? watchTogetherBinding;
late final bool wtOwnsStart;
try {
offlineWatchService = context.read<OfflineWatchSyncService>();
@@ -440,9 +448,8 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
// as user intents. Readiness re-handshakes on re-attach (item changes
// start a new media epoch; same-item source switches group-wait while
// we reload).
watchTogether = _activeWatchTogetherSession();
watchTogetherWasAttached = watchTogether?.hasAttachedPlayer ?? false;
cycleWatchTogetherAttachment = watchTogetherWasAttached;
watchTogether = initialWatchTogether;
watchTogetherBinding = _watchTogetherBinding;
wtOwnsStart = _watchTogetherOwnsPlaybackStart();
} catch (e, stackTrace) {
appLogger.e('Failed to prepare media reload during $reason', error: e, stackTrace: stackTrace);
@@ -501,8 +508,8 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
// Detach before pausing so the reload's internal pause can't broadcast
// a party-wide pause; the finally below restores the attachment.
if (cycleWatchTogetherAttachment) {
watchTogether!.detachPlayer();
if (watchTogether != null && watchTogetherBinding != null) {
watchTogether.unbindPlayer(expectedBinding: watchTogetherBinding);
}
try {
await currentPlayer.pause();
@@ -616,6 +623,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
plexClient: () => plexClient,
getProfileSettings: () => accountPreferences.activePreferences,
preferredAudioTrack: initializationAudioTrack,
wtStartupHold: () => reloadStartupHold,
primarySubtitleTranscoding: () => result.isTranscoding,
ensureAudioFocus: () => currentPlayer.requestAudioFocus(),
clearFirstFrameForOpen: false,
@@ -635,9 +643,14 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
// reusing the player for replacement media, otherwise its late
// completion can mutate the replacement item's tracks.
await attempt.trackMutationDrain;
await watchTogether?.pendingRateCommands;
// Native seeks must finish before a replacement source opens. The
// EOF delegate's reload is not a native seek and never joins this drain.
await _nativeSeekDrain?.future;
return isCurrentReload();
},
afterMediaOpened: (_, _, _) async {
afterMediaOpened: (_, holdPlaybackStart, _) async {
if (wtOwnsStart && holdPlaybackStart) reloadStartupHold = Completer<void>();
_episode.completionLatch.reset();
if (isItemChange) {
// Same-item reloads (including the spurious-EOF recovery itself
@@ -675,7 +688,9 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
// The player now owns the new file — publish the session at the
// same boundary so identity and source state flip together.
didOpenReplacement = true;
if (!attempt.isCurrent) return;
_commitPlaybackSession(session);
_commitWatchTogetherSelection(watchTogether, roomLease, metadata, openResumePosition ?? Duration.zero);
},
);
if (flow == null) return MediaReloadOutcome.superseded;
@@ -738,7 +753,7 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
// If the stop report already went out, un-latch the tracker so the
// resumed session keeps reporting (and its eventual real stop sends).
_progressTracker?.resumeAfterStoppedReport();
if (wasPlayingBeforeReload && mounted && player == currentPlayer) {
if (!wtOwnsStart && wasPlayingBeforeReload && mounted && player == currentPlayer) {
unawaited(_playWithPlaybackIntent(currentPlayer));
}
}
@@ -774,28 +789,26 @@ extension _VideoPlayerReloadMethods on VideoPlayerScreenState {
}
return didOpenReplacement ? MediaReloadOutcome.opened : MediaReloadOutcome.failed;
} finally {
// Restore Watch Together sync on every exit: after a successful item
// change (readiness re-handshakes for the new item), after a failed
// reload (the still-playing old item must stay synced), and when the
// controller auto-detached itself on a mid-reload player failure.
// _currentMetadata is correct on both the success and rollback paths
// by the time we get here.
if (attempt.isCurrent && !didOpenReplacement && _currentMetadata.globalKey != previousMetadata.globalKey) {
_currentMetadata = previousMetadata;
if (previousLaunchIdentity != null) {
VideoPlayerScreenState._activeRouteGuard.update(this, previousLaunchIdentity);
}
_firstFrame.restore(previousFirstFrame);
}
// Only the current screen/session/open may restore its committed source.
// A pre-open failure restores A; successful open commits B. Superseded
// work never binds either over its successor.
try {
final reattachServerId = _currentMetadata.serverId;
if (watchTogetherWasAttached &&
watchTogether != null &&
watchTogether.isInSession &&
mounted &&
player == currentPlayer &&
reattachServerId != null &&
if (watchTogether != null &&
roomLease != null &&
isCurrentReload() &&
watchTogether.isPlaybackLeaseCurrent(roomLease) &&
(watchTogetherBinding == null || watchTogether.ownsBinding(watchTogetherBinding)) &&
!watchTogether.hasAttachedPlayer) {
watchTogether.attachPlayer(
currentPlayer,
ratingKey: _currentMetadata.id,
serverId: reattachServerId,
mediaTitle: _currentMetadata.displayTitle,
hasFirstFrame: _firstFrame.uiReady.value,
remoteSeek: _seekPlayback,
_attachToWatchTogetherSession(
lease: roomLease,
startupHold: didOpenReplacement ? reloadStartupHold?.future : null,
);
}
} catch (e, stackTrace) {
@@ -146,12 +146,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
}
if (widget.isLive) {
_playerStreamSubscriptions.add(
currentPlayer.streams.sourceStarted.listen((source) {
if (!mounted || player != currentPlayer) return;
_live.bindClockSource(source);
}),
);
_playerStreamSubscriptions.add(
currentPlayer.streams.sourceReady.listen((source) {
if (!mounted || player != currentPlayer) return;
@@ -5,6 +5,14 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
final currentPlayer = player;
if (!mounted || currentPlayer == null) return;
final attempt = _beginPlaybackAttempt(currentPlayer);
final watchTogether = _activeWatchTogetherSession();
final watchTogetherLease = widget.watchTogetherLease;
_watchTogetherLease = watchTogetherLease;
if (watchTogether != null && watchTogetherLease != null && watchTogetherLease.isCurrent) {
_watchTogetherProvider = watchTogether;
watchTogether.onPlayerMediaSwitched = _handlePlayerMediaSwitch;
}
bool isCurrentStart() => attempt.isCurrent && (watchTogetherLease == null || watchTogetherLease.isCurrent);
_firstFrame.resetRenderedForAttempt();
_hasFatalPlaybackError = false;
// 503s observed from here on belong to this attempt's open.
@@ -212,11 +220,11 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
subtitleSelection: subtitleSelection,
headers: streamHeaders,
isLocalMedia: _isOfflinePlayback,
isCurrent: () => attempt.isCurrent,
isCurrent: isCurrentStart,
// When a Watch Together session is active the sync layer owns the
// start: open paused everywhere and let the host coordinate one
// simultaneous group start.
watchTogetherOwnsStart: _watchTogetherOwnsPlaybackStart,
watchTogetherOwnsStart: () => watchTogetherLease != null && _watchTogetherOwnsPlaybackStart(),
resolveShouldAutoStart: (wtOwnsStart) => !wtOwnsStart,
resumePosition: () => resumePosition,
plexClient: () => plexClientForTracks,
@@ -245,12 +253,15 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// Attach player to Watch Together session for sync (if in session).
// With a frame-rate startup gate pending, sync readiness waits for
// its release so the group start can't fire mid display switch.
if (mounted && !_isOfflinePlayback) {
if (wtOwnsStart && holdPlaybackStart) {
wtStartupHold = Completer<void>();
}
_attachToWatchTogetherSession(startupHold: wtStartupHold?.future);
_notifyWatchTogetherMediaChange();
if (isCurrentStart() && !_isOfflinePlayback && watchTogetherLease != null) {
_commitWatchTogetherSelection(
watchTogether,
watchTogetherLease,
_currentMetadata,
resumePosition ?? Duration.zero,
);
if (wtOwnsStart && holdPlaybackStart) wtStartupHold = Completer<void>();
_attachToWatchTogetherSession(lease: watchTogetherLease, startupHold: wtStartupHold?.future);
}
if (shouldAutoPlay && PlatformDetector.isAutomotive()) {
await _playWithPlaybackIntent(currentPlayer);

Some files were not shown because too many files have changed in this diff Show More