Compare commits

...
Author SHA1 Message Date
edde746 703df6a561 refactor(focus): consolidate Back behind PopScope with per-owner press gates
One TV Back press could act twice: a KeyDown-fired action (Apple TV, a TV
text field under its IME, Flutter's own dialog dismissal) popped a route or
moved focus, and the KeyUp then landed on a different focus chain whose
handler acted again. Eight mechanisms had grown around that: a global
one-shot frame marker, a KeyUp suppressor armed from a NavigatorObserver on
both navigators plus a hardware-phase press tracker, per-screen
_suppressBackAfterPop flags driven by didPopNext, a backHandledByKeyEvent
flag copied between screens, ~40 handleBackKeyAction sites with an Apple TV
special case re-implemented by hand, PopScopes disabled on Android keyboard
mode to avoid a double pop, and app-level DismissIntent bindings.

The Flutter Android embedding only redispatches a Back key into
onBackPressed -> popRoute when the framework reports it unhandled, so a
handled key never produces a system pop and the only double is in-framework.
Back now has two owners. A focused control that consumes Back before the
route (text field, sheet, player strip, reorder mode, content -> sidebar)
holds a BackPressGate: it consumes the press, acts on the platform's phase,
and ignores a KeyUp whose KeyDown it did not see. Everything else bubbles to
NavigatorBackHandler, installed once around each navigator, which turns the
press into Navigator.maybePop on the route's synchronous popDisposition, so
a screen's back policy lives in its PopScope for key and system back alike.
An unowned KeyUp is swallowed there instead of reaching the platform; a
press racing a push is swallowed rather than popping the new route; a press
at the true root with nothing to pop is left to the platform.

Deleted: BackKeyCoordinator, BackKeyUpSuppressor, BackKeyPressTracker,
BackKeySuppressorObserver, handleBackKeyAction, handleBackKeyNavigation,
InputModeTracker.shouldBlockSystemBack, both _suppressBackAfterPop copies,
both backHandledByKeyEvent copies, OverlaySheetHost.onSystemBack (now
onBack, the single policy), the goBack/browserBack/gameButtonB DismissIntent
bindings, and the route-popping Focus wrappers on screens whose Back only
popped.
2026-09-05 21:30:18 +02:00
edde746 111af97a61 refactor(focus): route synthetic keys through the framework key pipeline
Gamepad, companion-remote and Siri Remote input was dispatched by walking
FocusNode.parent by hand, so it never reached HardwareKeyboard: pressed-key
state stayed stale, the eight hardware handlers (input-mode tracking, media
transport, the hotkey recorder, the Steam duplicate guard) never saw it, and
a synthetic release went to the node that took the press rather than to the
current focus like a hardware release does.

Synthetic events now enter ui.PlatformDispatcher.onKeyData, the engine's own
entry point, marked synthesized so the framework dispatches them at once.
They run the complete pipeline a hardware key does. The stream is
regularized against the framework's pressed state (a down for a held key is
a repeat, an up for an unheld key is dropped), a disconnecting source
releases what it still holds, and the Siri adapter no longer has to announce
itself to the input-mode tracker by hand.
2026-09-05 21:30:18 +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
edde746 af9fb3f17c fix(live-tv): calibrate replacement stream clocks 2026-09-04 16:49:55 +02:00
edde746 781fb66421 fix(player): keep blocking MPV lifecycle work off main thread
A disposal race could call blocking native destruction after initialization resumed on Android's main thread. Finish that teardown on IO, enforce the lifecycle threading invariant, and bound exit responsiveness and teardown latency in the device regression test.
2026-09-04 14:43:54 +02:00
edde746 1b45588d3b fix(player): keep MediaCodec surface alive through MPV teardown
Setting vo=null during disposal could reinitialize MediaCodec and deadlock vendor decoders while the surface was already being released. Terminate libmpv synchronously with its surface references intact, then release the Android surfaces, and cover repeated hardware-decoded teardown on devices.
2026-09-04 14:43:54 +02:00
1a3dc3d3c9 fix(android): gate the DTS-HD IEC carrier on advertised DTS-HD support (#2231)
The ExoPlayer carrier gate only asked whether the route could open a
192kHz/7.1 IEC 61937 track. TrueHD rides that same tuple, so a sink that
decodes Dolby but no DTS-HD passed the probe and was handed a DTS-HD
burst it discards: the AudioTrack initialises and drains, Android reports
no error, and neither the audio-recovery ladder nor the mpv fallback
fires, so the file plays with no audio at all.

Evaluate the gate per format. TrueHD keeps the transport-only probe, so
the #1804 routes are unchanged; DTS-HD additionally requires the route to
advertise ENCODING_DTS_HD, the pairing mpvSpdifCodecs already applies and
the reason mpv plays these files while ExoPlayer does not.

Declining the carrier is not a forced decode: the format falls through to
media3's raw path, which downgrades DTS-HD to the DTS core for receivers
that take the core but not the lossless stream.

Co-authored-by: claude-opus-5 (high effort, 1M context) <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 12:48:34 +02:00
edde746 5d6ee0861e fix(player): snap Android MediaCodec frames to display vsync 2026-09-04 12:02:39 +02:00
edde746 986e769ac0 fix(player): stop subtitle updates from dropping video frames on the mpv Android backend
With the mpv backend on Android, every subtitle change cost two dropped
video frames on a Fire TV Cube (36 per minute with PGS SDH subtitles, 15
with srt, #2240) and accounts for the residual drops on the Shield in #2202,
whose sessions also had subtitles on. The diagnostic build showed the drops
in pairs: a frame handed over on time, then the video thread waking 90-100
ms late. vo_mediacodec rasterized subtitles and blitted them into the OSD
Surface on that thread, and locking the Surface waits CPU-side for the
compositor - dequeue plus the release fence, one or two vsyncs, 42-83 ms at
23.976 Hz. Kodi, VLC 4 and ExoPlayer all keep subtitle output off the
presentation thread.

Move the native pin to edde746/mpv-build@88c1b135e7: the whole OSD stage
runs on its own thread, fed with the video pts at every flip, so the video
thread never waits on the compositor or on libass. Subtitles latch on the
same vsync as the video in the common case and one later when the lock has
to wait. Shield on vo=mediacodec with ASS subtitles: no video-thread stage
over 10 ms and 0 drops across five 150 s runs, subtitles verified on
screen; DTS-HD MA 4K unchanged at 0 drops. The pin keeps the #2202
diagnostics and adds VO stage timing to them. Apple, Linux and Windows
assets are unchanged.
2026-09-04 06:14:18 +02:00
edde746 b61fc9e661 fix(audio): stop the passthrough clock from overshooting after a stall on the mpv Android backend
The smoothed passthrough clock from aeb06c2c9 (edde746/mpv-build 0105)
still let the Shield in #2202 drop a frame every 70 s or so on TrueHD and
DTS-HD MA. The average handles head jitter completely - on a Shield with
the raw head artificially held for 150 ms it went from 410 dropped frames
in 200 s to 0 - but it regresses on stalls: when the head freezes (device
underrun, rebuffer, a HAL that stops updating) while the wall clock runs,
the estimate runs past what was written, (written - head) wraps, and
getLatency reports 0 - the core sees the whole device buffer vanish in one
step and drops video. With the AO starved for 400 ms every 15 s that was 77
drops in 170 s against 8 with the raw clock, and a Box R 4K Plus hit the
wrap 361 times in four minutes of ordinary DTS-HD playback.

Move the native pin to edde746/mpv-build@a58f0c1336: the estimate is
clamped to the written position and its window restarted on overshoot, and
between reports it may deviate from "last report + elapsed" by at most 10%
of the elapsed time unless it is a second or more off - the two guards
media3's AudioTrackPositionTracker keeps over the same average. Starvation
run: 9 drops, no wrap; real HAL with pauses and seeks: 0 drops. The pin also
carries verbose-level diagnostics for this issue (per-drop timing in vo.c,
passthrough clock anomalies and a 10 s summary in ao_audiotrack) so the
next log from the reporter pins whatever remains. Apple, Linux and Windows
assets are unchanged (their content keys did not move).
2026-09-04 01:58:34 +02:00
edde746 fa8da33ec8 fix(tv): drop a stale Discover rail-focus claim, log swallowed tvOS Menu presses, and unblock shelf test resets
Three findings from the #2239 investigation.

Discover armed "focus the browse rail when it has hubs" on its first
load and kept it armed indefinitely while the rail had nothing to show.
Hubs landing minutes later — reconnect, push refresh — then yanked the
remote off a sidebar item the user had since moved to. The claim now
lapses once focus sits on a control outside the screen; a bare content
scope (the startup state) still lets the rail take its initial focus.

On Apple TV a Menu press that reaches MainScreen at the home root is
consumed silently by design (the engine normally hands root Menu to
UIKit). That branch is what turned a focus slip into a "dead remote"
report, so it now logs passthrough state, picker state, and the primary
focus label.

SystemShelfService.debugReset awaited the mutation tail queued by the
previous widget test, whose FakeAsync zone ends without flushing the
microtasks that settle the chain, so any second test in a file using it
hung in setUp. It now drops the queue synchronously; the #2239 session
regression test moves back beside the profile-switch test it belongs
with.
2026-09-03 23:54:09 +02:00
edde746 44599549e1 fix(focus): keep root-navigator routes from losing the remote to the covered profile session
On tvOS the profile picker shown after a background/standby resume lost
its focus highlight and the Siri Remote went dead until a force-quit.
Android TV hit the same shape with the player (#2034).

The picker and PIN dialog are pushed on the root navigator, above the
nested profile-session navigator. Nested routes still report
`isCurrent == true` under that cover, and Flutter leaves a covered
route's scope focusable, so any focus self-heal under MainScreen —
the sidebar reveal on an offline/online flip, a Libraries grid reload,
the TV browse rail — won the remote behind the picker. With menu
passthrough pinned off while the picker is up, Menu was swallowed too.

Restore the invariant once at the navigator boundary instead of at
every reclaim site: CoveredRouteFocusBoundary wraps the session in an
ExcludeFocus keyed on the root route's currency, so requests below a
cover are no-ops, and re-requests its own scope on uncover so focus
walks back to the leaf that had it (the covering route's pop culls the
excluded scope from the route scope's history before the exclusion
lifts, which is why Flutter's own restoration cannot). This makes
isRouteChainCurrent redundant; the player's guards return to plain
nested currency.

close #2239
2026-09-03 23:47:27 +02:00
edde746 e46b3dab7c fix(seerr): diagnose an auth proxy in front of Seerr instead of misreporting it
A Seerr instance behind forward-auth (Authelia, Authentik, Cloudflare
Access) or HTTP Basic failed setup with "No Seerr instance at … (HTTP
200)": the probe followed the proxy's redirect to its login page and
decoded the HTML as "not JSON". A live session that later hit the same
wall took the proxy's 401 as Seerr's, re-authed through the wall, failed,
and unlinked a perfectly good stored session.

Seerr's API never redirects and always answers with JSON, so a 3xx or a
non-JSON 401/403 is the proxy talking. Seerr requests no longer follow
redirects, that shape maps to a SeerrProxyException with a message that
says what to do, the probe reports it in place of "no instance", and the
client neither re-auths nor unlinks on it.

close #1877
2026-09-03 21:42:47 +02:00
edde746 e67bf996c5 fix(player): keep subtitles on the picture when ambient lighting is on
With ambient lighting enabled, ASS subtitles anchored to a corner (and PGS
bitmaps) rendered against the whole screen instead of the video: the effect
stretches mpv's frame to the window with video-aspect-override and lets the
shader composite the real picture inside it, and mpv places subtitles against
that stretched frame.

The pinned libmpv now carries --sub-video-rect-aspect (edde746/mpv-build
ec08018), which makes mpv derive its subtitle margins from the picture's real
aspect inside the displayed rect. AmbientLightingService hands it the video
aspect before overriding the frame, so a libmpv without the option refuses
cleanly with the frame untouched, and resets it on disable.

Verified on a Pixel 7 (2400x1080) with a 16:9 file whose OP credits are
\pos-anchored: subtitle extents are pixel-identical with ambient lighting on
and off, and mpv reports OSD borders l=240 r=240 for the overridden frame.

close #2120
2026-09-03 21:28:10 +02:00
edde746 66cc6fc192 fix(player): hide the header clock on a portrait phone
On a phone in portrait the video controls header is too narrow: the clock crowds the back button, title, and track/chapter controls. Hide it there; landscape, tablets, desktop, and TV keep it.
2026-09-03 20:32:05 +02:00
edde746 691d10b3c6 feat(navigation): landscape rail for the mobile shell and phone rotation
Phones were locked to portrait outside the video player, and the mobile
shell had no answer for a wide, short viewport: car head units (which use
the mobile layout) and tablets in landscape spent their scarce height on
a bottom navigation bar and a 500dp hero.

In landscape the mobile shell now puts the bottom bar's destinations on a
leading Material NavigationRail (MobileNavigationRail) and keeps every
other mobile layout decision as it is. The rail keeps the bottom bar's
extras: the offline reconnect affordance as its leading action and the
long-press library quick picker on the Libraries destination. Labels
follow the existing nav-bar-labels setting but give way automatically
when the destinations cannot fit (a landscape phone is ~410dp tall), and
the rail scrolls as a last resort. The content beside it drops the leading
system inset the rail already absorbs.

Phones now allow every orientation; OrientationHelper.restoreDefaultOrientations
is context-free and the player exit path uses it instead of its own copy
of the phone lock. The Discover hero fills the viewport in mobile landscape
and compacts its logo and bottom offset when short, so its content stays
below the top bar.

The music mini-player learns a start inset (MiniPlayerInsetController.setNavInsets)
so it floats beside the rail in landscape and above the bar in portrait;
route suspension zeroes both.

Known follow-up: the media detail header is not yet laid out for a
landscape phone (logo runs under the status bar and back button).
2026-09-03 20:29:08 +02:00
edde746 42b9bf7fb8 fix(automotive): keep every screen beside a side car system bar
On Android Automotive head units with a left- or right-positioned system
bar, the whole edge of the app rendered underneath it: Discover's title,
hero text and first card were clipped, and the player controls sat under
the bar. Play's car app quality AR-1 requires interactive UI to stay clear
of system bars.

The platform reports the bar as a systemBars inset and Flutter forwards it
as MediaQuery.padding.left/right, but the mobile screens only honour the
top and bottom insets. On the emulator this only showed after the first
video session (the player's edge-to-edge restore stops the DecorView from
fitting a navigation-bar-typed left bar); on Android 14+ CarSystemUI the
left bar is a status-bar-typed inset, so it is under the bar from launch.

Car bars are opaque and may be impossible to hide, so nothing is worth
drawing under them: FormFactorScale now wraps the automotive surface in a
horizontal SafeArea inside the scaled MediaQuery, consuming the insets
once for every route.

Reproduced and verified on the API 33 automotive image with the
com.android.systemui.rro.left overlay enabled for the foreground user.
2026-09-03 20:28:53 +02:00
edde746 2efd300a5f fix(downloads): show resume progress bar on downloaded episodes
Downloaded episodes never showed the partially-watched progress bar, so a
user resuming offline could not tell which episode they had started.

Offline playback already records progress: the tracker queues a progress
action and emits a WatchStateNotifier event, and DownloadProvider hydrates
those rows back into WatchStateStore on load. EpisodeCard consumed that
state via withFreshWatchState but then passed progressAvailable: false to
WatchedIndicator for offline cards, a guard left over from before offline
progress tracking existed. Downloaded movie cards (MediaCard) never had the
guard and already showed the bar.

Drop the guard and the now-unused WatchedIndicator.progressAvailable flag.

close #2236
2026-09-03 19:14:45 +02:00
edde746 8b1ac156f3 fix(player): drop chapter-derived intro markers longer than three minutes
A movie whose first chapter is titled "Opening Credits" or "Introduction"
got a Skip Intro button that skipped the whole chapter — several minutes
of picture. Detected intros are short; only Plezy's own chapter-title
fallback produced these. Cap chapter-derived intros at three minutes.
Server-supplied markers and credits chapters are unaffected.

close #2235
2026-09-03 18:34:44 +02:00
edde746 be8fa15733 chore: restore pubspec.lock quoting rewritten by pub get 2026-09-03 18:24:41 +02:00
edde746 fa054153ae fix(plex): surface a stale-endpoint failure instead of cascading after a mid-probe promotion 2026-09-03 18:22:13 +02:00
edde746 a230d51598 fix(plex): keep a LAN session on its local endpoint after device sleep
Apple TV (and any suspended process) wakes with dead keep-alive sockets in
the HTTP pool. The first request after resume — the health probe — failed
with a connection error, and the failover cascade treated that as a dead
endpoint: it validated the remote candidate, switched to it, and persisted
it as preferred. Nothing walked the session back to the local endpoint,
because the only re-optimization trigger is a connectivity event and a
same-interface sleep/wake never produces one. Only killing the app fixed it.

FailoverHttpClient now runs the existing candidate trust gate against the
current endpoint on a connection error and retries in place when it answers;
timeouts and 5xx still cascade directly. MultiServerManager gains
reoptimizeDemotedServers, called from the resume probe, which re-races any
online Plex server sitting on a remote or relay endpoint while a local one
is published.

close #2056
2026-09-03 18:13:28 +02:00
edde746 e4f3888694 fix(seerr): seed advanced requests from the Sonarr anime defaults and expose tags
Requesting an anime series with REQUEST_ADVANCED routed it to the standard
Sonarr profile and root folder. The sheet seeded its pickers from
activeProfileId/activeDirectory only, then posted them as explicit overrides,
which beat the anime defaults Seerr would otherwise apply at approval. A user
without the permission sent no overrides and was routed correctly.

The service DTOs now carry activeAnimeProfileId, activeAnimeDirectory,
activeAnimeLanguageProfileId, activeTags, activeAnimeTags and the instance's
tag list. The sheet detects the TMDB anime keyword on the TV details it
already fetches and seeds each picker the way Seerr's web requester does:
the anime value when the series is an anime and the instance configures one,
else the standard value. Options carry the "(Default)" marker and an anime
series shows the same note as the web UI.

Tags are editable: an inline checklist under the advanced pickers, seeded from
the instance's (anime) default tags and posted as `tags`. It is inline rather
than a nested sheet page because the host builds only the top page, so a push
would dispose the sheet and drop the season and picker selections on the way
back.

close #2215
2026-09-03 16:46:27 +02:00
Jan HaiderandGitHub 2087dff32d fix(tvos): eliminate runtime and project build warnings (#2228) 2026-09-03 14:41:16 +02:00
edde746 d112ade601 style(android): space out the annotated and commented libmpv lock declarations
ktlint's spacing-between-declarations-with-annotations and
-with-comments rules failed on android/libmpv/build.gradle.kts, so
scripts/format_native.sh --check - and with it scripts/ci_checks.sh -
reported a failure on an untouched file.

Add the blank line before the @Suppress'd mpvAssets declaration and
before the comment above stagedArchiveName.
2026-09-03 13:12:48 +02:00
edde746 6301d5124e chore(plex): send the JSON Accept header unconditionally
PlexConfig.acceptJson defaulted to true in both constructors and
travelled through copyWith, but no production or test caller ever passed
false, and every Plex path decodes JSON, so the header was conditional on
a flag that could not be off.

Emit `Accept: application/json` directly and drop the field. Headers on
the wire are unchanged.
2026-09-03 13:08:46 +02:00
edde746 13acd41b09 refactor(player): classify player keys with the shared D-pad extension
The video controls' key handling redefined the directional, horizontal
and select key sets privately, duplicating DpadKeyExtension with
identical members; the private select helper then handed the event to
handleOneShotSelect, which classified it again with the canonical
isSelectKey.

Use isDpadDirection, isLeftKey/isRightKey and isSelectKey and delete the
copies.
2026-09-03 13:08:46 +02:00
edde746 0003d95c0a refactor(watch_together): handle created and joined in one branch
The two setup acknowledgements ran the same sequence twice: accept and
validate the response, handle the same two exceptions, install the peer
roster, publish connected state, and complete the setup completer. Only
the log line differed, plus a roster loop that skipped the
already-present check.

One case handles both, using the guarded add so the roster publishes
idempotently for either acknowledgement.
2026-09-03 13:08:46 +02:00
edde746 4d2aa8f9a2 chore(plex): trim LiveTvDvr to the fields Live TV renders
LiveTvDvr parsed 21 DVR fields plus nested Setting and raw Device lists
on every Live TV availability refresh, while the app reads only the key,
the three lineup labels, and the enabled channel mappings. The rest
describe tuner hardware and DVR setup, which Plezy does not implement.

Keep key, lineup, lineupTitle, lineupURL and channelMappings, and
channelKey/enabled on ChannelMapping. The tests keep the root
channel-mapping fallback, malformed-sibling tolerance and flexible
`enabled` parsing.
2026-09-03 13:08:46 +02:00
edde746 deadf712a8 perf(profiles): resolve accounts from the registry watcher payloads
AccountPreferencesController subscribed to watchConnections() and
watchForProfile() and threw both payloads away, then re-read the same two
tables through list() and listForProfile() on every resolve - four
one-shot queries during attach alone, each re-decoding rows and revealing
credentials the watcher had already decoded. The generation counter
discarded stale results but never the queries.

Retain the rows the subscriptions deliver and resolve from them, clearing
the profile rows whenever the watched profile changes so one profile's
rows can never pair with another's. Until both watchers have delivered,
the resolve task parks on that first delivery, so ensureActiveLoaded
still has something to wait for.
2026-09-03 13:08:46 +02:00
edde746 ef91156827 perf(music): observe only the properties the audio-only core needs
The audio-only core reused the video core's property registrations
wholesale, so every music session observed track-list,
demuxer-cache-state, audio-device-list, audio-device, secondary-sid,
seekable, paused-for-cache, volume, speed, aid and sid. libmpv sends an
initial notification plus an event per edge for each one, and three of
them decode structured nodes into track and device models nothing on the
music path reads.

Register time-pos, duration, pause, eof-reached and playlist-pos there;
the video path keeps the full set. Also drop the post-initialization
`gapless-audio` write: every native audio core already sets it before
mpv_initialize, which is where libmpv wants initial configuration.
2026-09-03 13:08:46 +02:00
edde746 42b6710f04 fix(player): stop letting item metadata language override the audio track
The audio ladder still carried a per-item language tier between the
server's selected stream and the account preference. For Jellyfin and
Emby the value mapped to it was `PreferredMetadataLanguage`, which is the
library's metadata-scraping language - it inherits from the parent, the
library options, or the server config and says nothing about playback -
so on any source whose selected/default stream did not map to a native
track it silently picked audio in the scraping language instead of the
user's preferred one. Plex's item-level `audioLanguage` reached the same
tier, after 2dfebf32b and 7ae293631 had already settled that the server
folds every preference level into the stream `selected` flags.

Drop the tier and the plumbing that only fed it:
TrackSelectionPriority.perMedia, MediaItem.audioLanguage and
subtitleLanguage, PlexMetadataDto.audioLanguage/subtitleLanguage/
subtitleMode, and the Jellyfin PreferredMetadataLanguage mapping. The
order is now carried selection, server-selected or default source
stream, account preference, native default. Persisted cache entries
holding the old keys decode unchanged.
2026-09-03 13:08:46 +02:00
edde746 7e583ef05a style(tv): sit the detail track status on the action row's bottom edge 2026-09-03 13:03:25 +02:00
edde746 2132c23fab fix(tv): put the detail track status at the screen edge, not inside the hero column 2026-09-03 13:03:25 +02:00
edde746 c4be378219 fix(detail): make the track status read-only and right-align it
The action row's track status was a button that opened a pre-play
chooser, but on TV the hero describes whichever episode the rail has
focused, and focusing the row hands the hero back to the show, so there
was never a per-episode target for the control to act on. It also sat
inline with the buttons in muted grey, reading as a sixth, low-contrast
action.

The status is now plain text at the row's far end, right-aligned to the
hero's text column, in the hero's own ink, off the focus path. The
chooser sheet, the per-screen choice, and the Play pass-through go with
it, along with the overlay-host and track-tile changes that only served
the chooser.
2026-09-03 13:03:24 +02:00
edde746 f967b9b2f2 feat(detail): preview and choose the audio and subtitle tracks before Play
The detail screen gave no hint of what pressing Play would do with the
file: which audio track and which subtitles the player would start with,
or the picture format. Changing tracks meant starting playback first.

The action row now ends with a status line — picture labels, then the
audio and subtitle rows the player will select — computed by the player's
own selection ladder (TrackSelectionService) over the server's stream
rows, so it agrees with what the player then does. Select opens a
pre-play chooser with the track sheet's two columns; Play and rail
activation pass the pick to the player as its navigation-tier preference,
and a pick carries across episodes by semantics, forced-ness included.
Plex listings carry no stream rows, so a focused episode is probed once,
debounced, and cached for the screen.

Along the way: MediaStream keeps the server's `selected` apart from the
container `default` flag (they were OR'd, so the account's pick was
indistinguishable from the default track); the overlay sheet host no
longer overrides a row that claimed focus itself.
2026-09-03 13:03:13 +02:00
edde746 eafacff3be fix(tv): let the detail hero and rail describe the focused episode once
The TV show detail page spent its hero rows on information that never
changes while browsing episodes (the show's genres, the file's stream
quality) while the rail cards repeated the show name on every card and
pushed the episode title into a truncated subtitle. The Play button also
kept naming the on-deck episode while the hero described the focused one.

Rail cards inside the show now headline the episode title with
`S1 E3 · 23min` as subtitle; genres and quality labels move from the hero
to the details sheet (which now also names the episode and takes the show's
genres explicitly); Play follows the hero's episode and falls back to
on-deck when nothing is focused. The freed rows go back to the logo and the
third description line.

close #2217
2026-09-03 12:57:39 +02:00
edde746 33d845dcdd feat(tv): show the focused episode's title in the detail hero
On the TV detail screen the hero follows the focused episode: its metadata
line and description update, but the episode's own title is nowhere in the
hero. The only copy is the rail card's subtitle, which truncates for most
titles, so the viewer cannot read what they are about to play.

Add a title line between the show logo and the episode metadata line,
reserved in the hero's height budget like the other rows, announced in the
hero's accessibility label, and inside the focusable info block that opens
the details sheet. The hide-spoilers path no longer substitutes the episode
title for a missing summary, since the title line already names it.

close #2217
2026-09-03 12:57:05 +02:00
edde746 0134d9dbc0 fix(tv): tighten the spacing between mpv.conf editor rows
The remove button's default 48px tap target set each row's height, adding
about 12px of dead space around every 36px field on top of the row gap.
Constrain the button to 36px so the field decides the row height, and trim
the field padding and row gap.
2026-09-03 12:01:37 +02:00
edde746 4fd9034e8e feat(tv): edit mpv.conf line by line with the system keyboard on TV
On Android TV the mpv.conf editor used Plezy's own on-screen keyboard
instead of Gboard, and the only way to get a long conf onto a TV was to
retype it key by key (#2232). The system keyboards cannot host the
multiline editor: FireTVIME has no newline key, and both FireTVIME and
Gboard could wipe a pre-filled multiline field on the first keystroke
after the first-show restartInput.

On TV, render one single-line native field per line, with a line-number
gutter, a remove button per row and an Add line button. Rows open their
keyboard on Select only, so D-pad traversal does not raise it. A value
arriving with newlines (a paste from a phone remote) is split into rows
and typing continues on the last one; the rows are keyed so the split
does not rebind another row's input host. The keyboard's action key does
not insert a row: the app never learns which action the IME sent, and
FireTVIME reports Back as `previous`, so an insert-on-action editor kept
opening rows on every Back. Completion hands focus down instead — the
next row, or Add line after the last one.

TvTextInputController gains focusAndOpenTextInput() so a row the app
creates (Add line, paste split) can be typed into without a second
Select; it activates after the focus request lands, since the host's
focus sync deactivates an unfocused field.

Verified on a Google TV box (Gboard, Android 14) and a Fire TV Stick
(FireTVIME): rows keep their text on open, Back closes the keyboard
without side effects, edits persist across leaving the screen. Phone,
desktop and pointer platforms keep the text-area editor.

close #2232
2026-09-03 11:56:03 +02:00
edde746 51beb5ac4b fix(player): leave Plex subtitle auto-selection to the server
Plex Web reads only the `selected` flag PMS stamps on each stream; the
account's autoSelectSubtitle is applied server-side when that flag is
computed. Plezy's Priority 2 already trusts the same flag, but once
playback read AccountPreferences the Plex mode became non-null and could
reach the client-side profile pass whenever there was no media info or no
embedded subtitle streams - forcing subtitles off for Plex's 0 ("manually
selected") or on for 2, over a decision the server had already made.

TrackSelectionService now skips the profile subtitle mode for Plex items;
MediaBrowser keeps it because those servers do not pre-select.
2026-09-03 11:37:12 +02:00
edde746 b43cd81102 chore(profiles): let AccountPreferencesController own the active user's playback preferences
UserProfileProvider kept a second cache of the active user's audio/subtitle
defaults beside AccountPreferencesRepository, re-implemented the active
profile/connection listener wiring and Plex Home token resolution, and for
Plex Home profiles bypassed the shared cache entirely (fetching /user with
its own client), so a write in the Account preferences screen never reached
Home-profile playback.

The controller now resolves the active account (the head of its sorted
account list), loads it through the one repository on attach, on profile
switch, and when the account's token changes, and exposes it as
activePreferences; ensureActiveLoaded replaces initialize() for the startup
gate. Playback, offline waiting, and logout storage clearing read the
controller directly; UserProfileProvider, the per-backend user-profile DTOs,
PlexAuthService.getUserProfile, and JellyfinClient.fetchUserProfile are
deleted.

The ranked defaultAudioLanguages/defaultSubtitleLanguages lists go with
them: Plex Web never reads them, PMS applies them when it stamps `selected`
on streams, and Plezy already trusts that flag ahead of the profile. They
only refined a best-effort audio fallback for Plex items with no media
info, which the primary language covers; on that path a second preferred
language is no longer tried before the file's default track.

Plex Home profiles now read /user/profile with the switched token through
the same cache as local profiles.
2026-09-03 11:36:56 +02:00
edde746 5112d71820 chore(player,cards): share budgeted card realization and scope player subscriptions in bags
The memo-lookup, scroll-budget, skeleton-scheduling sequence was copied
in the paginated grid, the browse tab, and hub rows; SkeletonUpgradeScheduler
now owns it as realizeBudgeted with per-site skeleton and card builders,
keeping the hub's focus salt and padded skeleton.

The video player screen held fourteen nullable subscription fields and
listed them twice when cancelling; they are two lists (player re-wire,
media controls) snapshotted and cleared before cancellation, with the
screen-lifetime Apple TV and sleep-timer subscriptions left as named
fields. Live seek and subtitle switches open through one helper, and the
initial live open applies the shared stream options instead of an
inline property write.
2026-09-03 11:36:29 +02:00
edde746 ab477c971d chore(relay): generate reconnect token size and version predicate from the protocol spec
The 32-byte reconnect token and its 43-character base64url shape were
pinned by hand in both server/main.go and the Dart peer service, and the
supported-version check was spelled out three times in Go. The spec now
carries reconnectTokenBytes and the generator emits the token size,
encoded length, and validator for Dart plus the size and
supportedRelayProtocolVersion for Go, failing before writing either
target when the key is missing. The last handwritten error code in lib/
('not_in_room') uses the generated constant, and releaseSession's
in-flight join is a FutureCoalescer.
2026-09-03 11:36:29 +02:00
edde746 59f44d9ae1 chore(trackers,downloads): share rating request matching and download queue feedback
MDBList and Trakt repeated the rating type, entry matching, and request
body construction; tracker_rating_match.dart now owns them with the
service label passed in. The collection detail screen re-implemented
fetchAndQueueListDownload minus its cellular-blocked branch and now uses
it, and the three showDownloadOptionsAndQueue call sites share
queueDownloadWithFeedback, so the detail action buttons surface a queue
failure as an error snackbar instead of an unhandled async error.
2026-09-03 11:36:29 +02:00
edde746 de39e15e36 chore(sheets): build flat sheets on BottomSheetPageScaffold
Seven sheets hand-rolled the Column(min) + BottomSheetHeader + Flexible
tree that BottomSheetPageScaffold already produces; each now passes its
header arguments to the scaffold. No onBack is supplied, so the widget
tree, height, and focus behaviour are unchanged.
2026-09-03 11:36:29 +02:00
edde746 7b1b0d766b chore: reuse drainPages, SerialFutureQueue, and FutureCoalescer for hand-rolled equivalents
Three MediaBrowser pagination loops re-implemented drainPages; it gains an
onPage hook (fires after intermediate pages only) so the two
progressive-render loops fit as well as the episode queue. Five
handwritten FIFO future tails (companion lifecycle, Seerr persistence,
tracker write lock, companion host-auth commit, Watch Together message
routing) become SerialFutureQueue, and the companion peer service's
disconnect/dispose in-flight joins become FutureCoalescer.
2026-09-03 11:36:29 +02:00
edde746 960adf42f2 perf(downloads): resolve cache scopes once per server and build one deletion plan
Loading the downloads list re-resolved the backend and profile scope for
every row, and the provider's bulk lookup only tried the public key, so
every MediaBrowser leaf fell back to a per-item lookup that resolved the
scope again (twice more for episodes and tracks). Startup was therefore
O(downloads x profile bindings) in database selects.

getAllPinnedMetadata now resolves each distinct server once, covers
queued and in-progress rows too, and returns the scope snapshot beside
the items; hydration tries the exact compound key, then the public key,
then the existing per-item fallback, and reuses the snapshot for parent
rows. findProfileScopeId prefetches bound connections in one select while
keeping binding precedence. A provider test pins the select count as
constant across download count.

Deleting a container also read the row, resolved metadata, and queried
its children twice; deleteDownload now loads them once and threads the
snapshot through the file deletion, keeping the cancel/progress/file/row
order intact.
2026-09-03 11:36:29 +02:00
edde746 d181bd34ae chore(mpv): share desktop startup options and stop forwarding unused Android events
Linux and Windows each wrote the same eleven pre-initialize mpv options
(keep-open, idle, input disabling, OSC, ytdl, audio fallback, and the
audio-only set); ApplyCommonStartupOptions in mpv_player_common.h owns
them once, with the ytdl security rationale kept in one place. Rendering,
windowing, HDR, and log-level options stay per platform.

The Android JNI event thread forwarded every mpv event to Kotlin, where
MpvEvent modelled ten variants but MpvPlayerCore consumes four. The
native switch now forwards only START_FILE, FILE_LOADED, and
PLAYBACK_RESTART (END_FILE keeps its own path), and the unused variants
are gone.
2026-09-03 11:36:29 +02:00
edde746 b27cab4510 build: read Windows libmpv pins from the lock and share the Linux fetch and version parsing
windows/CMakeLists.txt repeated the mpv-build key, asset base, and both
SHA-256 values that mpv-build.lock.json already owns, and the pin mover
never touched it, so a lock bump left Windows silently stale. CMake now
reads artifacts.windows with string(JSON) (floor raised to 3.19) and the
Windows native cache key hashes the lock; a workflow guard rejects
hand-pinned checksums.

The byte-identical Linux libmpv fetch heredoc in build.yml and ci.yml
moves to scripts/fetch_linux_libmpv.py, which verifies the checksum
before extracting and is what the packaging script now points local
builds at. The package-windows and release jobs and
linux/packaging/build-packages.py read the version through
scripts/pubspec_version.py instead of three looser regexes.
2026-09-03 11:36:29 +02:00
edde746 17c877c973 chore(website): share page metadata, model screenshot devices as records, honor reduced motion
Home, privacy, and scan each repeated the same 13 title/canonical/Open
Graph/Twitter tags; PageMetadata now emits them once with route-specific
title, description, url, and an optional noindex. Screenshots keeps one
record per device instead of four parallel arrays plus a lookup table.

FAQ hash jumps and screenshot arrows forced behavior: 'smooth' from JS,
which bypasses the prefers-reduced-motion override in layout.css; both
now defer to CSS scroll-behavior. Removes the unused adapter-auto
dependency.
2026-09-03 11:36:29 +02:00
edde746 1373c299df chore(plex): hydrate Plex Home users at bootstrap through PlexHomeService
ConnectionBootstrap kept a private cache read/fetch/write path for Plex
Home users beside PlexHomeService, which already owns refresh
coalescing, generation checks, durable writes, and publication. The
bootstrap now keeps only the one-time legacy cache migration, then
reloads the service from storage or asks it to refresh, and reads the
hydrated users from the service snapshot. A failed hydration clears the
service's empty cache slot before the migrated account is removed.
2026-09-03 11:36:29 +02:00
edde746 7e84df6385 perf(livetv): rebuild the live timeline once per second instead of per position tick
The timeline bar rendered whole seconds but rebuilt on every ~250ms
position emission, so three of four rebuilds produced identical output.
It now derives a distinct position-seconds stream, the same pattern
ContentStrip uses for its chapter index, and rebinds only when the player
changes; streamStartEpoch changes still apply on the next build.
2026-09-03 11:36:29 +02:00
edde746 e8e67df379 chore(plex): map metadata type codes and fetch detail metadata in one place
The movie/show/season/episode/artist/album/track to 1/2/3/4/8/9/10 table
was repeated in the query translator (forward and inverse), collection
creation, and the metadata-edit adapter; PlexMetadataType now owns
forKind/kindFor and each caller keeps its own unsupported sentinel.

getMetadataWithImagesAndOnDeck and _getMetadataWithImages shared the
endpoint, cache key, query flags, first-row parsing, and library-section
stamping and differed only by includeOnDeck; they are one private typed
fetcher returning (metadata, onDeckEpisode), so fetchItemWithOnDeck no
longer reads a string-keyed dynamic map.
2026-09-03 11:36:29 +02:00
edde746 c70f88cee0 fix(android): transcode mpv strings as standard UTF-8 across JNI
Emoji and other supplementary-plane characters in titles, file names, and
subtitle paths were corrupted on the Android MPV path, and malformed bytes
from mpv logs could abort under CheckJNI. NewStringUTF/GetStringUTFChars
speak JNI's modified UTF-8, not the standard UTF-8 mpv produces and
consumes; the native lead-byte filter and the Kotlin surrogate scrubber
ran on the wrong side of that conversion and could not recover it.

Both directions now go through UTF-16 (NewString/GetStringChars) with a
small JNI-free transcoder that replaces malformed input with U+FFFD, the
same policy as shared/cpp/sanitize_utf8.h on desktop. The header is
covered by the Android host native test harness.
2026-09-03 11:36:29 +02:00
edde746 54efd4fe73 fix(emby): stop leading the transcode codec list with AV1
Capping quality against an Emby server on macOS (or any device with an
AV1 decoder) failed with HTTP 500: Emby takes the first entry of the
TranscodingProfile codec list verbatim and has no AV1 encoder. Jellyfin
rotates admin-disabled codecs to the back, which is what the AV1-first
list relied on. Gate AV1 on that dialect behavior.

close #2230
2026-09-03 08:02:09 +02:00
edde746 deaa06826c fix(player): stop corrupting high-bitrate HEVC at large keyframes on MediaTek Fire TV
High-bitrate HEVC episodes showed smeared frames and green flashes for the
first 10-15 seconds of playback on the Fire TV Stick 4K Max 2nd gen with the
mpv backend, while ExoPlayer played the same file cleanly (#2227). The
reporter's clip failed at exactly two timestamps on every run: the two IDR
frames larger than 1 MiB.

The FFmpeg mediacodec decoder never set "max-input-size" on the codec
format, so OMX.MTK.VIDEO.DECODER.HEVC allocated 1,052,672-byte input buffers
and FFmpeg split any larger packet across several queueInputBuffer calls.
The MediaTek VPU treats each buffer as a complete access unit, rejects the
remainder ("invalid u4BufferAccumulated"), fails the slice and conceals with
lost-picture references until the next IDR. ExoPlayer is unaffected because
media3 sizes HEVC input buffers at max(2 MiB, w*h*3/4) and never splits a
sample.

Move the native pin to edde746/mpv-build@83879375c9, which sizes the
buffers the same way ExoPlayer does for H.264, HEVC, MPEG-4, VP8, VP9 and
AV1 and logs a warning if a packet still does not fit. Verified on the
AFTKRT with the reporter's clip: zero VPU errors and no lost pictures across
repeated runs, and an H.264 file with ~1 MB keyframes still plays on the
MediaTek AVC decoder; both files also play on a Pixel 7 (Exynos C2). Apple,
Linux and Windows assets are unchanged (their content keys did not move).

close #2227
2026-09-03 07:47:58 +02:00
edde746 d371bfed3a fix(networking): connect in resolver address order with a Happy Eyeballs race
On a dual-stack network Plezy pinned API traffic and the notification
websocket to IPv4 while the native Plex apps and the media stream used
IPv6. dart:io looks up A before AAAA and connects in arrival order, so
the AAAA address was never tried when the IPv4 path answered within
250 ms.

Install an HttpClient.connectionFactory that does one lookup (keeping
the resolver's RFC 6724 order), interleaves families per RFC 8305 and
races candidates 250 ms apart. The task is returned before the lookup
starts so connectionTimeout and cancel cover resolution. TLS is done in
the factory since the SDK skips it once one is installed; proxied
requests stay plain. The library-event and companion-remote websockets
get the same client because WebSocket.connect otherwise builds its own.

Supersedes #2233.
2026-09-03 07:35:34 +02:00
edde746 aeb06c2c9e fix(audio): smooth the passthrough audio clock on the mpv Android backend
Playing lossless passthrough audio (TrueHD, DTS-HD MA) on an Nvidia Shield
running Android 9 dropped a video frame every ten to twenty seconds with the
mpv backend, while lossy passthrough and PCM were unaffected and A/V sync
never moved (#2202).

IEC 61937 and raw passthrough tracks in ao_audiotrack never use
AudioTimestamp; their clock is AudioTrack.getPlaybackHeadPosition() taken at
face value, which on a direct track is the HAL's render position and
advances in HAL-period steps - 8192 frames at 192 kHz (42.7 ms) for the HBR
output on the Shield, longer than a 24p frame. The AO thread samples it once
per chunk and feeds it straight into the end time mpv schedules video
against, so a forward snap larger than a frame's remaining lead makes mpv
drop that frame; lossy passthrough runs at 48 kHz where the period stays
below a frame.

Move the native pin to edde746/mpv-build@af74e1906e, which estimates the
passthrough position the way media3 does: a ten-sample average of
(head - clock) offsets taken at least 30 ms apart, so a period-sized step is
spread over the window instead of landing on one frame. PCM tracks keep
their AudioTimestamp path. Apple, Linux and Windows assets are unchanged
(their content keys did not move).
2026-09-03 05:35:03 +02:00
edde746 9a9b89082b fix(home): apply the grid spacing setting to hub rows
Grid Spacing (Settings > Appearance) only changed the library grid. The
home, explore, detail and library-recommended rows were byte-identical
across Tight/Normal/Spacious because #2083 deliberately opted hub-row
cell packing out of the setting and hard-coded a 2px pad per card, and
the TV home rail never read the setting at all.

Treat a hub row as one row of the grid. MediaGridDelegate.cellWidth
packs row cells with the same gutter MediaGridGeometry uses, so a row
and the "see all" grid behind it render the same card at every setting.
HubSection renders max(4, gap) between cards - Tight keeps its historical
4px so nothing moves on update, Normal/Spacious render the grid's 6/12px
gutter - and feeds the same gap into its D-pad item extent and card memo
epoch. TvBrowseRail threads GridSpacing through metricsForHub,
maxActiveRailHeight and estimateHeight; non-full-card rails use the
setting scaled with the rest of the rail metrics, full-card rails keep
their own scale-derived gutter like full-bleed grids. The spotlight and
detail rail height reservations pass the setting too so reserved
heights track the narrower cards. Both surfaces watch the pref, so rows
re-lay out live.

close #2226
2026-09-02 22:38:11 +02:00
edde746 c79ff68a7b perf(tv): shape spotlight text once per swap instead of twice
Every D-pad step on the TV home ends with a spotlight swap, and on a
low-end box that frame ran over budget: 12 % of UI-thread CPU during
navigation was paragraph layout, most of it shaping strings a second time
to measure them before the render tree shaped them for real.

- FittingTitleText no longer lays the title out to learn whether it fits:
  Text wraps and ellipsizes at maxLines, so the box can only overflow
  vertically, and one cached one-glyph line height settles that. Only a
  box shorter than maxLines lines still searches, now bracketed by the
  proportional shrink of the base layout and stopped at a 0.25 px
  tolerance instead of a fixed 12 bisections.
- FittedMetadataLine and inlineRatingBadgeWidth measure through a small
  bounded cache keyed by text, style, scaler and direction, so separators,
  type labels, certifications, runtimes and years measure once per
  session.
- MediaHub.isContinueWatchingHub/usesContinueWatchingAction memoize their
  regex tokenisation per key; every card build and step re-asked them.

Cold navigation on the Android 14 box (30 steps over three hubs): build
312-316 ms -> 284-285 ms, layout 530-540 ms -> 515 ms, UI frame p90
7.5-7.9 ms -> 6.8-7.2 ms.
2026-09-02 22:38:11 +02:00
edde746 7e1f18f93f perf(android): skip the semantics tree when no enabled accessibility service can read it
Android enables Flutter semantics for any bound accessibility service, so a
TV running the Projectivy Launcher (whose service only wants foreground-app
events) paid for a full semantics tree on every frame that touched a node:
on an Android 14 box, 2 ms per frame, ~20 ms on each spotlight swap, and a
third of the navigation-time GC churn.

The Android side now reports whether any enabled service can consume the
tree (touch exploration, an accessibility tool, or spoken/braille/audible/
visual feedback; an empty list means UiAutomation, which reads it too), and
AssistiveTechnologyService closes a SemanticsTreeGate on the app binding
when none can. The gate sits under SemanticsBinding.semanticsEnabled, so the
pipeline owner drops the semantics owner exactly as if the platform had
turned accessibility off; explicit ensureSemantics clients (the debug handle
for Maestro) always win. Re-evaluated on platform toggles, service-list
changes (API 33+) and resume.

Cold navigation on the box with Projectivy's service bound, 30 D-pad steps
over three hubs: SEMANTICS phase 700-770 ms -> 0, UI frame max 31-37 ms ->
25 ms, GC 3.0 s -> 2.2-2.6 s.
2026-09-02 22:38:11 +02:00
edde746 5bd742c8b8 fix(tv): glide D-pad row and hub scrolls in 150ms on Android TV instead of 500ms
D-pad navigation on Android TV felt sluggish next to Projectivy and the
Fire TV launcher: every step of the home rail and hub rows trailed the
focus border in a 500ms ease-out, and hub moves deferred the vertical
glide by a frame. The 500ms was measured from the tvOS focus engine
(f357be407) and applied to every platform; Leanback prices a one-card
step at roughly 100-150ms, so the same glide reads as input lag on a
D-pad.

FocusTheme.navigationScrollDuration now vends the per-platform value:
Apple TV keeps the measured 500ms, everything else glides in 150ms.
TvBrowseRail and HubSection use it for row and hub-list scrolls, and a
hub move starts the vertical animation in the same frame when the
build-time section offsets are already known.

Measured with screen recordings on a Shield TV (2019) and a Fire TV
Stick 4K Max: row motion per press 470-510ms before, 135-150ms after;
hub move 480ms before, 120-150ms after.
2026-09-02 22:38:11 +02:00
Jan HaiderandGitHub 5c37c72a08 fix(tvos): adopt UIScene lifecycle for Xcode 27 (#2225) 2026-09-02 22:16:11 +02:00
edde746 22c1ad56d3 fix(player): play Dolby Vision profile 7 and DV+HDR10+ files on MediaTek Fire TV devices
Dolby Vision profile 7 titles (UHD Blu-ray remuxes, typically TrueHD or
DTS-HD MA) never showed a frame on the Fire TV Stick 4K Max 2nd gen with the
mpv backend: the spinner stayed forever while audio came up. Profile 8.1
titles carrying HDR10+ on the same device went fully black, controls
included, with audio playing.

Both are the FFmpeg mediacodec DV packet filter. It emitted every rewritten
access unit with a five-byte "00 00 00 00 01" start code - the leading zero
was copied once as head bytes and again as part of the first NAL region -
and MediaTek's VPU rejects such units ("H265_decode invalid NALU"), so the
profile 7 conversion and strip paths both produced nothing. And the filter
never dropped in-band HDR10+ SEI, which these chipsets cannot take next to
the RPU on the native DV decoder (#1296 on the ExoPlayer path).

Move the native pin to edde746/mpv-build@0ad6388c, which counts that zero
once and strips the ST 2094-40 SEI whenever a video/dolby-vision decoder is
selected. Verified on an AFTKRT with the published armeabi-v7a asset: a
P7.6 remux sample decodes on both the HEVC (strip) and DVHE.STH (convert)
paths with no VPU errors, and a P8.1 + HDR10+ remux plays on the DV decoder
with a single output-format change. Apple, Linux and Windows assets are
unchanged (their content keys did not move).

close #2176
2026-09-02 20:59:02 +02:00
edde746 3b3a816de0 fix(player): deinterlace software-decoded video on Android and Linux
Enabling Deinterlacing had no effect on Android and Android TV for
interlaced MPEG-2 (and any other software-decoded stream): mpv asks
libavfilter for bwdif, and the pinned Android and Linux FFmpeg builds were
configured with --disable-filters, so the request failed with
"filter 'bwdif' not found" and the frame passed through combed. Windows
and Apple ship FFmpeg with the filter, which is why the same file
deinterlaced on Windows.

Move the native pin to edde746/mpv-build@9bfbcd69, which builds bwdif into
the Android and Linux FFmpeg. Apple and Windows assets are unchanged
(content-addressed keys did not move). Verified on a Shield TV Pro with the
published arm64 asset: 480i MPEG-2 now deinterlaces cleanly.

close #2224
2026-09-02 20:05:38 +02:00
edde746 3fc229cd6c fix(android): tell external players to start unwatched items from the beginning
With an external player on Plex, starting a never-watched episode could
open a "resume playback?" prompt at the position the previous, unrelated
file was exited. The intent carried no start hint when the item had no
resume point, so players with their own bookmark store (Zidoo native
player, VLC) consulted it - and every Plex part URL ends in the same
`file.<ext>`, so those bookmarks collide across items.

Send `from_start=true` on fresh launches and `from_start=false` beside
`position` when resuming, matching the Zidoo integrations in
jellyfin-androidtv-zidoo and PlexToZidoo. MX, Just Player and mpv ignore
the extra.

close #2223
2026-09-02 15:43:04 +02:00
edde746 b5186624b8 fix(android): output 5.1 PCM at 44.1 kHz on the mpv backend instead of downmixing to stereo
AAC/AC3/EAC3 5.1 tracks at 44.1 kHz played as stereo through mpv on Android:
ao_audiotrack sized its buffer as 75 ms aligned to the sample size, which at
44.1 kHz is not a whole number of 6-channel frames, so AudioTrack refused it
("Invalid audio buffer size") and mpv fell back to the stereo-only OpenSL ES
output. 48 kHz sources (DTS, or audio-samplerate=48000) happened to align.

Pin mpv-build 1779135, which frame-aligns the audiotrack PCM buffer
(edde746/mpv-build@d89cfbb). Only the Android libmpv key moves.

close #1445
2026-09-02 14:16:20 +02:00
edde746 0c809491c7 fix(watch-together): declare rate changes to the room and stop the host clock during its own stalls
Rooms drifted in speed and kept pausing on poor links. A 2x long-press or a
speed pick on any peer propagated with no attribution, so it read as the app
changing speed on its own; a host whose cache starved let mpv resume its player
on its own timetable while the room waited, so every recovery re-anchored ahead
of the guests and they hard-seeked in a loop; and a wall-clock step on a guest
made every host anchor read stale and seeked the file by the size of the step.

Rate intent is now declared by the screen (speed sheet, keyboard, long-press,
media controls) through WatchTogetherProvider.onLocalRate instead of being
inferred from the player's rate stream, matching how seeks already work. The
expectation ledger no longer tracks rate at all, so a nudge, a default-speed
apply or a late ack can never become a room rate change, and a promoted host
adopts the room rate rather than its player's momentary one. Remote rate
changes surface as "<name> set the speed to <rate>".

The host pauses its own player when it stalls, anchors the room there, raises
mpv's cache-pause-wait to 4s, and holds the resume until it has buffered three
times the stall it just had. Heartbeats during a sub-grace blip keep
extrapolating the previous anchor instead of re-anchoring on a frozen position.

The sync layer's clock is monotonic. ClockSync discards its window when a
sample's offset moves further than the round trips involved can explain, and a
guest that receives an anchor reading implausibly old holds corrections and
re-converges instead of seeking.
2026-09-02 12:31:27 +02:00
edde746 5860079b43 fix(plex): include personal media libraries in search results
Searching for items in a Plex "Other Videos"/home-video library returned
nothing, while the same query worked in Plex Web. `/library/search` with
`searchTypes=movies,tv,music` skips libraries on the Personal Media agent;
they only answer to the `otherVideos` category.

Add `otherVideos` to the search request and treat personal-media rows
(`*.agents.none://` guids) as their own category when a saturated response
is supplemented, so a large movie library cannot starve home videos and
vice versa.

close #2216
2026-09-02 11:31:47 +02:00
edde746andClaude Fable 5.1 ce03857588 fix(seerr): take permissions from /auth/me, not the partial login body
A local Seerr account with request rights could sign in to Plezy but never
saw the Request action. POST /auth/local loads only the columns it needs to
check the password, so the entity it returns carries the class default
permissions of 0 (and the email as display name); the session stored that
snapshot and the detail screen gated Request on it.

Sign-in now ignores the login body and reads the user back through
GET /auth/me with the fresh cookie, for every method. SeerrUser.permissions
is required, so a user without a mask fails sign-in instead of persisting 0.

Two related gaps kept an affected session broken until a manual reconnect:

- Seerr answers an expired session with 403, never 401, so the silent
  re-auth never ran. A 403 now re-auths once GET /auth/me confirms the
  cookie is dead; a 403 from a live session stays a permission denial so a
  Quick Connect session is not unlinked over one.
- Nothing refreshed a stored session's permissions. The account provider
  now re-reads the user on bind, so sessions persisted with 0 by earlier
  builds, and admin-side permission changes, reach the Request action on
  the next launch.

close #2213

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:31:47 +02:00
edde746 200d896bea build: switch every platform's libmpv supply chain to the unified mpv-build repo
Plezy consumed mpv through four unrelated supply chains: an MPVKit fork
via SwiftPM for the Apple platforms, a libmpv-android fork's AAR for
Android, an in-CI from-source build for Linux, and an unpinned
sourceforge mpv-dev 7z for Windows. All four now consume the same
per-commit, content-addressed binaries from
https://github.com/edde746/mpv-build, pinned to one commit and built
from one set of pinned sources (mpv v0.41.0 on Apple/Android/Windows,
ffmpeg n8.0.1, our libass fork).

- Apple: the SwiftPM package moves from edde746/MPVKit to
  edde746/mpv-build across the ios/macos/tvos projects;
  scripts/set_mpvkit_revision.sh becomes set_native_revision.sh, writes
  every pin site plus the new repo-root mpv-build.lock.json, and
  tvos/scripts/wire_mpv.rb derives the package repo from the locks.
- Android: the mpv Kotlin API and JNI glue move in-app under
  android/libmpv (repackaged com.edde746.plezy.libmpv, exports renamed,
  shrinker rules covered), and the module downloads per-ABI native
  tarballs (lib/*.so incl. libc++_shared.so + include/) driven entirely
  by mpv-build.lock.json, with a PLEZY_LOCAL_MPV_DIR escape hatch for
  locally built artifacts. The fork AAR and its Maven coordinates are
  gone.
- Linux: CI downloads the prebuilt self-relocating libmpv prefix
  (lock-driven, sha256-verified) instead of compiling mpv/ffmpeg/dav1d/
  libplacebo/shaderc from source; linux/packaging/build-libmpv.sh and
  its test are deleted and native-inputs.json shrinks to the simdutf
  entry the CMake builds still fetch.
- Windows: both arches FetchContent the mpv-build dev zips with
  URL_HASH enforcement, replacing the checksum-less sourceforge
  download and its ARM64 7-Zip special case; guard scripts updated.

The lock plus the Apple pin sites all point at mpv-build commit
d7c3d559, whose manifest was verified asset-by-asset against the
published release digests (17/17 match). Verified locally: wire and
pin-script suites green, runtime-input checks green, all four Android
ABI tarballs downloaded/verified/extracted through the real Gradle
tasks, the Windows FetchContent block exercised end to end through
cmake, the Linux asset hash and layout checked against the workflow
contract, and xcodebuild resolved the flipped SwiftPM graph with
SwiftPM validating every binary checksum.
2026-09-02 11:31:47 +02:00
edde746 8fe7e4bcfd fix(logs): render log entries lazily to stop OOM kills on the logs screen
Opening View Logs with a full 5 MiB buffer laid out the entire log as one
paragraph, freezing the frame for tens of seconds and using ~1 GB of glyph
data — the OS killed the app on low-memory phones and TVs (watchdog/OOM).

Render one Text.rich per entry in a SliverList.builder so only the visible
slice is laid out, keeping selection through a SelectionArea. Also cap the
copy-to-clipboard payload at 256 KiB (newest lines kept) to stay under
Android's ~1 MiB binder transaction limit.
2026-09-02 11:31:47 +02:00
edde746 d2b5039bb3 fix(explore): stop d-pad on detail prose instead of skipping to the next button
On TV, pressing down on the Explore detail screen jumped straight from the
action bar to the next focusable button, scrolling the description and
background prose past unread. The overview and background sections are now
collapsible focus stops wired into the d-pad chain in both directions, so a
viewer can stop, read, and expand them before moving on.

close #2199
2026-09-02 11:31:46 +02:00
edde746 12b8c7e7ef fix(windows): log tone-map input churn on Adreno for #2191 diagnosis
4K HDR->SDR playback on Windows-on-ARM stutters and flickers because libplacebo regenerates its tone-map and gamut-map LUTs on every drawn frame. The two earlier workarounds targeted peak detection and HDR10+ metadata, but both regenerating LUTs log at info level, which libplacebo only does for non-dynamic LUTs, so something else is moving the LUT keys. The same libmpv build on a Snapdragon X Elite CRD (Adreno X1-85, driver 31.0.160.0) shows no churn at all, so the trigger is specific to the reporter's machine.

Add an HdrProbe to the Windows runner that polls the two LUT inputs every 250 ms - mpv's video-target-params (destination colour space, derived per frame from DXGI_OUTPUT_DESC1 and the DisplayConfig SDR white level) and video-out-params (source HDR metadata) - plus the raw DXGI output description, SDR white level and factory IsCurrent() state once a second, and reports every change and a 10 s summary into the app log as hdr-probe lines. Change streams are rate limited so an uploaded log stays within the relay's 1 MiB.
2026-09-02 03:59:26 +02:00
edde746 e9988165df style: run the repo formatters over the Dart and Kotlin stragglers
CI's formatting jobs were red on main: dart format wanted
media_card_grid_spacing_test.dart collapsed, and ktlint wanted three
Kotlin expression bodies on their signature line plus one import moved
into lexicographic order.

Pure formatter output from `dart format` and `scripts/format_native.sh
--fix`; no behavior change.
2026-09-01 16:21:07 +02:00
edde746 582d419618 fix(libraries): keep a sidebar library selection that mounts the Libraries tab
Choosing a hidden library from the sidebar on a fresh launch opened the
topmost visible library instead, and reordering the sidebar changed which
wrong library appeared. A second attempt worked.

The first visit to the Libraries tab mounts LibrariesScreen, and two
post-frame callbacks then run in registration order: MainScreen's, which
applies the requested library, and the one initState registers during that
frame's build, which picks the saved or topmost-visible default. The second
one overwrote the first — and a hidden library can be neither default, so
the selection was always lost.

Initialization now treats an existing selection as authoritative, both on
entry and after each of its awaits, so a selection arriving while it is
suspended (the refresh() stale-resume path) also survives.

close #2207
2026-09-01 16:11:09 +02:00
edde746 72d15b96a2 fix(linux): spell the maybe_unused attribute on the plugin keepalive globals
The Linux build stopped compiling everywhere: d9e31fd1a wrote the
attribute as [[maybe unused]], with a space instead of an underscore.
An attribute-list is comma-separated, so two bare identifiers are a
parse error rather than an ignorable unknown attribute, and
linux/runner/mpv/mpv_plugin.cc reaches every Linux build.

Use [[maybe_unused]], which is what clang 23's -Wunused-but-set-global
actually checks for, so the diagnostic the original change was chasing
stays suppressed.
2026-09-01 16:01:55 +02:00
K1ngfish3randGitHub d9e31fd1a4 fix for llvm23 (#2212) 2026-09-01 15:38:21 +02:00
484 changed files with 23992 additions and 8012 deletions
+24 -19
View File
@@ -414,9 +414,7 @@ jobs:
- arch: arm64
runner: windows-11-arm
flutter_setup: git
native_cache_path: |
build/windows/arm64/_deps
build/windows/arm64/mpv-dev-arm64
native_cache_path: build/windows/arm64/_deps
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -427,12 +425,7 @@ jobs:
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ matrix.native_cache_path }}
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt') }}
- name: Install 7-Zip
if: matrix.arch == 'arm64' && steps.windows-native-cache.outputs.cache-hit != 'true'
shell: pwsh
run: choco install 7zip -y
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-windows-native-${{ matrix.arch }}-${{ hashFiles('windows/CMakeLists.txt', 'mpv-build.lock.json') }}
- name: Setup Flutter
if: matrix.flutter_setup == 'action'
@@ -585,10 +578,10 @@ jobs:
- name: Read version from pubspec
id: version
shell: pwsh
shell: bash
run: |
$v = (Select-String -Path pubspec.yaml -Pattern '^version:\s*(\S+)').Matches[0].Groups[1].Value -replace '\+.*'
echo "version=$v" >> $env:GITHUB_OUTPUT
VERSION=$(python3 scripts/pubspec_version.py pubspec.yaml)
echo "version=${VERSION%%+*}" >> "$GITHUB_OUTPUT"
- name: Build installer and portables
run: .\windows\build-installer.ps1 -X64BuildDir "build-x64" -Arm64BuildDir "build-arm64" -Version "${{ steps.version.outputs.version }}"
@@ -725,17 +718,26 @@ jobs:
exit 1
- name: Cache libmpv build
- name: Cache libmpv prefix
id: libmpv-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: libmpv-prefix
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('mpv-build.lock.json') }}
- name: Build libmpv
# The prebuilt prefix from our unified mpv-build repo: the script reads
# the asset and expected SHA-256 from the lock and verifies the bytes
# before a single one is extracted. The tarball is a self-relocating
# prefix tree (lib/ or lib/<triplet>/ with the libmpv.so* chain,
# lib/libshaderc_shared.so*, include/, and pkgconfig/ dirs with
# ${pcfiledir}-relative roots), so the downstream PKG_CONFIG_PATH,
# bundle-copy, and find steps are unchanged.
- name: Fetch libmpv
if: steps.libmpv-cache.outputs.cache-hit != 'true'
shell: bash
run: bash linux/packaging/build-libmpv.sh
run: |
command -v zstd >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y --no-install-recommends zstd; }
python3 scripts/fetch_linux_libmpv.py --dest libmpv-prefix
- name: Install fpm
shell: bash
@@ -872,15 +874,18 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
sparse-checkout: pubspec.yaml
sparse-checkout: |
pubspec.yaml
scripts/pubspec_version.py
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Read version from pubspec.yaml
id: version
run: |
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//')
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | sed 's/.*+//')
VERSION=$(python3 scripts/pubspec_version.py pubspec.yaml)
BUILD_NUMBER=${VERSION#*+}
VERSION=${VERSION%%+*}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
+23 -20
View File
@@ -252,9 +252,6 @@ jobs:
- name: Verify native formatting
run: scripts/format_native.sh --check
- name: Verify Linux native acquisition and build plan
run: bash linux/packaging/build-libmpv_test.sh
linux-native-test:
name: Linux native reliability (${{ matrix.sanitizer }})
runs-on: ubuntu-latest
@@ -404,7 +401,9 @@ jobs:
- name: Verify tvOS project wiring
if: matrix.platform == 'tvOS'
run: ruby tvos/scripts/test_wire_mpv.rb
run: |
ruby tvos/scripts/test_wire_mpv.rb
ruby tvos/scripts/test_wire_top_shelf.rb
- name: Select Apple test destination
env:
@@ -645,10 +644,10 @@ jobs:
cache: true
pub-cache: false
# The packaging deps, minus libmpv: this job builds it from source below,
# because the distro's is a different version with different windowing
# backends, and a package smoke-built against it cannot show that
# build-libmpv.sh still works or that the bundle it produces is coherent.
# The packaging deps, minus libmpv: that arrives prebuilt from mpv-build
# below. The dev packages still matter — they install the runtime
# libraries the pinned libmpv links (ass, pulse, pipewire, ...), which
# bundle-libs.sh resolves off the host into the bundle.
- name: Install packaging dependencies
run: |
sudo apt-get update
@@ -662,25 +661,29 @@ jobs:
liblua5.2-dev rpm libarchive-tools imagemagick ruby-dev build-essential
sudo gem install fpm --version 1.17.0 --no-document
# Keyed the same way build.yml keys it, so editing the script or its pinned
# inputs is what invalidates the cache - and this branch's whole point is
# that those edits get exercised somewhere.
- name: Cache libmpv build
# Keyed the same way build.yml keys it: the lock names the asset and its
# checksum, so editing the lock is what invalidates the cache.
- name: Cache libmpv prefix
id: libmpv-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: libmpv-prefix
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
key: ci-libmpv-${{ runner.arch }}-${{ hashFiles('mpv-build.lock.json') }}
- name: Build libmpv
# Same contract as build.yml: the script reads mpv-build.lock.json,
# downloads the linux asset for this arch, verifies its SHA-256, and
# extracts the prefix tree.
- name: Fetch libmpv
if: steps.libmpv-cache.outputs.cache-hit != 'true'
run: bash linux/packaging/build-libmpv.sh
run: |
command -v zstd >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y --no-install-recommends zstd; }
python3 scripts/fetch_linux_libmpv.py --dest libmpv-prefix
# The windowing backends the runner depends on, read off the library the
# script just produced. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY, so
# a libmpv without Wayland cannot find the VAAPI device and quietly decodes
# in software; X11 and VDPAU are gone with the texture path.
- name: Check the built libmpv's backends
# The windowing backends the runner depends on, read off the pinned
# library just extracted. The plane hands mpv MPV_RENDER_PARAM_WL_DISPLAY,
# so a libmpv without Wayland cannot find the VAAPI device and quietly
# decodes in software; X11 and VDPAU are gone with the texture path.
- name: Check the pinned libmpv's backends
run: |
LIB=$(find libmpv-prefix -name 'libmpv.so.2' | head -1)
echo "== $LIB =="
+41 -16
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
@@ -205,4 +230,4 @@ Plezy is licensed under [GPL-3.0](LICENSE).
- Built with [Flutter](https://flutter.dev)
- Supports [Plex Media Server](https://www.plex.tv), [Jellyfin](https://jellyfin.org), and [Emby](https://emby.media)
- Playback powered by [mpv](https://mpv.io), [MPVKit](https://github.com/mpvkit/MPVKit), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), [libass-android](https://github.com/peerless2012/libass-android), and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
- Playback powered by [mpv](https://mpv.io) via our [mpv-build](https://github.com/edde746/mpv-build) pipeline (started as a fork of [MPVKit](https://github.com/mpvkit/MPVKit); the Android Kotlin/JNI glue descends from [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), and [libass-android](https://github.com/peerless2012/libass-android)
+31 -93
View File
@@ -58,95 +58,31 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val mpvVersion = "v1.2.2"
val mpvSha256 = "0207bb46660c239268c4c17bddc7fd570722513344b136c7c5336aca135dc8a0"
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
val mpvAar = "libmpv-release.aar"
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
// Dev-only escape hatch: point the build at a locally built libmpv AAR
// (libmpv-android's build-local.sh output) to test fork patches before a
// release is pinned. The URL + sha256 above stay authoritative otherwise.
val localMpvAar: String? = (project.findProperty("plezy.localMpvAar") as String?)
?: System.getenv("PLEZY_LOCAL_MPV_AAR")
// The in-project :libmpv module owns the mpv-build pin (repo-root
// mpv-build.lock.json assets + checksums, plus the plezy.localMpvDir/
// PLEZY_LOCAL_MPV_DIR escape hatch) and extracts the per-ABI tarballs'
// prebuilt native libraries. This file reads two of its output trees
// back: FFmpeg .so files for the Media3 adapter link step, and the libc++
// runtime packaged at PROJECT scope below.
val libmpvBuildDir = project(":libmpv").layout.buildDirectory.dir("libmpv").get().asFile
val libmpvNativeJniDir = File(libmpvBuildDir, "native/jni")
val libmpvLibcxxJniDir = File(libmpvBuildDir, "libcxx/jni")
val media3Version = "1.11.0"
val mpvFfmpegVersion = "8.0.1"
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
val mpvFfmpegDevelopmentDir = File(mpvDir, "ffmpeg-development")
val downloadLibmpv = tasks.register("downloadLibmpv") {
val aar = File(mpvDir, mpvAar)
val manifest = File(mpvDir, ".manifest")
inputs.property("version", mpvVersion)
inputs.property("sourceUrl", mpvUrl)
inputs.property("sha256", mpvSha256)
inputs.property("localOverride", localMpvAar ?: "")
localMpvAar?.let { inputs.file(it) }
outputs.files(aar, manifest)
doLast {
mpvDir.parentFile.mkdirs()
val staging = File(mpvDir.parentFile, "${mpvDir.name}.staging-${UUID.randomUUID()}")
try {
staging.mkdirs()
val stagedAar = File(staging, mpvAar)
if (localMpvAar != null) {
File(localMpvAar).copyTo(stagedAar, overwrite = true)
File(staging, ".manifest").writeText("version=local\nsource=$localMpvAar\n")
} else {
try {
providers.exec {
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
}
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
}
promoteDirectory(staging, mpvDir)
} finally {
staging.deleteRecursively()
}
}
}
// Extract libc++_shared.so from the libmpv AAR so the app source set can package
// it with top merge priority (see packaging { jniLibs } and sourceSets below).
val extractMpvLibcxx = tasks.register("extractMpvLibcxx") {
dependsOn(downloadLibmpv)
val aar = File(mpvDir, mpvAar)
val outDir = File(mpvDir, "libcxx")
inputs.file(aar)
outputs.dir(outDir)
doLast {
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
outDir.mkdirs()
providers.exec {
commandLine(
"unzip",
"-q",
"-o",
aar.absolutePath,
"jni/*/libc++_shared.so",
"-d",
outDir.absolutePath
)
}.result.get().assertNormalExitValue()
}
}
val mpvFfmpegDevelopmentDir = layout.buildDirectory.dir("libmpv-ffmpeg-development").get().asFile
// Build the Media3 JNI adapter against the same shared FFmpeg libraries that
// libmpv packages. Headers are pinned to libmpv's FFmpeg version and remain
// build-only; the APK contains one FFmpeg implementation for both players.
val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") {
dependsOn(downloadLibmpv)
val aar = File(mpvDir, mpvAar)
dependsOn(":libmpv:extractLibmpvNative")
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
val libraries = listOf("avcodec", "avutil", "swresample")
inputs.file(aar)
inputs.dir(libmpvNativeJniDir)
inputs.property("ffmpegVersion", mpvFfmpegVersion)
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
@@ -216,15 +152,12 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
)
project.copy {
from(zipTree(aar)) {
from(libmpvNativeJniDir) {
include(
"jni/*/libavcodec.so",
"jni/*/libavutil.so",
"jni/*/libswresample.so"
"*/libavcodec.so",
"*/libavutil.so",
"*/libswresample.so"
)
eachFile {
path = path.removePrefix("jni/")
}
}
includeEmptyDirs = false
into(nativeDir)
@@ -235,11 +168,11 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
}.filterNot(File::isFile)
if (missing.isNotEmpty()) {
throw GradleException(
"libmpv $mpvVersion is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
"the :libmpv prebuilt tree is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
)
}
File(staging, ".manifest").writeText(
"mpv=$mpvVersion\nffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
"ffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
)
sourceArchive.delete()
extractedSource.deleteRecursively()
@@ -349,7 +282,7 @@ android {
defaultConfig {
applicationId = "com.edde746.plezy"
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
minSdk = 25 // Fire OS 6.x (API 25); :libmpv shares the same floor
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
@@ -448,8 +381,9 @@ android {
packaging {
jniLibs {
// pickFirst only suppresses the duplicate libc++ merge error; the
// sourceSets rule below makes libmpv's newer runtime win for
// std::from_chars<float>, while older native consumers remain ABI-compatible.
// sourceSets rule below makes the runtime :libmpv extracts from the
// mpv-build tarballs win for std::from_chars<float>, while older
// native consumers remain ABI-compatible.
pickFirsts.add("lib/*/libc++_shared.so")
}
}
@@ -457,8 +391,10 @@ android {
sourceSets {
getByName("main") {
// PROJECT-scope jniLibs merge ahead of subprojects/AARs, so dependency
// order cannot accidentally select the older libc++ copy.
jniLibs.srcDir(File(mpvDir, "libcxx/jni"))
// order cannot accidentally select an older libc++ copy. The directory
// is :libmpv's extractLibmpvNative output (the tarballs' 16 KB-capable
// libc++), wired below via the JniLibFolders dependency.
jniLibs.srcDir(libmpvLibcxxJniDir)
}
}
@@ -514,16 +450,18 @@ tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative")
}
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
dependsOn(prepareMpvFfmpegDevelopment)
}
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
// extracted libmpv libc++ directory present during input discovery.
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
dependsOn(extractMpvLibcxx)
dependsOn(":libmpv:extractLibmpvNative")
}
dependencies {
implementation(files(File(mpvDir, mpvAar)))
// mpv Kotlin API + JNI glue live in-project; the prebuilt libmpv/FFmpeg .so
// set rides along from the module's extracted mpv-build tarballs.
implementation(project(":libmpv"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// Android TV Watch Next integration
@@ -0,0 +1,177 @@
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
}
}
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<application>
<activity
android:name=".mpv.MpvLifecycleTestActivity"
android:exported="false"
android:theme="@style/NormalTheme" />
</application>
</manifest>
@@ -0,0 +1,18 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.os.Bundle
import android.view.WindowManager
import android.widget.FrameLayout
class MpvLifecycleTestActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
window.addFlags(
WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
super.onCreate(savedInstanceState)
setContentView(FrameLayout(this))
}
}
-3
View File
@@ -6,9 +6,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
@@ -28,6 +28,12 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
private const val API_VLC_RESULT_POSITION = "extra_position"
private const val API_VLC_RESULT_DURATION = "extra_duration"
// Honored by VLC and the native Zidoo player (com.android.gallery3d /
// com.zidoo.player). Without it, a launch with no resume point lets the
// player consult its own bookmark store, which on Zidoo collides across
// Plex items because every part URL ends in the same `file.<ext>` (#2223).
private const val API_VLC_FROM_START = "from_start"
private const val API_VIMU_TITLE = "forcename"
private const val API_VIMU_SEEK_POSITION = "startfrom"
private const val API_VIMU_RESUME = "forceresume"
@@ -150,7 +156,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
}
}
private data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
internal data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
private fun resolveSource(filePath: String): Source {
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
@@ -168,7 +174,7 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
return Source(uri, grantRead = true, fileName = file.name)
}
private fun buildIntent(
internal fun buildIntent(
source: Source,
packageName: String?,
startPositionMs: Long,
@@ -181,6 +187,9 @@ internal class ExternalPlayerChannel(private val activity: Activity) {
if (startPosition > 0) {
putExtra(API_MX_RESULT_POSITION, startPosition)
putExtra(API_VIMU_SEEK_POSITION, startPosition)
putExtra(API_VLC_FROM_START, false)
} else {
putExtra(API_VLC_FROM_START, true)
}
putExtra(API_MX_RETURN_RESULT, true)
putExtra(API_MX_SECURE_URI, true)
@@ -31,6 +31,7 @@ import com.edde746.plezy.car.CarRestrictionsMonitor
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
import com.edde746.plezy.shared.AssistiveTechnologyMonitor
import com.edde746.plezy.shared.DeviceQuirks
import com.edde746.plezy.shared.MediaCodecQuery
import com.edde746.plezy.shared.ThemeHelper
@@ -96,9 +97,12 @@ class MainActivity : FlutterActivity() {
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
private val CAR_RESTRICTIONS_CHANNEL = "com.plezy/car_restrictions"
private val ASSISTIVE_TECHNOLOGY_CHANNEL = "com.plezy/assistive_technology"
private var watchNextPlugin: WatchNextPlugin? = null
private var carRestrictions: CarRestrictionsMonitor? = null
private var carRestrictionsChannel: MethodChannel? = null
private var assistiveTechnology: AssistiveTechnologyMonitor? = null
private var assistiveTechnologyChannel: MethodChannel? = null
private var nativeTextInputFocused = false
private val imeRecoveryHandler = Handler(Looper.getMainLooper())
private var imeShowAttempts = 0
@@ -593,6 +597,9 @@ class MainActivity : FlutterActivity() {
carRestrictions?.release()
carRestrictions = null
carRestrictionsChannel = null
assistiveTechnology?.release()
assistiveTechnology = null
assistiveTechnologyChannel = null
activityStarted = false
flutterSurfaceReconnectPending = false
flutterTextureView = null
@@ -781,6 +788,19 @@ class MainActivity : FlutterActivity() {
}
}
val assistiveChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, ASSISTIVE_TECHNOLOGY_CHANNEL)
assistiveTechnologyChannel = assistiveChannel
val assistiveMonitor = assistiveTechnology ?: AssistiveTechnologyMonitor(applicationContext).also {
assistiveTechnology = it
}
assistiveMonitor.start { runOnUiThread { assistiveTechnologyChannel?.invokeMethod("onChanged", null) } }
assistiveChannel.setMethodCallHandler { call, result ->
when (call.method) {
"getSignals" -> result.success(assistiveMonitor.signals())
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
handleDeviceAdjustmentCall(call.method, call.arguments, result)
}
@@ -163,10 +163,7 @@ internal fun mpvSpdifCodecs(
@Suppress("DEPRECATION")
@OptIn(UnstableApi::class)
internal fun supportedMpvSpdifCodecs(context: Context): String {
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
val audioAttributes = movieMedia3AudioAttributes()
val capabilities = try {
AudioCapabilities.getCapabilities(context, audioAttributes, null)
} catch (error: Exception) {
@@ -301,6 +298,31 @@ internal fun supportsIecCarrier(context: Context): Boolean = iecRouteSupported(
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, IecCarrier.SAMPLE_RATE, IecCarrier.CHANNEL_COUNT) }
)
/**
* Whether the route carries the IEC 61937 tuple *and* advertises DTS-HD. TrueHD rides the same
* tuple, so carrying it says nothing about whether the receiver decodes DTS-HD.
*/
internal fun dtsHdCarrierUsable(
supportsEncoding: (Int) -> Boolean,
supportsCarrier: () -> Boolean
): Boolean = supportsEncoding(C.ENCODING_DTS_HD) && supportsCarrier()
/** [dtsHdCarrierUsable] resolved against the audio route [context] is currently routed to. */
internal fun supportsDtsHdIecCarrier(context: Context): Boolean = dtsHdCarrierUsable(
// Encoding first: it skips the carrier tiering's several route calls.
supportsEncoding = { encoding -> routeSupportsEncoding(context, encoding) },
supportsCarrier = { supportsIecCarrier(context) }
)
@Suppress("DEPRECATION")
@OptIn(UnstableApi::class)
private fun routeSupportsEncoding(context: Context, encoding: Int): Boolean = try {
AudioCapabilities.getCapabilities(context, movieMedia3AudioAttributes(), null).supportsEncoding(encoding)
} catch (error: Exception) {
Log.w(TAG, "Audio route capabilities unavailable; DTS-HD will not bitstream", error)
false
}
/**
* [supportsIecCarrier]/[supportsMpvIecShape] with the platform probes injected. Probes are only
* consulted on the API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`)
@@ -322,8 +344,7 @@ internal fun iecRouteSupported(
else -> hdmiRouteAdvertised()
}
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean =
canSizeDirectBuffer(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937)
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean = canSizeDirectBuffer(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937)
private fun canSizeDirectBuffer(sampleRate: Int, channelMask: Int, encoding: Int): Boolean = try {
AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding) > 0
@@ -371,8 +392,7 @@ private fun hdmiAdvertisesIecRoute(context: Context, sampleRate: Int, channelCou
}
/** The exact tuple an IEC output's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat =
directProbeFormat(AudioFormat.ENCODING_IEC61937, sampleRate, channelMask)
private fun iecProbeFormat(sampleRate: Int, channelMask: Int): AudioFormat = directProbeFormat(AudioFormat.ENCODING_IEC61937, sampleRate, channelMask)
private fun directProbeFormat(encoding: Int, sampleRate: Int, channelMask: Int): AudioFormat = AudioFormat.Builder()
.setEncoding(encoding)
@@ -380,8 +400,9 @@ private fun directProbeFormat(encoding: Int, sampleRate: Int, channelMask: Int):
.setSampleRate(sampleRate)
.build()
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
private fun movieMedia3AudioAttributes(): AudioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
.getPlatformAudioAttributes()
private fun movieAudioAttributes(): android.media.AudioAttributes = movieMedia3AudioAttributes().getPlatformAudioAttributes()
@@ -2445,7 +2445,7 @@ class ExoPlayerCore(private val activity: Activity) :
* is false the stream decodes regardless of the passthrough setting.
*/
private fun routeCanBitstreamDts(mimeType: String): Boolean {
if (mimeType == MimeTypes.AUDIO_DTS_HD && supportsIecCarrier(activity)) return true
if (mimeType == MimeTypes.AUDIO_DTS_HD && supportsDtsHdIecCarrier(activity)) return true
val audioAttributes = buildMovieAudioAttributes()
return try {
AudioCapabilities
@@ -45,8 +45,11 @@ import java.util.concurrent.atomic.AtomicInteger
internal class IecCarrierSink(
private val defaultSink: AudioSink,
private val carrierSink: AudioSink,
/** Whether the current route can bitstream the carrier tuple. Evaluated per format. */
private val carrierRouteAvailable: () -> Boolean,
/**
* Whether the current route can bitstream this format on the carrier. Per format: the tuple is
* shared, but a route carrying it does not necessarily advertise every codec riding it.
*/
private val carrierRouteAvailable: (Format) -> Boolean,
/** Whether policy currently forbids bitstreaming at all (downmix, normalization, user setting). */
private val directOutputBlocked: (Format) -> Boolean,
private val log: ((String, String, String) -> Unit)? = null
@@ -126,7 +129,7 @@ internal class IecCarrierSink(
if (!isCarrierRateFamily(format.sampleRate)) return false
if (playbackParameters.speed != 1f) return false
if (directOutputBlocked(format)) return false
return carrierRouteAvailable()
return carrierRouteAvailable(format)
}
/**
@@ -172,14 +175,14 @@ internal class IecCarrierSink(
override fun supportsFormat(format: Format): Boolean = when {
shouldUseCarrier(format) -> true
isTrueHd(format) -> false
isDtsHd(format) && carrierRouteAvailable() -> false
isDtsHd(format) && carrierRouteAvailable(format) -> false
else -> defaultSink.supportsFormat(format)
}
override fun getFormatSupport(format: Format): Int = when {
shouldUseCarrier(format) -> AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
isTrueHd(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED
isDtsHd(format) && carrierRouteAvailable() -> AudioSink.SINK_FORMAT_UNSUPPORTED
isDtsHd(format) && carrierRouteAvailable(format) -> AudioSink.SINK_FORMAT_UNSUPPORTED
else -> defaultSink.getFormatSupport(format)
}
@@ -207,12 +207,15 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
return IecCarrierSink(
defaultSink = processedSink,
carrierSink = buildCarrierSink(context, bufferSizeProvider),
carrierRouteAvailable = { supportsIecCarrier(context) },
carrierRouteAvailable = { format -> carrierRouteAvailableFor(context, format) },
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
log = audioDiagnosticsLogger
).also { iecCarrierSink = it }
}
/** TrueHD needs only the carrier tuple (#1804); DTS-HD also needs the route to advertise it. */
private fun carrierRouteAvailableFor(context: Context, format: Format): Boolean = if (format.sampleMimeType == MimeTypes.AUDIO_DTS_HD) supportsDtsHdIecCarrier(context) else supportsIecCarrier(context)
/**
* The delegate that carries packed TrueHD and DTS-HD (#1804, #1988).
*
@@ -35,6 +35,55 @@ internal object GpuVoPolicy {
*/
fun needsSoftwareRender(hwdecCurrent: String?): Boolean = !hwdecCurrent.isNullOrBlank() && hwdecCurrent != "mediacodec"
/**
* Whether a video track must be software-decoded up front because the
* bitstream is H.264 High 10 and no hardware decoder advertises the
* profile (#2065). Without this the session still ends up in software —
* MediaCodec refuses the stream, FFmpeg falls back, the 10-bit frames
* cannot enter the video plane and the chain fails — but only after a
* decoder init, a failed video chain and a vo recreation, several seconds
* of black with audio already running. [codec] and [codecProfile] come
* from mpv's track-list; the profile string is FFmpeg's
* (`avcodec_profile_name`: "High 10", "High 10 Intra").
*/
fun needsSoftwareDecode(codec: String?, codecProfile: String?, hardwareHigh10: Boolean): Boolean = !hardwareHigh10 && codec == "h264" && codecProfile?.startsWith("High 10") == true
/**
* 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 +106,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_HI10_SW_DECODE = "hi10-sw-decode"
}
@@ -1,9 +1,9 @@
package com.edde746.plezy.mpv
import dev.jdtech.mpv.EndFileReason
import dev.jdtech.mpv.LogLevel
import dev.jdtech.mpv.LogMessage
import dev.jdtech.mpv.MpvEvent
import com.edde746.plezy.libmpv.EndFileReason
import com.edde746.plezy.libmpv.LogLevel
import com.edde746.plezy.libmpv.LogMessage
import com.edde746.plezy.libmpv.MpvEvent
/** Adds the native diagnostic that libmpv-android exposes separately via logFlow. */
internal class MpvEndFileDiagnostics {
@@ -20,16 +20,15 @@ internal class MpvEndFileDiagnostics {
}
fun onEndFile(event: MpvEvent.EndFile): Map<String, Any>? {
val reason = event.reason
if (reason == null) {
errorMessage = null
return null
}
val data = mutableMapOf<String, Any>("reason" to reason.id)
if (reason == EndFileReason.Error) {
errorMessage?.let { data["message"] = it }
val data = mutableMapOf<String, Any>()
event.sourceId?.let { data["sourceId"] = it }
event.reason?.let { reason ->
data["reason"] = reason.id
if (reason == EndFileReason.Error) {
errorMessage?.let { data["message"] = it }
}
}
errorMessage = null
return data
return data.takeIf { it.isNotEmpty() }
}
}
@@ -17,12 +17,14 @@ import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import com.edde746.plezy.exoplayer.DoviBridge
import com.edde746.plezy.libmpv.*
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.GlCapabilities
import com.edde746.plezy.shared.MediaCodecQuery
import com.edde746.plezy.shared.PlayerDelegate
import com.edde746.plezy.shared.PlayerSurfaceHost
import com.edde746.plezy.shared.SurfacePlayerCore
import dev.jdtech.mpv.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -123,11 +125,20 @@ class MpvPlayerCore private constructor(
* chain-failure watchdog. Written under [gpuVoReasons]. */
@Volatile private var activeGpuVoTarget: String? = null
/** Whether the per-file DV policy is holding hwdec at `no`; the session's
* own hwdec value is parked in [hwdecBeforeDvReshape] meanwhile. */
@Volatile private var dvReshapeActive: Boolean = false
/** Per-file reasons holding hwdec at `no` (DV P5 reshaping, Hi10 without
* a hardware profile); the session's own hwdec value is parked in
* [parkedHwdec] while any is active. Written under itself. */
private val hwdecHoldReasons = LinkedHashSet<String>()
private val hwdecBeforeDvReshape = java.util.concurrent.atomic.AtomicReference<String?>()
@Volatile private var hwdecHeld: Boolean = false
private val parkedHwdec = java.util.concurrent.atomic.AtomicReference<String?>()
/** mpv option -> the default it carried before the cheap render tier
* replaced it; empty while the tier is off. See [applyRenderTier]. */
private val cheapRenderRestore = LinkedHashMap<String, String>()
@Volatile private var cheapRenderTierActive: Boolean = false
/** Last `dv-conversion-mode` Dart applied; input to the per-file DV
* routing policy. */
@@ -335,8 +346,15 @@ class MpvPlayerCore private constructor(
gpuVoReasons.clear()
activeGpuVoTarget = null
}
hwdecBeforeDvReshape.set(null)
dvReshapeActive = false
synchronized(hwdecHoldReasons) {
hwdecHoldReasons.clear()
hwdecHeld = false
}
parkedHwdec.set(null)
synchronized(cheapRenderRestore) {
cheapRenderRestore.clear()
cheapRenderTierActive = false
}
attachedOsdSurface = null
videoDisplayWidth = 0
videoDisplayHeight = 0
@@ -459,15 +477,46 @@ class MpvPlayerCore private constructor(
}
if (disposing) {
p.close()
// dispose() can win after create's IO block but before this main-thread
// continuation. Native destruction is blocking, so finish it on IO too.
withContext(NonCancellable + Dispatchers.IO) {
p.close()
}
onResult(false)
return@launch
}
player = p
isInitialized = true
if (usesMediaCodecVo) {
// Per-file decode routing runs inside mpv's on_preloaded hook:
// the demuxer has opened the file, no decoder exists yet, and
// mpv waits for the answer. file-loaded would be too late — the
// MediaCodec decoder is already created by then (#2065).
p.hookHandler = { name ->
if (name == "on_preloaded" && !disposing) {
withContext(mpvWriteDispatcher) {
applyDvReshapePolicy(p)
applySoftwareDecodePolicy(p)
}
}
}
}
if (!audioOnly) refreshVideoOutput("initialize")
if (!usesMediaCodecVo && !audioOnly) {
// vo=gpu from the start (hardware decoding off): same tier
// decision the plane sessions make when they leave the plane.
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
try {
applyRenderTier(p, glVoActive = true)
} catch (e: CancellationException) {
Log.d(TAG, "Canceled render tier setup")
} catch (e: Exception) {
Log.w(TAG, "Render tier setup failed", e)
}
}
}
// Start collecting events/properties/logs
collectEvents(p)
@@ -506,6 +555,17 @@ class MpvPlayerCore private constructor(
)
}
private fun lifecycleData(
sourceId: Long?,
positionSeconds: Double? = null
): Map<String, Any>? {
if (sourceId == null && positionSeconds == null) return null
return buildMap {
sourceId?.let { put("sourceId", it) }
positionSeconds?.let { put("positionSeconds", it) }
}
}
private fun collectEvents(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.eventFlow.collect { event ->
@@ -520,24 +580,17 @@ class MpvPlayerCore private constructor(
// file. A genuine failure re-arms it, costing one switch per bad
// file instead of the whole session's HDR/10-bit scanout.
setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, false)
delegate?.onEvent("start-file", null)
delegate?.onEvent("start-file", lifecycleData(event.sourceId))
}
is MpvEvent.FileLoaded -> {
if (usesMediaCodecVo) {
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
try {
applyDvReshapePolicy(p)
} catch (e: CancellationException) {
Log.d(TAG, "Canceled DV routing policy")
} catch (e: Exception) {
Log.w(TAG, "DV routing policy failed", e)
}
}
}
delegate?.onEvent("file-loaded", null)
delegate?.onEvent("file-loaded", lifecycleData(event.sourceId))
}
is MpvEvent.PlaybackRestart -> {
delegate?.onEvent(
"playback-restart",
lifecycleData(event.sourceId, event.positionSeconds)
)
}
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
else -> {}
}
}
}
@@ -560,7 +613,7 @@ class MpvPlayerCore private constructor(
if (change.name == "pause" && change is PropertyChange.Flag) {
cachedPaused = change.value
}
delegate?.onPropertyChange(change.name, value)
delegate?.onPropertyChange(change.name, value, change.sourceId)
}
}
}
@@ -756,6 +809,7 @@ class MpvPlayerCore private constructor(
if (target == null && p != null) attachOsdSurfaceIfNeeded(p)
writeProperty("vo", target ?: "mediacodec")
if (p == null) return@launch
applyRenderTier(p, glVoActive = target != null)
if (target != null) {
// A failed conversion chain makes mpv deselect the video track
// ("Could not initialize video chain" -> vid=no) before the VO
@@ -798,20 +852,101 @@ class MpvPlayerCore private constructor(
conversionMode = currentDvConversionMode,
canPlayP5Natively = DoviBridge.canPlayDolbyVisionP5(context)
)
if (needs == dvReshapeActive) return
dvReshapeActive = needs
if (needs) {
if (holdHwdec(p, GpuVoPolicy.REASON_DV_RESHAPE, needs) && needs) {
Log.i(TAG, "DV P5 (bitstream) without native support: software decode + gpu-next reshaping")
val current = p.getString("hwdec")
hwdecBeforeDvReshape.set(current ?: "no")
writeProperty("hwdec", "no")
} else {
val restore = hwdecBeforeDvReshape.getAndSet(null)
if (restore != null && restore != "no") writeProperty("hwdec", restore)
}
setGpuVoRequirement(GpuVoPolicy.REASON_DV_RESHAPE, needs)
}
/**
* Per-file Hi10 routing (#2065): an H.264 High 10 stream on hardware that
* advertises no such profile goes straight to software decode on a GL vo,
* instead of letting MediaCodec refuse it and the video chain fail first.
* The profile comes from the container's avcC record (fork patch), so it
* is known inside on_preloaded. Distinct from [GpuVoPolicy.REASON_SW_DECODE]:
* that one follows `hwdec-current`, which is still blank at this point.
*/
private suspend fun applySoftwareDecodePolicy(p: MpvPlayer) {
val track = videoTracks(p).firstOrNull()
val codec = track?.optString("codec")
val codecProfile = track?.optString("codec-profile")
val hardwareHigh10 = MediaCodecQuery.hardwareAvcHigh10Support()
Log.d(TAG, "Decode routing: codec=$codec profile=$codecProfile hardwareHigh10=$hardwareHigh10")
val needs = GpuVoPolicy.needsSoftwareDecode(codec, codecProfile, hardwareHigh10)
if (holdHwdec(p, GpuVoPolicy.REASON_HI10_SW_DECODE, needs) && needs) {
Log.i(TAG, "H.264 High 10 without a hardware profile: software decode on the GL vo")
}
setGpuVoRequirement(GpuVoPolicy.REASON_HI10_SW_DECODE, needs)
}
/**
* Adds or removes a per-file reason to hold hwdec at `no`. The session's
* own value is parked on the first reason and restored when the last one
* drops (Dart writes meanwhile land in the park, see [setProperty]).
* Returns whether the reason set changed.
*/
private suspend fun holdHwdec(p: MpvPlayer, reason: String, needs: Boolean): Boolean {
val transition: Boolean? = synchronized(hwdecHoldReasons) {
val changed = if (needs) hwdecHoldReasons.add(reason) else hwdecHoldReasons.remove(reason)
if (!changed) return false
val held = hwdecHoldReasons.isNotEmpty()
if (held == hwdecHeld) {
null
} else {
hwdecHeld = held
held
}
}
when (transition) {
true -> {
parkedHwdec.set(p.getString("hwdec") ?: "no")
writeProperty("hwdec", "no")
}
false -> {
val restore = parkedHwdec.getAndSet(null)
if (restore != null && restore != "no") writeProperty("hwdec", restore)
}
null -> {}
}
return true
}
/**
* Moves the render options between mpv's defaults and the cheap tier as
* the session enters or leaves a GL vo. Why: [GpuVoPolicy.needsCheapRenderTier].
* Only options still at their mpv default are replaced, so a user's
* mpv.conf line for any of them wins, and only those are restored.
* Serialized on [mpvWriteDispatcher] behind the vo write it follows.
*/
private suspend fun applyRenderTier(p: MpvPlayer, glVoActive: Boolean) {
val wanted = GpuVoPolicy.needsCheapRenderTier(
glVoActive = glVoActive,
textureNorm16 = GlCapabilities.hasTextureNorm16()
)
val transition: Boolean = synchronized(cheapRenderRestore) {
if (wanted == cheapRenderTierActive) return
cheapRenderTierActive = wanted
wanted
}
if (transition) {
val replaced = LinkedHashMap<String, String>()
for ((option, cheap) in GpuVoPolicy.CHEAP_RENDER_OPTIONS) {
val current = p.getString(option)
if (!GpuVoPolicy.isDefaultRenderOption(option, current)) {
Log.d(TAG, "Render tier keeps $option=$current (not the mpv default)")
continue
}
replaced[option] = current!!
writeProperty(option, cheap)
}
synchronized(cheapRenderRestore) { cheapRenderRestore.putAll(replaced) }
Log.i(TAG, "Cheap render tier (no GL_EXT_texture_norm16): ${replaced.keys.joinToString(",")}")
} else {
val restore = synchronized(cheapRenderRestore) { LinkedHashMap(cheapRenderRestore).also { cheapRenderRestore.clear() } }
for ((option, value) in restore) writeProperty(option, value)
}
}
/**
* Selected video track's Dolby Vision profile, or null for non-DV content
* (mpv omits the field when the bitstream carries no DOVI configuration
@@ -1447,12 +1582,13 @@ class MpvPlayerCore private constructor(
}
}
// While the per-file DV policy holds hwdec at `no`, park writes instead
// of applying them: a hardware value under gpu-next would lose the RPU
// side data (and blue-screen the Tegra class, #2010). The parked value
// is restored when a non-P5 file drops the requirement.
if (name == "hwdec" && dvReshapeActive) {
hwdecBeforeDvReshape.set(value)
// While a per-file policy holds hwdec at `no` (DV P5 reshaping, Hi10
// without a hardware profile), park writes instead of applying them: a
// hardware value under gpu-next would lose the RPU side data (and
// blue-screen the Tegra class, #2010). The parked value is restored when
// the next file drops the last requirement.
if (name == "hwdec" && hwdecHeld) {
parkedHwdec.set(value)
onComplete?.invoke(Result.success(Unit))
return
}
@@ -1848,6 +1984,8 @@ class MpvPlayerCore private constructor(
val osdSv = osdSurfaceView
val container = surfaceContainer
val contentView = if (audioOnly) null else activity.findViewById<ViewGroup>(android.R.id.content)
val retiringPlaceholderSurface = placeholderSurface
val retiringPlaceholderImageReader = placeholderImageReader
surfaceContainer = null
surfaceView = null
@@ -1862,9 +2000,7 @@ class MpvPlayerCore private constructor(
overlayLayoutListener = null
pendingSurface = null
placeholderSurface?.release()
placeholderSurface = null
placeholderImageReader?.close()
placeholderImageReader = null
pausedForSurfaceLoss = false
pausedForAudioFocusLoss = false
@@ -1880,27 +2016,18 @@ class MpvPlayerCore private constructor(
pendingVideoOutputDisableJob = null
isInitialized = false
// Detach surface and close player on background thread, then remove views
// Close the player on a background thread, then release surfaces and remove views.
if (p != null) {
Thread {
try {
// Detach surface BEFORE close to prevent GPU mutex contention with
// view removal (audio-only never attached one)
if (!audioOnly) {
try {
runBlocking {
p.setProperty("force-window", "no")
p.setProperty("vo", "null")
}
p.detachSurface()
} catch (e: Exception) {
Log.w(TAG, "Failed to detach surface during dispose", e)
}
}
// Native close blocks through decoder and VO teardown. Keep both the
// SurfaceView surfaces and any attached placeholder alive until it returns.
p.close()
} catch (e: Exception) {
Log.w(TAG, "MPV close failed", e)
}
retiringPlaceholderSurface?.release()
retiringPlaceholderImageReader?.close()
player = null
Log.d(TAG, "Disposed (native)")
Handler(Looper.getMainLooper()).post {
@@ -1914,6 +2041,8 @@ class MpvPlayerCore private constructor(
}.start()
} else {
// No player — safe to remove views immediately
retiringPlaceholderSurface?.release()
retiringPlaceholderImageReader?.close()
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
sv?.holder?.removeCallback(this)
osdSv?.holder?.removeCallback(osdSurfaceCallback)
@@ -69,10 +69,10 @@ open class MpvPlayerPlugin(
// down that successor's core; it is acknowledged without touching anything.
private var coreInstanceId: Long? = null
// How long a Dart `dispose` waits for the native teardown before being
// answered anyway. Generous against slow-but-healthy teardowns (a 4K HDR
// session's surface/audio release); small against the alternative, which
// is wedging every subsequent playback session behind a hung teardown.
// How long a Dart `dispose` waits for native teardown before being
// acknowledged. Native lifecycle operations remain serialized on background
// workers after the watchdog fires, so a successor cannot overlap a stuck
// decoder, exhaust codec instances, or block Android's main thread.
private val disposeWatchdogMs = 6_000L
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
@@ -356,10 +356,10 @@ open class MpvPlayerPlugin(
result.success(null)
return@runOnMain
}
// A hung native teardown must not wedge the Dart-side release chain:
// answer after the watchdog even if the teardown thread is stuck, so
// the next session can start on a fresh core. The stuck core leaks its
// resources until the process ends — recoverable, unlike the wedge.
// A hung native teardown must not wedge the Dart-side release chain.
// Native create/destroy remains serialized on background workers behind
// that teardown, so a successor cannot accumulate another MediaCodec
// instance while the old one still owns its resources.
val completed = AtomicBoolean(false)
fun completeOnce(reason: String) {
if (completed.compareAndSet(false, true)) {
@@ -608,8 +608,12 @@ open class MpvPlayerPlugin(
// PlayerDelegate
override fun onPropertyChange(name: String, value: Any?) {
onPropertyChange(name, value, null)
}
override fun onPropertyChange(name: String, value: Any?, sourceId: Long?) {
val propId = nameToId[name] ?: return
channels.emitProperty(propId, value)
channels.emitProperty(propId, value, sourceId)
}
override fun onEvent(name: String, data: Map<String, Any>?) {
@@ -0,0 +1,86 @@
package com.edde746.plezy.shared
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.os.Build
import android.view.accessibility.AccessibilityManager
/**
* Reports whether an enabled accessibility service can consume the app's semantics tree.
*
* Flutter compiles a semantics tree for every frame while [AccessibilityManager.isEnabled], which
* Android sets for any bound service. On TV that is routinely a utility that never reads app
* content (a launcher's foreground-app hook, a key remapper), so Dart gates the tree on this
* verdict. The verdict errs towards keeping the tree:
*
* - touch exploration is a screen reader, full stop;
* - an empty enabled list while accessibility is on means a [android.app.UiAutomation] client
* (instrumentation, Maestro), which is not listed but reads the tree;
* - on API 33+ a service flagged `isAccessibilityTool`, or one giving spoken, braille, audible or
* visual feedback, consumes; a non-tool with only generic/haptic feedback does not;
* - below 33 only a haptic-only service is treated as not consuming, since `feedbackGeneric` is
* what Switch Access-style tools declare.
*/
class AssistiveTechnologyMonitor(context: Context) {
private val manager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
private var onChanged: (() -> Unit)? = null
private val stateListener = AccessibilityManager.AccessibilityStateChangeListener { onChanged?.invoke() }
private val touchExplorationListener =
AccessibilityManager.TouchExplorationStateChangeListener { onChanged?.invoke() }
private var servicesListener: AccessibilityManager.AccessibilityServicesStateChangeListener? = null
fun start(onChanged: () -> Unit) {
if (this.onChanged != null) return
this.onChanged = onChanged
manager.addAccessibilityStateChangeListener(stateListener)
manager.addTouchExplorationStateChangeListener(touchExplorationListener)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val listener = AccessibilityManager.AccessibilityServicesStateChangeListener { onChanged() }
servicesListener = listener
manager.addAccessibilityServicesStateChangeListener(listener)
}
}
fun release() {
if (onChanged == null) return
onChanged = null
manager.removeAccessibilityStateChangeListener(stateListener)
manager.removeTouchExplorationStateChangeListener(touchExplorationListener)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
servicesListener?.let { manager.removeAccessibilityServicesStateChangeListener(it) }
servicesListener = null
}
}
fun signals(): Map<String, Any> {
val services = manager.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
return mapOf(
"accessibilityEnabled" to manager.isEnabled,
"touchExplorationEnabled" to manager.isTouchExplorationEnabled,
"enabledServiceCount" to services.size,
"consumesSemantics" to consumesSemantics(services)
)
}
private fun consumesSemantics(services: List<AccessibilityServiceInfo>): Boolean {
if (manager.isTouchExplorationEnabled) return true
if (services.isEmpty()) return true
return services.any { info -> consumesSemantics(info) }
}
private fun consumesSemantics(info: AccessibilityServiceInfo): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (info.isAccessibilityTool) return true
return info.feedbackType and READER_FEEDBACK != 0
}
return info.feedbackType and AccessibilityServiceInfo.FEEDBACK_HAPTIC.inv() != 0
}
private companion object {
const val READER_FEEDBACK =
AccessibilityServiceInfo.FEEDBACK_SPOKEN or
AccessibilityServiceInfo.FEEDBACK_BRAILLE or
AccessibilityServiceInfo.FEEDBACK_AUDIBLE or
AccessibilityServiceInfo.FEEDBACK_VISUAL
}
}
@@ -0,0 +1,97 @@
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,26 @@ 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
}
fun findHardwareDecoder(
mimeType: String,
codecKind: Int = MediaCodecList.REGULAR_CODECS,
@@ -62,6 +62,10 @@ internal class PlayerChannelBinding(
runOnMain { eventSink?.success(listOf(id, value)) }
}
fun emitProperty(id: Int, value: Any?, sourceId: Long?) {
runOnMain { eventSink?.success(listOf(id, value, sourceId)) }
}
fun emitEvent(name: String, data: Map<String, Any>? = null) {
val event = mutableMapOf<String, Any>(
"type" to "event",
@@ -2,5 +2,9 @@ package com.edde746.plezy.shared
interface PlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
fun onPropertyChange(name: String, value: Any?, sourceId: Long?) {
onPropertyChange(name, value)
}
fun onEvent(name: String, data: Map<String, Any>?)
}
+5
View File
@@ -13,3 +13,8 @@ add_executable(ffmpeg_audio_buffer_test ffmpeg_audio_buffer_test.cpp)
target_compile_features(ffmpeg_audio_buffer_test PRIVATE cxx_std_17)
add_test(NAME ffmpeg_audio_buffer_test COMMAND ffmpeg_audio_buffer_test)
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)
@@ -0,0 +1,59 @@
#include <cstdio>
#include <string>
#include "../../../../libmpv/src/main/cpp/utf8_convert.h"
namespace {
using plezy::utf8::FromUtf16;
using plezy::utf8::ToUtf16;
bool check(bool condition, const char* message) {
if (!condition) std::fprintf(stderr, "%s\n", message);
return condition;
}
bool roundTripsAsciiBmpAndSupplementary() {
// "a" U+00E9 U+4E2D U+1F3AC (clapper board) — 1/2/3/4-byte sequences.
const std::string utf8 = "a\xC3\xA9\xE4\xB8\xAD\xF0\x9F\x8E\xAC";
const std::u16string utf16 = ToUtf16(utf8.c_str());
return check(utf16 == u"a\u00E9\u4E2D\U0001F3AC", "UTF-8 -> UTF-16 mismatch") &&
check(FromUtf16(utf16.data(), utf16.size()) == utf8, "UTF-16 -> UTF-8 mismatch");
}
bool doesNotEmitModifiedUtf8() {
// JNI's modified UTF-8 would encode U+1F3AC as a 6-byte CESU-8 surrogate
// pair; mpv/open() need the real 4-byte form.
const std::u16string clapper = u"\U0001F3AC";
return check(FromUtf16(clapper.data(), clapper.size()) == "\xF0\x9F\x8E\xAC", "supplementary char not 4 bytes") &&
check(
ToUtf16("\xED\xA0\xBC\xED\xBE\xAC") == u"\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD",
"CESU-8 surrogate bytes accepted as UTF-8");
}
bool replacesMalformedBytesOneAtATime() {
return check(ToUtf16("ok\xFF\xFEz") == u"ok\uFFFD\uFFFDz", "stray bytes not replaced individually") &&
check(ToUtf16("\xC0\x80") == u"\uFFFD\uFFFD", "overlong NUL accepted") &&
check(ToUtf16("\xE2\x82") == u"\uFFFD\uFFFD", "truncated sequence not replaced") &&
check(ToUtf16("\xF4\x90\x80\x80") == u"\uFFFD\uFFFD\uFFFD\uFFFD", "code point above U+10FFFF accepted") &&
check(ToUtf16("\xE0\x80\xAF") == u"\uFFFD\uFFFD\uFFFD", "overlong 3-byte form accepted");
}
bool replacesLoneSurrogates() {
const std::u16string lone = u"x\xD83Cy\xDFACz";
return check(FromUtf16(lone.data(), lone.size()) == "x\xEF\xBF\xBDy\xEF\xBF\xBDz", "lone surrogates not replaced");
}
bool handlesNullAndEmpty() {
return check(ToUtf16(nullptr).empty(), "NULL input not empty") &&
check(ToUtf16("").empty(), "empty input not empty") &&
check(FromUtf16(nullptr, 0).empty(), "NULL UTF-16 not empty");
}
} // namespace
int main() {
const bool ok = roundTripsAsciiBmpAndSupplementary() && doesNotEmitModifiedUtf8() &&
replacesMalformedBytesOneAtATime() && replacesLoneSurrogates() && handlesNullAndEmpty();
return ok ? 0 : 1;
}
@@ -2,8 +2,10 @@ package com.edde746.plezy
import android.app.Activity
import android.content.Intent
import android.net.Uri
import io.flutter.plugin.common.MethodChannel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@@ -30,6 +32,40 @@ class ExternalPlayerChannelTest {
assertEquals(false, result["playbackError"])
}
@Test
fun freshLaunchTellsPlayerToStartFromBeginning() {
val activity = Robolectric.buildActivity(Activity::class.java).get()
val channel = ExternalPlayerChannel(activity)
val source = ExternalPlayerChannel.Source(
Uri.parse("http://plex:32400/library/parts/9808/1775431760/file.mkv?X-Plex-Token=tok"),
grantRead = false,
fileName = "file.mkv"
)
val intent = channel.buildIntent(source, packageName = null, startPositionMs = 0L, title = "Episode")
assertTrue(intent.getBooleanExtra("from_start", false))
assertFalse(intent.hasExtra("position"))
assertFalse(intent.hasExtra("startfrom"))
}
@Test
fun resumeLaunchPassesPositionAndDisablesFromStart() {
val activity = Robolectric.buildActivity(Activity::class.java).get()
val channel = ExternalPlayerChannel(activity)
val source = ExternalPlayerChannel.Source(
Uri.parse("http://plex:32400/library/parts/9808/1775431760/file.mkv?X-Plex-Token=tok"),
grantRead = false,
fileName = "file.mkv"
)
val intent = channel.buildIntent(source, packageName = null, startPositionMs = 90_000L, title = null)
assertFalse(intent.getBooleanExtra("from_start", true))
assertEquals(90_000, intent.getIntExtra("position", -1))
assertEquals(90_000, intent.getIntExtra("startfrom", -1))
}
@Test
fun activityDestroyCompletesPendingChannelCall() {
val activity = Robolectric.buildActivity(Activity::class.java).get()
@@ -80,6 +80,20 @@ class AudioOutputPolicyTest {
assertFalse(shouldForceFfmpegDtsDecode(MimeTypes.AUDIO_TRUEHD, { true }, { false }))
}
@Test
fun dtsHdTakesTheCarrierOnlyWhenTheRouteAdvertisesDtsHd() {
assertTrue(dtsHdCarrierUsable({ it == C.ENCODING_DTS_HD }, { true }))
// Carries the tuple for TrueHD but decodes no DTS-HD: the burst would drain unheard.
assertFalse(dtsHdCarrierUsable({ false }, { true }))
assertFalse(dtsHdCarrierUsable({ true }, { false }))
}
@Test
fun theCarrierProbeIsSkippedWhenDtsHdIsNotAdvertised() {
// The carrier tiering costs several route calls; a route without DTS-HD must not pay them.
assertFalse(dtsHdCarrierUsable({ false }, { throw AssertionError("carrier probed without DTS-HD") }))
}
@Test
fun nonDtsMimesNeverConsultTheDtsProbes() {
// Decoder selection runs this predicate for every mime, video included; the probes make
@@ -303,6 +317,5 @@ class AudioOutputPolicyTest {
private val allShapes = MpvIecShape.values().toSet()
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>, raw: Set<Int> = emptySet()): String =
mpvSpdifCodecs({ it in encodings }, { it in shapes }, { it in raw })
private fun spdifCodecs(encodings: Set<Int>, shapes: Set<MpvIecShape>, raw: Set<Int> = emptySet()): String = mpvSpdifCodecs({ it in encodings }, { it in shapes }, { it in raw })
}
@@ -126,6 +126,20 @@ class IecCarrierSinkTest {
assertEquals(AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY, carrierSink.getFormatSupport(dtsHdFormat()))
}
/** The tuple is shared, so the gate is per format: TrueHD keeps the carrier while DTS-HD leaves. */
@Test
fun theCarrierGateIsEvaluatedPerFormat() {
val carrier = FakeSink()
val normal = FakeSink()
val carrierSink = IecCarrierSink(normal, carrier, { it.sampleMimeType != MimeTypes.AUDIO_DTS_HD }, { false })
carrierSink.configure(audioSinkConfig(trueHdFormat()))
assertEquals(IecCarrier.SAMPLE_RATE, checkNotNull(carrier.configuredConfig).format.sampleRate)
carrierSink.configure(audioSinkConfig(dtsHdFormat()))
assertEquals(MimeTypes.AUDIO_DTS_HD, checkNotNull(normal.configuredConfig).format.sampleMimeType)
}
/** DTS Express shares the DTS-HD mime prefix but carries `;profile=lbr`; it keeps decoding. */
@Test
fun dtsExpressIsLeftToTheNormalSink() {
@@ -80,6 +80,49 @@ 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))
assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10 Intra", hardwareHigh10 = false))
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HI10_SW_DECODE)))
}
@Test
fun `Hi10 routing leaves every other stream to the hardware path`() {
// A decoder that advertises the profile gets to try.
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = true))
// 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))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "Constrained Baseline", hardwareHigh10 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("hevc", "Main 10", hardwareHigh10 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", null, hardwareHigh10 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "", hardwareHigh10 = false))
assertFalse(GpuVoPolicy.needsSoftwareDecode(null, "High 10", hardwareHigh10 = false))
}
@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)))
@@ -7,11 +7,11 @@ import android.os.Looper
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.FrameLayout
import com.edde746.plezy.libmpv.EndFileReason
import com.edde746.plezy.libmpv.LogLevel
import com.edde746.plezy.libmpv.LogMessage
import com.edde746.plezy.libmpv.MpvEvent
import com.edde746.plezy.shared.AudioFocusManager
import dev.jdtech.mpv.EndFileReason
import dev.jdtech.mpv.LogLevel
import dev.jdtech.mpv.LogMessage
import dev.jdtech.mpv.MpvEvent
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
@@ -778,10 +778,11 @@ class MpvPlayerPluginTest {
assertEquals(
mapOf(
"sourceId" to 73L,
"reason" to 4,
"message" to "Invalid data found when processing input"
),
diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error))
diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, 73L))
)
}
@@ -791,9 +792,10 @@ class MpvPlayerPluginTest {
diagnostics.onLogMessage(LogMessage("ffmpeg", LogLevel.Error, "old failure"))
diagnostics.onStartFile()
assertEquals(mapOf("reason" to 0), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Eof)))
assertEquals(mapOf("reason" to 4), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error)))
assertNull(diagnostics.onEndFile(MpvEvent.EndFile(null)))
assertEquals(mapOf("reason" to 0), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Eof, null)))
assertEquals(mapOf("reason" to 4), diagnostics.onEndFile(MpvEvent.EndFile(EndFileReason.Error, null)))
assertEquals(mapOf("sourceId" to 81L), diagnostics.onEndFile(MpvEvent.EndFile(null, 81L)))
assertNull(diagnostics.onEndFile(MpvEvent.EndFile(null, null)))
}
@Test
@@ -805,6 +807,7 @@ class MpvPlayerPluginTest {
plugin.onEvent(
"end-file",
mapOf(
"sourceId" to 92L,
"reason" to 4,
"message" to "Failed to open stream"
)
@@ -815,6 +818,7 @@ class MpvPlayerPluginTest {
"type" to "event",
"name" to "end-file",
"data" to mapOf(
"sourceId" to 92L,
"reason" to 4,
"message" to "Failed to open stream"
)
@@ -823,6 +827,52 @@ class MpvPlayerPluginTest {
)
}
@Test
fun sourceQualifiedLifecycleAndPropertiesKeepTheirDequeueIdentity() {
val sink = RecordingEventSink()
val plugin = MpvPlayerPlugin()
plugin.onListen(null, sink)
plugin.onMethodCall(
MethodCall(
"observeProperty",
mapOf("name" to "time-pos", "format" to "double", "id" to 7)
),
RecordingResult()
)
plugin.onPropertyChange("time-pos", 0.0)
plugin.onEvent("start-file", mapOf("sourceId" to 202L))
plugin.onEvent("file-loaded", mapOf("sourceId" to 202L))
plugin.onPropertyChange("time-pos", 12.5, 101L)
plugin.onEvent(
"playback-restart",
mapOf("sourceId" to 202L, "positionSeconds" to 18.75)
)
assertEquals(
listOf(
listOf(7, 0.0, null),
mapOf(
"type" to "event",
"name" to "start-file",
"data" to mapOf("sourceId" to 202L)
),
mapOf(
"type" to "event",
"name" to "file-loaded",
"data" to mapOf("sourceId" to 202L)
),
listOf(7, 12.5, 101L),
mapOf(
"type" to "event",
"name" to "playback-restart",
"data" to mapOf("sourceId" to 202L, "positionSeconds" to 18.75)
)
),
sink.successValues
)
}
private fun propertyCall() = MethodCall(
"setProperty",
mapOf("name" to "volume", "value" to "50")
@@ -990,6 +1040,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
@@ -1101,9 +1184,11 @@ class MpvPlayerPluginTest {
private class RecordingEventSink : EventChannel.EventSink {
var successValue: Any? = null
val successValues = mutableListOf<Any?>()
override fun success(event: Any?) {
successValue = event
successValues += event
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) = Unit
@@ -3,6 +3,7 @@ package com.edde746.plezy.shared
import android.app.Activity
import android.os.Handler
import android.os.Looper
import java.time.Duration
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
@@ -10,7 +11,6 @@ import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import java.time.Duration
/**
* Teardown ordering for frame-rate matching (#2172): restoring the display
@@ -21,8 +21,7 @@ import java.time.Duration
@RunWith(RobolectricTestRunner::class)
class FrameRateManagerRestoreTest {
private fun buildManager(activity: Activity): FrameRateManager =
FrameRateManager(activity, Handler(Looper.getMainLooper()))
private fun buildManager(activity: Activity): FrameRateManager = FrameRateManager(activity, Handler(Looper.getMainLooper()))
private fun preferredModeId(activity: Activity): Int = activity.window.attributes.preferredDisplayModeId
+1 -1
View File
@@ -12,7 +12,7 @@ android {
compileSdk = 36
// Matches the app's latest stable NDK so every project-owned native library
// is built with the same 16 KB page-size-capable libc++ toolchain. That copy
// is NOT what ships: the app packages the libmpv AAR's newer copy with top
// is NOT what ships: the app packages the mpv-build tarball's newer copy with top
// merge priority (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
ndkVersion = "29.0.14206865"
+292
View File
@@ -0,0 +1,292 @@
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.UUID
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
fun verifySha256(file: File, expected: String, identity: String) {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
val actual = digest.digest().joinToString("") {
(it.toInt() and 0xff).toString(16).padStart(2, '0')
}
if (actual != expected) {
throw GradleException("SHA-256 mismatch for $identity: expected $expected, got $actual")
}
}
fun promoteDirectory(staging: File, destination: File) {
val backup = File(destination.parentFile, "${destination.name}.backup-${UUID.randomUUID()}")
val hadDestination = destination.exists()
try {
if (hadDestination) {
Files.move(destination.toPath(), backup.toPath(), StandardCopyOption.ATOMIC_MOVE)
}
try {
Files.move(staging.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
} catch (promotionFailure: Exception) {
if (hadDestination && backup.exists()) {
try {
Files.move(backup.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
} catch (restoreFailure: Exception) {
promotionFailure.addSuppressed(restoreFailure)
}
}
throw promotionFailure
}
if (hadDestination && backup.exists() && !backup.deleteRecursively()) {
throw GradleException("Failed to remove obsolete native artifact backup at ${backup.absolutePath}")
}
} finally {
staging.deleteRecursively()
}
}
// mpv Kotlin API + JNI glue built in-project (imported from the libmpv-android
// fork, commit e60c3ba); the external dependency is reduced to prebuilt native
// trees carried by the mpv-build per-ABI tarballs pinned below.
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
// Single source for the pinned native artifacts: the repo-root
// mpv-build.lock.json (github.com/edde746/mpv-build release assets + sha256
// checksums). This module downloads and extracts them; app/build.gradle.kts
// reads FFmpeg .so files and the libc++ runtime back out of the extracted
// trees.
val mpvAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
val mpvLockFile = rootProject.file("../mpv-build.lock.json")
val mpvLockAndroid = run {
@Suppress("UNCHECKED_CAST")
val lock = groovy.json.JsonSlurper().parse(mpvLockFile) as Map<String, Any?>
@Suppress("UNCHECKED_CAST")
((lock["artifacts"] as? Map<String, Any?>)?.get("android") as? Map<String, Any?>)
?: throw GradleException("$mpvLockFile has no artifacts.android section")
}
val mpvKey = mpvLockAndroid["key"] as? String
?: throw GradleException("$mpvLockFile artifacts.android.key is missing")
val mpvAssetBase = mpvLockAndroid["assetBase"] as? String
?: throw GradleException("$mpvLockFile artifacts.android.assetBase is missing")
@Suppress("UNCHECKED_CAST")
val mpvAssets = (mpvLockAndroid["assets"] as? Map<String, Map<String, String>>).let { assets ->
val missing = mpvAbis.filter { assets?.get(it)?.get("asset").isNullOrEmpty() || assets?.get(it)?.get("checksum").isNullOrEmpty() }
if (assets == null || missing.isNotEmpty()) {
throw GradleException("$mpvLockFile artifacts.android.assets lacks asset+checksum for: ${missing.joinToString()}")
}
assets
}
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
val mpvArchivesDir = File(mpvDir, "archives")
val mpvNativeDir = File(mpvDir, "native")
val mpvLibcxxDir = File(mpvDir, "libcxx")
// Downloaded archives are renamed to a key-independent name so a lock bump
// (new key) changes task inputs, not the output file set.
fun stagedArchiveName(abi: String) = "libmpv-android-$abi.tar.gz"
// Dev-only escape hatch: point the build at a directory of locally built
// mpv-build android tarballs (platforms/android output, one
// libmpv-android-<key>-<abi>.tar.gz per ABI) to test changes before a lock
// bump. Checksums are skipped for local archives; the lock stays
// authoritative otherwise.
val localMpvDir: String? = (project.findProperty("plezy.localMpvDir") as String?)
?: System.getenv("PLEZY_LOCAL_MPV_DIR")
fun localArchive(abi: String): File {
val dir = File(localMpvDir!!)
val exact = File(dir, mpvAssets.getValue(abi).getValue("asset"))
if (exact.isFile) return exact
val pattern = Regex("libmpv-android-.+-${Regex.escape(abi)}\\.tar\\.gz")
val matches = dir.listFiles()?.filter { it.isFile && pattern.matches(it.name) }.orEmpty()
return matches.singleOrNull() ?: throw GradleException(
"PLEZY_LOCAL_MPV_DIR=$localMpvDir must contain exactly one libmpv-android-*-$abi.tar.gz, found ${matches.size}"
)
}
val downloadLibmpv = tasks.register("downloadLibmpv") {
val manifest = File(mpvArchivesDir, ".manifest")
inputs.file(mpvLockFile)
inputs.property("localOverride", localMpvDir ?: "")
if (localMpvDir != null) mpvAbis.forEach { abi -> inputs.file(localArchive(abi)) }
outputs.files(mpvAbis.map { File(mpvArchivesDir, stagedArchiveName(it)) } + manifest)
doLast {
mpvDir.mkdirs()
val staging = File(mpvDir, "archives.staging-${UUID.randomUUID()}")
try {
staging.mkdirs()
val manifestText = StringBuilder()
mpvAbis.forEach { abi ->
val staged = File(staging, stagedArchiveName(abi))
if (localMpvDir != null) {
val source = localArchive(abi)
source.copyTo(staged, overwrite = true)
manifestText.append("$abi=local:${source.absolutePath}\n")
} else {
val asset = mpvAssets.getValue(abi).getValue("asset")
val checksum = mpvAssets.getValue(abi).getValue("checksum")
try {
providers.exec {
commandLine("curl", "-sfL", "$mpvAssetBase/$asset", "-o", staged.absolutePath)
}.result.get().assertNormalExitValue()
} catch (error: Exception) {
throw GradleException("Failed to download $asset (mpv-build $mpvKey)", error)
}
verifySha256(staged, checksum, "$asset (mpv-build $mpvKey)")
manifestText.append("$abi=$asset sha256=$checksum\n")
}
}
File(staging, ".manifest").writeText(manifestText.toString())
promoteDirectory(staging, mpvArchivesDir)
} finally {
staging.deleteRecursively()
}
}
}
// Each tarball is a native tree: lib/*.so (libmpv, seven FFmpeg libraries,
// libc++_shared) + include/mpv/*.h. The .so files land in the per-ABI jniLibs
// layout under native/jni, the headers under native/include for the CMake
// glue build, and libc++ in a separate tree that the app packages at PROJECT
// scope so the tarball's 16 KB-capable runtime deterministically wins the
// merge (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
val extractLibmpvNative = tasks.register("extractLibmpvNative") {
dependsOn(downloadLibmpv)
inputs.files(mpvAbis.map { File(mpvArchivesDir, stagedArchiveName(it)) })
outputs.dir(mpvNativeDir)
outputs.dir(mpvLibcxxDir)
doLast {
val nativeStaging = File(mpvDir, "native.staging-${UUID.randomUUID()}")
val libcxxStaging = File(mpvDir, "libcxx.staging-${UUID.randomUUID()}")
val unpackRoot = File(mpvDir, "unpack-${UUID.randomUUID()}")
try {
mpvAbis.forEach { abi ->
val unpack = File(unpackRoot, abi).apply { mkdirs() }
providers.exec {
commandLine(
"tar",
"-xzf",
File(mpvArchivesDir, stagedArchiveName(abi)).absolutePath,
"-C",
unpack.absolutePath
)
}.result.get().assertNormalExitValue()
val jniDir = File(nativeStaging, "jni/$abi")
File(unpack, "lib").listFiles()?.filter { it.isFile && it.name.endsWith(".so") }?.forEach { so ->
val target = if (so.name == "libc++_shared.so") {
File(libcxxStaging, "jni/$abi/${so.name}")
} else {
File(jniDir, so.name)
}
target.parentFile.mkdirs()
Files.move(so.toPath(), target.toPath())
}
// Headers are identical across ABIs; keep the first archive's copy.
val include = File(unpack, "include")
val includeTarget = File(nativeStaging, "include")
if (!includeTarget.exists() && include.isDirectory) {
include.copyRecursively(includeTarget)
}
}
val missing = buildList {
mpvAbis.forEach { abi ->
if (!File(nativeStaging, "jni/$abi/libmpv.so").isFile) add("native/jni/$abi/libmpv.so")
if (!File(nativeStaging, "jni/$abi/libavcodec.so").isFile) add("native/jni/$abi/libavcodec.so")
if (!File(libcxxStaging, "jni/$abi/libc++_shared.so").isFile) add("libcxx/jni/$abi/libc++_shared.so")
}
if (!File(nativeStaging, "include/mpv/client.h").isFile) add("native/include/mpv/client.h")
}
if (missing.isNotEmpty()) {
throw GradleException(
"mpv-build $mpvKey android archives are missing expected entries: ${missing.joinToString()}"
)
}
promoteDirectory(nativeStaging, mpvNativeDir)
promoteDirectory(libcxxStaging, mpvLibcxxDir)
} finally {
nativeStaging.deleteRecursively()
libcxxStaging.deleteRecursively()
unpackRoot.deleteRecursively()
}
}
}
android {
namespace = "com.edde746.plezy.libmpv"
compileSdk = 36
// Matches the app's latest stable NDK so every project-owned native library
// is built with the same 16 KB page-size-capable libc++ toolchain. That copy
// is NOT what ships: the app packages the mpv-build tarball's newer copy with
// top merge priority (see app/build.gradle.kts packaging { jniLibs } + sourceSets).
ndkVersion = "29.0.14206865"
defaultConfig {
// Fire OS 6.x (API 25), same floor as the app. The fork declared 26, but
// nothing in this API or glue uses anything above 25.
minSdk = 25
consumerProguardFiles("consumer-rules.pro")
externalNativeBuild {
cmake {
arguments += listOf(
"-DANDROID_STL=c++_shared",
"-DMPV_PREBUILT_ROOT=${mpvNativeDir.absolutePath}"
)
cFlags += "-Werror"
cppFlags += "-std=c++11"
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "4.1.2"
}
}
sourceSets {
getByName("main") {
// Prebuilt libmpv + FFmpeg .so files extracted from the mpv-build tarballs;
// the glue libplayer.so comes from the CMake build above.
jniLibs.srcDir(File(mpvNativeDir, "jni"))
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
// The imported libmpv.so/libavcodec.so must exist before CMake links the glue.
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
dependsOn(extractLibmpvNative)
}
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
// extracted prebuilt directory present during input discovery.
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
dependsOn(extractLibmpvNative)
}
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
dependsOn(extractLibmpvNative)
}
dependencies {
// Same version the app pins; MpvPlayer's public flows compile against it.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
}
+19
View File
@@ -0,0 +1,19 @@
# JNI exports bind by name (Java_com_edde746_plezy_libmpv_MpvPlayer_native*); keep the names stable.
-keepclasseswithmembernames class com.edde746.plezy.libmpv.* {
native <methods>;
}
# 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.
-keep class com.edde746.plezy.libmpv.MpvPlayer {
public static void onPropertyChanged(java.lang.String, long, boolean);
public static void onPropertyChanged(java.lang.String, boolean, long, boolean);
public static void onPropertyChanged(java.lang.String, long, long, boolean);
public static void onPropertyChanged(java.lang.String, double, long, boolean);
public static void onPropertyChanged(java.lang.String, java.lang.String, long, boolean);
public static void onEvent(int, long, boolean, double, boolean);
public static void onEndFile(int, long, boolean);
public static void onLogMessage(java.lang.String, int, java.lang.String);
public static void onHook(java.lang.String, long);
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,45 @@
cmake_minimum_required(VERSION 3.22.1)
project("libmpv")
# The JNI glue this module compiles; MpvPlayer loads it with
# System.loadLibrary("player") after libmpv.so itself.
add_library(
player
SHARED
main.cpp render.cpp log.cpp jni_utils.cpp property.cpp event.cpp
)
# Prebuilt libraries extracted from the pinned mpv-build per-ABI tarballs by the
# extractLibmpvNative Gradle task; MPV_PREBUILT_ROOT points at its output.
add_library(
mpv
SHARED
IMPORTED
)
set_target_properties(
mpv
PROPERTIES IMPORTED_LOCATION
${MPV_PREBUILT_ROOT}/jni/${ANDROID_ABI}/libmpv.so
)
# av_jni_set_java_vm / av_jni_set_android_app_ctx (main.cpp) live in FFmpeg's
# libavcodec, which the same tarballs package.
add_library(
avcodec
SHARED
IMPORTED
)
set_target_properties(
avcodec
PROPERTIES IMPORTED_LOCATION
${MPV_PREBUILT_ROOT}/jni/${ANDROID_ABI}/libavcodec.so
)
# mpv public headers ride in the extracted tarballs next to libmpv.so; the
# vendored tree only carries FFmpeg's libavcodec/jni.h (see include/README.md).
include_directories( ${MPV_PREBUILT_ROOT}/include ${CMAKE_CURRENT_SOURCE_DIR}/include )
target_link_libraries( player mpv avcodec log )
+145
View File
@@ -0,0 +1,145 @@
#include <jni.h>
#include <mpv/client.h>
#include <cmath>
#include "globals.h"
#include "jni_utils.h"
#include "log.h"
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;
switch (prop->format) {
case MPV_FORMAT_NONE:
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SJZ, 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);
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);
break;
case MPV_FORMAT_DOUBLE:
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SDJZ, 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);
break;
default:
break;
}
if (jprop) env->DeleteLocalRef(jprop);
if (jvalue) env->DeleteLocalRef(jvalue);
}
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);
}
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;
env->CallStaticVoidMethod(
mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jint)reason, (jlong)source_id, end_file ? JNI_TRUE : JNI_FALSE);
}
static void sendLogMessageToJava(JNIEnv* env, mpv_event_log_message* msg) {
jstring jprefix = new_java_string(env, msg->prefix);
jstring jtext = new_java_string(env, msg->text);
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onLogMessage, jprefix, (jint)msg->log_level, jtext);
if (jprefix) env->DeleteLocalRef(jprefix);
if (jtext) env->DeleteLocalRef(jtext);
}
// A hook holds mpv (playback does not proceed) until Kotlin answers with
// nativeHookContinue(id); MpvPlayer guarantees that answer for every hook.
static void sendHookToJava(JNIEnv* env, mpv_event_hook* hook) {
jstring jname = new_java_string(env, hook->name);
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onHook, jname, (jlong)hook->id);
if (jname) env->DeleteLocalRef(jname);
}
void* event_thread(void* arg) {
JNIEnv* env = NULL;
acquire_jni_env(g_vm, &env);
if (!env) die("failed to acquire java env");
int64_t source_id = 0;
bool has_source_id = false;
while (true) {
mpv_event* mp_event;
mpv_event_property* mp_property;
mpv_event_log_message* msg;
mp_event = mpv_wait_event(g_mpv, -1.0);
if (g_event_thread_request_exit) break;
if (mp_event->event_id == MPV_EVENT_NONE) continue;
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:
mp_property = (mpv_event_property*)mp_event->data;
sendPropertyUpdateToJava(env, mp_property, source_id, has_source_id);
break;
case MPV_EVENT_END_FILE:
sendEndFileToJava(env, mp_event);
break;
case MPV_EVENT_START_FILE: {
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 &&
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;
}
default:
// Nothing on the Kotlin side consumes the remaining ids (MpvEvent.fromId).
break;
}
}
g_vm->DetachCurrentThread();
return NULL;
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
void* event_thread(void* arg);
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <jni.h>
#include <mpv/client.h>
#include <atomic>
extern JavaVM* g_vm;
extern mpv_handle* g_mpv;
extern std::atomic<bool> g_event_thread_request_exit;
@@ -0,0 +1,15 @@
# Vendored native headers
Build-time headers for the JNI glue in this module; nothing here ships in the APK.
Each file carries its own upstream license text — none is modified.
- `libavcodec/jni.h` — FFmpeg n8.0.1 (https://github.com/FFmpeg/FFmpeg, tag `n8.0.1`,
commit `894da5ca7d742e4429ffb2af534fcda0103ef593`), copied unmodified. Declares
`av_jni_set_java_vm` / `av_jni_set_android_app_ctx`, which `main.cpp` calls into the
`libavcodec.so` packaged by the pinned mpv-build tarballs (FFmpeg 8.0.1 — the
version `app/build.gradle.kts` also pins for the Media3 adapter headers).
The mpv public headers (`mpv/client.h`, `mpv/render.h`, `mpv/render_gl.h`,
`mpv/stream_cb.h`) are no longer vendored: each mpv-build per-ABI tarball carries
`include/mpv/*.h` matching its `libmpv.so`, and `extractLibmpvNative` places them
under `native/include`, which CMake reads via `MPV_PREBUILT_ROOT`.
@@ -0,0 +1,67 @@
/*
* JNI public API functions
*
* Copyright (c) 2015-2016 Matthieu Bouron <matthieu.bouron stupeflix.com>
*
* This file is part of FFmpeg.
*
* FFmpeg is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* FFmpeg is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with FFmpeg; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
#ifndef AVCODEC_JNI_H
#define AVCODEC_JNI_H
/*
* Manually set a Java virtual machine which will be used to retrieve the JNI
* environment. Once a Java VM is set it cannot be changed afterwards, meaning
* you can call multiple times av_jni_set_java_vm with the same Java VM pointer
* however it will error out if you try to set a different Java VM.
*
* @param vm Java virtual machine
* @param log_ctx context used for logging, can be NULL
* @return 0 on success, < 0 otherwise
*/
int av_jni_set_java_vm(void *vm, void *log_ctx);
/*
* Get the Java virtual machine which has been set with av_jni_set_java_vm.
*
* @param vm Java virtual machine
* @return a pointer to the Java virtual machine
*/
void *av_jni_get_java_vm(void *log_ctx);
/*
* Set the Android application context which will be used to retrieve the Android
* content resolver to handle content uris.
*
* This function is only available on Android.
*
* @param app_ctx global JNI reference to the Android application context
* @return 0 on success, < 0 otherwise
*/
int av_jni_set_android_app_ctx(void *app_ctx, void *log_ctx);
/*
* Get the Android application context that has been set with
* av_jni_set_android_app_ctx.
*
* This function is only available on Android.
*
* @return a pointer the the Android application context
*/
void *av_jni_get_android_app_ctx(void);
#endif /* AVCODEC_JNI_H */
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,760 @@
/* Copyright (C) 2018 the mpv developers
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
#ifndef MPV_CLIENT_API_RENDER_H_
#define MPV_CLIENT_API_RENDER_H_
#include "client.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* Overview
* --------
*
* This API can be used to make mpv render using supported graphic APIs (such
* as OpenGL). It can be used to handle video display.
*
* The renderer needs to be created with mpv_render_context_create() before
* you start playback (or otherwise cause a VO to be created). Then (with most
* backends) mpv_render_context_render() can be used to explicitly render the
* current video frame. Use mpv_render_context_set_update_callback() to get
* notified when there is a new frame to draw.
*
* Preferably rendering should be done in a separate thread. If you call
* normal libmpv API functions on the renderer thread, deadlocks can result
* (these are made non-fatal with timeouts, but user experience will obviously
* suffer). See "Threading" section below.
*
* You can output and embed video without this API by setting the mpv "wid"
* option to a native window handle (see "Embedding the video window" section
* in the client.h header). In general, using the render API is recommended,
* because window embedding can cause various issues, especially with GUI
* toolkits and certain platforms.
*
* Supported backends
* ------------------
*
* OpenGL: via MPV_RENDER_API_TYPE_OPENGL, see render_gl.h header.
* Software: via MPV_RENDER_API_TYPE_SW, see section "Software renderer"
*
* Threading
* ---------
*
* You are recommended to do rendering on a separate thread than normal libmpv
* use.
*
* The mpv_render_* functions can be called from any thread, under the
* following conditions:
* - only one of the mpv_render_* functions can be called at the same time
* (unless they belong to different mpv cores created by mpv_create())
* - never can be called from within the callbacks set with
* mpv_set_wakeup_callback() or mpv_render_context_set_update_callback()
* - if the OpenGL backend is used, for all functions the OpenGL context
* must be "current" in the calling thread, and it must be the same OpenGL
* context as the mpv_render_context was created with. Otherwise, undefined
* behavior will occur.
* - the thread does not call libmpv API functions other than the mpv_render_*
* functions, except APIs which are declared as safe (see below). Likewise,
* there must be no lock or wait dependency from the render thread to a
* thread using other libmpv functions. Basically, the situation that your
* render thread waits for a "not safe" libmpv API function to return must
* not happen. If you ignore this requirement, deadlocks can happen, which
* are made non-fatal with timeouts; then playback quality will be degraded,
* and the message
* mpv_render_context_render() not being called or stuck.
* is logged. If you set MPV_RENDER_PARAM_ADVANCED_CONTROL, you promise that
* this won't happen, and must absolutely guarantee it, or a real deadlock
* will freeze the mpv core thread forever.
*
* libmpv functions which are safe to call from a render thread are:
* - functions marked with "Safe to be called from mpv render API threads."
* - client.h functions which don't have an explicit or implicit mpv_handle
* parameter
* - mpv_render_* functions; but only for the same mpv_render_context pointer.
* If the pointer is different, mpv_render_context_free() is not safe. (The
* reason is that if MPV_RENDER_PARAM_ADVANCED_CONTROL is set, it may have
* to process still queued requests from the core, which it can do only for
* the current context, while requests for other contexts would deadlock.
* Also, it may have to wait and block for the core to terminate the video
* chain to make sure no resources are used after context destruction.)
* - if the mpv_handle parameter refers to a different mpv core than the one
* you're rendering for (very obscure, but allowed)
*
* Note about old libmpv version:
*
* Before API version 1.105 (basically in mpv 0.29.x), simply enabling
* MPV_RENDER_PARAM_ADVANCED_CONTROL could cause deadlock issues. This can
* be worked around by setting the "vd-lavc-dr" option to "no".
* In addition, you were required to call all mpv_render*() API functions
* from the same thread on which mpv_render_context_create() was originally
* run (for the same the mpv_render_context). Not honoring it led to UB
* (deadlocks, use of invalid mp_thread handles), even if you moved your GL
* context to a different thread correctly.
* These problems were addressed in API version 1.105 (mpv 0.30.0).
*
* Context and handle lifecycle
* ----------------------------
*
* Video initialization will fail if the render context was not initialized yet
* (with mpv_render_context_create()), or it will revert to a VO that creates
* its own window.
*
* Currently, there can be only 1 mpv_render_context at a time per mpv core.
*
* Calling mpv_render_context_free() while a VO is using the render context is
* active will disable video.
*
* You must free the context with mpv_render_context_free() before the mpv core
* is destroyed. If this doesn't happen, undefined behavior will result.
*
* Software renderer
* -----------------
*
* MPV_RENDER_API_TYPE_SW provides an extremely simple (but slow) renderer to
* memory surfaces. You probably don't want to use this. Use other render API
* types, or other methods of video embedding.
*
* Use mpv_render_context_create() with MPV_RENDER_PARAM_API_TYPE set to
* MPV_RENDER_API_TYPE_SW.
*
* Call mpv_render_context_render() with various MPV_RENDER_PARAM_SW_* fields
* to render the video frame to an in-memory surface. The following fields are
* required: MPV_RENDER_PARAM_SW_SIZE, MPV_RENDER_PARAM_SW_FORMAT,
* MPV_RENDER_PARAM_SW_STRIDE, MPV_RENDER_PARAM_SW_POINTER.
*
* This method of rendering is very slow, because everything, including color
* conversion, scaling, and OSD rendering, is done on the CPU, single-threaded.
* In particular, large video or display sizes, as well as presence of OSD or
* subtitles can make it too slow for realtime. As with other software rendering
* VOs, setting "sw-fast" may help. Enabling or disabling zimg may help,
* depending on the platform.
*
* In addition, certain multimedia job creation measures like HDR may not work
* properly, and will have to be manually handled by for example inserting
* filters.
*
* This API is not really suitable to extract individual frames from video etc.
* (basically non-playback uses) - there are better libraries for this. It can
* be used this way, but it may be clunky and tricky.
*
* Further notes:
* - MPV_RENDER_PARAM_FLIP_Y is currently ignored (unsupported)
* - MPV_RENDER_PARAM_DEPTH is ignored (meaningless)
*/
/**
* Opaque context, returned by mpv_render_context_create().
*/
typedef struct mpv_render_context mpv_render_context;
/**
* Parameters for mpv_render_param (which is used in a few places such as
* mpv_render_context_create().
*
* Also see mpv_render_param for conventions and how to use it.
*/
typedef enum mpv_render_param_type {
/**
* Not a valid value, but also used to terminate a params array. Its value
* is always guaranteed to be 0 (even if the ABI changes in the future).
*/
MPV_RENDER_PARAM_INVALID = 0,
/**
* The render API to use. Valid for mpv_render_context_create().
*
* Type: char*
*
* Defined APIs:
*
* MPV_RENDER_API_TYPE_OPENGL:
* OpenGL desktop 2.1 or later (preferably core profile compatible to
* OpenGL 3.2), or OpenGLES 2.0 or later.
* Providing MPV_RENDER_PARAM_OPENGL_INIT_PARAMS is required.
* It is expected that an OpenGL context is valid and "current" when
* calling mpv_render_* functions (unless specified otherwise). It
* must be the same context for the same mpv_render_context.
*/
MPV_RENDER_PARAM_API_TYPE = 1,
/**
* Required parameters for initializing the OpenGL renderer. Valid for
* mpv_render_context_create().
* Type: mpv_opengl_init_params*
*/
MPV_RENDER_PARAM_OPENGL_INIT_PARAMS = 2,
/**
* Describes a GL render target. Valid for mpv_render_context_render().
* Type: mpv_opengl_fbo*
*/
MPV_RENDER_PARAM_OPENGL_FBO = 3,
/**
* Control flipped rendering. Valid for mpv_render_context_render().
* Type: int*
* If the value is set to 0, render normally. Otherwise, render it flipped,
* which is needed e.g. when rendering to an OpenGL default framebuffer
* (which has a flipped coordinate system).
*/
MPV_RENDER_PARAM_FLIP_Y = 4,
/**
* Control surface depth. Valid for mpv_render_context_render().
* Type: int*
* This implies the depth of the surface passed to the render function in
* bits per channel. If omitted or set to 0, the renderer will assume 8.
* Typically used to control dithering.
*/
MPV_RENDER_PARAM_DEPTH = 5,
/**
* ICC profile blob. Valid for mpv_render_context_set_parameter().
* Type: mpv_byte_array*
* Set an ICC profile for use with the "icc-profile-auto" option. (If the
* option is not enabled, the ICC data will not be used.)
*/
MPV_RENDER_PARAM_ICC_PROFILE = 6,
/**
* Deprecated
* Ambient light in lux. Valid for mpv_render_context_set_parameter().
* Type: int*
* This can be used for automatic gamma correction.
*/
MPV_RENDER_PARAM_AMBIENT_LIGHT = 7,
/**
* X11 Display, sometimes used for hwdec. Valid for
* mpv_render_context_create(). The Display must stay valid for the lifetime
* of the mpv_render_context.
* Type: Display*
*/
MPV_RENDER_PARAM_X11_DISPLAY = 8,
/**
* Wayland display, sometimes used for hwdec. Valid for
* mpv_render_context_create(). The wl_display must stay valid for the
* lifetime of the mpv_render_context.
* Type: struct wl_display*
*/
MPV_RENDER_PARAM_WL_DISPLAY = 9,
/**
* Better control about rendering and enabling some advanced features. Valid
* for mpv_render_context_create().
*
* This conflates multiple requirements the API user promises to abide if
* this option is enabled:
*
* - The API user's render thread, which is calling the mpv_render_*()
* functions, never waits for the core. Otherwise deadlocks can happen.
* See "Threading" section.
* - The callback set with mpv_render_context_set_update_callback() can now
* be called even if there is no new frame. The API user should call the
* mpv_render_context_update() function, and interpret the return value
* for whether a new frame should be rendered.
* - Correct functionality is impossible if the update callback is not set,
* or not set soon enough after mpv_render_context_create() (the core can
* block while waiting for you to call mpv_render_context_update(), and
* if the update callback is not correctly set, it will deadlock, or
* block for too long).
*
* In general, setting this option will enable the following features (and
* possibly more):
*
* - "Direct rendering", which means the player decodes directly to a
* texture, which saves a copy per video frame ("vd-lavc-dr" option
* needs to be enabled, and the rendering backend as well as the
* underlying GPU API/driver needs to have support for it).
* - Rendering screenshots with the GPU API if supported by the backend
* (instead of using a suboptimal software fallback via libswscale).
*
* Warning: do not just add this without reading the "Threading" section
* above, and then wondering that deadlocks happen. The
* requirements are tricky. But also note that even if advanced
* control is disabled, not adhering to the rules will lead to
* playback problems. Enabling advanced controls simply makes
* violating these rules fatal.
*
* Type: int*: 0 for disable (default), 1 for enable
*/
MPV_RENDER_PARAM_ADVANCED_CONTROL = 10,
/**
* Return information about the next frame to render. Valid for
* mpv_render_context_get_info().
*
* Type: mpv_render_frame_info*
*
* It strictly returns information about the _next_ frame. The implication
* is that e.g. mpv_render_context_update()'s return value will have
* MPV_RENDER_UPDATE_FRAME set, and the user is supposed to call
* mpv_render_context_render(). If there is no next frame, then the
* return value will have is_valid set to 0.
*/
MPV_RENDER_PARAM_NEXT_FRAME_INFO = 11,
/**
* Enable or disable video timing. Valid for mpv_render_context_render().
*
* Type: int*: 0 for disable, 1 for enable (default)
*
* When video is timed to audio, the player attempts to render video a bit
* ahead, and then do a blocking wait until the target display time is
* reached. This blocks mpv_render_context_render() for up to the amount
* specified with the "video-timing-offset" global option. You can set
* this parameter to 0 to disable this kind of waiting. If you do, it's
* recommended to use the target time value in mpv_render_frame_info to
* wait yourself, or to set the "video-timing-offset" to 0 instead.
*
* Disabling this without doing anything in addition will result in A/V sync
* being slightly off.
*/
MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME = 12,
/**
* Use to skip rendering in mpv_render_context_render().
*
* Type: int*: 0 for rendering (default), 1 for skipping
*
* If this is set, you don't need to pass a target surface to the render
* function (and if you do, it's completely ignored). This can still call
* into the lower level APIs (i.e. if you use OpenGL, the OpenGL context
* must be set).
*
* Be aware that the render API will consider this frame as having been
* rendered. All other normal rules also apply, for example about whether
* you have to call mpv_render_context_report_swap(). It also does timing
* in the same way.
*/
MPV_RENDER_PARAM_SKIP_RENDERING = 13,
/**
* Deprecated. Not supported. Use MPV_RENDER_PARAM_DRM_DISPLAY_V2 instead.
* Type : struct mpv_opengl_drm_params*
*/
MPV_RENDER_PARAM_DRM_DISPLAY = 14,
/**
* DRM draw surface size, contains draw surface dimensions.
* Valid for mpv_render_context_create().
* Type : struct mpv_opengl_drm_draw_surface_size*
*/
MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE = 15,
/**
* DRM display, contains drm display handles.
* Valid for mpv_render_context_create().
* Type : struct mpv_opengl_drm_params_v2*
*/
MPV_RENDER_PARAM_DRM_DISPLAY_V2 = 16,
/**
* MPV_RENDER_API_TYPE_SW only: rendering target surface size, mandatory.
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
* Type: int[2] (e.g.: int s[2] = {w, h}; param.data = &s[0];)
*
* The video frame is transformed as with other VOs. Typically, this means
* the video gets scaled and black bars are added if the video size or
* aspect ratio mismatches with the target size.
*/
MPV_RENDER_PARAM_SW_SIZE = 17,
/**
* MPV_RENDER_API_TYPE_SW only: rendering target surface pixel format,
* mandatory.
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
* Type: char* (e.g.: char *f = "rgb0"; param.data = f;)
*
* Valid values are:
* "rgb0", "bgr0", "0bgr", "0rgb"
* 4 bytes per pixel RGB, 1 byte (8 bit) per component, component bytes
* with increasing address from left to right (e.g. "rgb0" has r at
* address 0), the "0" component contains uninitialized garbage (often
* the value 0, but not necessarily; the bad naming is inherited from
* FFmpeg)
* Pixel alignment size: 4 bytes
* "rgb24"
* 3 bytes per pixel RGB. This is strongly discouraged because it is
* very slow.
* Pixel alignment size: 1 bytes
* other
* The API may accept other pixel formats, using mpv internal format
* names, as long as it's internally marked as RGB, has exactly 1
* plane, and is supported as conversion output. It is not a good idea
* to rely on any of these. Their semantics and handling could change.
*/
MPV_RENDER_PARAM_SW_FORMAT = 18,
/**
* MPV_RENDER_API_TYPE_SW only: rendering target surface bytes per line,
* mandatory.
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
* Type: size_t*
*
* This is the number of bytes between a pixel (x, y) and (x, y + 1) on the
* target surface. It must be a multiple of the pixel size, and have space
* for the surface width as specified by MPV_RENDER_PARAM_SW_SIZE.
*
* Both stride and pointer value should be a multiple of 64 to facilitate
* fast SIMD operation. Lower alignment might trigger slower code paths,
* and in the worst case, will copy the entire target frame. If mpv is built
* with zimg (and zimg is not disabled), the performance impact might be
* less.
* In either cases, the pointer and stride must be aligned at least to the
* pixel alignment size. Otherwise, crashes and undefined behavior is
* possible on platforms which do not support unaligned accesses (either
* through normal memory access or aligned SIMD memory access instructions).
*/
MPV_RENDER_PARAM_SW_STRIDE = 19,
/*
* MPV_RENDER_API_TYPE_SW only: rendering target surface pixel data pointer,
* mandatory.
* Valid for MPV_RENDER_API_TYPE_SW & mpv_render_context_render().
* Type: void*
*
* This points to the first pixel at the left/top corner (0, 0). In
* particular, each line y starts at (pointer + stride * y). Upon rendering,
* all data between pointer and (pointer + stride * h) is overwritten.
* Whether the padding between (w, y) and (0, y + 1) is overwritten is left
* unspecified (it should not be, but unfortunately some scaler backends
* will do it anyway). It is assumed that even the padding after the last
* line (starting at bytepos(w, h) until (pointer + stride * h)) is
* writable.
*
* See MPV_RENDER_PARAM_SW_STRIDE for alignment requirements.
*/
MPV_RENDER_PARAM_SW_POINTER = 20,
} mpv_render_param_type;
/**
* For backwards compatibility with the old naming of
* MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE
*/
#define MPV_RENDER_PARAM_DRM_OSD_SIZE MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE
/**
* Used to pass arbitrary parameters to some mpv_render_* functions. The
* meaning of the data parameter is determined by the type, and each
* MPV_RENDER_PARAM_* documents what type the value must point to.
*
* Each value documents the required data type as the pointer you cast to
* void* and set on mpv_render_param.data. For example, if MPV_RENDER_PARAM_FOO
* documents the type as Something* , then the code should look like this:
*
* Something foo = {...};
* mpv_render_param param;
* param.type = MPV_RENDER_PARAM_FOO;
* param.data = & foo;
*
* Normally, the data field points to exactly 1 object. If the type is char*,
* it points to a 0-terminated string.
*
* In all cases (unless documented otherwise) the pointers need to remain
* valid during the call only. Unless otherwise documented, the API functions
* will not write to the params array or any data pointed to it.
*
* As a convention, parameter arrays are always terminated by type==0. There
* is no specific order of the parameters required. The order of the 2 fields in
* this struct is guaranteed (even after ABI changes).
*/
typedef struct mpv_render_param {
enum mpv_render_param_type type;
void *data;
} mpv_render_param;
/**
* Predefined values for MPV_RENDER_PARAM_API_TYPE.
*/
// See render_gl.h
#define MPV_RENDER_API_TYPE_OPENGL "opengl"
// See section "Software renderer"
#define MPV_RENDER_API_TYPE_SW "sw"
/**
* Flags used in mpv_render_frame_info.flags. Each value represents a bit in it.
*/
typedef enum mpv_render_frame_info_flag {
/**
* Set if there is actually a next frame. If unset, there is no next frame
* yet, and other flags and fields that require a frame to be queued will
* be unset.
*
* This is set for _any_ kind of frame, even for redraw requests.
*
* Note that when this is unset, it simply means no new frame was
* decoded/queued yet, not necessarily that the end of the video was
* reached. A new frame can be queued after some time.
*
* If the return value of mpv_render_context_render() had the
* MPV_RENDER_UPDATE_FRAME flag set, this flag will usually be set as well,
* unless the frame is rendered, or discarded by other asynchronous events.
*/
MPV_RENDER_FRAME_INFO_PRESENT = 1 << 0,
/**
* If set, the frame is not an actual new video frame, but a redraw request.
* For example if the video is paused, and an option that affects video
* rendering was changed (or any other reason), an update request can be
* issued and this flag will be set.
*
* Typically, redraw frames will not be subject to video timing.
*
* Implies MPV_RENDER_FRAME_INFO_PRESENT.
*/
MPV_RENDER_FRAME_INFO_REDRAW = 1 << 1,
/**
* If set, this is supposed to reproduce the previous frame perfectly. This
* is usually used for certain "video-sync" options ("display-..." modes).
* Typically the renderer will blit the video from a FBO. Unset otherwise.
*
* Implies MPV_RENDER_FRAME_INFO_PRESENT.
*/
MPV_RENDER_FRAME_INFO_REPEAT = 1 << 2,
/**
* If set, the player timing code expects that the user thread blocks on
* vsync (by either delaying the render call, or by making a call to
* mpv_render_context_report_swap() at vsync time).
*
* Implies MPV_RENDER_FRAME_INFO_PRESENT.
*/
MPV_RENDER_FRAME_INFO_BLOCK_VSYNC = 1 << 3,
} mpv_render_frame_info_flag;
/**
* Information about the next video frame that will be rendered. Can be
* retrieved with MPV_RENDER_PARAM_NEXT_FRAME_INFO.
*/
typedef struct mpv_render_frame_info {
/**
* A bitset of mpv_render_frame_info_flag values (i.e. multiple flags are
* combined with bitwise or).
*/
uint64_t flags;
/**
* Absolute time at which the frame is supposed to be displayed. This is in
* the same unit and base as the time returned by mpv_get_time_us(). For
* frames that are redrawn, or if vsync locked video timing is used (see
* "video-sync" option), then this can be 0. The "video-timing-offset"
* option determines how much "headroom" the render thread gets (but a high
* enough frame rate can reduce it anyway). mpv_render_context_render() will
* normally block until the time is elapsed, unless you pass it
* MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME = 0.
*/
int64_t target_time;
} mpv_render_frame_info;
/**
* Initialize the renderer state. Depending on the backend used, this will
* access the underlying GPU API and initialize its own objects.
*
* You must free the context with mpv_render_context_free(). Not doing so before
* the mpv core is destroyed may result in memory leaks or crashes.
*
* Currently, only at most 1 context can exists per mpv core (it represents the
* main video output).
*
* You should pass the following parameters:
* - MPV_RENDER_PARAM_API_TYPE to select the underlying backend/GPU API.
* - Backend-specific init parameter, like MPV_RENDER_PARAM_OPENGL_INIT_PARAMS.
* - Setting MPV_RENDER_PARAM_ADVANCED_CONTROL and following its rules is
* strongly recommended.
* - If you want to use hwdec, possibly hwdec interop resources.
*
* @param res set to the context (on success) or NULL (on failure). The value
* is never read and always overwritten.
* @param mpv handle used to get the core (the mpv_render_context won't depend
* on this specific handle, only the core referenced by it)
* @param params an array of parameters, terminated by type==0. It's left
* unspecified what happens with unknown parameters. At least
* MPV_RENDER_PARAM_API_TYPE is required, and most backends will
* require another backend-specific parameter.
* @return error code, including but not limited to:
* MPV_ERROR_UNSUPPORTED: the OpenGL version is not supported
* (or required extensions are missing)
* MPV_ERROR_NOT_IMPLEMENTED: an unknown API type was provided, or
* support for the requested API was not
* built in the used libmpv binary.
* MPV_ERROR_INVALID_PARAMETER: at least one of the provided parameters was
* not valid.
*/
MPV_EXPORT int mpv_render_context_create(mpv_render_context **res, mpv_handle *mpv,
mpv_render_param *params);
/**
* Attempt to change a single parameter. Not all backends and parameter types
* support all kinds of changes.
*
* @param ctx a valid render context
* @param param the parameter type and data that should be set
* @return error code. If a parameter could actually be changed, this returns
* success, otherwise an error code depending on the parameter type
* and situation.
*/
MPV_EXPORT int mpv_render_context_set_parameter(mpv_render_context *ctx,
mpv_render_param param);
/**
* Retrieve information from the render context. This is NOT a counterpart to
* mpv_render_context_set_parameter(), because you generally can't read
* parameters set with it, and this function is not meant for this purpose.
* Instead, this is for communicating information from the renderer back to the
* user. See mpv_render_param_type; entries which support this function
* explicitly mention it, and for other entries you can assume it will fail.
*
* You pass param with param.type set and param.data pointing to a variable
* of the required data type. The function will then overwrite that variable
* with the returned value (at least on success).
*
* @param ctx a valid render context
* @param param the parameter type and data that should be retrieved
* @return error code. If a parameter could actually be retrieved, this returns
* success, otherwise an error code depending on the parameter type
* and situation. MPV_ERROR_NOT_IMPLEMENTED is used for unknown
* param.type, or if retrieving it is not supported.
*/
MPV_EXPORT int mpv_render_context_get_info(mpv_render_context *ctx,
mpv_render_param param);
typedef void (*mpv_render_update_fn)(void *cb_ctx);
/**
* Set the callback that notifies you when a new video frame is available, or
* if the video display configuration somehow changed and requires a redraw.
* Similar to mpv_set_wakeup_callback(), you must not call any mpv API from
* the callback, and all the other listed restrictions apply (such as not
* exiting the callback by throwing exceptions).
*
* This can be called from any thread, except from an update callback. In case
* of the OpenGL backend, no OpenGL state or API is accessed.
*
* Calling this will raise an update callback immediately.
*
* @param callback callback(callback_ctx) is called if the frame should be
* redrawn
* @param callback_ctx opaque argument to the callback
*/
MPV_EXPORT void mpv_render_context_set_update_callback(mpv_render_context *ctx,
mpv_render_update_fn callback,
void *callback_ctx);
/**
* The API user is supposed to call this when the update callback was invoked
* (like all mpv_render_* functions, this has to happen on the render thread,
* and _not_ from the update callback itself).
*
* This is optional if MPV_RENDER_PARAM_ADVANCED_CONTROL was not set (default).
* Otherwise, it's a hard requirement that this is called after each update
* callback. If multiple update callback happened, and the function could not
* be called sooner, it's OK to call it once after the last callback.
*
* If an update callback happens during or after this function, the function
* must be called again at the soonest possible time.
*
* If MPV_RENDER_PARAM_ADVANCED_CONTROL was set, this will do additional work
* such as allocating textures for the video decoder.
*
* @return a bitset of mpv_render_update_flag values (i.e. multiple flags are
* combined with bitwise or). Typically, this will tell the API user
* what should happen next. E.g. if the MPV_RENDER_UPDATE_FRAME flag is
* set, mpv_render_context_render() should be called. If flags unknown
* to the API user are set, or if the return value is 0, nothing needs
* to be done.
*/
MPV_EXPORT uint64_t mpv_render_context_update(mpv_render_context *ctx);
/**
* Flags returned by mpv_render_context_update(). Each value represents a bit
* in the function's return value.
*/
typedef enum mpv_render_update_flag {
/**
* A new video frame must be rendered. mpv_render_context_render() must be
* called.
*/
MPV_RENDER_UPDATE_FRAME = 1 << 0,
} mpv_render_context_flag;
/**
* Render video.
*
* Typically renders the video to a target surface provided via mpv_render_param
* (the details depend on the backend in use). Options like "panscan" are
* applied to determine which part of the video should be visible and how the
* video should be scaled. You can change these options at runtime by using the
* mpv property API.
*
* The renderer will reconfigure itself every time the target surface
* configuration (such as size) is changed.
*
* This function implicitly pulls a video frame from the internal queue and
* renders it. If no new frame is available, the previous frame is redrawn.
* The update callback set with mpv_render_context_set_update_callback()
* notifies you when a new frame was added. The details potentially depend on
* the backends and the provided parameters.
*
* Generally, libmpv will invoke your update callback some time before the video
* frame should be shown, and then lets this function block until the supposed
* display time. This will limit your rendering to video FPS. You can prevent
* this by setting the "video-timing-offset" global option to 0. (This applies
* only to "audio" video sync mode.)
*
* You should pass the following parameters:
* - Backend-specific target object, such as MPV_RENDER_PARAM_OPENGL_FBO.
* - Possibly transformations, such as MPV_RENDER_PARAM_FLIP_Y.
*
* @param ctx a valid render context
* @param params an array of parameters, terminated by type==0. Which parameters
* are required depends on the backend. It's left unspecified what
* happens with unknown parameters.
* @return error code
*/
MPV_EXPORT int mpv_render_context_render(mpv_render_context *ctx, mpv_render_param *params);
/**
* Tell the renderer that a frame was flipped at the given time. This is
* optional, but can help the player to achieve better timing.
*
* Note that calling this at least once informs libmpv that you will use this
* function. If you use it inconsistently, expect bad video playback.
*
* If this is called while no video is initialized, it is ignored.
*
* @param ctx a valid render context
*/
MPV_EXPORT void mpv_render_context_report_swap(mpv_render_context *ctx);
/**
* Destroy the mpv renderer state.
*
* If video is still active (e.g. a file playing), video will be disabled
* forcefully.
*
* @param ctx a valid render context. After this function returns, this is not
* a valid pointer anymore. NULL is also allowed and does nothing.
*/
MPV_EXPORT void mpv_render_context_free(mpv_render_context *ctx);
#ifdef MPV_CPLUGIN_DYNAMIC_SYM
MPV_DEFINE_SYM_PTR(mpv_render_context_create)
#define mpv_render_context_create pfn_mpv_render_context_create
MPV_DEFINE_SYM_PTR(mpv_render_context_set_parameter)
#define mpv_render_context_set_parameter pfn_mpv_render_context_set_parameter
MPV_DEFINE_SYM_PTR(mpv_render_context_get_info)
#define mpv_render_context_get_info pfn_mpv_render_context_get_info
MPV_DEFINE_SYM_PTR(mpv_render_context_set_update_callback)
#define mpv_render_context_set_update_callback pfn_mpv_render_context_set_update_callback
MPV_DEFINE_SYM_PTR(mpv_render_context_update)
#define mpv_render_context_update pfn_mpv_render_context_update
MPV_DEFINE_SYM_PTR(mpv_render_context_render)
#define mpv_render_context_render pfn_mpv_render_context_render
MPV_DEFINE_SYM_PTR(mpv_render_context_report_swap)
#define mpv_render_context_report_swap pfn_mpv_render_context_report_swap
MPV_DEFINE_SYM_PTR(mpv_render_context_free)
#define mpv_render_context_free pfn_mpv_render_context_free
#endif
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,211 @@
/* Copyright (C) 2018 the mpv developers
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
#ifndef MPV_CLIENT_API_RENDER_GL_H_
#define MPV_CLIENT_API_RENDER_GL_H_
#include "render.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* OpenGL backend
* --------------
*
* This header contains definitions for using OpenGL with the render.h API.
*
* OpenGL interop
* --------------
*
* The OpenGL backend has some special rules, because OpenGL itself uses
* implicit per-thread contexts, which causes additional API problems.
*
* This assumes the OpenGL context lives on a certain thread controlled by the
* API user. All mpv_render_* APIs have to be assumed to implicitly use the
* OpenGL context if you pass a mpv_render_context using the OpenGL backend,
* unless specified otherwise.
*
* The OpenGL context is indirectly accessed through the OpenGL function
* pointers returned by the get_proc_address callback in mpv_opengl_init_params.
* Generally, mpv will not load the system OpenGL library when using this API.
*
* OpenGL state
* ------------
*
* OpenGL has a large amount of implicit state. All the mpv functions mentioned
* above expect that the OpenGL state is reasonably set to OpenGL standard
* defaults. Likewise, mpv will attempt to leave the OpenGL context with
* standard defaults. The following state is excluded from this:
*
* - the glViewport state
* - the glScissor state (but GL_SCISSOR_TEST is in its default value)
* - glBlendFuncSeparate() state (but GL_BLEND is in its default value)
* - glClearColor() state
* - mpv may overwrite the callback set with glDebugMessageCallback()
* - mpv always disables GL_DITHER at init
*
* Messing with the state could be avoided by creating shared OpenGL contexts,
* but this is avoided for the sake of compatibility and interoperability.
*
* On OpenGL 2.1, mpv will strictly call functions like glGenTextures() to
* create OpenGL objects. You will have to do the same. This ensures that
* objects created by mpv and the API users don't clash. Also, legacy state
* must be either in its defaults, or not interfere with core state.
*
* API use
* -------
*
* The mpv_render_* API is used. That API supports multiple backends, and this
* section documents specifics for the OpenGL backend.
*
* Use mpv_render_context_create() with MPV_RENDER_PARAM_API_TYPE set to
* MPV_RENDER_API_TYPE_OPENGL, and MPV_RENDER_PARAM_OPENGL_INIT_PARAMS provided.
*
* Call mpv_render_context_render() with MPV_RENDER_PARAM_OPENGL_FBO to render
* the video frame to an FBO.
*
* Hardware decoding
* -----------------
*
* Hardware decoding via this API is fully supported, but requires some
* additional setup. (At least if direct hardware decoding modes are wanted,
* instead of copying back surface data from GPU to CPU RAM.)
*
* There may be certain requirements on the OpenGL implementation:
*
* - Windows: ANGLE is required (although in theory GL/DX interop could be used)
* - Intel/Linux: EGL is required, and also the native display resource needs
* to be provided (e.g. MPV_RENDER_PARAM_X11_DISPLAY for X11 and
* MPV_RENDER_PARAM_WL_DISPLAY for Wayland)
* - nVidia/Linux: Both GLX and EGL should work (GLX is required if vdpau is
* used, e.g. due to old drivers.)
* - macOS: CGL is required (CGLGetCurrentContext() returning non-NULL)
* - iOS: EAGL is required (EAGLContext.currentContext returning non-nil)
*
* Once these things are setup, hardware decoding can be enabled/disabled at
* any time by setting the "hwdec" property.
*/
/**
* For initializing the mpv OpenGL state via MPV_RENDER_PARAM_OPENGL_INIT_PARAMS.
*/
typedef struct mpv_opengl_init_params {
/**
* This retrieves OpenGL function pointers, and will use them in subsequent
* operation.
* Usually, you can simply call the GL context APIs from this callback (e.g.
* glXGetProcAddressARB or wglGetProcAddress), but some APIs do not always
* return pointers for all standard functions (even if present); in this
* case you have to compensate by looking up these functions yourself when
* libmpv wants to resolve them through this callback.
* libmpv will not normally attempt to resolve GL functions on its own, nor
* does it link to GL libraries directly.
*/
void *(*get_proc_address)(void *ctx, const char *name);
/**
* Value passed as ctx parameter to get_proc_address().
*/
void *get_proc_address_ctx;
} mpv_opengl_init_params;
/**
* For MPV_RENDER_PARAM_OPENGL_FBO.
*/
typedef struct mpv_opengl_fbo {
/**
* Framebuffer object name. This must be either a valid FBO generated by
* glGenFramebuffers() that is complete and color-renderable, or 0. If the
* value is 0, this refers to the OpenGL default framebuffer.
*/
int fbo;
/**
* Valid dimensions. This must refer to the size of the framebuffer. This
* must always be set.
*/
int w, h;
/**
* Underlying texture internal format (e.g. GL_RGBA8), or 0 if unknown. If
* this is the default framebuffer, this can be an equivalent.
*/
int internal_format;
} mpv_opengl_fbo;
/**
* Deprecated. For MPV_RENDER_PARAM_DRM_DISPLAY.
*/
typedef struct mpv_opengl_drm_params {
int fd;
int crtc_id;
int connector_id;
struct _drmModeAtomicReq **atomic_request_ptr;
int render_fd;
} mpv_opengl_drm_params;
/**
* For MPV_RENDER_PARAM_DRM_DRAW_SURFACE_SIZE.
*/
typedef struct mpv_opengl_drm_draw_surface_size {
/**
* size of the draw plane surface in pixels.
*/
int width, height;
} mpv_opengl_drm_draw_surface_size;
/**
* For MPV_RENDER_PARAM_DRM_DISPLAY_V2.
*/
typedef struct mpv_opengl_drm_params_v2 {
/**
* DRM fd (int). Set to -1 if invalid.
*/
int fd;
/**
* Currently used crtc id
*/
int crtc_id;
/**
* Currently used connector id
*/
int connector_id;
/**
* Pointer to a drmModeAtomicReq pointer that is being used for the renderloop.
* This pointer should hold a pointer to the atomic request pointer
* The atomic request pointer is usually changed at every renderloop.
*/
struct _drmModeAtomicReq **atomic_request_ptr;
/**
* DRM render node. Used for VAAPI interop.
* Set to -1 if invalid.
*/
int render_fd;
} mpv_opengl_drm_params_v2;
/**
* For backwards compatibility with the old naming of mpv_opengl_drm_draw_surface_size
*/
#define mpv_opengl_drm_osd_size mpv_opengl_drm_draw_surface_size
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,247 @@
/* Copyright (C) 2017 the mpv developers
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
#ifndef MPV_CLIENT_API_STREAM_CB_H_
#define MPV_CLIENT_API_STREAM_CB_H_
#include "client.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* Warning: this API is not stable yet.
*
* Overview
* --------
*
* This API can be used to make mpv read from a stream with a custom
* implementation. This interface is inspired by funopen on BSD and
* fopencookie on linux. The stream is backed by user-defined callbacks
* which can implement customized open, read, seek, size and close behaviors.
*
* Usage
* -----
*
* Register your stream callbacks with the mpv_stream_cb_add_ro() function. You
* have to provide a mpv_stream_cb_open_ro_fn callback to it (open_fn argument).
*
* Once registered, you can `loadfile myprotocol://myfile`. Your open_fn will be
* invoked with the URI and you must fill out the provided mpv_stream_cb_info
* struct. This includes your stream callbacks (like read_fn), and an opaque
* cookie, which will be passed as the first argument to all the remaining
* stream callbacks.
*
* Note that your custom callbacks must not invoke libmpv APIs as that would
* cause a deadlock. (Unless you call a different mpv_handle than the one the
* callback was registered for, and the mpv_handles refer to different mpv
* instances.)
*
* Stream lifetime
* ---------------
*
* A stream remains valid until its close callback has been called. It's up to
* libmpv to call the close callback, and the libmpv user cannot close it
* directly with the stream_cb API.
*
* For example, if you consider your custom stream to become suddenly invalid
* (maybe because the underlying stream died), libmpv will continue using your
* stream. All you can do is returning errors from each callback, until libmpv
* gives up and closes it.
*
* Protocol registration and lifetime
* ----------------------------------
*
* Protocols remain registered until the mpv instance is terminated. This means
* in particular that it can outlive the mpv_handle that was used to register
* it, but once mpv_terminate_destroy() is called, your registered callbacks
* will not be called again.
*
* Protocol unregistration is finished after the mpv core has been destroyed
* (e.g. after mpv_terminate_destroy() has returned).
*
* If you do not call mpv_terminate_destroy() yourself (e.g. plugin-style code),
* you will have to deal with the registration or even streams outliving your
* code. Here are some possible ways to do this:
* - call mpv_terminate_destroy(), which destroys the core, and will make sure
* all streams are closed once this function returns
* - you refcount all resources your stream "cookies" reference, so that it
* doesn't matter if streams live longer than expected
* - create "cancellation" semantics: after your protocol has been unregistered,
* notify all your streams that are still opened, and make them drop all
* referenced resources - then return errors from the stream callbacks as
* long as the stream is still opened
*
*/
/**
* Read callback used to implement a custom stream. The semantics of the
* callback match read(2) in blocking mode. Short reads are allowed (you can
* return less bytes than requested, and libmpv will retry reading the rest
* with another call). If no data can be immediately read, the callback must
* block until there is new data. A return of 0 will be interpreted as final
* EOF, although libmpv might retry the read, or seek to a different position.
*
* @param cookie opaque cookie identifying the stream,
* returned from mpv_stream_cb_open_fn
* @param buf buffer to read data into
* @param size of the buffer
* @return number of bytes read into the buffer
* @return 0 on EOF
* @return -1 on error
*/
typedef int64_t (*mpv_stream_cb_read_fn)(void *cookie, char *buf, uint64_t nbytes);
/**
* Seek callback used to implement a custom stream.
*
* Note that mpv will issue a seek to position 0 immediately after opening. This
* is used to test whether the stream is seekable (since seekability might
* depend on the URI contents, not just the protocol). Return
* MPV_ERROR_UNSUPPORTED if seeking is not implemented for this stream. This
* seek also serves to establish the fact that streams start at position 0.
*
* This callback can be NULL, in which it behaves as if always returning
* MPV_ERROR_UNSUPPORTED.
*
* @param cookie opaque cookie identifying the stream,
* returned from mpv_stream_cb_open_fn
* @param offset target absolute stream position
* @return the resulting offset of the stream
* MPV_ERROR_UNSUPPORTED or MPV_ERROR_GENERIC if the seek failed
*/
typedef int64_t (*mpv_stream_cb_seek_fn)(void *cookie, int64_t offset);
/**
* Size callback used to implement a custom stream.
*
* Return MPV_ERROR_UNSUPPORTED if no size is known.
*
* This callback can be NULL, in which it behaves as if always returning
* MPV_ERROR_UNSUPPORTED.
*
* @param cookie opaque cookie identifying the stream,
* returned from mpv_stream_cb_open_fn
* @return the total size in bytes of the stream
*/
typedef int64_t (*mpv_stream_cb_size_fn)(void *cookie);
/**
* Close callback used to implement a custom stream.
*
* @param cookie opaque cookie identifying the stream,
* returned from mpv_stream_cb_open_fn
*/
typedef void (*mpv_stream_cb_close_fn)(void *cookie);
/**
* Cancel callback used to implement a custom stream.
*
* This callback is used to interrupt any current or future read and seek
* operations. It will be called from a separate thread than the demux
* thread, and should not block.
*
* This callback can be NULL.
*
* Available since API 1.106.
*
* @param cookie opaque cookie identifying the stream,
* returned from mpv_stream_cb_open_fn
*/
typedef void (*mpv_stream_cb_cancel_fn)(void *cookie);
/**
* See mpv_stream_cb_open_ro_fn callback.
*/
typedef struct mpv_stream_cb_info {
/**
* Opaque user-provided value, which will be passed to the other callbacks.
* The close callback will be called to release the cookie. It is not
* interpreted by mpv. It doesn't even need to be a valid pointer.
*
* The user sets this in the mpv_stream_cb_open_ro_fn callback.
*/
void *cookie;
/**
* Callbacks set by the user in the mpv_stream_cb_open_ro_fn callback. Some
* of them are optional, and can be left unset.
*
* The following callbacks are mandatory: read_fn, close_fn
*/
mpv_stream_cb_read_fn read_fn;
mpv_stream_cb_seek_fn seek_fn;
mpv_stream_cb_size_fn size_fn;
mpv_stream_cb_close_fn close_fn;
mpv_stream_cb_cancel_fn cancel_fn; /* since API 1.106 */
} mpv_stream_cb_info;
/**
* Open callback used to implement a custom read-only (ro) stream. The user
* must set the callback fields in the passed info struct. The cookie field
* also can be set to store state associated to the stream instance.
*
* Note that the info struct is valid only for the duration of this callback.
* You can't change the callbacks or the pointer to the cookie at a later point.
*
* Each stream instance created by the open callback can have different
* callbacks.
*
* The close_fn callback will terminate the stream instance. The pointers to
* your callbacks and cookie will be discarded, and the callbacks will not be
* called again.
*
* @param user_data opaque user data provided via mpv_stream_cb_add()
* @param uri name of the stream to be opened (with protocol prefix)
* @param info fields which the user should fill
* @return 0 on success, MPV_ERROR_LOADING_FAILED if the URI cannot be opened.
*/
typedef int (*mpv_stream_cb_open_ro_fn)(void *user_data, char *uri,
mpv_stream_cb_info *info);
/**
* Add a custom stream protocol. This will register a protocol handler under
* the given protocol prefix, and invoke the given callbacks if an URI with the
* matching protocol prefix is opened.
*
* The "ro" is for read-only - only read-only streams can be registered with
* this function.
*
* The callback remains registered until the mpv core is registered.
*
* If a custom stream with the same name is already registered, then the
* MPV_ERROR_INVALID_PARAMETER error is returned.
*
* @param protocol protocol prefix, for example "foo" for "foo://" URIs
* @param user_data opaque pointer passed into the mpv_stream_cb_open_fn
* callback.
* @return error code
*/
MPV_EXPORT int mpv_stream_cb_add_ro(mpv_handle *ctx, const char *protocol, void *user_data,
mpv_stream_cb_open_ro_fn open_fn);
#ifdef MPV_CPLUGIN_DYNAMIC_SYM
MPV_DEFINE_SYM_PTR(mpv_stream_cb_add_ro)
#define mpv_stream_cb_add_ro pfn_mpv_stream_cb_add_ro
#endif
#ifdef __cplusplus
}
#endif
#endif
+70
View File
@@ -0,0 +1,70 @@
#define UTIL_EXTERN
#include "jni_utils.h"
#include <jni.h>
#include <cstdlib>
#include "utf8_convert.h"
jstring new_java_string(JNIEnv* env, const char* utf8) {
if (!utf8) return NULL;
const std::u16string u16 = plezy::utf8::ToUtf16(utf8);
return env->NewString(reinterpret_cast<const jchar*>(u16.data()), static_cast<jsize>(u16.size()));
}
std::string java_string_to_utf8(JNIEnv* env, jstring jstr) {
if (!jstr) return std::string();
const jsize len = env->GetStringLength(jstr);
const jchar* chars = env->GetStringChars(jstr, NULL);
if (!chars) return std::string();
std::string out = plezy::utf8::FromUtf16(reinterpret_cast<const char16_t*>(chars), static_cast<size_t>(len));
env->ReleaseStringChars(jstr, chars);
return out;
}
bool acquire_jni_env(JavaVM* vm, JNIEnv** env) {
int ret = vm->GetEnv((void**)env, JNI_VERSION_1_6);
if (ret == JNI_EDETACHED)
return vm->AttachCurrentThread(env, NULL) == 0;
else
return ret == JNI_OK;
}
void init_methods_cache(JNIEnv* env) {
static bool methods_initialized = false;
if (methods_initialized) return;
// Plain assignments straight from env->FindClass, promoted to global refs
// on the following line, keep every Get*MethodID owner traceable for
// scripts/checks/check_shrinker_rules.py.
java_Integer = env->FindClass("java/lang/Integer");
java_Integer = reinterpret_cast<jclass>(env->NewGlobalRef(java_Integer));
java_Integer_init = env->GetMethodID(java_Integer, "<init>", "(I)V");
java_Double = env->FindClass("java/lang/Double");
java_Double = reinterpret_cast<jclass>(env->NewGlobalRef(java_Double));
java_Double_init = env->GetMethodID(java_Double, "<init>", "(D)V");
java_Boolean = env->FindClass("java/lang/Boolean");
java_Boolean = reinterpret_cast<jclass>(env->NewGlobalRef(java_Boolean));
java_Boolean_init = env->GetMethodID(java_Boolean, "<init>", "(Z)V");
mpv_MpvPlayer = env->FindClass("com/edde746/plezy/libmpv/MpvPlayer");
mpv_MpvPlayer = reinterpret_cast<jclass>(env->NewGlobalRef(mpv_MpvPlayer));
mpv_MpvPlayer_onPropertyChanged_SJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JZ)V");
mpv_MpvPlayer_onPropertyChanged_SZJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;ZJZ)V");
mpv_MpvPlayer_onPropertyChanged_SJJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;JJZ)V");
mpv_MpvPlayer_onPropertyChanged_SDJZ =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/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");
mpv_MpvPlayer_onLogMessage =
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(Ljava/lang/String;ILjava/lang/String;)V");
mpv_MpvPlayer_onHook = env->GetStaticMethodID(mpv_MpvPlayer, "onHook", "(Ljava/lang/String;J)V");
methods_initialized = true;
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <jni.h>
#include <string>
#define jni_func_name(name) Java_com_edde746_plezy_libmpv_MpvPlayer_##name
#define jni_func(return_type, name, ...) \
JNIEXPORT return_type JNICALL jni_func_name(name)(JNIEnv * env, jobject obj, ##__VA_ARGS__)
bool acquire_jni_env(JavaVM* vm, JNIEnv** env);
void init_methods_cache(JNIEnv* env);
// Standard-UTF-8 string crossings; see utf8_convert.h for why NewStringUTF /
// GetStringUTFChars are wrong for mpv data. `utf8` may be NULL (-> NULL).
jstring new_java_string(JNIEnv* env, const char* utf8);
// `jstr` may be NULL (-> empty).
std::string java_string_to_utf8(JNIEnv* env, jstring jstr);
#ifndef UTIL_EXTERN
#define UTIL_EXTERN extern
#endif
UTIL_EXTERN jclass java_Integer, java_Double, java_Boolean;
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_onHook;
+13
View File
@@ -0,0 +1,13 @@
#include "log.h"
#include "globals.h"
#include "jni_utils.h"
void die(const char* msg) {
ALOGE("%s", msg);
JNIEnv* env = nullptr;
if (g_vm && acquire_jni_env(g_vm, &env) && env) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, msg);
}
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#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);
#define CHECK_MPV_INIT() \
do { \
if (__builtin_expect(!g_mpv, 0)) { \
die("libmpv is not initialized"); \
return; \
} \
} while (0)
#define CHECK_MPV_INIT_RET(val) \
do { \
if (__builtin_expect(!g_mpv, 0)) { \
die("libmpv is not initialized"); \
return val; \
} \
} while (0)
+148
View File
@@ -0,0 +1,148 @@
#include <jni.h>
#include <mpv/client.h>
#include <pthread.h>
#include <atomic>
#include <clocale>
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <mutex>
#include <string>
#include <vector>
extern "C" {
#include <libavcodec/jni.h>
}
#include "event.h"
#include "jni_utils.h"
#include "log.h"
#define ARRAYLEN(a) (sizeof(a) / sizeof(a[0]))
void render_cleanup(JNIEnv* env);
extern "C" {
jni_func(void, nativeCreate, jobject appctx);
jni_func(void, nativeInit);
jni_func(void, nativeDestroy);
jni_func(void, nativeCommand, jobjectArray jarray);
jni_func(void, nativeHookContinue, jlong id);
};
JavaVM* g_vm;
mpv_handle* g_mpv;
std::atomic<bool> g_event_thread_request_exit(false);
static pthread_t event_thread_id;
static std::mutex g_lifecycle_mutex;
static void prepare_environment(JNIEnv* env, jobject appctx) {
setlocale(LC_NUMERIC, "C");
if (!env->GetJavaVM(&g_vm) && g_vm) av_jni_set_java_vm(g_vm, NULL);
jobject global_appctx = env->NewGlobalRef(appctx);
if (global_appctx) av_jni_set_android_app_ctx(global_appctx, NULL);
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;
}
// 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;
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;
}
mpv_handle* local_mpv = g_mpv;
g_event_thread_request_exit = true;
mpv_wakeup(local_mpv);
pthread_join(event_thread_id, NULL);
g_mpv = NULL;
// The MediaCodec VO can retain the Surface until final decoder teardown.
// Keep its JNI refs alive for the entire blocking termination.
mpv_terminate_destroy(local_mpv);
render_cleanup(env);
}
jni_func(void, nativeCommand, jobjectArray jarray) {
CHECK_MPV_INIT();
const char* arguments[128] = {0};
int len = env->GetArrayLength(jarray);
if (len >= (int)ARRAYLEN(arguments)) {
die("too many command arguments");
return;
}
std::vector<std::string> storage;
storage.reserve(len);
for (int i = 0; i < len; ++i) {
jstring jarg = (jstring)env->GetObjectArrayElement(jarray, i);
storage.push_back(java_string_to_utf8(env, jarg));
arguments[i] = storage.back().c_str();
env->DeleteLocalRef(jarg);
}
mpv_command(g_mpv, arguments);
}
jni_func(void, nativeHookContinue, jlong id) {
if (!g_mpv) return;
mpv_hook_continue(g_mpv, (uint64_t)id);
}
+115
View File
@@ -0,0 +1,115 @@
#include <jni.h>
#include <mpv/client.h>
#include <cstdlib>
#include <string>
#include "globals.h"
#include "jni_utils.h"
#include "log.h"
extern "C" {
jni_func(jint, nativeSetOptionString, 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(void, nativeObserveProperty, jstring property, jint format);
}
jni_func(jint, nativeSetOptionString, jstring joption, jstring jvalue) {
CHECK_MPV_INIT_RET(0);
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());
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);
const char* prop = env->GetStringUTFChars(jproperty, NULL);
int result = mpv_get_property(g_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);
const char* prop = env->GetStringUTFChars(jproperty, NULL);
int result = mpv_set_property(g_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);
return result;
}
jni_func(jobject, nativeGetPropertyInt, jstring jproperty) {
int64_t value = 0;
if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0) return NULL;
return env->NewObject(java_Integer, java_Integer_init, (jint)value);
}
jni_func(jobject, nativeGetPropertyDouble, jstring jproperty) {
double value = 0;
if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) return NULL;
return env->NewObject(java_Double, java_Double_init, (jdouble)value);
}
jni_func(jobject, nativeGetPropertyBoolean, jstring jproperty) {
int value = 0;
if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0) return NULL;
return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value);
}
jni_func(jstring, nativeGetPropertyString, jstring jproperty) {
char* value;
if (common_get_property(env, 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) {
int64_t value = static_cast<int64_t>(jvalue);
common_set_property(env, jproperty, MPV_FORMAT_INT64, &value);
}
jni_func(void, nativeSetPropertyDouble, jstring jproperty, jdouble jvalue) {
double value = static_cast<double>(jvalue);
common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value);
}
jni_func(void, nativeSetPropertyBoolean, jstring jproperty, jboolean jvalue) {
int value = jvalue == JNI_TRUE ? 1 : 0;
common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value);
}
jni_func(void, nativeSetPropertyString, 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);
}
jni_func(void, nativeObserveProperty, jstring property, jint format) {
CHECK_MPV_INIT();
const char* prop = env->GetStringUTFChars(property, NULL);
int result = mpv_observe_property(g_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);
}
+79
View File
@@ -0,0 +1,79 @@
#include <jni.h>
#include <mpv/client.h>
#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);
};
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;
// 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();
osd_surface = env->NewGlobalRef(surface_);
if (!osd_surface) {
die("invalid osd surface provided");
return;
}
int64_t wid = reinterpret_cast<intptr_t>(osd_surface);
int result = mpv_set_option(g_mpv, "vo-mediacodec-osd-surface", MPV_FORMAT_INT64, &wid);
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;
}
void render_cleanup(JNIEnv* env) {
if (surface) {
env->DeleteGlobalRef(surface);
surface = NULL;
}
if (osd_surface) {
env->DeleteGlobalRef(osd_surface);
osd_surface = NULL;
}
}
+126
View File
@@ -0,0 +1,126 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
// UTF-8 <-> UTF-16 transcoding for the JNI boundary.
//
// mpv speaks standard UTF-8. JNI's NewStringUTF/GetStringUTFChars speak
// *modified* UTF-8: supplementary-plane characters are CESU-8 surrogate pairs
// and NUL is 0xC0 0x80. Feeding one encoding to the other corrupts emoji in
// file names and titles, and malformed bytes from mpv (log lines, ID3 tags,
// system-encoded paths) abort under CheckJNI. Both directions therefore go
// through UTF-16 with NewString/GetStringChars; malformed input is replaced
// with U+FFFD one unit at a time, matching shared/cpp/sanitize_utf8.h on
// desktop. Header-only and JNI-free so the host test harness can exercise it.
namespace plezy {
namespace utf8 {
// Decodes one scalar value at `s`. Returns the number of bytes consumed, or 0
// when `s` does not start a well-formed sequence (Unicode Table 3-7).
inline size_t DecodeOne(const unsigned char* s, size_t len, uint32_t* cp) {
const unsigned char c = s[0];
if (c < 0x80) {
*cp = c;
return 1;
}
size_t need;
unsigned char lo = 0x80, hi = 0xBF;
if (c >= 0xC2 && c <= 0xDF) {
need = 2;
*cp = c & 0x1F;
} else if (c >= 0xE0 && c <= 0xEF) {
need = 3;
*cp = c & 0x0F;
if (c == 0xE0) lo = 0xA0;
if (c == 0xED) hi = 0x9F; // no surrogates
} else if (c >= 0xF0 && c <= 0xF4) {
need = 4;
*cp = c & 0x07;
if (c == 0xF0) lo = 0x90;
if (c == 0xF4) hi = 0x8F; // <= U+10FFFF
} else {
return 0;
}
if (len < need) return 0;
for (size_t i = 1; i < need; ++i) {
const unsigned char b = s[i];
if (b < lo || b > hi) return 0;
lo = 0x80;
hi = 0xBF;
*cp = (*cp << 6) | (b & 0x3F);
}
return need;
}
// Standard UTF-8 -> UTF-16. Malformed bytes become U+FFFD.
inline std::u16string ToUtf16(const char* input, size_t len) {
std::u16string out;
if (!input) return out;
out.reserve(len);
const unsigned char* s = reinterpret_cast<const unsigned char*>(input);
size_t pos = 0;
while (pos < len) {
uint32_t cp;
const size_t n = DecodeOne(s + pos, len - pos, &cp);
if (n == 0) {
out.push_back(u'\uFFFD');
pos += 1;
continue;
}
pos += n;
if (cp < 0x10000) {
out.push_back(static_cast<char16_t>(cp));
} else {
cp -= 0x10000;
out.push_back(static_cast<char16_t>(0xD800 | (cp >> 10)));
out.push_back(static_cast<char16_t>(0xDC00 | (cp & 0x3FF)));
}
}
return out;
}
inline std::u16string ToUtf16(const char* input) {
return input ? ToUtf16(input, std::char_traits<char>::length(input)) : std::u16string();
}
// UTF-16 -> standard UTF-8. Lone surrogates become U+FFFD.
inline std::string FromUtf16(const char16_t* input, size_t len) {
std::string out;
if (!input) return out;
out.reserve(len * 3);
for (size_t i = 0; i < len; ++i) {
uint32_t cp = input[i];
if (cp >= 0xD800 && cp <= 0xDBFF) {
if (i + 1 < len && input[i + 1] >= 0xDC00 && input[i + 1] <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (input[i + 1] - 0xDC00);
++i;
} else {
cp = 0xFFFD;
}
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
cp = 0xFFFD;
}
if (cp < 0x80) {
out.push_back(static_cast<char>(cp));
} else if (cp < 0x800) {
out.push_back(static_cast<char>(0xC0 | (cp >> 6)));
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
} else if (cp < 0x10000) {
out.push_back(static_cast<char>(0xE0 | (cp >> 12)));
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
} else {
out.push_back(static_cast<char>(0xF0 | (cp >> 18)));
out.push_back(static_cast<char>(0x80 | ((cp >> 12) & 0x3F)));
out.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (cp & 0x3F)));
}
}
return out;
}
} // namespace utf8
} // namespace plezy
@@ -0,0 +1,13 @@
package com.edde746.plezy.libmpv
enum class EndFileReason(val id: Int) {
Eof(0),
Stop(2),
Quit(3),
Error(4),
Redirect(5);
companion object {
fun fromId(id: Int): EndFileReason? = entries.find { it.id == id }
}
}
@@ -0,0 +1,15 @@
package com.edde746.plezy.libmpv
enum class LogLevel(internal val nativeValue: Int) {
Fatal(10),
Error(20),
Warn(30),
Info(40),
Verbose(50),
Debug(60),
Trace(70);
companion object {
internal fun fromNative(value: Int): LogLevel? = entries.find { it.nativeValue == value }
}
}
@@ -0,0 +1,7 @@
package com.edde746.plezy.libmpv
data class LogMessage(
val prefix: String,
val level: LogLevel,
val text: String
)
@@ -0,0 +1,30 @@
package com.edde746.plezy.libmpv
sealed interface MpvEvent {
val sourceId: Long?
data class StartFile(override val sourceId: Long?) : MpvEvent
data class EndFile(
val reason: EndFileReason?,
override val sourceId: Long?
) : MpvEvent
data class FileLoaded(override val sourceId: Long?) : MpvEvent
data class PlaybackRestart(
override val sourceId: Long?,
val positionSeconds: Double?
) : MpvEvent
companion object {
// Mirrors the ids event.cpp forwards; END_FILE arrives via its own JNI path.
internal fun fromId(
id: Int,
sourceId: Long?,
positionSeconds: Double?
): MpvEvent? = when (id) {
6 -> StartFile(sourceId)
8 -> FileLoaded(sourceId)
21 -> PlaybackRestart(sourceId, positionSeconds)
else -> null
}
}
}
@@ -0,0 +1,3 @@
package com.edde746.plezy.libmpv
class MpvException(message: String) : RuntimeException(message)
@@ -0,0 +1,392 @@
package com.edde746.plezy.libmpv
import android.content.Context
import android.os.Looper
import android.view.Surface
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filter
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 {
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")
}
private val instance = AtomicReference<MpvPlayer?>(null)
/**
* Creates and initializes the process-global native player off the Android
* main thread. A predecessor may still be terminating under the native
* lifecycle lock, so this call must remain safe to suspend behind it.
*/
suspend fun create(
context: Context,
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
try {
nativeCreate(context.applicationContext)
MpvPlayerConfig().apply(configure)
nativeInit()
ensureActive()
player
} catch (e: Throwable) {
instance.compareAndSet(player, null)
try {
nativeDestroy()
} catch (_: Throwable) {}
throw e
}
}
// JNI callbacks — called from native event thread
@JvmStatic
fun onPropertyChanged(name: String, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
PropertyChange.None(name, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(name: String, value: Boolean, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
PropertyChange.Flag(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(name: String, value: Long, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
PropertyChange.Int64(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(name: String, value: Double, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
PropertyChange.Double(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onPropertyChanged(name: String, value: String, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(
PropertyChange.Str(name, value, sourceId.takeIf { hasSourceId })
)
}
@JvmStatic
fun onEvent(
eventId: Int,
sourceId: Long,
hasSourceId: Boolean,
positionSeconds: Double,
hasPositionSeconds: Boolean
) {
val event = MpvEvent.fromId(
eventId,
sourceId.takeIf { hasSourceId },
positionSeconds.takeIf { hasPositionSeconds && it.isFinite() }
) ?: return
instance.get()?.rawEvents?.trySend(event)
}
@JvmStatic
fun onEndFile(reason: Int, sourceId: Long, hasSourceId: Boolean) {
instance.get()?.rawEvents?.trySend(
MpvEvent.EndFile(
EndFileReason.fromId(reason),
sourceId.takeIf { hasSourceId }
)
)
}
@JvmStatic
fun onLogMessage(prefix: String, level: Int, text: String) {
val logLevel = LogLevel.fromNative(level) ?: return
instance.get()?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
}
@JvmStatic
fun onHook(name: String, id: Long) {
val player = instance.get()
if (player == null || player.closed || !player.rawHooks.trySend(Hook(name, id)).isSuccess) {
// Nobody will answer: release mpv rather than leave it waiting.
nativeHookContinue(id)
}
}
private fun checkNotMainThread(operation: String) {
check(Looper.myLooper() != Looper.getMainLooper()) {
"$operation must not run on the Android main thread"
}
}
// JNI native declarations — private to avoid internal name mangling
@JvmStatic private external fun nativeCreate(appctx: Context)
@JvmStatic private external fun nativeInit()
@JvmStatic private external fun nativeDestroy()
@JvmStatic private external fun nativeCommand(cmd: Array<out String>)
@JvmStatic private external fun nativeHookContinue(id: Long)
@JvmStatic private external fun nativeSetOptionString(name: String, value: String): Int
@JvmStatic private external fun nativeAttachSurface(surface: Surface)
@JvmStatic private external fun nativeDetachSurface()
@JvmStatic private external fun nativeAttachOsdSurface(surface: Surface)
@JvmStatic private external fun nativeDetachOsdSurface()
@JvmStatic private external fun nativeGetPropertyInt(name: String): Int?
@JvmStatic private external fun nativeGetPropertyDouble(name: String): Double?
@JvmStatic private external fun nativeGetPropertyBoolean(name: String): Boolean?
@JvmStatic private external fun nativeGetPropertyString(name: String): String?
@JvmStatic private external fun nativeSetPropertyInt(name: String, value: Int)
@JvmStatic private external fun nativeSetPropertyDouble(name: String, value: Double)
@JvmStatic private external fun nativeSetPropertyBoolean(name: String, value: Boolean)
@JvmStatic private external fun nativeSetPropertyString(name: String, value: String)
@JvmStatic private external fun nativeObserveProperty(name: String, format: Int)
internal fun setOptionString(name: String, value: String): Int = nativeSetOptionString(name, value)
}
// The native event thread hands everything to unbounded channels: trySend
// on them cannot fail (until close) and cannot block mpv's event loop. A
// pump per stream re-emits into the SharedFlow, whose SUSPEND overflow
// parks the pump - not the native thread - while a collector catches up.
// The previous design tryEmit-ed straight into the 64-slot SharedFlow
// 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)
private val events = MutableSharedFlow<MpvEvent>(extraBufferCapacity = 64)
private val propertyChanges = MutableSharedFlow<PropertyChange>(extraBufferCapacity = 64)
private val logMessages = MutableSharedFlow<LogMessage>(extraBufferCapacity = 64)
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 = 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 {
if (!closed) nativeHookContinue(hook.id)
}
}
}
pumpScope.launch { for (c in rawPropertyChanges) propertyChanges.emit(c) }
pumpScope.launch { for (m in rawLogMessages) logMessages.emit(m) }
}
val eventFlow: SharedFlow<MpvEvent> = events.asSharedFlow()
val propertyFlow: SharedFlow<PropertyChange> = propertyChanges.asSharedFlow()
val logFlow: SharedFlow<LogMessage> = logMessages.asSharedFlow()
// Commands
suspend fun command(vararg args: String) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeCommand(args) }
}
// Surface — not suspend, called from SurfaceHolder.Callback
fun attachSurface(surface: Surface) {
checkNotClosed()
nativeAttachSurface(surface)
}
fun detachSurface() {
checkNotClosed()
nativeDetachSurface()
}
/** OSD/subtitle plane for `vo=mediacodec`; attach before selecting the VO. */
fun attachOsdSurface(surface: Surface) {
checkNotClosed()
nativeAttachOsdSurface(surface)
}
fun detachOsdSurface() {
checkNotClosed()
nativeDetachOsdSurface()
}
// Property getters
suspend fun getInt(name: String): Int? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyInt(name) }
}
suspend fun getDouble(name: String): Double? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyDouble(name) }
}
suspend fun getFlag(name: String): Boolean? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyBoolean(name) }
}
suspend fun getString(name: String): String? {
checkNotClosed()
return withContext(Dispatchers.IO) { nativeGetPropertyString(name) }
}
// Property setters
suspend fun setProperty(name: String, value: Int) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyInt(name, value) }
}
suspend fun setProperty(name: String, value: Double) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyDouble(name, value) }
}
suspend fun setProperty(name: String, value: Boolean) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyBoolean(name, value) }
}
suspend fun setProperty(name: String, value: String) {
checkNotClosed()
withContext(Dispatchers.IO) { nativeSetPropertyString(name, value) }
}
// Property observation
fun observeProperty(name: String, format: PropertyFormat): Flow<PropertyChange> {
checkNotClosed()
nativeObserveProperty(name, format.nativeValue)
return propertyFlow.filter { it.name == name }
}
fun observeFlag(name: String): Flow<Boolean> {
checkNotClosed()
nativeObserveProperty(name, PropertyFormat.Flag.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Flag>()
.filter { it.name == name }
.map { it.value }
}
fun observeInt(name: String): Flow<Long> {
checkNotClosed()
nativeObserveProperty(name, PropertyFormat.Int64.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Int64>()
.filter { it.name == name }
.map { it.value }
}
fun observeDouble(name: String): Flow<Double> {
checkNotClosed()
nativeObserveProperty(name, PropertyFormat.Double.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Double>()
.filter { it.name == name }
.map { it.value }
}
fun observeString(name: String): Flow<String> {
checkNotClosed()
nativeObserveProperty(name, PropertyFormat.String.nativeValue)
return propertyFlow
.filterIsInstance<PropertyChange.Str>()
.filter { it.name == name }
.map { it.value }
}
// Lifecycle
@Volatile
private var closed = false
/**
* Blocks until native teardown finishes. Callers must keep this off the
* 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()
}
// After nativeDestroy no callback can produce: closing the channels
// lets each pump drain what is already queued and then complete.
rawEvents.close()
rawHooks.close()
rawPropertyChanges.close()
rawLogMessages.close()
}
private fun checkNotClosed() {
check(!closed) { "MpvPlayer has been closed" }
}
}
@@ -0,0 +1,10 @@
package com.edde746.plezy.libmpv
class MpvPlayerConfig internal constructor() {
fun setOption(name: String, value: String) {
val result = MpvPlayer.setOptionString(name, value)
if (result < 0) {
throw MpvException("Failed to set option '$name' to '$value': error $result")
}
}
}
@@ -0,0 +1,35 @@
package com.edde746.plezy.libmpv
sealed interface PropertyChange {
val name: String
val sourceId: Long?
data class None(
override val name: String,
override val sourceId: Long?
) : PropertyChange
data class Flag(
override val name: String,
val value: Boolean,
override val sourceId: Long?
) : PropertyChange
data class Int64(
override val name: String,
val value: Long,
override val sourceId: Long?
) : PropertyChange
data class Double(
override val name: String,
val value: kotlin.Double,
override val sourceId: Long?
) : PropertyChange
data class Str(
override val name: String,
val value: String,
override val sourceId: Long?
) : PropertyChange
}
@@ -0,0 +1,9 @@
package com.edde746.plezy.libmpv
enum class PropertyFormat(internal val nativeValue: Int) {
None(0),
String(1),
Flag(3),
Int64(4),
Double(5)
}
+1
View File
@@ -25,3 +25,4 @@ plugins {
include(":app")
include(":libass")
include(":libmpv")
+5 -5
View File
@@ -283,7 +283,7 @@
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */,
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@@ -797,12 +797,12 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */ = {
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/edde746/MPVKit";
repositoryURL = "https://github.com/edde746/mpv-build";
requirement = {
kind = revision;
revision = b8b922ec74b84ac3a496e29e226a3a3e91491045;
revision = 0601b034da501abc0ce84d2f8fba9e5c9520ad79;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -810,7 +810,7 @@
/* Begin XCSwiftPackageProductDependency section */
6A8A46202EDB320D0057B88C /* MPVKit */ = {
isa = XCSwiftPackageProductDependency;
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */;
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */;
productName = MPVKit;
};
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
@@ -28,11 +28,11 @@
}
},
{
"identity" : "mpvkit",
"identity" : "mpv-build",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"location" : "https://github.com/edde746/mpv-build",
"state" : {
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
"revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79"
}
},
{
@@ -28,11 +28,11 @@
}
},
{
"identity" : "mpvkit",
"identity" : "mpv-build",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"location" : "https://github.com/edde746/mpv-build",
"state" : {
"revision" : "b8b922ec74b84ac3a496e29e226a3a3e91491045"
"revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79"
}
},
{
+37 -2
View File
@@ -54,7 +54,7 @@ final class RecordingLifecycleDelegate: MpvPlayerDelegate {
private(set) var events: [String] = []
private(set) var properties: [String] = []
func onPropertyChange(name: String, value: Any?) {
func onPropertyChange(name: String, value: Any?, sourceId: Int64?) {
properties.append(name)
}
@@ -108,6 +108,41 @@ final class MpvPlayerContractTests: XCTestCase {
userInfo: [NSLocalizedDescriptionKey: "controlled failure"]
)
func testSharedTransportEmitsSourceQualifiedPayloads() {
let plugin = RecordingMpvPlugin(core: nil)
plugin.nameToId["time-pos"] = 27
var messages: [Any?] = []
plugin.eventSink = { messages.append($0) }
let sourceId = Int64.max - 7
plugin.onPropertyChange(name: "time-pos", value: 12.5, sourceId: sourceId)
plugin.onPropertyChange(name: "time-pos", value: nil, sourceId: nil)
plugin.onEvent(
name: "playback-restart",
data: ["sourceId": sourceId, "positionSeconds": 12.5]
)
XCTAssertEqual(messages.count, 3)
guard
let sourcedProperty = messages[0] as? [Any?],
let preStartProperty = messages[1] as? [Any?],
let lifecycleEvent = messages[2] as? [String: Any],
let lifecycleData = lifecycleEvent["data"] as? [String: Any]
else {
return XCTFail("Expected property triples and a lifecycle event map")
}
XCTAssertEqual(sourcedProperty.count, 3)
XCTAssertEqual(sourcedProperty[0] as? Int, 27)
XCTAssertEqual(sourcedProperty[1] as? Double, 12.5)
XCTAssertEqual(sourcedProperty[2] as? Int64, sourceId)
XCTAssertEqual(preStartProperty.count, 3)
XCTAssertNil(preStartProperty[1])
XCTAssertNil(preStartProperty[2])
XCTAssertEqual(lifecycleEvent["name"] as? String, "playback-restart")
XCTAssertEqual(lifecycleData["sourceId"] as? Int64, sourceId)
XCTAssertEqual(lifecycleData["positionSeconds"] as? Double, 12.5)
}
func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() {
let core = ControllablePropertyCore()
let plugin = RecordingMpvPlugin(core: core)
@@ -329,7 +364,7 @@ final class MpvPlayerContractTests: XCTestCase {
core.delegate = delegate
let enqueueAndDispose = {
core.dispatchDelegateEvent(name: "file-loaded", data: nil)
core.dispatchDelegateProperty(name: "time-pos", value: 1.0)
core.dispatchDelegateProperty(name: "time-pos", value: 1.0, sourceId: 7)
XCTAssertTrue(core.beginDisposal())
}
if Thread.isMainThread {
+15 -35
View File
@@ -2,8 +2,8 @@ import 'dart:convert';
import '../models/plex/plex_home.dart';
import '../models/plex/plex_home_user.dart';
import '../profiles/plex_home_service.dart';
import '../profiles/profile.dart';
import '../profiles/plex_home_cache_codec.dart';
import '../profiles/profile_registry.dart';
import '../services/plex_auth_service.dart';
import '../services/server_registry.dart';
@@ -20,24 +20,23 @@ import 'plex_account_setup.dart';
///
/// Plex Home users are NOT persisted here — the bootstrap copies the
/// legacy `homeUsersCache` into the per-connection
/// `plex_home_users_{connectionId}` SharedPreferences slot so
/// [PlexHomeService] picks it up on cold start.
/// `plex_home_users_{connectionId}` SharedPreferences slot and asks
/// [PlexHomeService] to reload (or fetch) it.
class ConnectionBootstrap {
ConnectionBootstrap({
required this.storage,
required this.connectionRegistry,
required this.serverRegistry,
required this.profileRegistry,
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
required this.plexHome,
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers,
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
}) : _plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
final StorageService storage;
final ConnectionRegistry connectionRegistry;
final ServerRegistry serverRegistry;
final ProfileRegistry profileRegistry;
final Future<List<PlexHomeUser>> Function(String accountToken) _plexHomeUserFetcher;
final PlexHomeService plexHome;
final Future<Map<String, dynamic>> Function(String accountToken) _plexUserInfoFetcher;
static const String _keyProfileMigrationV1Done = 'profile_migration_v1_done';
@@ -63,6 +62,9 @@ class ConnectionBootstrap {
final prepared = await _preparePlexVirtualProfile(account);
if (!prepared) {
if (migratedAccount != null && hadLegacyPlexToken) {
// A failed/empty fetch may have left an empty cache slot on disk
// for the id we are about to drop; don't leave it orphaned.
await storage.clearPlexHomeUsersCache(migratedAccount.id);
await connectionRegistry.remove(migratedAccount.id);
}
appLogger.w('Migration: could not hydrate Plex Home profiles for ${account.id}; will retry later');
@@ -147,9 +149,12 @@ class ConnectionBootstrap {
/// profile. Plex users are never persisted as local Plezy profiles.
Future<bool> _preparePlexVirtualProfile(PlexAccountConnection account) async {
final copied = await _migrateLegacyPlexHomeUsersCache(account.id);
var users = copied ? _readPlexHomeUsersCache(account.id) : null;
users ??= await _fetchAndCachePlexHomeUsers(account);
final hydratedUsers = users;
if (copied) {
await plexHome.reloadFromStorage();
} else {
await plexHome.refresh(account);
}
final hydratedUsers = plexHome.current[account.id] ?? const [];
if (hydratedUsers.isEmpty) return false;
final legacyActiveUuid = storage.getCurrentUserUUID();
@@ -196,31 +201,6 @@ class ConnectionBootstrap {
}
}
List<PlexHomeUser>? _readPlexHomeUsersCache(String connectionId) {
final raw = storage.getPlexHomeUsersCacheJson(connectionId);
if (raw == null || raw.isEmpty) return null;
try {
return decodePlexHomeUsersCache(raw);
} catch (e, st) {
appLogger.w('Migration: failed to read Plex Home cache for $connectionId', error: e, stackTrace: st);
return null;
}
}
Future<List<PlexHomeUser>> _fetchAndCachePlexHomeUsers(PlexAccountConnection account) async {
try {
final users = await _plexHomeUserFetcher(account.accountToken);
if (users.isNotEmpty) {
await storage.savePlexHomeUsersCache(account.id, encodePlexHomeUsersCache(users));
appLogger.i('Migration: fetched ${users.length} Plex Home users for ${account.id}');
}
return users;
} catch (e, st) {
appLogger.w('Migration: Plex Home fetch failed for ${account.id}', error: e, stackTrace: st);
return const [];
}
}
Future<void> _migrateLegacyPlexHomeUsersCacheForExistingAccount(PlexAccountConnection? account) async {
if (storage.prefs.getString('home_users_cache') == null) return;
var target = account;
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// Which event of a physical Back press performs the action.
///
/// Verified delivery model (Flutter 3.47.1 engine sources, stage 0 of the focus
/// consolidation):
///
/// * Android: `FlutterView.dispatchKeyEvent` claims every key and only
/// redispatches it to the Activity — where `onKeyUp(KEYCODE_BACK)` becomes
/// `onBackPressed` → `popRoute` — when the framework reports the event
/// **unhandled**. A Back key the focus system handles therefore never
/// produces a system pop; key-first ordering is structural, not timing.
/// * tvOS (custom engine): Menu arrives as an engine-synthesized `escape`
/// press. The KeyUp is not delivered reliably, so the action runs on KeyDown.
/// * Desktop: `escape` / `browserBack` are plain key presses. There is no
/// system back.
/// * Handheld gestures (iOS swipe, Android 13+) arrive only as `popRoute` and
/// never as key events.
///
/// The one remaining way a single press can act twice is entirely inside the
/// framework: an action that runs on KeyDown (Apple TV, a TV text field whose
/// IME would swallow the KeyUp) moves focus or pops a route, and the KeyUp is
/// then delivered to a *different* focus chain. [BackPressGate] closes that by
/// only acting on a KeyUp whose KeyDown it saw itself.
enum BackPhase { keyDown, keyUp }
/// The phase on which a Back handler acts for [event], unless the owner has a
/// reason of its own (a TV text field under a native IME passes
/// [BackPhase.keyDown] explicitly).
BackPhase backPhaseFor(KeyEvent event) => PlatformDetector.isAppleTV() ? BackPhase.keyDown : BackPhase.keyUp;
/// Per-owner state for a Back key press: acts once per physical press, on the
/// configured phase, and never on a KeyUp whose KeyDown it did not see.
///
/// Owners are the widgets that consume Back *before* the route stack — a text
/// field dismissing its IME, a sheet closing a nested page, the player hiding
/// its content strip, a screen moving focus from content to the sidebar. Each
/// owner holds one gate; it costs two booleans. Everything that is not such an
/// owner lets the press bubble to [NavigatorBackHandler], which turns it into
/// `Navigator.maybePop` so the route's `PopScope` is the single place a screen
/// decides its back policy for both key and system back.
///
/// Results:
/// * KeyDown → `handled`, press armed; the action runs now when the phase is
/// [BackPhase.keyDown].
/// * KeyRepeat → `handled` while armed, otherwise `ignored`.
/// * KeyUp of an armed press → `handled`; the action runs now when the phase is
/// [BackPhase.keyUp].
/// * KeyUp without an armed press (the press started on another focus chain)
/// → `ignored`, so it bubbles to the navigator handler, which swallows it.
/// Returning `handled` here would be wrong for a press the owner declined.
/// * A KeyDown while still armed (the previous KeyUp was swallowed off-app,
/// e.g. by a closing TV IME) is a fresh press and re-arms.
class BackPressGate {
bool _armed = false;
bool _fired = false;
/// Whether a press is in flight between its KeyDown and KeyUp.
bool get isArmed => _armed;
KeyEventResult handle(KeyEvent event, VoidCallback onBack, {BackPhase? phase}) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
final effectivePhase = phase ?? backPhaseFor(event);
if (event is KeyDownEvent) {
_armed = true;
_fired = effectivePhase == BackPhase.keyDown;
if (_fired) onBack();
return KeyEventResult.handled;
}
if (event is KeyRepeatEvent) {
return _armed ? KeyEventResult.handled : KeyEventResult.ignored;
}
if (event is KeyUpEvent) {
if (!_armed) return KeyEventResult.ignored;
_armed = false;
final fired = _fired;
_fired = false;
if (!fired) onBack();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Forget an in-flight press. Call when the owner leaves the focus chain
/// mid-press and must not act on the eventual KeyUp.
void reset() {
_armed = false;
_fired = false;
}
}
/// A non-focusable [Focus] that consumes Back for its subtree with its own
/// [BackPressGate], for owners that are not already a `State` holding a gate:
/// a sheet sub-page returning to its parent, a screen region whose Back moves
/// focus rather than popping the route.
///
/// A route's own back policy does not belong here — put it in `PopScope`, where
/// [NavigatorBackHandler] delivers key back and the platform delivers system
/// back.
class BackKeyOwner extends StatefulWidget {
const BackKeyOwner({super.key, required this.onBack, this.phase, required this.child});
final VoidCallback onBack;
/// Overrides [backPhaseFor] for this owner.
final BackPhase? phase;
final Widget child;
@override
State<BackKeyOwner> createState() => _BackKeyOwnerState();
}
class _BackKeyOwnerState extends State<BackKeyOwner> {
final BackPressGate _gate = BackPressGate();
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
return _gate.handle(event, widget.onBack, phase: widget.phase);
}
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
debugLabel: 'BackKeyOwner',
onKeyEvent: _handleKeyEvent,
child: widget.child,
);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/widgets.dart';
/// Keeps a subtree from taking focus while the [ModalRoute] it lives in is
/// covered by another route, and hands focus back when it is uncovered.
///
/// Flutter only marks a covered route's scope `skipTraversal`; any descendant
/// may still call `requestFocus()` and win. That is harmless on a single
/// navigator, where every screen's `ModalRoute.of(context).isCurrent` guard
/// tells the truth. It breaks with a nested navigator: a route pushed on the
/// *root* navigator (the profile picker, the PIN dialog) covers the whole
/// nested stack, yet each nested route still reports `isCurrent == true` and
/// its focus self-heals — sidebar reveal, library grid load, TV browse rail —
/// yank the remote off the visible route, which on tvOS reads as a dead
/// remote (#2034, #2239).
///
/// The boundary restores the invariant once, at the navigator boundary,
/// instead of at every reclaim site: while the enclosing route is not current
/// the subtree is [ExcludeFocus]ed, so every `requestFocus()` below it is a
/// no-op. On uncover it re-requests the subtree's own [FocusScope], whose
/// focus history still leads back to the leaf that had focus before the
/// cover; Flutter's own restoration cannot, because the covering route's pop
/// culls the excluded scope from the route scope's history before the
/// exclusion lifts.
class CoveredRouteFocusBoundary extends StatefulWidget {
const CoveredRouteFocusBoundary({super.key, required this.child});
final Widget child;
@override
State<CoveredRouteFocusBoundary> createState() => _CoveredRouteFocusBoundaryState();
}
class _CoveredRouteFocusBoundaryState extends State<CoveredRouteFocusBoundary> {
final _scope = FocusScopeNode(debugLabel: 'CoveredRouteFocusBoundary');
bool _covered = false;
@override
void dispose() {
_scope.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// ModalRoute.of subscribes to the route's status, so this rebuilds on
// every isCurrent flip. Outside any route the subtree is never covered.
final covered = !(ModalRoute.of(context)?.isCurrent ?? true);
if (_covered && !covered) {
// The exclusion lifts in this build's didUpdateWidget, after the pop
// already parked focus on the route scope; restore once it has.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_scope.hasFocus) _scope.requestFocus();
});
}
_covered = covered;
return ExcludeFocus(
excluding: covered,
child: FocusScope(node: _scope, child: widget.child),
);
}
}
+5 -69
View File
@@ -71,10 +71,9 @@ extension DpadKeyExtension on LogicalKeyboardKey {
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
}
/// Base class for suppressing key events after a key category triggers an
/// action (e.g. opening a sheet). While suppressed, every event of the matched
/// key category is consumed — including a [KeyDownEvent] when
/// [consumeKeyDowns] is set, because an armer running in the
/// Suppresses SELECT key events after a select press already acted (e.g. a
/// long press opened a sheet). While suppressed, every select event is
/// consumed — including a [KeyDownEvent], because an armer running in the
/// [HardwareKeyboard] handler phase (the hotkey recorder) arms against the
/// very KeyDown that is then re-dispatched through the focus tree.
///
@@ -84,19 +83,10 @@ extension DpadKeyExtension on LogicalKeyboardKey {
/// focus-phase consumers still swallow it — which guarantees a stale armed
/// state (a KeyUp delivered to a focus chain that never consulted us) can
/// never swallow the next press.
///
/// Armers that target an in-flight KeyUp ([consumeKeyDowns] unset) treat a
/// matching [KeyDownEvent] as proof the suppressed press already ended without
/// its KeyUp reaching us (e.g. a closing TV IME session ate it): the armed
/// state is cleared and the fresh press passes through unconsumed.
class _KeyUpSuppressor {
final bool Function(LogicalKeyboardKey) _keyMatcher;
/// Whether a matching [KeyDownEvent] is consumed while armed. See the class
/// documentation for why armers targeting an in-flight KeyUp must unset it.
final bool consumeKeyDowns;
_KeyUpSuppressor(this._keyMatcher, {this.consumeKeyDowns = true});
_KeyUpSuppressor(this._keyMatcher);
bool _suppressed = false;
@@ -125,18 +115,11 @@ class _KeyUpSuppressor {
void clearSuppression() => _suppressed = false;
/// Returns `true` (consumed) when the event belongs to the matched key
/// category and suppression is active. Clears suppression on [KeyUpEvent],
/// and on a stale [KeyDownEvent] when [consumeKeyDowns] is unset.
/// category and suppression is active. Clears suppression on [KeyUpEvent].
bool consumeIfSuppressed(KeyEvent event) {
if (!_suppressed) return false;
if (!_keyMatcher(event.logicalKey)) return false;
if (event is KeyUpEvent) _suppressed = false;
if (event is KeyDownEvent && !consumeKeyDowns) {
// The in-flight KeyUp never arrived; this is a fresh press, not the
// suppressed one. Clear and let it through.
_suppressed = false;
return false;
}
return true;
}
}
@@ -149,50 +132,3 @@ class SelectKeyUpSuppressor {
static void clearSuppression() => _instance.clearSuppression();
static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event);
}
/// Global helper to suppress the next BACK key-up event.
///
/// Armed when a modal (dialog, sheet) closes while a back key is still held —
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — or when a
/// down-only back handler moves focus before the matching KeyUp is dispatched.
/// A matching KeyDown while armed clears the arming instead of consuming: it
/// can only mean the suppressed press's KeyUp was swallowed off-app, so the
/// fresh press must act normally.
class BackKeyUpSuppressor {
static final _instance = _KeyUpSuppressor((k) => k.isBackKey, consumeKeyDowns: false);
static void suppressBackUntilKeyUp() => _instance.suppress();
/// Clear any pending suppression. Call when opening a new modal
/// to ensure stale suppression from previous closes doesn't affect it.
static void clearSuppression() => _instance.clearSuppression();
static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event);
}
/// Tracks whether a back key is currently physically pressed.
///
/// Used by [BackKeySuppressorObserver] to detect when a route pop was
/// caused by a back key press (e.g. Flutter's built-in DismissAction,
/// DismissAction on KeyRepeat, or Android TV system back gesture) so it
/// can automatically suppress the stray KeyUp that follows.
class BackKeyPressTracker {
static bool _isBackKeyDown = false;
/// Whether a back key is currently held down.
///
/// Also checks [HardwareKeyboard.instance.logicalKeysPressed] as a
/// fallback in case our tracking drifted out of sync.
static bool get isBackKeyDown {
if (_isBackKeyDown) return true;
return HardwareKeyboard.instance.logicalKeysPressed.any((key) => key.isBackKey);
}
static bool handleKeyEvent(KeyEvent event) {
if (event.logicalKey.isBackKey) {
// KeyDown and KeyRepeat both mean the key is physically held.
_isBackKeyDown = event is! KeyUpEvent;
}
return false; // Never consume
}
}
+5 -18
View File
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../widgets/overlay_sheet.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
import 'key_event_utils.dart';
/// D-pad "move mode" reordering for a remote/keyboard-driven list of rows
/// inside a sheet or dialog.
@@ -41,7 +40,7 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
int? _originalIndex;
List<E>? _originalOrder;
bool _backKeyDownSeen = false;
final BackPressGate _backGate = BackPressGate();
/// The list being reordered. Mutated in place while moving and replaced
/// wholesale when a move is cancelled.
@@ -87,21 +86,9 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
KeyEventResult handleReorderKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
// Track back key down/up pairing. If focus was elsewhere during KeyDown
// (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray
// KeyUp that would incorrectly pop the dialog. Consume it instead.
if (key.isBackKey) {
if (event is KeyDownEvent) {
_backKeyDownSeen = true;
} else if (event is KeyUpEvent && !_backKeyDownSeen) {
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
_backKeyDownSeen = false;
}
}
final backResult = handleBackKeyAction(event, () {
// The gate ignores a Back release whose press landed elsewhere (focus was
// on a sheet during KeyDown and returned here before KeyUp).
final backResult = _backGate.handle(event, () {
if (movingIndex != null) {
setState(() {
final originalOrder = _originalOrder;
+14
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../services/device_performance.dart';
import '../theme/mono_tokens.dart';
import '../utils/platform_detector.dart';
class FocusTheme {
FocusTheme._();
@@ -24,6 +25,19 @@ class FocusTheme {
return Theme.of(context).extension<MonoTokens>()?.fast ?? const Duration(milliseconds: 150);
}
/// How long a TV row (or the hub list) glides after one D-pad focus step.
///
/// Apple TV keeps the ~500ms ease-out measured from the native focus
/// engine's scrollable containers (issue #2006): Siri Remote swipes chain
/// steps into one continuous glide and users expect that inertia. D-pad
/// platforms have no such reference: Leanback's `GridLayoutManager` prices a
/// one-card step at roughly 100-150ms, so a 500ms glide there trails the
/// focus border on every press and reads as input lag next to the launcher.
/// Successive presses (including hold-repeats) retarget the animation from
/// wherever the row currently is, so a fast series still glides continuously.
static Duration navigationScrollDuration() =>
PlatformDetector.isAppleTV() ? const Duration(milliseconds: 500) : const Duration(milliseconds: 150);
/// [radii] overrides [borderRadius] when per-corner radii are needed
/// (M3E grouped cards: large outer / small inner corners).
static BoxDecoration focusDecoration(
+4 -1
View File
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../widgets/app_icon.dart';
import '../widgets/clickable_cursor.dart';
import 'back_press.dart';
import 'focus_theme.dart';
import 'input_mode_tracker.dart';
import 'key_event_utils.dart';
@@ -98,6 +99,8 @@ class FocusableActionBarState extends State<FocusableActionBar> {
late List<FocusNode> _focusNodes;
late List<bool> _focusStates;
bool _hasAnyFocus = false;
// One gate for the row: every button shares [FocusableActionBar.onBack].
final _backGate = BackPressGate();
FocusNode? getFocusNode(int index) => index >= 0 && index < _focusNodes.length ? _focusNodes[index] : null;
@@ -320,7 +323,7 @@ class FocusableActionBarState extends State<FocusableActionBar> {
descendantsAreFocusable: false,
onKeyEvent: (node, event) {
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
final backResult = _backGate.handle(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) return backResult;
}
final previousIndex = _previousEnabledIndex(index);
+4 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/scroll_utils.dart';
import 'back_press.dart';
import 'owned_focus_node_binding.dart';
import 'input_mode_tracker.dart';
import 'dpad_navigator.dart';
@@ -44,6 +45,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
final _focusNodeBinding = OwnedFocusNodeBinding();
bool _isFocused = false;
final _selectLongPress = DpadSelectLongPressController();
final _backGate = BackPressGate();
FocusNode? _boundExternalNode;
/// Override to return the widget's optional external focus node.
@@ -90,6 +92,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
setState(() => _isFocused = hasFocus);
if (!hasFocus) {
_selectLongPress.reset();
_backGate.reset();
}
// Same convention as FocusableTileStateMixin: a chip inside a
// scrollable strip (TabChipStrip, filter bars) reveals itself on
@@ -121,7 +124,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
final key = event.logicalKey;
if (callbacks.onBack != null) {
final backResult = handleBackKeyAction(event, callbacks.onBack!);
final backResult = _backGate.handle(event, callbacks.onBack!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
+51 -23
View File
@@ -7,6 +7,7 @@ import '../services/gamepad_service.dart';
import '../utils/platform_detector.dart';
import '../utils/text_input_diagnostics.dart';
import '../widgets/tv_virtual_keyboard.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
import 'key_event_utils.dart';
import 'owned_focus_node_binding.dart';
@@ -86,6 +87,11 @@ class TvTextInputController {
/// Focus the field without opening either native or Flutter text input for
/// this focus entry.
void focusInputWithoutOpening() => _host?._focusWithoutKeyboard();
/// Focus the field and open its text input, as an explicit Select would —
/// for a field the app creates on the user's behalf (a new editor row) that
/// should be typed into at once, without a second press.
void focusAndOpenTextInput() => _host?._focusAndOpenTextInput();
}
String _describeTextInputKey(KeyEvent event) {
@@ -166,6 +172,7 @@ KeyEventResult _handleInputKey({
int? maxLines,
VoidCallback? onSelect,
VoidCallback? onBack,
required BackPressGate backGate,
VoidCallback? onNavigateLeft,
VoidCallback? onNavigateRight,
VoidCallback? onNavigateUp,
@@ -229,29 +236,11 @@ KeyEventResult _handleInputKey({
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
// down-only shape as [handleBackKeyAction]'s Apple TV branch, coordinator
// 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();
// onBack may move focus (empty search field -> sidebar); the matching
// KeyUp is then delivered to the NEW focus chain, whose shared
// handlers act on KeyUp — a second back action. Arm the suppressor
// (after onBack, so a modal opened by it cannot clear the arming) so
// whichever chain receives the KeyUp swallows it. This cannot pin:
// the suppressor's hardware observer clears the armed state once the
// physical press ends, and if the IME swallows that KeyUp entirely,
// the next back KeyDown is treated as stale arming and passes
// through — see _KeyUpSuppressor.
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
return finish(KeyEventResult.handled, 'onBack');
}
final backResult = handleBackKeyAction(event, onBack);
// closing IME session eats it), so back fires on KeyDown. onBack may move
// focus (empty search field -> sidebar); the KeyUp is then delivered to the
// new focus chain, where no gate has seen its KeyDown and the navigator
// handler swallows it. Elsewhere the shared KeyUp phase applies.
final backResult = backGate.handle(event, onBack, phase: PlatformDetector.isTV() ? BackPhase.keyDown : null);
if (backResult != KeyEventResult.ignored) return finish(backResult, 'onBack');
}
@@ -735,6 +724,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
VoidCallback openKeyboard, {
required bool activateNativeTextInput,
required VoidCallback activateNativeTextInputCallback,
required BackPressGate backGate,
}) {
return _handleInputKey(
controller: controller,
@@ -755,6 +745,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
maxLines: maxLines,
onSelect: onSelect,
onBack: onBack,
backGate: backGate,
onNavigateLeft: onNavigateLeft,
onNavigateRight: onNavigateRight,
onNavigateUp: onNavigateUp,
@@ -791,6 +782,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
FocusNode? _installedFocusNode;
FocusOnKeyEventCallback? _previousOnKeyEvent;
final BackPressGate _backGate = BackPressGate();
late final FocusOnKeyEventCallback _keyHandler = _handleKey;
late final VoidCallback _focusListener = _handleFocusChanged;
final Object _nativeFocusToken = Object();
@@ -1167,6 +1159,41 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
});
}
/// Focus the field and open text input as an explicit Select would. Clears
/// per-focus suppression first so a field configured with
/// [TvTextInputAutoOpenBehavior.never] still opens.
void _focusAndOpenTextInput() {
_suppressTvKeyboardAutoOpen = false;
_suppressNativeTextInputForCurrentFocus = false;
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
if (focusNode.hasFocus) {
_openTextInputForFocusedField();
return;
}
focusNode.requestFocus();
// 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();
});
}
void _openTextInputForFocusedField() {
if (widget.input._usesNativeTvKeyboard) {
_activateNativeTextInput();
} else if (widget.input._hasTvKeyboard && !_tvKeyboardOpen && !_tvKeyboardOpenScheduled) {
// The overlay is a navigator route; push it once the focus request has
// landed so the route's focus scope does not race the field's.
_tvKeyboardOpenScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_tvKeyboardOpenScheduled = false;
_openTvKeyboard();
});
}
}
void _setNativeTextInputFocused(bool focused) {
if (_reportedNativeTextInputFocused == focused) {
_logTvTextInput('Host.setNativeTextInputFocused no-op focused=$focused');
@@ -1246,6 +1273,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_openTvKeyboard,
activateNativeTextInput: activateNativeTextInput,
activateNativeTextInputCallback: _activateNativeTextInput,
backGate: _backGate,
);
}
+5 -2
View File
@@ -4,6 +4,7 @@ import 'package:flutter/rendering.dart';
import '../widgets/clickable_cursor.dart';
import '../utils/text_input_diagnostics.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
import 'dpad_select_long_press_controller.dart';
import 'focus_chrome.dart';
@@ -245,6 +246,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
Animation<double>? _scaleAnimation;
final _selectLongPress = DpadSelectLongPressController();
final _backGate = BackPressGate();
@override
void initState() {
@@ -307,9 +309,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
_isFocused = hasFocus;
});
// Reset long press state when focus is lost
// Reset press state when focus is lost
if (!hasFocus) {
_selectLongPress.reset();
_backGate.reset();
}
// Animate scale
@@ -448,7 +451,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
}
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
final backResult = _backGate.handle(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
return finish(backResult, 'onBack');
}
+27 -20
View File
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -60,18 +58,10 @@ class InputModeTracker extends StatefulWidget {
/// Keyboard/D-pad on TV, pointer everywhere else.
static InputMode get _defaultMode => PlatformDetector.isTV() ? InputMode.keyboard : InputMode.pointer;
/// Whether system back must be blocked because the dpad key handler owns
/// back navigation: on Android in keyboard mode (TV/gamepad), letting the
/// system back through as well double-pops the route.
static bool shouldBlockSystemBack(BuildContext context) {
return Platform.isAndroid && isKeyboardMode(context);
}
/// Report input from a device that cannot point — gamepad, companion remote,
/// Siri Remote touch. Those services synthesize their key events through
/// [KeyEventSimulatorController], which dispatches straight down the focus
/// chain and never reaches [HardwareKeyboard], so they cannot be observed by
/// [eventRequestsFocusNavigation] and must announce themselves here.
/// Report input from a device that cannot point and does not produce key
/// events — a gamepad shoulder or a companion-remote tab command. Key-shaped
/// input from those devices goes through [KeyEventSimulatorController], which
/// runs the framework key pipeline and is observed here like any hardware key.
///
/// Calls `setState`, so never call this during build. A no-op when no tracker
/// is mounted.
@@ -89,6 +79,9 @@ class InputModeTracker extends StatefulWidget {
class _InputModeTrackerState extends State<InputModeTracker> {
InputMode _mode = InputModeTracker._defaultMode;
/// Whether the in-flight Back press found a primary focus on KeyDown.
bool _backDownHadFocus = false;
@override
void initState() {
super.initState();
@@ -109,18 +102,32 @@ class _InputModeTrackerState extends State<InputModeTracker> {
}
bool _handleKeyEvent(KeyEvent event) {
// Track back key press state for automatic suppression of stray KeyUp
// events after route pops (see BackKeySuppressorObserver).
BackKeyPressTracker.handleKeyEvent(event);
// Only a key that asks to navigate by focus starts a keyboard session.
// Activation and dismissal act on what is already focused, so promoting on
// them would arm focus chrome for a viewer who never asked to navigate.
if (eventRequestsFocusNavigation(event)) {
_setMode(InputMode.keyboard);
}
// Return false to let the event continue propagating
return false;
return _consumeBackReleaseWithoutFocus(event);
}
/// A Back KeyDown acted on a focus chain that has since been torn down, so
/// its KeyUp arrives with no primary focus at all. The focus system skips
/// dispatch and reports it unhandled; on Android the embedding then
/// redispatches it and `onBackPressed` → `popRoute` performs a second,
/// system-driven back. Claim it here in the hardware phase instead. A press
/// that already had no focus on KeyDown is left alone: neither event was
/// acted on, and the platform's own back is the correct outcome.
bool _consumeBackReleaseWithoutFocus(KeyEvent event) {
if (!event.logicalKey.isBackKey) return false;
if (event is KeyDownEvent) {
_backDownHadFocus = FocusManager.instance.primaryFocus != null;
return false;
}
if (event is! KeyUpEvent) return false;
final hadFocus = _backDownHadFocus;
_backDownHadFocus = false;
return hadFocus && FocusManager.instance.primaryFocus == null;
}
void _setMode(InputMode mode) {
+3 -131
View File
@@ -1,118 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// Handles back key events by popping the current route.
///
/// Optionally pass a [result] to return to the previous route.
///
/// Use this as an `onKeyEvent` callback for Focus widgets that need
/// simple back navigation behavior:
///
/// ```dart
/// Focus(
/// onKeyEvent: (node, event) => handleBackKeyNavigation(context, event),
/// child: ...
/// )
/// ```
///
/// With a result value:
/// ```dart
/// Focus(
/// onKeyEvent: (node, event) => handleBackKeyNavigation(
/// context,
/// event,
/// result: _hasChanges,
/// ),
/// child: ...
/// )
/// ```
class BackKeyCoordinator {
static bool _handledThisFrame = false;
static bool _clearScheduled = false;
static int _clearGeneration = 0;
static void markHandled() {
_handledThisFrame = true;
if (_clearScheduled) return;
_clearScheduled = true;
final clearGeneration = _clearGeneration;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (clearGeneration != _clearGeneration) return;
_handledThisFrame = false;
_clearScheduled = false;
});
// addPostFrameCallback does not request a frame. Ensure the one-shot
// marker cannot leak when handling Back does not otherwise schedule one.
WidgetsBinding.instance.scheduleFrame();
}
static void clear() {
_handledThisFrame = false;
_clearScheduled = false;
// Invalidate a pending callback so it cannot clear a newer marker.
_clearGeneration++;
}
static bool consumeIfHandled() {
if (!_handledThisFrame) return false;
clear();
return true;
}
}
/// Consumes KeyDown/KeyRepeat to avoid duplicate actions, runs [onBack] on KeyUp.
KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
// Check if this BACK event should be suppressed (e.g., after modal closed)
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// AppleTV back (Siri Remote Menu via engine-synthesized escape): run onBack
// on KeyDown only; consume KeyUp silently. Some engine paths report Menu as
// a non-keyboard device, but the same down-only handling is still required.
// No suppressor arming is needed here: onBack typically calls
// Navigator.pop, swapping the focus tree before the matching KeyUp is
// dispatched — but the orphaned KeyUp lands on the new chain, where this
// same branch consumes it silently.
if (PlatformDetector.isAppleTV()) {
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
onBack();
}
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
BackKeyCoordinator.markHandled();
onBack();
return KeyEventResult.handled;
}
if (event is KeyDownEvent || event is KeyRepeatEvent) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event, {T? result}) {
// Let system handle back if there's nothing to pop (e.g. root screen)
if (!Navigator.canPop(context)) {
return KeyEventResult.ignored;
}
// Don't handle back when a dialog/overlay is on top of our route —
// the overlay handles its own dismissal via DismissAction.
if (ModalRoute.of(context)?.isCurrent != true) {
return KeyEventResult.ignored;
}
// Handle on KeyUpEvent to prevent double-pop when returning from child screens
// (KeyDownEvent can be received by both the popping screen and the returned-to screen)
return handleBackKeyAction(event, () => Navigator.pop(context, result));
}
// Back key handling lives in back_press.dart (BackPressGate for owners that
// consume Back before the route) and navigator_back_handler.dart (the
// per-navigator fallback that turns Back into Navigator.maybePop).
/// Consumes all select-key events (down, repeat, up) so they don't reach
/// platform-level handlers; fires [onActivate] on the initial KeyDown only.
@@ -234,24 +127,3 @@ FocusOnKeyEventCallback dpadKeyHandler({
return KeyEventResult.ignored;
};
}
/// Navigator observer that automatically suppresses stray back KeyUp events
/// after any route pop caused by a back key press.
///
/// This catches pops triggered by Flutter's built-in DismissAction (which fires
/// on KeyDown for dialogs) and Android TV system back gestures, preventing the
/// orphaned KeyUp from propagating to the underlying screen's back handler.
class BackKeySuppressorObserver extends NavigatorObserver {
@override
void didPop(Route route, Route? previousRoute) {
// On AppleTV, handleBackKeyAction consumes the KeyUp silently regardless,
// so the suppressor isn't needed. (The atomic engine fix delivers
// KeyDown+KeyUp in a single recognizer Began callback, so didPop fires
// squarely inside the window where BackKeyPressTracker.isBackKeyDown is
// true.)
if (PlatformDetector.isAppleTV()) return;
if (BackKeyPressTracker.isBackKeyDown) {
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
/// Turns a Back key press that no focused owner consumed into
/// `Navigator.maybePop` on [navigatorKey], so key back and system back both end
/// in the route's `PopScope`.
///
/// Wrap each [Navigator] (root and profile session) once. The wrapper sits in
/// the focus chain above every route of that navigator, so it sees only the
/// presses that bubbled out of the focused subtree. It decides on KeyDown, from
/// the route's synchronous [ModalRoute.popDisposition], whether this navigator
/// owns the press:
///
/// * `pop` / `doNotPop` — a route below or a `PopScope(canPop: false)`: the
/// press is consumed and `maybePop` runs on the configured [BackPhase].
/// * `bubble` — nothing to pop and no `PopScope`: the press is declined, both
/// KeyDown and KeyUp bubble on. On Android an unhandled Back becomes the
/// platform's own `onBackPressed` → `popRoute` → `SystemNavigator.pop`, which
/// is the correct "leave the app" behavior at the true root.
///
/// A KeyUp or KeyRepeat that arrives with no armed press — the press acted on
/// another focus chain before focus moved here — is swallowed. Letting it reach
/// the platform unhandled would synthesize a second, system-driven back.
class NavigatorBackHandler extends StatefulWidget {
const NavigatorBackHandler({super.key, required this.navigatorKey, required this.child});
final GlobalKey<NavigatorState> navigatorKey;
final Widget child;
@override
State<NavigatorBackHandler> createState() => _NavigatorBackHandlerState();
}
/// What this navigator does with the Back press whose KeyDown just arrived.
enum _Claim {
/// Consume the press and run `maybePop` on the configured phase.
pop,
/// Consume the press and do nothing: the focused route is not the current
/// one. A route was just pushed and the new route's autofocus has not been
/// applied yet (FocusManager applies it in a microtask), so the old chain
/// still receives keys; `maybePop` would pop the route that was just pushed.
swallow,
/// Leave both events to the platform: nothing to pop and no `PopScope`.
decline,
}
class _NavigatorBackHandlerState extends State<NavigatorBackHandler> {
final BackPressGate _gate = BackPressGate();
bool _declined = false;
/// The route of this navigator that contains the primary focus, walking out
/// of nested navigators. Null when the focus is not inside any route of this
/// navigator (e.g. chrome mounted beside the navigator).
ModalRoute<Object?>? _routeForPrimaryFocus() {
final navigator = widget.navigatorKey.currentState;
if (navigator == null) return null;
final context = FocusManager.instance.primaryFocus?.context;
if (context == null) return null;
var route = ModalRoute.of(context);
while (route != null && !identical(route.navigator, navigator)) {
final navigatorContext = route.navigator?.context;
if (navigatorContext == null) return null;
route = ModalRoute.of(navigatorContext);
}
return route;
}
_Claim _claimForPrimaryFocus() {
final route = _routeForPrimaryFocus();
if (route == null) return _Claim.decline;
if (!route.isCurrent) return _Claim.swallow;
return route.popDisposition == RoutePopDisposition.bubble ? _Claim.decline : _Claim.pop;
}
void _pop() {
unawaited(widget.navigatorKey.currentState?.maybePop());
}
static void _noop() {}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
if (event is KeyDownEvent) {
switch (_claimForPrimaryFocus()) {
case _Claim.decline:
_declined = true;
_gate.reset();
return KeyEventResult.ignored;
case _Claim.swallow:
_declined = false;
return _gate.handle(event, _noop, phase: BackPhase.keyDown);
case _Claim.pop:
_declined = false;
return _gate.handle(event, _pop);
}
}
if (_declined) {
if (event is KeyUpEvent) _declined = false;
return KeyEventResult.ignored;
}
// An armed press acts (or, already acted on KeyDown, is finished); an
// unarmed KeyUp/KeyRepeat acted on another focus chain and is swallowed.
final result = _gate.handle(event, _pop);
return result == KeyEventResult.ignored ? KeyEventResult.handled : result;
}
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
debugLabel: 'NavigatorBackHandler',
onKeyEvent: _handleKeyEvent,
child: widget.child,
);
}
}
+26 -4
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",
@@ -855,6 +864,9 @@
"presetDeleted": "Ön ayar silindi",
"confirmDeletePreset": "Bu ön ayarı silmək istədiyinizə əminsiniz?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# şərh",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context və gpu-api Linux-da nəzərə alınmır: daxili video həmişə video müstəvisində vo=libmpv vasitəsilə göstərilir və gpu-next (ArtCNN kimi hesablama şeyderlərinə lazımdır) daxili işləyə bilməz."
},
"dialog": {
@@ -1251,6 +1263,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",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} fasilə etdi",
"participantResumed": "${name} davam etdirdi",
"participantSeeked": "${name} oynatma mövqeyini dəyişdi",
"participantChangedSpeed": "",
"participantBuffering": "${name} buferləyir",
"participantNeedsUpdate": "${name} köhnə tətbiq versiyasındadır",
"resumingWithout": "${name} olmadan davam edilir",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Keyfiyyət profili",
"rootFolder": "Kök qovluq",
"languageProfile": "Dil profili",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Sorğu göndərildi",
"requestFailed": "Sorğu uğursuz oldu: ${error}",
"requestsLoadFailed": "Seçimlər yüklənə bilmədi",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "Bloklanmış",
"couldNotReach": "${url} ünvanına çatmaq olmadı: ${error}",
"noInstanceAtUrl": "${url} ünvanında Seerr instansiyası yoxdur (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "https://seerr.example.com kimi server ünvanı daxil edin",
"quickConnectUnsupported": "Bu Seerr nüsxəsi Sürətli Qoşulmanı dəstəkləmir. Seerr 3.4 və ya daha yeni versiya tələb olunur.",
"notInitialized": "Bu Seerr instansiyasının ilkin quraşdırılması tamamlanmayıb",
+26 -4
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": "Забавяне за автоматично прескачане",
@@ -855,6 +864,9 @@
"presetDeleted": "Пресетът е изтрит",
"confirmDeletePreset": "Сигурни ли сте, че искате да изтриете този пресет?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context и gpu-api се игнорират на Linux: вграденото видео винаги се рендерира през vo=libmpv върху видео равнината, а gpu-next (който е нужен за compute шейдъри като ArtCNN) не може да работи вградено."
},
"dialog": {
@@ -1251,6 +1263,10 @@
"notInLibrary": "Не е в твоята библиотека",
"inTheseLibraries": "В тези библиотеки",
"checkingLibrary": "Проверка на твоята библиотека...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Тук все още няма нищо",
"emptyMessage": "Редовете от ${source} ще се появят тук, когато има съдържание.",
"searchHint": "Търсене в ${source}",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} постави на пауза",
"participantResumed": "${name} продължи",
"participantSeeked": "${name} промени позицията на възпроизвеждане",
"participantChangedSpeed": "",
"participantBuffering": "${name} буферира",
"participantNeedsUpdate": "${name} е с по-стара версия на приложението — синхронизирането не е налично",
"resumingWithout": "Продължаване без ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Профил за качество",
"rootFolder": "Основна папка",
"languageProfile": "Езиков профил",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Заявката е изпратена",
"requestFailed": "Заявката се провали: ${error}",
"requestsLoadFailed": "Неуспешно зареждане на опциите за заявка",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "В списъка с блокирани",
"couldNotReach": "Неуспешна връзка с ${url}: ${error}",
"noInstanceAtUrl": "На ${url} няма инстанция на Seerr (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Въведете адрес на сървър като https://seerr.example.com",
"quickConnectUnsupported": "Тази Seerr инстанция не поддържа Quick Connect. Изисква се Seerr 3.4 или по-нова версия.",
"notInitialized": "Тази инстанция на Seerr не е завършила първоначалната настройка",
+26 -4
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",
@@ -855,6 +864,9 @@
"presetDeleted": "Forudindstilling slettet",
"confirmDeletePreset": "Er du sikker på, at du vil slette denne forudindstilling?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context og gpu-api ignoreres på Linux: indlejret video renderes altid via vo=libmpv på videoplanen, og gpu-next (som compute-shaders som ArtCNN kræver) kan ikke køre indlejret."
},
"dialog": {
@@ -1251,6 +1263,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}",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} satte på pause",
"participantResumed": "${name} genoptog",
"participantSeeked": "${name} ændrede afspilningspositionen",
"participantChangedSpeed": "",
"participantBuffering": "${name} bufferer",
"participantNeedsUpdate": "${name} bruger en ældre appversion — synkronisering er ikke tilgængelig",
"resumingWithout": "Fortsætter uden ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Kvalitetsprofil",
"rootFolder": "Rodmappe",
"languageProfile": "Sprogprofil",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Anmodning sendt",
"requestFailed": "Anmodning mislykkedes: ${error}",
"requestsLoadFailed": "Kunne ikke indlæse anmodningsmuligheder",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "På blokeringslisten",
"couldNotReach": "Kunne ikke nå ${url}: ${error}",
"noInstanceAtUrl": "Ingen Seerr-instans på ${url} (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Indtast en serveradresse som https://seerr.example.com",
"quickConnectUnsupported": "Denne Seerr-instans understøtter ikke Quick Connect. Den kræver Seerr 3.4 eller nyere.",
"notInitialized": "Denne Seerr-instans har ikke fuldført førstegangsopsætningen",
+26 -4
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",
@@ -855,6 +864,9 @@
"presetDeleted": "Voreinstellung gelöscht",
"confirmDeletePreset": "Diese Voreinstellung wirklich löschen?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context und gpu-api werden unter Linux ignoriert: eingebettetes Video wird immer über vo=libmpv auf der Videoebene gerendert, und gpu-next (das Compute-Shader wie ArtCNN benötigen) kann nicht eingebettet ausgeführt werden."
},
"dialog": {
@@ -1251,6 +1263,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",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} hat pausiert",
"participantResumed": "${name} hat fortgesetzt",
"participantSeeked": "${name} hat die Wiedergabeposition geändert",
"participantChangedSpeed": "",
"participantBuffering": "${name} puffert",
"participantNeedsUpdate": "${name} verwendet eine ältere Appversion — Synchronisierung nicht verfügbar",
"resumingWithout": "Fortfahren ohne ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Qualitätsprofil",
"rootFolder": "Stammordner",
"languageProfile": "Sprachprofil",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Anfrage gesendet",
"requestFailed": "Anfrage fehlgeschlagen: ${error}",
"requestsLoadFailed": "Anfrageoptionen konnten nicht geladen werden",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "Auf der Sperrliste",
"couldNotReach": "${url} nicht erreichbar: ${error}",
"noInstanceAtUrl": "Keine Seerr-Instanz unter ${url} (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Gib eine Serveradresse ein, z. B. https://seerr.example.com",
"quickConnectUnsupported": "Diese Seerr-Instanz unterstützt Quick Connect nicht. Dafür ist Seerr 3.4 oder neuer erforderlich.",
"notInitialized": "Die Ersteinrichtung dieser Seerr-Instanz wurde noch nicht abgeschlossen",
+26 -4
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",
@@ -855,6 +864,9 @@
"presetDeleted": "Preset deleted",
"confirmDeletePreset": "Are you sure you want to delete this preset?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "option=value",
"addLine": "Add line",
"removeLine": "Remove line",
"embeddedVoHint": "vo, gpu-context and gpu-api are ignored on Linux: embedded video always renders through vo=libmpv on the video plane, and gpu-next (which compute shaders like ArtCNN need) cannot run embedded."
},
"dialog": {
@@ -1251,6 +1263,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}",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} paused",
"participantResumed": "${name} resumed",
"participantSeeked": "${name} changed the playback position",
"participantChangedSpeed": "${name} set the speed to ${speed}",
"participantBuffering": "${name} is buffering",
"participantNeedsUpdate": "${name} is on an older app version — sync unavailable",
"resumingWithout": "Resuming without ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Quality profile",
"rootFolder": "Root folder",
"languageProfile": "Language profile",
"tags": "Tags",
"noTags": "No tags",
"defaultOption": "${name} (Default)",
"animeNote": "This series is an anime.",
"requestSubmitted": "Request submitted",
"requestFailed": "Request failed: ${error}",
"requestsLoadFailed": "Couldn't load request options",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "Blocklisted",
"couldNotReach": "Could not reach ${url}: ${error}",
"noInstanceAtUrl": "No Seerr instance at ${url} (HTTP ${status})",
"behindAuthProxy": "An authenticating reverse proxy (SSO or HTTP auth) answered instead of Seerr. Plezy cannot sign in through it: let Seerr's /api/v1 path bypass the proxy for this app, or use an address that reaches Seerr directly.",
"invalidUrl": "Enter a server address like https://seerr.example.com",
"quickConnectUnsupported": "This Seerr instance does not support Quick Connect. It needs Seerr 3.4 or newer.",
"notInitialized": "This Seerr instance has not completed first-run setup",
+26 -4
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",
@@ -855,6 +864,9 @@
"presetDeleted": "Preajuste eliminado",
"confirmDeletePreset": "¿Estás seguro de que quieres eliminar este preajuste?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context y gpu-api se ignoran en Linux: el vídeo integrado siempre se renderiza mediante vo=libmpv en el plano de vídeo, y gpu-next (que los shaders de cómputo como ArtCNN necesitan) no puede ejecutarse integrado."
},
"dialog": {
@@ -1251,6 +1263,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}",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} pausó",
"participantResumed": "${name} reanudó",
"participantSeeked": "${name} cambió la posición de reproducción",
"participantChangedSpeed": "",
"participantBuffering": "${name} está almacenando en búfer",
"participantNeedsUpdate": "${name} usa una versión anterior de la aplicación — sincronización no disponible",
"resumingWithout": "Reanudando sin ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Perfil de calidad",
"rootFolder": "Carpeta raíz",
"languageProfile": "Perfil de idioma",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Solicitud enviada",
"requestFailed": "La solicitud falló: ${error}",
"requestsLoadFailed": "No se pudieron cargar las opciones de solicitud",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "En la lista de bloqueo",
"couldNotReach": "No se pudo conectar con ${url}: ${error}",
"noInstanceAtUrl": "No hay ninguna instancia de Seerr en ${url} (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Introduce una dirección de servidor como https://seerr.example.com",
"quickConnectUnsupported": "Esta instancia de Seerr no admite Quick Connect. Necesita Seerr 3.4 o más reciente.",
"notInitialized": "Esta instancia de Seerr no ha completado la configuración inicial",
+26 -4
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 l’introduction",
"autoSkipIntroDescription": "Passer automatiquement les marqueurs d’introduction 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",
@@ -855,6 +864,9 @@
"presetDeleted": "Préréglage supprimé",
"confirmDeletePreset": "Êtes-vous sûr de vouloir supprimer ce préréglage ?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context et gpu-api sont ignorés sous Linux : la vidéo intégrée est toujours rendue via vo=libmpv sur le plan vidéo, et gpu-next (dont les shaders de calcul comme ArtCNN ont besoin) ne peut pas fonctionner en mode intégré."
},
"dialog": {
@@ -1251,6 +1263,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 qu’elles contiendront des éléments.",
"searchHint": "Rechercher dans ${source}",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} a mis en pause",
"participantResumed": "${name} a repris",
"participantSeeked": "${name} a changé la position de lecture",
"participantChangedSpeed": "",
"participantBuffering": "La lecture de ${name} est en cours de mise en mémoire tampon",
"participantNeedsUpdate": "${name} utilise une ancienne version de l’app — synchronisation indisponible",
"resumingWithout": "Reprise sans ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Profil de qualité",
"rootFolder": "Dossier racine",
"languageProfile": "Profil de langue",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Demande envoyée",
"requestFailed": "Échec de la demande : ${error}",
"requestsLoadFailed": "Impossible de charger les options de demande",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "Sur la liste de blocage",
"couldNotReach": "Impossible de joindre ${url} : ${error}",
"noInstanceAtUrl": "Aucune instance Seerr à l’adresse ${url} (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Saisissez une adresse de serveur comme https://seerr.example.com",
"quickConnectUnsupported": "Cette instance Seerr ne prend pas en charge Quick Connect. Elle nécessite Seerr 3.4 ou version ultérieure.",
"notInitialized": "La configuration initiale de cette instance Seerr n’est pas terminée",
+26 -4
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",
@@ -855,6 +864,9 @@
"presetDeleted": "Előbeállítás törölve",
"confirmDeletePreset": "Biztosan törölni szeretnéd ezt az előbeállítást?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# megjegyzés",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "A vo, gpu-context és gpu-api beállítások Linuxon figyelmen kívül maradnak: a beágyazott videó mindig a vo=libmpv-n keresztül jelenik meg a videósíkon, a gpu-next (amelyre az ArtCNN-hez hasonló compute shadereknek szükségük van) pedig nem futhat beágyazva."
},
"dialog": {
@@ -1251,6 +1263,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}",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} szüneteltette a lejátszást",
"participantResumed": "${name} folytatta a lejátszást",
"participantSeeked": "${name} módosította a lejátszási pozíciót",
"participantChangedSpeed": "",
"participantBuffering": "${name} pufferel",
"participantNeedsUpdate": "${name} régebbi alkalmazásverziót használ — a szinkronizálás nem érhető el",
"resumingWithout": "Folytatás a következő nélkül: ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Minőségi profil",
"rootFolder": "Gyökérmappa",
"languageProfile": "Nyelvi profil",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Igénylés elküldve",
"requestFailed": "Az igénylés nem sikerült: ${error}",
"requestsLoadFailed": "Nem sikerült betölteni az igénylési opciókat",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "Tiltólistán",
"couldNotReach": "Nem sikerült elérni ezt: ${url}: ${error}",
"noInstanceAtUrl": "Nem található Seerr-példány ezen a címen: ${url} (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Adj meg egy szervercímet, például: https://seerr.example.com",
"quickConnectUnsupported": "Ez a Seerr-példány nem támogatja a Quick Connectet. Seerr 3.4 vagy újabb verzió szükséges.",
"notInitialized": "Ennek a Seerr-példánynak a kezdeti beállítása még nem fejeződött be",
+26 -4
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",
@@ -855,6 +864,9 @@
"presetDeleted": "Preset eliminato",
"confirmDeletePreset": "Sei sicuro di voler eliminare questo preset?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context e gpu-api vengono ignorati su Linux: il video incorporato viene sempre renderizzato tramite vo=libmpv sul piano video e gpu-next (che gli shader di calcolo come ArtCNN richiedono) non può essere eseguito in modalità incorporata."
},
"dialog": {
@@ -1251,6 +1263,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}",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} ha messo in pausa",
"participantResumed": "${name} ha ripreso",
"participantSeeked": "${name} ha cambiato la posizione di riproduzione",
"participantChangedSpeed": "",
"participantBuffering": "${name} è in buffering",
"participantNeedsUpdate": "${name} usa una versione precedente dell'app — sincronizzazione non disponibile",
"resumingWithout": "Ripresa senza ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Profilo di qualità",
"rootFolder": "Cartella radice",
"languageProfile": "Profilo della lingua",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Richiesta inviata",
"requestFailed": "Richiesta non riuscita: ${error}",
"requestsLoadFailed": "Impossibile caricare le opzioni di richiesta",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "Nella lista di blocco",
"couldNotReach": "Impossibile raggiungere ${url}: ${error}",
"noInstanceAtUrl": "Nessuna istanza Seerr all'indirizzo ${url} (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Inserisci un indirizzo del server come https://seerr.example.com",
"quickConnectUnsupported": "Questa istanza Seerr non supporta Quick Connect. È necessaria la versione 3.4 o successiva di Seerr.",
"notInitialized": "Questa istanza Seerr non ha completato la configurazione iniziale",
+25 -4
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": "自動スキップの遅延",
@@ -851,6 +860,9 @@
"presetDeleted": "プリセットを削除しました",
"confirmDeletePreset": "このプリセットを削除してもよろしいですか?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "Linux では vo、gpu-context、gpu-api は無視されます。埋め込み動画は常にビデオプレーン上で vo=libmpv を通してレンダリングされ、gpu-next(ArtCNN のようなコンピュートシェーダーに必要)は埋め込みでは実行できません。"
},
"dialog": {
@@ -1245,6 +1257,9 @@
"notInLibrary": "ライブラリにありません",
"inTheseLibraries": "これらのライブラリにあります",
"checkingLibrary": "ライブラリを確認中…",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "まだ何もありません",
"emptyMessage": "${source}にコンテンツが追加されると、ここに表示されます。",
"searchHint": "${source}を検索",
@@ -1618,6 +1633,7 @@
"participantPaused": "${name}が一時停止しました",
"participantResumed": "${name}が再開しました",
"participantSeeked": "${name}が再生位置を変更しました",
"participantChangedSpeed": "",
"participantBuffering": "${name}がバッファリング中",
"participantNeedsUpdate": "${name}は古いバージョンのアプリを使用しているため、同期できません",
"resumingWithout": "${name}抜きで再開",
@@ -2064,6 +2080,10 @@
"qualityProfile": "画質プロファイル",
"rootFolder": "ルートフォルダ",
"languageProfile": "言語プロファイル",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "リクエストを送信しました",
"requestFailed": "リクエストに失敗しました: ${error}",
"requestsLoadFailed": "リクエストオプションを読み込めませんでした",
@@ -2075,6 +2095,7 @@
"statusBlocklisted": "ブロックリスト登録済み",
"couldNotReach": "${url}に接続できませんでした: ${error}",
"noInstanceAtUrl": "${url}にSeerrインスタンスがありません(HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "https://seerr.example.comのようなサーバーアドレスを入力してください",
"quickConnectUnsupported": "このSeerrインスタンスはQuick Connectに対応していません。Seerr 3.4以降が必要です。",
"notInitialized": "このSeerrインスタンスでは初回セットアップが完了していません",
+26 -4
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": "Автоматты өткізу кідірісі",
@@ -855,6 +864,9 @@
"presetDeleted": "Баптау өшірілді",
"confirmDeletePreset": "Осы баптауды өшіргіңіз келетініне сенімдісіз бе?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# түсініктеме",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context және gpu-api Linux-те еленбейді: ендірілген бейне әрқашан бейне жазықтығында vo=libmpv арқылы көрсетіледі, ал gpu-next (ArtCNN сияқты compute шейдерлеріне қажет) ендірілген режимде жұмыс істей алмайды."
},
"dialog": {
@@ -1251,6 +1263,10 @@
"notInLibrary": "Кітапханаңызда жоқ",
"inTheseLibraries": "Осы кітапханаларда бар",
"checkingLibrary": "Кітапхана тексерілуде...",
"libraryCheckFailed": {
"one": "",
"other": ""
},
"emptyTitle": "Әлі де мұнда ештеңе жоқ",
"emptyMessage": "${source} дереккөзінен алынған қатарлар мұнда көрінеді.",
"searchHint": "${source} ішінен іздеу",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} кідіртті",
"participantResumed": "${name} жалғастырды",
"participantSeeked": "${name} уақытты өзгертті",
"participantChangedSpeed": "",
"participantBuffering": "${name} буферлеуде",
"participantNeedsUpdate": "${name} ескі нұсқада",
"resumingWithout": "${name} ескерусіз жалғастырылуда",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Сапа профилі",
"rootFolder": "Түпкі қапшық",
"languageProfile": "Тіл профилі",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Сұрау жіберілді",
"requestFailed": "Сұрау қатесі: ${error}",
"requestsLoadFailed": "Параметрлерді жүктеу мүмкін болмады",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "Бұғаттау тізімінде",
"couldNotReach": "${url} мекенжайына қосылу мүмкін болмады: ${error}",
"noInstanceAtUrl": "${url} мекенжайында Seerr данасы жоқ (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Сервер мекенжайын енгізіңіз, мысалы: https://seerr.example.com",
"quickConnectUnsupported": "Бұл Seerr данасы Жылдам қосылуды қолдамайды. Оған Seerr 3.4 немесе одан жаңарақ нұсқа қажет.",
"notInitialized": "Бұл Seerr данасының бастапқы баптауы аяқталмаған",
+25 -4
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": "자동 건너뛰기 지연",
@@ -851,6 +860,9 @@
"presetDeleted": "프리셋이 삭제되었습니다",
"confirmDeletePreset": "이 프리셋을 삭제하시겠습니까?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "Linux에서는 vo, gpu-context, gpu-api가 무시됩니다. 내장 동영상은 항상 비디오 평면에서 vo=libmpv로 렌더링되며, gpu-next(ArtCNN 같은 컴퓨트 셰이더에 필요)는 내장 방식으로 실행할 수 없습니다."
},
"dialog": {
@@ -1245,6 +1257,9 @@
"notInLibrary": "라이브러리에 없음",
"inTheseLibraries": "이 라이브러리에 있음",
"checkingLibrary": "라이브러리 확인 중...",
"libraryCheckFailed": {
"other": ""
},
"emptyTitle": "아직 아무것도 없습니다",
"emptyMessage": "${source}에 콘텐츠가 추가되면 여기에 표시됩니다.",
"searchHint": "${source}에서 검색",
@@ -1618,6 +1633,7 @@
"participantPaused": "${name}님이 일시정지했습니다",
"participantResumed": "${name}님이 재생했습니다",
"participantSeeked": "${name}님이 재생 위치를 변경했습니다",
"participantChangedSpeed": "",
"participantBuffering": "${name}님이 버퍼링 중입니다",
"participantNeedsUpdate": "${name}님이 이전 버전의 앱을 사용 중입니다 — 동기화를 사용할 수 없습니다",
"resumingWithout": "${name}님 없이 재생을 재개합니다",
@@ -2064,6 +2080,10 @@
"qualityProfile": "화질 프로파일",
"rootFolder": "루트 폴더",
"languageProfile": "언어 프로파일",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "요청을 제출했습니다",
"requestFailed": "요청 실패: ${error}",
"requestsLoadFailed": "요청 옵션을 불러올 수 없습니다",
@@ -2075,6 +2095,7 @@
"statusBlocklisted": "차단 목록에 있음",
"couldNotReach": "${url}에 연결할 수 없습니다: ${error}",
"noInstanceAtUrl": "${url}에 Seerr 인스턴스가 없습니다(HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "https://seerr.example.com과 같은 서버 주소를 입력하세요",
"quickConnectUnsupported": "이 Seerr 인스턴스는 Quick Connect를 지원하지 않습니다. Seerr 3.4 이상이 필요합니다.",
"notInitialized": "이 Seerr 인스턴스는 최초 실행 설정을 완료하지 않았습니다",
+26 -4
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",
@@ -855,6 +864,9 @@
"presetDeleted": "Forhåndsinnstilling slettet",
"confirmDeletePreset": "Er du sikker på at du vil slette denne forhåndsinnstillingen?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# kommentar",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context og gpu-api ignoreres på Linux: innebygd video renderes alltid via vo=libmpv på videoplanet, og gpu-next (som compute-shadere som ArtCNN trenger) kan ikke kjøre innebygd."
},
"dialog": {
@@ -1251,6 +1263,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}",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} satte avspillingen på pause",
"participantResumed": "${name} startet avspillingen igjen",
"participantSeeked": "${name} endret avspillingsposisjonen",
"participantChangedSpeed": "",
"participantBuffering": "${name} buffrer",
"participantNeedsUpdate": "${name} bruker en eldre appversjon — synkronisering er ikke tilgjengelig",
"resumingWithout": "Fortsetter uten ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Kvalitetsprofil",
"rootFolder": "Rotmappe",
"languageProfile": "Språkprofil",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Forespørsel sendt",
"requestFailed": "Forespørsel mislyktes: ${error}",
"requestsLoadFailed": "Kunne ikke laste forespørselsalternativer",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "På blokkeringslisten",
"couldNotReach": "Kunne ikke nå ${url}: ${error}",
"noInstanceAtUrl": "Ingen Seerr-instans på ${url} (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Skriv inn en serveradresse som https://seerr.example.com",
"quickConnectUnsupported": "Denne Seerr-instansen støtter ikke Quick Connect. Den krever Seerr 3.4 eller nyere.",
"notInitialized": "Denne Seerr-instansen har ikke fullført førstegangsoppsettet",
+26 -4
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",
@@ -855,6 +864,9 @@
"presetDeleted": "Voorinstelling verwijderd",
"confirmDeletePreset": "Weet je zeker dat je deze voorinstelling wilt verwijderen?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context en gpu-api worden genegeerd op Linux: ingebedde video wordt altijd weergegeven via vo=libmpv op het videovlak, en gpu-next (nodig voor compute-shaders zoals ArtCNN) kan niet ingebed draaien."
},
"dialog": {
@@ -1251,6 +1263,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}",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} heeft gepauzeerd",
"participantResumed": "${name} heeft hervat",
"participantSeeked": "${name} heeft de afspeelpositie gewijzigd",
"participantChangedSpeed": "",
"participantBuffering": "${name} is aan het bufferen",
"participantNeedsUpdate": "${name} gebruikt een oudere appversie — synchronisatie niet beschikbaar",
"resumingWithout": "Hervatten zonder ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Kwaliteitsprofiel",
"rootFolder": "Hoofdmap",
"languageProfile": "Taalprofiel",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Aanvraag verzonden",
"requestFailed": "Aanvraag mislukt: ${error}",
"requestsLoadFailed": "Aanvraagopties konden niet worden geladen",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "Op de blokkeerlijst",
"couldNotReach": "Kon ${url} niet bereiken: ${error}",
"noInstanceAtUrl": "Geen Seerr-instantie op ${url} (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Voer een serveradres in, zoals https://seerr.example.com",
"quickConnectUnsupported": "Deze Seerr-instantie ondersteunt Quick Connect niet. Hiervoor is Seerr 3.4 of nieuwer nodig.",
"notInitialized": "De eerste configuratie van deze Seerr-instantie is niet voltooid",
+28 -4
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",
@@ -863,6 +872,9 @@
"presetDeleted": "Usunięto ustawienie wstępne",
"confirmDeletePreset": "Czy na pewno chcesz usunąć to ustawienie wstępne?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context i gpu-api są ignorowane w systemie Linux: wbudowane wideo jest zawsze renderowane przez vo=libmpv na płaszczyźnie wideo, a gpu-next (wymagany przez shadery obliczeniowe takie jak ArtCNN) nie może działać w trybie wbudowanym."
},
"dialog": {
@@ -1263,6 +1275,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}",
@@ -1648,6 +1666,7 @@
"participantPaused": "${name} wstrzymał",
"participantResumed": "${name} wznowił",
"participantSeeked": "${name} zmienił pozycję odtwarzania",
"participantChangedSpeed": "",
"participantBuffering": "${name} buforuje",
"participantNeedsUpdate": "${name} używa starszej wersji aplikacji — synchronizacja jest niedostępna",
"resumingWithout": "Wznawianie bez ${name}",
@@ -2094,6 +2113,10 @@
"qualityProfile": "Profil jakości",
"rootFolder": "Folder główny",
"languageProfile": "Profil językowy",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Zamówienie wysłane",
"requestFailed": "Zamówienie nie powiodło się: ${error}",
"requestsLoadFailed": "Nie udało się wczytać opcji zamówienia",
@@ -2105,6 +2128,7 @@
"statusBlocklisted": "Na liście blokowanych",
"couldNotReach": "Nie udało się połączyć z ${url}: ${error}",
"noInstanceAtUrl": "Pod adresem ${url} nie ma instancji Seerr (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Wprowadź adres serwera, np. https://seerr.example.com",
"quickConnectUnsupported": "Ta instancja Seerr nie obsługuje Quick Connect. Wymagany jest Seerr 3.4 lub nowszy.",
"notInitialized": "Ta instancja Seerr nie ukończyła konfiguracji początkowej",
+26 -4
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",
@@ -855,6 +864,9 @@
"presetDeleted": "Predefinição excluída",
"confirmDeletePreset": "Tem certeza de que deseja excluir esta predefinição?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context e gpu-api são ignorados no Linux: o vídeo incorporado é sempre renderizado via vo=libmpv no plano de vídeo, e gpu-next (necessário para shaders de computação como ArtCNN) não pode ser executado incorporado."
},
"dialog": {
@@ -1251,6 +1263,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}",
@@ -1628,6 +1644,7 @@
"participantPaused": "${name} pausou",
"participantResumed": "${name} retomou",
"participantSeeked": "${name} mudou a posição da reprodução",
"participantChangedSpeed": "",
"participantBuffering": "${name} está aguardando o carregamento",
"participantNeedsUpdate": "${name} está usando uma versão mais antiga do app — sincronização indisponível",
"resumingWithout": "Retomando sem ${name}",
@@ -2074,6 +2091,10 @@
"qualityProfile": "Perfil de qualidade",
"rootFolder": "Pasta raiz",
"languageProfile": "Perfil de idioma",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Solicitação enviada",
"requestFailed": "Falha na solicitação: ${error}",
"requestsLoadFailed": "Não foi possível carregar as opções de solicitação",
@@ -2085,6 +2106,7 @@
"statusBlocklisted": "Na lista de bloqueio",
"couldNotReach": "Não foi possível acessar ${url}: ${error}",
"noInstanceAtUrl": "Nenhuma instância do Seerr em ${url} (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Insira um endereço de servidor como https://seerr.example.com",
"quickConnectUnsupported": "Esta instância do Seerr não oferece suporte a Quick Connect. Ela precisa do Seerr 3.4 ou mais recente.",
"notInitialized": "Esta instância do Seerr não concluiu a configuração inicial",
+28 -4
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": "Задержка автопропуска",
@@ -863,6 +872,9 @@
"presetDeleted": "Пресет удалён",
"confirmDeletePreset": "Вы уверены, что хотите удалить этот пресет?",
"configPlaceholder": "gpu-api=vulkan\nhwdec=auto\n# comment",
"lineHint": "",
"addLine": "",
"removeLine": "",
"embeddedVoHint": "vo, gpu-context и gpu-api игнорируются в Linux: встроенное видео всегда отображается через vo=libmpv на видеоплоскости, а gpu-next (нужный для compute-шейдеров вроде ArtCNN) не может работать во встроенном режиме."
},
"dialog": {
@@ -1263,6 +1275,12 @@
"notInLibrary": "Нет в вашей библиотеке",
"inTheseLibraries": "В этих библиотеках",
"checkingLibrary": "Проверка вашей библиотеки...",
"libraryCheckFailed": {
"one": "",
"few": "",
"many": "",
"other": ""
},
"emptyTitle": "Здесь пока ничего нет",
"emptyMessage": "Разделы из ${source} появятся здесь, когда в них появится контент.",
"searchHint": "Поиск в ${source}",
@@ -1648,6 +1666,7 @@
"participantPaused": "${name} поставил на паузу",
"participantResumed": "${name} возобновил",
"participantSeeked": "${name} перемотал",
"participantChangedSpeed": "",
"participantBuffering": "У ${name} идёт буферизация",
"participantNeedsUpdate": "${name} использует старую версию приложения — синхронизация недоступна",
"resumingWithout": "Возобновление без ${name}",
@@ -2094,6 +2113,10 @@
"qualityProfile": "Профиль качества",
"rootFolder": "Корневая папка",
"languageProfile": "Языковой профиль",
"tags": "",
"noTags": "",
"defaultOption": "",
"animeNote": "",
"requestSubmitted": "Запрос отправлен",
"requestFailed": "Ошибка запроса: ${error}",
"requestsLoadFailed": "Не удалось загрузить параметры запроса",
@@ -2105,6 +2128,7 @@
"statusBlocklisted": "В списке блокировки",
"couldNotReach": "Не удалось связаться с ${url}: ${error}",
"noInstanceAtUrl": "По адресу ${url} нет экземпляра Seerr (HTTP ${status})",
"behindAuthProxy": "",
"invalidUrl": "Введите адрес сервера, например https://seerr.example.com",
"quickConnectUnsupported": "Этот экземпляр Seerr не поддерживает Quick Connect. Нужна версия Seerr 3.4 или новее.",
"notInitialized": "Первоначальная настройка этого экземпляра Seerr не завершена",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 43747 (1988 per locale)
/// Strings: 43683 (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}',

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