Compare commits

...
Author SHA1 Message Date
amarildoandGitHub 33557f0eda chore(deps): update golang docker tag to 1.27.1 (#2289) 2026-09-08 16:21:35 +02:00
edde746 a4f3c5393b fix(release): scope symbol uploads to shipped platform artifacts
Mixed-platform build trees sent stale artifacts in unbounded DIF assembles. Select shipped identities explicitly, bound uploads, and require native, Dart-map, source, and release phases to complete.

Verified the actual mixed-build Android release against a local Bugs server, 10 uploader tests, 39 release tests, and scoped Dart analysis. The repository-wide analyzer reports unrelated warnings in nested worktrees; the pre-commit hook is skipped for that known check failure.
2026-09-08 12:58:11 +02:00
edde746 533028fa0e build(android): pin mpv-build with the audio-output stall watchdog
Moves the native pin to edde746/mpv-build@6fcf0f2 so Android MPV ends
playback with an audio-output error instead of freezing when the audio
device stops taking audio. Also picks up 5521906 (hardware subtitles
aligned with the video rectangle), which sits between the previous pin and
this one.
2026-09-08 02:38:04 +02:00
edde746 d7f1d4a8fc fix(player): exit with an audio-output error when the Android audio device stalls
On a Fire TV whose audio HAL stopped taking audio (#2255 investigation),
mpv playback froze silently: the AudioTrack writer blocked forever and
nothing surfaced. The native side now gives up after one bounded recovery
and ends the file with MPV_ERROR_AO_INIT_FAILED; this carries that verdict
to the UI.

The JNI bridge forwards mpv_event_end_file.error (previously discarded),
MpvEvent.EndFile exposes it as a typed MpvError, and
MpvEndFileDiagnostics tags an AO failure with the audio-output-failed
cause. The Dart failure policy treats that cause as terminal before any
HTTP diagnosis or the live-TV ladder, since re-opening the stream would
only run the same dead output again, and the player shows localized
guidance instead of raw log text. Progress is preserved; nothing is
marked completed.

Requires the mpv-build revision carrying the audiotrack stall watchdog;
until the lock is bumped, the new cause can only come from audio-only
media whose output fails to open.
2026-09-08 02:27:10 +02:00
github-actions[bot] c6c0ce8bea chore: update cask to 2.19.1 2026-09-07 20:20:58 +00:00
edde746 5a7e0966a3 chore: bump version to 2.19.1 2026-09-07 20:50:59 +02:00
edde746 c84b12eacd fix(livetv): allow cold MediaBrowser tuners time to open
Emby and Jellyfin Live TV could fail before playback when opening a cold tuner took longer than ten seconds. Use the existing thirty-second tune timeout only for live PlaybackInfo requests that open a source, preserving other request budgets and transport aborts.

Cover delayed initial tuning and recovery, bounded cancellation without replay, and unchanged VOD and metadata timeouts.

close #2274
2026-09-07 20:32:16 +02:00
edde746 f9b9fbf611 fix(player): restore Android MPV surfaces after screensaver
Paused playback could remain stuck after the screensaver destroyed its video and OSD surfaces, leaving later resume and initialization blocked.

Attach video and OSD surfaces together, drain placeholder frames, and bound surface retirement with explicit failure handling. Preserve native surface ownership through teardown and cover recreation, pause preservation, and disposal.

close #2249
2026-09-07 20:32:16 +02:00
edde746 e727f5bef4 fix(emby): negotiate MPEG-TS HLS for live TV
Emby Live TV can fail to parse fMP4 HLS fragments, and retrying the same negotiated URL cannot change the segment container.

Restrict Emby Live TV negotiation to the existing MPEG-TS profile on initial tune and direct-play recovery. Preserve direct play, stream copy, Jellyfin negotiation, and Emby VOD. Cover transport scoping and recovery with protocol regressions.

close #2273
2026-09-07 20:32:16 +02:00
edde746 3cf412b9c8 fix(player): use native AV1 decoding when Android hardware support is absent
Software MediaCodec AV1 decoding on Pixel fails during ambient lighting and lock/unlock surface transitions, leaving corrupt or black video.

Check hardware AV1 capability before decoder initialization and reuse the per-file software decoding policy to select dav1d with GPU output. Preserve hardware decoding for supported files.

close #2272
2026-09-07 20:32:15 +02:00
edde746 048bf688b2 fix(player): await terminal playback reporting before desktop exit
Closing the app during video playback left the backend session alive until it timed out. Await shared shutdown completion and the terminal video report before closing server clients, while keeping exit bounded when teardown stalls.

close #2275
2026-09-07 20:09:12 +02:00
edde746 8238705d3e fix(android): pin MPV with stable frame scheduling
Android MPV could turn timing jitter into uneven frame presentation without reporting dropped frames.

Pin mpv-build d38d607fd299e106a83f3557e310ac4b8202b39c to consume cadence prediction and refresh-selection hysteresis. Update the unified pin and Apple lock mirrors; only Android binary artifacts change.

Refs #2262
2026-09-07 07:01:09 +02:00
edde746 722c4ddd00 fix(homebrew): migrate cask to postflight steps
Homebrew warns when loading the deprecated postflight hook. Use structured steps with install-time appdir expansion while preserving recursive extended-attribute removal.

close #2259
2026-09-07 07:01:09 +02:00
56b990c04c feat(jellyfin): report the platform in the MediaBrowser client name (#2261)
Jellyfin and Emby sessions expose Client and DeviceName but no platform,
so dashboards and session trackers (Tracearr's normalizeClient, for one)
keyword-match the Client string the way they do for "Jellyfin Android TV"
and "Swiftfin tvOS". Plezy sent Client="Plezy" on every platform, so every
install showed up as platform "Plezy", and an unresolvable device name
became Device="Plezy" as well.

Add jellyfinClientName, which appends the platform ("Plezy Android TV",
"Plezy tvOS", "Plezy iOS", ...), and jellyfinDeviceName, which falls back
through the hardware model and the platform before the app name, and use
both at the two Jellyfin/Emby header call sites. Every emitted client
string was checked against Tracearr's matcher. The Plex headers and the
DisplayPreferences client key are unchanged.


Claude-Session: https://claude.ai/code/session_0134eXvhukgvG6Szg5NTwAim

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 01:39:07 +02:00
edde746 b904c11750 fix(network): use default ports for websocket connections
Watch Together could not reach relays when their URLs omitted a port because Dart passes port 0 to custom WebSocket connection factories.

Resolve port 0 to the HTTP/HTTPS default in the Happy Eyeballs factory while preserving explicit destination and proxy ports. Cover portless ws/wss connections with isolated regression tests.

close #2263
2026-09-07 01:30:08 +02:00
edde746 8870852a9e fix(player): reset TrueHD passthrough state after seeks
TrueHD passthrough retains stale muxer timing and partial MAT frames across seeks, including Android's startup decoder refresh. Pin mpv-build d3a1e12c9da054026aa0d546fee29e2ceeae0226, which publishes the upstream SPDIF reset backport for every native platform, and synchronize all Apple and non-Apple locks.

Refs #2262. The timing-warning regression is reproduced and fixed; sustained Fire TV judder still needs reporter confirmation.
2026-09-07 01:18:57 +02:00
github-actions[bot] 6039fde33b chore: update cask to 2.19.0 2026-09-06 21:34:04 +00:00
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
edde746 c5968029e0 fix(plex): send metadata type on Discover guid match so library items resolve for the watchlist
"Add to Watchlist" disappeared from library card menus after the first open, and
the detail-screen bookmark never appeared, for every Plex movie and show (#1873).
Plex Discover's `/library/metadata/matches` answers a `guid` lookup only when
paired with the numeric metadata `type`; a bare guid returns an empty container.
The empty result was cached as "no source can hold this item", hiding the entry
on the next open.

`PlexDiscoverClient.match` now takes the item kind and sends `type=1` (movie) or
`type=2` (show) alongside the guid; other kinds return null without a request.

Fixes #1873
2026-09-05 15:15:19 +02:00
edde746 cd22475cb6 fix(plex): send the media type to Discover's matches endpoint so external ids resolve
Adding a Trakt, MAL or library title to the Plex watchlist failed with "no
rating key": /library/metadata/matches answers an empty container unless
`type` is sent, so every external-id resolution against Discover came back
empty. The client now passes the movie/show type with the guid.
2026-09-05 14:44:02 +02:00
edde746 bc13ff007a perf(explore): resolve library copies with one guid filter and a native-title search per server
The Explore library lookup spent up to eight search-index queries per
server per tap, plus one children fetch per candidate for sequels, and the
Trakt path added two alias/translation requests per detail open to reach
romaji and localized library titles.

Both backends index originalTitle on every copy of a foreign title whatever
language it is filed under, so the lookup now leads with the native title
and drops alternate titles as candidates: one query reaches the English,
romaji, localized and native copies alike. Plex's /library/all?guid= filter
is a literal prefix match with comma as OR, so every legacy-agent guid form
the external ids imply rides one indexed request alongside the Discover
guid, and the sequel gate asks for the season of every candidate in one
/library/all?type=3&show.id=…&season.index=N request. Worst case is five
concurrent requests per server, typically two or three, with no cloud call.

The Trakt alias/translation fetch is removed: the copies it reached are the
ones the native title reaches, and Trakt already sends original_title.

close #2098
2026-09-05 14:44:02 +02:00
edde746 29a6286fe6 fix(linux): suppress Steam Input duplicate actions
With Steam Input enabled on Linux, every controller press acted twice:
Steam's desktop layout injects arrow/Enter/Escape keys through a uinput
keyboard while the physical controller stays readable over evdev, so
GamepadService synthesized the same key a second time. The duplicate
guard that fixed this on Windows was gated to Platform.isWindows and its
native key handler was never registered on Linux. Enable both on Linux.

close #1694
2026-09-05 14:44:02 +02:00
edde746 6815850b61 feat(explore): match Trakt items to romaji and localized library titles via Trakt aliases
A Trakt item only carried its English title, so a Plex or Jellyfin library
filed under the romaji title (`Sousou no Frieren`), the user's language
(`Frieren: Tras finalizar el viaje`) or the native title stayed invisible to
the Explore library lookup.

The Trakt detail load now fetches /aliases and /translations/{app locale}
alongside people and related, picks one ASCII alias from the item's own
country plus the locale translation as alternate titles, and the matcher
reads original_title for every source. The lookup budget grows to four
title families, the matcher keys its cache on the candidate titles, and the
detail screen re-resolves when a detail load adds titles, not only ids.

close #2098
2026-09-05 14:44:02 +02:00
edde746 a2c9bea041 fix(explore): find library copies through the Plex search index and report servers that could not answer
Explore showed "Not in your library" for titles the user owns. Two causes:
Plex's /library/all?title= filter is an ordered word-prefix substring match,
so `Oshi no Ko` never found a library titled `[Oshi no Ko]` and a romaji or
localized title was unreachable once the first candidate hit; and a server
that answered slower than the 10s header budget was treated as a dead
endpoint, dropped from the wave, cached as a negative for ten minutes and
cascaded through its stale LAN candidates.

Plex title candidates now go through /hubs/search with includeGuids, the
full-text index behind Plex's own search, which also covers originalTitle.
Both backends search every candidate concurrently and union the id-verified
copies instead of stopping at the first title that hit; the candidate cap
becomes two title families so a sequel can reach its alternate title. Lookup
requests run under a dedicated deadline with endpoint failover off. The
aggregation layer reports per-server failures, the matcher only memoizes
complete positive waves, and the detail screen shows "Couldn't check N
servers" instead of claiming absence.

close #2098
2026-09-05 14:44:01 +02:00
edde746 a629a3407a fix(player): route Hi10 to software up front and drop to bilinear on GPUs without norm16
H.264 High 10 on Android TV boxes without a 10-bit hardware decoder
(Amlogic S905X4 class: onn 4K Pro, Homatics Box R) started black for over
ten seconds and then played choppy: mpv tried MediaCodec first, hit
"Could not initialize video chain", and only then fell back to software
decode on the GL vo, where the Mali-G31 driver has no
GL_EXT_texture_norm16 and pays an integer-texture conversion pass plus
lanczos scaling it cannot afford at 1080p.

Decide the decoder before mpv creates it. An on_preloaded hook now carries
both per-file policies (Dolby Vision P5 reshaping and this one): when the
track's codec-profile is High 10 (published at demux by the fork, see the
mpv-build pin) and MediaCodecList advertises no hardware AVCProfileHigh10,
hwdec is held at `no` and the session goes to the GL vo directly. The hold
parks any hwdec write from Dart and restores it for the next file, as the
DV P5 hold already did.

On a GL vo whose driver lacks norm16 (probed once through a pbuffer EGL
context), scale/cscale/dscale drop to bilinear and dither to off for the
session, restored when the vo goes back to the plane. Only options still at
their mpv defaults are touched, so a user's mpv.conf wins. Paired with the
fork's rg8-backed 16-bit plane emulation, the box goes from 13 drops/s and
a 13.7 s first frame to 0.7 drops/s and 2.5 s. Devices with norm16 (Shield,
Pixel 7) take neither the tier nor the emulation.

The native pin moves to edde746/mpv-build@0601b034da, which also picks up
the earlier android/linux/windows patch-series squash.

close #2065
2026-09-05 14:08:05 +02:00
edde746 7e930580cf fix(media-detail): tighten hero chips and give landscape phones a full hero
The phone hero's chip rows were spaced 8px apart with 12/6 padding and a
16px gap to the action row, which read loose against the centred layout.
The scores pill and the tappable Rate chip also came out taller than the
plain text chips because their 16px icons pushed past the 13px label
line, so the row had a visible step in it.

Chip geometry now lives in one _HeroChips table: 4px spacing between
chips and rows, 10/5 padding, a 10px (6px on short heroes) gap to the
actions, and every chip sizes its content to a shared 20px box so icons
and text can never make one chip taller than its neighbours.

Landscape phones got a hero at 60% of a short viewport, which shrank the
logo to a sliver and let the overview run under the camera cutout. The
hero now has a floor of one full hero — status bar and back strip,
full-size logo, both chip rows and the action row — and the body sliver
sits in a SliverSafeArea so section text keeps the same horizontal inset
as the hero content above it. The hero's top padding also reserves the
back-button strip explicitly instead of relying on the logo budget.
2026-09-05 08:54:06 +02:00
edde746 b79af75a1f feat(media-detail): centre the phone hero's logo, chips and actions
On phones the movie/show hero hugged the left edge under a full-bleed
backdrop while the new collection page stacks its poster, title and
actions on the centre line, so the two pages read differently.

At widths under the mobile breakpoint the hero now centres the clear
logo (or title fallback), both chip rows and the action row; the hero
height, chip shedding, focus order and back button are unchanged. Wide
heroes keep the bottom-left column — a 400px logo centred in a
tablet-wide hero floats, and the wide collection header is left-aligned
too. TV is untouched.

_buildDetailLogoOrTitle gains an alignment and _buildDetailTitle a
textAlign, both defaulting to the previous values.
2026-09-05 08:54:06 +02:00
edde746 a873009fff feat(collections): show poster, summary and item count in a header above the collection grid
Collections opened as a bare poster grid under a plain app bar: no
collection artwork, no summary, no count, so they looked unrelated to
the movie/show and album pages around them (#1493).

The collection screen now leads with the same header the album and
artist pages use: the collection poster (square for music collections)
beside its title, item count, year span, content rating and collapsible
summary, with the Play / Shuffle / Download / Delete row underneath.
The collection's backdrop art washes the header region behind a scrim
that fades to the scaffold background before the grid starts; a
collection with only a poster gets a blurred copy of it instead so the
wash still reads as colour. A transparent title bar fades in once the
header scrolls away, and the circular back button matches the detail
pages. The year span is only shown once every page has loaded so a
partially fetched collection never reports a narrower range.

buildDetailScaffold gains optional `behind`/`above` layers so a detail
screen can paint art under, and chrome over, its scroll view without
re-implementing the overlay-sheet host and back handling.

Verified on Pixel 9a, 10" tablet and Android TV emulators: touch scroll
and pinned title, D-pad up from the grid lands on the action row and
scrolls the header back into view, D-pad back pops, Plex collection with
art and Jellyfin collection without art.
2026-09-05 08:54:06 +02:00
edde746 41c92b6324 fix(windows): persist the on-screen rect of a snapped window
Snapping the window with Aero Snap (edge drag, Win+Arrow, Snap Layouts)
and quitting restored it where it sat before the snap. Windows keeps a
snapped window in the "arranged" state: GetWindowPlacement still reports
SW_SHOWNORMAL with the pre-snap rect in rcNormalPosition, so the saved
placement never reflected what was on screen.

Resolve IsWindowArranged from user32 at runtime (exported since Windows
10 1903, no header declaration) and, when the window is arranged, save
GetWindowRect converted to workspace coordinates instead. The same
correction covers the pre-fullscreen placement captured by the native
fullscreen toggle. Relaunch lands a normal window on the snapped rect;
there is no API to re-enter the snapped state.

close #1895
2026-09-05 07:14:59 +02:00
edde746 e59b79266a docs(readme): collapse package manager installation instructions
Keep package manager instructions out of the main download section until expanded. Add the community RPM repository as a setup link and place Moss last, below Nix.
2026-09-05 06:26:21 +02:00
edde746 d2fe21b169 feat(player): per-marker skip modes with an Off option for intros and credits
Turning auto-skip off still surfaced a skip button on every intro and
credits marker, so a viewer who wants to watch episodes in full had no
way to silence it. The two auto-skip switches become per-marker
selectors — Off, Show button, Automatic — stored as skip_intro_mode and
skip_credits_mode. Off filters the marker before it becomes current, so
the player behaves as if the server had sent none: no prompt, no
countdown, no TV autofocus, and Back reaches the screen. Flipping a kind
to Off while its prompt is up drops the prompt immediately.

The legacy auto_skip_intro/auto_skip_credits booleans migrate on first
read: true becomes Automatic, false becomes Show button, matching what
each meant before.

close #2138
2026-09-05 04:18:36 +02:00
edde746 f45eaf1367 feat(library): add a toggle to hide watched indicators on cards
Watched posters always carried the corner checkmark, and viewers who
find it noisy had no way to turn it off. A new Appearance switch,
Show Watched Indicators (default on), gates the checkmark on every
surface that stamps watch state onto artwork: grid cards, hub rows,
folder tree rows, episode and playlist thumbnails. Progress bars and the
unwatched-count pill keep their own behavior.

close #1998
2026-09-05 04:18:35 +02:00
edde746 4f9e527ef2 feat(tvos): default audio passthrough on for Apple TV
Apple TV shipped with passthrough opt-in while the E-AC-3/Atmos
sample-buffer renderer was unverified, so Atmos titles played as PCM
until the viewer found the switch. The renderer is now hardware-verified
on real receivers, so the pref defaults on for Apple TV the same way it
does for Android TV. An explicit stored value still wins.

close #1300
2026-09-05 04:18:35 +02:00
edde746 5efc4f384c fix(plex): honor the quality preset on Live TV instead of leaving the server to pick the tier
Playing a Plex live channel from outside the LAN always came back as a full
re-encode at the server's highest transcode tier (20 Mbps 1080p on the
reporter's server), no matter which quality the app was set to. The Plex live
path never sent a ceiling: a live source has no bitrate the server can check
against its remote-stream limit, so a remote session without a client cap
falls onto the server's own top tier. On the LAN the server remuxes anyway,
which hid the gap.

The live stream path now reads the same saved preset the library path and
the Jellyfin live path (#2198) already honor. Original is unchanged: a remux
(directStream=1) with no ceiling. A capped preset pins directStream=0 and
sends the bitrate limitation clause plus the videoResolution/videoQuality caps
so the encode lands at the chosen tier rather than the server's. The preset is
session state, so a recovery re-tune keeps the cap.

directPlay stays 0: a tuned Plex session is only reachable through the
transcoder's HLS output, so "no re-encode" on live means a remux, not direct
play. Capped sessions use the h264-only TS target because every codec in the
target becomes an encode output once directStream is off, and HEVC into TS is
the #1859 corruption; the broadcast-codec live target remains the Original
remux menu.

close #2072
2026-09-05 04:15:17 +02:00
edde746 12a380807d fix(live-tv): anchor the live clock on the playback transcode's server origin
A skip back of 10-20 s on a freshly tuned Plex Live TV channel landed on
or after the frame being shown, while the same skip worked once the viewer
had already time-shifted. Offset-less opens (initial tune, retry, channel
zap, subtitle switch at the edge) pinned stream position zero to wall clock
at open time, but the transcode starts behind real time by tuner ingest and
encoder start-up latency; offset opens were exact because the server
defines their origin.

Every /:/timeline response already carries the playback transcode as the
top-level TranscodeSession next to the CaptureBuffer wrapper; its timeStamp
is the epoch of stream position zero, which Plex's own client uses as the
playhead origin. The parser used to return only the capture window. It now
returns both, and each heartbeat re-anchors the clock on the playback
origin unless the response predates the current open or an open is still
calibrating. Offset-less opens seed a provisional anchor from the capture
edge instead of wall clock, and the live-edge threshold is widened to 15 s
so a live stream trailing the edge by normal latency still reads as live.
2026-09-05 01:51:03 +02:00
332 changed files with 27691 additions and 6408 deletions
+6 -6
View File
@@ -1,6 +1,6 @@
cask "plezy" do
version "2.18.0"
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
version "2.19.1"
sha256 "7d3ffd0efccf4db5faf063e8ecc03e897ee30d08e9fa3b007d127c63cbf187e2"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
@@ -16,10 +16,10 @@ cask "plezy" do
app "Plezy.app"
postflight do
system_command "/usr/bin/xattr",
args: ["-cr", "#{appdir}/Plezy.app"],
sudo: false
postflight_steps do
run "/usr/bin/xattr",
args: ["-cr", "{{appdir}}/Plezy.app"],
sudo: false
end
uninstall quit: "com.edde746.plezy"
+45 -15
View File
@@ -30,22 +30,47 @@ A modern client for Plex, Jellyfin, and Emby on desktop, mobile, and TV. Built w
| Linux x64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz) |
| Linux arm64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz) |
Package managers:
<details>
<summary>Install with a package manager</summary>
- [Nix](https://search.nixos.org/packages?channel=unstable&query=plezy) - Community package by [@mio-19](https://github.com/mio-19) and [@MiniHarinn](https://github.com/MiniHarinn)
- **Homebrew** (macOS):
```bash
brew tap edde746/plezy https://github.com/edde746/plezy
brew install --cask plezy
```
- [Pacman](https://archlinux.org/packages/extra/x86_64/plezy/) (Arch Linux) - Official package:
```bash
sudo pacman -S plezy
```
- **WinGet** (Windows):
```bash
winget install edde746.Plezy
```
### macOS — Homebrew
```bash
brew tap edde746/plezy https://github.com/edde746/plezy
brew install --cask plezy
```
### Windows — WinGet
```bash
winget install edde746.Plezy
```
### Arch Linux — Pacman
[Distribution package](https://archlinux.org/packages/extra/x86_64/plezy/).
```bash
sudo pacman -S plezy
```
### Fedora / Red Hat — DNF
[Installation instructions](https://github.com/aldobarr/plezy-rpm) · Community repository by [@aldobarr](https://github.com/aldobarr).
### Nix
[Community package](https://search.nixos.org/packages?channel=unstable&query=plezy) maintained by [@mio-19](https://github.com/mio-19) and [@MiniHarinn](https://github.com/MiniHarinn).
### aerynOS — Moss
[Distribution package](https://github.com/aerynOS/recipes/tree/main/p/plezy).
```bash
sudo moss it plezy
```
</details>
## Features
@@ -110,6 +135,11 @@ Package managers:
### <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]
@@ -1,177 +0,0 @@
package com.edde746.plezy.mpv
import android.app.Instrumentation
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.ViewGroup
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.concurrent.CountDownLatch
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 MpvLifecycleDeviceTest {
@Test
fun repeatedMediaCodecPlaybackCompletesTerminalTeardown() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val fixtureBytes = instrumentation.context.assets.open("ffmpeg/mediacodec_teardown.mp4").use { it.readBytes() }
val fixture = copyFixture(fixtureBytes, instrumentation.targetContext.cacheDir)
val activity = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, MpvLifecycleTestActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
) as MpvLifecycleTestActivity
instrumentation.waitForIdleSync()
try {
repeat(CYCLE_COUNT) { cycle ->
runPlaybackCycle(instrumentation, activity, fixture, cycle)
}
} finally {
instrumentation.runOnMainSync(activity::finish)
instrumentation.waitForIdleSync()
fixture.delete()
}
}
private fun runPlaybackCycle(
instrumentation: Instrumentation,
activity: MpvLifecycleTestActivity,
fixture: File,
cycle: Int
) {
val initialized = CountDownLatch(1)
val initializationResult = AtomicReference<Boolean>()
val events = RecordingDelegate()
val core = AtomicReference<MpvPlayerCore>()
instrumentation.runOnMainSync {
core.set(
MpvPlayerCore(activity, hardwareDecoding = true).also { playerCore ->
playerCore.delegate = events
playerCore.initialize { success ->
initializationResult.set(success)
initialized.countDown()
}
}
)
}
assertCompletes(initialized, "MPV initialization", cycle)
assertTrue("MPV initialization failed in cycle $cycle", initializationResult.get())
setProperty(instrumentation, core.get(), "hwdec", "mediacodec", cycle)
setProperty(instrumentation, core.get(), "aid", "no", cycle)
val commandCompleted = CountDownLatch(1)
val commandResult = AtomicReference<Boolean>()
instrumentation.runOnMainSync {
core.get().command(arrayOf("loadfile", fixture.absolutePath, "replace")) { success ->
commandResult.set(success)
commandCompleted.countDown()
}
}
assertCompletes(commandCompleted, "loadfile command", cycle)
assertTrue("loadfile command failed in cycle $cycle", commandResult.get())
assertCompletes(events.fileLoaded, "file-loaded event", cycle)
assertCompletes(events.playbackRestart, "playback-restart event", cycle)
assertEquals("mediacodec", core.get().getProperty("current-vo"))
assertTrue(
"Expected MediaCodec hardware decoding in cycle $cycle",
core.get().getProperty("hwdec-current")?.startsWith("mediacodec") == true
)
val disposed = CountDownLatch(1)
val nextMainTurn = CountDownLatch(1)
val disposeElapsedMs = AtomicReference<Long>()
val synchronousDisposeElapsedMs = AtomicReference<Long>()
val disposeStartedAt = SystemClock.elapsedRealtime()
instrumentation.runOnMainSync {
val synchronousDisposeStartedAt = SystemClock.elapsedRealtime()
core.get().dispose {
disposeElapsedMs.set(SystemClock.elapsedRealtime() - disposeStartedAt)
disposed.countDown()
}
Handler(Looper.getMainLooper()).post(nextMainTurn::countDown)
synchronousDisposeElapsedMs.set(SystemClock.elapsedRealtime() - synchronousDisposeStartedAt)
}
assertTrue(
"dispose() blocked the main thread for ${synchronousDisposeElapsedMs.get()}ms in cycle $cycle",
synchronousDisposeElapsedMs.get() <= MAX_SYNCHRONOUS_DISPOSE_MS
)
assertCompletes(nextMainTurn, "main-looper turn after dispose", cycle, MAIN_LOOP_TIMEOUT_SECONDS)
assertCompletes(disposed, "terminal teardown", cycle, DISPOSE_TIMEOUT_SECONDS)
assertTrue(
"Terminal teardown took ${disposeElapsedMs.get()}ms in cycle $cycle",
disposeElapsedMs.get() <= MAX_DISPOSE_LATENCY_MS
)
instrumentation.runOnMainSync {
val content = activity.findViewById<ViewGroup>(android.R.id.content)
assertEquals("Player surface container leaked in cycle $cycle", 1, content.childCount)
}
}
private fun setProperty(
instrumentation: Instrumentation,
core: MpvPlayerCore,
name: String,
value: String,
cycle: Int
) {
val completed = CountDownLatch(1)
val result = AtomicReference<Result<Unit>>()
instrumentation.runOnMainSync {
core.setProperty(name, value) { outcome ->
result.set(outcome)
completed.countDown()
}
}
assertCompletes(completed, "$name property write", cycle)
assertTrue("$name property write failed in cycle $cycle", result.get().isSuccess)
}
private fun assertCompletes(
latch: CountDownLatch,
operation: String,
cycle: Int,
timeoutSeconds: Long = OPERATION_TIMEOUT_SECONDS
) {
assertTrue(
"$operation timed out in cycle $cycle after ${timeoutSeconds}s",
latch.await(timeoutSeconds, TimeUnit.SECONDS)
)
}
private fun copyFixture(bytes: ByteArray, cacheDir: File): File = File.createTempFile("mpv-lifecycle-", ".mp4", cacheDir).apply { writeBytes(bytes) }
private class RecordingDelegate : PlayerDelegate {
val fileLoaded = CountDownLatch(1)
val playbackRestart = CountDownLatch(1)
override fun onPropertyChange(name: String, value: Any?) = Unit
override fun onEvent(name: String, data: Map<String, Any>?) {
when (name) {
"file-loaded" -> fileLoaded.countDown()
"playback-restart" -> playbackRestart.countDown()
}
}
}
private companion object {
const val CYCLE_COUNT = 8
const val OPERATION_TIMEOUT_SECONDS = 10L
const val DISPOSE_TIMEOUT_SECONDS = 15L
const val MAIN_LOOP_TIMEOUT_SECONDS = 1L
const val MAX_SYNCHRONOUS_DISPOSE_MS = 500L
const val MAX_DISPOSE_LATENCY_MS = 2_000L
}
}
@@ -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,354 @@
package com.edde746.plezy.mpv
import android.app.Instrumentation
import android.content.Intent
import android.graphics.Color
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
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.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MpvLifecycleDeviceTest {
@Test
fun repeatedMediaCodecPlaybackCompletesTerminalTeardown() = runPlaybackTest(recreateSurfaces = false)
@Test
fun pausedMediaCodecPlaybackSurvivesRepeatedSurfaceRecreation() = runPlaybackTest(recreateSurfaces = true)
@Test
fun pausedGpuPlaybackSurvivesRepeatedSurfaceRecreation() = runPlaybackTest(recreateSurfaces = true, hardwareDecoding = false)
@Test
fun placeholderConsumesFramesWithoutBlockingProducer() {
val placeholder = runBlocking { MpvPlaceholderSurface.create() }
val completed = CountDownLatch(1)
val failure = AtomicReference<Throwable?>()
val producer = Thread {
try {
// More than a BufferQueue can retain without an active consumer.
repeat(16) {
val canvas = placeholder.surface.lockCanvas(null)
canvas.drawColor(Color.BLACK)
placeholder.surface.unlockCanvasAndPost(canvas)
}
} catch (error: Throwable) {
failure.set(error)
} finally {
completed.countDown()
}
}.apply { isDaemon = true }
try {
producer.start()
assertCompletes(completed, "placeholder buffer consumption", 0)
failure.get()?.let { throw AssertionError("Placeholder producer failed", it) }
} finally {
placeholder.close()
producer.join(2_000)
assertTrue("Placeholder producer survived cleanup", !producer.isAlive)
}
}
private fun runPlaybackTest(recreateSurfaces: Boolean, hardwareDecoding: Boolean = true) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val fixtureBytes = instrumentation.context.assets.open("ffmpeg/mediacodec_teardown.mp4").use { it.readBytes() }
val fixture = copyFixture(fixtureBytes, instrumentation.targetContext.cacheDir)
try {
val activity = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, MpvLifecycleTestActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
) as MpvLifecycleTestActivity
try {
instrumentation.waitForIdleSync()
repeat(CYCLE_COUNT) { cycle ->
runPlaybackCycle(instrumentation, activity, fixture, cycle, recreateSurfaces, hardwareDecoding)
}
} finally {
instrumentation.runOnMainSync(activity::finish)
instrumentation.waitForIdleSync()
}
} finally {
fixture.delete()
}
}
private fun runPlaybackCycle(
instrumentation: Instrumentation,
activity: MpvLifecycleTestActivity,
fixture: File,
cycle: Int,
recreateSurfaces: Boolean,
hardwareDecoding: Boolean
) {
val initialized = CountDownLatch(1)
val initializationResult = AtomicReference<Boolean>()
val events = RecordingDelegate()
val core = AtomicReference<MpvPlayerCore>()
instrumentation.runOnMainSync {
core.set(
MpvPlayerCore(activity, hardwareDecoding = hardwareDecoding).also { playerCore ->
playerCore.delegate = events
playerCore.initialize { success ->
initializationResult.set(success)
initialized.countDown()
}
}
)
}
try {
assertCompletes(initialized, "MPV initialization", cycle)
assertTrue("MPV initialization failed in cycle $cycle", initializationResult.get())
setProperty(instrumentation, core.get(), "hwdec", if (hardwareDecoding) "mediacodec" else "no", cycle)
setProperty(instrumentation, core.get(), "aid", "no", cycle)
if (recreateSurfaces) setProperty(instrumentation, core.get(), "loop-file", "inf", cycle)
val commandCompleted = CountDownLatch(1)
val commandResult = AtomicReference<Boolean>()
instrumentation.runOnMainSync {
core.get().command(arrayOf("loadfile", fixture.absolutePath, "replace")) { success ->
commandResult.set(success)
commandCompleted.countDown()
}
}
assertCompletes(commandCompleted, "loadfile command", cycle)
assertTrue("loadfile command failed in cycle $cycle", commandResult.get())
assertCompletes(events.fileLoaded, "file-loaded event", cycle)
assertCompletes(events.playbackRestart, "playback-restart event", cycle)
assertVideoOutput(core.get(), cycle, hardwareDecoding)
if (recreateSurfaces) exerciseSurfaceRecreation(instrumentation, activity, core.get(), cycle, hardwareDecoding)
} finally {
disposeCore(instrumentation, activity, core.get(), cycle)
}
}
private fun disposeCore(
instrumentation: Instrumentation,
activity: MpvLifecycleTestActivity,
core: MpvPlayerCore,
cycle: Int
) {
val disposed = CountDownLatch(1)
val nextMainTurn = CountDownLatch(1)
val disposeElapsedMs = AtomicReference<Long>()
val synchronousDisposeElapsedMs = AtomicReference<Long>()
val disposeStartedAt = SystemClock.elapsedRealtime()
instrumentation.runOnMainSync {
val synchronousDisposeStartedAt = SystemClock.elapsedRealtime()
core.dispose {
disposeElapsedMs.set(SystemClock.elapsedRealtime() - disposeStartedAt)
disposed.countDown()
}
Handler(Looper.getMainLooper()).post(nextMainTurn::countDown)
synchronousDisposeElapsedMs.set(SystemClock.elapsedRealtime() - synchronousDisposeStartedAt)
}
try {
assertTrue(
"dispose() blocked the main thread for ${synchronousDisposeElapsedMs.get()}ms in cycle $cycle",
synchronousDisposeElapsedMs.get() <= MAX_SYNCHRONOUS_DISPOSE_MS
)
assertCompletes(nextMainTurn, "main-looper turn after dispose", cycle, MAIN_LOOP_TIMEOUT_SECONDS)
} finally {
assertCompletes(disposed, "terminal teardown", cycle, DISPOSE_TIMEOUT_SECONDS)
}
assertTrue(
"Terminal teardown took ${disposeElapsedMs.get()}ms in cycle $cycle",
disposeElapsedMs.get() <= MAX_DISPOSE_LATENCY_MS
)
instrumentation.runOnMainSync {
val content = activity.findViewById<ViewGroup>(android.R.id.content)
assertEquals("Player surface container leaked in cycle $cycle", 1, content.childCount)
}
}
private fun exerciseSurfaceRecreation(
instrumentation: Instrumentation,
activity: MpvLifecycleTestActivity,
core: MpvPlayerCore,
cycle: Int,
hardwareDecoding: Boolean
) {
val surfaces = mutableListOf<SurfaceView>()
instrumentation.runOnMainSync {
val content = activity.findViewById<ViewGroup>(android.R.id.content)
val container = content.getChildAt(0) as ViewGroup
// The host's first plane only punches out letterboxing; the remaining
// SurfaceViews are the actual video and OSD planes.
for (index in 1 until container.childCount) {
(container.getChildAt(index) as? SurfaceView)?.let(surfaces::add)
}
}
assertEquals("Expected video and optional OSD surfaces", if (hardwareDecoding) 2 else 1, surfaces.size)
// Separate visibility changes and callback latches guarantee both real
// destruction/creation orders, including video returning before the OSD.
val orders = if (hardwareDecoding) listOf(surfaces, surfaces.reversed()) else listOf(surfaces)
for (order in orders) {
setProperty(instrumentation, core, "pause", "yes", cycle)
awaitProperty(core, "pause", "public pause", cycle) { it == "yes" }
for (surface in order) {
changeSurfaceVisibility(instrumentation, surface, visible = false, cycle = cycle)
assertEquals("Surface loss cleared public pause in cycle $cycle", "yes", core.getProperty("pause"))
}
for (surface in order) {
changeSurfaceVisibility(instrumentation, surface, visible = true, cycle = cycle)
assertEquals("Surface restoration cleared public pause in cycle $cycle", "yes", core.getProperty("pause"))
}
val pausedPosition = awaitProperty(core, "time-pos", "paused playback position", cycle) {
it?.toDoubleOrNull() != null
}.toDouble()
setProperty(instrumentation, core, "pause", "no", cycle)
awaitProperty(core, "pause", "public resume", cycle) { it == "no" }
var progressStart = pausedPosition
awaitProperty(core, "time-pos", "playback progress after surface restoration", cycle) { value ->
val position = value?.toDoubleOrNull()
if (position == null) {
false
} else {
// The fixture is two seconds long and loops. Start measuring again
// across a loop boundary rather than mistaking a wrap for a stall.
if (position < progressStart) progressStart = position
position >= progressStart + 0.1
}
}
assertVideoOutput(core, cycle, hardwareDecoding)
}
}
private fun changeSurfaceVisibility(
instrumentation: Instrumentation,
surface: SurfaceView,
visible: Boolean,
cycle: Int
) {
val changed = CountDownLatch(1)
val callback = object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
if (visible) changed.countDown()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
override fun surfaceDestroyed(holder: SurfaceHolder) {
if (!visible) changed.countDown()
}
}
try {
instrumentation.runOnMainSync {
surface.holder.addCallback(callback)
surface.visibility = if (visible) View.VISIBLE else View.INVISIBLE
}
assertCompletes(changed, if (visible) "real surface creation" else "real surface destruction", cycle)
instrumentation.runOnMainSync {
assertEquals("Unexpected surface validity in cycle $cycle", visible, surface.holder.surface.isValid)
}
} finally {
instrumentation.runOnMainSync { surface.holder.removeCallback(callback) }
}
}
private fun awaitProperty(
core: MpvPlayerCore,
name: String,
operation: String,
cycle: Int,
matches: (String?) -> Boolean
): String {
val deadline = SystemClock.elapsedRealtime() + TimeUnit.SECONDS.toMillis(OPERATION_TIMEOUT_SECONDS)
var value: String?
do {
value = core.getProperty(name)
if (matches(value)) return requireNotNull(value)
SystemClock.sleep(20)
} while (SystemClock.elapsedRealtime() < deadline)
throw AssertionError("$operation timed out in cycle $cycle; $name=$value")
}
private fun assertVideoOutput(core: MpvPlayerCore, cycle: Int, hardwareDecoding: Boolean) {
if (hardwareDecoding) {
assertEquals("mediacodec", core.getProperty("current-vo"))
assertTrue(
"Expected MediaCodec hardware decoding in cycle $cycle",
core.getProperty("hwdec-current")?.startsWith("mediacodec") == true
)
} else {
assertEquals("gpu", core.getProperty("current-vo"))
assertEquals("no", core.getProperty("hwdec-current"))
}
}
private fun setProperty(
instrumentation: Instrumentation,
core: MpvPlayerCore,
name: String,
value: String,
cycle: Int
) {
val completed = CountDownLatch(1)
val result = AtomicReference<Result<Unit>>()
instrumentation.runOnMainSync {
core.setProperty(name, value) { outcome ->
result.set(outcome)
completed.countDown()
}
}
assertCompletes(completed, "$name property write", cycle)
assertTrue("$name property write failed in cycle $cycle", result.get().isSuccess)
}
private fun assertCompletes(
latch: CountDownLatch,
operation: String,
cycle: Int,
timeoutSeconds: Long = OPERATION_TIMEOUT_SECONDS
) {
assertTrue(
"$operation timed out in cycle $cycle after ${timeoutSeconds}s",
latch.await(timeoutSeconds, TimeUnit.SECONDS)
)
}
private fun copyFixture(bytes: ByteArray, cacheDir: File): File = File.createTempFile("mpv-lifecycle-", ".mp4", cacheDir).apply { writeBytes(bytes) }
private class RecordingDelegate : PlayerDelegate {
val fileLoaded = CountDownLatch(1)
val playbackRestart = CountDownLatch(1)
override fun onPropertyChange(name: String, value: Any?) = Unit
override fun onEvent(name: String, data: Map<String, Any>?) {
when (name) {
"file-loaded" -> fileLoaded.countDown()
"playback-restart" -> playbackRestart.countDown()
}
}
}
private companion object {
const val CYCLE_COUNT = 8
const val OPERATION_TIMEOUT_SECONDS = 10L
const val DISPOSE_TIMEOUT_SECONDS = 15L
const val MAIN_LOOP_TIMEOUT_SECONDS = 1L
const val MAX_SYNCHRONOUS_DISPOSE_MS = 500L
const val MAX_DISPOSE_LATENCY_MS = 2_000L
}
}
@@ -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
}
}
@@ -35,6 +35,83 @@ internal object GpuVoPolicy {
*/
fun needsSoftwareRender(hwdecCurrent: String?): Boolean = !hwdecCurrent.isNullOrBlank() && hwdecCurrent != "mediacodec"
/**
* Select native software decoding before opening a decoder when hardware
* cannot serve the stream. H.264 High 10 needs an advertised profile
* (#2065); AV1 needs an actual hardware decoder (#2272), not a software
* MediaCodec component whose surface/copy paths can fail on VO changes.
* [codec] and [codecProfile] come from mpv's pending video track.
*/
fun needsSoftwareDecode(
codec: String?,
codecProfile: String?,
hardwareHigh10: Boolean,
hardwareAv1: Boolean
): Boolean = when (codec) {
"h264" -> !hardwareHigh10 && codecProfile?.startsWith("High 10") == true
"av1" -> !hardwareAv1
else -> false
}
/**
* 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,
* which on Android singles out the low-end Mali/Adreno TV class whose
* texture units cannot afford a second full-resolution pass at 1080p
* (measured on an S905X4/Mali-G31: mpv's default lanczos chroma pass alone
* runs the frame over budget, bilinear brings it back under; every
* norm16-capable GPU tested renders the whole default ladder in a few
* milliseconds). The video plane never scales in GL, so hardware sessions
* on it are untouched.
*/
fun needsCheapRenderTier(glVoActive: Boolean, textureNorm16: Boolean): Boolean = glVoActive && !textureNorm16
/** mpv option -> value for the cheap render tier, applied only where the
* option still carries its mpv default (a user's mpv.conf line wins). */
val CHEAP_RENDER_OPTIONS: Map<String, String> = linkedMapOf(
"scale" to "bilinear",
"cscale" to "bilinear",
"dscale" to "bilinear",
"dither" to "no"
)
/** The values mpv 0.41 reports for [CHEAP_RENDER_OPTIONS] when nothing
* set them; anything else is a user choice and stays. `cscale` inherits
* `scale` by default, which the property reads back as an empty string
* (measured on 0.41) or `inherit`. */
val MPV_DEFAULT_RENDER_OPTIONS: Map<String, Set<String>> = mapOf(
"scale" to setOf("lanczos"),
"cscale" to setOf("", "inherit"),
"dscale" to setOf("hermite"),
"dither" to setOf("fruit")
)
/** Whether [value] is what mpv reports for [option] by default. */
fun isDefaultRenderOption(option: String, value: String?): Boolean = value != null && MPV_DEFAULT_RENDER_OPTIONS[option]?.contains(value) == true
/**
* The vo a session with these active requirements should run, or null for
* the video plane.
@@ -57,4 +134,5 @@ internal object GpuVoPolicy {
const val REASON_CHAIN_FAILURE = "chain-failure"
const val REASON_HDR_SDR = "hdr-sdr"
const val REASON_SW_DECODE = "sw-decode"
const val REASON_CODEC_SW_DECODE = "codec-sw-decode"
}
@@ -3,12 +3,22 @@ package com.edde746.plezy.mpv
import com.edde746.plezy.libmpv.EndFileReason
import com.edde746.plezy.libmpv.LogLevel
import com.edde746.plezy.libmpv.LogMessage
import com.edde746.plezy.libmpv.MpvError
import com.edde746.plezy.libmpv.MpvEvent
/** Adds the native diagnostic that libmpv-android exposes separately via logFlow. */
internal class MpvEndFileDiagnostics {
private var errorMessage: String? = null
companion object {
/**
* The audio device stopped taking audio (or never could) and mpv gave up
* on it. Keep in sync with PlayerError.audioOutputFailed in Dart: a device
* fault, so no stream retry or backend switch can recover it.
*/
const val CAUSE_AUDIO_OUTPUT_FAILED = "audio-output-failed"
}
fun onStartFile() {
errorMessage = null
}
@@ -26,6 +36,7 @@ internal class MpvEndFileDiagnostics {
data["reason"] = reason.id
if (reason == EndFileReason.Error) {
errorMessage?.let { data["message"] = it }
if (event.error == MpvError.AoInitFailed) data["cause"] = CAUSE_AUDIO_OUTPUT_FAILED
}
}
errorMessage = null
@@ -0,0 +1,54 @@
package com.edde746.plezy.mpv
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
import androidx.media3.common.util.EGLSurfaceTexture
import kotlinx.coroutines.android.asCoroutineDispatcher
import kotlinx.coroutines.withContext
/**
* An offscreen consumer, not an undrained ImageReader queue. MediaCodec and GPU
* output can both keep posting buffers while the real SurfaceViews are absent.
* Media3's common EGL utility consumes each frame on its own GL thread, including
* while the main thread is waiting for MPV to acknowledge a surface handoff.
*/
internal class MpvPlaceholderSurface private constructor(
val surface: Surface,
private val handler: Handler,
private val texture: EGLSurfaceTexture
) : AutoCloseable {
companion object {
/** The caller must retain or close the result even if initialization is canceled. */
suspend fun create(): MpvPlaceholderSurface {
val thread = HandlerThread("MpvPlaceholder").apply { start() }
val handler = Handler(thread.looper)
val texture = EGLSurfaceTexture(handler)
return withContext(handler.asCoroutineDispatcher("MpvPlaceholder")) {
try {
texture.init(EGLSurfaceTexture.SECURE_MODE_NONE)
MpvPlaceholderSurface(Surface(texture.surfaceTexture), handler, texture)
} catch (error: Throwable) {
try {
texture.release()
} finally {
thread.quitSafely()
}
throw error
}
}
}
}
/** Call only after native consumers have retired; destruction stays on the GL thread. */
override fun close() {
handler.post {
try {
surface.release()
texture.release()
} finally {
handler.looper.quitSafely()
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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()
}
}
@@ -0,0 +1,104 @@
package com.edde746.plezy.shared
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLSurface
import android.opengl.GLES20
import android.util.Log
/**
* GLES capabilities that decide render policy before mpv creates its own
* context. Probed once per process on a throwaway pbuffer context; the
* answers are hardware/driver properties and do not change at runtime.
*/
internal object GlCapabilities {
private const val TAG = "GlCapabilities"
private const val EGL_OPENGL_ES3_BIT_KHR = 0x0040
@Volatile private var textureNorm16: Boolean? = null
/**
* Whether the GLES driver exposes `GL_EXT_texture_norm16`. Without it mpv
* cannot upload >8-bit planes as filterable textures, and the GPUs that
* lack it are the ones that cannot afford mpv's default scalers either
* (see [com.edde746.plezy.mpv.GpuVoPolicy.needsCheapRenderTier]).
*
* A probe failure reports `true`: the default render path is the safe
* answer for an unknown GPU, not the cheap tier.
*/
fun hasTextureNorm16(): Boolean {
textureNorm16?.let { return it }
synchronized(this) {
textureNorm16?.let { return it }
val probed = probe { extensions -> extensions.contains("GL_EXT_texture_norm16") } ?: true
textureNorm16 = probed
return probed
}
}
/** Test seam: pretend the probe answered [value]. */
internal fun overrideTextureNorm16ForTesting(value: Boolean?) {
textureNorm16 = value
}
/**
* Runs [read] against the extension string of a fresh ES3 pbuffer context
* and tears everything down again. Returns null when EGL refuses any step;
* callers pick the safe default. Must not run on a thread that already
* has an EGL context current — the temporary context replaces it.
*/
private fun probe(read: (String) -> Boolean): Boolean? {
var display: EGLDisplay = EGL14.EGL_NO_DISPLAY
var context: EGLContext = EGL14.EGL_NO_CONTEXT
var surface: EGLSurface = EGL14.EGL_NO_SURFACE
try {
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
if (display == EGL14.EGL_NO_DISPLAY) return null
val version = IntArray(2)
if (!EGL14.eglInitialize(display, version, 0, version, 1)) return null
val configAttributes = intArrayOf(
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_PBUFFER_BIT,
EGL14.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR,
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_NONE
)
val configs = arrayOfNulls<EGLConfig>(1)
val count = IntArray(1)
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
)
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
)
if (context == EGL14.EGL_NO_CONTEXT) return null
if (!EGL14.eglMakeCurrent(display, surface, surface, context)) return null
val extensions = GLES20.glGetString(GLES20.GL_EXTENSIONS) ?: return null
return read(extensions)
} catch (e: RuntimeException) {
Log.w(TAG, "GLES capability probe failed", e)
return null
} finally {
if (display != EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context)
if (surface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, surface)
// No eglTerminate: the default display is shared with the Flutter
// engine and mpv; eglInitialize on an initialized display is a no-op.
}
}
}
}
@@ -35,6 +35,33 @@ internal object MediaCodecQuery {
return mimeTypes
}
/**
* Whether a hardware `video/avc` decoder advertises H.264 High 10 (Hi10P).
* Decoders that do not advertise it either refuse the stream (this is what
* the mpv/FFmpeg MediaCodec path sees) or, on some SoCs, accept it and
* render garbage; neither is a reason to let the hardware path try first
* (#2065). Answered once per process: the codec list is static.
*/
fun hardwareAvcHigh10Support(): Boolean = hardwareAvcHigh10.value
private val hardwareAvcHigh10: Lazy<Boolean> = lazy {
findHardwareDecoder("video/avc") { info, type ->
val profiles = try {
info.getCapabilitiesForType(type).profileLevels
} catch (e: IllegalArgumentException) {
return@findHardwareDecoder false
}
profiles.any { it.profile == MediaCodecInfo.CodecProfileLevel.AVCProfileHigh10 }
} != null
}
/** Software MediaCodec AV1 components are not a hardware decode path (#2272). */
fun hardwareAv1Support(): Boolean = hardwareAv1.value
private val hardwareAv1: Lazy<Boolean> = lazy {
findHardwareDecoder("video/av01") != null
}
fun findHardwareDecoder(
mimeType: String,
codecKind: Int = MediaCodecList.REGULAR_CODECS,
@@ -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,700 @@
#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"
#define pthread_mutex_lock tracked_mutex_lock
#include "../../../../libmpv/src/main/cpp/render.cpp"
#undef pthread_mutex_lock
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;
jobject osd_option = nullptr;
int rebuilds = 0;
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;
// Each NewGlobalRef is a distinct handle, even for the same Java Surface.
std::vector<std::unique_ptr<jobject>> reference_handles;
std::map<jobject, jobject> global_refs;
int fail_ref_after = 0;
std::vector<std::string> option_failures;
bool consume_osd_before_failure = false;
bool hold_wid = false;
bool wid_entered = false;
bool allow_wid = false;
bool surface_waiting = false;
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, surface_handoff };
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) {
for (jobject ref : {handle->video, handle->osd, handle->osd_option}) {
if (ref) require(global_refs.count(ref) == 1, "Surface option/consumer retained a deleted JNI reference");
}
}
jobject new_global_ref(jobject object) {
std::lock_guard<std::mutex> lock(gate);
if (!object) return nullptr;
if (fail_ref_after > 0 && --fail_ref_after == 0) return nullptr;
reference_handles.push_back(std::make_unique<jobject>(object));
jobject ref = reference_handles.back().get();
global_refs.emplace(ref, object);
return ref;
}
void delete_global_ref(jobject object) {
if (!object) return;
std::lock_guard<std::mutex> lock(gate);
require(global_refs.count(object) == 1, "JNI global reference released more than once");
if (active_handle &&
(active_handle->video == object || active_handle->osd == object || active_handle->osd_option == object)) {
require(active_handle->terminated, "Surface released while still referenced by MPV");
}
global_refs.erase(object);
}
int live_surface_refs() {
int count = 0;
for (const auto& entry : global_refs) {
if (entry.second != &app_context) ++count;
}
return count;
}
void require_pair(mpv_handle* handle, jobject video, jobject osd) {
require_surfaces(handle);
require(global_refs.at(handle->video) == video, "VO retained the wrong video Surface");
require((handle->osd ? global_refs.at(handle->osd) : nullptr) == osd, "VO retained the wrong OSD Surface");
require(handle->osd_option == handle->osd, "OSD option and active consumer disagree");
}
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); }
jint attach_surfaces(jlong session, jobject video, jobject osd) {
return jni_func_name(nativeAttachSurfaces)(&jni, nullptr, session, video, osd);
}
void reset_dependencies() {
std::lock_guard<std::mutex> lock(gate);
require(active_handle == nullptr, "previous test left a live MPV instance");
require(live_surface_refs() == 0, "terminal Surface cleanup leaked a global reference");
handles.clear();
callback_strings.clear();
global_refs.clear();
reference_handles.clear();
fail_ref_after = 0;
option_failures.clear();
consume_osd_before_failure = false;
hold_wid = false;
wid_entered = false;
allow_wid = false;
surface_waiting = false;
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;
require(attach_surfaces(old_session, &video, &osd) == 0, "initial surface handoff failed");
{
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);
require(
attach_surfaces(old_session, nullptr, nullptr) == MPV_ERROR_UNINITIALIZED,
"revoked surface handoff was admitted during termination");
{
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;
}
require(attach_surfaces(successor_session, &video, &osd) == 0, "successor surface handoff failed");
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);
require(
attach_surfaces(old_session, nullptr, nullptr) == MPV_ERROR_UNINITIALIZED,
"stale surface handoff reached successor");
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);
}
}
void paired_surface_replacements() {
reset_dependencies();
const jlong session = create_player();
initialize_player(session);
mpv_handle* handle = active_handle;
int video_a, video_b, osd_a, osd_b;
require(attach_surfaces(session, &video_a, &osd_a) == 0, "initial paired handoff failed");
require_pair(handle, &video_a, &osd_a);
require(attach_surfaces(session, &video_b, &osd_b) == 0, "paired replacement failed");
require_pair(handle, &video_b, &osd_b);
for (jobject next_osd : {static_cast<jobject>(&osd_a), static_cast<jobject>(nullptr), static_cast<jobject>(&osd_b)}) {
const int rebuilds = handle->rebuilds;
require(attach_surfaces(session, &video_b, next_osd) == 0, "OSD-only replacement failed");
require_pair(handle, &video_b, next_osd);
require(handle->rebuilds == rebuilds + 1, "OSD-only change did not rebuild the VO");
require(live_surface_refs() == (next_osd ? 2 : 1), "replacement leaked overwritten Surface references");
}
destroy_player(session);
}
void surface_handoff_failures() {
reset_dependencies();
const jlong session = create_player();
initialize_player(session);
mpv_handle* handle = active_handle;
int video_a, video_b, osd_a, osd_b;
require(attach_surfaces(session, &video_a, &osd_a) == 0, "initial paired handoff failed");
require(
attach_surfaces(session, nullptr, &osd_b) == MPV_ERROR_INVALID_PARAMETER,
"live session admitted a null video Surface");
for (int allocation : {1, 2}) {
fail_ref_after = allocation;
require(
attach_surfaces(session, &video_b, &osd_b) == MPV_ERROR_NOMEM, "global-reference allocation failure was lost");
require_pair(handle, &video_a, &osd_a);
require(live_surface_refs() == 2, "allocation failure leaked a staged Surface");
}
option_failures = {"vo-mediacodec-osd-surface"};
require(attach_surfaces(session, &video_b, &osd_b) == MPV_ERROR_GENERIC, "OSD option failure was lost");
require_pair(handle, &video_a, &osd_a);
require(live_surface_refs() == 2, "rejected OSD option leaked staged references");
option_failures = {"wid"};
require(attach_surfaces(session, &video_b, &osd_b) == MPV_ERROR_GENERIC, "wid option failure was lost");
require_pair(handle, &video_a, &osd_a);
require(attach_surfaces(session, &video_b, &osd_b) == 0, "handoff did not recover from wid failure");
require_pair(handle, &video_b, &osd_b);
require(live_surface_refs() == 2, "successful replacement retained failed handoff references");
// A VO may start between the option writes. Rollback changes only the OSD
// option, so the staged reference must survive even when rollback succeeds.
consume_osd_before_failure = true;
for (bool fail_rollback : {false, true}) {
option_failures = {"wid"};
if (fail_rollback) option_failures.push_back("vo-mediacodec-osd-surface");
require(attach_surfaces(session, &video_a, &osd_a) == MPV_ERROR_GENERIC, "failed handoff reported success");
require(option_failures.empty(), "OSD option rollback was not attempted");
require_surfaces(handle);
}
require(attach_surfaces(session, &video_a, nullptr) == 0, "handoff did not recover from rollback failure");
require_pair(handle, &video_a, nullptr);
require(live_surface_refs() == 1, "successful null-OSD replacement leaked failed handoff references");
option_failures = {"wid", "vo-mediacodec-osd-surface"};
require(attach_surfaces(session, &video_b, &osd_b) < 0, "terminal failed handoff reported success");
destroy_player(session);
require(live_surface_refs() == 0, "termination leaked pending failed-handoff references");
}
void overlapping_handoffs_and_teardown() {
reset_dependencies();
const jlong session = create_player();
initialize_player(session);
mpv_handle* handle = active_handle;
int video_a, video_b, video_c, osd_a, osd_b, osd_c;
require(attach_surfaces(session, &video_a, &osd_a) == 0, "initial paired handoff failed");
hold_wid = true;
jint first_result = MPV_ERROR_GENERIC, second_result = MPV_ERROR_GENERIC;
std::thread first([&] { first_result = attach_surfaces(session, &video_b, &osd_b); });
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [] { return wid_entered; }, "first handoff did not enter synchronous VO rebuild");
require_surfaces(handle);
require(live_surface_refs() == 4, "old Surface references did not survive the wid rebuild");
}
std::thread second([&] {
operation = Operation::surface_handoff;
second_result = attach_surfaces(session, &video_c, &osd_c);
});
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [] { return surface_waiting; }, "overlapping handoff bypassed surface serialization");
require(live_surface_refs() == 4, "waiting handoff mutated active Surface references");
}
std::thread retiring([&] {
operation = Operation::replacing_reader;
destroy_player(session);
});
{
std::unique_lock<std::mutex> lock(gate);
await(lock, [] { return reader_draining; }, "teardown did not drain admitted surface handoffs");
require(!handle->termination_entered, "teardown overtook an admitted surface handoff");
allow_wid = true;
changed.notify_all();
}
first.join();
second.join();
retiring.join();
require(first_result == 0 && second_result == 0, "admitted handoff lost its session during teardown");
require(handle->rebuilds == 3, "overlapping handoffs lost a complete VO rebuild");
require(live_surface_refs() == 0, "overlapping replacement/teardown leaked Surface references");
}
} // 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 && operation != Operation::surface_handoff) return pthread_mutex_lock(mutex);
const int result = pthread_mutex_trylock(mutex);
if (result != EBUSY) return result;
{
std::lock_guard<std::mutex> lock(gate);
if (operation == Operation::successor) successor_waiting = true;
if (operation == Operation::surface_handoff) surface_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");
require(live_surface_refs() == 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::unique_lock<std::mutex> lock(gate);
require_live(handle);
require_surfaces(handle);
require(format == MPV_FORMAT_INT64, "unexpected Surface option format");
jobject object = reinterpret_cast<jobject>(static_cast<intptr_t>(*static_cast<int64_t*>(data)));
require(!object || global_refs.count(object) == 1, "option received an invalid JNI reference");
const bool is_wid = std::strcmp(name, "wid") == 0;
if (is_wid && hold_wid) {
wid_entered = true;
changed.notify_all();
await(lock, [] { return allow_wid; }, "test did not release the synchronous wid rebuild");
require_surfaces(handle);
}
if (!option_failures.empty() && option_failures.front() == name) {
option_failures.erase(option_failures.begin());
if (is_wid && consume_osd_before_failure) handle->osd = handle->osd_option;
return MPV_ERROR_GENERIC;
}
if (is_wid) {
// mpv ignores equal option values; changing wid synchronously retires the
// consumers and rebuilds using the OSD option, not the former OSD plane.
if (handle->video != object) {
handle->video = object;
handle->osd = handle->osd_option;
++handle->rebuilds;
}
} else {
require(std::strcmp(name, "vo-mediacodec-osd-surface") == 0, "unexpected Surface option");
handle->osd_option = 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();
paired_surface_replacements();
surface_handoff_failures();
overlapping_handoffs_and_teardown();
reset_dependencies();
std::puts("MPV lifecycle: session isolation, paired handoffs, rollback ownership and overlapping teardown 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>
@@ -80,6 +80,91 @@ class GpuVoPolicyTest {
assertNull(GpuVoPolicy.targetFor(emptySet()))
}
@Test
fun `High 10 without a hardware profile is software-decoded up front`() {
assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = false, hardwareAv1 = true))
assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10 Intra", hardwareHigh10 = false, hardwareAv1 = true))
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_CODEC_SW_DECODE)))
}
@Test
fun `hardware supported and unrelated streams retain the configured decoder`() {
// A decoder that advertises the profile gets to try.
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = true, hardwareAv1 = false))
// 8-bit profiles, other codecs, and streams whose container carries no
// profile (Annex B transport streams) are not routed.
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High", hardwareHigh10 = false, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "Constrained Baseline", hardwareHigh10 = false, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("hevc", "Main 10", hardwareHigh10 = false, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", null, hardwareHigh10 = false, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "", hardwareHigh10 = false, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode(null, "High 10", hardwareHigh10 = false, hardwareAv1 = false))
}
@Test
fun `AV1 bypasses software MediaCodec even without a reported profile`() {
assertTrue(GpuVoPolicy.needsSoftwareDecode("av1", "Main", hardwareHigh10 = true, hardwareAv1 = false))
assertTrue(GpuVoPolicy.needsSoftwareDecode("av1", null, hardwareHigh10 = true, hardwareAv1 = false))
assertTrue(GpuVoPolicy.needsSoftwareDecode("av1", "", hardwareHigh10 = true, hardwareAv1 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("av1", "Main", hardwareHigh10 = false, hardwareAv1 = true))
}
// 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))
// The plane never scales in GL; a capable GPU keeps mpv's defaults.
assertFalse(GpuVoPolicy.needsCheapRenderTier(glVoActive = false, textureNorm16 = false))
assertFalse(GpuVoPolicy.needsCheapRenderTier(glVoActive = true, textureNorm16 = true))
}
@Test
fun `cheap tier replaces only options still at their mpv default`() {
for ((option, defaults) in GpuVoPolicy.MPV_DEFAULT_RENDER_OPTIONS) {
for (default in defaults) assertTrue(option, GpuVoPolicy.isDefaultRenderOption(option, default))
// A user's mpv.conf value, or an unreadable option, is left alone.
assertFalse(option, GpuVoPolicy.isDefaultRenderOption(option, "ewa_lanczos"))
assertFalse(option, GpuVoPolicy.isDefaultRenderOption(option, null))
}
assertEquals(GpuVoPolicy.CHEAP_RENDER_OPTIONS.keys, GpuVoPolicy.MPV_DEFAULT_RENDER_OPTIONS.keys)
// cscale's default is "inherit", which mpv 0.41 reads back as empty.
assertTrue(GpuVoPolicy.isDefaultRenderOption("cscale", ""))
assertFalse(GpuVoPolicy.isDefaultRenderOption("scale", ""))
}
@Test
fun `dv reshaping targets gpu-next even alongside other reasons`() {
assertEquals("gpu-next", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_DV_RESHAPE)))
@@ -0,0 +1,43 @@
package com.edde746.plezy.mpv
import com.edde746.plezy.libmpv.EndFileReason
import com.edde746.plezy.libmpv.LogLevel
import com.edde746.plezy.libmpv.LogMessage
import com.edde746.plezy.libmpv.MpvError
import com.edde746.plezy.libmpv.MpvEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class MpvEndFileDiagnosticsTest {
private val diagnostics = MpvEndFileDiagnostics()
@Test
fun `an AO failure ends the file with the audio-output cause and the last error line`() {
diagnostics.onStartFile()
diagnostics.onLogMessage(LogMessage("cplayer", LogLevel.Error, "Audio output stopped responding; stopping playback."))
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 7, MpvError.AoInitFailed))
assertEquals(
mapOf(
"sourceId" to 7L,
"reason" to EndFileReason.Error.id,
"message" to "Audio output stopped responding; stopping playback.",
"cause" to MpvEndFileDiagnostics.CAUSE_AUDIO_OUTPUT_FAILED
),
data
)
}
@Test
fun `other errors carry no cause so Dart keeps its generic handling`() {
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 7, MpvError.LoadingFailed))
assertNull(data?.get("cause"))
}
@Test
fun `a clean stop never reports a cause even after an earlier error line`() {
diagnostics.onLogMessage(LogMessage("ao/audiotrack", LogLevel.Error, "AudioTrack.write failed with -32"))
val data = diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Stop, 7))
assertEquals(mapOf("sourceId" to 7L, "reason" to EndFileReason.Stop.id), data)
}
}
@@ -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)
}
@@ -1040,6 +1094,39 @@ class MpvPlayerPluginTest {
assertEquals("mediacodec", lastVo())
}
@Test
fun hwdecWritesParkWhileAPerFileHoldIsActive() {
// While DV P5 reshaping or Hi10 routing holds hwdec at `no`, a session
// write of a hardware value must not reach mpv (it would re-enable the
// decoder that was just refused) but must be kept for the restore.
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value ->
writes.add(name to value)
})
setBoolean(core, "isInitialized", true)
setBoolean(core, "hwdecHeld", true)
var outcome: Result<Unit>? = null
core.setProperty("hwdec", "mediacodec,mediacodec-copy") { outcome = it }
awaitCondition { outcome != null }
assertTrue(outcome!!.isSuccess)
assertTrue(writes.isEmpty())
val parked = MpvPlayerCore::class.java.getDeclaredField("parkedHwdec").run {
isAccessible = true
@Suppress("UNCHECKED_CAST")
(get(core) as java.util.concurrent.atomic.AtomicReference<String?>).get()
}
assertEquals("mediacodec,mediacodec-copy", parked)
// Once the hold is gone, hwdec writes flow through again.
setBoolean(core, "hwdecHeld", false)
outcome = null
core.setProperty("hwdec", "no") { outcome = it }
awaitCondition { outcome != null }
assertEquals(listOf("hwdec" to "no"), writes.toList())
}
@Test
fun dvConversionModeMapsOntoForkDecoderOptions() {
// The app-level `dv-conversion-mode` property must translate to the fork
@@ -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 -8
View File
@@ -6,13 +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 onPropertyChanged(...);
public static void onEvent(...);
public static void onEndFile(...);
public static void onLogMessage(...);
public static void onHook(...);
}
+47 -18
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,28 +63,42 @@ 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) {
mpv_event_end_file* end_file = (mpv_event_end_file*)event->data;
const int reason = end_file ? end_file->reason : -1;
const int64_t source_id = end_file ? end_file->playlist_entry_id : 0;
// mpv_error code when reason is MPV_END_FILE_REASON_ERROR, 0 otherwise.
const int error = end_file ? end_file->error : 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, (jint)error);
}
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(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, (jlong)thread_session, jname, (jlong)hook->id);
if (jname) env->DeleteLocalRef(jname);
}
void* event_thread(void* arg) {
JNIEnv* env = NULL;
acquire_jni_env(g_vm, &env);
@@ -82,7 +112,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;
@@ -91,7 +121,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:
@@ -105,20 +134,20 @@ 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:
sendHookToJava(env, (mpv_event_hook*)mp_event->data);
break;
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 -8
View File
@@ -50,20 +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", "(JIJZI)V");
mpv_MpvPlayer_onLogMessage =
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(Ljava/lang/String;ILjava/lang/String;)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;
}
+1 -1
View File
@@ -27,4 +27,4 @@ UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init;
UTIL_EXTERN jclass mpv_MpvPlayer;
UTIL_EXTERN jmethodID mpv_MpvPlayer_onPropertyChanged_SJZ, mpv_MpvPlayer_onPropertyChanged_SZJZ,
mpv_MpvPlayer_onPropertyChanged_SJJZ, mpv_MpvPlayer_onPropertyChanged_SDJZ, mpv_MpvPlayer_onPropertyChanged_SSJZ,
mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage;
mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage, mpv_MpvPlayer_onHook;
-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);
+169 -65
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,19 +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(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");
@@ -49,78 +79,125 @@ 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);
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);
pthread_join(event_thread_id, NULL);
g_mpv = NULL;
mpv_terminate_destroy(leaked_mpv);
render_cleanup(env);
}
g_mpv = mpv_create();
if (!g_mpv) {
die("context init failed");
return;
}
mpv_request_log_messages(g_mpv, "v");
}
jni_func(void, nativeInit) {
if (!g_mpv) {
die("mpv is not created");
return;
}
if (mpv_initialize(g_mpv) < 0) {
die("mpv init failed");
return;
}
g_event_thread_request_exit = false;
if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) {
die("thread create failed");
return;
}
pthread_setname_np(event_thread_id, "event_thread");
}
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;
}
// 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_event_thread_request_exit = true;
mpv_wakeup(local_mpv);
pthread_join(event_thread_id, NULL);
g_mpv = NULL;
g_session = 0;
if (event_thread_started) g_event_thread_request_exit = true;
return local_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);
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(void, nativeCommand, jobjectArray jarray) {
CHECK_MPV_INIT();
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 0;
}
g_session = ++next_session;
mpv_request_log_messages(g_mpv, "warn");
return (jlong)g_session;
}
jni_func(jint, nativeInit, jlong session) {
LifecycleGuard lock;
SessionWriteGuard admission;
if (!g_mpv || g_session != (uint64_t)session) return MPV_ERROR_UNINITIALIZED;
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
// before mpv creates the decoder; file-loaded is already too late for the
// MediaCodec path. on_preloaded runs after the demuxer opened the file and
// holds playback until Kotlin continues it (MpvPlayer.onHook).
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 MPV_ERROR_GENERIC;
}
event_thread_started = true;
pthread_setname_np(event_thread_id, "event_thread");
return 0;
}
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();
}
destroy_locked(env, local_mpv);
}
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;
@@ -132,5 +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;
}
// 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);
}
+74 -54
View File
@@ -1,79 +1,99 @@
#include <jni.h>
#include <mpv/client.h>
#include <new>
#include <vector>
#include "globals.h"
#include "jni_utils.h"
#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(jint, nativeAttachSurfaces, jlong session, jobject surface_, jobject osd_surface_);
};
// Admission (S) precedes this mutex. Teardown drains S before cleanup and
// never takes this mutex, so a waiting handoff cannot outlive its mpv handle.
static pthread_mutex_t surface_lock = PTHREAD_MUTEX_INITIALIZER;
class SurfaceGuard {
public:
SurfaceGuard() { pthread_mutex_lock(&surface_lock); }
~SurfaceGuard() { pthread_mutex_unlock(&surface_lock); }
SurfaceGuard(const SurfaceGuard&) = delete;
SurfaceGuard& operator=(const SurfaceGuard&) = delete;
};
static jobject surface;
jni_func(void, nativeAttachSurface, jobject surface_) {
CHECK_MPV_INIT();
surface = env->NewGlobalRef(surface_);
if (!surface) {
die("invalid surface provided");
return;
}
int64_t wid = reinterpret_cast<intptr_t>(surface);
int result = mpv_set_option(g_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();
int64_t wid = 0;
int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid);
if (result < 0) ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result));
env->DeleteGlobalRef(surface);
surface = NULL;
}
static jobject osd_surface;
// A failed wid update can leave an OSD option/consumer using a staged ref.
// Even a successful option rollback cannot retire a consumer created between
// the two writes. Release these only after a successful wid rebuild or destroy.
static std::vector<jobject> pending_osd_surfaces;
// 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(jint, nativeAttachSurfaces, jlong session, jobject surface_, jobject osd_surface_) {
SessionGuard guard(session);
if (!guard.mpv) return MPV_ERROR_UNINITIALIZED;
if (!surface_) return MPV_ERROR_INVALID_PARAMETER;
SurfaceGuard lock;
osd_surface = env->NewGlobalRef(surface_);
if (!osd_surface) {
die("invalid osd surface provided");
return;
if (osd_surface_) {
try {
pending_osd_surfaces.reserve(pending_osd_surfaces.size() + 1);
} catch (const std::bad_alloc&) {
return MPV_ERROR_NOMEM;
}
}
int64_t wid = reinterpret_cast<intptr_t>(osd_surface);
int result = mpv_set_option(g_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;
int64_t wid = 0;
int result = mpv_set_option(g_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;
jobject next_surface = env->NewGlobalRef(surface_);
if (!next_surface) return MPV_ERROR_NOMEM;
jobject next_osd = osd_surface_ ? env->NewGlobalRef(osd_surface_) : nullptr;
if (osd_surface_ && !next_osd) {
env->DeleteGlobalRef(next_surface);
return MPV_ERROR_NOMEM;
}
int64_t osd_wid = reinterpret_cast<intptr_t>(next_osd);
int result = mpv_set_option(guard.mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &osd_wid);
if (result < 0) {
env->DeleteGlobalRef(next_surface);
if (next_osd) env->DeleteGlobalRef(next_osd);
return result;
}
if (next_osd) pending_osd_surfaces.push_back(next_osd);
// wid has UPDATE_VO: mpv_set_option synchronously tears down the old VO and
// decoder before rebuilding with both options. A fresh video global ref also
// changes wid when only the Java OSD Surface changed (equal values are ignored).
int64_t wid = reinterpret_cast<intptr_t>(next_surface);
result = mpv_set_option(guard.mpv, "wid", MPV_FORMAT_INT64, &wid);
if (result < 0) {
osd_wid = reinterpret_cast<intptr_t>(osd_surface);
const int rollback = mpv_set_option(guard.mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &osd_wid);
if (rollback < 0) ALOGE("OSD surface rollback failed: %s", mpv_error_string(rollback));
env->DeleteGlobalRef(next_surface);
return result;
}
if (next_osd) pending_osd_surfaces.pop_back();
if (surface) env->DeleteGlobalRef(surface);
if (osd_surface) env->DeleteGlobalRef(osd_surface);
for (jobject pending : pending_osd_surfaces) env->DeleteGlobalRef(pending);
pending_osd_surfaces.clear();
surface = next_surface;
osd_surface = next_osd;
return 0;
}
// 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);
surface = NULL;
surface = nullptr;
}
if (osd_surface) {
env->DeleteGlobalRef(osd_surface);
osd_surface = NULL;
osd_surface = nullptr;
}
for (jobject pending : pending_osd_surfaces) env->DeleteGlobalRef(pending);
pending_osd_surfaces.clear();
}
@@ -0,0 +1,18 @@
package com.edde746.plezy.libmpv
/** The `mpv_error` codes an end-file event can carry (client.h). */
enum class MpvError(val code: Int) {
LoadingFailed(-13),
AoInitFailed(-14),
VoInitFailed(-15),
NothingToPlay(-16),
UnknownFormat(-17),
Unsupported(-18),
NotImplemented(-19),
Generic(-20);
companion object {
/** Null for success (0) and for codes this enum does not model. */
fun fromCode(code: Int): MpvError? = entries.find { it.code == code }
}
}
@@ -6,7 +6,9 @@ sealed interface MpvEvent {
data class StartFile(override val sourceId: Long?) : MpvEvent
data class EndFile(
val reason: EndFileReason?,
override val sourceId: Long?
override val sourceId: Long?,
/** mpv_error code when [reason] is [EndFileReason.Error]; null otherwise. */
val error: MpvError? = null
) : MpvEvent
data class FileLoaded(override val sourceId: Long?) : MpvEvent
data class PlaybackRestart(
@@ -18,10 +18,26 @@ import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
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. */
private const val HOOK_TIMEOUT_MS = 3_000L
init {
System.loadLibrary("mpv")
System.loadLibrary("player")
@@ -39,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,
@@ -108,23 +136,34 @@ 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, error: Int) {
target(session)?.rawEvents?.trySend(
MpvEvent.EndFile(
EndFileReason.fromId(reason),
sourceId.takeIf { hasSourceId }
sourceId.takeIf { hasSourceId },
MpvError.fromCode(error)
)
)
}
@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(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. The
// native side drops this if the session has since been retired.
nativeHookContinue(session, id)
}
}
private fun checkNotMainThread(operation: String) {
@@ -133,45 +172,56 @@ 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 nativeSetOptionString(name: String, value: String): Int
@JvmStatic private external fun nativeSetLogLevel(session: Long, level: String): Int
@JvmStatic private external fun nativeAttachSurface(surface: Surface)
@JvmStatic private external fun nativeHookContinue(session: Long, id: Long)
@JvmStatic private external fun nativeDetachSurface()
@JvmStatic private external fun nativeSetOptionString(session: Long, name: String, value: String): Int
@JvmStatic private external fun nativeAttachOsdSurface(surface: Surface)
@JvmStatic private external fun nativeAttachSurfaces(session: Long, surface: Surface, osdSurface: Surface?): Int
@JvmStatic private external fun nativeDetachOsdSurface()
@JvmStatic private external fun nativeGetPropertyInt(session: Long, name: String): Int?
@JvmStatic private external fun nativeGetPropertyInt(name: String): Int?
@JvmStatic private external fun nativeGetPropertyDouble(session: Long, name: String): Double?
@JvmStatic private external fun nativeGetPropertyDouble(name: String): Double?
@JvmStatic private external fun nativeGetPropertyBoolean(session: Long, name: String): Boolean?
@JvmStatic private external fun nativeGetPropertyBoolean(name: String): Boolean?
@JvmStatic private external fun nativeGetPropertyString(session: Long, name: String): String?
@JvmStatic private external fun nativeGetPropertyString(name: String): String?
@JvmStatic private external fun nativeSetPropertyInt(session: Long, name: String, value: Int)
@JvmStatic private external fun nativeSetPropertyInt(name: String, value: Int)
@JvmStatic private external fun nativeSetPropertyDouble(session: Long, name: String, value: Double)
@JvmStatic private external fun nativeSetPropertyDouble(name: String, value: Double)
@JvmStatic private external fun nativeSetPropertyBoolean(session: Long, name: String, value: Boolean)
@JvmStatic private external fun nativeSetPropertyBoolean(name: String, value: Boolean)
@JvmStatic private external fun nativeSetPropertyString(session: Long, name: String, value: String)
@JvmStatic private external fun nativeSetPropertyString(name: String, value: String)
@JvmStatic private external fun nativeObserveProperty(session: Long, name: String, format: Int)
@JvmStatic private external fun nativeObserveProperty(name: String, format: Int)
internal fun setOptionString(session: Long, name: String, value: String): Int = nativeSetOptionString(session, name, value)
internal fun setOptionString(name: String, value: String): Int = nativeSetOptionString(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
@@ -182,6 +232,7 @@ class MpvPlayer private constructor() : AutoCloseable {
// buffer, which silently dropped whatever arrived during a burst; losing
// e.g. the one cplayer log line that signals a failed video chain.
private val rawEvents = Channel<MpvEvent>(Channel.UNLIMITED)
private val rawHooks = Channel<Hook>(Channel.UNLIMITED)
private val rawPropertyChanges = Channel<PropertyChange>(Channel.UNLIMITED)
private val rawLogMessages = Channel<LogMessage>(Channel.UNLIMITED)
@@ -191,8 +242,36 @@ class MpvPlayer private constructor() : AutoCloseable {
private val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private class Hook(val name: String, val id: Long)
/**
* Handler for mpv hooks the native side registered (`on_preloaded`). mpv
* holds playback until the handler returns; a handler that throws or
* overruns [HOOK_TIMEOUT_MS] is abandoned and playback continues. Set it
* before loading a file; unset, hooks continue immediately.
*/
@Volatile var hookHandler: (suspend (name: String) -> Unit)? = null
init {
pumpScope.launch { for (e in rawEvents) events.emit(e) }
pumpScope.launch {
for (hook in rawHooks) {
try {
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")
}
} catch (e: Exception) {
android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler failed; continuing playback", e)
} finally {
// 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)
}
}
}
pumpScope.launch { for (c in rawPropertyChanges) propertyChanges.emit(c) }
pumpScope.launch { for (m in rawLogMessages) logMessages.emit(m) }
}
@@ -203,89 +282,88 @@ 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
}
// Surface — not suspend, called from SurfaceHolder.Callback
fun attachSurface(surface: Surface) {
/** Called on the core's ordered IO writer, without suspending between writes. */
fun setLogLevel(level: String) {
checkNotClosed()
nativeAttachSurface(surface)
requestLogMessages(session, level)
}
fun detachSurface() {
/** Installs both planes and synchronously rebuilds the VO on the core's ordered IO writer. */
fun attachSurfaces(surface: Surface, osdSurface: Surface?) {
checkNotClosed()
nativeDetachSurface()
}
/** OSD/subtitle plane for `vo=mediacodec`; attach before selecting the VO. */
fun attachOsdSurface(surface: Surface) {
checkNotClosed()
nativeAttachOsdSurface(surface)
}
fun detachOsdSurface() {
checkNotClosed()
nativeDetachOsdSurface()
checkNotMainThread("MPV surface handoff")
val result = nativeAttachSurfaces(session, surface, osdSurface)
if (result < 0) throw MpvException("Failed to attach MPV surfaces: error $result")
}
// 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 }
@@ -294,7 +372,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 }
@@ -303,7 +381,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 }
@@ -312,7 +390,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 }
@@ -329,17 +407,20 @@ 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()
rawLogMessages.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 = dafa7762af20052031a7b512c9761a5d8bde327d;
revision = 6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -32,7 +32,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/mpv-build",
"state" : {
"revision" : "dafa7762af20052031a7b512c9761a5d8bde327d"
"revision" : "6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee"
}
},
{
@@ -32,7 +32,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/mpv-build",
"state" : {
"revision" : "dafa7762af20052031a7b512c9761a5d8bde327d"
"revision" : "6fcf0f29e21f693cf23f40aa95f772b6b08dc8ee"
}
},
{
+1 -1
View File
@@ -15,7 +15,7 @@ platform :ios do
sh("cd #{PROJECT_ROOT_ARG} && flutter build ipa --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=app-store --dart-define=SENTRY_DIST=app-store --split-debug-info=debug-info/ios")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store BUGS_APPLE_ARCHIVE=build/ios/archive/Runner.xcarchive ./scripts/upload-symbols.sh ios")
upload_to_app_store(
ipa: "../build/ios/ipa/Plezy.ipa",
+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) {
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Yan menyu genişlənmiş qalır və məzmun sahəsi buna uyğunlaşır",
"showUnwatchedCount": "Baxılmamış sayını göstər",
"showUnwatchedCountDescription": "Seriallarda və mövsümlərdə baxılmamış seriya sayını göstər",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Kartlarda seriya nömrəsini göstər",
"showEpisodeNumberOnCardsDescription": "Seriya kartlarında mövsüm və seriya nömrəsini göstər",
"showSeasonPostersOnTabs": "Mərhələlərdə mövsüm posterlərini göstər",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Bir seriya bitdikdə növbətisini avtomatik başlat",
"playNextCountdown": "Növbəti seriya geri sayımı",
"playNextCountdownImmediate": "Dərhal oynat",
"autoSkipIntro": "Girişi avtomatik ötür",
"autoSkipIntroDescription": "Bir neçə saniyədən sonra giriş işarələrini avtomatik ötür",
"autoSkipCredits": "Titrləri avtomatik ötür",
"autoSkipCreditsDescription": "Titrləri avtomatik ötür və növbəti seriyanı oynat",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Ehtiyat işarələri məcburi et",
"forceSkipMarkerFallbackDescription": "Plex işarələri olsa belə hissə başlığı şablonlarını istifadə et",
"autoSkipDelay": "Avtomatik ötürmə ləngiməsi",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "Server yanlış oynatma məlumatı qaytardı.",
"playbackCancelled": "Oynatma ləğv edildi.",
"playbackFailed": "Oynatma başladılarkən xəta.",
"audioOutputFailed": "",
"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",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Kitabxananızda yoxdur",
"inTheseLibraries": "Bu kitabxanalarda var",
"checkingLibrary": "Kitabxananız yoxlanılır...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Hələlik burada heç nə yoxdur",
"emptyMessage": "${source} mənbəsindən olan sətirlər burada görünəcək.",
"searchHint": "${source} daxilində axtar",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Страничната лента остава разгъната и зоната със съдържание се наглася да пасне",
"showUnwatchedCount": "Показвай броя негледани",
"showUnwatchedCountDescription": "Показвай броя негледани епизоди при сериали и сезони",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Показвай номера на епизода върху картите",
"showEpisodeNumberOnCardsDescription": "Показвай сезон и номер на епизод върху картите на епизодите",
"showSeasonPostersOnTabs": "Показвай постери на сезоните в табовете",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Пускай следващия епизод автоматично, когато текущият свърши",
"playNextCountdown": "Отброяване до следващия епизод",
"playNextCountdownImmediate": "Пусни веднага",
"autoSkipIntro": "Автоматично прескачане на интро",
"autoSkipIntroDescription": "Автоматично прескачай интро маркери след няколко секунди",
"autoSkipCredits": "Автоматично прескачане на финални надписи",
"autoSkipCreditsDescription": "Автоматично прескачай финалните надписи и пускай следващия епизод",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Принуди резервни маркери",
"forceSkipMarkerFallbackDescription": "Използвай шаблони в заглавията на главите дори когато Plex има маркери",
"autoSkipDelay": "Забавяне за автоматично прескачане",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "Сървърът върна невалидна информация за възпроизвеждането.",
"playbackCancelled": "Възпроизвеждането беше отменено.",
"playbackFailed": "Възпроизвеждането не можа да бъде стартирано.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
"errorLoadingSeries": "Грешка при зареждане на сериала",
"musicNotSupported": "Възпроизвеждането на музика все още не се поддържа",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Не е в твоята библиотека",
"inTheseLibraries": "В тези библиотеки",
"checkingLibrary": "Проверка на твоята библиотека...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Тук все още няма нищо",
"emptyMessage": "Редовете от ${source} ще се появят тук, когато има съдържание.",
"searchHint": "Търсене в ${source}",
@@ -1647,7 +1662,8 @@
"timedOut": "Релейният сървър не отговори навреме",
"connectionLost": "Връзката се затвори, преди сесията да е готова",
"invalidRelayResponse": "Релейният сървър изпрати неочакван отговор",
"sessionEnded": "Организаторът прекрати сесията"
"sessionEnded": "Организаторът прекрати сесията",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2103,7 +2119,9 @@
"noSessionCookie": "Seerr не издаде бисквитка за сесията",
"freshCookieRejected": "Seerr отхвърли новата бисквитка за сесията",
"noUserInformation": "Seerr не върна информация за потребителя",
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход"
"sessionRejectedAfterReauth": "Сесията беше отхвърлена след повторния вход",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Услуги",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Sidepanelet forbliver udvidet, og indholdsområdet tilpasser sig",
"showUnwatchedCount": "Vis antal usete",
"showUnwatchedCountDescription": "Vis antal usete episoder på serier og sæsoner",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Vis episodenummer på kort",
"showEpisodeNumberOnCardsDescription": "Vis sæson- og episodenummer på episodekort",
"showSeasonPostersOnTabs": "Vis sæsonplakater på faner",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Start automatisk næste afsnit, når et afsnit slutter",
"playNextCountdown": "Nedtælling til næste afsnit",
"playNextCountdownImmediate": "Afspil med det samme",
"autoSkipIntro": "Spring intro over automatisk",
"autoSkipIntroDescription": "Spring automatisk intromarkører over efter få sekunder",
"autoSkipCredits": "Spring rulletekster over automatisk",
"autoSkipCreditsDescription": "Spring automatisk rulleteksterne over, og afspil næste episode",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Tving reservemarkører",
"forceSkipMarkerFallbackDescription": "Brug mønstre i kapiteltitler, selv når Plex har markører",
"autoSkipDelay": "Forsinkelse ved automatisk spring",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "Serveren returnerede ugyldige afspilningsoplysninger.",
"playbackCancelled": "Afspilningen blev annulleret.",
"playbackFailed": "Afspilningen kunne ikke startes.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fejl ved indlæsning af filinfo: ${error}",
"errorLoadingSeries": "Fejl ved indlæsning af serie",
"musicNotSupported": "Musikafspilning understøttes endnu ikke",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Ikke i dit bibliotek",
"inTheseLibraries": "I disse biblioteker",
"checkingLibrary": "Tjekker dit bibliotek...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Der er ikke noget her endnu",
"emptyMessage": "Indholdsrækker fra ${source} vises her, når de har indhold.",
"searchHint": "Søg i ${source}",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an",
"showUnwatchedCount": "Anzahl nicht gesehener Folgen anzeigen",
"showUnwatchedCountDescription": "Zeigt die Anzahl nicht gesehener Episoden bei Serien und Staffeln an",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Episodennummer auf Karten anzeigen",
"showEpisodeNumberOnCardsDescription": "Staffel- und Episodennummer auf Episodenkarten anzeigen",
"showSeasonPostersOnTabs": "Staffelposter auf Tabs anzeigen",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Die nächste Episode automatisch starten, wenn die aktuelle endet",
"playNextCountdown": "Countdown bis zur nächsten Episode",
"playNextCountdownImmediate": "Sofort abspielen",
"autoSkipIntro": "Intro automatisch überspringen",
"autoSkipIntroDescription": "Intro-Marker nach wenigen Sekunden automatisch überspringen",
"autoSkipCredits": "Abspann automatisch überspringen",
"autoSkipCreditsDescription": "Abspann automatisch überspringen und nächste Episode abspielen",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Ersatzmarkierungen erzwingen",
"forceSkipMarkerFallbackDescription": "Kapitel-Titelmuster auch dann verwenden, wenn Plex über Markierungen verfügt",
"autoSkipDelay": "Verzögerung für automatisches Überspringen",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "Der Server hat ungültige Wiedergabeinformationen zurückgegeben.",
"playbackCancelled": "Die Wiedergabe wurde abgebrochen.",
"playbackFailed": "Die Wiedergabe konnte nicht gestartet werden.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
"errorLoadingSeries": "Fehler beim Laden der Serie",
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Nicht in deiner Mediathek",
"inTheseLibraries": "In diesen Mediatheken",
"checkingLibrary": "Deine Mediathek wird überprüft …",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Hier ist noch nichts",
"emptyMessage": "Zeilen aus ${source} erscheinen hier, sobald sie Inhalte enthalten.",
"searchHint": "${source} durchsuchen",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Sidebar stays expanded and content area adjusts to fit",
"showUnwatchedCount": "Show Unwatched Count",
"showUnwatchedCountDescription": "Display unwatched episode count on shows and seasons",
"showWatchedIndicators": "Show Watched Indicators",
"showWatchedIndicatorsDescription": "Display a checkmark on watched movies, shows, and episodes",
"showEpisodeNumberOnCards": "Show Episode Number on Cards",
"showEpisodeNumberOnCardsDescription": "Show season and episode number on episode cards",
"showSeasonPostersOnTabs": "Show Season Posters on Tabs",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Start the next episode automatically when one ends",
"playNextCountdown": "Play Next Countdown",
"playNextCountdownImmediate": "Play immediately",
"autoSkipIntro": "Auto Skip Intro",
"autoSkipIntroDescription": "Automatically skip intro markers after a few seconds",
"autoSkipCredits": "Auto Skip Credits",
"autoSkipCreditsDescription": "Automatically skip credits and play next episode",
"skipIntroMode": "Skip Intro",
"skipIntroModeOffDescription": "Play intros normally without a skip button",
"skipIntroModeButtonDescription": "Show a skip button when an intro starts",
"skipIntroModeAutoDescription": "Skip intros automatically after the delay below",
"skipCreditsMode": "Skip Credits",
"skipCreditsModeOffDescription": "Play credits normally without a skip button",
"skipCreditsModeButtonDescription": "Show a skip button when credits start",
"skipCreditsModeAutoDescription": "Skip credits automatically and play the next episode",
"skipMarkerModeOff": "Off",
"skipMarkerModeButton": "Show button",
"skipMarkerModeAuto": "Automatic",
"forceSkipMarkerFallback": "Force Fallback Markers",
"forceSkipMarkerFallbackDescription": "Use chapter title patterns even when Plex has markers",
"autoSkipDelay": "Auto Skip Delay",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "The server returned invalid playback information.",
"playbackCancelled": "Playback was canceled.",
"playbackFailed": "Playback could not be started.",
"audioOutputFailed": "The audio output stopped responding. Check the TV or receiver's audio connection; if other apps have no sound either, restart the device.",
"mediaUnavailable": "This content is no longer available.",
"errorLoadingFileInfo": "Error loading file info: ${error}",
"errorLoadingSeries": "Error loading series",
"musicNotSupported": "Music playback is not yet supported",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Not in your library",
"inTheseLibraries": "In these libraries",
"checkingLibrary": "Checking your library...",
"libraryCheckFailed": {
"one": "Couldn't check ${n} server",
"other": "Couldn't check ${n} servers"
},
"emptyTitle": "Nothing here yet",
"emptyMessage": "Rows from ${source} will appear here once they have content.",
"searchHint": "Search ${source}",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse",
"showUnwatchedCount": "Mostrar el número de elementos no vistos",
"showUnwatchedCountDescription": "Mostrar el número de episodios no vistos en series y temporadas",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Mostrar número de episodio en las tarjetas",
"showEpisodeNumberOnCardsDescription": "Mostrar temporada y episodio en tarjetas de episodio",
"showSeasonPostersOnTabs": "Mostrar pósters de temporada en las pestañas",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Iniciar automáticamente el siguiente episodio cuando termine el actual",
"playNextCountdown": "Cuenta atrás para el siguiente episodio",
"playNextCountdownImmediate": "Reproducir de inmediato",
"autoSkipIntro": "Saltar introducción automáticamente",
"autoSkipIntroDescription": "Saltar automáticamente los marcadores de introducción después de unos segundos",
"autoSkipCredits": "Saltar créditos automáticamente",
"autoSkipCreditsDescription": "Saltar automáticamente los créditos y reproducir el episodio siguiente",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Forzar marcadores alternativos",
"forceSkipMarkerFallbackDescription": "Usar patrones de títulos de capítulos aunque Plex tenga marcadores",
"autoSkipDelay": "Retraso del salto automático",
@@ -770,6 +779,8 @@
"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.",
"audioOutputFailed": "",
"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",
@@ -1254,6 +1265,10 @@
"notInLibrary": "No está en tu biblioteca",
"inTheseLibraries": "En estas bibliotecas",
"checkingLibrary": "Comprobando tu biblioteca...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Aquí no hay nada todavía",
"emptyMessage": "Las filas de ${source} aparecerán aquí cuando tengan contenido.",
"searchHint": "Buscar en ${source}",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "La barre latérale reste étendue et la zone de contenu s'adapte",
"showUnwatchedCount": "Afficher le nombre d’éléments non vus",
"showUnwatchedCountDescription": "Afficher le nombre d’épisodes non vus pour les séries et les saisons",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Afficher le numéro de l’épisode sur les cartes",
"showEpisodeNumberOnCardsDescription": "Afficher les numéros de saison et d’épisode sur les cartes d’épisode",
"showSeasonPostersOnTabs": "Afficher les affiches de saison sur les onglets",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Lancer automatiquement l'épisode suivant lorsqu'un épisode se termine",
"playNextCountdown": "Compte à rebours avant l'épisode suivant",
"playNextCountdownImmediate": "Lire immédiatement",
"autoSkipIntro": "Passer automatiquement lintroduction",
"autoSkipIntroDescription": "Passer automatiquement les marqueurs dintroduction après quelques secondes",
"autoSkipCredits": "Passer automatiquement le générique",
"autoSkipCreditsDescription": "Passer automatiquement le générique et lire l’épisode suivant",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Forcer les marqueurs de secours",
"forceSkipMarkerFallbackDescription": "Utiliser les motifs des titres de chapitre même lorsque Plex fournit des marqueurs",
"autoSkipDelay": "Délai avant le saut automatique",
@@ -770,6 +779,8 @@
"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.",
"audioOutputFailed": "",
"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",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Absent de votre bibliothèque",
"inTheseLibraries": "Dans ces bibliothèques",
"checkingLibrary": "Vérification de votre bibliothèque...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Rien ici pour l'instant",
"emptyMessage": "Les lignes de ${source} apparaîtront ici dès quelles contiendront des éléments.",
"searchHint": "Rechercher dans ${source}",
@@ -1647,7 +1662,8 @@
"timedOut": "Le serveur relais na pas répondu à temps",
"connectionLost": "La connexion sest fermée avant que la session ne soit prête",
"invalidRelayResponse": "Le serveur relais a renvoyé une réponse inattendue",
"sessionEnded": "Lhôte a mis fin à la session"
"sessionEnded": "Lhôte a mis fin à la session",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2103,7 +2119,9 @@
"noSessionCookie": "Seerr na pas fourni de cookie de session",
"freshCookieRejected": "Seerr a refusé le nouveau cookie de session",
"noUserInformation": "Seerr na renvoyé aucune information sur lutilisateur",
"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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Az oldalsáv kibontva marad, a tartalom területe igazodik hozzá",
"showUnwatchedCount": "Nem látott elemek számának megjelenítése",
"showUnwatchedCountDescription": "Megjeleníti a még nem látott epizódok számát a sorozatoknál és évadoknál",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Epizódszám megjelenítése a kártyákon",
"showEpisodeNumberOnCardsDescription": "Megjeleníti az évad- és epizódszámot az epizódkártyákon",
"showSeasonPostersOnTabs": "Évadborítók megjelenítése a füleken",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "A következő epizód automatikus elindítása, amikor az aktuális véget ér",
"playNextCountdown": "Visszaszámlálás a következő epizódig",
"playNextCountdownImmediate": "Azonnali lejátszás",
"autoSkipIntro": "Intró automatikus átugrása",
"autoSkipIntroDescription": "Az intrójelölők automatikus átugrása néhány másodperc után",
"autoSkipCredits": "Stáblista automatikus átugrása",
"autoSkipCreditsDescription": "A stáblista automatikus átugrása és a következő epizód lejátszása",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Tartalék jelölők kényszerítése",
"forceSkipMarkerFallbackDescription": "Fejezetcím-minták használata akkor is, ha a Plex rendelkezik jelölőkkel",
"autoSkipDelay": "Automatikus átugrás késleltetése",
@@ -770,6 +779,8 @@
"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.",
"audioOutputFailed": "",
"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",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Nincs a könyvtáradban",
"inTheseLibraries": "Ezekben a könyvtárakban",
"checkingLibrary": "Könyvtár ellenőrzése...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Még nincs itt semmi",
"emptyMessage": "A(z) ${source} forrásból származó sorok itt fognak megjelenni, amint van tartalmuk.",
"searchHint": "Keresés itt: ${source}",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "La barra laterale rimane espansa e l'area del contenuto si adatta",
"showUnwatchedCount": "Mostra il numero di episodi non visti",
"showUnwatchedCountDescription": "Mostra il numero di episodi non visti per serie e stagioni",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Mostra il numero dell'episodio sulle schede",
"showEpisodeNumberOnCardsDescription": "Mostra il numero della stagione e dell'episodio sulle schede degli episodi",
"showSeasonPostersOnTabs": "Mostra i poster delle stagioni nelle schede",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Avvia automaticamente l'episodio successivo quando termina quello in riproduzione",
"playNextCountdown": "Conto alla rovescia del successivo",
"playNextCountdownImmediate": "Riproduci subito",
"autoSkipIntro": "Salta automaticamente la sigla iniziale",
"autoSkipIntroDescription": "Salta automaticamente i marcatori della sigla iniziale dopo alcuni secondi",
"autoSkipCredits": "Salta automaticamente i titoli di coda",
"autoSkipCreditsDescription": "Salta automaticamente i titoli di coda e riproduce l'episodio successivo",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Forza i marcatori di ripiego",
"forceSkipMarkerFallbackDescription": "Usa i modelli dei titoli dei capitoli anche quando Plex dispone di marcatori",
"autoSkipDelay": "Ritardo del salto automatico",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "Il server ha restituito informazioni di riproduzione non valide.",
"playbackCancelled": "Riproduzione annullata.",
"playbackFailed": "Impossibile avviare la riproduzione.",
"audioOutputFailed": "",
"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",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Non è nella tua libreria",
"inTheseLibraries": "In queste librerie",
"checkingLibrary": "Ricerca nella tua libreria...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Ancora niente qui",
"emptyMessage": "Le sezioni di ${source} appariranno qui quando saranno disponibili dei contenuti.",
"searchHint": "Cerca su ${source}",
@@ -1647,7 +1662,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": "Lhost ha terminato la sessione"
"sessionEnded": "Lhost ha terminato la sessione",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2103,7 +2119,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",
+23 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "サイドバーを展開したままにし、コンテンツ領域を幅に合わせて調整します",
"showUnwatchedCount": "未視聴数を表示",
"showUnwatchedCountDescription": "番組とシーズンに未視聴エピソード数を表示",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "カードにエピソード番号を表示",
"showEpisodeNumberOnCardsDescription": "エピソードカードにシーズン番号とエピソード番号を表示します",
"showSeasonPostersOnTabs": "タブにシーズンポスターを表示",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "エピソードが終了すると、次のエピソードを自動的に再生します",
"playNextCountdown": "次回再生のカウントダウン",
"playNextCountdownImmediate": "すぐに再生",
"autoSkipIntro": "イントロを自動スキップ",
"autoSkipIntroDescription": "数秒後にイントロマーカーを自動的にスキップ",
"autoSkipCredits": "クレジットを自動スキップ",
"autoSkipCreditsDescription": "クレジットを自動的にスキップして次のエピソードを再生",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "フォールバックマーカーを強制",
"forceSkipMarkerFallbackDescription": "Plexにマーカーがある場合でもチャプタータイトルのパターンを使用します",
"autoSkipDelay": "自動スキップの遅延",
@@ -766,6 +775,8 @@
"playbackDataInvalid": "サーバーから無効な再生情報が返されました。",
"playbackCancelled": "再生がキャンセルされました。",
"playbackFailed": "再生を開始できませんでした。",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
"errorLoadingSeries": "シリーズの読み込みエラー",
"musicNotSupported": "音楽の再生はまだサポートされていません",
@@ -1248,6 +1259,9 @@
"notInLibrary": "ライブラリにありません",
"inTheseLibraries": "これらのライブラリにあります",
"checkingLibrary": "ライブラリを確認中…",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "まだ何もありません",
"emptyMessage": "${source}にコンテンツが追加されると、ここに表示されます。",
"searchHint": "${source}を検索",
@@ -1637,7 +1651,8 @@
"timedOut": "リレーサーバーが時間内に応答しませんでした",
"connectionLost": "セッションの準備が整う前に接続が閉じられました",
"invalidRelayResponse": "リレーサーバーから予期しない応答が返されました",
"sessionEnded": "ホストがセッションを終了しました"
"sessionEnded": "ホストがセッションを終了しました",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2093,7 +2108,9 @@
"noSessionCookie": "Seerrからセッションクッキーが発行されませんでした",
"freshCookieRejected": "Seerrが新しいセッションクッキーを拒否しました",
"noUserInformation": "Seerrからユーザー情報が返されませんでした",
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました"
"sessionRejectedAfterReauth": "再サインイン後にセッションが拒否されました",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "サービス",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Жүйелік мәзір ашық күйінде қалады",
"showUnwatchedCount": "Көрілмегендер санын көрсету",
"showUnwatchedCountDescription": "Сериалдар мен маусымдарда көрілмеген бөлімдер санын көрсету",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Карточкаларда бөлім нөмірін көрсету",
"showEpisodeNumberOnCardsDescription": "Бөлім карточкаларында маусым мен бөлім нөмірін көрсету",
"showSeasonPostersOnTabs": "Қойындыларда маусым постерлерін көрсету",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Бір бөлім аяқталғанда келесі бөлімді автоматты түрде бастау",
"playNextCountdown": "Келесіні ойнату санағы",
"playNextCountdownImmediate": "Дереу ойнату",
"autoSkipIntro": "Киріс бөлімді (Intro) автоматты өткізу",
"autoSkipIntroDescription": "Бірнеше секундтан кейін киріс белгілерін автоматты өткізу",
"autoSkipCredits": "Титрлерді автоматты өткізу",
"autoSkipCreditsDescription": "Титрлерді автоматты өткізіп, келесі бөлімді ойнату",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Қосалқы белгілерді мәжбүрлеу",
"forceSkipMarkerFallbackDescription": "Plex белгілері болса да бөлім тақырыбы үлгілерін пайдалану",
"autoSkipDelay": "Автоматты өткізу кідірісі",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "Сервер қате ойнату мәліметтерін қайтарды.",
"playbackCancelled": "Ойнатудан бас тартылды.",
"playbackFailed": "Ойнатуды іске қосу қатесі.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Файл ақпаратын жүктеу қатесі: ${error}",
"errorLoadingSeries": "Сериалды жүктеу қатесі",
"musicNotSupported": "Музыка ойнату әлі қолдау таппайды",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Кітапханаңызда жоқ",
"inTheseLibraries": "Осы кітапханаларда бар",
"checkingLibrary": "Кітапхана тексерілуде...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Әлі де мұнда ештеңе жоқ",
"emptyMessage": "${source} дереккөзінен алынған қатарлар мұнда көрінеді.",
"searchHint": "${source} ішінен іздеу",
@@ -1647,7 +1662,8 @@
"timedOut": "Реле сервері уақытында жауап бермеді",
"connectionLost": "Сеанс дайын болмай тұрып байланыс үзілді",
"invalidRelayResponse": "Реле сервері күтпеген жауап жіберді",
"sessionEnded": "Ұйымдастырушы сеансты аяқтады"
"sessionEnded": "Ұйымдастырушы сеансты аяқтады",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2103,7 +2119,9 @@
"noSessionCookie": "Seerr сеанс cookie файлын бермеді",
"freshCookieRejected": "Seerr жаңа сеанс cookie файлын қабылдамады",
"noUserInformation": "Seerr пайдаланушы туралы мәліметтерді қайтармады",
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады"
"sessionRejectedAfterReauth": "Қайта кіргеннен кейін сеанс қабылданбады",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "Қызметтер",
+23 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다",
"showUnwatchedCount": "미시청 수 표시",
"showUnwatchedCountDescription": "시리즈 및 시즌에 미시청 에피소드 수 표시",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "카드에 에피소드 번호 표시",
"showEpisodeNumberOnCardsDescription": "에피소드 카드에 시즌 및 에피소드 번호 표시",
"showSeasonPostersOnTabs": "탭에 시즌 포스터 표시",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "에피소드가 끝나면 다음 에피소드를 자동으로 재생",
"playNextCountdown": "다음 재생 카운트다운",
"playNextCountdownImmediate": "즉시 재생",
"autoSkipIntro": "자동으로 오프닝 건너뛰기",
"autoSkipIntroDescription": "몇 초 후 오프닝을 자동으로 건너뛰기",
"autoSkipCredits": "자동으로 엔딩 건너뛰기",
"autoSkipCreditsDescription": "엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "대체 마커 강제 사용",
"forceSkipMarkerFallbackDescription": "Plex에 마커가 있어도 챕터 제목 패턴 사용",
"autoSkipDelay": "자동 건너뛰기 지연",
@@ -766,6 +775,8 @@
"playbackDataInvalid": "서버에서 잘못된 재생 정보를 반환했습니다.",
"playbackCancelled": "재생이 취소되었습니다.",
"playbackFailed": "재생을 시작할 수 없습니다.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
"errorLoadingSeries": "시리즈 로딩 중 오류",
"musicNotSupported": "음악 재생 미지원",
@@ -1248,6 +1259,9 @@
"notInLibrary": "라이브러리에 없음",
"inTheseLibraries": "이 라이브러리에 있음",
"checkingLibrary": "라이브러리 확인 중...",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "아직 아무것도 없습니다",
"emptyMessage": "${source}에 콘텐츠가 추가되면 여기에 표시됩니다.",
"searchHint": "${source}에서 검색",
@@ -1637,7 +1651,8 @@
"timedOut": "릴레이 서버가 제시간에 응답하지 않았습니다",
"connectionLost": "세션이 준비되기 전에 연결이 종료되었습니다",
"invalidRelayResponse": "릴레이 서버가 예기치 않은 응답을 보냈습니다",
"sessionEnded": "호스트가 세션을 종료했습니다"
"sessionEnded": "호스트가 세션을 종료했습니다",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2093,7 +2108,9 @@
"noSessionCookie": "Seerr에서 세션 쿠키를 발급하지 않았습니다",
"freshCookieRejected": "Seerr에서 새 세션 쿠키를 거부했습니다",
"noUserInformation": "Seerr에서 사용자 정보를 반환하지 않았습니다",
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다"
"sessionRejectedAfterReauth": "다시 로그인한 후 세션이 거부되었습니다",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "서비스",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Sidefeltet forblir utvidet og innholdsområdet tilpasser seg",
"showUnwatchedCount": "Vis antall usette",
"showUnwatchedCountDescription": "Vis antall usette episoder på serier og sesonger",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Vis episodenummer på kort",
"showEpisodeNumberOnCardsDescription": "Vis sesong- og episodenummer på episodekort",
"showSeasonPostersOnTabs": "Vis sesongplakater på faner",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Start neste episode automatisk når en episode er ferdig",
"playNextCountdown": "Nedtelling for neste episode",
"playNextCountdownImmediate": "Spill umiddelbart",
"autoSkipIntro": "Hopp over intro automatisk",
"autoSkipIntroDescription": "Hopp automatisk over intromarkører etter noen sekunder",
"autoSkipCredits": "Hopp over rulletekst automatisk",
"autoSkipCreditsDescription": "Hopp automatisk over rulletekst og spill neste episode",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Tving reservemarkører",
"forceSkipMarkerFallbackDescription": "Bruk mønstre i kapiteltitler selv når Plex har markører",
"autoSkipDelay": "Forsinkelse for automatisk hopp",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "Serveren returnerte ugyldig avspillingsinformasjon.",
"playbackCancelled": "Avspillingen ble avbrutt.",
"playbackFailed": "Kunne ikke starte avspillingen.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Feil ved lasting av filinformasjon: ${error}",
"errorLoadingSeries": "Feil ved lasting av serie",
"musicNotSupported": "Musikkavspilling støttes ikke ennå",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Ikke i biblioteket ditt",
"inTheseLibraries": "I disse bibliotekene",
"checkingLibrary": "Sjekker biblioteket ditt...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Ingenting her ennå",
"emptyMessage": "Rader fra ${source} vises her når de har innhold.",
"searchHint": "Søk i ${source}",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan",
"showUnwatchedCount": "Aantal ongekeken tonen",
"showUnwatchedCountDescription": "Toon aantal ongekeken afleveringen bij series en seizoenen",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Afleveringsnummer op kaarten tonen",
"showEpisodeNumberOnCardsDescription": "Toon seizoen- en afleveringsnummer op afleveringskaarten",
"showSeasonPostersOnTabs": "Toon seizoensposters op tabbladen",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Start de volgende aflevering automatisch wanneer een aflevering eindigt",
"playNextCountdown": "Aftellen voor volgende aflevering",
"playNextCountdownImmediate": "Direct afspelen",
"autoSkipIntro": "Intro automatisch overslaan",
"autoSkipIntroDescription": "Intromarkeringen na enkele seconden automatisch overslaan",
"autoSkipCredits": "Aftiteling automatisch overslaan",
"autoSkipCreditsDescription": "Aftiteling automatisch overslaan en de volgende aflevering afspelen",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Reservemarkeringen afdwingen",
"forceSkipMarkerFallbackDescription": "Gebruik patronen in hoofdstuktitels, zelfs wanneer Plex markeringen heeft",
"autoSkipDelay": "Vertraging voor automatisch overslaan",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "De server heeft ongeldige afspeelinformatie geretourneerd.",
"playbackCancelled": "Het afspelen is geannuleerd.",
"playbackFailed": "Het afspelen kon niet worden gestart.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fout bij laden van bestandsinformatie: ${error}",
"errorLoadingSeries": "Fout bij laden van serie",
"musicNotSupported": "Muziek afspelen wordt nog niet ondersteund",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Niet in je bibliotheek",
"inTheseLibraries": "In deze bibliotheken",
"checkingLibrary": "Je bibliotheek controleren...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Hier is nog niets",
"emptyMessage": "Rijen van ${source} verschijnen hier zodra ze inhoud hebben.",
"searchHint": "Zoeken in ${source}",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+26 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Panel boczny jest rozwinięty, a obszar treści dostosowuje się",
"showUnwatchedCount": "Pokaż liczbę nieobejrzanych",
"showUnwatchedCountDescription": "Wyświetl liczbę nieobejrzanych odcinków w serialach i sezonach",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Pokaż numer odcinka na kartach",
"showEpisodeNumberOnCardsDescription": "Pokazuj numer sezonu i odcinka na kartach odcinków",
"showSeasonPostersOnTabs": "Pokaż plakaty sezonów na zakładkach",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Automatycznie uruchamiaj następny odcinek, gdy bieżący się skończy",
"playNextCountdown": "Odliczanie do następnego odcinka",
"playNextCountdownImmediate": "Odtwarzaj natychmiast",
"autoSkipIntro": "Automatyczne pomijanie intro",
"autoSkipIntroDescription": "Automatycznie pomijaj znaczniki intro po kilku sekundach",
"autoSkipCredits": "Automatyczne pomijanie napisów końcowych",
"autoSkipCreditsDescription": "Automatycznie pomijaj napisy końcowe i odtwórz następny odcinek",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Wymuś znaczniki awaryjne",
"forceSkipMarkerFallbackDescription": "Używaj wzorców tytułów rozdziałów, nawet gdy Plex ma znaczniki",
"autoSkipDelay": "Opóźnienie automatycznego pomijania",
@@ -778,6 +787,8 @@
"playbackDataInvalid": "Serwer zwrócił nieprawidłowe informacje o odtwarzaniu.",
"playbackCancelled": "Odtwarzanie zostało anulowane.",
"playbackFailed": "Nie udało się rozpocząć odtwarzania.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Błąd ładowania informacji o pliku: ${error}",
"errorLoadingSeries": "Błąd ładowania serialu",
"musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane",
@@ -1266,6 +1277,12 @@
"notInLibrary": "Nie ma tego w Twojej bibliotece",
"inTheseLibraries": "W tych bibliotekach",
"checkingLibrary": "Sprawdzanie Twojej biblioteki...",
"libraryCheckFailed": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"emptyTitle": "Jeszcze nic tu nie ma",
"emptyMessage": "Wiersze z ${source} pojawią się tutaj, gdy będą zawierać treści.",
"searchHint": "Szukaj w ${source}",
@@ -1667,7 +1684,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": {
@@ -2123,7 +2141,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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "A barra lateral fica expandida e a área de conteúdo se ajusta",
"showUnwatchedCount": "Mostrar Contagem de Não Assistidos",
"showUnwatchedCountDescription": "Exibir contagem de episódios não assistidos em séries e temporadas",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Mostrar Número do Episódio nos Cards",
"showEpisodeNumberOnCardsDescription": "Mostrar temporada e episódio nos cartões de episódio",
"showSeasonPostersOnTabs": "Mostrar Pôsteres de Temporada nas Abas",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Iniciar o próximo episódio automaticamente quando um terminar",
"playNextCountdown": "Contagem regressiva do próximo episódio",
"playNextCountdownImmediate": "Reproduzir imediatamente",
"autoSkipIntro": "Pular introdução automaticamente",
"autoSkipIntroDescription": "Pular marcadores de introdução automaticamente após alguns segundos",
"autoSkipCredits": "Pular créditos automaticamente",
"autoSkipCreditsDescription": "Pular os créditos automaticamente e reproduzir o próximo episódio",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Forçar marcadores alternativos",
"forceSkipMarkerFallbackDescription": "Usar padrões de títulos de capítulos mesmo quando o Plex tiver marcadores",
"autoSkipDelay": "Atraso para pular automaticamente",
@@ -770,6 +779,8 @@
"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.",
"audioOutputFailed": "",
"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",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Não está na sua biblioteca",
"inTheseLibraries": "Nestas bibliotecas",
"checkingLibrary": "Verificando sua biblioteca...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Ainda não há nada aqui",
"emptyMessage": "As linhas de ${source} aparecerão aqui quando tiverem conteúdo.",
"searchHint": "Buscar em ${source}",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+26 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Боковая панель остаётся развёрнутой, область контента подстраивается",
"showUnwatchedCount": "Показывать количество непросмотренных",
"showUnwatchedCountDescription": "Отображать количество непросмотренных эпизодов для сериалов и сезонов",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Показывать номер эпизода на карточках",
"showEpisodeNumberOnCardsDescription": "Показывать номер сезона и серии на карточках серий",
"showSeasonPostersOnTabs": "Показывать постеры сезонов на вкладках",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Автоматически запускать следующий эпизод, когда заканчивается текущий",
"playNextCountdown": "Обратный отсчёт перед следующим",
"playNextCountdownImmediate": "Воспроизводить сразу",
"autoSkipIntro": "Автопропуск вступления",
"autoSkipIntroDescription": "Автоматически пропускать маркеры вступления через несколько секунд",
"autoSkipCredits": "Автопропуск титров",
"autoSkipCreditsDescription": "Автоматически пропускать титры и воспроизводить следующий эпизод",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Всегда использовать резервные маркеры",
"forceSkipMarkerFallbackDescription": "Использовать шаблоны названий глав, даже если в Plex есть собственные маркеры",
"autoSkipDelay": "Задержка автопропуска",
@@ -778,6 +787,8 @@
"playbackDataInvalid": "Сервер вернул некорректные сведения для воспроизведения.",
"playbackCancelled": "Воспроизведение отменено.",
"playbackFailed": "Не удалось начать воспроизведение.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}",
"errorLoadingSeries": "Ошибка загрузки сериала",
"musicNotSupported": "Воспроизведение музыки пока не поддерживается",
@@ -1266,6 +1277,12 @@
"notInLibrary": "Нет в вашей библиотеке",
"inTheseLibraries": "В этих библиотеках",
"checkingLibrary": "Проверка вашей библиотеки...",
"libraryCheckFailed": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"emptyTitle": "Здесь пока ничего нет",
"emptyMessage": "Разделы из ${source} появятся здесь, когда в них появится контент.",
"searchHint": "Поиск в ${source}",
@@ -1667,7 +1684,8 @@
"timedOut": "Сервер ретрансляции не ответил вовремя",
"connectionLost": "Соединение закрылось до того, как сессия была готова",
"invalidRelayResponse": "Сервер ретрансляции прислал неожиданный ответ",
"sessionEnded": "Организатор завершил сессию"
"sessionEnded": "Организатор завершил сессию",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2123,7 +2141,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: 43756 (1988 per locale)
/// Strings: 43688 (1985 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$az extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Bir seriya bitdikdə növbətisini avtomatik başlat';
@override String get playNextCountdown => 'Növbəti seriya geri sayımı';
@override String get playNextCountdownImmediate => 'Dərhal oynat';
@override String get autoSkipIntro => 'Girişi avtomatik ötür';
@override String get autoSkipIntroDescription => 'Bir neçə saniyədən sonra giriş işarələrini avtomatik ötür';
@override String get autoSkipCredits => 'Titrləri avtomatik ötür';
@override String get autoSkipCreditsDescription => 'Titrləri avtomatik ötür və növbəti seriyanı oynat';
@override String get forceSkipMarkerFallback => 'Ehtiyat işarələri məcburi et';
@override String get forceSkipMarkerFallbackDescription => 'Plex işarələri olsa belə hissə başlığı şablonlarını istifadə et';
@override String get autoSkipDelay => 'Avtomatik ötürmə ləngiməsi';
@@ -3194,10 +3190,6 @@ extension on TranslationsAz {
'settings.autoPlayNextEpisodeDescription' => 'Bir seriya bitdikdə növbətisini avtomatik başlat',
'settings.playNextCountdown' => 'Növbəti seriya geri sayımı',
'settings.playNextCountdownImmediate' => 'Dərhal oynat',
'settings.autoSkipIntro' => 'Girişi avtomatik ötür',
'settings.autoSkipIntroDescription' => 'Bir neçə saniyədən sonra giriş işarələrini avtomatik ötür',
'settings.autoSkipCredits' => 'Titrləri avtomatik ötür',
'settings.autoSkipCreditsDescription' => 'Titrləri avtomatik ötür və növbəti seriyanı oynat',
'settings.forceSkipMarkerFallback' => 'Ehtiyat işarələri məcburi et',
'settings.forceSkipMarkerFallbackDescription' => 'Plex işarələri olsa belə hissə başlığı şablonlarını istifadə et',
'settings.autoSkipDelay' => 'Avtomatik ötürmə ləngiməsi',
@@ -3449,12 +3441,12 @@ extension on TranslationsAz {
'fileInfo.has64bitOffsets' => '64-bit ofsetlər',
'fileInfo.protocol' => 'Protokol',
'fileInfo.mediaType' => 'Media növü',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Mənbə növü',
'fileInfo.optimizedVersion' => 'Optimallaşdırılmış versiya',
'fileInfo.optimizationTarget' => 'Optimallaşdırma hədəfi',
'fileInfo.deletedAt' => 'Silinib',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Uzaq mənbə',
'fileInfo.infiniteStream' => 'Sonsuz axın',
'fileInfo.directPlay' => 'Birbaşa oynatma',
@@ -3963,12 +3955,12 @@ extension on TranslationsAz {
'libraries.filterCategories.contentRating' => 'Məzmun reytinqi',
'libraries.filterCategories.tag' => 'Teq',
'libraries.filterCategories.unwatched' => 'Baxılmayıb',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Oynadılmayıb',
'libraries.filterCategories.favorites' => 'Sevimlilər',
'libraries.sortLabels.title' => 'Ad',
'libraries.sortLabels.dateAdded' => 'Əlavə olunma tarixi',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Buraxılış tarixi',
'libraries.sortLabels.rating' => 'Reytinq',
'libraries.sortLabels.communityRating' => 'İcma reytinqi',
@@ -4477,12 +4469,12 @@ extension on TranslationsAz {
'downloads.syncRuleCleanupUnavailable' => 'Əlaqəli yükləmələr təhlükəsiz şəkildə müəyyən edilə bilmədi. Serverə yenidən qoşulub cəhd edin və ya qaydanı yükləmələri silmədən silin.',
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title} üçün ${count} yeni seriya eyniləşdirildi',
'downloads.activeSyncRules' => 'Eyniləşdirmə qaydaları',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Eyniləşdirmə qaydası yoxdur',
'downloads.manageSyncRule' => 'Eyniləşdirməni idarə et',
'downloads.editEpisodeCount' => 'Seriya sayı',
'downloads.editSyncFilter' => 'Eyniləşdirmə filtri',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Bütün elementlər eyniləşdirilir',
'downloads.syncUnwatchedItems' => 'Baxılmayan elementlər eyniləşdirilir',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$bg extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Пускай следващия епизод автоматично, когато текущият свърши';
@override String get playNextCountdown => 'Отброяване до следващия епизод';
@override String get playNextCountdownImmediate => 'Пусни веднага';
@override String get autoSkipIntro => 'Автоматично прескачане на интро';
@override String get autoSkipIntroDescription => 'Автоматично прескачай интро маркери след няколко секунди';
@override String get autoSkipCredits => 'Автоматично прескачане на финални надписи';
@override String get autoSkipCreditsDescription => 'Автоматично прескачай финалните надписи и пускай следващия епизод';
@override String get forceSkipMarkerFallback => 'Принуди резервни маркери';
@override String get forceSkipMarkerFallbackDescription => 'Използвай шаблони в заглавията на главите дори когато Plex има маркери';
@override String get autoSkipDelay => 'Забавяне за автоматично прескачане';
@@ -3194,10 +3190,6 @@ extension on TranslationsBg {
'settings.autoPlayNextEpisodeDescription' => 'Пускай следващия епизод автоматично, когато текущият свърши',
'settings.playNextCountdown' => 'Отброяване до следващия епизод',
'settings.playNextCountdownImmediate' => 'Пусни веднага',
'settings.autoSkipIntro' => 'Автоматично прескачане на интро',
'settings.autoSkipIntroDescription' => 'Автоматично прескачай интро маркери след няколко секунди',
'settings.autoSkipCredits' => 'Автоматично прескачане на финални надписи',
'settings.autoSkipCreditsDescription' => 'Автоматично прескачай финалните надписи и пускай следващия епизод',
'settings.forceSkipMarkerFallback' => 'Принуди резервни маркери',
'settings.forceSkipMarkerFallbackDescription' => 'Използвай шаблони в заглавията на главите дори когато Plex има маркери',
'settings.autoSkipDelay' => 'Забавяне за автоматично прескачане',
@@ -3449,12 +3441,12 @@ extension on TranslationsBg {
'fileInfo.has64bitOffsets' => '64-битови отмествания',
'fileInfo.protocol' => 'Протокол',
'fileInfo.mediaType' => 'Тип медия',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Вид източник',
'fileInfo.optimizedVersion' => 'Оптимизирана версия',
'fileInfo.optimizationTarget' => 'Цел на оптимизацията',
'fileInfo.deletedAt' => 'Изтрит',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Отдалечен източник',
'fileInfo.infiniteStream' => 'Безкраен поток',
'fileInfo.directPlay' => 'Директно възпроизвеждане',
@@ -3963,12 +3955,12 @@ extension on TranslationsBg {
'libraries.filterCategories.contentRating' => 'Възрастов рейтинг',
'libraries.filterCategories.tag' => 'Таг',
'libraries.filterCategories.unwatched' => 'Негледани',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Непускани',
'libraries.filterCategories.favorites' => 'Любими',
'libraries.sortLabels.title' => 'Заглавие',
'libraries.sortLabels.dateAdded' => 'Дата на добавяне',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Дата на излизане',
'libraries.sortLabels.rating' => 'Рейтинг',
'libraries.sortLabels.communityRating' => 'Оценка от общността',
@@ -4477,12 +4469,12 @@ extension on TranslationsBg {
'downloads.syncRuleCleanupUnavailable' => 'Свързаните изтегляния не можаха да бъдат идентифицирани безопасно. Свържете се отново със сървъра и опитайте отново, или премахнете правилото, без да изтривате изтеглянията.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Синхронизирани са ${count} нови епизода за ${title}',
'downloads.activeSyncRules' => 'Правила за синхронизация',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Няма правила за синхронизация',
'downloads.manageSyncRule' => 'Управление на синхронизацията',
'downloads.editEpisodeCount' => 'Брой епизоди',
'downloads.editSyncFilter' => 'Филтър за синхронизация',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Синхронизират се всички елементи',
'downloads.syncUnwatchedItems' => 'Синхронизират се негледаните елементи',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Сървър: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$da extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Start automatisk næste afsnit, når et afsnit slutter';
@override String get playNextCountdown => 'Nedtælling til næste afsnit';
@override String get playNextCountdownImmediate => 'Afspil med det samme';
@override String get autoSkipIntro => 'Spring intro over automatisk';
@override String get autoSkipIntroDescription => 'Spring automatisk intromarkører over efter få sekunder';
@override String get autoSkipCredits => 'Spring rulletekster over automatisk';
@override String get autoSkipCreditsDescription => 'Spring automatisk rulleteksterne over, og afspil næste episode';
@override String get forceSkipMarkerFallback => 'Tving reservemarkører';
@override String get forceSkipMarkerFallbackDescription => 'Brug mønstre i kapiteltitler, selv når Plex har markører';
@override String get autoSkipDelay => 'Forsinkelse ved automatisk spring';
@@ -3194,10 +3190,6 @@ extension on TranslationsDa {
'settings.autoPlayNextEpisodeDescription' => 'Start automatisk næste afsnit, når et afsnit slutter',
'settings.playNextCountdown' => 'Nedtælling til næste afsnit',
'settings.playNextCountdownImmediate' => 'Afspil med det samme',
'settings.autoSkipIntro' => 'Spring intro over automatisk',
'settings.autoSkipIntroDescription' => 'Spring automatisk intromarkører over efter få sekunder',
'settings.autoSkipCredits' => 'Spring rulletekster over automatisk',
'settings.autoSkipCreditsDescription' => 'Spring automatisk rulleteksterne over, og afspil næste episode',
'settings.forceSkipMarkerFallback' => 'Tving reservemarkører',
'settings.forceSkipMarkerFallbackDescription' => 'Brug mønstre i kapiteltitler, selv når Plex har markører',
'settings.autoSkipDelay' => 'Forsinkelse ved automatisk spring',
@@ -3449,12 +3441,12 @@ extension on TranslationsDa {
'fileInfo.has64bitOffsets' => '64-bit-forskydninger',
'fileInfo.protocol' => 'Protokol',
'fileInfo.mediaType' => 'Medietype',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Kildetype',
'fileInfo.optimizedVersion' => 'Optimeret version',
'fileInfo.optimizationTarget' => 'Optimeringsmål',
'fileInfo.deletedAt' => 'Slettet',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Fjernkilde',
'fileInfo.infiniteStream' => 'Uendelig strøm',
'fileInfo.directPlay' => 'Direkte afspilning',
@@ -3963,12 +3955,12 @@ extension on TranslationsDa {
'libraries.filterCategories.contentRating' => 'Aldersvurdering',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Usete',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Ikke afspillet',
'libraries.filterCategories.favorites' => 'Favoritter',
'libraries.sortLabels.title' => 'Titel',
'libraries.sortLabels.dateAdded' => 'Tilføjet dato',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Udgivelsesdato',
'libraries.sortLabels.rating' => 'Vurdering',
'libraries.sortLabels.communityRating' => 'Fællesskabsvurdering',
@@ -4477,12 +4469,12 @@ extension on TranslationsDa {
'downloads.syncRuleCleanupUnavailable' => 'Tilknyttede downloads kunne ikke identificeres sikkert. Genopret forbindelse til serveren og prøv igen, eller fjern reglen uden at slette downloads.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synkroniserede ${count} nye episoder for ${title}',
'downloads.activeSyncRules' => 'Synkroniseringsregler',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Ingen synkroniseringsregler',
'downloads.manageSyncRule' => 'Administrer synkronisering',
'downloads.editEpisodeCount' => 'Antal episoder',
'downloads.editSyncFilter' => 'Synkroniseringsfilter',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Synkroniserer alle elementer',
'downloads.syncUnwatchedItems' => 'Synkroniserer usete elementer',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$de extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Die nächste Episode automatisch starten, wenn die aktuelle endet';
@override String get playNextCountdown => 'Countdown bis zur nächsten Episode';
@override String get playNextCountdownImmediate => 'Sofort abspielen';
@override String get autoSkipIntro => 'Intro automatisch überspringen';
@override String get autoSkipIntroDescription => 'Intro-Marker nach wenigen Sekunden automatisch überspringen';
@override String get autoSkipCredits => 'Abspann automatisch überspringen';
@override String get autoSkipCreditsDescription => 'Abspann automatisch überspringen und nächste Episode abspielen';
@override String get forceSkipMarkerFallback => 'Ersatzmarkierungen erzwingen';
@override String get forceSkipMarkerFallbackDescription => 'Kapitel-Titelmuster auch dann verwenden, wenn Plex über Markierungen verfügt';
@override String get autoSkipDelay => 'Verzögerung für automatisches Überspringen';
@@ -3194,10 +3190,6 @@ extension on TranslationsDe {
'settings.autoPlayNextEpisodeDescription' => 'Die nächste Episode automatisch starten, wenn die aktuelle endet',
'settings.playNextCountdown' => 'Countdown bis zur nächsten Episode',
'settings.playNextCountdownImmediate' => 'Sofort abspielen',
'settings.autoSkipIntro' => 'Intro automatisch überspringen',
'settings.autoSkipIntroDescription' => 'Intro-Marker nach wenigen Sekunden automatisch überspringen',
'settings.autoSkipCredits' => 'Abspann automatisch überspringen',
'settings.autoSkipCreditsDescription' => 'Abspann automatisch überspringen und nächste Episode abspielen',
'settings.forceSkipMarkerFallback' => 'Ersatzmarkierungen erzwingen',
'settings.forceSkipMarkerFallbackDescription' => 'Kapitel-Titelmuster auch dann verwenden, wenn Plex über Markierungen verfügt',
'settings.autoSkipDelay' => 'Verzögerung für automatisches Überspringen',
@@ -3449,12 +3441,12 @@ extension on TranslationsDe {
'fileInfo.has64bitOffsets' => '64-Bit-Offsets',
'fileInfo.protocol' => 'Protokoll',
'fileInfo.mediaType' => 'Medientyp',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Quellenart',
'fileInfo.optimizedVersion' => 'Optimierte Version',
'fileInfo.optimizationTarget' => 'Optimierungsziel',
'fileInfo.deletedAt' => 'Gelöscht',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Remote-Quelle',
'fileInfo.infiniteStream' => 'Endlos-Stream',
'fileInfo.directPlay' => 'Direct Play',
@@ -3963,12 +3955,12 @@ extension on TranslationsDe {
'libraries.filterCategories.contentRating' => 'Altersfreigabe',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Ungesehene',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Nicht abgespielt',
'libraries.filterCategories.favorites' => 'Favoriten',
'libraries.sortLabels.title' => 'Titel',
'libraries.sortLabels.dateAdded' => 'Hinzugefügt am',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Erscheinungsdatum',
'libraries.sortLabels.rating' => 'Bewertung',
'libraries.sortLabels.communityRating' => 'Communitybewertung',
@@ -4477,12 +4469,12 @@ extension on TranslationsDe {
'downloads.syncRuleCleanupUnavailable' => 'Zugehörige Downloads konnten nicht sicher ermittelt werden. Verbinde den Server erneut und versuche es noch einmal, oder entferne die Regel, ohne die Downloads zu löschen.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} neue Episoden für ${title} synchronisiert',
'downloads.activeSyncRules' => 'Synchronisierungsregeln',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Keine Synchronisierungsregeln',
'downloads.manageSyncRule' => 'Synchronisierung verwalten',
'downloads.editEpisodeCount' => 'Episodenanzahl',
'downloads.editSyncFilter' => 'Synchronisierungsfilter',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Alle Elemente synchronisieren',
'downloads.syncUnwatchedItems' => 'Ungesehene Elemente synchronisieren',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server}${status}',
+81 -18
View File
@@ -546,6 +546,12 @@ class Translations$settings$en {
/// en: 'Display unwatched episode count on shows and seasons'
String get showUnwatchedCountDescription => 'Display unwatched episode count on shows and seasons';
/// en: 'Show Watched Indicators'
String get showWatchedIndicators => 'Show Watched Indicators';
/// en: 'Display a checkmark on watched movies, shows, and episodes'
String get showWatchedIndicatorsDescription => 'Display a checkmark on watched movies, shows, and episodes';
/// en: 'Show Episode Number on Cards'
String get showEpisodeNumberOnCards => 'Show Episode Number on Cards';
@@ -858,17 +864,38 @@ class Translations$settings$en {
/// en: 'Play immediately'
String get playNextCountdownImmediate => 'Play immediately';
/// en: 'Auto Skip Intro'
String get autoSkipIntro => 'Auto Skip Intro';
/// en: 'Skip Intro'
String get skipIntroMode => 'Skip Intro';
/// en: 'Automatically skip intro markers after a few seconds'
String get autoSkipIntroDescription => 'Automatically skip intro markers after a few seconds';
/// en: 'Play intros normally without a skip button'
String get skipIntroModeOffDescription => 'Play intros normally without a skip button';
/// en: 'Auto Skip Credits'
String get autoSkipCredits => 'Auto Skip Credits';
/// en: 'Show a skip button when an intro starts'
String get skipIntroModeButtonDescription => 'Show a skip button when an intro starts';
/// en: 'Automatically skip credits and play next episode'
String get autoSkipCreditsDescription => 'Automatically skip credits and play next episode';
/// en: 'Skip intros automatically after the delay below'
String get skipIntroModeAutoDescription => 'Skip intros automatically after the delay below';
/// en: 'Skip Credits'
String get skipCreditsMode => 'Skip Credits';
/// en: 'Play credits normally without a skip button'
String get skipCreditsModeOffDescription => 'Play credits normally without a skip button';
/// en: 'Show a skip button when credits start'
String get skipCreditsModeButtonDescription => 'Show a skip button when credits start';
/// en: 'Skip credits automatically and play the next episode'
String get skipCreditsModeAutoDescription => 'Skip credits automatically and play the next episode';
/// en: 'Off'
String get skipMarkerModeOff => 'Off';
/// en: 'Show button'
String get skipMarkerModeButton => 'Show button';
/// en: 'Automatic'
String get skipMarkerModeAuto => 'Automatic';
/// en: 'Force Fallback Markers'
String get forceSkipMarkerFallback => 'Force Fallback Markers';
@@ -2245,6 +2272,12 @@ class Translations$messages$en {
/// en: 'Playback could not be started.'
String get playbackFailed => 'Playback could not be started.';
/// en: 'The audio output stopped responding. Check the TV or receiver's audio connection; if other apps have no sound either, restart the device.'
String get audioOutputFailed => 'The audio output stopped responding. Check the TV or receiver\'s audio connection; if other apps have no sound either, restart the device.';
/// 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}';
@@ -3420,6 +3453,12 @@ class Translations$explore$en {
/// en: 'Checking your library...'
String get checkingLibrary => 'Checking your library...';
/// en: '(one) {Couldn't check ${n} server} (other) {Couldn't check ${n} servers}'
String libraryCheckFailed({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
one: 'Couldn\'t check ${n} server',
other: 'Couldn\'t check ${n} servers',
);
/// en: 'Nothing here yet'
String get emptyTitle => 'Nothing here yet';
@@ -5302,6 +5341,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
@@ -6526,6 +6571,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
@@ -7129,6 +7177,8 @@ extension on Translations {
'settings.alwaysKeepSidebarOpenDescription' => 'Sidebar stays expanded and content area adjusts to fit',
'settings.showUnwatchedCount' => 'Show Unwatched Count',
'settings.showUnwatchedCountDescription' => 'Display unwatched episode count on shows and seasons',
'settings.showWatchedIndicators' => 'Show Watched Indicators',
'settings.showWatchedIndicatorsDescription' => 'Display a checkmark on watched movies, shows, and episodes',
'settings.showEpisodeNumberOnCards' => 'Show Episode Number on Cards',
'settings.showEpisodeNumberOnCardsDescription' => 'Show season and episode number on episode cards',
'settings.showSeasonPostersOnTabs' => 'Show Season Posters on Tabs',
@@ -7233,10 +7283,17 @@ extension on Translations {
'settings.autoPlayNextEpisodeDescription' => 'Start the next episode automatically when one ends',
'settings.playNextCountdown' => 'Play Next Countdown',
'settings.playNextCountdownImmediate' => 'Play immediately',
'settings.autoSkipIntro' => 'Auto Skip Intro',
'settings.autoSkipIntroDescription' => 'Automatically skip intro markers after a few seconds',
'settings.autoSkipCredits' => 'Auto Skip Credits',
'settings.autoSkipCreditsDescription' => 'Automatically skip credits and play next episode',
'settings.skipIntroMode' => 'Skip Intro',
'settings.skipIntroModeOffDescription' => 'Play intros normally without a skip button',
'settings.skipIntroModeButtonDescription' => 'Show a skip button when an intro starts',
'settings.skipIntroModeAutoDescription' => 'Skip intros automatically after the delay below',
'settings.skipCreditsMode' => 'Skip Credits',
'settings.skipCreditsModeOffDescription' => 'Play credits normally without a skip button',
'settings.skipCreditsModeButtonDescription' => 'Show a skip button when credits start',
'settings.skipCreditsModeAutoDescription' => 'Skip credits automatically and play the next episode',
'settings.skipMarkerModeOff' => 'Off',
'settings.skipMarkerModeButton' => 'Show button',
'settings.skipMarkerModeAuto' => 'Automatic',
'settings.forceSkipMarkerFallback' => 'Force Fallback Markers',
'settings.forceSkipMarkerFallbackDescription' => 'Use chapter title patterns even when Plex has markers',
'settings.autoSkipDelay' => 'Auto Skip Delay',
@@ -7477,6 +7534,8 @@ extension on Translations {
'fileInfo.path' => 'Path',
'fileInfo.fileName' => 'File Name',
'fileInfo.size' => 'Size',
_ => null,
} ?? switch (path) {
'fileInfo.totalSize' => 'Total Size',
'fileInfo.container' => 'Container',
'fileInfo.duration' => 'Duration',
@@ -7486,8 +7545,6 @@ extension on Translations {
'fileInfo.filePresent' => 'File Present',
'fileInfo.fileReadable' => 'Readable by Server',
'fileInfo.streamPath' => 'Stream Path',
_ => null,
} ?? switch (path) {
'fileInfo.optimizedForStreaming' => 'Optimized for Streaming',
'fileInfo.has64bitOffsets' => '64-bit Offsets',
'fileInfo.protocol' => 'Protocol',
@@ -7695,6 +7752,8 @@ extension on Translations {
'messages.playbackDataInvalid' => 'The server returned invalid playback information.',
'messages.playbackCancelled' => 'Playback was canceled.',
'messages.playbackFailed' => 'Playback could not be started.',
'messages.audioOutputFailed' => 'The audio output stopped responding. Check the TV or receiver\'s audio connection; if other apps have no sound either, restart the device.',
'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',
@@ -7989,6 +8048,8 @@ extension on Translations {
'libraries.noFoldersFound' => 'No folders found',
'libraries.folders' => 'folders',
'libraries.tabs.recommended' => 'Recommended',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Browse',
'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists',
@@ -8000,8 +8061,6 @@ extension on Translations {
'libraries.groupings.episodes' => 'Episodes',
'libraries.groupings.artists' => 'Artists',
'libraries.groupings.albums' => 'Albums',
_ => null,
} ?? switch (path) {
'libraries.groupings.tracks' => 'Tracks',
'libraries.groupings.folders' => 'Folders',
'libraries.filterCategories.genre' => 'Genre',
@@ -8117,6 +8176,7 @@ extension on Translations {
'explore.notInLibrary' => 'Not in your library',
'explore.inTheseLibraries' => 'In these libraries',
'explore.checkingLibrary' => 'Checking your library...',
'explore.libraryCheckFailed' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Couldn\'t check ${n} server', other: 'Couldn\'t check ${n} servers', ),
'explore.emptyTitle' => 'Nothing here yet',
'explore.emptyMessage' => ({required Object source}) => 'Rows from ${source} will appear here once they have content.',
'explore.searchHint' => ({required Object source}) => 'Search ${source}',
@@ -8468,6 +8528,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',
@@ -8501,6 +8562,8 @@ extension on Translations {
'downloads.downloadingTooltip' => 'Downloading...',
'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloading ${files}',
'downloads.noDownloadsTree' => 'No downloads',
_ => null,
} ?? switch (path) {
'downloads.pauseAll' => 'Pause all',
'downloads.resumeAll' => 'Resume all',
'downloads.deleteAll' => 'Delete all',
@@ -8514,8 +8577,6 @@ extension on Translations {
'downloads.invalidEpisodeCount' => 'Enter a valid episode count.',
'downloads.keepSynced' => 'Keep synced',
'downloads.downloadOnce' => 'Download once',
_ => null,
} ?? switch (path) {
'downloads.keepNUnwatched' => ({required Object count}) => 'Keep ${count} unwatched',
'downloads.editSyncRule' => 'Edit sync rule',
'downloads.removeSyncRule' => 'Remove sync rule',
@@ -8893,6 +8954,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 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$es extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Iniciar automáticamente el siguiente episodio cuando termine el actual';
@override String get playNextCountdown => 'Cuenta atrás para el siguiente episodio';
@override String get playNextCountdownImmediate => 'Reproducir de inmediato';
@override String get autoSkipIntro => 'Saltar introducción automáticamente';
@override String get autoSkipIntroDescription => 'Saltar automáticamente los marcadores de introducción después de unos segundos';
@override String get autoSkipCredits => 'Saltar créditos automáticamente';
@override String get autoSkipCreditsDescription => 'Saltar automáticamente los créditos y reproducir el episodio siguiente';
@override String get forceSkipMarkerFallback => 'Forzar marcadores alternativos';
@override String get forceSkipMarkerFallbackDescription => 'Usar patrones de títulos de capítulos aunque Plex tenga marcadores';
@override String get autoSkipDelay => 'Retraso del salto automático';
@@ -3194,10 +3190,6 @@ extension on TranslationsEs {
'settings.autoPlayNextEpisodeDescription' => 'Iniciar automáticamente el siguiente episodio cuando termine el actual',
'settings.playNextCountdown' => 'Cuenta atrás para el siguiente episodio',
'settings.playNextCountdownImmediate' => 'Reproducir de inmediato',
'settings.autoSkipIntro' => 'Saltar introducción automáticamente',
'settings.autoSkipIntroDescription' => 'Saltar automáticamente los marcadores de introducción después de unos segundos',
'settings.autoSkipCredits' => 'Saltar créditos automáticamente',
'settings.autoSkipCreditsDescription' => 'Saltar automáticamente los créditos y reproducir el episodio siguiente',
'settings.forceSkipMarkerFallback' => 'Forzar marcadores alternativos',
'settings.forceSkipMarkerFallbackDescription' => 'Usar patrones de títulos de capítulos aunque Plex tenga marcadores',
'settings.autoSkipDelay' => 'Retraso del salto automático',
@@ -3449,12 +3441,12 @@ extension on TranslationsEs {
'fileInfo.has64bitOffsets' => 'Desplazamientos de 64 bits',
'fileInfo.protocol' => 'Protocolo',
'fileInfo.mediaType' => 'Tipo de medio',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Tipo de origen',
'fileInfo.optimizedVersion' => 'Versión optimizada',
'fileInfo.optimizationTarget' => 'Destino de optimización',
'fileInfo.deletedAt' => 'Eliminado',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Origen remoto',
'fileInfo.infiniteStream' => 'Flujo infinito',
'fileInfo.directPlay' => 'Reproducción directa',
@@ -3963,12 +3955,12 @@ extension on TranslationsEs {
'libraries.filterCategories.contentRating' => 'Clasificación por edad',
'libraries.filterCategories.tag' => 'Etiqueta',
'libraries.filterCategories.unwatched' => 'No vistos',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'No reproducidos',
'libraries.filterCategories.favorites' => 'Favoritos',
'libraries.sortLabels.title' => 'Título',
'libraries.sortLabels.dateAdded' => 'Fecha de adición',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Fecha de estreno',
'libraries.sortLabels.rating' => 'Valoración',
'libraries.sortLabels.communityRating' => 'Valoración de la comunidad',
@@ -4477,12 +4469,12 @@ extension on TranslationsEs {
'downloads.syncRuleCleanupUnavailable' => 'No se pudieron identificar las descargas asociadas de forma segura. Vuelve a conectar el servidor e inténtalo de nuevo, o elimina la regla sin borrar las descargas.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nuevos episodios sincronizados para ${title}',
'downloads.activeSyncRules' => 'Reglas de sincronización',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Sin reglas de sincronización',
'downloads.manageSyncRule' => 'Gestionar sincronización',
'downloads.editEpisodeCount' => 'Número de episodios',
'downloads.editSyncFilter' => 'Filtro de sincronización',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Sincronizando todos los elementos',
'downloads.syncUnwatchedItems' => 'Sincronizando elementos no vistos',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Servidor: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$fr extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Lancer automatiquement l\'épisode suivant lorsqu\'un épisode se termine';
@override String get playNextCountdown => 'Compte à rebours avant l\'épisode suivant';
@override String get playNextCountdownImmediate => 'Lire immédiatement';
@override String get autoSkipIntro => 'Passer automatiquement lintroduction';
@override String get autoSkipIntroDescription => 'Passer automatiquement les marqueurs dintroduction après quelques secondes';
@override String get autoSkipCredits => 'Passer automatiquement le générique';
@override String get autoSkipCreditsDescription => 'Passer automatiquement le générique et lire l’épisode suivant';
@override String get forceSkipMarkerFallback => 'Forcer les marqueurs de secours';
@override String get forceSkipMarkerFallbackDescription => 'Utiliser les motifs des titres de chapitre même lorsque Plex fournit des marqueurs';
@override String get autoSkipDelay => 'Délai avant le saut automatique';
@@ -3194,10 +3190,6 @@ extension on TranslationsFr {
'settings.autoPlayNextEpisodeDescription' => 'Lancer automatiquement l\'épisode suivant lorsqu\'un épisode se termine',
'settings.playNextCountdown' => 'Compte à rebours avant l\'épisode suivant',
'settings.playNextCountdownImmediate' => 'Lire immédiatement',
'settings.autoSkipIntro' => 'Passer automatiquement lintroduction',
'settings.autoSkipIntroDescription' => 'Passer automatiquement les marqueurs dintroduction après quelques secondes',
'settings.autoSkipCredits' => 'Passer automatiquement le générique',
'settings.autoSkipCreditsDescription' => 'Passer automatiquement le générique et lire l’épisode suivant',
'settings.forceSkipMarkerFallback' => 'Forcer les marqueurs de secours',
'settings.forceSkipMarkerFallbackDescription' => 'Utiliser les motifs des titres de chapitre même lorsque Plex fournit des marqueurs',
'settings.autoSkipDelay' => 'Délai avant le saut automatique',
@@ -3449,12 +3441,12 @@ extension on TranslationsFr {
'fileInfo.has64bitOffsets' => 'Décalages 64 bits',
'fileInfo.protocol' => 'Protocole',
'fileInfo.mediaType' => 'Type de média',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Type de source',
'fileInfo.optimizedVersion' => 'Version optimisée',
'fileInfo.optimizationTarget' => 'Cible d\'optimisation',
'fileInfo.deletedAt' => 'Supprimé',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Source distante',
'fileInfo.infiniteStream' => 'Flux infini',
'fileInfo.directPlay' => 'Lecture directe',
@@ -3963,12 +3955,12 @@ extension on TranslationsFr {
'libraries.filterCategories.contentRating' => 'Classification',
'libraries.filterCategories.tag' => 'Étiquette',
'libraries.filterCategories.unwatched' => 'Non vus',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Non lus',
'libraries.filterCategories.favorites' => 'Favoris',
'libraries.sortLabels.title' => 'Titre',
'libraries.sortLabels.dateAdded' => 'Date d\'ajout',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Date de sortie',
'libraries.sortLabels.rating' => 'Note',
'libraries.sortLabels.communityRating' => 'Note communautaire',
@@ -4477,12 +4469,12 @@ extension on TranslationsFr {
'downloads.syncRuleCleanupUnavailable' => 'Les téléchargements associés n\'ont pas pu être identifiés en toute sécurité. Reconnectez le serveur et réessayez, ou retirez la règle sans supprimer les téléchargements.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nouveaux épisodes synchronisés pour ${title}',
'downloads.activeSyncRules' => 'Règles de synchronisation',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Aucune règle de synchronisation',
'downloads.manageSyncRule' => 'Gérer la synchronisation',
'downloads.editEpisodeCount' => 'Nombre d’épisodes',
'downloads.editSyncFilter' => 'Filtre de synchronisation',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Synchronisation de tous les éléments',
'downloads.syncUnwatchedItems' => 'Synchronisation des éléments non vus',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Serveur : ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$hu extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'A következő epizód automatikus elindítása, amikor az aktuális véget ér';
@override String get playNextCountdown => 'Visszaszámlálás a következő epizódig';
@override String get playNextCountdownImmediate => 'Azonnali lejátszás';
@override String get autoSkipIntro => 'Intró automatikus átugrása';
@override String get autoSkipIntroDescription => 'Az intrójelölők automatikus átugrása néhány másodperc után';
@override String get autoSkipCredits => 'Stáblista automatikus átugrása';
@override String get autoSkipCreditsDescription => 'A stáblista automatikus átugrása és a következő epizód lejátszása';
@override String get forceSkipMarkerFallback => 'Tartalék jelölők kényszerítése';
@override String get forceSkipMarkerFallbackDescription => 'Fejezetcím-minták használata akkor is, ha a Plex rendelkezik jelölőkkel';
@override String get autoSkipDelay => 'Automatikus átugrás késleltetése';
@@ -3194,10 +3190,6 @@ extension on TranslationsHu {
'settings.autoPlayNextEpisodeDescription' => 'A következő epizód automatikus elindítása, amikor az aktuális véget ér',
'settings.playNextCountdown' => 'Visszaszámlálás a következő epizódig',
'settings.playNextCountdownImmediate' => 'Azonnali lejátszás',
'settings.autoSkipIntro' => 'Intró automatikus átugrása',
'settings.autoSkipIntroDescription' => 'Az intrójelölők automatikus átugrása néhány másodperc után',
'settings.autoSkipCredits' => 'Stáblista automatikus átugrása',
'settings.autoSkipCreditsDescription' => 'A stáblista automatikus átugrása és a következő epizód lejátszása',
'settings.forceSkipMarkerFallback' => 'Tartalék jelölők kényszerítése',
'settings.forceSkipMarkerFallbackDescription' => 'Fejezetcím-minták használata akkor is, ha a Plex rendelkezik jelölőkkel',
'settings.autoSkipDelay' => 'Automatikus átugrás késleltetése',
@@ -3449,12 +3441,12 @@ extension on TranslationsHu {
'fileInfo.has64bitOffsets' => '64 bites eltolások',
'fileInfo.protocol' => 'Protokoll',
'fileInfo.mediaType' => 'Médiatípus',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Forrás típusa',
'fileInfo.optimizedVersion' => 'Optimalizált verzió',
'fileInfo.optimizationTarget' => 'Optimalizálási cél',
'fileInfo.deletedAt' => 'Törölve',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Távoli forrás',
'fileInfo.infiniteStream' => 'Végtelen adatfolyam',
'fileInfo.directPlay' => 'Közvetlen lejátszás',
@@ -3963,12 +3955,12 @@ extension on TranslationsHu {
'libraries.filterCategories.contentRating' => 'Korhatár-besorolás',
'libraries.filterCategories.tag' => 'Címke',
'libraries.filterCategories.unwatched' => 'Nem látott',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Nem lejátszott',
'libraries.filterCategories.favorites' => 'Kedvencek',
'libraries.sortLabels.title' => 'Cím',
'libraries.sortLabels.dateAdded' => 'Hozzáadás dátuma',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Bemutató dátuma',
'libraries.sortLabels.rating' => 'Értékelés',
'libraries.sortLabels.communityRating' => 'Közösségi értékelés',
@@ -4477,12 +4469,12 @@ extension on TranslationsHu {
'downloads.syncRuleCleanupUnavailable' => 'A kapcsolódó letöltések nem azonosíthatók biztonságosan. Csatlakoztassa újra a szervert, és próbálja újra, vagy távolítsa el a szabályt a letöltések törlése nélkül.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} új epizód szinkronizálva a következőhöz: ${title}',
'downloads.activeSyncRules' => 'Szinkronizálási szabályok',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Nincsenek szinkronizálási szabályok',
'downloads.manageSyncRule' => 'Szinkronizálás kezelése',
'downloads.editEpisodeCount' => 'Epizódszám',
'downloads.editSyncFilter' => 'Szinkronizálási szűrő',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Minden elem szinkronizálása',
'downloads.syncUnwatchedItems' => 'Nem látott elemek szinkronizálása',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Szerver: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$it extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Avvia automaticamente l\'episodio successivo quando termina quello in riproduzione';
@override String get playNextCountdown => 'Conto alla rovescia del successivo';
@override String get playNextCountdownImmediate => 'Riproduci subito';
@override String get autoSkipIntro => 'Salta automaticamente la sigla iniziale';
@override String get autoSkipIntroDescription => 'Salta automaticamente i marcatori della sigla iniziale dopo alcuni secondi';
@override String get autoSkipCredits => 'Salta automaticamente i titoli di coda';
@override String get autoSkipCreditsDescription => 'Salta automaticamente i titoli di coda e riproduce l\'episodio successivo';
@override String get forceSkipMarkerFallback => 'Forza i marcatori di ripiego';
@override String get forceSkipMarkerFallbackDescription => 'Usa i modelli dei titoli dei capitoli anche quando Plex dispone di marcatori';
@override String get autoSkipDelay => 'Ritardo del salto automatico';
@@ -3194,10 +3190,6 @@ extension on TranslationsIt {
'settings.autoPlayNextEpisodeDescription' => 'Avvia automaticamente l\'episodio successivo quando termina quello in riproduzione',
'settings.playNextCountdown' => 'Conto alla rovescia del successivo',
'settings.playNextCountdownImmediate' => 'Riproduci subito',
'settings.autoSkipIntro' => 'Salta automaticamente la sigla iniziale',
'settings.autoSkipIntroDescription' => 'Salta automaticamente i marcatori della sigla iniziale dopo alcuni secondi',
'settings.autoSkipCredits' => 'Salta automaticamente i titoli di coda',
'settings.autoSkipCreditsDescription' => 'Salta automaticamente i titoli di coda e riproduce l\'episodio successivo',
'settings.forceSkipMarkerFallback' => 'Forza i marcatori di ripiego',
'settings.forceSkipMarkerFallbackDescription' => 'Usa i modelli dei titoli dei capitoli anche quando Plex dispone di marcatori',
'settings.autoSkipDelay' => 'Ritardo del salto automatico',
@@ -3449,12 +3441,12 @@ extension on TranslationsIt {
'fileInfo.has64bitOffsets' => 'Offset a 64 bit',
'fileInfo.protocol' => 'Protocollo',
'fileInfo.mediaType' => 'Tipo di supporto',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Tipo di origine',
'fileInfo.optimizedVersion' => 'Versione ottimizzata',
'fileInfo.optimizationTarget' => 'Obiettivo ottimizzazione',
'fileInfo.deletedAt' => 'Eliminato',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Origine remota',
'fileInfo.infiniteStream' => 'Flusso infinito',
'fileInfo.directPlay' => 'Riproduzione diretta',
@@ -3963,12 +3955,12 @@ extension on TranslationsIt {
'libraries.filterCategories.contentRating' => 'Classificazione per età',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Non visti',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Non riprodotti',
'libraries.filterCategories.favorites' => 'Preferiti',
'libraries.sortLabels.title' => 'Titolo',
'libraries.sortLabels.dateAdded' => 'Data di aggiunta',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Data di uscita',
'libraries.sortLabels.rating' => 'Valutazione',
'libraries.sortLabels.communityRating' => 'Valutazione della comunità',
@@ -4477,12 +4469,12 @@ extension on TranslationsIt {
'downloads.syncRuleCleanupUnavailable' => 'Non è stato possibile identificare in sicurezza i download associati. Riconnetti il server e riprova, oppure rimuovi la regola senza eliminare i download.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nuovi episodi sincronizzati per ${title}',
'downloads.activeSyncRules' => 'Regole di sincronizzazione',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Nessuna regola di sincronizzazione',
'downloads.manageSyncRule' => 'Gestisci sincronizzazione',
'downloads.editEpisodeCount' => 'Numero di episodi',
'downloads.editSyncFilter' => 'Filtro di sincronizzazione',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Sincronizzazione di tutti gli elementi',
'downloads.syncUnwatchedItems' => 'Sincronizzazione degli elementi non visti',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$ja extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'エピソードが終了すると、次のエピソードを自動的に再生します';
@override String get playNextCountdown => '次回再生のカウントダウン';
@override String get playNextCountdownImmediate => 'すぐに再生';
@override String get autoSkipIntro => 'イントロを自動スキップ';
@override String get autoSkipIntroDescription => '数秒後にイントロマーカーを自動的にスキップ';
@override String get autoSkipCredits => 'クレジットを自動スキップ';
@override String get autoSkipCreditsDescription => 'クレジットを自動的にスキップして次のエピソードを再生';
@override String get forceSkipMarkerFallback => 'フォールバックマーカーを強制';
@override String get forceSkipMarkerFallbackDescription => 'Plexにマーカーがある場合でもチャプタータイトルのパターンを使用します';
@override String get autoSkipDelay => '自動スキップの遅延';
@@ -3184,10 +3180,6 @@ extension on TranslationsJa {
'settings.autoPlayNextEpisodeDescription' => 'エピソードが終了すると、次のエピソードを自動的に再生します',
'settings.playNextCountdown' => '次回再生のカウントダウン',
'settings.playNextCountdownImmediate' => 'すぐに再生',
'settings.autoSkipIntro' => 'イントロを自動スキップ',
'settings.autoSkipIntroDescription' => '数秒後にイントロマーカーを自動的にスキップ',
'settings.autoSkipCredits' => 'クレジットを自動スキップ',
'settings.autoSkipCreditsDescription' => 'クレジットを自動的にスキップして次のエピソードを再生',
'settings.forceSkipMarkerFallback' => 'フォールバックマーカーを強制',
'settings.forceSkipMarkerFallbackDescription' => 'Plexにマーカーがある場合でもチャプタータイトルのパターンを使用します',
'settings.autoSkipDelay' => '自動スキップの遅延',
@@ -3439,12 +3431,12 @@ extension on TranslationsJa {
'fileInfo.has64bitOffsets' => '64ビットオフセット',
'fileInfo.protocol' => 'プロトコル',
'fileInfo.mediaType' => 'メディアタイプ',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'ソース種別',
'fileInfo.optimizedVersion' => '最適化バージョン',
'fileInfo.optimizationTarget' => '最適化ターゲット',
'fileInfo.deletedAt' => '削除済み',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'リモートソース',
'fileInfo.infiniteStream' => '無限ストリーム',
'fileInfo.directPlay' => 'ダイレクト再生',
@@ -3953,12 +3945,12 @@ extension on TranslationsJa {
'libraries.filterCategories.contentRating' => '視聴年齢区分',
'libraries.filterCategories.tag' => 'タグ',
'libraries.filterCategories.unwatched' => '未視聴',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => '未再生',
'libraries.filterCategories.favorites' => 'お気に入り',
'libraries.sortLabels.title' => 'タイトル',
'libraries.sortLabels.dateAdded' => '追加日',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'リリース日',
'libraries.sortLabels.rating' => '評価',
'libraries.sortLabels.communityRating' => 'コミュニティ評価',
@@ -4467,12 +4459,12 @@ extension on TranslationsJa {
'downloads.syncRuleCleanupUnavailable' => '関連するダウンロードを安全に特定できませんでした。サーバーに再接続して再試行するか、ダウンロードを削除せずにルールを削除してください。',
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title}の新しいエピソードを${count}件同期しました',
'downloads.activeSyncRules' => '同期ルール',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => '同期ルールなし',
'downloads.manageSyncRule' => '同期を管理',
'downloads.editEpisodeCount' => 'エピソード数',
'downloads.editSyncFilter' => '同期フィルター',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'すべてのアイテムを同期中',
'downloads.syncUnwatchedItems' => '未視聴のアイテムを同期中',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'サーバー: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$kk extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Бір бөлім аяқталғанда келесі бөлімді автоматты түрде бастау';
@override String get playNextCountdown => 'Келесіні ойнату санағы';
@override String get playNextCountdownImmediate => 'Дереу ойнату';
@override String get autoSkipIntro => 'Киріс бөлімді (Intro) автоматты өткізу';
@override String get autoSkipIntroDescription => 'Бірнеше секундтан кейін киріс белгілерін автоматты өткізу';
@override String get autoSkipCredits => 'Титрлерді автоматты өткізу';
@override String get autoSkipCreditsDescription => 'Титрлерді автоматты өткізіп, келесі бөлімді ойнату';
@override String get forceSkipMarkerFallback => 'Қосалқы белгілерді мәжбүрлеу';
@override String get forceSkipMarkerFallbackDescription => 'Plex белгілері болса да бөлім тақырыбы үлгілерін пайдалану';
@override String get autoSkipDelay => 'Автоматты өткізу кідірісі';
@@ -3194,10 +3190,6 @@ extension on TranslationsKk {
'settings.autoPlayNextEpisodeDescription' => 'Бір бөлім аяқталғанда келесі бөлімді автоматты түрде бастау',
'settings.playNextCountdown' => 'Келесіні ойнату санағы',
'settings.playNextCountdownImmediate' => 'Дереу ойнату',
'settings.autoSkipIntro' => 'Киріс бөлімді (Intro) автоматты өткізу',
'settings.autoSkipIntroDescription' => 'Бірнеше секундтан кейін киріс белгілерін автоматты өткізу',
'settings.autoSkipCredits' => 'Титрлерді автоматты өткізу',
'settings.autoSkipCreditsDescription' => 'Титрлерді автоматты өткізіп, келесі бөлімді ойнату',
'settings.forceSkipMarkerFallback' => 'Қосалқы белгілерді мәжбүрлеу',
'settings.forceSkipMarkerFallbackDescription' => 'Plex белгілері болса да бөлім тақырыбы үлгілерін пайдалану',
'settings.autoSkipDelay' => 'Автоматты өткізу кідірісі',
@@ -3449,12 +3441,12 @@ extension on TranslationsKk {
'fileInfo.has64bitOffsets' => '64-биттік ығысулар',
'fileInfo.protocol' => 'Протокол',
'fileInfo.mediaType' => 'Медиа түрі',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Дереккөз түрі',
'fileInfo.optimizedVersion' => 'Оңтайландырылған нұсқа',
'fileInfo.optimizationTarget' => 'Оңтайландыру мақсаты',
'fileInfo.deletedAt' => 'Өшірілген',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Қашықтағы дереккөз',
'fileInfo.infiniteStream' => 'Шексіз ағын',
'fileInfo.directPlay' => 'Тікелей ойнату',
@@ -3963,12 +3955,12 @@ extension on TranslationsKk {
'libraries.filterCategories.contentRating' => 'Мазмұн рейтингі',
'libraries.filterCategories.tag' => 'Тег',
'libraries.filterCategories.unwatched' => 'Көрілмеген',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Ойнатылмаған',
'libraries.filterCategories.favorites' => 'Таңдаулылар',
'libraries.sortLabels.title' => 'Атауы',
'libraries.sortLabels.dateAdded' => 'Қосылған күні',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Шыққан күні',
'libraries.sortLabels.rating' => 'Рейтинг',
'libraries.sortLabels.communityRating' => 'Қауымдастық рейтингі',
@@ -4477,12 +4469,12 @@ extension on TranslationsKk {
'downloads.syncRuleCleanupUnavailable' => 'Байланысты жүктеулерді қауіпсіз анықтау мүмкін болмады. Серверге қайта қосылып көріңіз немесе ережені жүктеулерді жоймай өшіріңіз.',
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title} үшін ${count} жаңа бөлім синхрондалды',
'downloads.activeSyncRules' => 'Белсенді синхрондау ережелері',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Синхрондау ережелері жоқ',
'downloads.manageSyncRule' => 'Синхрондауды басқару',
'downloads.editEpisodeCount' => 'Бөлімдер саны',
'downloads.editSyncFilter' => 'Синхрондау фильтрі',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Барлық элементтер синхрондалады',
'downloads.syncUnwatchedItems' => 'Көрілмеген элементтер синхрондалады',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Сервер: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$ko extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => '에피소드가 끝나면 다음 에피소드를 자동으로 재생';
@override String get playNextCountdown => '다음 재생 카운트다운';
@override String get playNextCountdownImmediate => '즉시 재생';
@override String get autoSkipIntro => '자동으로 오프닝 건너뛰기';
@override String get autoSkipIntroDescription => '몇 초 후 오프닝을 자동으로 건너뛰기';
@override String get autoSkipCredits => '자동으로 엔딩 건너뛰기';
@override String get autoSkipCreditsDescription => '엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생';
@override String get forceSkipMarkerFallback => '대체 마커 강제 사용';
@override String get forceSkipMarkerFallbackDescription => 'Plex에 마커가 있어도 챕터 제목 패턴 사용';
@override String get autoSkipDelay => '자동 건너뛰기 지연';
@@ -3184,10 +3180,6 @@ extension on TranslationsKo {
'settings.autoPlayNextEpisodeDescription' => '에피소드가 끝나면 다음 에피소드를 자동으로 재생',
'settings.playNextCountdown' => '다음 재생 카운트다운',
'settings.playNextCountdownImmediate' => '즉시 재생',
'settings.autoSkipIntro' => '자동으로 오프닝 건너뛰기',
'settings.autoSkipIntroDescription' => '몇 초 후 오프닝을 자동으로 건너뛰기',
'settings.autoSkipCredits' => '자동으로 엔딩 건너뛰기',
'settings.autoSkipCreditsDescription' => '엔딩 크레딧 자동 건너뛰기 후 다음 에피소드 재생',
'settings.forceSkipMarkerFallback' => '대체 마커 강제 사용',
'settings.forceSkipMarkerFallbackDescription' => 'Plex에 마커가 있어도 챕터 제목 패턴 사용',
'settings.autoSkipDelay' => '자동 건너뛰기 지연',
@@ -3439,12 +3431,12 @@ extension on TranslationsKo {
'fileInfo.has64bitOffsets' => '64비트 오프셋',
'fileInfo.protocol' => '프로토콜',
'fileInfo.mediaType' => '미디어 유형',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => '소스 종류',
'fileInfo.optimizedVersion' => '최적화 버전',
'fileInfo.optimizationTarget' => '최적화 대상',
'fileInfo.deletedAt' => '삭제됨',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => '원격 소스',
'fileInfo.infiniteStream' => '무한 스트림',
'fileInfo.directPlay' => 'Direct Play',
@@ -3953,12 +3945,12 @@ extension on TranslationsKo {
'libraries.filterCategories.contentRating' => '시청 등급',
'libraries.filterCategories.tag' => '태그',
'libraries.filterCategories.unwatched' => '미시청',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => '재생하지 않음',
'libraries.filterCategories.favorites' => '즐겨찾기',
'libraries.sortLabels.title' => '제목',
'libraries.sortLabels.dateAdded' => '추가된 날짜',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => '출시일',
'libraries.sortLabels.rating' => '평점',
'libraries.sortLabels.communityRating' => '커뮤니티 평점',
@@ -4467,12 +4459,12 @@ extension on TranslationsKo {
'downloads.syncRuleCleanupUnavailable' => '연결된 다운로드를 안전하게 확인할 수 없습니다. 서버에 다시 연결한 후 시도하거나, 다운로드를 삭제하지 않고 규칙만 제거하세요.',
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title}의 새 에피소드 ${count}개 동기화됨',
'downloads.activeSyncRules' => '동기화 규칙',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => '동기화 규칙 없음',
'downloads.manageSyncRule' => '동기화 관리',
'downloads.editEpisodeCount' => '에피소드 수',
'downloads.editSyncFilter' => '동기화 필터',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => '모든 항목 동기화 중',
'downloads.syncUnwatchedItems' => '시청하지 않은 항목 동기화 중',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => '서버: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$nb extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Start neste episode automatisk når en episode er ferdig';
@override String get playNextCountdown => 'Nedtelling for neste episode';
@override String get playNextCountdownImmediate => 'Spill umiddelbart';
@override String get autoSkipIntro => 'Hopp over intro automatisk';
@override String get autoSkipIntroDescription => 'Hopp automatisk over intromarkører etter noen sekunder';
@override String get autoSkipCredits => 'Hopp over rulletekst automatisk';
@override String get autoSkipCreditsDescription => 'Hopp automatisk over rulletekst og spill neste episode';
@override String get forceSkipMarkerFallback => 'Tving reservemarkører';
@override String get forceSkipMarkerFallbackDescription => 'Bruk mønstre i kapiteltitler selv når Plex har markører';
@override String get autoSkipDelay => 'Forsinkelse for automatisk hopp';
@@ -3194,10 +3190,6 @@ extension on TranslationsNb {
'settings.autoPlayNextEpisodeDescription' => 'Start neste episode automatisk når en episode er ferdig',
'settings.playNextCountdown' => 'Nedtelling for neste episode',
'settings.playNextCountdownImmediate' => 'Spill umiddelbart',
'settings.autoSkipIntro' => 'Hopp over intro automatisk',
'settings.autoSkipIntroDescription' => 'Hopp automatisk over intromarkører etter noen sekunder',
'settings.autoSkipCredits' => 'Hopp over rulletekst automatisk',
'settings.autoSkipCreditsDescription' => 'Hopp automatisk over rulletekst og spill neste episode',
'settings.forceSkipMarkerFallback' => 'Tving reservemarkører',
'settings.forceSkipMarkerFallbackDescription' => 'Bruk mønstre i kapiteltitler selv når Plex har markører',
'settings.autoSkipDelay' => 'Forsinkelse for automatisk hopp',
@@ -3449,12 +3441,12 @@ extension on TranslationsNb {
'fileInfo.has64bitOffsets' => '64-biters forskyvninger',
'fileInfo.protocol' => 'Protokoll',
'fileInfo.mediaType' => 'Mediatype',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Kildetype',
'fileInfo.optimizedVersion' => 'Optimalisert versjon',
'fileInfo.optimizationTarget' => 'Optimaliseringsmål',
'fileInfo.deletedAt' => 'Slettet',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Ekstern kilde',
'fileInfo.infiniteStream' => 'Uendelig strøm',
'fileInfo.directPlay' => 'Direct Play',
@@ -3963,12 +3955,12 @@ extension on TranslationsNb {
'libraries.filterCategories.contentRating' => 'Aldersgrense',
'libraries.filterCategories.tag' => 'Tagg',
'libraries.filterCategories.unwatched' => 'Usette',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Ikke avspilt',
'libraries.filterCategories.favorites' => 'Favoritter',
'libraries.sortLabels.title' => 'Tittel',
'libraries.sortLabels.dateAdded' => 'Dato lagt til',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Utgivelsesdato',
'libraries.sortLabels.rating' => 'Vurdering',
'libraries.sortLabels.communityRating' => 'Fellesskapsvurdering',
@@ -4477,12 +4469,12 @@ extension on TranslationsNb {
'downloads.syncRuleCleanupUnavailable' => 'Tilknyttede nedlastinger kunne ikke identifiseres på en trygg måte. Koble til serveren på nytt og prøv igjen, eller fjern regelen uten å slette nedlastingene.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synkroniserte ${count} nye episoder for ${title}',
'downloads.activeSyncRules' => 'Synkroniseringsregler',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Ingen synkroniseringsregler',
'downloads.manageSyncRule' => 'Administrer synkronisering',
'downloads.editEpisodeCount' => 'Antall episoder',
'downloads.editSyncFilter' => 'Synkroniseringsfilter',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Synkroniserer alle elementer',
'downloads.syncUnwatchedItems' => 'Synkroniserer usette elementer',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$nl extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Start de volgende aflevering automatisch wanneer een aflevering eindigt';
@override String get playNextCountdown => 'Aftellen voor volgende aflevering';
@override String get playNextCountdownImmediate => 'Direct afspelen';
@override String get autoSkipIntro => 'Intro automatisch overslaan';
@override String get autoSkipIntroDescription => 'Intromarkeringen na enkele seconden automatisch overslaan';
@override String get autoSkipCredits => 'Aftiteling automatisch overslaan';
@override String get autoSkipCreditsDescription => 'Aftiteling automatisch overslaan en de volgende aflevering afspelen';
@override String get forceSkipMarkerFallback => 'Reservemarkeringen afdwingen';
@override String get forceSkipMarkerFallbackDescription => 'Gebruik patronen in hoofdstuktitels, zelfs wanneer Plex markeringen heeft';
@override String get autoSkipDelay => 'Vertraging voor automatisch overslaan';
@@ -3194,10 +3190,6 @@ extension on TranslationsNl {
'settings.autoPlayNextEpisodeDescription' => 'Start de volgende aflevering automatisch wanneer een aflevering eindigt',
'settings.playNextCountdown' => 'Aftellen voor volgende aflevering',
'settings.playNextCountdownImmediate' => 'Direct afspelen',
'settings.autoSkipIntro' => 'Intro automatisch overslaan',
'settings.autoSkipIntroDescription' => 'Intromarkeringen na enkele seconden automatisch overslaan',
'settings.autoSkipCredits' => 'Aftiteling automatisch overslaan',
'settings.autoSkipCreditsDescription' => 'Aftiteling automatisch overslaan en de volgende aflevering afspelen',
'settings.forceSkipMarkerFallback' => 'Reservemarkeringen afdwingen',
'settings.forceSkipMarkerFallbackDescription' => 'Gebruik patronen in hoofdstuktitels, zelfs wanneer Plex markeringen heeft',
'settings.autoSkipDelay' => 'Vertraging voor automatisch overslaan',
@@ -3449,12 +3441,12 @@ extension on TranslationsNl {
'fileInfo.has64bitOffsets' => '64-bits offsets',
'fileInfo.protocol' => 'Protocol',
'fileInfo.mediaType' => 'Mediatype',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Soort bron',
'fileInfo.optimizedVersion' => 'Geoptimaliseerde versie',
'fileInfo.optimizationTarget' => 'Optimalisatiedoel',
'fileInfo.deletedAt' => 'Verwijderd',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Externe bron',
'fileInfo.infiniteStream' => 'Oneindige stream',
'fileInfo.directPlay' => 'Direct Play',
@@ -3963,12 +3955,12 @@ extension on TranslationsNl {
'libraries.filterCategories.contentRating' => 'Leeftijdsclassificatie',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Onbekeken',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Niet afgespeeld',
'libraries.filterCategories.favorites' => 'Favorieten',
'libraries.sortLabels.title' => 'Titel',
'libraries.sortLabels.dateAdded' => 'Toegevoegd op',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Uitgavedatum',
'libraries.sortLabels.rating' => 'Beoordeling',
'libraries.sortLabels.communityRating' => 'Beoordeling door community',
@@ -4477,12 +4469,12 @@ extension on TranslationsNl {
'downloads.syncRuleCleanupUnavailable' => 'Gekoppelde downloads konden niet veilig worden geïdentificeerd. Maak opnieuw verbinding met de server en probeer het opnieuw, of verwijder de regel zonder de downloads te verwijderen.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nieuwe afleveringen gesynchroniseerd voor ${title}',
'downloads.activeSyncRules' => 'Synchronisatieregels',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Geen synchronisatieregels',
'downloads.manageSyncRule' => 'Synchronisatie beheren',
'downloads.editEpisodeCount' => 'Aantal afleveringen',
'downloads.editSyncFilter' => 'Synchronisatiefilter',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Alle items synchroniseren',
'downloads.syncUnwatchedItems' => 'Ongekeken items synchroniseren',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$pl extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Automatycznie uruchamiaj następny odcinek, gdy bieżący się skończy';
@override String get playNextCountdown => 'Odliczanie do następnego odcinka';
@override String get playNextCountdownImmediate => 'Odtwarzaj natychmiast';
@override String get autoSkipIntro => 'Automatyczne pomijanie intro';
@override String get autoSkipIntroDescription => 'Automatycznie pomijaj znaczniki intro po kilku sekundach';
@override String get autoSkipCredits => 'Automatyczne pomijanie napisów końcowych';
@override String get autoSkipCreditsDescription => 'Automatycznie pomijaj napisy końcowe i odtwórz następny odcinek';
@override String get forceSkipMarkerFallback => 'Wymuś znaczniki awaryjne';
@override String get forceSkipMarkerFallbackDescription => 'Używaj wzorców tytułów rozdziałów, nawet gdy Plex ma znaczniki';
@override String get autoSkipDelay => 'Opóźnienie automatycznego pomijania';
@@ -3214,10 +3210,6 @@ extension on TranslationsPl {
'settings.autoPlayNextEpisodeDescription' => 'Automatycznie uruchamiaj następny odcinek, gdy bieżący się skończy',
'settings.playNextCountdown' => 'Odliczanie do następnego odcinka',
'settings.playNextCountdownImmediate' => 'Odtwarzaj natychmiast',
'settings.autoSkipIntro' => 'Automatyczne pomijanie intro',
'settings.autoSkipIntroDescription' => 'Automatycznie pomijaj znaczniki intro po kilku sekundach',
'settings.autoSkipCredits' => 'Automatyczne pomijanie napisów końcowych',
'settings.autoSkipCreditsDescription' => 'Automatycznie pomijaj napisy końcowe i odtwórz następny odcinek',
'settings.forceSkipMarkerFallback' => 'Wymuś znaczniki awaryjne',
'settings.forceSkipMarkerFallbackDescription' => 'Używaj wzorców tytułów rozdziałów, nawet gdy Plex ma znaczniki',
'settings.autoSkipDelay' => 'Opóźnienie automatycznego pomijania',
@@ -3469,12 +3461,12 @@ extension on TranslationsPl {
'fileInfo.has64bitOffsets' => 'Przesunięcia 64-bitowe',
'fileInfo.protocol' => 'Protokół',
'fileInfo.mediaType' => 'Typ multimediów',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Rodzaj źródła',
'fileInfo.optimizedVersion' => 'Zoptymalizowana wersja',
'fileInfo.optimizationTarget' => 'Cel optymalizacji',
'fileInfo.deletedAt' => 'Usunięto',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Źródło zdalne',
'fileInfo.infiniteStream' => 'Nieskończony strumień',
'fileInfo.directPlay' => 'Odtwarzanie bezpośrednie',
@@ -3983,12 +3975,12 @@ extension on TranslationsPl {
'libraries.filterCategories.contentRating' => 'Klasyfikacja wiekowa',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Nieobejrzane',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Nieodtworzone',
'libraries.filterCategories.favorites' => 'Ulubione',
'libraries.sortLabels.title' => 'Tytuł',
'libraries.sortLabels.dateAdded' => 'Data dodania',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Data premiery',
'libraries.sortLabels.rating' => 'Ocena',
'libraries.sortLabels.communityRating' => 'Ocena społeczności',
@@ -4497,12 +4489,12 @@ extension on TranslationsPl {
'downloads.syncRuleCleanupUnavailable' => 'Nie udało się bezpiecznie zidentyfikować powiązanych pobrań. Połącz się ponownie z serwerem i spróbuj ponownie albo usuń regułę bez usuwania pobrań.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Zsynchronizowano ${count} nowych odcinków dla ${title}',
'downloads.activeSyncRules' => 'Reguły synchronizacji',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Brak reguł synchronizacji',
'downloads.manageSyncRule' => 'Zarządzaj synchronizacją',
'downloads.editEpisodeCount' => 'Liczba odcinków',
'downloads.editSyncFilter' => 'Filtr synchronizacji',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Synchronizacja wszystkich elementów',
'downloads.syncUnwatchedItems' => 'Synchronizacja nieobejrzanych elementów',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Serwer: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$pt extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Iniciar o próximo episódio automaticamente quando um terminar';
@override String get playNextCountdown => 'Contagem regressiva do próximo episódio';
@override String get playNextCountdownImmediate => 'Reproduzir imediatamente';
@override String get autoSkipIntro => 'Pular introdução automaticamente';
@override String get autoSkipIntroDescription => 'Pular marcadores de introdução automaticamente após alguns segundos';
@override String get autoSkipCredits => 'Pular créditos automaticamente';
@override String get autoSkipCreditsDescription => 'Pular os créditos automaticamente e reproduzir o próximo episódio';
@override String get forceSkipMarkerFallback => 'Forçar marcadores alternativos';
@override String get forceSkipMarkerFallbackDescription => 'Usar padrões de títulos de capítulos mesmo quando o Plex tiver marcadores';
@override String get autoSkipDelay => 'Atraso para pular automaticamente';
@@ -3194,10 +3190,6 @@ extension on TranslationsPt {
'settings.autoPlayNextEpisodeDescription' => 'Iniciar o próximo episódio automaticamente quando um terminar',
'settings.playNextCountdown' => 'Contagem regressiva do próximo episódio',
'settings.playNextCountdownImmediate' => 'Reproduzir imediatamente',
'settings.autoSkipIntro' => 'Pular introdução automaticamente',
'settings.autoSkipIntroDescription' => 'Pular marcadores de introdução automaticamente após alguns segundos',
'settings.autoSkipCredits' => 'Pular créditos automaticamente',
'settings.autoSkipCreditsDescription' => 'Pular os créditos automaticamente e reproduzir o próximo episódio',
'settings.forceSkipMarkerFallback' => 'Forçar marcadores alternativos',
'settings.forceSkipMarkerFallbackDescription' => 'Usar padrões de títulos de capítulos mesmo quando o Plex tiver marcadores',
'settings.autoSkipDelay' => 'Atraso para pular automaticamente',
@@ -3449,12 +3441,12 @@ extension on TranslationsPt {
'fileInfo.has64bitOffsets' => 'Deslocamentos de 64 bits',
'fileInfo.protocol' => 'Protocolo',
'fileInfo.mediaType' => 'Tipo de mídia',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Tipo de origem',
'fileInfo.optimizedVersion' => 'Versão otimizada',
'fileInfo.optimizationTarget' => 'Alvo de otimização',
'fileInfo.deletedAt' => 'Excluído',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Origem remota',
'fileInfo.infiniteStream' => 'Fluxo infinito',
'fileInfo.directPlay' => 'Reprodução direta',
@@ -3963,12 +3955,12 @@ extension on TranslationsPt {
'libraries.filterCategories.contentRating' => 'Classificação indicativa',
'libraries.filterCategories.tag' => 'Tag',
'libraries.filterCategories.unwatched' => 'Não assistidos',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Não reproduzidos',
'libraries.filterCategories.favorites' => 'Favoritos',
'libraries.sortLabels.title' => 'Título',
'libraries.sortLabels.dateAdded' => 'Data de adição',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Data de lançamento',
'libraries.sortLabels.rating' => 'Avaliação',
'libraries.sortLabels.communityRating' => 'Avaliação da comunidade',
@@ -4477,12 +4469,12 @@ extension on TranslationsPt {
'downloads.syncRuleCleanupUnavailable' => 'Os downloads associados não puderam ser identificados com segurança. Reconecte o servidor e tente novamente, ou remova a regra sem excluir os downloads.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} novos episódios sincronizados para ${title}',
'downloads.activeSyncRules' => 'Regras de sincronização',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Nenhuma regra de sincronização',
'downloads.manageSyncRule' => 'Gerenciar sincronização',
'downloads.editEpisodeCount' => 'Número de episódios',
'downloads.editSyncFilter' => 'Filtro de sincronização',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Sincronizando todos os itens',
'downloads.syncUnwatchedItems' => 'Sincronizando itens não assistidos',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Servidor: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$ru extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Автоматически запускать следующий эпизод, когда заканчивается текущий';
@override String get playNextCountdown => 'Обратный отсчёт перед следующим';
@override String get playNextCountdownImmediate => 'Воспроизводить сразу';
@override String get autoSkipIntro => 'Автопропуск вступления';
@override String get autoSkipIntroDescription => 'Автоматически пропускать маркеры вступления через несколько секунд';
@override String get autoSkipCredits => 'Автопропуск титров';
@override String get autoSkipCreditsDescription => 'Автоматически пропускать титры и воспроизводить следующий эпизод';
@override String get forceSkipMarkerFallback => 'Всегда использовать резервные маркеры';
@override String get forceSkipMarkerFallbackDescription => 'Использовать шаблоны названий глав, даже если в Plex есть собственные маркеры';
@override String get autoSkipDelay => 'Задержка автопропуска';
@@ -3214,10 +3210,6 @@ extension on TranslationsRu {
'settings.autoPlayNextEpisodeDescription' => 'Автоматически запускать следующий эпизод, когда заканчивается текущий',
'settings.playNextCountdown' => 'Обратный отсчёт перед следующим',
'settings.playNextCountdownImmediate' => 'Воспроизводить сразу',
'settings.autoSkipIntro' => 'Автопропуск вступления',
'settings.autoSkipIntroDescription' => 'Автоматически пропускать маркеры вступления через несколько секунд',
'settings.autoSkipCredits' => 'Автопропуск титров',
'settings.autoSkipCreditsDescription' => 'Автоматически пропускать титры и воспроизводить следующий эпизод',
'settings.forceSkipMarkerFallback' => 'Всегда использовать резервные маркеры',
'settings.forceSkipMarkerFallbackDescription' => 'Использовать шаблоны названий глав, даже если в Plex есть собственные маркеры',
'settings.autoSkipDelay' => 'Задержка автопропуска',
@@ -3469,12 +3461,12 @@ extension on TranslationsRu {
'fileInfo.has64bitOffsets' => '64-битные смещения',
'fileInfo.protocol' => 'Протокол',
'fileInfo.mediaType' => 'Тип медиа',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Тип источника',
'fileInfo.optimizedVersion' => 'Оптимизированная версия',
'fileInfo.optimizationTarget' => 'Цель оптимизации',
'fileInfo.deletedAt' => 'Удалено',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Удалённый источник',
'fileInfo.infiniteStream' => 'Бесконечный поток',
'fileInfo.directPlay' => 'Прямое воспроизведение',
@@ -3983,12 +3975,12 @@ extension on TranslationsRu {
'libraries.filterCategories.contentRating' => 'Возрастной рейтинг',
'libraries.filterCategories.tag' => 'Тег',
'libraries.filterCategories.unwatched' => 'Непросмотренные',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Не прослушано',
'libraries.filterCategories.favorites' => 'Избранное',
'libraries.sortLabels.title' => 'Название',
'libraries.sortLabels.dateAdded' => 'Дата добавления',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Дата выхода',
'libraries.sortLabels.rating' => 'Рейтинг',
'libraries.sortLabels.communityRating' => 'Оценка сообщества',
@@ -4497,12 +4489,12 @@ extension on TranslationsRu {
'downloads.syncRuleCleanupUnavailable' => 'Не удалось надёжно определить связанные загрузки. Переподключите сервер и повторите попытку либо удалите правило без удаления загрузок.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Синхронизировано ${count} новых эпизодов для ${title}',
'downloads.activeSyncRules' => 'Правила синхронизации',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Нет правил синхронизации',
'downloads.manageSyncRule' => 'Управление синхронизацией',
'downloads.editEpisodeCount' => 'Количество эпизодов',
'downloads.editSyncFilter' => 'Фильтр синхронизации',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Синхронизация всех элементов',
'downloads.syncUnwatchedItems' => 'Синхронизация непросмотренных элементов',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Сервер: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$sv extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Starta nästa avsnitt automatiskt när det aktuella avsnittet slutar';
@override String get playNextCountdown => 'Nedräkning för nästa avsnitt';
@override String get playNextCountdownImmediate => 'Spela direkt';
@override String get autoSkipIntro => 'Hoppa över intro automatiskt';
@override String get autoSkipIntroDescription => 'Hoppa automatiskt över intromarkörer efter några sekunder';
@override String get autoSkipCredits => 'Hoppa över eftertexter automatiskt';
@override String get autoSkipCreditsDescription => 'Hoppa automatiskt över eftertexterna och spela nästa avsnitt';
@override String get forceSkipMarkerFallback => 'Tvinga reservmarkörer';
@override String get forceSkipMarkerFallbackDescription => 'Använd mönster i kapiteltitlar även när Plex har markörer';
@override String get autoSkipDelay => 'Fördröjning före automatiskt hopp';
@@ -3194,10 +3190,6 @@ extension on TranslationsSv {
'settings.autoPlayNextEpisodeDescription' => 'Starta nästa avsnitt automatiskt när det aktuella avsnittet slutar',
'settings.playNextCountdown' => 'Nedräkning för nästa avsnitt',
'settings.playNextCountdownImmediate' => 'Spela direkt',
'settings.autoSkipIntro' => 'Hoppa över intro automatiskt',
'settings.autoSkipIntroDescription' => 'Hoppa automatiskt över intromarkörer efter några sekunder',
'settings.autoSkipCredits' => 'Hoppa över eftertexter automatiskt',
'settings.autoSkipCreditsDescription' => 'Hoppa automatiskt över eftertexterna och spela nästa avsnitt',
'settings.forceSkipMarkerFallback' => 'Tvinga reservmarkörer',
'settings.forceSkipMarkerFallbackDescription' => 'Använd mönster i kapiteltitlar även när Plex har markörer',
'settings.autoSkipDelay' => 'Fördröjning före automatiskt hopp',
@@ -3449,12 +3441,12 @@ extension on TranslationsSv {
'fileInfo.has64bitOffsets' => '64-bitars offsetvärden',
'fileInfo.protocol' => 'Protokoll',
'fileInfo.mediaType' => 'Mediatyp',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Källtyp',
'fileInfo.optimizedVersion' => 'Optimerad version',
'fileInfo.optimizationTarget' => 'Optimeringsmål',
'fileInfo.deletedAt' => 'Raderad',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Fjärrkälla',
'fileInfo.infiniteStream' => 'Oändlig ström',
'fileInfo.directPlay' => 'Direktuppspelning',
@@ -3963,12 +3955,12 @@ extension on TranslationsSv {
'libraries.filterCategories.contentRating' => 'Åldersgräns',
'libraries.filterCategories.tag' => 'Tagg',
'libraries.filterCategories.unwatched' => 'Osedda',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Ospelat',
'libraries.filterCategories.favorites' => 'Favoriter',
'libraries.sortLabels.title' => 'Titel',
'libraries.sortLabels.dateAdded' => 'Tillagd',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Releasedatum',
'libraries.sortLabels.rating' => 'Betyg',
'libraries.sortLabels.communityRating' => 'Användarbetyg',
@@ -4477,12 +4469,12 @@ extension on TranslationsSv {
'downloads.syncRuleCleanupUnavailable' => 'Associerade nedladdningar kunde inte identifieras på ett säkert sätt. Återanslut servern och försök igen, eller ta bort regeln utan att ta bort nedladdningar.',
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synkroniserade ${count} nya avsnitt för ${title}',
'downloads.activeSyncRules' => 'Synkregler',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Inga synkregler',
'downloads.manageSyncRule' => 'Hantera synkronisering',
'downloads.editEpisodeCount' => 'Antal avsnitt',
'downloads.editSyncFilter' => 'Synkroniseringsfilter',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Synkroniserar alla objekt',
'downloads.syncUnwatchedItems' => 'Synkroniserar osedda objekt',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$tr extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Bir bölüm bittiğinde sonraki bölümü otomatik başlat';
@override String get playNextCountdown => 'Sonraki Bölüm Geri Sayımı';
@override String get playNextCountdownImmediate => 'Hemen oynat';
@override String get autoSkipIntro => 'Jeneriği Otomatik Atla';
@override String get autoSkipIntroDescription => 'Kaç saniye sonra jenerik işaretçilerini otomatik atla';
@override String get autoSkipCredits => 'Emeği Geçenleri Otomatik Atla';
@override String get autoSkipCreditsDescription => 'Emeği geçenleri otomatik atla ve sonraki bölümü oynat';
@override String get forceSkipMarkerFallback => 'Yedek İşaretçileri Zorla';
@override String get forceSkipMarkerFallbackDescription => 'Plex işaretçileri olsa bile kısım başlığı kalıplarını kullan';
@override String get autoSkipDelay => 'Otomatik Atlama Gecikmesi';
@@ -3194,10 +3190,6 @@ extension on TranslationsTr {
'settings.autoPlayNextEpisodeDescription' => 'Bir bölüm bittiğinde sonraki bölümü otomatik başlat',
'settings.playNextCountdown' => 'Sonraki Bölüm Geri Sayımı',
'settings.playNextCountdownImmediate' => 'Hemen oynat',
'settings.autoSkipIntro' => 'Jeneriği Otomatik Atla',
'settings.autoSkipIntroDescription' => 'Kaç saniye sonra jenerik işaretçilerini otomatik atla',
'settings.autoSkipCredits' => 'Emeği Geçenleri Otomatik Atla',
'settings.autoSkipCreditsDescription' => 'Emeği geçenleri otomatik atla ve sonraki bölümü oynat',
'settings.forceSkipMarkerFallback' => 'Yedek İşaretçileri Zorla',
'settings.forceSkipMarkerFallbackDescription' => 'Plex işaretçileri olsa bile kısım başlığı kalıplarını kullan',
'settings.autoSkipDelay' => 'Otomatik Atlama Gecikmesi',
@@ -3449,12 +3441,12 @@ extension on TranslationsTr {
'fileInfo.has64bitOffsets' => '64-bit Ofsetler',
'fileInfo.protocol' => 'Protokol',
'fileInfo.mediaType' => 'Medya Türü',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Kaynak Türü',
'fileInfo.optimizedVersion' => 'Optimize Edilmiş Sürüm',
'fileInfo.optimizationTarget' => 'Optimizasyon Hedefi',
'fileInfo.deletedAt' => 'Silindi',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Uzak Kaynak',
'fileInfo.infiniteStream' => 'Sonsuz Akış',
'fileInfo.directPlay' => 'Doğrudan Oynatma',
@@ -3963,12 +3955,12 @@ extension on TranslationsTr {
'libraries.filterCategories.contentRating' => 'İçerik Derecelendirmesi',
'libraries.filterCategories.tag' => 'Etiket',
'libraries.filterCategories.unwatched' => 'İzlenmemiş',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Oynatılmamış',
'libraries.filterCategories.favorites' => 'Favoriler',
'libraries.sortLabels.title' => 'Başlık',
'libraries.sortLabels.dateAdded' => 'Ekleme Tarihi',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Yayınlanma Tarihi',
'libraries.sortLabels.rating' => 'Puan',
'libraries.sortLabels.communityRating' => 'Topluluk Puanı',
@@ -4477,12 +4469,12 @@ extension on TranslationsTr {
'downloads.syncRuleCleanupUnavailable' => 'İlişkili indirmeler güvenli biçimde belirlenemedi. Sunucuya yeniden bağlanıp tekrar deneyin veya kuralı indirmeleri silmeden kaldırın.',
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title} için ${count} yeni bölüm eşitlendi',
'downloads.activeSyncRules' => 'Eşitleme kuralları',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Eşitleme kuralı yok',
'downloads.manageSyncRule' => 'Eşitlemeyi yönet',
'downloads.editEpisodeCount' => 'Bölüm sayısı',
'downloads.editSyncFilter' => 'Eşitleme filtresi',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Tüm ögeler eşitleniyor',
'downloads.syncUnwatchedItems' => 'İzlenmeyen ögeler eşitleniyor',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Sunucu: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class _Translations$settings$uz extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => 'Qism tugagach, keyingisini avtomatik ijro etish';
@override String get playNextCountdown => 'Keyingi qism sanogʻi';
@override String get playNextCountdownImmediate => 'Darhol ijro etish';
@override String get autoSkipIntro => 'Kirish qismini (Intro) avtomatik oʻtkazish';
@override String get autoSkipIntroDescription => 'Bir necha soniyadan soʻng kirish qismlarini avtomatik oʻtkazish';
@override String get autoSkipCredits => 'Titrlarni avtomatik oʻtkazish';
@override String get autoSkipCreditsDescription => 'Titrlarni oʻtkazib yuborish va keyingi qismni ijro etish';
@override String get forceSkipMarkerFallback => 'Zahira belgilarini majburlash';
@override String get forceSkipMarkerFallbackDescription => 'Plex belgilari boʻlsa ham boʻlim sarlavhasi shablonlaridan foydalanish';
@override String get autoSkipDelay => 'Avtomatik oʻtkazish kechikishi';
@@ -3194,10 +3190,6 @@ extension on TranslationsUz {
'settings.autoPlayNextEpisodeDescription' => 'Qism tugagach, keyingisini avtomatik ijro etish',
'settings.playNextCountdown' => 'Keyingi qism sanogʻi',
'settings.playNextCountdownImmediate' => 'Darhol ijro etish',
'settings.autoSkipIntro' => 'Kirish qismini (Intro) avtomatik oʻtkazish',
'settings.autoSkipIntroDescription' => 'Bir necha soniyadan soʻng kirish qismlarini avtomatik oʻtkazish',
'settings.autoSkipCredits' => 'Titrlarni avtomatik oʻtkazish',
'settings.autoSkipCreditsDescription' => 'Titrlarni oʻtkazib yuborish va keyingi qismni ijro etish',
'settings.forceSkipMarkerFallback' => 'Zahira belgilarini majburlash',
'settings.forceSkipMarkerFallbackDescription' => 'Plex belgilari boʻlsa ham boʻlim sarlavhasi shablonlaridan foydalanish',
'settings.autoSkipDelay' => 'Avtomatik oʻtkazish kechikishi',
@@ -3449,12 +3441,12 @@ extension on TranslationsUz {
'fileInfo.has64bitOffsets' => '64-bitli siljishlar',
'fileInfo.protocol' => 'Protokol',
'fileInfo.mediaType' => 'Media turi',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => 'Manba turi',
'fileInfo.optimizedVersion' => 'Optimallashtirilgan versiya',
'fileInfo.optimizationTarget' => 'Optimallashtirish maqsadi',
'fileInfo.deletedAt' => 'Oʻchirilgan',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => 'Masofaviy manba',
'fileInfo.infiniteStream' => 'Cheksiz oqim',
'fileInfo.directPlay' => 'Toʻgʻridan-toʻgʻri ijro',
@@ -3963,12 +3955,12 @@ extension on TranslationsUz {
'libraries.filterCategories.contentRating' => 'Kontent reytingi',
'libraries.filterCategories.tag' => 'Teg',
'libraries.filterCategories.unwatched' => 'Koʻrilmagan',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => 'Eshitilmagan',
'libraries.filterCategories.favorites' => 'Tanlanganlar',
'libraries.sortLabels.title' => 'Nomi',
'libraries.sortLabels.dateAdded' => 'Qoʻshilgan sanasi',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => 'Chıqqan sanasi',
'libraries.sortLabels.rating' => 'Reyting',
'libraries.sortLabels.communityRating' => 'Hamjamiyat reytingi',
@@ -4477,12 +4469,12 @@ extension on TranslationsUz {
'downloads.syncRuleCleanupUnavailable' => 'Bogʻliq yuklamalarni xavfsiz aniqlab boʻlmadi. Serverga qayta ulanib koʻring yoki yuklamalarni oʻchirmasdan qoidani olib tashlang.',
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title} uchun ${count} yangi qism sinxronlandi',
'downloads.activeSyncRules' => 'Faol sinxronlash qoidalari',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => 'Sinxronlash qoidalari yoʻq',
'downloads.manageSyncRule' => 'Sinxronlashni boshqarish',
'downloads.editEpisodeCount' => 'Qismlar soni',
'downloads.editSyncFilter' => 'Sinxronlash filtri',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => 'Barcha elementlar sinxronlanadi',
'downloads.syncUnwatchedItems' => 'Koʻrilmagan elementlar sinxronlanadi',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server}${status}',
+6 -14
View File
@@ -380,10 +380,6 @@ class Translations$settings$zh extends Translations$settings$en {
@override String get autoPlayNextEpisodeDescription => '当前剧集结束时自动播放下一集';
@override String get playNextCountdown => '播放下一集倒计时';
@override String get playNextCountdownImmediate => '立即播放';
@override String get autoSkipIntro => '自动跳过片头';
@override String get autoSkipIntroDescription => '几秒钟后自动跳过片头标记';
@override String get autoSkipCredits => '自动跳过片尾';
@override String get autoSkipCreditsDescription => '自动跳过片尾并播放下一集';
@override String get forceSkipMarkerFallback => '强制使用备用标记';
@override String get forceSkipMarkerFallbackDescription => '即使 Plex 有标记,也使用章节标题模式';
@override String get autoSkipDelay => '自动跳过延迟';
@@ -3184,10 +3180,6 @@ extension on TranslationsZh {
'settings.autoPlayNextEpisodeDescription' => '当前剧集结束时自动播放下一集',
'settings.playNextCountdown' => '播放下一集倒计时',
'settings.playNextCountdownImmediate' => '立即播放',
'settings.autoSkipIntro' => '自动跳过片头',
'settings.autoSkipIntroDescription' => '几秒钟后自动跳过片头标记',
'settings.autoSkipCredits' => '自动跳过片尾',
'settings.autoSkipCreditsDescription' => '自动跳过片尾并播放下一集',
'settings.forceSkipMarkerFallback' => '强制使用备用标记',
'settings.forceSkipMarkerFallbackDescription' => '即使 Plex 有标记,也使用章节标题模式',
'settings.autoSkipDelay' => '自动跳过延迟',
@@ -3439,12 +3431,12 @@ extension on TranslationsZh {
'fileInfo.has64bitOffsets' => '64 位偏移量',
'fileInfo.protocol' => '协议',
'fileInfo.mediaType' => '媒体类型',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => '来源类型',
'fileInfo.optimizedVersion' => '优化版本',
'fileInfo.optimizationTarget' => '优化目标',
'fileInfo.deletedAt' => '已删除',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => '远程来源',
'fileInfo.infiniteStream' => '无限流',
'fileInfo.directPlay' => '直接播放',
@@ -3953,12 +3945,12 @@ extension on TranslationsZh {
'libraries.filterCategories.contentRating' => '内容分级',
'libraries.filterCategories.tag' => '标签',
'libraries.filterCategories.unwatched' => '未观看',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => '未播放',
'libraries.filterCategories.favorites' => '收藏夹',
'libraries.sortLabels.title' => '标题',
'libraries.sortLabels.dateAdded' => '添加日期',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => '发行日期',
'libraries.sortLabels.rating' => '评分',
'libraries.sortLabels.communityRating' => '社区评分',
@@ -4467,12 +4459,12 @@ extension on TranslationsZh {
'downloads.syncRuleCleanupUnavailable' => '无法安全识别相关下载。请重新连接服务器后再试,或在不删除下载的情况下移除规则。',
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '已为 ${title} 同步 ${count} 个新剧集',
'downloads.activeSyncRules' => '同步规则',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => '没有同步规则',
'downloads.manageSyncRule' => '管理同步',
'downloads.editEpisodeCount' => '剧集数量',
'downloads.editSyncFilter' => '同步筛选',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => '同步所有项目',
'downloads.syncUnwatchedItems' => '同步未观看项目',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => '服务器:${server}${status}',
+6 -14
View File
@@ -381,10 +381,6 @@ class _Translations$settings$zh_Hant extends Translations$settings$zh {
@override String get autoPlayNextEpisodeDescription => '目前單集結束時自動播放下一集';
@override String get playNextCountdown => '下一集倒數計時';
@override String get playNextCountdownImmediate => '立即播放';
@override String get autoSkipIntro => '自動跳過片頭';
@override String get autoSkipIntroDescription => '幾秒鐘後自動跳過片頭標記';
@override String get autoSkipCredits => '自動跳過片尾';
@override String get autoSkipCreditsDescription => '自動跳過片尾並播放下一集';
@override String get forceSkipMarkerFallback => '強制使用備用標記';
@override String get forceSkipMarkerFallbackDescription => '即使 Plex 有標記,也強制使用章節標題模式';
@override String get autoSkipDelay => '自動跳過延遲';
@@ -3185,10 +3181,6 @@ extension on TranslationsZhHant {
'settings.autoPlayNextEpisodeDescription' => '目前單集結束時自動播放下一集',
'settings.playNextCountdown' => '下一集倒數計時',
'settings.playNextCountdownImmediate' => '立即播放',
'settings.autoSkipIntro' => '自動跳過片頭',
'settings.autoSkipIntroDescription' => '幾秒鐘後自動跳過片頭標記',
'settings.autoSkipCredits' => '自動跳過片尾',
'settings.autoSkipCreditsDescription' => '自動跳過片尾並播放下一集',
'settings.forceSkipMarkerFallback' => '強制使用備用標記',
'settings.forceSkipMarkerFallbackDescription' => '即使 Plex 有標記,也強制使用章節標題模式',
'settings.autoSkipDelay' => '自動跳過延遲',
@@ -3440,12 +3432,12 @@ extension on TranslationsZhHant {
'fileInfo.has64bitOffsets' => '具 64 位元偏移量',
'fileInfo.protocol' => '通訊協定',
'fileInfo.mediaType' => '媒體類型',
_ => null,
} ?? switch (path) {
'fileInfo.sourceKind' => '來源類型',
'fileInfo.optimizedVersion' => '最佳化版本',
'fileInfo.optimizationTarget' => '最佳化目標',
'fileInfo.deletedAt' => '已刪除',
_ => null,
} ?? switch (path) {
'fileInfo.remoteSource' => '遠端來源',
'fileInfo.infiniteStream' => '無限串流',
'fileInfo.directPlay' => '直接播放',
@@ -3954,12 +3946,12 @@ extension on TranslationsZhHant {
'libraries.filterCategories.contentRating' => '分級',
'libraries.filterCategories.tag' => '標籤',
'libraries.filterCategories.unwatched' => '未觀看',
_ => null,
} ?? switch (path) {
'libraries.filterCategories.unplayed' => '未播放',
'libraries.filterCategories.favorites' => '我的最愛',
'libraries.sortLabels.title' => '標題',
'libraries.sortLabels.dateAdded' => '新增日期',
_ => null,
} ?? switch (path) {
'libraries.sortLabels.releaseDate' => '發行日期',
'libraries.sortLabels.rating' => '評分',
'libraries.sortLabels.communityRating' => '社群評分',
@@ -4468,12 +4460,12 @@ extension on TranslationsZhHant {
'downloads.syncRuleCleanupUnavailable' => '無法安全地識別相關的下載內容。請重新連線伺服器後再試,或在不刪除下載內容的情況下移除規則。',
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '已為 ${title} 同步 ${count} 個新單集',
'downloads.activeSyncRules' => '同步規則',
_ => null,
} ?? switch (path) {
'downloads.noSyncRules' => '沒有同步規則',
'downloads.manageSyncRule' => '管理同步',
'downloads.editEpisodeCount' => '單集數量',
'downloads.editSyncFilter' => '同步篩選器',
_ => null,
} ?? switch (path) {
'downloads.syncAllItems' => '同步所有項目',
'downloads.syncUnwatchedItems' => '同步未觀看項目',
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => '伺服器:${server}${status}',
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Sidofältet förblir utfällt och innehållsytan anpassas efter det",
"showUnwatchedCount": "Visa antal osedda",
"showUnwatchedCountDescription": "Visa antal osedda avsnitt för serier och säsonger",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Visa avsnittsnummer på kort",
"showEpisodeNumberOnCardsDescription": "Visa säsongs- och avsnittsnummer på avsnittskort",
"showSeasonPostersOnTabs": "Visa säsongsaffischer på flikar",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Starta nästa avsnitt automatiskt när det aktuella avsnittet slutar",
"playNextCountdown": "Nedräkning för nästa avsnitt",
"playNextCountdownImmediate": "Spela direkt",
"autoSkipIntro": "Hoppa över intro automatiskt",
"autoSkipIntroDescription": "Hoppa automatiskt över intromarkörer efter några sekunder",
"autoSkipCredits": "Hoppa över eftertexter automatiskt",
"autoSkipCreditsDescription": "Hoppa automatiskt över eftertexterna och spela nästa avsnitt",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Tvinga reservmarkörer",
"forceSkipMarkerFallbackDescription": "Använd mönster i kapiteltitlar även när Plex har markörer",
"autoSkipDelay": "Fördröjning före automatiskt hopp",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "Servern returnerade ogiltig uppspelningsinformation.",
"playbackCancelled": "Uppspelningen avbröts.",
"playbackFailed": "Det gick inte att starta uppspelningen.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}",
"errorLoadingSeries": "Fel vid laddning av serie",
"musicNotSupported": "Musikuppspelning stöds inte ännu",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Finns inte i ditt bibliotek",
"inTheseLibraries": "I dessa bibliotek",
"checkingLibrary": "Kontrollerar ditt bibliotek...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Inget här ännu",
"emptyMessage": "Rader från ${source} visas här när de har innehåll.",
"searchHint": "Sök i ${source}",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Yan menü genişletilmiş kalır ve içerik alanı buna göre ayarlanır",
"showUnwatchedCount": "İzlenmemiş Sayısını Göster",
"showUnwatchedCountDescription": "Dizilerde ve sezonlarda izlenmemiş bölüm sayısını göster",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Kartlarda Bölüm Numarasını Göster",
"showEpisodeNumberOnCardsDescription": "Bölüm kartlarında sezon ve bölüm numarasını göster",
"showSeasonPostersOnTabs": "Sekmelerde Sezon Afişlerini Göster",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Bir bölüm bittiğinde sonraki bölümü otomatik başlat",
"playNextCountdown": "Sonraki Bölüm Geri Sayımı",
"playNextCountdownImmediate": "Hemen oynat",
"autoSkipIntro": "Jeneriği Otomatik Atla",
"autoSkipIntroDescription": "Kaç saniye sonra jenerik işaretçilerini otomatik atla",
"autoSkipCredits": "Emeği Geçenleri Otomatik Atla",
"autoSkipCreditsDescription": "Emeği geçenleri otomatik atla ve sonraki bölümü oynat",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Yedek İşaretçileri Zorla",
"forceSkipMarkerFallbackDescription": "Plex işaretçileri olsa bile kısım başlığı kalıplarını kullan",
"autoSkipDelay": "Otomatik Atlama Gecikmesi",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "Sunucu geçersiz oynatma bilgisi döndürdü.",
"playbackCancelled": "Oynatma iptal edildi.",
"playbackFailed": "Oynatma başlatılamadı.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Dosya bilgisi yüklenirken hata: ${error}",
"errorLoadingSeries": "Dizi yüklenirken hata",
"musicNotSupported": "Müzik oynatma henüz desteklenmiyor",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Kitaplığınızda yok",
"inTheseLibraries": "Bu kitaplıklarda var",
"checkingLibrary": "Kitaplığınız kontrol ediliyor...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Henüz burada bir şey yok",
"emptyMessage": "${source} kaynağındaki satırlar içerik bulunduğunda burada görünecektir.",
"searchHint": "${source} üzerinde ara",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+24 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "Yon menyu ochiq holatda qoladi",
"showUnwatchedCount": "Koʻrilmaganlar sonini koʻrsatish",
"showUnwatchedCountDescription": "Seriallar va mavsumlarda koʻrilmagan qismlar sonini koʻrsatish",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "Kartochkalarda qism raqamini koʻrsatish",
"showEpisodeNumberOnCardsDescription": "Qism kartochkalarida mavsum va qism raqamini koʻrsatish",
"showSeasonPostersOnTabs": "Varaqlarda mavsum posterlarini koʻrsatish",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "Qism tugagach, keyingisini avtomatik ijro etish",
"playNextCountdown": "Keyingi qism sanogʻi",
"playNextCountdownImmediate": "Darhol ijro etish",
"autoSkipIntro": "Kirish qismini (Intro) avtomatik oʻtkazish",
"autoSkipIntroDescription": "Bir necha soniyadan soʻng kirish qismlarini avtomatik oʻtkazish",
"autoSkipCredits": "Titrlarni avtomatik oʻtkazish",
"autoSkipCreditsDescription": "Titrlarni oʻtkazib yuborish va keyingi qismni ijro etish",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "Zahira belgilarini majburlash",
"forceSkipMarkerFallbackDescription": "Plex belgilari boʻlsa ham boʻlim sarlavhasi shablonlaridan foydalanish",
"autoSkipDelay": "Avtomatik oʻtkazish kechikishi",
@@ -770,6 +779,8 @@
"playbackDataInvalid": "Server notoʻgʻri ijro maʼlumotlarini qaytardi.",
"playbackCancelled": "Ijro bekor qilindi.",
"playbackFailed": "Ijroni ishga tushirishda xatolik.",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "Fayl maʼlumotlarini yuklashda xatolik: ${error}",
"errorLoadingSeries": "Serialni yuklashda xatolik",
"musicNotSupported": "Musiqa ijrosi hali qoʻllab-quvvatlanmaydi",
@@ -1254,6 +1265,10 @@
"notInLibrary": "Kutubxonangizda yoʻq",
"inTheseLibraries": "Ushbu kutubxonalarda bor",
"checkingLibrary": "Kutubxona tekshirilmoqda...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Hali bu yerda hech narsa yoʻq",
"emptyMessage": "${source} manbasidan olingan qatorlar bu yerda koʻrinadi.",
"searchHint": "${source} ichidan qidirish",
@@ -1647,7 +1662,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": {
@@ -2103,7 +2119,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",
+23 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "側邊欄保持展開狀態,內容區域自動調整",
"showUnwatchedCount": "顯示未觀看數量",
"showUnwatchedCountDescription": "在影集和單季上顯示未觀看的集數",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "在卡片上顯示集數",
"showEpisodeNumberOnCardsDescription": "在單集卡片上顯示季和集編號",
"showSeasonPostersOnTabs": "在索引標籤上顯示單季海報",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "目前單集結束時自動播放下一集",
"playNextCountdown": "下一集倒數計時",
"playNextCountdownImmediate": "立即播放",
"autoSkipIntro": "自動跳過片頭",
"autoSkipIntroDescription": "幾秒鐘後自動跳過片頭標記",
"autoSkipCredits": "自動跳過片尾",
"autoSkipCreditsDescription": "自動跳過片尾並播放下一集",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "強制使用備用標記",
"forceSkipMarkerFallbackDescription": "即使 Plex 有標記,也強制使用章節標題模式",
"autoSkipDelay": "自動跳過延遲",
@@ -766,6 +775,8 @@
"playbackDataInvalid": "伺服器傳回的播放資訊無效。",
"playbackCancelled": "播放已取消。",
"playbackFailed": "無法開始播放。",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "載入檔案資訊時發生錯誤:${error}",
"errorLoadingSeries": "載入影集時發生錯誤",
"musicNotSupported": "目前不支援播放音樂",
@@ -1248,6 +1259,9 @@
"notInLibrary": "不在您的媒體庫中",
"inTheseLibraries": "在這些媒體庫中",
"checkingLibrary": "正在檢查您的媒體庫…",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "這裡還沒有任何內容",
"emptyMessage": "當 ${source} 有內容時,相關資訊將顯示在此處。",
"searchHint": "搜尋 ${source}",
@@ -1637,7 +1651,8 @@
"timedOut": "中繼伺服器未及時回應",
"connectionLost": "工作階段就緒前連線已中斷",
"invalidRelayResponse": "中繼伺服器傳回了非預期的回應",
"sessionEnded": "主持人已結束工作階段"
"sessionEnded": "主持人已結束工作階段",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2093,7 +2108,9 @@
"noSessionCookie": "Seerr 未發出工作階段 Cookie",
"freshCookieRejected": "Seerr 拒絕了新的工作階段 Cookie",
"noUserInformation": "Seerr 未傳回使用者資訊",
"sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕"
"sessionRejectedAfterReauth": "重新登入後,工作階段遭到拒絕",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "外部服務",
+23 -6
View File
@@ -169,6 +169,8 @@
"alwaysKeepSidebarOpenDescription": "侧边栏保持展开状态,内容区域自动调整",
"showUnwatchedCount": "显示未观看数量",
"showUnwatchedCountDescription": "在剧集和季上显示未观看集数",
"showWatchedIndicators": "",
"showWatchedIndicatorsDescription": "",
"showEpisodeNumberOnCards": "在卡片上显示集数",
"showEpisodeNumberOnCardsDescription": "在剧集卡片上显示季和集编号",
"showSeasonPostersOnTabs": "在选项卡上显示季海报",
@@ -273,10 +275,17 @@
"autoPlayNextEpisodeDescription": "当前剧集结束时自动播放下一集",
"playNextCountdown": "播放下一集倒计时",
"playNextCountdownImmediate": "立即播放",
"autoSkipIntro": "自动跳过片头",
"autoSkipIntroDescription": "几秒钟后自动跳过片头标记",
"autoSkipCredits": "自动跳过片尾",
"autoSkipCreditsDescription": "自动跳过片尾并播放下一集",
"skipIntroMode": "",
"skipIntroModeOffDescription": "",
"skipIntroModeButtonDescription": "",
"skipIntroModeAutoDescription": "",
"skipCreditsMode": "",
"skipCreditsModeOffDescription": "",
"skipCreditsModeButtonDescription": "",
"skipCreditsModeAutoDescription": "",
"skipMarkerModeOff": "",
"skipMarkerModeButton": "",
"skipMarkerModeAuto": "",
"forceSkipMarkerFallback": "强制使用备用标记",
"forceSkipMarkerFallbackDescription": "即使 Plex 有标记,也使用章节标题模式",
"autoSkipDelay": "自动跳过延迟",
@@ -766,6 +775,8 @@
"playbackDataInvalid": "服务器返回了无效的播放信息。",
"playbackCancelled": "播放已取消。",
"playbackFailed": "无法开始播放。",
"audioOutputFailed": "",
"mediaUnavailable": "",
"errorLoadingFileInfo": "加载文件信息时出错:${error}",
"errorLoadingSeries": "加载剧集时出错",
"musicNotSupported": "尚不支持播放音乐",
@@ -1248,6 +1259,9 @@
"notInLibrary": "不在你的媒体库中",
"inTheseLibraries": "在这些媒体库中",
"checkingLibrary": "正在检查你的媒体库…",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "这里还什么都没有",
"emptyMessage": "当 ${source} 有内容时,相关内容将显示在这里。",
"searchHint": "搜索 ${source}",
@@ -1637,7 +1651,8 @@
"timedOut": "中继服务器未及时响应",
"connectionLost": "会话准备就绪前连接已断开",
"invalidRelayResponse": "中继服务器返回了意外的响应",
"sessionEnded": "主持人已结束会话"
"sessionEnded": "主持人已结束会话",
"sessionUnavailable": ""
}
},
"downloads": {
@@ -2093,7 +2108,9 @@
"noSessionCookie": "Seerr 未签发会话 Cookie",
"freshCookieRejected": "Seerr 拒绝了新的会话 Cookie",
"noUserInformation": "Seerr 未返回用户信息",
"sessionRejectedAfterReauth": "重新登录后会话被拒绝"
"sessionRejectedAfterReauth": "重新登录后会话被拒绝",
"permissionDenied": "",
"permissionRevoked": ""
},
"services": {
"title": "服务",
+23 -6
View File
@@ -49,6 +49,7 @@ import 'package:path_provider/path_provider.dart';
import 'services/image_cache_service.dart';
import 'services/gamepad_service.dart';
import 'services/trackers/tracker_coordinator.dart';
import 'services/playback_coordinator.dart';
import 'providers/account_preferences_controller.dart';
import 'services/account_preferences_repository.dart';
import 'providers/multi_server_provider.dart';
@@ -1249,7 +1250,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
bool _isAutoDeleteRunning = false;
bool _lastConnectivityWasWifi = false;
bool _lastConnectivityHadNetwork = true;
bool _shutdownStarted = false;
Future<void>? _shutdownFuture;
/// Last time server health probes ran from a resume event (cooldown for desktop)
DateTime _lastResumeProbe = DateTime(0);
@@ -1302,10 +1303,14 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
);
}
Future<void> _shutdownForExit() async {
if (_shutdownStarted) return;
_shutdownStarted = true;
Future<void> _shutdownForExit() => _shutdownFuture ??= _runShutdownForExit().timeout(
const Duration(seconds: 15),
onTimeout: () {
appLogger.w('Application exit teardown exceeded its deadline');
},
);
Future<void> _runShutdownForExit() async {
// Hide the window before anything else so the exit reads as an instant
// close: the teardown below runs against a still-mounted tree and its
// state churn must never be user-visible. Cmd+Q and OS-initiated exits
@@ -1320,6 +1325,14 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
}
}
// The player owns the backend session. Flush it while its client and
// native position still exist, before unrelated cleanup can delay exit.
try {
await PlaybackCoordinator.instance.shutdownVideo().timeout(const Duration(seconds: 3));
} catch (e, st) {
appLogger.w('Video shutdown did not complete before exit', error: e, stackTrace: st);
}
_syncDebounce?.cancel();
await _watchStateSubscription?.cancel();
_removeConnectivitySyncListener();
@@ -1330,7 +1343,11 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// Quitting straight from the player is a real stop: the trackers that own
// their own watched semantics need the terminal report before the process
// goes away. Bounded — a hung tracker must not hold the app open.
await TrackerCoordinator.instance.stopPlayback().timeout(const Duration(seconds: 3), onTimeout: () {});
try {
await TrackerCoordinator.instance.stopPlayback().timeout(const Duration(seconds: 3));
} catch (e, st) {
appLogger.w('Tracker shutdown did not complete before exit', error: e, stackTrace: st);
}
TrackerCoordinator.instance.cancelInFlight();
await _serverManager.shutdown();
@@ -1349,7 +1366,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
_removeConnectivitySyncListener();
_memoryCheckTimer?.cancel();
_appLifecycleListener.dispose();
if (!_shutdownStarted) {
if (_shutdownFuture == null) {
_libraryEventService.dispose();
_downloadManager.dispose();
_serverManager.dispose();
+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, '
+24 -3
View File
@@ -23,6 +23,27 @@ class LiveProgramInfo {
static const none = LiveProgramInfo();
}
/// What a live heartbeat learned from the server. Both windows are
/// `TranscodeSession` snapshots (epoch origin plus min/max offsets), but they
/// describe different things:
///
/// - [captureBuffer]: the tuner's seekable history — the coordinate system
/// for time-shift offsets and the timeline's range.
/// - [playbackStream]: the transcode currently feeding the player. Its
/// `startedAt` is the epoch of stream position zero, i.e. the exact clock
/// anchor for `epoch = startedAt + player position`. Plex's own client
/// derives the playhead from this object, not from wall clock (#2100).
///
/// Either may be null when the backend does not report it.
class LiveTimelineUpdate {
final CaptureBuffer? captureBuffer;
final CaptureBuffer? playbackStream;
const LiveTimelineUpdate({this.captureBuffer, this.playbackStream});
bool get isEmpty => captureBuffer == null && playbackStream == null;
}
/// One live-TV playback session, produced by [LiveTvSupport.startPlayback].
///
/// This is the backend-neutral handle the player drives; the
@@ -80,9 +101,9 @@ abstract class LiveTvPlaybackSession {
/// Send a playback heartbeat (`'playing'` / `'paused'` / `'stopped'`).
/// [positionMs] is elapsed playback time; [durationMs] the program
/// duration when known. Returns an updated capture buffer when the backend
/// supplies one, null otherwise.
Future<CaptureBuffer?> reportTimeline({required String state, required int positionMs, required int durationMs});
/// duration when known. Returns what the backend reported back (capture
/// window, playback-stream origin), null when it reports nothing.
Future<LiveTimelineUpdate?> reportTimeline({required String state, required int positionMs, required int durationMs});
/// Re-establish playback after stream death. Plex re-tunes (the previous
/// capture session expires while the player exhausts its reconnect
+16 -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) {
@@ -117,6 +125,12 @@ enum MediaBrowserDialect {
/// leading codec must be one the dialect is known to emit.
bool get rotatesDisabledTranscodeCodecs => this == MediaBrowserDialect.jellyfin;
/// Restrict Live TV HLS negotiation to MPEG-TS. Emby fMP4 live streams
/// fail with missing initialization metadata in affected setups (#2273),
/// while the same sources play as TS. VOD keeps fMP4; this is a transport
/// compatibility policy, not a restriction on direct play or stream copy.
bool get requiresMpegTsForLiveTv => this == MediaBrowserDialect.emby;
/// `GET /Audio/{id}/Lyrics` (Jellyfin 10.9+). Never call this on Emby: the
/// route resolves to audio streaming with `Lyrics` as the container and
/// spawns an ffmpeg process that fails with a 500.
+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);
}
+40 -16
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();
@@ -603,11 +608,13 @@ abstract class MediaServerClient {
/// Reverse lookup: find every library movie/show matching any of [ids].
///
/// Neither backend can filter by external id — Plex's `guid=` matches only
/// the primary `plex://` guid (verified on PMS 1.43) and Jellyfin dropped
/// `anyProviderIdEquals` (silently ignored on 10.11.10) — so both search by
/// title and verify candidates against their exact external ids. Title
/// alone never produces a match.
/// Neither backend can filter by the external ids a modern item carries —
/// Plex's `guid=` sees only the primary guid (verified on PMS 1.43) and
/// Jellyfin dropped `anyProviderIdEquals` (silently ignored on 10.11.10) —
/// so both search by title and verify candidates against their exact
/// external ids. Title alone never produces a match. Plex does reach a
/// legacy-agent item by id, because that item's primary guid *is* the
/// external id ([ExternalIds.legacyPlexGuidPrefixes]).
///
/// One title can own several library items: a server with a 4K section and
/// an HD section holds two rating keys for the same movie, and a library
@@ -618,12 +625,17 @@ abstract class MediaServerClient {
/// the caller (the Explore "In these libraries" chooser) exists to show
/// them. Ordering is the implementation's, and callers re-sort.
///
/// [titles] are tried in order until one yields id-verified candidates;
/// pass the entry's own title first and broader forms after (see
/// `titleMatchCandidates`). A sequel entry's own title never matches its
/// parent show, which is why more than one is needed. [year] applies a ±1
/// window to the first attempt only — for a sequel the catalog year is the
/// season's, not the show's.
/// Every entry of [titles] is searched, and the union of their id-verified
/// candidates is returned. The caller (`CatalogLibraryMatcher.lookupTitles`)
/// 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
/// backends whose title search is a substring match: a ±1 window on the
/// first title only, since for a sequel the catalog year is the season's,
/// not the show's.
///
/// [plexGuid] is a Plex-only escape hatch: a `plex://show/…` guid the caller
/// already holds, which the local server *can* filter on exactly. It is
@@ -637,10 +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. 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 [],
@@ -676,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,

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