Compare commits

...
Author SHA1 Message Date
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
edde746 23f9afa5b1 fix(windows): strip HDR10+ metadata from the Adreno tone-map path
Disabling peak detection (efb0e885) did not stop the per-frame shader LUT regeneration on Snapdragon devices: libplacebo keys the tone-map LUT on the frame's raw HDR metadata, and mpv forwards HDR10+ per-scene values (scene_max, scene_avg, ootf) on every decoded frame, so the LUT - at tens of milliseconds per rebuild on Qualcomm's D3D11 driver - still regenerated continuously and playback kept stuttering and flickering (#2191).

Zero the dynamic metadata before the renderer with vf=format:hdr10plus=no on Qualcomm GPUs; tone mapping uses the static HDR10 mastering metadata and the LUT is generated once. Dolby Vision L1 metadata is deliberately not stripped: dovi=no would break profile-5 rendering. The applied HDR pipeline options are now reported into the app log on first file load so uploaded logs are self-diagnosing.
2026-09-01 09:02:59 +02:00
edde746 efb0e885ae fix(windows): disable dynamic HDR peak detection on Qualcomm GPUs
Playing HDR content tone-mapped to an SDR display on Windows-on-ARM stutters heavily and flickers in brightness: dynamic peak detection moves the tone-mapping parameters every frame, libplacebo regenerates its tone-map and gamut-map shader LUTs each time, and Qualcomm's Adreno D3D11 driver takes tens of milliseconds per regeneration - roughly 100 ms of work against a 42 ms frame budget at 24 fps.

Scan the DXGI adapter list for a Qualcomm vendor id and set hdr-compute-peak=no when one is present, keeping auto elsewhere; static metadata-driven tone mapping generates its LUTs once.

close #2191
2026-09-01 07:37:10 +02:00
edde746 5fb4c78385 fix(jellyfin): restore Live TV on ts-only tuners and honor the quality preset
Since 2.18.0 no Jellyfin Live TV channel plays: opening one fails with
"Could not start the live channel", and switching channel inside the player
does nothing at all. Negotiating video transcodes as fMP4 (#2131) left the
device profile with no MPEG-TS entry, and Jellyfin discards every non-ts
transcoding profile for a live source whose tuner sets
UseMostCompatibleTranscodingProfile - hardcoded for every HDHomeRun host, the
default for M3U ones - so the negotiation came back with no HLS URL at all.

The device profile now sends a ts profile alongside the fMP4 one, listed
second, the way jellyfin-web does. Both of its codec lists are strict subsets
of the fMP4 entry's and the server ranks profiles with a stable sort, so ts
can only win where fMP4 has been filtered out; VOD keeps negotiating fMP4.
flac and truehd are absent because TS cannot carry them, av1 because that is
the gap fMP4 exists to fill.

Live TV also stops being unconditionally transcode-only. It now reads the same
saved quality preset the library path does: Original asks for direct play and
sends no bitrate ceiling, so a channel the server can hand over untouched no
longer costs a GPU encode session per viewer, and a capped preset transcodes at
that ceiling. A direct-play session whose stream dies re-negotiates a forced
transcode instead of re-opening a URL the server will not serve.

Two smaller holes on the same path: a negotiation that bailed out left the live
stream AutoOpenLiveStream had already opened running, because no playback
session existed to ever stop-report it - a few failed tunes could saturate a
provider's connection budget - and a channel switch that failed to start showed
no message at all.

close #2198
2026-08-31 19:23:22 +02:00
edde746 5a666fd10f feat(discover): refresh home and library content from server library push events
New media added server-side was invisible while the app was open - the
client had no channel for library-change notifications, so the home
screen and library tabs sat stale until an app restart.

Each online server now runs a reconnecting websocket push channel
(Plex `/:/websockets/notifications`; Jellyfin/Emby `/socket` with the
dialect deltas, including Emby's capabilities registration), owned per
server by a LibraryEventService supervisor that follows server
online/offline/replaced-client transitions and app lifecycle. Channels
emit one coalesced LibraryChangeEvent per burst on a leading-edge
throttle modeled on Plex Web's repopulate pacing: the first settled
change surfaces immediately and a library scan's flood merges behind
it. Connection failure degrades silently to the stale-refresh paths -
bounded reconnect backoff, re-armed on every status sync.

Consumers pace their reaction through one shared RefreshPacer: burst
debounce, blocked retry while video playback or an active scroll owns
the surface, a cooldown bounding pass frequency during bulk imports,
and credit for committed pull passes so a push landing right after a
fresh load defers to the cooldown's trailing edge.

- Discover runs a debounced full pass and swaps the result in place;
  the hero carousel resets only when a pass lands first content, so a
  push never yanks a screen the user is reading. Pushed removals drop
  from every visible list immediately via the deletion bus, scoped to
  the emitting server.
- The visible library tab swaps its data in place: transaction tabs
  reload without clearing, and the browse and paginated card grids
  refetch their loaded span (Plex Web's repopulateRange) with the
  scroll offset anchored on the first visible item and the span
  clamped after alpha jumps. Hidden tabs - and tabs behind another
  main tab - mark per-library staleness epochs instead and reload when
  next shown; epochs are snapshotted at load start so a push racing an
  in-flight fetch stays stale.
- The stale-resume and tab-shown paths refetch home hubs (previously
  Continue Watching only), covering setups where the socket cannot
  connect.

close #1646
2026-08-31 18:52:03 +02:00
edde746 56312a9eb2 fix(ui): keep clear logos with significant color untouched on heroes
Remapping a mark whose color is part of its identity - a red-outline
wordmark with white fill, a colored badge - fixes legibility but changes
the logo's character, which reads worse on hero surfaces than the
partial invisibility it cures.

The light tone class now splits on measured colored-pixel fraction:
light-dominant marks with incidental color (<=15% saturated pixels,
lightAccented) always remap, while lightMixed marks remap only when the
new ToneMappedLogoImage.remapMixed policy allows it. Heroes pass false
on the network, offline, and TV spotlight logo paths; the Live TV
guide's channel cells keep remapping mixed marks. The policy joins the
image-cache key. The threshold comes from a real clear-logo set:
remap-friendly marks measured at <=0.11 colored, identity-colored marks
at >=0.28.
2026-08-31 18:51:46 +02:00
edde746 976bdbd021 fix(ui): keep hero titles and clear logos legible in light theme
On the light theme, the detail hero's fallback title was hard-coded
white over a scrim that washes artwork toward the near-white background,
and white-on-transparent clear logos vanished into the same wash on the
detail, home, and TV spotlight heroes.

The title fallback now defaults to the theme foreground with a
background-side halo, matching what the TV hero already did. Clear logos
run through the channel-logo tone remap: light-toned marks recolor
toward the theme foreground on light backdrops (network and downloaded
artwork alike), colored marks keep their pixels, and dark themes render
the original artwork. The backdrop-luminance gate is shared with the
Live TV guide as logoToneTargetFor.
2026-08-31 18:51:46 +02:00
edde746 274b31ebac fix(livetv): recolor light channel logos for legibility in light theme
White-on-transparent broadcast logos (CBS, FOX, the NBC wordmark) vanish
on the light theme's white guide cards, making channels hard to identify.

Channel logo decodes now classify the frame's tone once per image-cache
entry and bake light-neutral pixels toward the theme foreground:
monochrome white marks recolor fully, mixed marks (colored peacock plus
white wordmark) keep their colored pixels, and self-backed or dark
artwork decodes unchanged. Applied in the guide channel column, guide
search, and favorite reordering whenever the backdrop is light,
including the dark theme's inverted focus card; dark backdrops keep the
original artwork.

close #2197
2026-08-31 18:51:46 +02:00
edde746 e1ef52aba1 fix(android): fall back to software decoding for non-Main-profile AV1
4:4:4 (High profile) AV1 played as green or black video on Android TV
devices with AV1 hardware decoders: MediaCodec defines no profile
constants beyond Main, but decoders like the Fire Stick 4K Max (MTK)
and Amlogic C2 accept the stream at configure time and emit garbage or
nothing, so no error ever reached the player and no fallback ran.

libmpv v1.2.2 carries an FFmpeg patch that reads seq_profile from the
bitstream at av1_mediacodec init and refuses High/Professional streams,
letting mpv's hwdec probing fall through to dav1d software decoding.
Main-profile AV1 keeps hardware decoding.

close #2194
2026-08-31 13:29:13 +02:00
edde746 9db6f54b7a feat(plex): make the covered-source direct play optional
A quality preset the source already fits under direct plays the file
since #2152, but a transcode above the source bitrate is sometimes the
point: a device that cannot decode the source (10-bit H.264, AV1, 4:4:4)
wants the server's highest-quality encode, and the shortcut silently
handed it the very file it cannot play smoothly.

A new Quality switch, "Play Smaller Videos at Original Quality" (on by
default), gates the shortcut in PlexClient._presetNeedsTranscode; off
restores the pre-#2152 behavior where any non-original preset always
transcodes. Plex-only by design: MediaBrowser servers make the
equivalent direct-play-vs-transcode call server-side.

close #2193
2026-08-31 11:32:07 +02:00
edde746 39ee089212 fix(audio): reload the mpv audio output when the HDMI route changes
TrueHD/Atmos passthrough with Frame Rate Matching enabled never engaged
the receiver on the mpv backend (Ugoos SK1 -> Denon AVR): the display
mode switch settles 2 s before the audio output opens, but some HDMI
chains are still renegotiating audio then, so the IEC 61937 track binds
a half-negotiated route and the sink never locks onto the MAT
bitstream - for the whole session. The same file with Frame Rate
Matching off, or on ExoPlayer, engages Atmos immediately: media3
rebuilds its sink when the audio device set changes, while mpv's
ao_audiotrack never revisited a track once created.

libmpv v1.2.1 polls AudioTrack.getRoutedDevice from the AO thread and
reloads the AO when the route changes - or vanishes and rebinds - under
a live IEC or raw passthrough track, reopening it against the settled
route. PCM tracks are exempt, and audiotrack-route-reload=no is the
escape hatch.

close #2190
2026-08-31 10:05:08 +02:00
edde746 89d54d8f1d feat(watch-together): let the host hand the room to another participant
close #2183

Reusing a room means whoever enters first becomes the host, and moving the
session to someone else's library meant everyone leaving and re-entering in
the right order. The host can now promote any connected guest from the
participant list (Watch Together screen and the in-player session sheet),
with playback, control mode, and reconnect identities carried across.

The relay owns the swap: a new `transferHost` message validates the sender
is the live host and the target a connected modern-protocol guest, then
swaps `HostPeerID` and the reconnect verifiers (each peer keeps its own
token), persists the room, and broadcasts `hostChanged` to every peer.

Clients flip roles in place: the controller swaps its role engine while
keeping the session, message queue, and player attachment. A promoted guest
seeds the coordinator with the room's last known intent (a paused room
stays paused) and the known-peer roster so the fresh epoch re-gates
instead of solo-starting; a demoted host falls back to a reconciler with a
fresh clock sync and asks the new authority for state. Guests re-pin the
host identity, reset their sequence, and re-converge their clocks.

A rejected transfer (`not_host` / `peer_not_found`) surfaces as a toast
instead of tearing the session down. Pre-transfer app builds in the room
ignore `hostChanged` and stop following the room at the next transfer;
targets on an old sync protocol are not offered the action.
2026-08-31 08:55:45 +02:00
edde746 44773fd742 feat(player): remember the swipe brightness level between playbacks
The brightness set with the left-edge swipe was lost the moment playback
ended, so every session started back at the system level. A new
"Remember Brightness Level" toggle under Settings > Playback > Gestures
persists the level a swipe settles on and reapplies it when the next
playback starts (and when the app resumes mid-session). Leaving the
player still restores the pre-playback brightness, so the rest of the
app is unaffected. Default off, preserving current behavior.

close #2178
2026-08-31 06:43:47 +02:00
edde746 4f4a7338e0 fix(search): tighten spacing around the kind filter chip strip
The chip strip sat 24px below the search field and 24px above the first
result. Drop the strip's own top padding (the field's bottom padding
already provides 16px) and halve the results sliver's top padding when
the strip is shown, so both gaps read as an even 16px.
2026-08-31 06:01:48 +02:00
edde746 10f9139ac6 fix(audio): bitstream Dolby/DTS as raw tracks on the mpv path
Audio Passthrough on 2.18.0 played EAC3 silently on a Shield in front of
a Dolby-Digital-only receiver: mpv's IEC 61937 AudioTrack bypasses the
platform's Dolby transcoder, so the pre-packed DD+ bitstream reached a
sink that cannot decode it, drained at full rate, and no failure was
observable app-side. Disabling passthrough lost surround instead, because
multichannel PCM collapses to stereo in the platform mixer before the
HDMI re-encode.

libmpv v1.2.0 unwraps the IEC bursts and feeds AC3, E-AC3 and the DTS
core to a raw ENCODING_AC3/E_AC3/DTS track - the transport ExoPlayer and
Kodi use - keeping the platform decoder/transcoder in the path, with the
IEC track as fallback and audiotrack-raw-passthrough=no as an escape
hatch. TrueHD and DTS-HD MA stay on the 8-channel IEC carrier.

The audio-spdif route probe now accepts a codec when the route takes its
raw track (the same direct-playback tiering media3 uses; encoding-only
below API 29) or one of the IEC shapes, matching the AO's transport
ladder. A dev-only PLEZY_LOCAL_MPV_AAR override allows testing locally
built fork AARs before a release is pinned.

close #2177
close #2179
2026-08-30 19:22:14 +02:00
edde746 3c1ab3cc6c fix(windows): bundle the Vulkan loader so libmpv's vulkan-1.dll import resolves
Since the libmpv bump to 20260809 (2.15.0), libmpv-2.dll dynamically links
the Khronos Vulkan loader instead of statically linking it. plezy.exe links
libmpv at load time, so on machines whose GPU driver does not install
vulkan-1.dll - Windows-on-ARM Adreno drivers, driverless x64 VMs - the
process failed before startup with "vulkan-1.dll was not found".

Download the LunarG-built loader (pinned, hash-verified) at configure time
and install it next to the executable with its license. Without a Vulkan
ICD the gpu-api=auto probe fails cleanly and falls back to D3D11, matching
the old static-loader behavior.

close #2110
2026-08-30 12:16:26 +02:00
github-actions[bot] c597390e1c chore: update cask to 2.18.0 2026-08-30 08:39:52 +00:00
edde746 2806115015 chore: bump version to 2.18.0 2026-08-30 09:28:50 +02:00
edde746 d9493c6157 feat(player): move every Android install to the mpv backend
Making mpv the Android default only moved installs that had never opened
the Player Backend setting. Anyone who had ever picked ExoPlayer kept it,
including the low-end TVs and Hi10P devices the new backend was built for.

The backend choice now lives under a new `android_use_exoplayer` key and
the old `use_exoplayer` one is dropped at startup, so every install starts
on mpv. ExoPlayer stays selectable in Settings > Playback > Player Backend,
and a pick made there writes the new key and sticks.
2026-08-30 09:22:31 +02:00
edde746 7cfb890046 fix(i18n): translate the Live TV guide search and TV options labels
The guide-search sheet and the TV rail's options button fell back to
English in every non-English locale: their six keys existed only as
empty strings in the sibling translation files.

Fill all 126 empty targets across the 21 locales and regenerate the
slang bindings. searchNoResults keeps its ${query} placeholder and each
locale's quote convention, and channelsSection uses the broadcast sense
of "channel" rather than the audio-channel word already used by
fileInfo.channels.
2026-08-30 08:48:32 +02:00
edde746 c6458295a1 fix(player): let the HDR exit settle before restoring the display mode
Stopping HDR playback with frame rate matching enabled left some TVs
black for up to 30 seconds. The teardown reset preferredDisplayModeId
while the display was still signaling HDR, folding the HDR exit and the
refresh-rate restore into one HDMI renegotiation that slow sink chains
take many seconds to complete (8.5 s on a Shield Pro with an HDR10
display; ~30 s on the reporter's DV AVR+TV chain). Sequenced, the HDR
infoframe clear is free and the SDR mode switch takes about a second.

FrameRateManager.clearVideoFrameRate now takes an hdrActive hint from
the core (mpv: the content-color-transfer decision; ExoPlayer: the
selected track's colorInfo transfer) and defers the restore by 400 ms so
the surface teardown commits the HDR exit first. The deferred restore
lives on its own main-looper handler because core dispose clears the
shared player handler wholesale, and a new setVideoFrameRate cancels it
so a fresh session's switch is never clobbered. SDR teardown behavior is
unchanged.

Verified on a Shield Pro (mpv backend, 1917 DV P8/TrueHD, Jellyfin):
link recovery after stop went from ~8.5 s to ~0.8 s, with the SMPTE 2086
clear now committing before the mode set.

close #2172
2026-08-30 08:25:18 +02:00
edde746 6e2dda092d feat(music): make the background fill the mini player's only progress display
The mini player drew a thin scrubbable progress bar along its top edge on
top of the ambient background fill. Drop the scrub lane: the fill is the
card's only progress display, and precise seeking stays on the
now-playing screen's seek bar. Horizontal drags across the card's top
edge swipe-dismiss the card again.
2026-08-29 19:12:03 +02:00
edde746 45e8da1610 feat(music): restore the last music session paused on launch
Quitting the app lost the music session: on the next launch the queue,
current song, and playhead were gone. The session now persists per
profile (new MusicSessions Drift table, schema v22) through throttled
write-through during playback, and the next launch rehydrates it as a
parked-paused queue — no audio core and no source resolution until the
first play, which opens the track at the saved offset. Ending the
session (stop, swipe-dismiss, video claim) clears the snapshot, and a
new "Remember music session" playback setting (default on) gates the
restore.

close #2148
2026-08-29 19:11:58 +02:00
edde746 ad026cecc1 fix(player): keep hardware decoding after a transient VideoToolbox failure
Some HEVC files carry a frame Apple's hardware decoder refuses to decode
(kVTVideoDecoderBadDataErr on an otherwise conformant P-frame). mpv fell
back to software decoding for the rest of the file after the first error,
and 4K HEVC software decode drops frames every few minutes on Apple TV.

MPVKit b8b922ec carries an mpv vd_lavc patch that keeps the configured
fast fallback while probing but lets a hardware decoder that has already
delivered frames ride out a mid-stream error burst: the burst one
undecodable frame causes ends at the next keyframe by construction, so
fallback now requires errors to survive two keyframe intervals. Verified
against the reporter's sample: playback logs "Hardware decoding
recovered after 90 errored frames" and stays on VideoToolbox instead of
switching to software.

close #2167
2026-08-29 17:39:35 +02:00
edde746 ffc95d6b2c feat(tv): mark whole seasons watched from the show detail rail
On TV the show detail screen exposed season actions nowhere: marking a
season watched meant toggling every episode one by one, and the action
row's menu only covered the whole show.

Each season row on the TV detail rail now carries a full-size Options
card that opens the same season context menu the mobile season tabs
offer (mark watched/unwatched, download, delete, add to). The card
rests off-screen left of the first episode — the row is a
center-anchored scroll view and the card lives in the negative-offset
region before the anchor — so the resting layout and default focus are
unchanged and normal playback gains no extra steps; pressing LEFT from
the first episode slides it in. The trailing View All / retry / loading
slots become matching full-size cards instead of compact pills.

close #2156
2026-08-29 13:56:24 +02:00
edde746 a3cbf323b3 feat(livetv): search the guide for channels and programs
Live TV had no search: finding a channel or a program meant scrolling
the guide row by row, which is close to unusable on TV with a few
hundred IPTV channels, and the global search tab covers neither
channels nor EPG data.

A search action on the Live TV tab bar (shown on every tab, since the
D-pad route through the chips selects each tab it crosses) opens a
sheet that filters channels by name, call sign, and number, and the
next 24 hours of programs by title and series name, from one
fetchSchedule fan-out per open. Selecting a result jumps the guide to
it: the favorites filter is dropped when it would hide the target row,
the time window is re-anchored when the airing is outside the visible
six hours, and D-pad focus lands on the channel cell or program block.
While the sheet is open, companion-remote search queries land in it
instead of the global Search tab (same save/restore idiom as the video
player overrides). Jumps requested while a guide load is in flight are
stashed and replayed when the load commits.

Based on #1526 by @l3gitpanda, rebased over the guide virtualization
and source-group work, with the schedule fan-out moved onto
forEachLiveTvServer and the jump focus routed through the guide's
focus-snapshot publisher so the landed cell actually renders focused.

close #2168
2026-08-29 01:54:21 +02:00
edde746 2f2016fdbe feat(player): show the next episode's thumbnail in the play-next prompt
The end-of-episode autoplay prompt only named the next episode; Plex shows
a visual preview, which is what non-readers navigate by. The prompt card now
paints the episode's 16:9 still edge-to-edge behind the text under a bottom
gradient scrim, falling back to the text-only card when no thumbnail can be
served (no thumb, or no client and no downloaded artwork). Unwatched stills
respect the hide-spoilers blur, and the lookup only runs while the prompt is
visible to keep the playback-path build cheap.

close #2166
2026-08-28 18:43:26 +02:00
edde746 ff1064d35f fix(player): render HDR letterbox bars off the SDR window plane
HDR/Dolby Vision playback on Fire TV (and some Sony/Philips models)
shows gray letterbox bars instead of black on OLED panels: the bars
were the player container's window-plane background, and those device
compositors raise SDR graphics-plane black while the display is in
HDR/DV output mode. Black inside the video plane is unaffected (same
mechanism as ExoPlayer #8803 and Kodi #25300).

Add a fullscreen, buffer-less SurfaceView beneath the video surface in
the shared player container. Like any below-window SurfaceView it
registers its rect as a window transparent region, so the letterbox
area scans out as the SurfaceFlinger backdrop instead of window-plane
pixels. No buffer is ever posted: SurfaceFlinger skips buffer-less
layers, and drawing into one would put the bars back on an SDR layer.
Covers both the ExoPlayer and mpv backends.

close #2163
2026-08-28 11:24:37 +02:00
edde746 861111a191 feat(player): offer a 140% zoom preset
close #2158
2026-08-28 08:12:38 +02:00
edde746 463ad6b933 fix(i18n): translate settings and account preferences into all locales
94 strings added since the settings rework were empty placeholders in every
non-English locale, so those screens fell back to blanks. Fill all 1974
missing values across 21 locales, reuse each file's existing terminology,
and translate the few verbatim-English leftovers (es rankPopular, fr tags,
it dynamicRange, pl stop, nb tag). Regenerated Slang output.
2026-08-28 06:51:10 +02:00
edde746 1ee648f442 fix(plex): direct play a source the quality preset already covers
Any quality preset other than Original started a transcode even when the
file was already well under it: a 6.2 Mbps 1080p h264 source under the
"1080p 10 Mbps" preset came back from PMS as a 10.4 Mbps encode, so capping
quality on mobile data spent more data than leaving it uncapped, plus server
CPU nobody asked for (#2152).

A preset is a ceiling, so a version that fits under both its bitrate and its
resolution is now served by the file itself, on the same direct-play path
Original takes, and no transcode decision is sent at all. Height counts as
well as bitrate because the preset's label promises a resolution too, and a
device that asked for 1080p may not decode the 4K source a bitrate-only
comparison would hand it. An unreported bitrate or height leaves the
transcode standing.

The comparison has to happen in the client: PMS answers directPlay=1 with
"Direct play OK" whatever bitrate cap the request carries, measured on 1.43
with a 65 Mbps 4K source under a 10 Mbps cap, so its MDE cannot be asked to
enforce one. Plex Web reaches its own directPlay/directStream flags the same
way, folding the preset's bitrate into its client-side direct-play profile
first. Jellyfin already gets this right server-side.

close #2152
2026-08-27 20:37:56 +02:00
edde746 ed71b200b9 feat(music): make the mini player progress bar scrubbable
While music plays, the persistent mini player showed progress only as a
subtle background tint with no way to seek; the full seek bar sat one
navigation hop away on the now-playing screen (#2141).

The card's top edge now hosts a thin visible progress bar inside a taller
touch lane: tap seeks proportionally, dragging scrubs and commits one seek
on release, and hovering or dragging grows the bar and shows a thumb. Card
taps outside the lane still open the now-playing screen. Pointer-only by
design: the overlay never renders on TV and the keyboard focus chain leads
to the now-playing screen's focusable seek bar.

close #2141
2026-08-27 16:59:08 +02:00
edde746 a1d8ab5210 fix(plex): surface instant mix failures instead of silently doing nothing
Tapping Instant Mix on Plex often did nothing: the /playQueues POST used no
endpoint failover, createPlayQueue swallowed every error into null,
fetchInstantMix mapped that to an empty list, playInstantMix returned
silently on empty tracks, and the errors stream's only listener is the
now-playing screen, which is not mounted at tap time (#2141).

The play-queue POST now rides the shared endpoint failover (replaying it is
safe: an orphaned duplicate queue on the server is inert), createPlayQueue
and fetchInstantMix propagate typed errors, playInstantMix reports
started/empty/superseded and throws fetch failures, and the tap site shows
a translated snackbar for a failed or empty mix. A failed collection or
playlist queue launch now also reaches the real failure snackbar instead of
the misleading "no items" path.
2026-08-27 16:59:08 +02:00
edde746 a13febf67b fix(downloads): store music tracks as Music/{Artist}/{Album}/{NN - Title}
Downloaded tracks landed in the generic downloads/{serverId}/{ratingKey}/
layout as video.mp3 because the path builder only special-cases movies and
episodes, so a user-visible download folder showed opaque folders and
meaningless names (#2141).

Tracks now get Music/{Artist}/{Album}/{NN - Title}.{ext} in both file and
SAF modes, grouped by sanitized album-artist with Unknown Artist/Album
fallbacks and a disc prefix on multi-disc albums. Existing downloads keep
working: playback and deletion resolve the stored path per record, and the
empty-parent cleanup keeps shared album folders while a sibling track
remains.
2026-08-27 16:58:52 +02:00
edde746 a97be5823f feat(player): add play-next countdown, gesture toggles, and deinterlacing
Users could not adjust the five-second Play Next timer, switch off the
edge-swipe/pinch gestures they trigger by accident, deinterlace DVD-era
content, or keep always-on-top across episodes.

Play Next Countdown (0-30 s) joins Auto-Play & Skip; zero skips the
prompt and starts the next episode immediately, while the transient-
retry prompt keeps its fixed five-second spacing. Brightness swipe,
volume swipe, and pinch-to-zoom each get a switch in a mobile-only
Gestures group; a disabled edge also stops stealing the content-strip
drag. A Deinterlacing toggle (mpv-only — ExoPlayer has no filter chain)
sets mpv's deinterlace=auto at player init. The player's always-on-top
toggle now persists via a pref that is re-applied when the next player
opens, including autoplay episode transitions; the window flag is still
dropped when the player closes.

Also resolves the reorg TODO markers per triage: hold-to-speed lock
(#1046) will be gesture behavior rather than a setting, metadata
language (#1668) and delete re-authentication (#1924) are rejected, and
always-on-top is player-persisted state, not a Window setting (#1386).

close #1827
close #1810
close #2149
close #931
2026-08-27 16:42:38 +02:00
edde746 c9de78697b feat(settings): reorganize settings into general, appearance, and playback homes
Settings were grouped by screen history rather than by what they govern:
the profile-selection prompt, startup section, force-TV mode, and app
language sat under Appearance, Discord Rich Presence and the companion
remote host hid inside Playback > Behavior, and maximum volume lived
under Seek & Timing.

Introduce a General screen (language, startup, desktop window), regroup
Appearance into display/library/home/navigation/Live TV, split the
Playback monolith into engine, video & display, audio, quality,
subtitles, seek, auto-play & skip, and behavior groups, surface
auto-play next episode in settings, rename the Keyboard Shortcuts
section to Controls (now also hosting the companion remote host), move
Discord Rich Presence to Services > Integrations, and move the
performance-overlay switch to Advanced. Pref keys are unchanged, so
stored values, export/import, and reset behavior are unaffected.

TODO markers record the agreed homes for upcoming settings (#1998,
#2064, #1641, #1769, #2149, #1827, #2138, #1810, #1046, #1924, #1413,
#1668, #1386, #1795).
2026-08-27 16:02:10 +02:00
edde746 9ac8002b53 feat(player): apply separate default quality on cellular connections
On mobile data the single default streaming quality either burns
through the user's data plan or permanently caps quality at home.
Playback start now applies a dedicated cellular default when the
device is on a cellular-only connection, read from the app's existing
single connectivity subscription. The new setting defaults to
following the general default, ships in Settings > Playback for
phones/tablets only, and explicit per-play quality picks still win.

'Metered connection' had been spelled out independently in the offline
provider, the download WiFi-only gate and the sync-rule cooldown.
Rather than add a fourth copy, all four now share
ConnectivityLinkType. Network *presence* is deliberately left alone:
its callers disagree on whether an empty snapshot counts as
connected, so folding that in would change behavior.

showSelectionDialog now returns the picked DialogOption instead of its
raw value so the tile's null-valued 'Same as Default Quality' option
stays distinguishable from dismissing the dialog.

close #2147
2026-08-27 14:14:01 +02:00
edde746 942065e39d fix(plex): honor disabled credits detection when synthesizing chapter markers
When a Plex admin disables credits detection for a show or movie, PMS
strips detected credits markers from its responses, but Plezy's
chapter-title fallback resurrected the skip action from a credits-named
chapter — surfacing a "Next Episode" button (and auto-skip) during
credits the admin asked to leave alone.

Drop credits markers when the owning show/movie carries an explicit
enableCreditsMarkerGeneration=0. Movies read the attribute off the item
already fetched; episodes resolve the grandparent show through the
shared cache-first metadata row, so the lookup normally costs no extra
request and only runs when a credits marker is actually present. Absent
attribute (servers without the feature) and -1 (library default) keep
the fallback intact; lookup failures fail open. The offline cached
extras path applies the same rule from cached rows.

close #2137
2026-08-27 10:16:05 +02:00
edde746 94565b4ee0 feat(library): make grid spacing between posters configurable
The library grid packs posters edge to edge. The grid delegate's cross-
and main-axis spacing were hardcoded to zero, so the only visible gap
came from each card's own 3px padding, and Library Density changed
poster size rather than the space between posters. The result reads
denser than Plex, Jellyfin, or any commercial client, both horizontally
between posters and vertically between a poster and its title.

Add a Grid Spacing setting - Tight, Normal, Spacious (0/6/12px) - below
Library Density. Tight is the default and reproduces the current layout
exactly, so no existing grid moves on update.

The gutter lands in MediaGridDelegate.spacingFor(), already the single
funnel for non-full-bleed grid spacing, so every grid surface picks it
up: library browse, collections, playlists, downloads, and the detail
screens. Square music grids keep their 8px floor. The pref is watched
once in MediaCardSliverLayout - the only MediaGridGeometry.resolve()
call site - so grids re-layout live instead of on restart.

Grid cells grow the poster-to-title gap to 2/4/6px as well; the
Expanded poster absorbs the delta, so the cell keeps its aspect ratio.
Fixed-height hub-row cards keep the historical 2px because their text
band cannot absorb more, and hub-row cell packing opts out of the
setting entirely so shelves stay byte-identical across it.

Full-bleed TV grids are untouched - they already carry an 8-18px
scale-derived gutter - and are opt-in behind tvFullCardLayout, so the
default TV library grid follows the setting like every other surface.

Verified on macOS across all three steps: gutters and title gap grow,
grids re-pack live, and the segmented control reflects and writes the
value.

close #1597
close #2083
2026-08-27 09:21:48 +02:00
edde746 0c5308cfa1 refactor(networking): drop Cronet and NSURLSession for the tuned dart:io client
Plezy carried three native HTTP clients on the assumption that they beat
Dart's own. Benchmarked against real Plex and Jellyfin servers on macOS,
Windows, Linux and three Android devices, two of them do not.

Cronet loses on every request shape the app issues: 8.1 vs 83.8 MiB/s on a LAN
body read, 84 vs 150 req/s on an artwork fan-out, 14.3 vs 10.7 ms on
sequential API calls. It also fails a 60-way fan-out outright with
net::ERR_CACHE_WRITE_FAILURE under the 2 MiB memory cache we configured, and
cost 70-705 ms of CronetEngine.build on first use. Paying that build off the
critical path is the only reason AndroidPlatformHttpClient,
warmUpPlatformHttpClient and the per-request delegate swap existed; all three
go away with it.

CupertinoClient had no measured advantage either, losing the TLS fan-out 44 vs
60 req/s, and no reported issue ever justified it. tvOS already shipped the
dart:io client, so Apple platforms now agree with it.

WinHttpClient stays. WINHTTP_OPTION_IPV6_FAST_FALLBACK (#1128) has no dart:io
equivalent, and it brings the system proxy and the Schannel trust store.

The pool tuning becomes unconditional. It was opt-in behind usePlexApiClient
so generic tracker and auth clients stayed disposable, but every dart:io client
has carried connectionTimeout and forceCloseOnDrainTimeout since #1972, so
tuned and untuned already share shutdown semantics and the flag only cost
throughput: 12 connections per host with a 90s idle measured ~4x the dart:io
default on a 60-way fan-out on Linux and ~2x on Android.

media3-datasource-cronet and cronet-embedded stay. They back ExoPlayer's
CronetDataSource independently of package:cronet_http.

Refs #2140.
2026-08-27 09:21:48 +02:00
edde746 a18ecb5c4a test(watch-together): stop the relay setup tests racing a real handshake
Two relay setup tests failed on CI while the same commit had passed a
few minutes earlier. The harness rewrote every 10-second timer in the
zone to 10 ms, which also compressed the connect deadline of the
recovery that runs once setup retries are exhausted. On a loaded runner
that deadline expired after the relay had upgraded the socket but
before the client sent its first frame, so the recorded connection
carried no messages and shifted the expected sequence; when all three
recovery attempts were starved this way, the recovery never announced
at all.

A reply that never comes is safe to compress, a real loopback handshake
is not, and one zone hook matching on duration cannot tell them apart.
The service now exposes the setup acknowledgement, the release
handshake, and the release acknowledgement as separate budgets, each
still 10 seconds in production, and every test sets only the one it
means to expire. The blunt helper is gone; what remains collapses the
250 ms and 500 ms retry delays, which are waits rather than races.

Verified by reproducing both failure modes under CPU saturation, about
one run in ten, then 30 consecutive clean runs of the file under the
same load and a full suite at 6332 passing.
2026-08-27 09:21:48 +02:00
edde746 f7060478d1 fix(plex): offer User Rating and Plays sorts in home-video libraries
Browsing a Plex home-video / "Other Videos" library under "Show all"
offered no way to sort by your own rating. The sorts Plex advertises for
those sections only cover critic and audience ratings, which unmatched
files never carry, so nothing rating-based was usable.

Plex honors `sort=userRating:desc` and `sort=viewCount:desc` on those
sections without advertising them, and the client already appends both
for movie and TV libraries. Home-video sections are `type="movie"` on the
wire and only differ by `subtype="clip"`, which the library mapper folds
into MediaKind.clip for folder-first grouping and wide cards, so they
were failing the movie/show allow-list. Add clip to it.
2026-08-26 20:28:19 +02:00
edde746 3f03262833 fix(player): recover playback after a lost native-channel handoff
A player whose predecessor had not released the shared native channel
within three seconds skipped its own native dispose and chained its
release onto that predecessor. If the predecessor's teardown never
completed - one hung 4K session was enough - every later player waited
on the chain, failed to initialize, and playback stayed broken with
"Playback could not be started" until the app was killed.

Initialize and dispose now carry the creating instance's token. The
Android plugins remember which token created the current core and
acknowledge a dispose from any other token without touching the core,
so a dispose that lost the ownership race is provably stale and safe to
send. With that guard, a timed-out ownership wait force-disposes
instead of skipping, settles its release unchained, and frees the
channel for the next session; a dispose watchdog answers Dart even if
a native teardown hangs, leaking that one core instead of wedging all
future playback. Commands wait eight seconds (was three) so a slow but
healthy teardown delays the next session instead of failing it.

Verified on a Shield Pro: 38 back-to-back session races at 0.8-2.2s
gaps with zero failures and balanced teardowns, and a deep-link-over-
playback collision whose stale dispose is ignored, after which Back
returns to the still-playing session and Retry starts the new one.
Apple and desktop handlers ignore the token and keep the historical
skip semantics until they gain the guard.
2026-08-26 18:59:28 +02:00
edde746 3c6d398598 feat(player): default Android playback to the mpv backend
With video on the MediaCodec plane, real bitstream passthrough, media3
demuxing, and the GL fallback ladder in place, mpv measures at parity
or better than ExoPlayer on every device class tested (Tegra, Mali,
Amlogic armv7, Adreno; API 28-36; phone, TV, and foldable), including
the two historical Android mpv failure modes: low-end TV playback and
Hi10P.

Installs that never chose a backend move to mpv; a stored ExoPlayer
choice is preserved, and the Player Backend toggle stays user-visible
as the escape hatch. The automatic ExoPlayer-to-mpv runtime fallback
is unchanged.
2026-08-26 18:59:28 +02:00
edde746 8f893e7a67 fix(player): demux Android direct play with media3 again
The in-app FFmpeg container demuxer classified every DTS variant as
plain DTS (profile-blind MIME mapping at the demux boundary), so
DTS-HD MA lost its lossless identity downstream, and it ignored
container display-aspect-ratio overrides. media3's extractors get both
right.

Delete the FFmpeg demuxer (JNI, extractor, and its setting); ExoPlayer
direct play demuxes with media3's DefaultExtractorsFactory again, and
mpv - the Android default - reads container display dimensions itself.

close #2124
close #2115
2026-08-26 18:59:28 +02:00
edde746 465da3e82a feat(audio): bitstream passthrough by default on Android TV
mpv's ao_audiotrack hardcoded stereo IEC61937 frames, which cannot
carry TrueHD MAT or DTS-HD MA 8-channel bursts, and the app fed
audio-spdif an empty list in self-defense because mpv
force-passthroughs every codec named there with no decode fallback.
The mpv path therefore decoded everything, and passthrough had to be
enabled by hand on every install.

The Kotlin audio route probe (AudioOutputPolicy) now feeds mpv a
per-route audio-spdif list, the fork's multichannel IEC61937 patch
(pinned libmpv v1.1.3) provides the burst geometry, and E-AC3, TrueHD
and DTS-HD MA bitstream on the mpv path. Android TV installs with no
stored preference default to passthrough on, on both backends; an
explicit user choice is preserved.

Verified by route probing and mpv logs on Shield and Box R; no AVR was
in the loop, so receiver-side decode is unconfirmed.
2026-08-26 18:59:28 +02:00
edde746 04615ce2fc feat(player): HDR output for software-decoded video on Android
Software-decoded HDR always collapsed to SDR on Android: the EGL
window surface carried no colorspace, so PQ/HLG content that fell back
to software decode rendered through an SDR surface, and Android was
excluded from the hdr-enabled path outright.

The GL vo now requests a BT2020-PQ window surface (fork patch, pinned
libmpv v1.1.3) and Android joins the hdr-enabled path, so software
decode keeps HDR scanout and tone mapping happens only when the
display genuinely cannot show HDR.
2026-08-26 18:59:28 +02:00
edde746 a1c98b9d27 feat(player): render Android mpv video on the MediaCodec plane
Android mpv drew every frame through vo=gpu: an ImageReader/GLES copy
pinned to 8-bit RGB0 with a 100 ms timed wait per frame, which
truncated 10-bit and HDR output, raced acquireLatestImage, and
performed badly on low-end TVs.

Video now scans out on a SurfaceFlinger video plane through the fork's
vo=mediacodec: decoder buffers are queued to the surface at their
target PTS (av_mediacodec_release_buffer_at_time), so 10-bit and HDR
dataspaces reach the display untouched and frame pacing no longer
depends on GL vsync. Subtitles and OSD render on a sibling transparent
surface presented for the same PTS, frame-locked by construction.

The plane takes decoder buffers only, so software-decoded video stays
on a GL vo, with a chain-failure watchdog re-initializing the output
when the plane refuses a stream mid-session. The GL fallback is vo=gpu
- gpu-next breaks the Tegra GLES linker (#2010) - and GpuVoPolicy
selects gpu-next only when Dolby Vision RPU reshaping needs
libplacebo. AV1 film grain is applied in the decoder
(vd-lavc-film-grain=cpu) because the GL raster grain fallback is not
available on this path.

Pins libmpv-android v1.1.3, which carries the fork patches this path
rides on: the vo itself, BT2020-PQ EGL window surfaces, GLES direct
rendering treated as slow (H.264 Hi10P software decode on Tegra went
from 0.3x realtime with wrong colors to 1.0x with correct 10-bit
output), and multichannel IEC61937 for ao_audiotrack.

Verified on Shield Pro (Tegra/GLES), Pixel 7 (Mali), Box R 4K Plus
(Amlogic armv7), and Galaxy Z Fold3 (Adreno): 520 play/teardown cycles
and 3 hours of continuous 4K HDR playback; HDR engagement confirmed
via SurfaceFlinger dataspace BT2020_ITU_PQ.
2026-08-26 18:59:19 +02:00
edde746 85bf948004 fix(macos): keep text and icons legible by pinning the Skia renderer
On macOS 2.17.1 the UI started flashing garbage: text and Material icon
glyphs rendered as striped noise, and a keypress could flash a whole
glyph-atlas texture across the window. 2.17.0 on the same machine was fine.

2.17.1 moved to Flutter 3.47.1, which made Impeller the default macOS
renderer. Plezy is unusually exposed to that switch: MainFlutterWindow
clears the Flutter view to transparent so the mpv CAMetalLayer behind it
stays visible, so whatever Impeller leaves in the presented drawable is
blended onto the screen instead of being hidden under an opaque frame.

Opt out through the Info.plist switch the macOS embedder reads from the
main bundle at launch, so it applies to release builds too. That restores
the renderer every release up to 2.17.0 shipped; keep it until the engine
fixes for the 3.47 macOS Impeller regressions reach stable.

close #2132
2026-08-26 17:16:21 +02:00
edde746 c564635ad9 fix(jellyfin): advertise video codecs from a native hardware-decode probe
A device with no hardware HEVC decoder could still be handed an HEVC
transcode: the device profile advertised a fixed codec list that assumed
every device decodes everything. Prepending AV1 to reach the AV1 encoders
issue #2131 asks for would have made that worse - an Apple TV 4K and every
iPhone before the A17 Pro have no AV1 decoder at all.

Probe the platform instead. Android enumerates MediaCodecList for a
hardware decoder and iOS/tvOS ask VideoToolbox, both feeding one latched
VideoDecodeCapabilities that the device profile reads when it builds its
codec lists. Desktop deliberately answers nothing: a pre-Kaby-Lake Mac has
no hardware HEVC decoder and an M1 no AV1 one, yet both software-decode in
real time, so narrowing there would force transcodes for nothing. An
unanswered or failed probe advertises everything, so the list never
narrows on missing data.

The transcode target becomes av1,hevc,h264 filtered by the probe. Leading
with AV1 is safe because the server rotates codecs its admin has not
enabled ("Allow encoding in HEVC/AV1 format", both off by default) to the
back before picking one, so it costs nothing on a server that will not
emit AV1.

Audio now accepts everything the path can carry. The direct-play profile
drops its AudioCodec list entirely - an omitted list means "any codec" to
Jellyfin - so an audio stream can no longer be what blocks direct play.
The transcode target lists every codec Jellyfin can put in an fMP4
segment, so a video-only transcode copies DTS or TrueHD instead of
re-encoding it. Two limits bound that string: the server validates it
against ^[a-zA-Z0-9\-\._,|]{0,40}$ when it echoes the list into the
transcode URL, so alac does not fit and * is not a wildcard; and omitting
the key is not "accept everything" here the way it is for direct play,
because the server substitutes the source codec and then ships no audio at
all for a source fMP4 cannot carry.

close #2131
2026-08-26 16:53:14 +02:00
edde746 fce4193f7e fix(jellyfin): negotiate video transcodes as fMP4 HLS instead of MPEG-TS
MPEG-TS segments cannot carry AV1, so a Jellyfin server with an AV1
hardware encoder (Intel Arc and similar) could never pick AV1 as the
transcode output no matter what the codec list advertised. Switch the
video TranscodingProfile container from ts to mp4, matching
jellyfin-web's fMP4 HLS behavior; mpv consumes fMP4 HLS on every
platform and the Plex VOD target has shipped it since issue #1859.

close #2131
2026-08-26 14:41:37 +02:00
edde746 628fd21ec3 fix(windows): keep the video visible behind the OSD by pinning the Skia renderer
After 2.17.1 the video went fully black on Windows for as long as the player
UI was on screen and came back the instant it faded out. Audio was never
affected, mpv.conf made no difference, and 2.17.0 on the same machine and
driver was fine.

2.17.1 moved to Flutter 3.47.1, which made Impeller the default Windows
renderer. Plezy is unusually exposed to that switch: the patched engine
presents the UI on a topmost DirectComposition visual that DWM blends over the
mpv video child, so the video is only as visible as the presented frame's
per-pixel alpha says it is. When Impeller's GLES backend gets that alpha wrong,
the OSD's full-screen scrim presents opaque and hides the video outright;
unmounting the controls presents a transparent frame again, which is why the
picture tracked the UI exactly.

Opt out through the embedder Impeller switch that 3.47 added. Unlike
FLUTTER_ENGINE_SWITCHES it applies to release builds, and it restores the
renderer every release up to 2.17.0 shipped without touching the engine patches.

close #2127
2026-08-26 03:15:59 +02:00
edde746 d2c4e8345f fix(player): retry transient stream errors instead of falling back to MPV
During Android direct play, a single dropped connection or network blip
mid-stream kicked an otherwise healthy ExoPlayer session over to the MPV
fallback - on a Shield this showed as random backend switches minutes
into an episode (log pu4ad: "ffmpeg demuxer read failed: -5" with
contentIsMalformed=true, while MPV reopened the same URL fine).

Three defects lined up behind it, all in the 2.17.0 ffmpeg demux path:

- The AVIO read callback turned the input proxy's stored IOException
  (its -1 return) into a bare AVERROR(EIO) without marking javaError, so
  the extractor classified the failure as a malformed container. media3
  never retries a ParserException, so the designed ERR_JAVA ->
  IOException -> load-error-retry path was unreachable. The callback now
  latches javaError for a negative read and fails fast on every later
  read in the same native call, so matroska resync cannot clobber the
  stored message or skip past the failed range.
- A failed refill latches AVIOContext error/eof_reached and avio never
  drives the callback again, so even a correctly classified retry would
  re-fail on the stale error. nativeReadPacket and nativeSeek now clear
  the latch on entry; a genuine end of file (error == 0) is left alone.
- FfmpegRandomAccessSource kept a handle whose read had thrown, and its
  position matched the retried request, so the retry was handed the same
  dead handle. A failed read now drops the handle and the retry reopens.

A transient failure now surfaces as a retryable IOException, media3
reopens the source, and sample delivery resumes gaplessly; persistent
failures still exhaust the retry policy and reach the MPV fallback as
before.

close #2113
2026-08-25 11:15:43 +02:00
edde746andGitHub ae555ed5d7 feat(settings): account preferences, stored on the server instead of the device (#2112)
Preferences that belong to the media-server account had nowhere to live in Plezy. Jellyfin keeps a user's audio/subtitle language, subtitle mode and several library options in `UserConfiguration`; plex.tv keeps the same language choices plus watched indicators and review visibility on the account. Plezy read four of those fields for auto-track selection and could never show or change any of them.

Adds an Account preferences section under Connections. One account edits in place; several show a picker first, scoped to the active profile's own connections so a managed Plex Home user never edits the owner's record. Every row is gated on what the backend can actually store.

It also moves "rewatching in Next Up" onto the account. There is no `UserConfiguration` field for it, but the per-user `DisplayPreferences` store is keyed `(userId, displayPreferencesId, client)` with no device component, so the switch goes there and follows the account.

`AccountRef` keys by account, not `clientScopeId`: MediaBrowser is `{machineId}/{userId}`, Plex is (account, Home user). Writes are patch-shaped because both backends replace whole objects — Jellyfin's `POST /Users/{id}/Configuration` and its `DisplayPreferences` row both reset omitted fields, so each write re-reads, merges only the patched keys, and posts back. Plex takes its changes as query parameters with an empty body, and its `experience` blob and the PMS `/accounts/1` mirror are deliberately untouched.

`AccountPreferencesController` owns a single repository above the profile session, so changing a language in settings reaches the next playback without a restart. Emby is gated out of rewatching through `MediaBrowserDialect.supportsNextUpRewatching`.

close #1910
2026-08-25 08:42:48 +02:00
edde746 779c532fc7 chore(i18n): regenerate german strings after the premiereDate update
de.i18n.json changed in #2106 without a codegen run, so scripts/codegen.sh
--check failed on strings_de.g.dart. No other locale is affected.
2026-08-25 03:11:11 +02:00
edde746 595cd9fe24 feat(seerr): sign in with quick connect and accept schemeless urls
Linking a Seerr instance had two rough edges, both on the Jellyfin path.
The URL step prepended https:// to anything without a scheme, so a
plain-HTTP instance on the LAN — 192.168.1.5:5055, or a bare host on
Seerr's default port — failed with "could not reach" unless the user knew
to type the scheme. And the only way to sign in with a Jellyfin account
was typing a username and password, which on a TV remote is misery.

Schemeless input now expands into candidates that are probed together,
but TLS wins by construction: a plaintext success is held while any https
candidate is still in flight and is accepted only once they have all
failed. The sign-in that follows posts a password to whichever URL wins
here, so a slow-but-working https endpoint has to beat a fast plaintext
one, and the wait is already bounded by the probe timeout. When nothing
answers, the failure that reached a server outranks a transport error, so
"this instance has not completed first-run setup" is not masked by "could
not reach https://..." from a candidate the user never typed.

Quick Connect goes through Seerr's own proxy routes (3.4+): initiate,
then check polled at Seerr's own 2s cadence through the shared
pollWithBackoff — a 404 mid-poll means the secret is gone and is terminal
— then authenticate, which mints the session cookie through the existing
sign-in path. The secret rides in a query string, so it is registered for
log redaction the way the Jellyfin flow registers its own. An instance
that predates the routes 404s the initiate and is told apart from a
generic rejection.

SeerrAuthMethod.quickConnect deliberately stores no secret: silent
re-auth is impossible by construction, so an expired cookie lands in the
existing "no stored credentials" arm, unlinks the session, and the
connect flow asks for a fresh code. The affordance is limited to the
Jellyfin credential form, since Seerr rejects Quick Connect for Emby, and
is not auto-started on TV the way the MediaBrowser form is:
/settings/public exposes no "Quick Connect enabled" flag to gate that on.

The waiting panel and its attempt/cancel bookkeeping moved out of
AddJellyfinScreen into QuickConnectCodePanel and QuickConnectFlowMixin so
both screens share one implementation, and the post-frame focus helper
both forms use moved to AsyncFormStateMixin.
2026-08-25 03:07:14 +02:00
edde746 75279ca196 refactor(discovery): expand server url candidates through one shared helper
Turning what a user typed into URLs worth probing lived inside
JellyfinEndpointDiscovery, next to a private copy of the scheme-detection
regex that url_utils already had. A second backend needs the same rules
with a different default port, and the existing copy carried two latent
defects: input with no host came back unchanged, so a candidate that can
never resolve went to the prober, and a query string survived into the
base URL that request paths are appended to.

expandBaseUrlCandidates takes the ordered guesses a backend wants — a
scheme plus the port to try when the user typed none — and owns the rest.
A typed port beats a guessed one, so host:8096 collapses to one candidate
per scheme and the same guess list covers both bare hosts and hosts with
ports. Candidates are built field by field rather than through
Uri.replace, whose null query means "keep mine", so query and fragment
are dropped for real. Blank input and input with no host expand to
nothing.

JellyfinEndpointDiscovery passes its existing order, so its probe and
persistence behaviour is unchanged, and hasUrlScheme replaces the regex
copy.
2026-08-25 03:07:14 +02:00
BockiandGitHub bd2d19a1c1 Update de.i18n.json (#2106) 2026-08-25 01:21:35 +02:00
github-actions[bot] 66c6b137a5 chore: update cask to 2.17.1 2026-08-24 23:18:29 +00:00
edde746 805632efc1 chore: bump version to 2.17.1 2026-08-25 00:43:37 +02:00
edde746 fe9d9d5e7d test(player): cover saf reads and the demuxer's jni byte source under r8
The random-access demuxer added two surfaces with no gate behind them. A
download stored through SAF arrives as content://, where an index read has to
open a second descriptor while the loader holds one, and the AVIO callbacks
are reached only by name from C++, so R8 may rename them while every debug
check passes -- the shape #1703 shipped with.

FfmpegExtractorSeekTest now runs the cued fixture over a content:// URI
served by a test-only provider, and FfmpegDemuxerReachabilityTest drives
nativeOpen plus a seek through a media3-free byte source so it can run
minified. Wired into the existing R8 reachability target; verified it fails
when the FfmpegDemuxerJni$Input keep rule is removed.
2026-08-24 23:02:23 +02:00
edde746 1995fc6b75 refactor(player): demux through random-access IO instead of unwinding libavformat
Resuming or seeking a large MKV on the Android FFmpeg demux path buffered
for minutes with no error and no fallback. The cause was the seam, not the
container: libavformat's demuxers own their seeking — end-of-file index,
back to the header, binary search — while media3's Extractor is forward
only, so every backward jump had to abort the in-flight libavformat call
with a synthetic IO error and replay the whole open. matroskadec attempts
its deferred Cues parse exactly once per context, so that abort burned the
index for the session and every later seek fell back to libavformat's
linear generic scan, reading the file up to the target.

That protocol had produced a bug of this shape repeatedly (a NULL deref
resuming find_stream_info across an abort, a use-after-free on fallback
teardown, matroskadec resync skipping the keyframe cluster) and it was held
together by four empirical retry budgets whose exhaustion degraded silently
— which is why #2096 looked like an unbounded spinner instead of an error.

Give libavformat what it requires instead. FfmpegRandomAccessSource serves
any absolute position from a second DataSource built by the same factory
media3 uses, cloning the DataSpec media3 opened for the item so Cronet,
download caches, SAF and per-item request headers still apply. Reads that
the loader's ExtractorInput can serve still go through it, so sample
delivery keeps feeding media3's byte accounting, back-pressure and
load-error policy; the extractor nudges the loader to follow the demuxer
with one RESULT_SEEK, which is an optimization that can never stall
playback because the read succeeds either way.

Deleted with the protocol: the synthetic AVERROR_NEED_SEEK unwind, the
24 MB replay cache, the Cues priming seek and its read guard, the sticky
AVIO error clearing, and all four budgets (seekAttempts, stickyRecovers,
MAX_RECONCILES, MAX_OPEN_LOADER_ROUND_TRIPS). Opens run straight through,
seeks are one avformat_seek_file on the loader thread, and an IO failure is
an IOException that reaches media3's retry policy instead of a silent
degradation. The demuxer is now the only component that resolves seeks, so
the extractor's sample-derived seek index is gone too.

Verified on a SEI Robotics Android TV box against Jellyfin: the DV P8.1
28.4 GB MKV resume that started at a 4m28s spinner opens in 1.1 s and
resolves its seek in one call, a mid-playback scrub resolves in 1 ms from
the in-memory index, a 2 GB MKV resume opens in 0.5 s, fresh playback needs
no index read at all, and MP4/AVI still open and deliver. New coverage:
FfmpegRandomAccessSourceTest pins the read/reopen contract, and
FfmpegExtractorSeekTest asserts on-device that a cued seek lands on target
without walking the file and that a cueless file still reaches it.

close #2096
2026-08-24 23:02:23 +02:00
edde746 defb0f4983 fix(player): publish real dolby vision profiles from the ffmpeg demuxer
The demuxer JNI read AV_PKT_DATA_DOVI_CONF as the bit-packed dvcC/dvvC box
layout, but libavformat surfaces the unpacked
AVDOVIDecoderConfigurationRecord — one byte per field. Profile 8 level 6
streams therefore surfaced as "dvh1.04.00" plain HEVC (visible in #2096's
logs): DoviConvertingTrackOutput's profile 7 conversion could never engage,
and profile 8 was routed to the plain HEVC decoder instead of the Dolby
Vision decoder the media3 demux path selects.

Read the struct fields verbatim and mirror media3's DolbyVisionConfig in
Kotlin: recognized profiles publish video/dolby-vision with the
dvhe/dvav/dav1 RFC 6381 codecs string, unrecognized profiles keep the base
codec's format. A DV P8.1 MKV on the FFmpeg path now reports "dvhe.08.10",
mime video/dolby-vision, and takes the DV P8 passthrough path exactly like
the media3 demuxer.
2026-08-24 23:02:23 +02:00
edde746 371eab8637 fix(downloads): stream from the server when a downloaded SAF copy is unreachable
Playing a downloaded movie or episode failed outright once the storage
holding it was gone — an SD card ejected, a USB volume unplugged, the
files deleted from outside the app (issue #2101). The item still played
fine from the server, but Plezy never tried: it committed to the local
copy and surfaced mpv's open failure.

Resolution already refused a missing local copy and let the caller
stream instead, but only for filesystem paths. SAF `content://` URIs
skipped the check on the premise that they are always playable as
written. They are not: an unmounted volume answers
`FileNotFoundException: No root for <volume>`, and a revoked grant is
just as dead, while the row still reads `completed`.

Probe the stored document through SAF before preferring it, so an
unreachable copy resolves to nothing and every consumer degrades the
way a missing file already did — the player streams, and both
external-player entry points launch against the server instead of a
dead URI.

close #2101
2026-08-24 19:27:20 +02:00
edde746 f357be4077 fix(tvos): match Siri Remote navigation to measured native focus-engine physics
Siri Remote navigation felt sluggish and then over-sensitive next to
native tvOS apps (issue #2006): swipes were priced at a fixed travel
per step, a single flick could glide into a second focus step, hard
lifts coasted several extrapolated steps, and rail scrolls snapped in
65-250ms where native glides.

Retuned the whole path against two hardware instrumentation passes on
an Apple TV 4K: committed-move telemetry through an experimental
UIFocusItem bridge (branch feat/tvos-native-focus-bridge), then a
dedicated native probe app logging every touch sample, pan velocity,
engine hint, focus step, and scroll tick across 101 swipe sessions on
160/230/300pt tiles. What the data showed, now encoded:

- step pricing follows geometry: one step costs the focused item's
  extent along the swipe axis plus ~155pt (measured 314/391/410pt on
  160/230/300pt tiles), not a fixed distance. Thresholds derive per
  axis from the primary focus rect, normalized so a wide-flat control
  steps vertically once the finger covers its height. Locked-focus
  rows (hub rows, the TV browse rail) vend their selected card's rect
  through the new LockedFocusRowNode so the row-wide focus node's
  screen-sized rect never prices the step. Scopes, the player's
  catch-all surfaces, and unbuilt cards fall back to a fixed 400pt.
- a lift never coasts more than one step: sessions with lift
  velocities up to ~11400pt/s never produced a second coast step. The
  glide is gated on a sustained drag (two consecutive same-direction
  steps), cancelled by reversal pivots and new touches, so a discrete
  flick moves exactly one item.
- the native 'inertia' feel is the scroll animation, not focus
  physics: the engine's scrollable containers settle over ~450-900ms
  of ease-out. TV rail and hub-row navigation scrolls now retarget a
  500ms easeOutCubic animation per step, so drags and hold-repeats
  chain into one continuous glide that catches up on release.
2026-08-24 13:41:44 +02:00
edde746 0fdc22b4fa fix(linux): make the render-executor completion handoff visible to TSan
GLib's GMutex is a raw futex, so ThreadSanitizer cannot see the
happens-before edge g_main_context_invoke_full provides and fails the
thread lane on plane_render_executor_test. Publish the completion
payload through an explicit release/acquire pair, and suppress the
remaining reports that originate wholly inside libglib's own source
bookkeeping. Races on repository memory still fail the run; verified
0/100 failing TSan runs in an ubuntu:24.04 container (20/20 before).
2026-08-24 12:42:23 +02:00
edde746 fb85bb279a style(android): apply ktlint to the ffmpeg audio csd code
The Native Formatting lane rejects the unbraced multiline if/else in
FfmpegExtractor and the wrapped expression body in FfmpegAudioCsdTest.
2026-08-24 12:42:23 +02:00
edde746andGitHub cc4eff98c0 build: migrate to Flutter 3.47.1 (#2092) 2026-08-24 12:32:05 +02:00
edde746 0fbf2e205c fix(profiles): skip the resume profile prompt during a live companion-remote session
With "Ask for profile on app open" enabled, every screen-off or app
switch on a phone re-pushed the profile picker and PIN on resume, burying
a companion-remote session that survives backgrounding since the
reconnect cycle was made lifecycle-aware. Suppress the resume prompt
while a companion session is live, mirroring the active-playback
exemption — the session already belongs to the profile that started it.

close #2087
2026-08-24 10:06:00 +02:00
edde746 a9f2c1ff99 fix(player): shape ffmpeg demuxer audio extradata into media3 csd layouts
VP9/WebM-origin files stalled at 0ms in ExoPlayer on 2.17.0 and fell
back to MPV after ~40s. The ffmpeg demuxer published raw extradata as
the only initializationData entry; Android's Opus decoders consume
their first three input buffers as OpusHead/codec-delay/seek-pre-roll,
so they ate the first two real packets as configuration and silently
discarded every decoded sample, pinning the audio-driven clock at 0.
Vorbis (unsplit Xiph lacing) and FLAC (unmarked STREAMINFO) had the
same shape divergence.

Shape audio extradata into the layouts media3's own extractors emit
before publishing the track format.

close #2088
2026-08-24 09:36:10 +02:00
github-actions[bot] fe4e53f912 chore: update cask to 2.17.0 2026-08-24 05:28:32 +00:00
597 changed files with 48697 additions and 10230 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ description: >-
subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry
in the stable manifest) and `channel: master` would clone master HEAD, whose
engine is not the patched revision install-patched-engine.ps1 asserts
(4c525dac). This file is the only place that pin lives: the tag is fetched so
(5d531788). This file is the only place that pin lives: the tag is fetched so
the SDK reports its own version, then verified against the immutable commit
and against the version Flutter itself reports, so a moved tag fails the job
instead of quietly changing SDKs.
@@ -16,8 +16,8 @@ runs:
- name: Clone Flutter from its immutable commit
shell: pwsh
run: |
$version = "3.44.0"
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
$version = "3.47.1"
$expectedCommit = "6655482ec06e547f90abf8ae7590466f4415978d"
$root = "$env:RUNNER_TEMP\flutter"
git init $root
git -C $root remote add origin https://github.com/flutter/flutter.git
+25 -20
View File
@@ -31,7 +31,7 @@ on:
env:
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
FLUTTER_VERSION: "3.44.0"
FLUTTER_VERSION: "3.47.1"
# Shared by every release build command; SENTRY_DIST stays per-platform.
RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true
TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
@@ -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
+24 -21
View File
@@ -20,7 +20,7 @@ on:
env:
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
FLUTTER_VERSION: "3.44.0"
FLUTTER_VERSION: "3.47.1"
jobs:
analyze:
@@ -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 =="
+2 -2
View File
@@ -1,6 +1,6 @@
cask "plezy" do
version "2.16.0"
sha256 "87568115810fd627babfd05967fbc7d1bf9f40bd38eed319cf7b404b09e47162"
version "2.18.0"
sha256 "affa0922fb33b6ca79a0d6ce7e5042539097a0ea097f011c9a4cfdbe0e822f94"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
+2 -2
View File
@@ -148,7 +148,7 @@ Package managers:
## Building from Source
### Prerequisites
- Flutter SDK 3.44.0+
- Flutter SDK 3.47.0+
- A Plex account, or a Jellyfin or Emby server with user credentials
### Setup
@@ -205,4 +205,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)
+7
View File
@@ -4,6 +4,13 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:
+33 -84
View File
@@ -58,82 +58,31 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val mpvVersion = "v1.0.7"
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
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"
// 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)
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)
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", "avformat", "avutil", "swresample")
inputs.file(aar)
val libraries = listOf("avcodec", "avutil", "swresample")
inputs.dir(libmpvNativeJniDir)
inputs.property("ffmpegVersion", mpvFfmpegVersion)
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
@@ -145,7 +94,6 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
outputs.files(
File(mpvFfmpegDevelopmentDir, "include/libavcodec/avcodec.h"),
File(mpvFfmpegDevelopmentDir, "include/libavutil/avconfig.h"),
File(mpvFfmpegDevelopmentDir, "include/libavformat/avformat.h"),
File(mpvFfmpegDevelopmentDir, "include/libswresample/swresample.h"),
manifest
)
@@ -183,7 +131,7 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
} catch (error: Exception) {
throw GradleException("Failed to extract FFmpeg $mpvFfmpegVersion headers", error)
}
listOf("libavcodec", "libavformat", "libavutil", "libswresample").forEach { library ->
listOf("libavcodec", "libavutil", "libswresample").forEach { library ->
project.copy {
from(File(extractedSource, library)) {
include("*.h")
@@ -204,16 +152,12 @@ val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment")
)
project.copy {
from(zipTree(aar)) {
from(libmpvNativeJniDir) {
include(
"jni/*/libavcodec.so",
"jni/*/libavformat.so",
"jni/*/libavutil.so",
"jni/*/libswresample.so"
"*/libavcodec.so",
"*/libavutil.so",
"*/libswresample.so"
)
eachFile {
path = path.removePrefix("jni/")
}
}
includeEmptyDirs = false
into(nativeDir)
@@ -224,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()
@@ -338,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
@@ -437,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")
}
}
@@ -446,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)
}
}
@@ -503,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
+15 -8
View File
@@ -19,12 +19,19 @@
# growOutputBuffer's JNI descriptor names this type, so it may not be renamed either.
-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }
# ffmpeg_demuxer_jni.cc resolves the AVIO input proxy's callbacks by name
# (FindClass on the interface, GetMethodID for position/read/length). Keeping
# the interface members pins the names on every implementation, including the
# anonymous proxy inside FfmpegExtractor.
-keep interface com.edde746.plezy.exoplayer.FfmpegDemuxerJni$Input {
long position();
int read(byte[], int);
long length();
# MatroskaExtractor.init is final and its ExtractorOutput / subtitle scratch buffer live
# in private fields, so the extractor wrappers reach both by name: AssMatroskaExtractor
# (android/libass/.../media/extractor/AssMatroskaExtractor.kt) resolves extractorOutput
# and subtitleSample with getDeclaredField to redirect ASS subtitle samples, and
# MatroskaLatmSupport (android/app/.../exoplayer/MatroskaLatmSupport.kt) resolves
# extractorOutput the same way to wrap LATM tracks.
#
# R8 renaming either field makes getDeclaredField throw; AssMatroskaExtractor resolves
# them in its companion object, so the throw surfaces as ExceptionInInitializerError
# while constructing the extractor — every MKV direct-play, release builds only. Only
# the *names* need pinning: MatroskaExtractor uses both fields itself, so they survive
# shrinking through the compile-time subclass references.
-keepclassmembernames class androidx.media3.extractor.mkv.MatroskaExtractor {
private androidx.media3.extractor.ExtractorOutput extractorOutput;
private androidx.media3.common.util.ParsableByteArray subtitleSample;
}
@@ -0,0 +1,171 @@
package com.edde746.plezy.exoplayer
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
import androidx.test.platform.app.InstrumentationRegistry
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* On-device coverage for MKV files whose SeekHead references Tracks after the Clusters (#1969).
*
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for these files
* is before the Tracks element parsed, so the map permanently reports unseekable and ExoPlayer
* coerces every seek to t=0 (ProgressiveMediaPeriod; tracking issue androidx/media #3377). The
* first test is a canary asserting the upstream defect against a stock MatroskaExtractor — when it
* fails after a media3 upgrade, the TrackAwareSeekMap repair in [CuelessSeekExtractorWrapper] can
* be retired. The second test drives the production wrapper stack and requires the seek to
* actually land.
*/
class MatroskaLateTracksSeekDeviceTest {
private companion object {
const val TAG = "MkvLateTracksSeek"
const val SEEK_TARGET_MS = 500L
const val SETTLE_MS = 2_000L
}
private class Session(
val handler: Handler,
val thread: HandlerThread,
val player: AtomicReference<ExoPlayer?>,
val fixture: File
)
@Test
fun stockMatroskaExtractorSnapsSeeksToStart() {
val result = runSeekScenario("stock") { MatroskaExtractor(DefaultSubtitleParserFactory()) }
assertFalse(
"upstream media3 now reports tracks-after-clusters MKVs seekable — " +
"the TrackAwareSeekMap repair in CuelessSeekExtractorWrapper can be retired",
result.seekable
)
assertTrue(
"unseekable media must snap the seek to the start, position=${result.positionAfterSeekMs}ms",
result.positionAfterSeekMs < SEEK_TARGET_MS / 2
)
}
@Test
fun wrappedExtractorKeepsSeekPosition() {
val result = runSeekScenario("wrapped") {
val assHandler = AssHandler()
CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
}
assertTrue("wrapped extractor must report the item seekable", result.seekable)
assertTrue(
"seek to ${SEEK_TARGET_MS}ms must hold, position=${result.positionAfterSeekMs}ms",
result.positionAfterSeekMs >= SEEK_TARGET_MS / 2
)
}
private class ScenarioResult(val seekable: Boolean, val positionAfterSeekMs: Long)
private fun runSeekScenario(label: String, extractorFactory: () -> Extractor): ScenarioResult {
val session = openPaused(label, extractorFactory)
try {
val seekable = onPlayerThread(session) { it.isCurrentMediaItemSeekable }
session.handler.post { session.player.get()?.seekTo(SEEK_TARGET_MS) }
// The masked position is the seek target; the snap surfaces once the period resolves the
// seek, so give the load a moment before sampling the settled position.
Thread.sleep(SETTLE_MS)
val positionAfterSeekMs = onPlayerThread(session) { it.currentPosition }
Log.i(TAG, "==== $label: seekable=$seekable position=${positionAfterSeekMs}ms ====")
return ScenarioResult(seekable, positionAfterSeekMs)
} finally {
teardown(session)
}
}
private fun openPaused(label: String, extractorFactory: () -> Extractor): Session {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val fixture = copyFixture(context)
val thread = HandlerThread("mkv-late-tracks-$label").apply { start() }
val handler = Handler(thread.looper)
val player = AtomicReference<ExoPlayer?>(null)
val ready = CountDownLatch(1)
val failed = AtomicBoolean(false)
handler.post {
val exo = ExoPlayer.Builder(context).build()
player.set(exo)
exo.addListener(
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) ready.countDown()
}
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e(TAG, "$label player error", error)
failed.set(true)
ready.countDown()
}
}
)
val source = ProgressiveMediaSource.Factory(
DefaultDataSource.Factory(context),
ExtractorsFactory { arrayOf(extractorFactory()) }
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixture)))
exo.setMediaSource(source)
exo.playWhenReady = false
exo.prepare()
}
val session = Session(handler, thread, player, fixture)
if (!ready.await(30, TimeUnit.SECONDS) || failed.get()) {
teardown(session)
error("$label playback never became ready")
}
return session
}
private fun <T> onPlayerThread(session: Session, read: (ExoPlayer) -> T): T {
val value = AtomicReference<T>()
val done = CountDownLatch(1)
session.handler.post {
session.player.get()?.let { value.set(read(it)) }
done.countDown()
}
assertTrue("player thread stalled", done.await(5, TimeUnit.SECONDS))
return checkNotNull(value.get())
}
private fun teardown(session: Session) {
val done = CountDownLatch(1)
session.handler.post {
session.player.get()?.release()
done.countDown()
}
done.await(10, TimeUnit.SECONDS)
session.thread.quitSafely()
session.thread.join(5_000)
session.fixture.delete()
}
private fun copyFixture(context: android.content.Context): File {
val output = File.createTempFile("mkv-late-tracks-", ".mkv", context.cacheDir)
InstrumentationRegistry.getInstrumentation().context.assets
.open("ffmpeg/matroska_tracks_at_end.mkv")
.use { input -> output.outputStream().use { input.copyTo(it) } }
return output
}
}
-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"/>
+2 -4
View File
@@ -19,22 +19,20 @@ else()
target_link_libraries(dovi_bridge log)
endif()
foreach(ffmpeg_library avcodec avformat avutil swresample)
foreach(ffmpeg_library avcodec avutil swresample)
add_library(${ffmpeg_library} SHARED IMPORTED)
set_target_properties(${ffmpeg_library} PROPERTIES
IMPORTED_LOCATION "${MPV_FFMPEG_ROOT}/native/${ANDROID_ABI}/lib${ffmpeg_library}.so")
endforeach()
add_library(ffmpegJNI SHARED
media3_ffmpeg_decoder/ffmpeg_jni.cc
media3_ffmpeg_demuxer/ffmpeg_demuxer_jni.cc)
media3_ffmpeg_decoder/ffmpeg_jni.cc)
target_compile_features(ffmpegJNI PRIVATE cxx_std_17)
target_include_directories(ffmpegJNI PRIVATE
"${MPV_FFMPEG_ROOT}/include"
"${CMAKE_CURRENT_SOURCE_DIR}/media3_ffmpeg_decoder")
target_link_libraries(ffmpegJNI
avcodec
avformat
avutil
swresample
android
File diff suppressed because it is too large Load Diff
@@ -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,7 +31,9 @@ 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
import com.edde746.plezy.watchnext.WatchNextPlugin
import io.flutter.embedding.android.FlutterActivity
@@ -95,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
@@ -592,6 +597,9 @@ class MainActivity : FlutterActivity() {
carRestrictions?.release()
carRestrictions = null
carRestrictionsChannel = null
assistiveTechnology?.release()
assistiveTechnology = null
assistiveTechnologyChannel = null
activityStarted = false
flutterSurfaceReconnectPending = false
flutterTextureView = null
@@ -735,6 +743,7 @@ class MainActivity : FlutterActivity() {
"getTvDetection" -> result.success(getAndroidTvDetection())
"getDeviceName" -> result.success(getDeviceName())
"getPerformanceSignals" -> result.success(getPerformanceSignals())
"getVideoDecodeCapabilities" -> result.success(MediaCodecQuery.hardwareVideoDecodeSupport())
"getBackgroundWorkSignals" -> result.success(
BackgroundWorkClassifier.toMap(BackgroundWorkDiagnostics.read(this))
)
@@ -779,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)
}
@@ -76,58 +76,93 @@ internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
else -> false
}
/**
* mpv `audio-spdif` codec names and the exact platform encoding a route must
* advertise to carry that bitstream.
*
* Only the codecs mpv's audiotrack AO can physically carry are listed. That AO opens every
* spdif format as a stereo `ENCODING_IEC61937` track and clamps the track rate to
* `getNativeOutputSampleRate` (the 48kHz mixer rate), so E-AC3 (a 192kHz burst), TrueHD
* (192kHz/8ch MAT) and DTS-HD MA (192kHz/8ch) can never leave that AO intact. Naming one
* anyway force-passes the codec into a track that cannot carry it (#1991); those streams
* decode to PCM instead, like every other unnamed codec.
*/
private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
"ac3" to C.ENCODING_AC3,
"dts" to C.ENCODING_DTS
/** The IEC 61937 track shape a codec's spdif burst rides. */
internal enum class MpvIecShape { STEREO_48K, STEREO_192K, SURROUND_192K }
private class MpvSpdifCodec(
val name: String,
val encoding: Int,
val shape: MpvIecShape,
/** Whether the fork's audiotrack AO opens this codec as a raw bitstream track first. */
val raw: Boolean = false
)
/**
* Builds an `audio-spdif` value naming only the codecs [supportsEncoding] advertises.
* mpv `audio-spdif` codec names, the platform encoding a route must advertise to carry that
* bitstream, and the track shape the fork's `ao_audiotrack` opens for it.
*
* Since the raw-passthrough patch (0102), the AO feeds AC3, E-AC3 and the DTS core to a raw
* `ENCODING_AC3`/`E_AC3`/`DTS` track at 48kHz/stereo — the transport ExoPlayer and Kodi use —
* and only falls back to the IEC 61937 carrier when the route rejects the raw encoding
* outright. Raw tracks keep the platform's Dolby/DTS transcoder in the path; a pre-packed IEC
* track bypasses it and drains into silence on routes whose sink cannot decode the codec
* itself (#2177's Shield in front of a Dolby-Digital-only Sonos). TrueHD (MAT) and DTS-HD MA
* stay on the 8-channel 192kHz IEC carrier the multichannel patch (0101) added: their raw
* forms are not recoverable from the burst stream.
*
* `dts-hd` supersedes plain `dts`: that literal is what selects the lossless `spdif_dts_hd`
* decoder, and it enables spdif for the whole `dts` codec while doing so, with the core burst
* still chosen per file for tracks that are not HD (`ad_spdif.c:240-249`, `:400-418`). HRA rides
* a 2ch/192kHz burst under the same name, so gating it on the 8-channel carrier is the
* conservative choice.
*/
private val MPV_SPDIF_CODECS: List<MpvSpdifCodec> = listOf(
MpvSpdifCodec("ac3", C.ENCODING_AC3, MpvIecShape.STEREO_48K, raw = true),
MpvSpdifCodec("eac3", C.ENCODING_E_AC3, MpvIecShape.STEREO_192K, raw = true),
MpvSpdifCodec("truehd", C.ENCODING_DOLBY_TRUEHD, MpvIecShape.SURROUND_192K),
MpvSpdifCodec("dts", C.ENCODING_DTS, MpvIecShape.STEREO_48K, raw = true),
MpvSpdifCodec("dts-hd", C.ENCODING_DTS_HD, MpvIecShape.SURROUND_192K)
)
/**
* Builds an `audio-spdif` value naming only the codecs the route can carry: [supportsEncoding]
* advertises the codec's encoding *and* a transport the AO's ladder can open exists —
* [supportsRawTrack] for the raw bitstream track it tries first, or [supportsShape] for the
* IEC 61937 burst it falls back to. Plain `dts` is dropped whenever `dts-hd` qualifies, which
* already covers the core burst.
*
* mpv force-passes through every codec named here and has no decode fallback, so an
* unsupported name leaves the file rendering video against a dead audio output (#1703).
*
* The gate is the exact encoding rather than media3's passthrough probe on purpose. That
* probe answers by downgrading (DTS-HD to the DTS core, E-AC3 JOC to E-AC3) and rejects
* channel counts above the route's PCM maximum, neither of which describes what an IEC
* 61937 track carries.
* channel counts above the route's PCM maximum, neither of which describes what a
* passthrough track carries.
*/
internal fun mpvSpdifCodecs(supportsEncoding: (Int) -> Boolean): String = MPV_SPDIF_CODECS
.filter { (_, encoding) -> supportsEncoding(encoding) }
.joinToString(",") { (codec, _) -> codec }
internal fun mpvSpdifCodecs(
supportsEncoding: (Int) -> Boolean,
supportsShape: (MpvIecShape) -> Boolean,
supportsRawTrack: (Int) -> Boolean = { false }
): String {
val carried = MPV_SPDIF_CODECS.filter {
supportsEncoding(it.encoding) &&
((it.raw && supportsRawTrack(it.encoding)) || supportsShape(it.shape))
}
val dtsHd = carried.any { it.name == "dts-hd" }
return (if (dtsHd) carried.filterNot { it.name == "dts" } else carried).joinToString(",") { it.name }
}
/**
* [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to.
*
* Two conditions, both required:
* - The route must accept the exact track shape mpv opens for spdif output — stereo
* IEC 61937 at the 48kHz mixer rate ([supportsMpvIecShape]). Advertising the raw AC3/DTS
* encodings only says the receiver decodes them, not that the HAL takes an IEC 61937
* AudioTrack: #1991's Shield bitstreams AC3 through ExoPlayer's raw path while every mpv
* spdif attempt strands playback on a dead audio output, leaving nothing but AAC playable.
* Two conditions per codec, both required:
* - The route must accept a track shape the AO's ladder actually opens. For AC3, E-AC3 and the
* DTS core that is the raw bitstream track probed by [supportsMpvRawTrack], with the IEC
* stereo shapes ([supportsMpvIecShape], [supportsMpvHighRateIecShape]) as the AO's fallback
* transport; TrueHD and DTS-HD MA need the 192kHz/7.1 carrier ([supportsIecCarrier]).
* Advertising the raw encoding only says the receiver decodes it, not that the HAL takes the
* track: #1991's Shield strands playback on every mpv IEC attempt while bitstreaming AC3 raw.
* The probes are independent, so none of them may veto the whole list: a route that takes the
* 192kHz carrier but no raw track still bitstreams TrueHD and DTS-HD MA.
* - The receiver must decode the codec itself — the raw encoding on the current
* [AudioCapabilities] — because IEC 61937 is transport, not transcoding.
* [AudioCapabilities] — because passthrough is transport, not transcoding. (On a raw track
* the platform may additionally transcode, which is exactly why the AO prefers it.)
*/
// Deprecated only in favour of an overload that also takes spatializer channel masks, which
// do not affect bitstream routing. Same probe ExoPlayerCore's TrueHD decision uses.
@Suppress("DEPRECATION")
@OptIn(UnstableApi::class)
internal fun supportedMpvSpdifCodecs(context: Context): String {
if (!supportsMpvIecShape(context)) {
Log.i(TAG, "Route takes no stereo IEC 61937 track; mpv will decode instead of bitstreaming")
return ""
}
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
@@ -138,12 +173,32 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
Log.w(TAG, "Audio route capabilities unavailable; mpv will decode instead of bitstreaming", error)
return ""
}
return mpvSpdifCodecs(capabilities::supportsEncoding)
// Every probe costs real route calls and may be shared by more than one codec, so probe once.
val shapeProbed = HashMap<MpvIecShape, Boolean>(3)
val rawProbed = HashMap<Int, Boolean>(3)
val codecs = mpvSpdifCodecs(
capabilities::supportsEncoding,
{ shape -> shapeProbed.getOrPut(shape) { routeTakesIecShape(context, shape) } },
{ encoding -> rawProbed.getOrPut(encoding) { supportsMpvRawTrack(context, encoding) } }
)
if (codecs.isEmpty()) {
Log.i(TAG, "Route takes no passthrough track mpv can fill; mpv will decode instead of bitstreaming")
} else {
Log.i(TAG, "mpv will bitstream: $codecs")
}
return codecs
}
/** The track shape mpv's audiotrack AO opens for spdif output: stereo IEC 61937 at the mixer rate. */
private fun routeTakesIecShape(context: Context, shape: MpvIecShape): Boolean = when (shape) {
MpvIecShape.STEREO_48K -> supportsMpvIecShape(context)
MpvIecShape.STEREO_192K -> supportsMpvHighRateIecShape(context)
MpvIecShape.SURROUND_192K -> supportsIecCarrier(context)
}
/** The track shape mpv's audiotrack AO opens for an AC3 or DTS-core burst: stereo at the mixer rate. */
private const val MPV_IEC_SAMPLE_RATE = 48_000
private const val MPV_IEC_CHANNEL_COUNT = 2
private const val MPV_IEC_HIGH_SAMPLE_RATE = 192_000
internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
sdkInt = Build.VERSION.SDK_INT,
@@ -161,6 +216,47 @@ internal fun supportsMpvIecShape(context: Context): Boolean = iecRouteSupported(
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
)
/**
* Whether the route takes a raw bitstream `AudioTrack` for [encoding] at the shape the fork's
* `ao_audiotrack` opens first: the codec frame rate with a stereo mask (Kodi's raw shape; the
* HAL reads the real channel layout from the bitstream). Same probe tiering as the IEC shapes,
* except below API 29, where no runtime oracle exists for raw tracks and media3 gates its raw
* path on the advertised encoding alone — which [supportedMpvSpdifCodecs] already requires via
* [AudioCapabilities]. #1991's API 28 Shield bitstreams AC3 exactly this way.
*/
internal fun supportsMpvRawTrack(context: Context, encoding: Int): Boolean = iecRouteSupported(
sdkInt = Build.VERSION.SDK_INT,
canSizeBuffer = { canSizeDirectBuffer(MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, encoding) },
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
// check cannot see through the injected lambdas.
bitstreamSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
iecBitstreamSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
directPlaybackSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
iecDirectPlaybackSupported(directProbeFormat(encoding, MPV_IEC_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
hdmiRouteAdvertised = { true }
)
/** E-AC3's geometry: the stereo shape at the 192kHz burst rate, same route tiering as the others. */
internal fun supportsMpvHighRateIecShape(context: Context): Boolean = iecRouteSupported(
sdkInt = Build.VERSION.SDK_INT,
canSizeBuffer = { canSizeIecBuffer(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO) },
// The SDK_INT guards repeat iecRouteSupported's tiering only because lint's NewApi
// check cannot see through the injected lambdas.
bitstreamSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
iecBitstreamSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
directPlaybackSupported = {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
iecDirectPlaybackSupported(iecProbeFormat(MPV_IEC_HIGH_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO))
},
hdmiRouteAdvertised = { hdmiAdvertisesIecRoute(context, MPV_IEC_HIGH_SAMPLE_RATE, MPV_IEC_CHANNEL_COUNT) }
)
/**
* Whether this route can carry a packed bitstream inside IEC 61937 at 192kHz/7.1 — TrueHD as MAT
* (#1804) and DTS-HD MA as DTS type IV (#1988) both ride this exact tuple.
@@ -226,8 +322,10 @@ internal fun iecRouteSupported(
else -> hdmiRouteAdvertised()
}
private fun canSizeIecBuffer(sampleRate: Int, channelMask: Int): Boolean = try {
AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_IEC61937) > 0
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
} catch (error: Exception) {
false
}
@@ -272,8 +370,10 @@ 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 = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_IEC61937)
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)
.setChannelMask(channelMask)
.setSampleRate(sampleRate)
.build()
@@ -0,0 +1,226 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.SeekPoint
import androidx.media3.extractor.TrackAwareSeekMap
import androidx.media3.extractor.TrackOutput
import java.util.concurrent.CopyOnWriteArrayList
/**
* Extractor wrapper that repairs seeking for MKV files media3 reports as unseekable.
*
* Two repairs, in priority order:
* - [SeekMap.Unseekable] with a known duration (no Cues element at all): replaced with a
* proportional byte-position estimate, resynced to the nearest Cluster boundary after seeking.
* - A [TrackAwareSeekMap] whose [SeekMap.isSeekable] is false (media3 1.11.0 builds the Matroska
* seek map while parsing Cues, which for files whose Tracks element follows the Clusters is
* before any track is known — the primary seek track stays unset even though the Cues parsed):
* wrapped so seeks resolve through the per-track cue lookups using the track IDs observed on
* this output.
*/
@androidx.media3.common.util.UnstableApi
class CuelessSeekExtractorWrapper(
private val delegate: Extractor
) : Extractor {
companion object {
private const val TAG = "CuelessSeek"
// MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID)
private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
private const val SCAN_BUFFER_SIZE = 8192
// Max bytes to scan for a Cluster boundary before giving up
private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB
}
private var inputLength: Long = C.LENGTH_UNSET.toLong()
private var needsClusterResync = false
private var isApproximateSeeking = false
private var pendingSeekTimeUs: Long = C.TIME_UNSET
/** Track IDs and types observed on the output, consulted by [TrackCueSeekMap]. */
private val registeredTracks = CopyOnWriteArrayList<Pair<Int, Int>>()
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(SeekInterceptingOutput(output))
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
if (inputLength == C.LENGTH_UNSET.toLong()) {
inputLength = input.length
}
if (needsClusterResync) {
needsClusterResync = false
return scanForCluster(input, seekPosition)
}
return delegate.read(input, seekPosition)
}
override fun seek(position: Long, timeUs: Long) {
if (isApproximateSeeking && position > 0) {
needsClusterResync = true
pendingSeekTimeUs = timeUs
}
delegate.seek(position, timeUs)
}
override fun release() = delegate.release()
/**
* Scan forward from the current input position to find the next MKV Cluster
* element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's
* byte position so ExoPlayer repositions the DataSource there.
*/
private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int {
val buffer = ByteArray(SCAN_BUFFER_SIZE)
var totalScanned = 0L
// Carry over last 3 bytes across buffer boundaries to detect split IDs
var carry = ByteArray(0)
while (totalScanned < MAX_SCAN_BYTES) {
val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt())
val bytesRead: Int
try {
bytesRead = input.read(buffer, 0, toRead)
} catch (_: Exception) {
break
}
if (bytesRead == C.RESULT_END_OF_INPUT) break
// Combine carry + new data for scanning
val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead)
for (i in 0..scanData.size - 4) {
if (scanData[i] == CLUSTER_ID[0] &&
scanData[i + 1] == CLUSTER_ID[1] &&
scanData[i + 2] == CLUSTER_ID[2] &&
scanData[i + 3] == CLUSTER_ID[3]
) {
// Compute the absolute byte position of this Cluster
val clusterPosition = input.position - bytesRead - carry.size + i
Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)")
seekPosition.position = clusterPosition
delegate.seek(clusterPosition, pendingSeekTimeUs)
pendingSeekTimeUs = C.TIME_UNSET
return Extractor.RESULT_SEEK
}
}
// Keep last 3 bytes as carry for next iteration
carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf()
totalScanned += bytesRead
}
// Failed to find a Cluster — fall back to position 0
Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start")
seekPosition.position = 0
delegate.seek(0, 0)
return Extractor.RESULT_SEEK
}
/**
* ExtractorOutput wrapper that intercepts [seekMap] calls to replace
* [SeekMap.Unseekable] with an approximate proportional SeekMap.
*/
private inner class SeekInterceptingOutput(
private val delegate: ExtractorOutput
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput {
if (registeredTracks.none { it.first == id }) {
registeredTracks.add(id to type)
}
return delegate.track(id, type)
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) {
if (seekMap is SeekMap.Unseekable) {
val durationUs = seekMap.durationUs
if (durationUs != C.TIME_UNSET && durationUs > 0) {
Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)")
isApproximateSeeking = true
delegate.seekMap(ApproximateSeekMap(durationUs))
return
}
} else if (!seekMap.isSeekable && seekMap is TrackAwareSeekMap) {
Log.i(TAG, "Wrapping unseekable TrackAwareSeekMap with per-track cue seeking")
isApproximateSeeking = false
delegate.seekMap(TrackCueSeekMap(seekMap))
return
}
// File has real Cues or unknown duration — pass through
isApproximateSeeking = false
delegate.seekMap(seekMap)
}
}
/**
* Approximate SeekMap that estimates byte positions proportionally.
* Used when the MKV has no Cues but has a known duration.
*/
private inner class ApproximateSeekMap(
private val durationUs: Long
) : SeekMap {
override fun isSeekable(): Boolean = true
override fun getDurationUs(): Long = durationUs
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
val length = inputLength
if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) {
return SeekMap.SeekPoints(SeekPoint(0, 0))
}
val clampedTimeUs = timeUs.coerceIn(0, durationUs)
val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length)
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
}
}
/**
* Routes seeks through [TrackAwareSeekMap]'s per-track cue lookups when the delegate reports
* unseekable overall. The per-track queries read the live cue data (populated once the Cues
* element parsed), so they resolve correctly even when the map was constructed before the
* Tracks element — the media3 1.11.0 tracks-after-clusters case (androidx/media #3377).
*/
private inner class TrackCueSeekMap(
private val delegate: TrackAwareSeekMap
) : SeekMap {
override fun isSeekable(): Boolean = delegate.isSeekable || seekableTrackId() != null
override fun getDurationUs(): Long = delegate.durationUs
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
if (delegate.isSeekable) return delegate.getSeekPoints(timeUs)
val trackId = seekableTrackId() ?: return delegate.getSeekPoints(timeUs)
return delegate.getSeekPoints(timeUs, trackId)
}
/** Mirrors media3's primary-track priority: video first, then audio, then anything with cues. */
private fun seekableTrackId(): Int? {
var audio: Int? = null
var fallback: Int? = null
for ((id, type) in registeredTracks) {
if (!delegate.isSeekable(id)) continue
when (type) {
C.TRACK_TYPE_VIDEO -> return id
C.TRACK_TYPE_AUDIO -> if (audio == null) audio = id
else -> if (fallback == null) fallback = id
}
}
return audio ?: fallback
}
}
}
@@ -124,6 +124,17 @@ object DoviBridge {
.also { Log.i(TAG, "Device advertises DV Profile 8 (DvheSt): $it") }
}
/**
* Whether this device can natively render single-layer Dolby Vision
* Profile 5 (IPT-PQ-c2): it needs both a decoder advertising DvheStn and a
* Dolby Vision display pipeline. P5 has no compatible base layer, so a
* device that fails either check decodes it as plain HEVC with garbage
* colors; callers route those sessions to software decode + gpu-next,
* where libplacebo applies the RPU reshaping instead.
*/
fun canPlayDolbyVisionP5(context: Context): Boolean = deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheStn) &&
displaySupportsDolbyVision(context)
fun displaySupportsDolbyVision(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
Log.i(TAG, "Display Dolby Vision support: false (HDR capabilities require API 24, device API=${Build.VERSION.SDK_INT})")
@@ -145,6 +156,17 @@ object DoviBridge {
return supported
}
/** Whether the active display advertises any HDR output type. */
fun displaySupportsHdr(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
val display = getCurrentDisplay(context) ?: return false
val hdrTypes = runCatching { getDisplayHdrTypes(display) }.getOrElse { error ->
Log.w(TAG, "Display HDR support: failed to query HDR types", error)
return false
}
return hdrTypes.isNotEmpty()
}
fun describeDisplayHdrCapabilities(context: Context): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return "HDR capabilities unavailable (API=${Build.VERSION.SDK_INT})"
val display = getCurrentDisplay(context) ?: return "HDR capabilities unavailable (no active display)"
@@ -64,7 +64,6 @@ import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.mp4.FragmentedMp4Extractor
import androidx.media3.extractor.mp4.Mp4Extractor
import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput
import androidx.media3.extractor.ts.TsExtractor
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.CaptionStyleCompat
@@ -72,7 +71,6 @@ import androidx.media3.ui.SubtitleView
import com.edde746.plezy.AndroidRuntimeDiagnostics
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput
import com.edde746.plezy.libass.media.widget.AssSubtitleSurfaceView
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.DeviceQuirks
@@ -563,18 +561,6 @@ class ExoPlayerCore(private val activity: Activity) :
@Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null
// The FFmpeg extractor from the current session; getStats reads its
// measured audio bitrate for the playing item.
@Volatile private var activeFfmpegExtractor: FfmpegExtractor? = null
// Container-demuxer placement; read per media item when extractors are built.
@Volatile private var demuxerPreference: FfmpegDemuxerPolicy.Preference = FfmpegDemuxerPolicy.Preference.FFMPEG
fun setDemuxerMode(mode: String) {
demuxerPreference = FfmpegDemuxerPolicy.fromWire(mode)
emitLog("info", "init", "demuxer mode: ${demuxerPreference.wireName}")
}
private fun getConfiguredDvMode(): DvConversionMode {
val override = debugDvModeOverride
if (override != null) return override
@@ -585,17 +571,12 @@ class ExoPlayerCore(private val activity: Activity) :
}
fun initialize(
bufferSizeBytes: Int? = null,
bufferSizeAuto: Boolean = false,
tunnelingEnabled: Boolean = true,
audioPassthroughEnabled: Boolean = false,
// Read-ahead depth, as the wire name Dart sends. Kept a String because `LoadControlPolicy`
// is internal and this function is not; unrecognised names resolve to Auto, which is also
// the default (#1816).
bufferTier: String = "auto",
// Whether FFmpeg demuxes ahead of media3's extractors. Wire values from
// [FfmpegDemuxerPolicy.Preference]; unrecognised resolves to FFmpeg.
demuxerMode: String = "ffmpeg"
bufferTier: String = "auto"
): Boolean {
if (isInitialized) {
Log.d(TAG, "Already initialized")
@@ -604,7 +585,6 @@ class ExoPlayerCore(private val activity: Activity) :
tunnelingUserEnabled = tunnelingEnabled
this.audioPassthroughEnabled = audioPassthroughEnabled
demuxerPreference = FfmpegDemuxerPolicy.fromWire(demuxerMode)
this.dvMode = getConfiguredDvMode()
DoviBridge.logSupportSummary(activity)
Log.i(
@@ -750,11 +730,6 @@ class ExoPlayerCore(private val activity: Activity) :
// High-bitrate Plex DVR MPEG-TS recordings can have sparse PCR packets; the default
// 600-packet window may leave duration unknown and seeking disabled.
.setTsExtractorTimestampSearchBytes(TS_TIMESTAMP_SEARCH_PACKETS * TsExtractor.TS_PACKET_SIZE)
// Raw subtitle samples from MatroskaExtractor: the external text
// pipeline below feeds libass dialogue first and then re-adds cue
// transcoding — with the internal wrapper enabled the dialogue bytes
// would already be consumed before our wrapper could see them.
.setMatroskaExtractorFlags(MatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA)
// Inline buildWithAssSupport to retain AssHandler reference for font scale control.
val handler = AssHandler()
@@ -764,61 +739,39 @@ class ExoPlayerCore(private val activity: Activity) :
// composition object and blanks palette-only fade updates (#1953).
val subtitleParserFactory = PgsSubtitleParserFactory(AssSubtitleParserFactory(handler))
// Wrap extractors: FFmpeg demuxes the containers media3 is weak at
// (primary, before the list) and anything media3 cannot sniff (catch-all,
// behind it); give MatroskaExtractor the same libass/transcoding text
// pipeline so the media3 fallback (ffmpeg unavailable, media3-only
// preference) keeps ASS rendering; wrap MKV/MP4 extractors with the DV
// converter when enabled.
// Reads this.dvMode and this.demuxerPreference each time (not captured) so
// DV7→8.1 retry and demuxer-mode changes can reload without
// reinitializing the player.
// Wrap extractors: replace MatroskaExtractor with the ASS+zlib+LATM
// variant, wrap MP4 extractors with the DV converter when enabled.
// Reads this.dvMode each time (not captured) so DV7→8.1 retry can
// change mode and reload without reinitializing the player.
val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory {
val currentDvMode = this.dvMode
val doviEnabled = currentDvMode != DvConversionMode.DISABLED
val currentDemuxerPreference = this.demuxerPreference
Log.i(TAG, "[init] extractors: demuxer=${currentDemuxerPreference.wireName}")
// media3 invokes the factory once per player session and reuses the
// extractor instances for every item, so the sniff reads the live
// preference instead of a captured one.
val liveDemuxerPreference = { this@ExoPlayerCore.demuxerPreference }
val ffmpegFirst =
listOfNotNull(
FfmpegExtractor.create(
liveDemuxerPreference,
currentDvMode,
subtitleParserFactory,
handler
)?.also { activeFfmpegExtractor = it }
)
(
ffmpegFirst + extractorsFactory.createExtractors().map { extractor ->
when {
extractor is MatroskaExtractor -> {
val withTextPipeline = OutputWrappingExtractor(extractor) { out ->
AssSubtitleExtractorOutput(SubtitleTranscodingExtractorOutput(out, subtitleParserFactory), handler)
}
if (doviEnabled) {
DoviExtractorWrapper(withTextPipeline, currentDvMode) { level, prefix, message ->
emitLog(level, prefix, message)
}.also {
activeDoviMkvWrapper = it
}
} else {
withTextPipeline
}
}
doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> {
DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message ->
extractorsFactory.createExtractors().map { extractor ->
when {
extractor is MatroskaExtractor -> {
val assExtractor = ZlibMatroskaExtractor(subtitleParserFactory, handler)
val inner = if (doviEnabled) {
DoviExtractorWrapper(assExtractor, currentDvMode) { level, prefix, message ->
emitLog(level, prefix, message)
}.also {
activeDoviMp4Wrapper = it
activeDoviMkvWrapper = it
}
} else {
assExtractor
}
else -> extractor
// Wrap with approximate seeking for MKV files without Cues
CuelessSeekExtractorWrapper(inner)
}
doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> {
DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message ->
emitLog(level, prefix, message)
}.also {
activeDoviMp4Wrapper = it
}
}
else -> extractor
}
).toTypedArray()
}.toTypedArray()
}
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
@@ -832,21 +785,15 @@ class ExoPlayerCore(private val activity: Activity) :
.toTypedArray()
}
// Buffer budget. `bufferSizeBytes` carries the user's explicit Buffer Size choice; on
// Auto it still arrives (Dart derives it for mpv's demuxer, which shares the property)
// but `bufferSizeAuto` says to ignore it here, because mpv's demuxer and ExoPlayer's
// sample allocator have different shapes and different failure modes.
// Buffer budget. Derived natively from device memory (LoadControlPolicy);
// mpv's demuxer sizes itself the same way in MpvPlayerCore.
val activityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
val availableMB = (memoryInfo.availMem / (1024 * 1024)).toInt()
val largeHeapMB = activityManager.largeMemoryClass
val targetBufferBytes = if (!bufferSizeAuto && bufferSizeBytes != null && bufferSizeBytes > 0) {
bufferSizeBytes
} else {
LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
}
val targetBufferBytes = LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
val resolvedTier = LoadControlPolicy.BufferTier.fromWire(bufferTier)
val bufferDurations = LoadControlPolicy.bufferDurations(resolvedTier, availableMB)
@@ -869,8 +816,8 @@ class ExoPlayerCore(private val activity: Activity) :
emitLog(
"info",
"init",
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit (${if (bufferSizeAuto) "auto" else "manual"}, " +
"heap=${largeHeapMB}MB, available=${availableMB}MB), " +
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit " +
"(heap=${largeHeapMB}MB, available=${availableMB}MB), " +
"buffer=${bufferDurations.minBufferMs / 1000}-${bufferDurations.maxBufferMs / 1000}s " +
"(${resolvedTier.name.lowercase()}), " +
"tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
@@ -3776,7 +3723,6 @@ class ExoPlayerCore(private val activity: Activity) :
dv7RetryAttempted = override != null
activeDoviMkvWrapper = null
activeDoviMp4Wrapper = null
activeFfmpegExtractor = null
val debugMode = override?.name ?: "AUTO"
emitLog("info", "dv-debug", "P7 DV conversion mode set to $debugMode (active=$dvMode)")
reloadCurrentMediaForDvMode()
@@ -3813,7 +3759,6 @@ class ExoPlayerCore(private val activity: Activity) :
lastDvPlaybackInfo = null
activeDoviMkvWrapper = null
activeDoviMp4Wrapper = null
activeFfmpegExtractor = null
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
@@ -4189,7 +4134,13 @@ class ExoPlayerCore(private val activity: Activity) :
}
override fun clearVideoFrameRate() {
frameRateManager?.clearVideoFrameRate()
// HDR content on an HDR display means the decoder's dataspace put the
// display into HDR signaling; defer the rate restore past the HDR exit
// (see FrameRateManager.clearVideoFrameRate).
val transfer = currentVideoFormat?.colorInfo?.colorTransfer
val hdrActive = (transfer == C.COLOR_TRANSFER_ST2084 || transfer == C.COLOR_TRANSFER_HLG) &&
DoviBridge.displaySupportsHdr(activity)
frameRateManager?.clearVideoFrameRate(hdrActive = hdrActive)
}
private fun computeFrameRate(timestamps: LongArray): Float {
@@ -4280,12 +4231,7 @@ class ExoPlayerCore(private val activity: Activity) :
"audioMimeType" to audioFormat?.sampleMimeType,
"audioSampleRate" to audioFormat?.sampleRate,
"audioChannels" to audioFormat?.channelCount,
// Container-declared bitrate when present; otherwise the FFmpeg
// demuxer's measured value (Matroska rarely carries BPS tags — #2063).
"audioBitrate" to (
audioFormat?.bitrate?.takeIf { it > 0 }
?: audioFormat?.id?.let { activeFfmpegExtractor?.measuredAudioBitrateBps(it) }
),
"audioBitrate" to audioFormat?.bitrate?.takeIf { it > 0 },
"audioDecoderName" to audioDecoderInitName,
"audioOutputEncoding" to audioTrackConfig?.encoding,
"audioOutputChannels" to audioTrackConfig?.channelConfig?.let { Integer.bitCount(it) },
@@ -1,8 +1,6 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.util.Log
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.mpv.MpvPlayerCore
@@ -40,6 +38,11 @@ class ExoPlayerPlugin :
private val mainHandler get() = channels.mainHandler
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
private var playerCore: ExoPlayerCore? = null
// The Dart instanceId that created the current core. A `dispose` carrying a
// different token lost the ownership race to a successor and must not tear
// down that successor's session; it is acknowledged without touching it.
private var coreInstanceId: Long? = null
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
private var usingMpvFallback: Boolean = false
private var fallbackInProgress: Boolean = false
@@ -108,8 +111,6 @@ class ExoPlayerPlugin :
private val observedProperties = LinkedHashMap<String, ObservedProperty>()
private var configuredBufferSizeBytes: Int? = null
private var sessionGeneration = 0
private var mediaGeneration = 0
private var fallbackMediaGeneration: Int? = null
@@ -119,7 +120,8 @@ class ExoPlayerPlugin :
private var mpvForwardGeneration: Int? = null
private var mpvSignalGate: MpvSignalGate? = null
private var mpvCoreNeedsReplacement = false
internal var createMpvCore: (Activity) -> MpvPlayerCore = { MpvPlayerCore(it) }
internal var createMpvCore: (Activity) -> MpvPlayerCore =
{ MpvPlayerCore(it, hardwareDecoding = fallbackHardwareDecoding()) }
internal var initializeMpvCore: (MpvPlayerCore, (Boolean) -> Unit) -> Unit = { core, onInitialized ->
core.initialize(onInitialized)
}
@@ -139,6 +141,12 @@ class ExoPlayerPlugin :
// every codec in audio-spdif with no decode fallback, so the fallback core's value is
// derived from the audio route at the moment mpv actually starts (#1703).
private var audioPassthroughRequested = false
// `dv-conversion-mode` is not an mpv property and never reaches
// pendingMpvProperties: Dart routes it through setDvConversionMode (see
// PlayerAndroid.setProperty), so the fallback core has to be seeded from the
// last configured value or it loses the fork's P7/P5 handling entirely.
private var dvConversionMode = "auto"
private var currentExternalSubtitles: List<Map<String, Any?>>? = null
// FlutterPlugin
@@ -158,6 +166,7 @@ class ExoPlayerPlugin :
val exoCore = playerCore
val fallbackCore = mpvCore
playerCore = null
coreInstanceId = null
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
@@ -174,6 +183,7 @@ class ExoPlayerPlugin :
currentExternalSubtitles = null
pendingMpvProperties.clear()
audioPassthroughRequested = false
dvConversionMode = "auto"
if (clearActivity) {
activity = null
activityBinding = null
@@ -224,7 +234,7 @@ class ExoPlayerPlugin :
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(call, result)
"dispose" -> handleDispose(result)
"dispose" -> handleDispose(call, result)
"open" -> handleOpen(call, result)
"play" -> handlePlay(result)
"pause" -> handlePause(result)
@@ -250,15 +260,10 @@ class ExoPlayerPlugin :
)
"getStats" -> handleGetStats(result)
"getPlayerType" -> result.success(if (usingMpvFallback) "mpv" else "exoplayer")
"getHeapSize" -> {
val am = activity?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
result.success(am?.largeMemoryClass ?: 0)
}
"setSubtitleStyle" -> handleSetSubtitleStyle(call, result)
"setBoxFitMode" -> handleSetBoxFitMode(call, result)
"setVideoZoom" -> handleSetVideoZoom(call, result)
"setDvConversionMode" -> handleSetDvConversionMode(call, result)
"setDemuxerMode" -> handleSetDemuxerMode(call, result)
"setAudioNormalization" -> handleSetAudioNormalization(call, result)
"setAudioPassthrough" -> handleSetAudioPassthrough(call, result)
"setAudioDownmix" -> handleSetAudioDownmix(call, result)
@@ -292,21 +297,14 @@ class ExoPlayerPlugin :
return
}
val bufferSizeBytes = call.argument<Int>("bufferSizeBytes")
// Auto sizing is decided natively (LoadControlPolicy). `bufferSizeBytes` still arrives
// on Auto because Dart derives one for mpv's demuxer, which shares the property, and
// the fallback replay below needs it.
val bufferSizeAuto = call.argument<Boolean>("bufferSizeAuto") ?: false
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: false
val dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
dvConversionMode = call.argument<String>("dvConversionMode") ?: "auto"
val audioPassthroughEnabled = call.argument<Boolean>("audioPassthroughEnabled") ?: false
val assVideoLatencyFrames = call.argument<Int>("assVideoLatencyFrames") ?: 0
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1.0f
// ExoPlayer-only: mpv's read-ahead is owned by the mpv.conf editor, so there is no
// fallback replay for this one. Resolved in the core; unrecognised means Auto (#1816).
val bufferTier = call.argument<String>("bufferTier") ?: "auto"
val demuxerMode = call.argument<String>("demuxerMode") ?: "ffmpeg"
configuredBufferSizeBytes = bufferSizeBytes
// Seed the request here rather than waiting for Dart's separate setAudioPassthrough
// call, so a fallback raised before that arrives still derives audio-spdif correctly.
audioPassthroughRequested = audioPassthroughEnabled
@@ -345,13 +343,11 @@ class ExoPlayerPlugin :
this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled
}
playerCore = core
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
val success = core.initialize(
bufferSizeBytes = bufferSizeBytes,
bufferSizeAuto = bufferSizeAuto,
tunnelingEnabled = tunnelingEnabled,
audioPassthroughEnabled = audioPassthroughEnabled,
bufferTier = bufferTier,
demuxerMode = demuxerMode
bufferTier = bufferTier
)
if (!success) {
if (playerCore === core) playerCore = null
@@ -376,8 +372,15 @@ class ExoPlayerPlugin :
}
}
private fun handleDispose(result: MethodChannel.Result) {
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
val token = call.argument<Number>("instanceId")?.toLong()
runOnMain {
val owner = coreInstanceId
if ((playerCore != null || mpvCore != null) && token != null && owner != null && token != owner) {
Log.d(TAG, "Ignoring stale dispose (token=$token, core owner=$owner)")
result.success(null)
return@runOnMain
}
teardownSession(clearActivity = false)
Log.d(TAG, "Disposed")
result.success(null)
@@ -1131,12 +1134,24 @@ class ExoPlayerPlugin :
return
}
if (usingMpvFallback) {
result.success(false)
// The fallback core owns the property now; dropping the write would
// strand it on the mode ExoPlayer started with.
val core = mpvCore
if (core == null) {
result.success(false)
return
}
dvConversionMode = mode
core.setProperty("dv-conversion-mode", mode) { outcome ->
completeMpvPropertyResult(result, outcome, successValue = true)
}
return
}
activity?.runOnUiThread {
val handled = playerCore?.setDebugDvConversionMode(mode) == true
if (handled) {
// Keep the fallback seed on the value ExoPlayer is actually running.
dvConversionMode = mode
result.success(true)
} else {
result.error("INVALID_ARGS", "Invalid DV conversion mode: $mode", null)
@@ -1144,18 +1159,6 @@ class ExoPlayerPlugin :
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
}
private fun handleSetDemuxerMode(call: MethodCall, result: MethodChannel.Result) {
val mode = call.argument<String>("mode")
if (mode == null) {
result.error("INVALID_ARGS", "Missing 'mode'", null)
return
}
activity?.runOnUiThread {
playerCore?.setDemuxerMode(mode)
result.success(null)
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
}
private fun handleSetAudioNormalization(call: MethodCall, result: MethodChannel.Result) {
val enabled = call.argument<Boolean>("enabled")
if (enabled == null) {
@@ -1321,6 +1324,16 @@ class ExoPlayerPlugin :
}
}
/**
* Decode intent for a fallback mpv core, read from the `hwdec` value Dart
* already wrote for this session. It picks the core's initial vo chain
* ([MpvPlayerCore.initialVideoOutput]), so a software-decoding session must
* not inherit the hardware default: only gpu-next applies Dolby Vision RPU
* reshaping, and a session that asked for software decode is usually the
* one that needs it.
*/
private fun fallbackHardwareDecoding(): Boolean = pendingMpvProperties["hwdec"]?.let { it != "no" } ?: true
/**
* Configure a freshly initialized MPV fallback core: replay the properties
* and observers Dart registered against the ExoPlayer session, then resume
@@ -1330,17 +1343,13 @@ class ExoPlayerPlugin :
private fun prepareMpvFallback(core: MpvPlayerCore) {
val pendingProps = pendingMpvProperties.filterKeys { it != "audio-spdif" }.toList()
val observedProps = observedProperties.toList()
val bufferSize = configuredBufferSizeBytes
// vo is owned by MpvPlayerCore's init: the fallback core hardware-decodes
// (hwdec below), so it gets the legacy gpu VO — gpu-next under mediacodec
// fails every frame on Tegra (#2010) and reshapes no DV anyway.
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
// hwdec is not seeded here — Dart's write in pendingMpvProperties is the
// single source of the fallback core's chain.
core.setProperty("ao", "audiotrack")
if (bufferSize != null && bufferSize > 0) {
core.setProperty("demuxer-max-bytes", bufferSize.toString())
}
// Dart routes dv-conversion-mode through setDvConversionMode, so unlike
// hwdec it never reaches pendingMpvProperties.
core.setProperty("dv-conversion-mode", dvConversionMode)
for ((propName, propValue) in pendingProps) {
core.setProperty(propName, propValue) { outcome ->
@@ -1,138 +0,0 @@
package com.edde746.plezy.exoplayer
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
/**
* JNI surface for the libavformat demuxer shim (`cpp/media3_ffmpeg_demuxer`).
*
* One demuxer exists per process, owned by whichever [FfmpegExtractor] is
* currently open — Plezy plays at most one progressive source at a time, and
* every other playback mode (HLS/DASH, music) stays on media3's own sources.
*
* The long[] layouts mirror the constants documented in
* `ffmpeg_demuxer_jni.cc`; keep both sides in sync.
*/
@OptIn(UnstableApi::class)
internal object FfmpegDemuxerJni {
/** Re-entry point for the AVIO callbacks while a native call is in flight. */
interface Input {
/** Current byte position of the underlying [androidx.media3.extractor.ExtractorInput]. */
fun position(): Long
/**
* Reads up to [length] bytes into [buf]. Returns the number of bytes read,
* `0` at end of input, or `-1` after storing the failure in [lastError].
*/
fun read(buf: ByteArray, length: Int): Int
/** Total input length in bytes, or `-1` when unknown. */
fun length(): Long
var lastError: String?
}
// readPacket out[] indices. Deferral targets travel through
// [nativeConsumePendingSeek], not this array.
const val OUT_CODE = 0
const val OUT_STREAM_INDEX = 1
const val OUT_PTS_US = 2
const val OUT_FLAGS = 3
const val OUT_SIZE = 4
const val OUT_POSITION = 5
const val OUT_DURATION_US = 6
const val OUT_LENGTH = 7
// Result codes written to out[OUT_CODE]; negative values are raw AVERRORs.
const val CODE_PACKET = 0
const val CODE_EOF = 1
const val CODE_NEED_SEEK = 2
const val CODE_GROW = 3
const val ERR_JAVA = -102
// streamInfo long[] layout.
const val INFO_TRACK_TYPE = 0
const val INFO_WIDTH = 1
const val INFO_HEIGHT = 2
const val INFO_PAR_NUM = 3
const val INFO_PAR_DEN = 4
const val INFO_FPS_NUM = 5
const val INFO_FPS_DEN = 6
const val INFO_SAMPLE_RATE = 7
const val INFO_CHANNELS = 8
const val INFO_PCM_ENCODING = 9
const val INFO_ROTATION = 10
const val INFO_SELECTION_FLAGS = 11
const val INFO_ROLE_FLAGS = 12
const val INFO_BITRATE = 13
const val INFO_DOVI_PROFILE = 14
const val INFO_DOVI_LEVEL = 15
const val INFO_LATM = 16
const val INFO_LENGTH = 17
val available: Boolean = try {
System.loadLibrary("ffmpegJNI")
true
} catch (_: UnsatisfiedLinkError) {
false
}
/** Probes container bytes; returns the ffmpeg short name ("avi", "asf", …) or null. */
external fun nativeProbeFormat(header: ByteArray): String?
/**
* Opens (and resumes opening) the demuxer against [input]. Returns 0 on
* success, [CODE_NEED_SEEK] when the loader must move the input first, or a
* negative AVERROR. The stream count is read separately via
* [nativeStreamCount]; it must not ride the same namespace as result codes.
*/
external fun nativeOpen(input: Input): Int
external fun nativeClose()
external fun nativeStreamCount(): Int
/** Total duration in microseconds, or -1 when unknown. */
external fun nativeDurationUs(): Long
/** Fills [numbers]/[strings] for [index]; false when the stream is unusable. */
external fun nativeStreamInfo(index: Int, numbers: LongArray, strings: Array<String?>): Boolean
/** Codec initialization data (extradata), or null. */
external fun nativeStreamExtradata(index: Int): ByteArray?
/** Number of attachment streams (embedded fonts and friends) with payloads. */
external fun nativeAttachmentCount(): Int
/**
* Fills [strings] with [filename, mimetype] for attachment [ordinal] and
* returns the payload size in bytes, or -1 when out of range. Lets Kotlin
* enforce font budgets before copying any bytes across the boundary.
*/
external fun nativeAttachmentInfo(ordinal: Int, strings: Array<String?>): Long
/** Attachment payload bytes, or null. */
external fun nativeAttachmentData(ordinal: Int): ByteArray?
/**
* Reads one packet into [buffer], filling [out]. See the shim header
* comment for the result contract.
*/
external fun nativeReadPacket(buffer: ByteArray, out: LongArray): Int
/** Returns the recorded seek target, or [Long.MIN_VALUE] when none. */
external fun nativeConsumePendingSeek(): Long
/** Adopts [position] as the AVIO position; the input must already be there. */
external fun nativeResumeAfterSeek(position: Long): Int
/**
* Records a presentation-time seek (media3 timeline microseconds). The next
* [nativeReadPacket] executes it via avformat_seek_file, deferring through
* the loader as needed; the demuxer picks the byte position itself.
*/
external fun nativeSeekTo(timeUs: Long)
/** Drops cached header blocks; called when the extractor binds a new source. */
external fun nativeResetCache()
}
@@ -1,27 +0,0 @@
package com.edde746.plezy.exoplayer
/**
* Decides whether the FFmpeg demuxer sits ahead of media3's own extractors.
*
* FFmpeg demuxes every progressive container by default. media3's bundled
* extractors were weak exactly where this app kept accumulating container
* patches — AVI (XviD packed-bitstream timestamps, missing VOL csd — issue
* #2052), ASF/WMV (no extractor at all), MPEG-PS/VOB (no extractor), and
* Matroska (zlib-compressed subtitles, LOAS/LATM audio as A_MS/ACM, cueless
* seeking, font attachments) — and libavformat handles all of it natively.
* media3's extractors stay behind FFmpeg in the list, so a container FFmpeg
* fails to sniff still reaches media3's parsers. [Preference.MEDIA3_ONLY] is
* the user-facing escape hatch in case a file misbehaves on the FFmpeg path.
*/
internal object FfmpegDemuxerPolicy {
enum class Preference(val wireName: String) {
FFMPEG("ffmpeg"),
MEDIA3_ONLY("media3")
}
/** Unknown wire values (including the retired "auto") resolve to FFmpeg. */
fun fromWire(value: String?): Preference = Preference.entries.firstOrNull { it.wireName == value } ?: Preference.FFMPEG
/** Whether the FFmpeg extractor placed before media3's list demuxes at all. */
fun enabled(preference: Preference): Boolean = preference == Preference.FFMPEG
}
@@ -1,614 +0,0 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.ParserException
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.SeekPoint
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.text.SubtitleParser
import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput
import com.edde746.plezy.libass.media.AssFonts
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput
import java.io.IOException
/**
* Demuxes progressive containers with libavformat and feeds the packets to
* media3's renderers, so FFmpeg's container coverage replaces media3's on the
* paths where it is weak (AVI/XviD timestamps and VOL csd — #2052 — ASF/WMV,
* MPEG-PS) and where the app accumulated Matroska patches (zlib-compressed
* subtitles, LOAS/LATM audio, cueless seeking) without giving up any of the
* renderer-side work (passthrough carriers, DV sanitizing, libass rendering
* with embedded fonts, subtitle latency calibration).
*
* Text tracks are emitted in the exact sample shape media3's
* MatroskaExtractor writes (see [FfmpegSubtitleSamples]); the output chain
* composed in [init] then feeds libass dialogue and transcodes cues the same
* way the media3 Matroska path does.
*
* Seeking protocol: see `ffmpeg_demuxer_jni.cc`. The extractor never moves the
* input itself; position divergences abort the native call, and Kotlin either
* returns RESULT_SEEK or resumes in place when the loader already positioned
* the input at the requested byte.
*
* The instance sits before media3's extractors and sniffs everything FFmpeg
* can probe while [FfmpegDemuxerPolicy] enables it; media3's list behind it
* is the fallback for anything FFmpeg cannot open.
*/
@OptIn(UnstableApi::class)
internal class FfmpegExtractor private constructor(
private val preferenceSupplier: () -> FfmpegDemuxerPolicy.Preference,
private val dvMode: DvConversionMode,
private val subtitleParserFactory: SubtitleParser.Factory,
private val assHandler: AssHandler
) : Extractor {
companion object {
private const val TAG = "FfmpegExtractor"
private const val SNIFF_BYTES = 64 * 1024
private const val MAX_RECONCILES = 24
// Loader round trips (RESULT_SEEK) allowed for one item's whole open
// phase; matroska needs ~3, avi with an end-of-file index ~4.
private const val MAX_OPEN_LOADER_ROUND_TRIPS = 96
private const val INITIAL_PACKET_BUFFER_BYTES = 2 * 1024 * 1024
private const val AUDIO_INDEX_INTERVAL_US = 500_000L
/** Null when the native library is unavailable. */
fun create(
preferenceSupplier: () -> FfmpegDemuxerPolicy.Preference,
dvMode: DvConversionMode,
subtitleParserFactory: SubtitleParser.Factory,
assHandler: AssHandler
): FfmpegExtractor? = if (FfmpegDemuxerJni.available) {
FfmpegExtractor(preferenceSupplier, dvMode, subtitleParserFactory, assHandler)
} else {
null
}
}
private lateinit var output: ExtractorOutput
private var currentInput: ExtractorInput? = null
private val inputProxy = object : FfmpegDemuxerJni.Input {
override fun position(): Long = currentInput?.position ?: 0L
override fun read(buf: ByteArray, length: Int): Int {
val input = currentInput ?: return 0
lastError = null
return try {
val read = input.read(buf, 0, length)
if (read == C.RESULT_END_OF_INPUT) 0 else read
} catch (e: IOException) {
lastError = e.message ?: "demuxer input failed"
-1
}
}
override fun length(): Long {
val input = currentInput ?: return -1L
val length = input.length
return if (length == C.LENGTH_UNSET.toLong()) -1L else length
}
override var lastError: String? = null
}
private var opened = false
private var doviOutputWrapper: DoviExtractorOutputWrapper? = null
private var durationUs = C.TIME_UNSET
private var trackOutputs: Array<TrackOutput?> = emptyArray()
private var primaryStreamIndex = -1
private var primaryStreamIsAudio = false
private var packetBytes = ByteArray(INITIAL_PACKET_BUFFER_BYTES)
private val packetParsable = ParsableByteArray()
private val packetOut = LongArray(FfmpegDemuxerJni.OUT_LENGTH)
private val prefixParsable = ParsableByteArray()
// Per-stream text handling picked in prepareTracks; parallel to trackOutputs.
private var subtitleKinds: Array<SubtitleKind> = emptyArray()
private val latmOutputs = ArrayList<LatmTrackOutput>()
// Per-audio-stream measured bitrate, parallel to trackOutputs. Written by
// the loader thread, read by ExoPlayerCore.getStats on another thread.
@Volatile private var bitrateMeters: Array<StreamBitrateMeter?> = emptyArray()
// Seek index for the primary stream: (presentation time, input position) of
// video keyframes or periodic audio packets. The SeekMap reads it on the
// playback thread while the loader thread appends, hence the lock.
private val seekIndexTimes = ArrayList<Long>()
private val seekIndexPositions = ArrayList<Long>()
private val seekIndexLock = Any()
private val sniffScratch = ByteArray(SNIFF_BYTES)
override fun sniff(input: ExtractorInput): Boolean {
// media3 materializes the extractor array once per player session, so the
// policy must be read live at sniff time — a preference captured at
// construction would freeze for the session.
if (!FfmpegDemuxerPolicy.enabled(preferenceSupplier())) return false
val probed = probe(input) ?: return false
Log.i(TAG, "sniff accepted $probed")
return true
}
private fun probe(input: ExtractorInput): String? {
// create() only builds instances when the native library loaded.
input.resetPeekPosition()
return try {
val filled =
input.peekFully(
sniffScratch,
/* offset= */
0,
sniffScratch.size,
/* allowEndOfInput= */
true
)
if (!filled) return null
FfmpegDemuxerJni.nativeProbeFormat(sniffScratch)?.lowercase()
} catch (_: Throwable) {
// A sniffer must never take playback down: media3 escalates anything
// that is not an IOException into a fatal loader error.
null
} finally {
input.resetPeekPosition()
}
}
override fun init(output: ExtractorOutput) {
// media3 reuses extractor instances across sources; drop the previous
// item's header cache so this open cannot replay stale bytes.
FfmpegDemuxerJni.nativeResetCache()
openLoaderRoundTrips = 0
// Same composition as the MP4/MKV paths: DoviConvertingTrackOutput
// inspects each video track's codecs string and passes non-DV through.
val doviWrapped = if (dvMode != DvConversionMode.DISABLED) {
// The wrapper forwards emitLog into each converting track output; the
// playback-info hook stays wired to the MKV/MP4 wrappers, so nothing to
// capture here.
DoviExtractorOutputWrapper(
output,
dvMode,
emitLog = { level, prefix, message ->
Log.println(if (level == "error") Log.ERROR else Log.INFO, TAG, "$prefix $message")
},
onVideoTrackWrapped = { _ -> }
).also { doviOutputWrapper = it }
} else {
doviOutputWrapper = null
output
}
// Text pipeline, extractor-outward: raw media3-shape samples first hit
// AssTrackOutput (libass dialogue feed), then the transcoding wrapper
// parses them into cue samples. media3 does NOT wrap third-party
// extractors' outputs; without this wrapper no text track would ever
// reach the renderer as cues.
this.output = AssSubtitleExtractorOutput(
SubtitleTranscodingExtractorOutput(doviWrapped, subtitleParserFactory),
assHandler
)
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
currentInput = input
try {
if (!opened && !openStreams(input, seekPosition)) return Extractor.RESULT_SEEK
return readPacket(input, seekPosition)
} catch (e: Throwable) {
Log.w(TAG, "read threw at input=${input.position}", e)
throw e
} finally {
currentInput = null
}
}
/**
* Hands the presentation-time target to the demuxer: the next read runs
* avformat_seek_file, which picks the byte position and resets libavformat's
* parse state (jumping the AVIO position under the demuxer would leave
* demuxer-private state pointing into the old stream). The byte position
* media3 supplies is only its SeekMap guess; the loader converges on the
* demuxer's choice through the normal deferral round trip. The DV
* converter's buffered NAL state resets here as well.
*/
override fun seek(position: Long, timeUs: Long) {
Log.i(TAG, "extractor seek position=$position timeUs=$timeUs opened=$opened")
doviOutputWrapper?.resetTracks()
// Buffered LOAS bytes belong to the pre-seek stream; the StreamMuxConfig
// survives so audio resumes without waiting for the next config.
for (latm in latmOutputs) latm.reset()
if (opened) FfmpegDemuxerJni.nativeSeekTo(timeUs)
}
override fun release() {
if (opened) {
opened = false
FfmpegDemuxerJni.nativeClose()
}
// media3's extractors may demux a later item while this instance keeps
// the previous one's stream layout; a stale measurement must not leak
// into stats.
bitrateMeters = emptyArray()
}
/**
* Measured average bitrate (bps) of the audio stream whose index matches
* [formatId] (this extractor sets `Format.id` to the stream index), or null
* when this instance is not demuxing that stream. Fallback for containers
* that declare no per-track bitrate (#2063).
*/
fun measuredAudioBitrateBps(formatId: String?): Int? {
val index = formatId?.toIntOrNull() ?: return null
return bitrateMeters.getOrNull(index)?.bitrateBps()
}
// The in-call `reconciles` counter resets on every read() invocation, so it
// cannot bound RESULT_SEEK ping-pong across loader restarts. This budget
// spans the whole open of one item (reset in init) and turns a
// non-converging header phase into a clean fallback instead of a hang.
private var openLoaderRoundTrips = 0
/** Returns false when a loader seek was requested via [seekPosition]. */
private fun openStreams(input: ExtractorInput, seekPosition: PositionHolder): Boolean {
var reconciles = 0
while (true) {
when (val code = FfmpegDemuxerJni.nativeOpen(inputProxy)) {
0 -> {
prepareTracks()
opened = true
Log.i(TAG, "ffmpeg demuxer ready: ${trackOutputs.count { it != null }} tracks")
return true
}
FfmpegDemuxerJni.CODE_NEED_SEEK -> {
val target = FfmpegDemuxerJni.nativeConsumePendingSeek()
Log.i(TAG, "open: NEED_SEEK target=$target inputPos=${input.position}")
if (target == Long.MIN_VALUE || target < 0) {
// Sticky AVIO error replayed without a fresh deferral: re-sync at
// the loader's current position and continue (bounded below).
if (++reconciles > MAX_RECONCILES) {
throw malformed("demuxer produced an invalid seek target: $target")
}
if (FfmpegDemuxerJni.nativeResumeAfterSeek(input.position) != 0) {
throw malformed("demuxer resync failed at ${input.position}")
}
continue
}
if (++reconciles > MAX_RECONCILES) throw malformed("too many demuxer repositions")
if (!reconcile(input, seekPosition, target)) {
if (++openLoaderRoundTrips > MAX_OPEN_LOADER_ROUND_TRIPS) {
throw malformed("demuxer open did not converge after $openLoaderRoundTrips loader seeks")
}
return false
}
}
FfmpegDemuxerJni.ERR_JAVA ->
throw IOException(inputProxy.lastError ?: "demuxer input failed")
else -> throw malformed("ffmpeg demuxer open failed: $code")
}
}
}
private enum class SubtitleKind { NONE, SSA, SUBRIP, VTT }
private fun prepareTracks() {
val count = FfmpegDemuxerJni.nativeStreamCount()
durationUs = FfmpegDemuxerJni.nativeDurationUs().takeIf { it >= 0 } ?: C.TIME_UNSET
trackOutputs = arrayOfNulls(count)
subtitleKinds = Array(count) { SubtitleKind.NONE }
bitrateMeters = arrayOfNulls(count)
latmOutputs.clear()
// Fonts go in before any text track exists so AssHandler's store flushes
// them into libass when the first ASS track is created.
deliverFontAttachments()
val numbers = LongArray(FfmpegDemuxerJni.INFO_LENGTH)
val strings = arrayOfNulls<String>(3)
var primaryVideo = -1
var primaryAudio = -1
for (index in 0 until count) {
if (!FfmpegDemuxerJni.nativeStreamInfo(index, numbers, strings)) continue
val mime = strings[0] ?: continue
val trackType = when (numbers[FfmpegDemuxerJni.INFO_TRACK_TYPE]) {
0L -> C.TRACK_TYPE_VIDEO
1L -> C.TRACK_TYPE_AUDIO
2L -> C.TRACK_TYPE_TEXT
else -> continue
}
val builder = Format.Builder()
.setId(index.toString())
.setSampleMimeType(mime)
.setLanguage(strings[1])
.setLabel(strings[2])
.setSelectionFlags(numbers[FfmpegDemuxerJni.INFO_SELECTION_FLAGS].toInt())
.setRoleFlags(numbers[FfmpegDemuxerJni.INFO_ROLE_FLAGS].toInt())
.setAverageBitrate(numbers[FfmpegDemuxerJni.INFO_BITRATE].toInt())
val extradata = FfmpegDemuxerJni.nativeStreamExtradata(index)
if (extradata != null) {
builder.setInitializationData(listOf(extradata))
}
when (trackType) {
C.TRACK_TYPE_VIDEO -> {
// A Dolby Vision config record rides the stream as side data; the
// codecs string is what DoviConvertingTrackOutput keys on, exactly
// as it does for Matroska's CodecPrivate.
val doviProfile = numbers[FfmpegDemuxerJni.INFO_DOVI_PROFILE].toInt()
Log.i(TAG, "video track $index: doviProfile=$doviProfile mime=$mime")
if (doviProfile >= 0) {
val doviLevel = numbers[FfmpegDemuxerJni.INFO_DOVI_LEVEL].toInt().coerceAtLeast(0)
builder.setCodecs("dvh1.%02d.%02d".format(doviProfile, doviLevel))
}
val width = numbers[FfmpegDemuxerJni.INFO_WIDTH].toInt()
val height = numbers[FfmpegDemuxerJni.INFO_HEIGHT].toInt()
builder
.setWidth(width)
.setHeight(height)
.setRotationDegrees(numbers[FfmpegDemuxerJni.INFO_ROTATION].toInt())
val parNum = numbers[FfmpegDemuxerJni.INFO_PAR_NUM]
val parDen = numbers[FfmpegDemuxerJni.INFO_PAR_DEN]
if (parNum > 0 && parDen > 0 && parNum != parDen) {
builder.setPixelWidthHeightRatio(parNum.toFloat() / parDen.toFloat())
}
val fpsNum = numbers[FfmpegDemuxerJni.INFO_FPS_NUM]
val fpsDen = numbers[FfmpegDemuxerJni.INFO_FPS_DEN]
if (fpsNum > 0 && fpsDen > 0) {
builder.setFrameRate(fpsNum.toFloat() / fpsDen.toFloat())
}
if (primaryVideo < 0) {
primaryVideo = index
// libass needs the storage size before the first render; the
// Matroska path published it from the Tracks element the same way.
if (width > 0 && height > 0) assHandler.setVideoSize(width, height)
}
}
C.TRACK_TYPE_AUDIO -> {
builder
.setSampleRate(numbers[FfmpegDemuxerJni.INFO_SAMPLE_RATE].toInt())
.setChannelCount(numbers[FfmpegDemuxerJni.INFO_CHANNELS].toInt())
val pcmEncoding = numbers[FfmpegDemuxerJni.INFO_PCM_ENCODING].toInt()
if (pcmEncoding >= 0) builder.setPcmEncoding(pcmEncoding)
if (primaryAudio < 0) primaryAudio = index
bitrateMeters[index] = StreamBitrateMeter()
}
C.TRACK_TYPE_TEXT -> {
val kind = when (mime) {
MimeTypes.TEXT_SSA -> SubtitleKind.SSA
MimeTypes.APPLICATION_SUBRIP -> SubtitleKind.SUBRIP
MimeTypes.TEXT_VTT -> SubtitleKind.VTT
else -> SubtitleKind.NONE
}
subtitleKinds[index] = kind
if (kind == SubtitleKind.SSA) {
// Embedded ASS reaches libass as per-sample dialogue, never as a
// whole file: AssSubtitleParserFactory keys embedded handling off
// the Matroska container mime, and AssHeaderParser reads the
// header from initializationData[1] — the exact layout media3's
// MatroskaExtractor publishes.
builder.setContainerMimeType(MimeTypes.VIDEO_MATROSKA)
if (extradata != null) {
builder.setInitializationData(
listOf(FfmpegSubtitleSamples.SSA_DIALOGUE_FORMAT, extradata)
)
}
}
}
}
val rawOutput = output.track(index, trackType)
val trackOutput = if (trackType == C.TRACK_TYPE_AUDIO && numbers[FfmpegDemuxerJni.INFO_LATM] == 1L) {
// LOAS/LATM-framed AAC: unwrap to raw access units; the wrapper
// swallows this placeholder Format and emits the real one parsed from
// the StreamMuxConfig.
LatmTrackOutput(rawOutput, index).also { latmOutputs.add(it) }
} else {
rawOutput
}
trackOutput.format(builder.build())
trackOutputs[index] = trackOutput
}
primaryStreamIndex = if (primaryVideo >= 0) primaryVideo else primaryAudio
primaryStreamIsAudio = primaryVideo < 0 && primaryAudio >= 0
synchronized(seekIndexLock) {
seekIndexTimes.clear()
seekIndexPositions.clear()
}
output.endTracks()
output.seekMap(SeekMapImpl())
}
/** Hands embedded font attachments to libass, under the shared budgets. */
private fun deliverFontAttachments() {
val count = FfmpegDemuxerJni.nativeAttachmentCount()
if (count <= 0) return
var acceptedBytes = 0L
val meta = arrayOfNulls<String>(2)
for (ordinal in 0 until count) {
val size = FfmpegDemuxerJni.nativeAttachmentInfo(ordinal, meta)
if (size <= 0) continue
val name = meta[0] ?: continue
val mime = meta[1] ?: continue
if (mime !in AssFonts.fontMimeTypes) continue
val rejectionReason = AssFonts.rejectionReason(size, acceptedBytes)
if (rejectionReason != null) {
Log.w(TAG, "Skipping embedded font: $rejectionReason (bytes=$size, accepted=$acceptedBytes)")
continue
}
val data = FfmpegDemuxerJni.nativeAttachmentData(ordinal) ?: continue
acceptedBytes += size
assHandler.addFont(name, data)
}
}
private fun readPacket(input: ExtractorInput, seekPosition: PositionHolder): Int {
var reconciles = 0
while (true) {
val code = FfmpegDemuxerJni.nativeReadPacket(packetBytes, packetOut)
when (code) {
FfmpegDemuxerJni.CODE_PACKET -> {
val streamIndex = packetOut[FfmpegDemuxerJni.OUT_STREAM_INDEX].toInt()
val size = packetOut[FfmpegDemuxerJni.OUT_SIZE].toInt()
val trackOutput = trackOutputs.getOrNull(streamIndex) ?: continue
val ptsUs = packetOut[FfmpegDemuxerJni.OUT_PTS_US]
val isKeyframe = packetOut[FfmpegDemuxerJni.OUT_FLAGS] and 1L != 0L
val subtitleKind = subtitleKinds.getOrNull(streamIndex) ?: SubtitleKind.NONE
if (subtitleKind != SubtitleKind.NONE) {
// Text samples carry their duration inside the sample text, in
// media3's MatroskaExtractor shape; a sample without a duration
// cannot be displayed (same skip media3 performs).
val sampleDurationUs = packetOut[FfmpegDemuxerJni.OUT_DURATION_US]
if (sampleDurationUs <= 0) {
Log.w(TAG, "Skipping subtitle sample with no duration (stream=$streamIndex)")
continue
}
val prefix = when (subtitleKind) {
SubtitleKind.SSA -> FfmpegSubtitleSamples.ssaPrefix(sampleDurationUs)
SubtitleKind.SUBRIP -> FfmpegSubtitleSamples.subripPrefix(sampleDurationUs)
else -> FfmpegSubtitleSamples.vttPrefix(sampleDurationUs)
}
prefixParsable.reset(prefix, prefix.size)
trackOutput.sampleData(prefixParsable, prefix.size)
packetParsable.reset(packetBytes, size)
trackOutput.sampleData(packetParsable, size)
trackOutput.sampleMetadata(
ptsUs,
C.BUFFER_FLAG_KEY_FRAME,
prefix.size + size,
/* offset= */
0,
/* cryptoData= */
null
)
return Extractor.RESULT_CONTINUE
}
packetParsable.reset(packetBytes, size)
trackOutput.sampleData(packetParsable, size)
// Native drops packets without any timestamp, so ptsUs is always real.
trackOutput.sampleMetadata(
ptsUs,
if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0,
size,
/* offset= */
0,
/* cryptoData= */
null
)
recordSeekPoint(streamIndex, ptsUs, packetOut[FfmpegDemuxerJni.OUT_POSITION], isKeyframe)
bitrateMeters.getOrNull(streamIndex)?.onPacket(ptsUs, size)
return Extractor.RESULT_CONTINUE
}
FfmpegDemuxerJni.CODE_EOF -> return Extractor.RESULT_END_OF_INPUT
FfmpegDemuxerJni.CODE_NEED_SEEK -> {
val target = FfmpegDemuxerJni.nativeConsumePendingSeek()
if (target == Long.MIN_VALUE || target < 0) {
// Sticky AVIO error replayed without a fresh deferral: resync in
// place (bounded by reconciles) instead of killing the stream.
if (++reconciles > MAX_RECONCILES || !reSync(input)) {
throw malformed("demuxer produced an invalid seek target: $target")
}
continue
}
if (++reconciles > MAX_RECONCILES) throw malformed("too many demuxer repositions")
if (!reconcile(input, seekPosition, target)) return Extractor.RESULT_SEEK
}
FfmpegDemuxerJni.CODE_GROW -> {
val required = packetOut[FfmpegDemuxerJni.OUT_SIZE].toInt()
packetBytes = ByteArray(required + 64 * 1024)
}
FfmpegDemuxerJni.ERR_JAVA ->
throw IOException(inputProxy.lastError ?: "demuxer input failed")
else -> throw malformed("ffmpeg demuxer read failed: $code")
}
}
}
/** Re-establishes the AVIO position at the loader's current input position. */
private fun reSync(input: ExtractorInput): Boolean {
val position = input.position
return position >= 0 && FfmpegDemuxerJni.nativeResumeAfterSeek(position) == 0
}
/** Returns true when the input is already at [target] and native resumed. */
private fun reconcile(input: ExtractorInput, seekPosition: PositionHolder, target: Long): Boolean {
if (target == input.position) {
val code = FfmpegDemuxerJni.nativeResumeAfterSeek(target)
if (code != 0) throw malformed("demuxer resume failed: $code")
return true
}
seekPosition.position = target
return false
}
private fun recordSeekPoint(streamIndex: Int, timeUs: Long, position: Long, keyframe: Boolean) {
if (streamIndex != primaryStreamIndex || position <= 0) return
if (!primaryStreamIsAudio && !keyframe) return
synchronized(seekIndexLock) {
val lastIndex = seekIndexTimes.size - 1
if (lastIndex >= 0) {
if (timeUs <= seekIndexTimes[lastIndex]) return
if (primaryStreamIsAudio && timeUs - seekIndexTimes[lastIndex] < AUDIO_INDEX_INTERVAL_US) {
return
}
}
seekIndexTimes.add(timeUs)
seekIndexPositions.add(position)
}
}
private fun malformed(message: String): ParserException = ParserException.createForMalformedContainer(message, null)
private inner class SeekMapImpl : SeekMap {
// Seeks are executed by the demuxer (avformat_seek_file), so seekability
// does not depend on the sample-derived index below — that index only
// improves the loader's initial byte guess. The timeline is published
// from this value once, at prepare time, when the index is still empty.
override fun isSeekable(): Boolean = this@FfmpegExtractor.durationUs != C.TIME_UNSET
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
synchronized(seekIndexLock) {
if (seekIndexTimes.isEmpty()) return SeekMap.SeekPoints(SeekPoint(0, 0))
val index = seekIndexTimes.binarySearchFloor(timeUs)
if (index < 0) return SeekMap.SeekPoints(SeekPoint(seekIndexTimes[0], seekIndexPositions[0]))
val preceding = SeekPoint(seekIndexTimes[index], seekIndexPositions[index])
if (index + 1 >= seekIndexTimes.size || seekIndexTimes[index] == timeUs) {
return SeekMap.SeekPoints(preceding)
}
val following = SeekPoint(seekIndexTimes[index + 1], seekIndexPositions[index + 1])
return SeekMap.SeekPoints(preceding, following)
}
}
// Qualified on purpose: bare `durationUs` binds to the inherited Java
// getter as a synthetic property (this.getDurationUs()) and recurses.
override fun getDurationUs(): Long = this@FfmpegExtractor.durationUs
}
}
/** Floor binary search over an ascending list; returns the greatest index with value <= target. */
private fun List<Long>.binarySearchFloor(value: Long): Int {
var low = 0
var high = size - 1
var result = -1
while (low <= high) {
val mid = (low + high) ushr 1
if (this[mid] <= value) {
result = mid
low = mid + 1
} else {
high = mid - 1
}
}
return result
}
@@ -1,46 +0,0 @@
package com.edde746.plezy.exoplayer
import java.util.Locale
/**
* Builds text subtitle samples in the exact shape media3's MatroskaExtractor
* writes, so the same downstream consumers work behind the FFmpeg demuxer:
* the bundled SsaParser/SubripParser/WebvttParser (via
* SubtitleTranscodingExtractorOutput) and AssTrackOutput's dialogue feed.
*
* MatroskaExtractor prefixes each raw payload with a synthetic header whose
* *second* timecode carries the sample duration (BlockDuration), while the
* first stays zero; parsers reconstruct absolute times as sampleTimeUs +
* parsedStart. FFmpeg hands us the same raw payloads (an ASS packet is the
* MKV block: "ReadOrder,Layer,Style,...,Text"; SubRip/WebVTT packets are the
* bare cue text), so only the prefix needs synthesizing.
*/
internal object FfmpegSubtitleSamples {
val SSA_DIALOGUE_FORMAT: ByteArray =
"Format: Start, End, ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
.toByteArray(Charsets.UTF_8)
/** `Dialogue: 0:00:00:00,H:MM:SS:CC,` — centiseconds, ':' separator (media3's SSA shape). */
fun ssaPrefix(durationUs: Long): ByteArray = "Dialogue: 0:00:00:00,${timecode(durationUs, "%01d:%02d:%02d:%02d", 10_000)},"
.toByteArray(Charsets.UTF_8)
/** `1\n00:00:00,000 --> HH:MM:SS,mmm\n` — a complete SRT cue header. */
fun subripPrefix(durationUs: Long): ByteArray = "1\n00:00:00,000 --> ${timecode(durationUs, "%02d:%02d:%02d,%03d", 1_000)}\n"
.toByteArray(Charsets.UTF_8)
/** `WEBVTT\n\n00:00:00.000 --> HH:MM:SS.mmm\n` — a one-cue WebVTT document header. */
fun vttPrefix(durationUs: Long): ByteArray = "WEBVTT\n\n00:00:00.000 --> ${timecode(durationUs, "%02d:%02d:%02d.%03d", 1_000)}\n"
.toByteArray(Charsets.UTF_8)
private fun timecode(timeUs: Long, format: String, lastValueDivisor: Long): String {
val hours = timeUs / 3_600_000_000L
var remainder = timeUs - hours * 3_600_000_000L
val minutes = remainder / 60_000_000L
remainder -= minutes * 60_000_000L
val seconds = remainder / 1_000_000L
remainder -= seconds * 1_000_000L
val lastValue = remainder / lastValueDivisor
return String.format(Locale.US, format, hours, minutes, seconds, lastValue)
}
}
@@ -0,0 +1,65 @@
package com.edde746.plezy.exoplayer
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
/** MKV CodecID for Microsoft ACM compatibility mode. */
private const val CODEC_ID_ACM = "A_MS/ACM"
/**
* MatroskaExtractor.init is final and extractorOutput private; subclasses swap
* in wrapping outputs via reflection once the Segment element starts (shared
* with ZlibMatroskaExtractor).
*/
internal val matroskaExtractorOutputField by lazy {
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
}
/** WAVEFORMATEX format tag for LOAS/LATM-wrapped AAC (WAVE_FORMAT_MPEG_LOAS). */
private const val WAVE_FORMAT_MPEG_LOAS = 0x1602
/**
* Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM — ffmpeg's
* fallback mapping for aac_latm, which Matroska has no native codec ID for.
* The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of CodecPrivate.
*/
fun isLoasAcmTrack(codecId: String?, codecPrivate: ByteArray?): Boolean = codecId == CODEC_ID_ACM &&
codecPrivate != null &&
codecPrivate.size >= 2 &&
((codecPrivate[0].toInt() and 0xFF) or ((codecPrivate[1].toInt() and 0xFF) shl 8)) == WAVE_FORMAT_MPEG_LOAS
/**
* ExtractorOutput wrapper that wraps marked tracks with [LatmTrackOutput].
* Call [markNextTrackLatm] before the parent extractor creates the track
* (i.e. before super.endMasterElement(ID_TRACK_ENTRY)).
*/
class LatmExtractorOutputWrapper(
private val delegate: ExtractorOutput
) : ExtractorOutput {
private var nextTrackIsLatm = false
private val latmOutputs = mutableListOf<LatmTrackOutput>()
fun markNextTrackLatm() {
nextTrackIsLatm = true
}
/** Resets LATM parser state after an extractor seek. */
fun resetTracks() {
latmOutputs.forEach { it.reset() }
}
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
if (!nextTrackIsLatm) return original
nextTrackIsLatm = false
return LatmTrackOutput(original, id).also { latmOutputs.add(it) }
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
@@ -1,31 +0,0 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
/**
* Delegates everything to [delegate] but hands it a wrapped [ExtractorOutput].
* Used to compose the text pipeline (libass dialogue feed + subtitle cue
* transcoding) around media3's raw-subtitle MatroskaExtractor without
* subclassing or reflection.
*/
@UnstableApi
internal class OutputWrappingExtractor(
private val delegate: Extractor,
private val wrap: (ExtractorOutput) -> ExtractorOutput
) : Extractor {
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) = delegate.init(wrap(output))
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition)
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
override fun release() = delegate.release()
override fun getUnderlyingImplementation(): Extractor = delegate.underlyingImplementation
}
@@ -1,65 +0,0 @@
package com.edde746.plezy.exoplayer
/**
* Rolling average bitrate of one demuxed stream, measured from packet sizes
* over their presentation-time span — the same source of truth mpv uses for
* its `audio-bitrate` property. It exists because containers frequently
* declare no per-track bitrate (Matroska without BPS statistics tags), which
* left the performance overlay with nothing to show (#2063).
*
* Fed from the loader thread ([onPacket]) and read from the stats path
* ([bitrateBps]); both are synchronized. The measurement follows the demux
* position, which runs ahead of the playhead by the buffered duration —
* acceptable for a diagnostics overlay.
*/
internal class StreamBitrateMeter(private val windowUs: Long = DEFAULT_WINDOW_US) {
private val ptsUsQueue = ArrayDeque<Long>()
private val sizeQueue = ArrayDeque<Int>()
private var windowBytes = 0L
@Synchronized
fun onPacket(ptsUs: Long, sizeBytes: Int) {
if (sizeBytes <= 0) return
// A backwards pts jump is a seek; packets on either side of it do not
// form a contiguous span, so the window restarts.
val last = ptsUsQueue.lastOrNull()
if (last != null && ptsUs < last) reset()
ptsUsQueue.addLast(ptsUs)
sizeQueue.addLast(sizeBytes)
windowBytes += sizeBytes
while (ptsUsQueue.size > 1 && ptsUsQueue.last() - ptsUsQueue.first() > windowUs) {
ptsUsQueue.removeFirst()
windowBytes -= sizeQueue.removeFirst()
}
}
/**
* Average bitrate in bits per second over the current window, or null until
* enough packets span a measurable interval.
*/
@Synchronized
fun bitrateBps(): Int? {
if (ptsUsQueue.size < MIN_PACKETS) return null
val spanUs = ptsUsQueue.last() - ptsUsQueue.first()
if (spanUs <= 0) return null
// The pts span covers the durations of every packet except the last one,
// whose bytes are therefore excluded — a constant-rate stream measures
// exactly its rate.
val bytes = windowBytes - sizeQueue.last()
if (bytes <= 0) return null
return (bytes * 8_000_000L / spanUs).toInt()
}
@Synchronized
fun reset() {
ptsUsQueue.clear()
sizeQueue.clear()
windowBytes = 0L
}
companion object {
private const val DEFAULT_WINDOW_US = 10_000_000L
private const val MIN_PACKETS = 16
}
}
@@ -0,0 +1,91 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.ParserException
import androidx.media3.extractor.TrackOutput
import java.util.zip.DataFormatException
import java.util.zip.Inflater
/**
* TrackOutput wrapper that inflates zlib-compressed sample data (MKV ContentCompAlgo 0).
* Each MKV block is independently zlib-compressed; this wrapper decompresses per-sample
* between sampleData() and sampleMetadata() calls.
*
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class ZlibInflatingTrackOutput(
delegate: TrackOutput
) : BufferedTransformingTrackOutput(
delegate,
INITIAL_BUFFER_SIZE,
INFLATE_CHUNK,
MAX_COMPRESSED_SAMPLE_SIZE
) {
companion object {
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val INFLATE_CHUNK = 64 * 1024
private const val MAX_COMPRESSED_SAMPLE_SIZE = 16 * 1024 * 1024
private const val MAX_INFLATED_SAMPLE_SIZE = 16 * 1024 * 1024
private const val MAX_COMPRESSION_RATIO = 1024L
private const val MIN_RATIO_ALLOWANCE = 1024L * 1024
}
var active = false
private val inflater = Inflater()
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
private val overflowProbe = ByteArray(1)
override val transformEnabled: Boolean
get() = active
override val transformedBuffer: ByteArray
get() = inflateBuf
override fun transformSample(inputLength: Int, flags: Int): Int {
inflater.reset()
inflater.setInput(inputBuffer, 0, inputLength)
var written = 0
val ratioBound = maxOf(MIN_RATIO_ALLOWANCE, inputLength.toLong() * MAX_COMPRESSION_RATIO)
try {
while (true) {
if (written == inflateBuf.size) {
if (inflateBuf.size < MAX_INFLATED_SAMPLE_SIZE) {
val nextSize = minOf(MAX_INFLATED_SAMPLE_SIZE, inflateBuf.size * 2)
inflateBuf = inflateBuf.copyOf(nextSize)
} else {
val overflow = inflater.inflate(overflowProbe, 0, 1)
if (overflow > 0) {
throw malformed("Inflated sample exceeds the maximum size")
}
if (inflater.finished()) return written
throw stalledInflate()
}
}
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
written += count
if (written.toLong() > ratioBound) {
throw malformed("Inflated sample exceeds the maximum compression ratio")
}
if (inflater.finished()) return written
if (count == 0) throw stalledInflate()
}
} catch (_: DataFormatException) {
// Preserve playback for corrupt subtitle blocks; the raw sample remains bounded
// by MAX_COMPRESSED_SAMPLE_SIZE and matches the pre-hardening fallback.
if (inflateBuf.size < inputLength) inflateBuf = inflateBuf.copyOf(inputLength)
inputBuffer.copyInto(inflateBuf, endIndex = inputLength)
return inputLength
}
}
private fun stalledInflate(): ParserException = when {
inflater.needsDictionary() -> malformed("Zlib-compressed sample requires a dictionary")
inflater.needsInput() -> malformed("Truncated zlib-compressed sample")
else -> malformed("Zlib decompressor made no progress")
}
private fun malformed(message: String, cause: Throwable? = null): ParserException = ParserException.createForMalformedContainer(message, cause)
}
@@ -0,0 +1,315 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.extractor.DefaultExtractorInput
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.text.SubtitleParser
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
import java.io.EOFException
import java.util.zip.DataFormatException
import java.util.zip.Inflater
/**
* Extends AssMatroskaExtractor to add support for MKV quirks media3 rejects:
*
* ContentCompAlgo 0 (zlib) — media3 only supports ContentCompAlgo 3 (header
* stripping). This subclass intercepts the compression algorithm during track
* header parsing:
* - Tells the parent it's header stripping (algo 3) to avoid the ParserException
* - Skips ContentCompSettings for zlib tracks (not applicable)
* - For text subtitle tracks, inflates the block payload *before* the parent
* parses it (see below). For every other zlib track, wraps TrackOutputs with
* ZlibInflatingTrackOutput to decompress per-sample data.
*
* Text subtitle tracks (SRT/ASS/SSA/VTT) cannot be inflated at the TrackOutput
* level: MatroskaExtractor rewrites their samples in-place before any TrackOutput
* runs — it prepends a plaintext timecode prefix ("Dialogue: 0:00:00:00,…,") to
* the still-compressed payload and truncates the sample at the first NUL byte,
* which deflate streams routinely contain (#2023). AssTrackOutput additionally
* feeds that same internal buffer straight to libass. So for zlib text tracks the
* frame payload is inflated at the block level, before the parent's sample
* assembly, and the TrackOutput wrapper is left inactive.
*
* LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM
* tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput,
* which unwraps LOAS frames to raw AAC for direct-playing Matroska files.
*/
class ZlibMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
assHandler: AssHandler
) : AssMatroskaExtractor(subtitleParserFactory, assHandler) {
companion object {
private const val TAG = "ZlibMkvExtractor"
// Matroska EBML element IDs
private const val ID_SEGMENT = 0x18538067
private const val ID_TRACK_ENTRY = 0xAE
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
private const val ID_CONTENT_COMPRESSION = 0x5034
private const val ID_SIMPLE_BLOCK = 0xA3
private const val ID_BLOCK = 0xA1
/**
* Codec IDs whose samples MatroskaExtractor rewrites in-place (timecode prefix
* plus NUL truncation) before any TrackOutput runs — the authority is the
* prefix list in MatroskaExtractor.writeSampleData.
*/
private val TEXT_SUBTITLE_CODEC_IDS = setOf("S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT")
/** Subtitle blocks are tiny; anything above this passes through untouched. */
private const val MAX_TEXT_BLOCK_BYTES = 16 * 1024 * 1024
}
private var zlibOutput: ZlibExtractorOutputWrapper? = null
private var latmOutput: LatmExtractorOutputWrapper? = null
private var currentTrackUsesZlib = false
/** Track numbers whose blocks are inflated before the parent parses them. */
private val zlibTextTrackNumbers = mutableSetOf<Int>()
private val blockInflater = Inflater()
private var blockBuf = ByteArray(0)
private val peekBuf = ByteArray(8)
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// ContentCompAlgo DEFAULTS to 0 (zlib), so mkvmerge omits the element for
// zlib tracks entirely — the mere presence of ContentCompression means zlib
// until an explicit ContentCompAlgo says otherwise. media3 ignores an empty
// ContentCompression and would silently emit compressed samples.
if (id == ID_CONTENT_COMPRESSION) {
currentTrackUsesZlib = true
Log.i(TAG, "Track has ContentCompression, assuming ContentCompAlgo 0 (zlib) until told otherwise")
}
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib +
// LATM layers (zlib outermost so inflation runs before LATM parsing).
if (id == ID_SEGMENT && zlibOutput == null) {
val currentOutput = matroskaExtractorOutputField.get(this) as ExtractorOutput
val latmWrapper = LatmExtractorOutputWrapper(currentOutput)
latmOutput = latmWrapper
val wrapper = ZlibExtractorOutputWrapper(latmWrapper)
zlibOutput = wrapper
matroskaExtractorOutputField.set(this, wrapper)
Log.d(TAG, "Installed zlib+LATM ExtractorOutput wrapper")
}
}
override fun integerElement(id: Int, value: Long) {
if (id == ID_CONTENT_COMPRESSION_ALGORITHM) {
if (value == 0L) {
currentTrackUsesZlib = true
Log.i(TAG, "Track uses explicit ContentCompAlgo 0 (zlib), will inflate samples")
// Tell parent it's header stripping (algo 3) to avoid ParserException
super.integerElement(id, 3)
return
}
// Explicit non-zlib algorithm: header stripping (3) is handled by the
// parent; anything else makes the parent throw, matching stock behavior.
currentTrackUsesZlib = false
}
super.integerElement(id, value)
}
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) {
// Skip ContentCompSettings for zlib tracks — parent would store these as
// sampleStrippedBytes and prepend them to every sample, corrupting output.
input.skipFully(contentSize)
return
}
if ((id == ID_SIMPLE_BLOCK || id == ID_BLOCK) &&
zlibTextTrackNumbers.isNotEmpty() &&
contentSize in MIN_BLOCK_BYTES..MAX_TEXT_BLOCK_BYTES &&
peekBlockTrackNumber(input) in zlibTextTrackNumbers
) {
inflateTextBlock(id, contentSize, input)
return
}
super.binaryElement(id, contentSize, input)
}
override fun endMasterElement(id: Int) {
var zlibTextTrackNumber: Int? = null
if (id == ID_TRACK_ENTRY) {
// Must inspect before super — the track output is created inside super's
// endMasterElement, and the current track is cleared afterwards.
val track = getCurrentTrack(id)
if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
latmOutput?.markNextTrackLatm()
}
if (currentTrackUsesZlib && track.codecId in TEXT_SUBTITLE_CODEC_IDS) {
zlibTextTrackNumber = track.number
}
}
val wasZlib = currentTrackUsesZlib
super.endMasterElement(id)
if (id == ID_TRACK_ENTRY && wasZlib) {
currentTrackUsesZlib = false
if (zlibTextTrackNumber != null) {
// Text subtitle samples are rewritten inside the parent before any
// TrackOutput runs, so the TrackOutput wrapper stays inactive and the
// block payload is inflated in binaryElement instead.
zlibTextTrackNumbers.add(zlibTextTrackNumber)
Log.i(TAG, "Track $zlibTextTrackNumber is a zlib text subtitle track, inflating at block level")
} else {
zlibOutput?.activateLast()
Log.i(TAG, "Activated zlib inflation for track")
}
}
}
override fun seek(position: Long, timeUs: Long) {
latmOutput?.resetTracks()
zlibOutput?.resetTracks()
super.seek(position, timeUs)
}
/**
* Peeks the EBML varint at the block start — the block's track number — without
* consuming input. Returns null for malformed varints or truncated input; the
* parent then produces the canonical failure for the untouched stream.
*/
private fun peekBlockTrackNumber(input: ExtractorInput): Int? {
try {
input.peekFully(peekBuf, 0, 1)
val first = peekBuf[0].toInt() and 0xFF
if (first == 0) return null
val length = Integer.numberOfLeadingZeros(first) - 23
var value = (first and (0xFF ushr length)).toLong()
if (length > 1) {
input.peekFully(peekBuf, 1, length - 1)
for (i in 1 until length) {
value = (value shl 8) or (peekBuf[i].toLong() and 0xFF)
}
}
return if (value <= Int.MAX_VALUE) value.toInt() else null
} catch (_: EOFException) {
return null
} finally {
input.resetPeekPosition()
}
}
/**
* Buffers one text-subtitle block, inflates its frame payload, and hands the
* parent a block whose payload is plaintext. A block that cannot be rewritten
* (laced, corrupt, or over-bound) passes through byte-identical.
*/
private fun inflateTextBlock(id: Int, contentSize: Int, input: ExtractorInput) {
val basePosition = input.position
if (blockBuf.size < contentSize) blockBuf = ByteArray(maxOf(contentSize, blockBuf.size * 2))
input.readFully(blockBuf, 0, contentSize)
val rewritten = rewriteZlibTextBlock(blockBuf, contentSize, blockInflater)
if (rewritten == null) {
Log.w(TAG, "Passing zlib text block through uninflated (laced, corrupt, or over-bound)")
super.binaryElement(id, contentSize, bufferedInput(blockBuf, contentSize, basePosition))
} else {
super.binaryElement(id, rewritten.size, bufferedInput(rewritten, rewritten.size, basePosition))
}
}
private fun bufferedInput(data: ByteArray, limit: Int, position: Long): ExtractorInput = DefaultExtractorInput(ByteRangeDataReader(data, limit), position, position + limit)
private class ByteRangeDataReader(private val data: ByteArray, private val limit: Int) : DataReader {
private var position = 0
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (position == limit) return C.RESULT_END_OF_INPUT
val count = minOf(length, limit - position)
System.arraycopy(data, position, buffer, offset, count)
position += count
return count
}
}
/**
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
* Tracks are created inactive; activateLast() enables inflation for the most recently
* created track (called when we know a track uses zlib compression).
*/
private class ZlibExtractorOutputWrapper(
private val delegate: ExtractorOutput
) : ExtractorOutput {
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
private val trackOutputs = mutableListOf<ZlibInflatingTrackOutput>()
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
return ZlibInflatingTrackOutput(original).also {
trackOutputs.add(it)
lastCreatedWrapper = it
}
}
fun activateLast() {
lastCreatedWrapper?.active = true
}
fun resetTracks() {
trackOutputs.forEach { it.resetBufferedData() }
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
}
/** Smallest well-formed unlaced block: 1-byte varint + 2-byte timecode + flags + 1 payload byte. */
internal const val MIN_BLOCK_BYTES = 5
/**
* Rewrites one unlaced Matroska block whose frame payload is zlib-compressed:
* returns the unchanged block header followed by the inflated payload, or null
* when the block must pass through unchanged — lacing (never produced for text
* subtitle tracks), a truncated or corrupt deflate stream, or an inflated size
* beyond the bounds shared with [ZlibInflatingTrackOutput]'s hardening.
*/
internal fun rewriteZlibTextBlock(data: ByteArray, limit: Int, inflater: Inflater): ByteArray? {
if (limit < MIN_BLOCK_BYTES) return null
val first = data[0].toInt() and 0xFF
if (first == 0) return null // track-number varint longer than 8 bytes
val varintLength = Integer.numberOfLeadingZeros(first) - 23
val headerLength = varintLength + 3 // varint + 2-byte timecode + flags
if (limit <= headerLength) return null
if (data[headerLength - 1].toInt() and 0x06 != 0) return null // laced
val payloadLength = limit - headerLength
val ratioBound = maxOf(1024L * 1024, payloadLength.toLong() * 1024)
val maxInflatedBytes = 16 * 1024 * 1024
inflater.reset()
inflater.setInput(data, headerLength, payloadLength)
var buf = ByteArray(maxOf(4096, payloadLength * 4))
var written = 0
try {
while (true) {
if (written == buf.size) {
if (buf.size >= maxInflatedBytes) return null
buf = buf.copyOf(minOf(maxInflatedBytes, buf.size * 2))
}
val count = inflater.inflate(buf, written, buf.size - written)
written += count
if (written > ratioBound) return null
if (inflater.finished()) break
if (count == 0) return null // truncated stream or preset-dictionary request
}
} catch (_: DataFormatException) {
return null
}
return ByteArray(headerLength + written).also {
System.arraycopy(data, 0, it, 0, headerLength)
System.arraycopy(buf, 0, it, headerLength, written)
}
}
@@ -0,0 +1,28 @@
package com.edde746.plezy.mpv
/**
* Demuxer cache budget derived from the device heap class.
*
* mpv has no device-memory awareness: `demuxer-max-bytes` defaults to a fixed
* 150 MiB forward (+50 MiB back) on every device and the demuxer fills
* whatever it is allowed, which crowds a 1 GB TV box until Android's
* low-memory killer takes the whole app. Tiered off
* [android.app.ActivityManager.getLargeMemoryClass], the same signal
* `stream_buffer_sizing.dart` and ExoPlayer's `LoadControlPolicy` use.
*
* Applied as pre-init *options* in [MpvPlayerCore], so a `demuxer-max-bytes`
* line in the user's mpv.conf still wins.
*/
data class DemuxerBudget(val aheadBytes: Long, val backBytes: Long) {
companion object {
private const val MIB = 1024L * 1024L
/** Null for an unknown class (<= 0): callers keep mpv's own defaults. */
fun forHeapClassMB(largeMemoryClassMB: Int): DemuxerBudget? = when {
largeMemoryClassMB <= 0 -> null
largeMemoryClassMB <= 256 -> DemuxerBudget(aheadBytes = 32 * MIB, backBytes = 16 * MIB)
largeMemoryClassMB <= 512 -> DemuxerBudget(aheadBytes = 64 * MIB, backBytes = 32 * MIB)
else -> DemuxerBudget(aheadBytes = 100 * MIB, backBytes = 48 * MIB)
}
}
}
@@ -0,0 +1,74 @@
package com.edde746.plezy.mpv
import android.opengl.EGL14
/**
* Which mpv `egl-output-format` this device can pair with a BT.2020 PQ window
* surface, or null when HDR GL output is unavailable. 10-bit fixed point is
* preferred; fp16 is the fallback because some drivers (Tegra among them)
* expose the PQ colorspace but no 1010102 window config.
*
* Both halves are required by the mpv side: the fork's android GL context asks
* for `EGL_EXT_gl_colorspace_bt2020_pq` on the window surface, and the app
* pairs it with an `egl-output-format` that makes mpv's EGL config selection
* fail outright when no matching config exists - so this probe must be
* consulted before those options are ever set.
*/
internal object EglHdrCaps {
private const val EGL_COLOR_COMPONENT_TYPE_EXT = 0x3339
private const val EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT = 0x333B
private object Unprobed
@Volatile private var cached: Any? = Unprobed
fun pqOutputFormat(): String? {
val value = cached
if (value !== Unprobed) return value as String?
return probe().also { cached = it }
}
private fun probe(): String? {
val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
if (display == EGL14.EGL_NO_DISPLAY) return null
val version = IntArray(2)
// Deliberately no eglTerminate: the default display is process-global and
// Flutter's renderer shares it; terminating would invalidate its state.
if (!EGL14.eglInitialize(display, version, 0, version, 1)) return null
val extensions = EGL14.eglQueryString(display, EGL14.EGL_EXTENSIONS) ?: return null
if (!extensions.contains("EGL_EXT_gl_colorspace_bt2020_pq")) return null
if (hasWindowConfig(display, intArrayOf(EGL14.EGL_RED_SIZE, 10, EGL14.EGL_GREEN_SIZE, 10, EGL14.EGL_BLUE_SIZE, 10, EGL14.EGL_ALPHA_SIZE, 2))) {
return "rgb10_a2"
}
if (extensions.contains("EGL_EXT_pixel_format_float") &&
hasWindowConfig(
display,
intArrayOf(
EGL14.EGL_RED_SIZE,
16,
EGL14.EGL_GREEN_SIZE,
16,
EGL14.EGL_BLUE_SIZE,
16,
EGL_COLOR_COMPONENT_TYPE_EXT,
EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT
)
)
) {
return "rgba16f"
}
return null
}
private fun hasWindowConfig(display: android.opengl.EGLDisplay, extra: IntArray): Boolean {
val attribs = intArrayOf(
EGL14.EGL_SURFACE_TYPE,
EGL14.EGL_WINDOW_BIT,
EGL14.EGL_RENDERABLE_TYPE,
EGL14.EGL_OPENGL_ES2_BIT
) + extra + intArrayOf(EGL14.EGL_NONE)
val numConfigs = IntArray(1)
if (!EGL14.eglChooseConfig(display, attribs, 0, null, 0, 0, numConfigs, 0)) return false
return numConfigs[0] > 0
}
}
@@ -0,0 +1,60 @@
package com.edde746.plezy.mpv
/**
* Pure policy for when an mpv session must leave the video plane
* (vo=mediacodec) for a GL video output, and which one. Kept free of player
* and platform state so the routing matrix is unit-testable.
*/
internal object GpuVoPolicy {
/**
* Single-layer Dolby Vision Profile 5 (IPT-PQ-c2) has no compatible base
* layer: on a device without native DV support it decodes as plain HEVC
* with garbage colors, and the video plane applies no reshaping. gpu-next
* (libplacebo) under software decode is the only Android path that
* composites the RPU metadata (#1902). [dvProfile] comes from mpv's
* track-list — the bitstream's DOVI configuration record — never from
* server metadata, which mis-tags DV routinely. Only `auto` routes; the
* other modes are explicit user choices.
*/
fun needsDvReshaping(dvProfile: Long?, conversionMode: String, canPlayP5Natively: Boolean): Boolean = dvProfile == 5L && conversionMode == "auto" && !canPlayP5Natively
/**
* Whether an HDR signal has nowhere to tone-map: the video plane hands
* PQ/HLG straight to a display pipeline that advertises no HDR output, so
* it renders washed out (#2121). The GL vo tone-maps in the render chain.
*/
fun needsHdrToneMapping(gamma: String?, displaySupportsHdr: Boolean): Boolean = (gamma == "pq" || gamma == "hlg") && !displaySupportsHdr
/**
* Whether the decoder is handing mpv software frames, from `hwdec-current`.
*
* The plane refuses every format but MediaCodec buffers, so a per-file
* decode fallback (AV1 on Tegra, Hi10 without a profile match) has to move
* to a GL vo. Routing on this gets there before mpv fails the chain and
* [REASON_CHAIN_FAILURE] has to catch it.
*/
fun needsSoftwareRender(hwdecCurrent: String?): Boolean = !hwdecCurrent.isNullOrBlank() && hwdecCurrent != "mediacodec"
/**
* The vo a session with these active requirements should run, or null for
* the video plane.
*
* dv-reshape is the only reason that needs gpu-next, since libplacebo is
* what composites the RPU. Everything else takes gpu, the battle-tested
* GLES renderer on the Android device zoo. (The magenta field gpu-next
* used to render for 10-bit software frames on Tegra was its AV1 film
* grain shader overrunning the driver's uniform register budget; grain is
* decoder-applied now, but gpu-next buys this path nothing over gpu.)
*/
fun targetFor(reasons: Set<String>): String? = when {
reasons.isEmpty() -> null
REASON_DV_RESHAPE in reasons -> "gpu-next"
else -> "gpu"
}
const val REASON_DV_RESHAPE = "dv-reshape"
const val REASON_SHADERS = "shaders"
const val REASON_CHAIN_FAILURE = "chain-failure"
const val REASON_HDR_SDR = "hdr-sdr"
const val REASON_SW_DECODE = "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 {
@@ -1,6 +1,7 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.graphics.PixelFormat
import android.media.AudioAttributes
@@ -15,12 +16,13 @@ import android.view.SurfaceView
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.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
@@ -63,24 +65,41 @@ class MpvPlayerCore private constructor(
* The initial `vo` chain, decided by whether this session will hardware-
* decode.
*
* gpu-next (libplacebo) is the only Android path that applies Dolby Vision
* RPU reshaping (#1902), but reshaping only ever happens under software
* decode: FFmpeg's mediacodec wrapper exports no DOVI side data, so a
* hardware-decoded stream renders the untouched base layer on any VO.
* Hardware decode is also where gpu-next breaks: it samples the decoder
* output as a samplerExternalOES that libplacebo declares in both shader
* stages, and the Tegra GLES linker rejects that pair ("struct type
* mismatch between shaders for uniform"), failing every frame — a solid
* blue screen with audio on the Shield (#2010). The in-chain gpu fallback
* cannot catch it because gpu-next initializes fine and only fails
* per-frame renders.
* Hardware sessions use the fork vo=mediacodec: decoded buffers go from
* MediaCodec straight to the compositor with per-frame presentation
* timestamps - no GLES pass, 10-bit and the decoder's dataspace
* (HDR10/HLG) intact - and subtitles/OSD render on the sibling OSD
* surface. The plane takes decoder buffers only and refuses the rest, so
* a per-file decode fallback moves to a GL vo
* ([GpuVoPolicy.needsSoftwareRender], with the chain-failure watchdog as
* the backstop). gpu stays in the chain for preinit failure.
*
* So gpu-next is offered exactly where it can help — sessions that will
* software-decode — and hardware sessions keep the legacy gpu VO. A
* mid-session hwdec fallback to software stays on vo=gpu, which renders
* software frames correctly (the pre-2.15.0 behavior for every session).
* Software sessions run gpu,gpu-next: gpu is the battle-tested GLES
* renderer on the Android device zoo, and with film grain applied by the
* decoder nothing else on this path needs libplacebo. Dolby Vision RPU
* reshaping (#1902) is the one exception - it needs gpu-next, and the
* [GpuVoPolicy.REASON_DV_RESHAPE] observer moves the session there when a
* DV profile that needs reshaping appears. gpu-next under *hardware*
* decode is broken on Tegra (samplerExternalOES double declaration
* rejected by the GLES linker, blue screen on the Shield, #2010);
* vo=mediacodec sidesteps that entire class by never touching GLES.
*/
internal fun initialVideoOutput(hardwareDecoding: Boolean): String = if (hardwareDecoding) "gpu" else "gpu-next,gpu"
internal fun initialVideoOutput(hardwareDecoding: Boolean): String = if (hardwareDecoding) "mediacodec,gpu" else "gpu,gpu-next"
/**
* The `-append` list-option suffixes are not exposed through the property
* interface, so the app's decoder options replace the whole list. FFmpeg
* keeps the last duplicate key, so any user mpv.conf entries go first.
*/
internal fun mergeDecoderOptions(current: String?, ours: String): String = if (current.isNullOrBlank()) ours else "$current,$ours"
/**
* Whether content with this transfer is worth an HDR (BT.2020 PQ) GL
* surface. PQ and HLG both render into a PQ target; everything else -
* including unknown - stays on the default sRGB surface, which renders
* every content correctly (HDR arrives tone-mapped, as before).
*/
internal fun wantsHdrSurface(transfer: String?): Boolean = transfer == "smpte2084" || transfer == "arib-std-b67"
}
/** Video-only paths. The plugin always constructs video cores with the
@@ -89,7 +108,57 @@ class MpvPlayerCore private constructor(
get() = context as Activity
private var surfaceView: SurfaceView? = null
private var osdSurfaceView: SurfaceView? = null
private var surfaceContainer: android.widget.FrameLayout? = null
@Volatile private var pendingOsdSurface: Surface? = null
@Volatile private var attachedOsdSurface: Surface? = null
/** Active reasons the session must render off the plane. */
private val gpuVoReasons = LinkedHashSet<String>()
/** The GL vo this session is running, or null for the video plane. Non-null
* gates off the plane-only machinery: OSD attach, aspect-fitted layout,
* 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
private val hwdecBeforeDvReshape = java.util.concurrent.atomic.AtomicReference<String?>()
/** Last `dv-conversion-mode` Dart applied; input to the per-file DV
* routing policy. */
@Volatile private var currentDvConversionMode: String = "auto"
/** Whether this core already decided its GL surface colorspace; set by the
* first `content-color-transfer` announcement ([applyContentColorTransfer]). */
@Volatile private var hdrSurfaceDecided: Boolean = false
/** Whether this session outputs HDR to an HDR-capable display — via the PQ
* GL surface or the MediaCodec plane's decoder dataspace. Gates the
* deferred display-mode restore on teardown (see
* [FrameRateManager.clearVideoFrameRate]). */
@Volatile private var hdrDisplayActive: Boolean = false
@Volatile private var videoDisplayWidth: Int = 0
@Volatile private var videoDisplayHeight: Int = 0
/** Latest `panscan` (0..1) and `video-zoom` (log2) the app applied. The
* plane owns scaling, so these are view geometry here; see
* [applyVideoRectLayout]. */
@Volatile private var videoPanscan: Float = 0f
@Volatile private var videoZoomLog2: Float = 0f
/** Hardware sessions render through the fork vo=mediacodec (see
* [initialVideoOutput]); the OSD surface and video-rect layout exist only
* there. */
private val usesMediaCodecVo: Boolean
get() = !audioOnly && hardwareDecoding
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
@Volatile private var disposing: Boolean = false
@@ -260,6 +329,22 @@ class MpvPlayerCore private constructor(
lastAppliedSurfaceSize = null
lastKnownSurfaceWidth = 0
lastKnownSurfaceHeight = 0
// Video-output state, so a re-initialized core does not start stranded
// off the plane with a stale reason set.
synchronized(gpuVoReasons) {
gpuVoReasons.clear()
activeGpuVoTarget = null
}
hwdecBeforeDvReshape.set(null)
dvReshapeActive = false
attachedOsdSurface = null
videoDisplayWidth = 0
videoDisplayHeight = 0
videoPanscan = 0f
videoZoomLog2 = 0f
currentDvConversionMode = "auto"
hdrSurfaceDecided = false
hdrDisplayActive = false
if (!audioOnly) ensurePlaceholderSurface()
// Initialize audio focus handling. mpv has none built in, so both modes
@@ -287,6 +372,10 @@ class MpvPlayerCore private constructor(
surfaceContainer = PlayerSurfaceHost.createContainer(activity)
surfaceView = PlayerSurfaceHost.createVideoSurface(activity, this@MpvPlayerCore)
surfaceContainer!!.addView(surfaceView)
if (usesMediaCodecVo) {
osdSurfaceView = PlayerSurfaceHost.createOsdSurface(activity, osdSurfaceCallback)
surfaceContainer!!.addView(osdSurfaceView)
}
val contentView = PlayerSurfaceHost.attachToContent(activity, surfaceContainer!!)
flutterOverlayApplied = PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
@@ -295,6 +384,7 @@ class MpvPlayerCore private constructor(
ensureFlutterOverlayOnTop()
val sv = surfaceView
if (sv != null) applySurfaceSize(sv.width, sv.height)
applyVideoRectLayout()
}
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
@@ -308,6 +398,11 @@ class MpvPlayerCore private constructor(
return@launch
}
val displayFpsOverride = currentDisplayFpsOverride()
// Both core kinds cap their demuxer cache off the device heap class;
// rationale on DemuxerBudget. Null (unknown class) keeps mpv defaults.
val demuxerBudget = DemuxerBudget.forHeapClassMB(
(context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.largeMemoryClass ?: 0
)
val p = MpvPlayer.create(context.applicationContext) {
if (audioOnly) {
// Pure audio core (all set before mpv_initialize, mirroring the
@@ -322,15 +417,25 @@ class MpvPlayerCore private constructor(
setOption("gapless-audio", "weak")
} else {
// vo choice is decode-path-dependent; rationale on
// initialVideoOutput. Film grain is left on its `auto` default:
// applied by the VO under gpu-next, by the decoder under gpu.
// initialVideoOutput.
setOption("vo", initialVideoOutput(hardwareDecoding))
setOption("gpu-context", "android")
setOption("opengl-es", "yes")
// Keep AV1 film grain inside the decoder (dav1d). `auto` hands it
// to any vo claiming VO_CAP_FILM_GRAIN, and gpu-next claims it on
// GLES where libplacebo's raster grain fallback fetches luma by
// fragcoord (bottom-up) but chroma by uv: the luma renders
// upside-down (measured on a Shield Pro; desktop GL is unaffected
// because grain runs as a compute pass there).
setOption("vd-lavc-film-grain", "cpu")
if (displayFpsOverride != null) {
setOption("display-fps-override", displayFpsOverride)
}
}
if (demuxerBudget != null) {
setOption("demuxer-max-bytes", demuxerBudget.aheadBytes.toString())
setOption("demuxer-max-back-bytes", demuxerBudget.backBytes.toString())
}
setOption("ao", "audiotrack,opensles")
// Pause on the last frame at EOF instead of unloading the file, so a
// seek after the video ends still works (matches Linux/Windows).
@@ -342,6 +447,13 @@ class MpvPlayerCore private constructor(
// builtin script during mpv_initialize, hence an option here.
setOption("ytdl", "no")
}
if (demuxerBudget != null) {
Log.d(
TAG,
"Demuxer budget: ${demuxerBudget.aheadBytes / (1024 * 1024)}MB ahead, " +
"${demuxerBudget.backBytes / (1024 * 1024)}MB back"
)
}
if (displayFpsOverride != null) {
Log.d(TAG, "Initial display-fps-override=$displayFpsOverride")
}
@@ -361,6 +473,12 @@ class MpvPlayerCore private constructor(
collectEvents(p)
collectPropertyChanges(p)
collectLogMessages(p)
if (usesMediaCodecVo) {
collectVideoDimensions(p)
collectShaderState(p)
collectHdrToneMapState(p)
collectDecoderState(p)
}
Log.d(TAG, "Initialized successfully")
onResult(true)
@@ -397,11 +515,28 @@ class MpvPlayerCore private constructor(
}
is MpvEvent.StartFile -> {
endFileDiagnostics.onStartFile()
// The trigger is per-file (an exotic pixel format, a gralloc
// refusal for that stream), so give the plane back to the next
// 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)
}
is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null)
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)
}
is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null)
else -> {}
}
}
}
@@ -433,6 +568,18 @@ class MpvPlayerCore private constructor(
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.logFlow.collect { msg ->
endFileDiagnostics.onLogMessage(msg)
// A chain-init failure is the one runtime signal that frames cannot
// reach the video plane at all (exotic pixel formats, gralloc
// refusal). mpv is pinned in the fork, so the log line is a stable
// contract.
if (usesMediaCodecVo &&
activeGpuVoTarget == null &&
msg.prefix.startsWith("cplayer") &&
msg.text.contains("Could not initialize video chain")
) {
Log.w(TAG, "Video chain init failed under vo=mediacodec; leaving the video plane")
setGpuVoRequirement(GpuVoPolicy.REASON_CHAIN_FAILURE, true)
}
emitLog(msg.level.name.lowercase(), msg.prefix, msg.text)
}
}
@@ -484,6 +631,300 @@ class MpvPlayerCore private constructor(
detachSurfaceInternal(reason = "surfaceDestroyed")
}
// OSD surface (the vo=mediacodec subtitle/OSD plane)
private val osdSurfaceCallback = object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
if (disposing) return
pendingOsdSurface = holder.surface.takeIf { it.isValid }
Log.d(TAG, "OSD surface created")
if (player != null && hasAttachedRealSurface()) {
refreshVideoOutput("osdSurfaceCreated")
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d(TAG, "OSD surface destroyed")
pendingOsdSurface = null
val wasAttached = attachedOsdSurface != null
attachedOsdSurface = null
val p = player ?: return
if (disposing || !wasAttached) return
// Ordered against every other mpv write: a detach that overtook the
// re-attach of a recreated surface would clear the option the attach
// just set, and nothing re-arms it — subtitles would stay dead for the
// rest of the session.
scope.launch(mpvWriteDispatcher) {
try {
p.detachOsdSurface()
} catch (e: Exception) {
Log.w(TAG, "Failed to detach OSD surface", e)
}
}
}
}
/**
* Hands the OSD Surface to the vo. The vo reads the option when it is
* created, so this has to run before the `vo` or `wid` write that creates
* it, never after.
*/
private fun attachOsdSurfaceIfNeeded(p: MpvPlayer) {
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
val osd = pendingOsdSurface?.takeIf { it.isValid } ?: return
if (osd === attachedOsdSurface) return
p.attachOsdSurface(osd)
attachedOsdSurface = osd
Log.d(TAG, "Attached OSD surface for vo=mediacodec")
}
private fun collectVideoDimensions(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeInt("dwidth").collect { value ->
val w = value.toInt()
if (w > 0 && w != videoDisplayWidth) {
videoDisplayWidth = w
applyVideoRectLayout()
}
}
}
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeInt("dheight").collect { value ->
val h = value.toInt()
if (h > 0 && h != videoDisplayHeight) {
videoDisplayHeight = h
applyVideoRectLayout()
}
}
}
}
/**
* Arbiter for this session's video output: the active [GpuVoPolicy] reasons
* decide whether the session runs on the video plane or on a GL vo.
*/
private fun setGpuVoRequirement(reason: String, active: Boolean) {
if (!usesMediaCodecVo || disposing) return
val transition = synchronized(gpuVoReasons) {
val changed = if (active) gpuVoReasons.add(reason) else gpuVoReasons.remove(reason)
if (!changed) return
val desired = GpuVoPolicy.targetFor(gpuVoReasons)
if (desired == activeGpuVoTarget) return
val line = "${activeGpuVoTarget ?: "mediacodec"} -> ${desired ?: "mediacodec"} " +
"(reasons=[${gpuVoReasons.joinToString(",")}])"
activeGpuVoTarget = desired
line
}
Log.i(TAG, "Video output: $transition")
applyGpuVoTarget()
}
/**
* Moves the session to whatever the arbiter last decided.
*
* The decision is atomic under [gpuVoReasons], but the write cannot be:
* it has to leave the lock to reach mpv. Reasons are raised from different
* threads — per-file DV routing runs on [mpvWriteDispatcher], the gamma,
* shader and chain-failure observers on the main thread — so the order
* writes are *enqueued* is not the order decisions were *made*. Rather
* than trust the target its caller saw, every transition re-reads the
* current one here, which makes the last write the right one under any
* interleaving. Serialized on [mpvWriteDispatcher], so the paired main
* thread work stays in the same order too.
*/
private fun applyGpuVoTarget() {
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val target = synchronized(gpuVoReasons) { activeGpuVoTarget }
runOnMain {
if (disposing) return@runOnMain
if (target == null) {
osdSurfaceView?.visibility = View.VISIBLE
} else {
// The gpu VOs draw their own OSD; a stale subtitle frame must not
// linger on the overlay plane.
osdSurfaceView?.visibility = View.GONE
resetVideoSurfaceToFullContainer()
}
}
try {
// Before the vo write, which recreates the VO: it reads the OSD
// surface option at creation.
val p = player
if (target == null && p != null) attachOsdSurfaceIfNeeded(p)
writeProperty("vo", target ?: "mediacodec")
if (p == null) return@launch
if (target != null) {
// A failed conversion chain makes mpv deselect the video track
// ("Could not initialize video chain" -> vid=no) before the VO
// switch lands. Re-select the explicit track id: mid-file "auto"
// resolves to no selection rather than re-running selection.
if (p.getString("vid").let { it == null || it == "no" }) {
val videoTrackId = videoTracks(p).firstOrNull()?.optLong("id")
if (videoTrackId != null) {
Log.i(TAG, "Re-selecting video track $videoTrackId after chain failure")
writeProperty("vid", videoTrackId.toString())
}
}
}
applySurfaceSizeInternal(p, force = true)
if (target == null) {
// Refit the surfaces to the aspect rectangle now that the plane
// owns scaling again (the gpu VOs letterboxed within full
// containers).
applyVideoRectLayout()
}
} catch (e: CancellationException) {
Log.d(TAG, "Canceled vo transition write")
} catch (e: Exception) {
Log.w(TAG, "Failed to move the video output to ${target ?: "mediacodec"}", e)
}
}
}
/**
* Per-file Dolby Vision routing, decided from the bitstream: mpv exports
* the DOVI configuration record's profile on the track list (never trust
* server metadata for this — it mis-tags DV routinely). Re-evaluated on
* every file-loaded, so a following non-P5 file restores hardware decode
* and returns to the video plane.
*/
private suspend fun applyDvReshapePolicy(p: MpvPlayer) {
val profile = selectedVideoDvProfile(p)
val needs = GpuVoPolicy.needsDvReshaping(
dvProfile = profile,
conversionMode = currentDvConversionMode,
canPlayP5Natively = DoviBridge.canPlayDolbyVisionP5(context)
)
if (needs == dvReshapeActive) return
dvReshapeActive = needs
if (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)
}
/**
* Selected video track's Dolby Vision profile, or null for non-DV content
* (mpv omits the field when the bitstream carries no DOVI configuration
* record).
*/
private suspend fun selectedVideoDvProfile(p: MpvPlayer): Long? = videoTracks(p).firstOrNull()
?.takeIf { it.has("dolby-vision-profile") }
?.getLong("dolby-vision-profile")
/**
* Observed rather than derived from the hardware-decoding setting because
* the fallback is decided per file, inside mpv. Why it matters:
* [GpuVoPolicy.needsSoftwareRender].
*/
private fun collectDecoderState(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeString("hwdec-current").collect { value ->
setGpuVoRequirement(GpuVoPolicy.REASON_SW_DECODE, GpuVoPolicy.needsSoftwareRender(value))
}
}
}
/**
* User shaders need a GL vo; the video plane renders none. Observed
* natively so Dart's `glsl-shaders` change-list writes (ShaderService,
* ambient lighting) switch the session live, without a channel contract.
*/
private fun collectShaderState(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeString("glsl-shaders").collect { value ->
setGpuVoRequirement(GpuVoPolicy.REASON_SHADERS, value.isNotBlank())
}
}
}
/**
* Observed from video-params so the reason follows per-file transfer
* changes, and the display is re-queried per change so an HDMI mode switch
* mid-session is honoured on the next file. Why it matters:
* [GpuVoPolicy.needsHdrToneMapping].
*/
private fun collectHdrToneMapState(p: MpvPlayer) {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
p.observeString("video-params/gamma").collect { value ->
val needsToneMap = GpuVoPolicy.needsHdrToneMapping(
gamma = value,
displaySupportsHdr = DoviBridge.displaySupportsHdr(context)
)
setGpuVoRequirement(GpuVoPolicy.REASON_HDR_SDR, needsToneMap)
}
}
}
/** Video tracks from mpv's track list, selected first; empty on any parse failure. */
private suspend fun videoTracks(p: MpvPlayer): List<org.json.JSONObject> {
val json = p.getString("track-list") ?: return emptyList()
return try {
val tracks = org.json.JSONArray(json)
(0 until tracks.length())
.map { tracks.getJSONObject(it) }
.filter { it.optString("type") == "video" }
.sortedByDescending { it.optBoolean("selected") }
} catch (e: Exception) {
Log.w(TAG, "Failed to parse track-list", e)
emptyList()
}
}
/**
* Sizes the video surface to the rectangle the image should occupy, per
* [VideoRectPolicy], and lets the container clip the overflow.
*
* The OSD surface is left full-container: the vo builds its `mp_osd_res`
* from the OSD window's own size, so libass keeps the whole window as its
* canvas — subtitles sit in the letterbox bars as they did under vo=gpu,
* and stay on screen when a zoomed image runs past the container.
*/
private fun applyVideoRectLayout() {
if (!usesMediaCodecVo || activeGpuVoTarget != null) return
runOnMain {
if (disposing) return@runOnMain
val container = surfaceContainer ?: return@runOnMain
val size = VideoRectPolicy.sizeFor(
containerWidth = container.width,
containerHeight = container.height,
videoWidth = videoDisplayWidth,
videoHeight = videoDisplayHeight,
panscan = videoPanscan,
videoZoomLog2 = videoZoomLog2
) ?: return@runOnMain
// The guard matters: this runs from an OnGlobalLayoutListener, so an
// unconditional write would re-trigger layout forever.
surfaceView?.let { view ->
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
if (lp.width != size.width || lp.height != size.height || lp.gravity != android.view.Gravity.CENTER) {
lp.width = size.width
lp.height = size.height
lp.gravity = android.view.Gravity.CENTER
view.layoutParams = lp
}
}
}
}
private fun resetVideoSurfaceToFullContainer() {
val view = surfaceView ?: return
val lp = view.layoutParams as android.widget.FrameLayout.LayoutParams
if (lp.width == android.widget.FrameLayout.LayoutParams.MATCH_PARENT) return
lp.width = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
lp.height = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
lp.gravity = android.view.Gravity.NO_GRAVITY
view.layoutParams = lp
}
private fun rememberSurfaceSize(width: Int, height: Int) {
if (width <= 0 || height <= 0) return
lastKnownSurfaceWidth = width
@@ -559,10 +1000,15 @@ class MpvPlayerCore private constructor(
return@withLock
}
val needsAttach = !hasAttachedSurface || attachedSurface !== surface
// An unattached OSD Surface forces a wid re-attach: the VO reads the
// OSD surface option at creation, and setting wid recreates the VO.
// Suppressed off the plane, where the GL vo draws its own OSD.
val osdNeedsAttach = activeGpuVoTarget == null && pendingOsdSurface !== attachedOsdSurface
val needsAttach = !hasAttachedSurface || attachedSurface !== surface || osdNeedsAttach
val wasAttachedToPlaceholder = attachedToPlaceholder
val wasPausedForSurfaceLoss = pausedForSurfaceLoss
if (needsAttach) {
attachOsdSurfaceIfNeeded(p)
p.attachSurface(surface)
attachedSurface = surface
hasAttachedSurface = true
@@ -880,12 +1326,136 @@ class MpvPlayerCore private constructor(
Log.d(TAG, "Load pause intent updated: paused=$paused")
}
/**
* `dv-conversion-mode` is an app-level property shared with the ExoPlayer
* and Apple cores, not an mpv one. It maps onto the fork FFmpeg
* hevc_mediacodec decoder options, mirroring the ExoPlayer DoviBridge
* decision tree. Single-layer profiles (5/8) use the Dolby Vision decoder
* whenever the path is enabled and the decoder advertises the profile.
*/
private fun applyDvConversionMode(value: String, onComplete: ((Result<Unit>) -> Unit)?) {
val displayDv = DoviBridge.displaySupportsDolbyVision(context)
val (dolbyVision, p7Mode) = when (value.trim().lowercase()) {
"auto" -> if (displayDv) "1" to "auto" else "0" to "strip"
"disabled", "native" -> "1" to "native"
"dv81" -> "1" to "convert"
"hevc", "hevc_strip" -> "1" to "strip"
else -> {
onComplete?.invoke(Result.failure(IllegalArgumentException("Invalid DV conversion mode: $value")))
return
}
}
currentDvConversionMode = value.trim().lowercase()
Log.i(TAG, "DV conversion mode '$value' (displayDV=$displayDv) -> dolby_vision=$dolbyVision dv_p7_mode=$p7Mode")
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val completion = try {
val ours = "dolby_vision=$dolbyVision,dv_p7_mode=$p7Mode"
val merged = mergeDecoderOptions(player?.getString("vd-lavc-o"), ours)
writeProperty("vd-lavc-o", merged)
Result.success(Unit)
} catch (error: CancellationException) {
Result.failure(error)
} catch (error: Exception) {
Log.w(TAG, "DV conversion mode write failed")
Result.failure(error)
}
withContext(NonCancellable + Dispatchers.Main) {
onComplete?.invoke(completion)
}
}
}
/**
* `content-color-transfer` is an app-level property: Dart announces the
* selected stream's transfer (server metadata) before playback so an HDR
* session can get a BT.2020 PQ 10-bit GL surface instead of tone-mapped
* SDR. Consumed by whichever android GL context the session ever creates -
* up front for a software session, or at the fallback transition when a
* plane session leaves vo=mediacodec (the plane itself carries HDR via the
* decoder's dataspace and ignores all of this).
*
* The first announcement decides for the whole core: the surface colorspace
* is fixed at EGL-surface creation, and both latched states stay correct
* for later files (a PQ target renders SDR content correctly, an sRGB
* surface tone-maps HDR as before) - re-deciding mid-session could pair a
* live sRGB surface with a PQ render target, which is wrong everywhere.
*/
private fun applyContentColorTransfer(value: String, onComplete: ((Result<Unit>) -> Unit)?) {
val transfer = value.trim().lowercase()
if (hdrSurfaceDecided) {
onComplete?.invoke(Result.success(Unit))
return
}
hdrSurfaceDecided = true
val wants = wantsHdrSurface(transfer)
val displayHdr = wants && DoviBridge.displaySupportsHdr(context)
// Independent of the GL surface outcome: on the MediaCodec plane the
// decoder's dataspace carries HDR to the display without a PQ GL surface.
hdrDisplayActive = displayHdr
val outputFormat = if (wants) EglHdrCaps.pqOutputFormat() else null
if (!wants || !displayHdr || outputFormat == null) {
if (wants) {
Log.i(TAG, "HDR GL surface unavailable (transfer=$transfer displayHdr=$displayHdr eglFormat=$outputFormat)")
}
onComplete?.invoke(Result.success(Unit))
return
}
Log.i(TAG, "HDR GL surface engaged: BT.2020 PQ / $outputFormat for transfer=$transfer")
scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) {
val completion = try {
writeProperty("android-surface-colorspace", "bt2020-pq")
writeProperty("egl-output-format", outputFormat)
writeProperty("target-trc", "pq")
writeProperty("target-prim", "bt.2020")
Result.success(Unit)
} catch (error: CancellationException) {
Result.failure(error)
} catch (error: Exception) {
Log.w(TAG, "HDR surface property write failed")
Result.failure(error)
}
withContext(NonCancellable + Dispatchers.Main) {
onComplete?.invoke(completion)
}
}
}
fun setProperty(name: String, value: String, onComplete: ((Result<Unit>) -> Unit)? = null) {
if (!isInitialized || disposing || !scope.isActive) {
onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable")))
return
}
if (name == "dv-conversion-mode") {
applyDvConversionMode(value, onComplete)
return
}
if (name == "content-color-transfer") {
applyContentColorTransfer(value, onComplete)
return
}
// View geometry on the plane (see VideoRectPolicy), but both still fall
// through to mpv, which is what makes them work unchanged on the GL vos.
if (name == "panscan" || name == "video-zoom") {
val parsed = value.toFloatOrNull()
if (parsed != null) {
if (name == "panscan") videoPanscan = parsed else videoZoomLog2 = parsed
applyVideoRectLayout()
}
}
// 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)
onComplete?.invoke(Result.success(Unit))
return
}
val paused = if (name == "pause") normalizePauseValue(value) else null
val pauseIntent = paused?.let {
synchronized(publicPauseIntentLock) {
@@ -1223,7 +1793,7 @@ class MpvPlayerCore private constructor(
}
override fun clearVideoFrameRate() {
frameRateManager?.clearVideoFrameRate()
frameRateManager?.clearVideoFrameRate(hdrActive = hdrDisplayActive)
}
// Cleanup
@@ -1274,11 +1844,15 @@ class MpvPlayerCore private constructor(
// Capture locals for deferred cleanup (audio-only has no views)
val sv = surfaceView
val osdSv = osdSurfaceView
val container = surfaceContainer
val contentView = if (audioOnly) null else activity.findViewById<ViewGroup>(android.R.id.content)
surfaceContainer = null
surfaceView = null
osdSurfaceView = null
pendingOsdSurface = null
attachedOsdSurface = null
// Remove layout listener synchronously
overlayLayoutListener?.let { listener ->
@@ -1330,6 +1904,7 @@ class MpvPlayerCore private constructor(
Log.d(TAG, "Disposed (native)")
Handler(Looper.getMainLooper()).post {
sv?.holder?.removeCallback(this)
osdSv?.holder?.removeCallback(osdSurfaceCallback)
if (container?.parent != null) {
contentView?.removeView(container)
}
@@ -1340,6 +1915,7 @@ class MpvPlayerCore private constructor(
// No player — safe to remove views immediately
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
sv?.holder?.removeCallback(this)
osdSv?.holder?.removeCallback(osdSurfaceCallback)
if (container?.parent != null) {
contentView?.removeView(container)
}
@@ -1,6 +1,7 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
@@ -14,6 +15,7 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicBoolean
internal fun completeMpvPropertyResult(
result: MethodChannel.Result,
@@ -62,6 +64,17 @@ open class MpvPlayerPlugin(
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
// The Dart instanceId that created the current core. A `dispose` carrying a
// different token lost the ownership race to a successor and must not tear
// 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.
private val disposeWatchdogMs = 6_000L
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
@@ -95,6 +108,7 @@ open class MpvPlayerPlugin(
++sessionGeneration
val core = playerCore
playerCore = null
coreInstanceId = null
cancelPendingInits()
return core
}
@@ -149,7 +163,7 @@ open class MpvPlayerPlugin(
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialize" -> handleInitialize(call, result)
"dispose" -> handleDispose(result)
"dispose" -> handleDispose(call, result)
"setProperty" -> handleSetProperty(call, result)
"getProperty" -> handleGetProperty(call, result)
"getStats" -> handleGetStats(result)
@@ -164,6 +178,13 @@ open class MpvPlayerPlugin(
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
"openContentFd" -> handleOpenContentFd(call, result)
"closeContentFd" -> handleCloseContentFd(call, result)
"getHeapSize" -> {
// Device heap class for Dart-side memory tiering (stream ring cache).
// Lives on the always-registered mpv channel so it survives backends.
val context: Context? = activity ?: applicationContext
val am = context?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
result.success(am?.largeMemoryClass ?: 0)
}
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
"setLogLevel" -> handleSetLogLevel(call, result)
else -> result.notImplemented()
@@ -247,6 +268,7 @@ open class MpvPlayerPlugin(
delegate = this@MpvPlayerPlugin
}
playerCore = core
coreInstanceId = call.argument<Number>("instanceId")?.toLong()
} catch (e: Exception) {
Log.e(tag, "Failed to initialize: ${e.message}", e)
completePendingInits(attempt, success = false, errorMessage = e.message)
@@ -258,7 +280,10 @@ open class MpvPlayerPlugin(
playerCore !== core ||
!isCurrentInitAttempt(attempt)
if (stale || !success) {
if (playerCore === core) playerCore = null
if (playerCore === core) {
playerCore = null
coreInstanceId = null
}
core.dispose()
if (stale) {
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
@@ -315,13 +340,37 @@ open class MpvPlayerPlugin(
}
}
private fun handleDispose(result: MethodChannel.Result) {
private fun handleDispose(call: MethodCall, result: MethodChannel.Result) {
val token = call.argument<Number>("instanceId")?.toLong()
runOnMain {
val core = takeCoreForTeardown()
core?.dispose {
Log.d(tag, "Disposed")
val owner = coreInstanceId
if (playerCore != null && token != null && owner != null && token != owner) {
// This dispose lost the ownership race: a successor already created
// the current core. Acknowledge without touching it.
Log.d(tag, "Ignoring stale dispose (token=$token, core owner=$owner)")
result.success(null)
} ?: result.success(null)
return@runOnMain
}
val core = takeCoreForTeardown()
if (core == null) {
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.
val completed = AtomicBoolean(false)
fun completeOnce(reason: String) {
if (completed.compareAndSet(false, true)) {
Log.d(tag, reason)
result.success(null)
}
}
channels.mainHandler.postDelayed({
completeOnce("Dispose watchdog fired after ${disposeWatchdogMs}ms; teardown continues in background")
}, disposeWatchdogMs)
core.dispose { completeOnce("Disposed") }
}
}
@@ -0,0 +1,53 @@
package com.edde746.plezy.mpv
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
/**
* Pure geometry for the video plane's surface rectangle.
*
* The fork `vo=mediacodec` scales decoded buffers to the whole Surface and
* implements none of mpv's src/dst rect math, so aspect, cover and zoom are
* view geometry: the video surface is sized to the rectangle the image should
* occupy and the container clips whatever falls outside it. That mirrors the
* ExoPlayer path, which scales its own surface and keeps the subtitle overlay
* on the full-size container.
*/
internal object VideoRectPolicy {
/**
* `video-zoom` is reachable from the user's mpv.conf, where an absurd value
* is a compositor allocation rather than just arithmetic. Bounds it well
* outside the range the player's own zoom control offers.
*/
private const val MAX_ZOOM_LOG2 = 2f
data class Size(val width: Int, val height: Int)
/**
* Size for the video surface, or null when a dimension is not known yet.
*
* [panscan] is mpv's 0..1 property: 0 fits the image inside the container
* (letterbox), 1 fills it (crop), values between interpolate the scale.
* [videoZoomLog2] is mpv's `video-zoom`, a log2 factor, applied on top.
*/
fun sizeFor(
containerWidth: Int,
containerHeight: Int,
videoWidth: Int,
videoHeight: Int,
panscan: Float = 0f,
videoZoomLog2: Float = 0f
): Size? {
if (containerWidth <= 0 || containerHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) return null
val fit = min(containerWidth.toFloat() / videoWidth, containerHeight.toFloat() / videoHeight)
val cover = max(containerWidth.toFloat() / videoWidth, containerHeight.toFloat() / videoHeight)
val pan = panscan.coerceIn(0f, 1f)
val zoom = 2.0f.pow(videoZoomLog2.coerceIn(-MAX_ZOOM_LOG2, MAX_ZOOM_LOG2))
val scale = (fit + (cover - fit) * pan) * zoom
return Size(
width = (videoWidth * scale).toInt().coerceAtLeast(1),
height = (videoHeight * scale).toInt().coerceAtLeast(1)
)
}
}
@@ -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
}
}
@@ -19,6 +19,15 @@ class FrameRateManager(
private const val TAG = "FrameRateManager"
private const val DISPLAY_SETTLE_MS = 2000L
private const val WATCHDOG_MARGIN_MS = 3000L
// How long to let the HDR-exit commit land before restoring the refresh
// rate. Restoring while the display is still signaling HDR folds the
// HDR-exit and the mode change into one HDMI renegotiation, which some
// sink chains take 8-30 s to complete (#2172); sequenced, the HDR
// infoframe clear is free and the SDR mode switch takes ~1 s. The
// player's surface teardown commits the HDR exit within ~50 ms of
// dispose, so 400 ms covers it with margin even on a busy main thread.
private const val HDR_EXIT_SETTLE_MS = 400L
}
private var currentVideoFps: Float = 0f
@@ -30,6 +39,12 @@ class FrameRateManager(
private var watchdogRunnable: Runnable? = null
private var pendingCompletion: ((switched: Boolean) -> Unit)? = null
// Owns the deferred HDR-exit restore. Deliberately NOT the shared player
// [handler]: core dispose clears that one wholesale, and the restore must
// survive player disposal or the display stays at the content rate.
private val restoreHandler = Handler(android.os.Looper.getMainLooper())
private var pendingRestoreRunnable: Runnable? = null
private fun getDisplayManager(): DisplayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
// Request a display mode switch for the video's frame rate and/or, with
@@ -54,6 +69,9 @@ class FrameRateManager(
matchResolution: Boolean = false,
onComplete: (switched: Boolean) -> Unit
) {
// A new session's switch must not be clobbered by a still-pending
// deferred restore from the previous session's teardown.
cancelPendingRestore()
currentVideoFps = fps
currentVideoWidth = videoWidth
currentVideoHeight = videoHeight
@@ -78,21 +96,47 @@ class FrameRateManager(
}
}
fun clearVideoFrameRate() {
Log.d(TAG, "clearVideoFrameRate")
// [hdrActive]: the session was outputting HDR. The restore is then deferred
// by [HDR_EXIT_SETTLE_MS] so the caller's surface teardown can commit the
// HDR exit first — see [HDR_EXIT_SETTLE_MS] for why stacking them is slow.
fun clearVideoFrameRate(hdrActive: Boolean = false) {
Log.d(TAG, "clearVideoFrameRate(hdrActive=$hdrActive)")
currentVideoFps = 0f
// Resolve any pending setVideoFrameRate future as "not switched" so
// the Dart caller's await doesn't hang on player dispose.
firePendingCompletion("clear", switched = false)
// Restore default display mode on API M (preferredDisplayModeId persists)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = 0
activity.window?.attributes = attrs
cancelPendingRestore()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
// Nothing to restore when no preferred mode was ever applied.
if ((activity.window?.attributes?.preferredDisplayModeId ?: 0) == 0) return
if (hdrActive) {
val restore = Runnable {
pendingRestoreRunnable = null
// Log.d, not [log]: this fires after core dispose, when the
// Flutter-channel logger is already gone.
Log.d(TAG, "restoring default display mode after HDR exit")
restorePreferredDisplayMode()
}
pendingRestoreRunnable = restore
restoreHandler.postDelayed(restore, HDR_EXIT_SETTLE_MS)
} else {
restorePreferredDisplayMode()
}
}
private fun restorePreferredDisplayMode() {
// preferredDisplayModeId persists on the window; restore the default.
activity.window?.attributes?.let { attrs ->
attrs.preferredDisplayModeId = 0
activity.window?.attributes = attrs
}
}
private fun cancelPendingRestore() {
pendingRestoreRunnable?.let { restoreHandler.removeCallbacks(it) }
pendingRestoreRunnable = null
}
// Release pending callbacks/listener without restoring the display mode.
// Used by player-core dispose paths so a backend handoff (e.g. ExoPlayer→MPV
// audio fallback) doesn't clobber the just-applied refresh-rate switch —
@@ -7,6 +7,34 @@ import java.util.Locale
/** Canonical decoder lookup and hardware classification for native playback. */
internal object MediaCodecQuery {
/**
* Codecs whose advertised support is decided by hardware: both have a
* software decoder behind them, but a software HEVC or AV1 decode on
* phone/TV-class hardware drops frames.
*/
private val HARDWARE_GATED_VIDEO_MIME_TYPES = mapOf(
"hevc" to "video/hevc",
"av1" to "video/av01"
)
/** Codec name -> whether this device has a hardware decoder for it. */
fun hardwareVideoDecodeSupport(
hardwareMimeTypes: Set<String> = hardwareDecoderMimeTypes()
): Map<String, Boolean> = HARDWARE_GATED_VIDEO_MIME_TYPES.mapValues { (_, mimeType) -> mimeType in hardwareMimeTypes }
/**
* Every MIME type served by a hardware decoder, lowercased. One walk answers
* for all codecs, unlike [findHardwareDecoder], which rescans per lookup.
*/
private fun hardwareDecoderMimeTypes(): Set<String> {
val mimeTypes = HashSet<String>()
for (info in MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos) {
if (info.isEncoder || !isHardwareAccelerated(info)) continue
for (type in info.supportedTypes) mimeTypes.add(type.lowercase(Locale.ROOT))
}
return mimeTypes
}
fun findHardwareDecoder(
mimeType: String,
codecKind: Int = MediaCodecList.REGULAR_CODECS,
@@ -2,6 +2,7 @@ package com.edde746.plezy.shared
import android.app.Activity
import android.graphics.Color
import android.graphics.PixelFormat
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
@@ -14,8 +15,37 @@ internal object PlayerSurfaceHost {
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Fallback fill for the frames before the punch surface below is placed.
setBackgroundColor(Color.BLACK)
this.clipChildren = clipChildren
addView(createLetterboxPunchSurface(activity))
}
/**
* Fullscreen, buffer-less SurfaceView kept beneath the video surface.
*
* Its only job is taking the letterbox area off the app-window (graphics)
* plane: like any below-window SurfaceView it punches the parent canvas and
* registers its rect as a window transparent region, so the pixels around
* the video rect scan out as the SurfaceFlinger backdrop instead of
* window-plane black. Several TV compositors (Fire TV Stick 4K Max, some
* Sony/Philips models) raise SDR graphics-plane black while the display is
* in HDR/Dolby Vision mode, which shows as gray letterbox bars on OLED
* panels (issue #2163; same mechanism as ExoPlayer #8803 and Kodi #25300).
*
* No buffer is ever posted to it: SurfaceFlinger skips buffer-less layers,
* and drawing black into it would put the bars back onto an SDR layer —
* exactly the plane being avoided. Views drawn on the parent canvas after
* the punch (Media3 subtitle cues) still re-claim their own bounds.
*/
private fun createLetterboxPunchSurface(activity: Activity): SurfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
fun createVideoSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
@@ -29,6 +59,23 @@ internal object PlayerSurfaceHost {
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
/**
* Transparent plane directly above the video surface for the mpv
* `vo=mediacodec` subtitle/OSD output. Media-overlay z-order keeps it above
* the video SurfaceView but still beneath the Flutter window content.
*/
fun createOsdSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(callback)
holder.setFormat(PixelFormat.TRANSLUCENT)
setZOrderOnTop(false)
setZOrderMediaOverlay(true)
FlutterOverlayHelper.applyCompositionOrder(this, -1)
}
fun attachToContent(activity: Activity, container: FrameLayout): ViewGroup {
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(container, 0)
+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()
@@ -94,24 +94,111 @@ class AudioOutputPolicyTest {
}
@Test
fun spdifListNamesOnlyCodecsMpvsStereoIecTrackCanCarry() {
// eac3 (a 192kHz burst), truehd and dts-hd (192kHz/8ch) never survive mpv's audiotrack
// AO, which opens every spdif format as a stereo IEC 61937 track at the mixer rate;
// naming them strands playback on a dead audio output (#1991).
assertEquals("ac3,dts", mpvSpdifCodecs { true })
fun spdifListNamesEveryCodecARouteWithAllShapesCanCarry() {
// libmpv v1.1.0's audiotrack AO opens each burst at its own rate and channel mask, so a route
// that takes all three shapes and advertises everything bitstreams the lossless codecs too.
// `dts-hd` supersedes plain `dts`: ad_spdif picks the core burst per file for non-HD tracks.
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes))
}
@Test
fun spdifListOnAStereo48kOnlyRouteNamesTheCoreCodecsOnly() {
// E-AC3 (192kHz), TrueHD MAT and DTS-HD MA (192kHz/8ch) have no track to ride here.
assertEquals("ac3,dts", spdifCodecs(allEncodings, setOf(MpvIecShape.STEREO_48K)))
}
@Test
fun dtsHdFallsBackToThePlainCoreWithoutTheCarrierShape() {
// Advertising ENCODING_DTS_HD says the receiver decodes it, not that the route takes the
// 192kHz/8ch track its burst needs (#1988); naming `dts-hd` anyway strands playback.
assertEquals(
"dts",
spdifCodecs(setOf(C.ENCODING_DTS, C.ENCODING_DTS_HD), setOf(MpvIecShape.STEREO_48K))
)
}
@Test
fun eac3IsNotNamedWithoutThe192kStereoShape() {
assertEquals("", spdifCodecs(setOf(C.ENCODING_E_AC3), setOf(MpvIecShape.STEREO_48K)))
}
@Test
fun trueHdIsNotNamedWithoutTheCarrierShape() {
assertEquals(
"",
spdifCodecs(
setOf(C.ENCODING_DOLBY_TRUEHD),
setOf(MpvIecShape.STEREO_48K, MpvIecShape.STEREO_192K)
)
)
}
@Test
fun plainDtsSurvivesWhenTheRouteDoesNotAdvertiseDtsHd() {
// The core burst is stereo/48k, so it rides a full-shape route unchanged when only the
// lossless encoding is missing.
assertEquals("dts", spdifCodecs(setOf(C.ENCODING_DTS), allShapes))
}
@Test
fun spdifListDropsCodecsTheRouteCannotBitstream() {
// Google TV Streamer over HDMI to a Dolby-only sink: AC3 bitstreams, DTS does not (#1703).
// Google TV Streamer over HDMI to a Dolby-only sink: AC3/E-AC3 bitstream, DTS does not (#1703).
val dolbyOnlyRoute = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3)
assertEquals("ac3", mpvSpdifCodecs { encoding -> encoding in dolbyOnlyRoute })
assertEquals("ac3,eac3", spdifCodecs(dolbyOnlyRoute, allShapes))
}
@Test
fun spdifListIsEmptyForPcmOnlyRoutes() {
assertEquals("", mpvSpdifCodecs { false })
assertEquals("", mpvSpdifCodecs({ false }, { false }))
}
@Test
fun spdifListIsEmptyWhenTheRouteTakesNoIecTrackAtAll() {
// Every encoding advertised, but no raw track and no IEC 61937 shape opens: mpv has no
// decode fallback for a named codec, so nothing may be named (#1991).
assertEquals("", spdifCodecs(allEncodings, emptySet()))
}
@Test
fun rawCapableRouteBitstreamsTheCoreCodecsWithoutAnyIecShape() {
// #2177's Shield: every mpv IEC track opens and drains into silence, while raw
// ENCODING_AC3/E_AC3/DTS tracks (the ExoPlayer transport) play. The AO opens raw first,
// so raw support alone must qualify the core codecs.
assertEquals(
"ac3,eac3,dts",
spdifCodecs(allEncodings, emptySet(), raw = setOf(C.ENCODING_AC3, C.ENCODING_E_AC3, C.ENCODING_DTS))
)
}
@Test
fun rawSupportNeverQualifiesTheLosslessCodecs() {
// TrueHD and DTS-HD MA have no raw transport in the AO; they ride the 192kHz/7.1 IEC
// carrier or decode. A route that takes every raw track but no carrier must not name them.
assertEquals("ac3,eac3,dts", spdifCodecs(allEncodings, emptySet(), raw = allEncodings))
}
@Test
fun rawProbeIsOnlyConsultedForRawCandidates() {
// The raw probe costs real route calls; the carrier-only codecs must never trigger it.
val codecs = mpvSpdifCodecs(
{ true },
{ true },
{ encoding ->
if (encoding == C.ENCODING_DOLBY_TRUEHD || encoding == C.ENCODING_DTS_HD) {
throw AssertionError("raw probe consulted for a carrier-only codec")
}
true
}
)
assertEquals("ac3,eac3,truehd,dts-hd", codecs)
}
@Test
fun dtsHdStillSupersedesPlainDtsWhenDtsBitstreamsRaw() {
// dts-hd selects the lossless spdif decoder for the whole dts codec; the raw core track
// must not resurrect the plain name beside it.
assertEquals("ac3,eac3,truehd,dts-hd", spdifCodecs(allEncodings, allShapes, raw = allEncodings))
}
@Test
@@ -204,4 +291,17 @@ class AudioOutputPolicyTest {
)
}
}
/** Every encoding the spdif table can ask for, i.e. a receiver that decodes all of them. */
private val allEncodings = setOf(
C.ENCODING_AC3,
C.ENCODING_E_AC3,
C.ENCODING_DOLBY_TRUEHD,
C.ENCODING_DTS,
C.ENCODING_DTS_HD
)
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 })
}
@@ -1,29 +0,0 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FfmpegDemuxerPolicyTest {
@Test
fun wireValuesRoundTrip() {
assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("ffmpeg"))
assertEquals(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY, FfmpegDemuxerPolicy.fromWire("media3"))
}
@Test
fun unknownWireValuesResolveToFfmpeg() {
assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire(null))
assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire(""))
assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("bogus"))
// Retired wire value from the container-list era maps to the default.
assertEquals(FfmpegDemuxerPolicy.Preference.FFMPEG, FfmpegDemuxerPolicy.fromWire("auto"))
}
@Test
fun ffmpegDemuxesByDefaultAndMedia3OnlyDisables() {
assertTrue(FfmpegDemuxerPolicy.enabled(FfmpegDemuxerPolicy.Preference.FFMPEG))
assertFalse(FfmpegDemuxerPolicy.enabled(FfmpegDemuxerPolicy.Preference.MEDIA3_ONLY))
}
}
@@ -1,53 +0,0 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Byte-exact spec for the media3-shaped subtitle sample prefixes. The shapes
* mirror media3 1.11.0 MatroskaExtractor's SSA_PREFIX/SUBRIP_PREFIX/VTT_PREFIX
* with the duration patched into the second timecode; SsaParser,
* SubripParser, WebvttParser, and AssTrackOutput all parse these exact bytes.
*/
class FfmpegSubtitleSamplesTest {
private fun string(bytes: ByteArray): String = bytes.toString(Charsets.UTF_8)
@Test
fun ssaPrefixMatchesMatroskaExtractorShape() {
// 1h 2m 3s 450ms -> centiseconds, ':' separator, single-digit hour.
assertEquals(
"Dialogue: 0:00:00:00,1:02:03:45,",
string(FfmpegSubtitleSamples.ssaPrefix(3_723_450_000L))
)
assertEquals("Dialogue: 0:00:00:00,0:00:05:00,", string(FfmpegSubtitleSamples.ssaPrefix(5_000_000L)))
// Sub-centisecond remainders truncate, matching integer division.
assertEquals("Dialogue: 0:00:00:00,0:00:00:99,", string(FfmpegSubtitleSamples.ssaPrefix(999_999L)))
}
@Test
fun subripPrefixIsACompleteCueHeader() {
assertEquals(
"1\n00:00:00,000 --> 01:02:03,450\n",
string(FfmpegSubtitleSamples.subripPrefix(3_723_450_000L))
)
assertEquals("1\n00:00:00,000 --> 00:00:05,000\n", string(FfmpegSubtitleSamples.subripPrefix(5_000_000L)))
}
@Test
fun vttPrefixIsAOneCueDocumentHeader() {
assertEquals(
"WEBVTT\n\n00:00:00.000 --> 01:02:03.450\n",
string(FfmpegSubtitleSamples.vttPrefix(3_723_450_000L))
)
assertEquals("WEBVTT\n\n00:00:00.000 --> 00:00:05.000\n", string(FfmpegSubtitleSamples.vttPrefix(5_000_000L)))
}
@Test
fun ssaDialogueFormatMatchesMedia3Constant() {
assertEquals(
"Format: Start, End, ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
string(FfmpegSubtitleSamples.SSA_DIALOGUE_FORMAT)
)
}
}
@@ -0,0 +1,154 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.DefaultExtractorInput
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Regression coverage for Matroska files whose SeekHead references Tracks after the Clusters. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MatroskaLateTracksTest {
private class CapturedTrack(val type: Int) : TrackOutput {
val timesUs = mutableListOf<Long>()
var format: Format? = null
val sampleCount: Int
get() = timesUs.size
override fun format(format: Format) {
this.format = format
}
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int = input.read(ByteArray(length), 0, length)
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
data.skipBytes(length)
}
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
timesUs.add(timeUs)
}
}
private class CapturingExtractorOutput : ExtractorOutput {
val tracks = mutableMapOf<Int, CapturedTrack>()
val seekMaps = mutableListOf<SeekMap>()
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { CapturedTrack(type) }
override fun endTracks() = Unit
override fun seekMap(seekMap: SeekMap) {
seekMaps.add(seekMap)
}
}
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
var position = 0L
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (position >= data.size) return C.RESULT_END_OF_INPUT
val bytesRead = minOf(length, data.size - position.toInt())
data.copyInto(buffer, offset, position.toInt(), position.toInt() + bytesRead)
position += bytesRead
return bytesRead
}
}
private fun fixtureData(): ByteArray = checkNotNull(javaClass.getResourceAsStream("/matroska_tracks_at_end.mkv")) {
"fixture matroska_tracks_at_end.mkv missing from test resources"
}.use { it.readBytes() }
private fun readToEnd(extractor: Extractor, reader: ByteArrayDataReader, length: Long) {
var input: ExtractorInput = DefaultExtractorInput(reader, reader.position, length)
val seekPosition = PositionHolder()
repeat(100_000) {
when (extractor.read(input, seekPosition)) {
Extractor.RESULT_END_OF_INPUT -> return
Extractor.RESULT_SEEK -> {
reader.position = seekPosition.position
input = DefaultExtractorInput(reader, seekPosition.position, length)
}
}
}
error("extractor did not reach end of input")
}
@Test
fun extractsSamplesWhenSeekHeadReferencesTracksAfterClusters() {
val assHandler = AssHandler()
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
val output = CapturingExtractorOutput()
extractor.init(output)
val data = fixtureData()
readToEnd(extractor, ByteArrayDataReader(data), data.size.toLong())
assertEquals(2, output.tracks.size)
assertEquals(setOf(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO), output.tracks.values.map { it.type }.toSet())
output.tracks.values.forEach { track ->
assertNotNull(track.format)
assertTrue("expected extracted samples for track type ${track.type}", track.sampleCount > 0)
}
}
/**
* media3 1.11.0 builds the Matroska seek map at the end of the Cues element, which for
* tracks-after-clusters files is before the Tracks element parsed — the map then permanently
* reports unseekable and resolves every seek to byte 0, snapping playback to the start
* (#1969; upstream androidx/media #3377).
* The production stack must repair it through the per-track cue lookups.
*/
@Test
fun seeksViaCuesWhenSeekMapIsBuiltBeforeTracks() {
val assHandler = AssHandler()
val extractor = CuelessSeekExtractorWrapper(ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler))
val output = CapturingExtractorOutput()
extractor.init(output)
val data = fixtureData()
val reader = ByteArrayDataReader(data)
readToEnd(extractor, reader, data.size.toLong())
assertEquals(1, output.seekMaps.size)
val seekMap = output.seekMaps.single()
assertTrue("tracks-after-clusters file with Cues must be seekable", seekMap.isSeekable)
val points = seekMap.getSeekPoints(500_000L)
// Cue-based resolution snaps to the fixture's only cue point (t=0); a byte-proportional
// estimate would return the requested time instead.
assertEquals(0L, points.first.timeUs)
val position = points.first.position
val clusterId = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
assertTrue(
"seek position $position must point at a Cluster element",
data.copyOfRange(position.toInt(), position.toInt() + 4).contentEquals(clusterId)
)
output.tracks.values.forEach { it.timesUs.clear() }
extractor.seek(position, points.first.timeUs)
reader.position = position
readToEnd(extractor, reader, data.size.toLong())
output.tracks.values.forEach { track ->
assertTrue("expected extracted samples after seek for track type ${track.type}", track.sampleCount > 0)
assertEquals(points.first.timeUs, track.timesUs.first())
}
}
}
@@ -5,7 +5,14 @@ import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.DefaultExtractorInput
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import java.io.EOFException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -17,17 +24,16 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Drives LatmTrackOutput with LOAS frames from the committed fixture (1s 440Hz
* sine, AAC-LC 48kHz stereo, LATM/LOAS) and verifies it unwraps them to raw
* AAC with a synthesized AudioSpecificConfig. Both demuxer paths deliver LOAS
* this way: the FFmpeg extractor wraps aac_latm tracks directly.
* Extracts the committed fixture (1s 440Hz sine, AAC-LC 48kHz stereo, LATM/LOAS
* muxed into MKV as A_MS/ACM tag 0x1602) and verifies direct Matroska playback
* unwraps LOAS frames to raw AAC with a synthesized AudioSpecificConfig.
*
* Robolectric provides real android.util.* implementations for media3's
* internals.
* Robolectric provides real android.util.* implementations — MatroskaExtractor
* stores tracks in a SparseArray, which is a no-op stub on plain JVM.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class LatmTrackOutputTest {
class MatroskaLatmSupportTest {
private class CapturedSample(val timeUs: Long, val flags: Int, val data: ByteArray)
@@ -65,6 +71,14 @@ class LatmTrackOutputTest {
}
}
private class FakeExtractorOutput : ExtractorOutput {
val tracks = mutableMapOf<Int, FakeTrackOutput>()
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() }
override fun endTracks() {}
override fun seekMap(seekMap: SeekMap) {}
}
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
var position = 0L
@@ -104,46 +118,76 @@ class LatmTrackOutputTest {
return frames
}
private fun placeholderFormat(): Format = Format.Builder()
.setId("1")
.setSampleMimeType(MimeTypes.AUDIO_AAC)
.setLabel("LATM track")
.setLanguage("eng")
.build()
private fun extractFixture(): FakeExtractorOutput {
val data = fixtureData()
val assHandler = AssHandler()
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
val output = FakeExtractorOutput()
extractor.init(output)
val reader = ByteArrayDataReader(data)
var input = DefaultExtractorInput(reader, 0, data.size.toLong())
val seekPosition = PositionHolder()
while (true) {
when (extractor.read(input, seekPosition)) {
Extractor.RESULT_END_OF_INPUT -> return output
Extractor.RESULT_SEEK -> {
reader.position = seekPosition.position
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
}
else -> {}
}
}
}
@Test
fun unwrapsLoasFramesToRawAac() {
val delegate = FakeTrackOutput()
val output = LatmTrackOutput(delegate, 1)
output.format(placeholderFormat())
fun unwrapsLoasAcmTrackToRawAac() {
val output = extractFixture()
val frames = loasFrames(40)
var timeUs = 0L
for (frame in frames) {
output.sampleData(ParsableByteArray(frame), frame.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, frame.size, 0, null)
timeUs += 21_333
}
assertEquals(1, output.tracks.size)
val track = output.tracks.values.first()
// The placeholder format must be swallowed; the LATM-derived AAC format
// carries the AudioSpecificConfig and the original track metadata.
val format = delegate.formats.last()
// No audio/x-unknown format may reach the queue; the LATM-derived AAC
// format must carry the AudioSpecificConfig for MediaCodec.
assertFalse(track.formats.any { it.sampleMimeType == MimeTypes.AUDIO_UNKNOWN })
val format = track.formats.last()
assertEquals(MimeTypes.AUDIO_AAC, format.sampleMimeType)
assertEquals(48000, format.sampleRate)
assertEquals(2, format.channelCount)
assertEquals("1", format.id)
assertEquals("LATM track", format.label)
assertTrue(format.initializationData.isNotEmpty())
assertTrue(format.initializationData[0].isNotEmpty())
assertTrue("expected ~40 samples, got ${delegate.samples.size}", delegate.samples.size in 35..40)
for (sample in delegate.samples) {
// 1s at 48kHz / 1024 samples per AAC frame ≈ 47 frames
assertTrue("expected ~47 samples, got ${track.samples.size}", track.samples.size in 40..55)
// Raw AAC payloads: smaller than the LOAS wrapping, no LOAS syncword,
// keyframe-flagged, monotonic timestamps spanning ~1s.
var prevTimeUs = Long.MIN_VALUE
for (sample in track.samples) {
assertTrue(sample.data.isNotEmpty())
val isLoasSync = sample.data.size >= 2 &&
(sample.data[0].toInt() and 0xFF) == 0x56 &&
(sample.data[1].toInt() and 0xE0) == 0xE0
assertFalse("sample still LOAS-framed", isLoasSync)
assertEquals(C.BUFFER_FLAG_KEY_FRAME, sample.flags and C.BUFFER_FLAG_KEY_FRAME)
assertTrue(sample.timeUs >= prevTimeUs)
prevTimeUs = sample.timeUs
}
val spanUs = track.samples.last().timeUs - track.samples.first().timeUs
assertTrue("expected ~1s span, got ${spanUs}us", spanUs in 800_000..1_200_000)
}
@Test
fun detectsLoasAcmCodecPrivate() {
val loas = byteArrayOf(0x02, 0x16, 0, 0, 0, 0)
assertTrue(isLoasAcmTrack("A_MS/ACM", loas))
// Wrong tag (PCM), wrong codec, or missing private data must not match
assertFalse(isLoasAcmTrack("A_MS/ACM", byteArrayOf(0x01, 0x00, 0, 0)))
assertFalse(isLoasAcmTrack("A_AAC", loas))
assertFalse(isLoasAcmTrack("A_MS/ACM", null))
assertFalse(isLoasAcmTrack("A_MS/ACM", byteArrayOf(0x02)))
assertFalse(isLoasAcmTrack(null, loas))
}
@Test
@@ -161,14 +205,19 @@ class LatmTrackOutputTest {
}
@Test
fun preservesStreamMuxConfigAcrossReset() {
fun preservesStreamMuxConfigAcrossExtractorSeek() {
val frames = loasFrames(2)
assertEquals(0, frames[0][3].toInt() and 0x80)
assertEquals(0x80, frames[1][3].toInt() and 0x80)
val delegate = FakeTrackOutput()
val output = LatmTrackOutput(delegate, 1)
output.format(placeholderFormat())
output.format(
Format.Builder()
.setId("1")
.setSampleMimeType(MimeTypes.AUDIO_UNKNOWN)
.build()
)
output.sampleData(ParsableByteArray(frames[0]), frames[0].size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(0, C.BUFFER_FLAG_KEY_FRAME, frames[0].size, 0, null)
@@ -1,65 +0,0 @@
package com.edde746.plezy.exoplayer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class StreamBitrateMeterTest {
/** Feeds [count] packets of [sizeBytes] every [intervalUs], starting at [startUs]. */
private fun StreamBitrateMeter.feed(count: Int, sizeBytes: Int, intervalUs: Long, startUs: Long = 0L) {
for (i in 0 until count) onPacket(startUs + i * intervalUs, sizeBytes)
}
@Test
fun `constant packet stream measures its bitrate`() {
val meter = StreamBitrateMeter()
// 1200-byte packets every 32 ms: 1200 * 8 / 0.032 = 300 kbps.
meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
assertEquals(300_000, meter.bitrateBps())
}
@Test
fun `too few packets report nothing`() {
val meter = StreamBitrateMeter()
meter.feed(count = 15, sizeBytes = 1200, intervalUs = 32_000)
assertNull(meter.bitrateBps())
}
@Test
fun `zero pts span reports nothing`() {
val meter = StreamBitrateMeter()
repeat(32) { meter.onPacket(0L, 1200) }
assertNull(meter.bitrateBps())
}
@Test
fun `window drops old packets so a bitrate change is followed`() {
val meter = StreamBitrateMeter(windowUs = 1_000_000)
meter.feed(count = 100, sizeBytes = 1200, intervalUs = 32_000)
// Continue at four times the packet size; after well over a window of
// new packets, only the new rate remains.
meter.feed(count = 100, sizeBytes = 4800, intervalUs = 32_000, startUs = 100 * 32_000L)
assertEquals(1_200_000, meter.bitrateBps())
}
@Test
fun `backwards pts jump resets the window`() {
val meter = StreamBitrateMeter()
meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
// Seek back: the single post-seek packet must not combine with the
// pre-seek span.
meter.onPacket(0L, 1200)
assertNull(meter.bitrateBps())
meter.feed(count = 31, sizeBytes = 1200, intervalUs = 32_000, startUs = 32_000)
assertEquals(300_000, meter.bitrateBps())
}
@Test
fun `empty packets are ignored`() {
val meter = StreamBitrateMeter()
meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000)
meter.onPacket(32 * 32_000L, 0)
assertEquals(300_000, meter.bitrateBps())
}
}
@@ -0,0 +1,248 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.ParserException
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import java.util.zip.DeflaterOutputStream
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ZlibInflatingTrackOutputTest {
@Before
fun enableMedia3TestBoundsChecks() {
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true)
}
@After
fun resetMedia3TestBoundsChecks() {
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null)
}
@Test
fun activeTransformInflatesChunkedSampleAndPreservesMetadata() {
val original = ByteArray(512 * 1024) { (it % 251).toByte() }
val compressed = deflate(original)
val delegate = RecordingTrackOutput(retainBytes = true)
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
val split = compressed.size / 2
output.sampleData(
ParsableByteArray(compressed.copyOfRange(0, split)),
split,
TrackOutput.SAMPLE_DATA_PART_MAIN
)
output.sampleData(
ParsableByteArray(compressed.copyOfRange(split, compressed.size)),
compressed.size - split,
TrackOutput.SAMPLE_DATA_PART_MAIN
)
output.sampleMetadata(42L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null)
assertArrayEquals(original, delegate.retained.toByteArray())
assertEquals(42L, delegate.timeUs)
assertEquals(C.BUFFER_FLAG_KEY_FRAME, delegate.flags)
assertEquals(original.size, delegate.sampleSize)
assertEquals(0, delegate.offset)
assertEquals(1, delegate.metadataCount)
}
@Test
fun activeTransformUsesMetadataBoundariesForBufferedSamples() {
val first = ByteArray(4096) { (it % 19).toByte() }
val second = ByteArray(2048) { (it % 23).toByte() }
val firstCompressed = deflate(first)
val secondCompressed = deflate(second)
val combined = firstCompressed + secondCompressed
val delegate = RecordingTrackOutput(retainBytes = true)
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
output.sampleData(ParsableByteArray(combined), combined.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(1, 0, firstCompressed.size, secondCompressed.size, null)
output.sampleMetadata(2, 0, secondCompressed.size, 0, null)
assertArrayEquals(first + second, delegate.retained.toByteArray())
assertEquals(2, delegate.metadataCount)
}
@Test
fun exactlySixteenMiBInflatedSampleIsAccepted() {
val size = 16 * 1024 * 1024
val compressed = deflateRepeated(size)
val delegate = RecordingTrackOutput()
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
feed(output, compressed)
assertEquals(size, delegate.byteCount)
assertEquals(size, delegate.sampleSize)
assertEquals(1, delegate.metadataCount)
assertTrue(compressed.size < 64 * 1024)
}
@Test
fun oneByteOverInflatedLimitFailsBeforeDelegation() {
val compressed = deflateRepeated(16 * 1024 * 1024 + 1)
val delegate = RecordingTrackOutput()
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
assertThrows(ParserException::class.java) { feed(output, compressed) }
assertEquals(0, delegate.byteCount)
assertEquals(0, delegate.metadataCount)
assertTrue(compressed.size < 64 * 1024)
}
@Test
fun excessiveCompressionRatioFailsBeforeInflatedSizeLimit() {
val compressed = deflate(ByteArray(8 * 1024 * 1024))
val delegate = RecordingTrackOutput()
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
assertThrows(ParserException::class.java) { feed(output, compressed) }
assertEquals(0, delegate.byteCount)
assertEquals(0, delegate.metadataCount)
}
@Test
fun corruptStreamPassesTheBoundedSampleThroughUnchanged() {
val corrupt = byteArrayOf(0, 1, 2, 3)
val delegate = RecordingTrackOutput(retainBytes = true)
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
feed(output, corrupt)
assertArrayEquals(corrupt, delegate.retained.toByteArray())
assertEquals(corrupt.size, delegate.sampleSize)
assertEquals(1, delegate.metadataCount)
}
@Test
fun truncatedAndDictionaryStreamsFailClosed() {
val valid = deflate(ByteArray(4096) { 7 })
val dictionary = "shared-zlib-dictionary".toByteArray()
val cases = listOf(
valid.copyOf(valid.size - 2),
deflate(ByteArray(4096) { 3 }, dictionary)
)
for (compressed in cases) {
val delegate = RecordingTrackOutput()
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
assertThrows(ParserException::class.java) { feed(output, compressed) }
assertEquals(0, delegate.byteCount)
assertEquals(0, delegate.metadataCount)
}
}
@Test
fun inactiveWrapperDelegatesBytesAndMetadataUnchanged() {
val bytes = byteArrayOf(9, 8, 7, 6)
val trailing = byteArrayOf(5, 4)
val allBytes = bytes + trailing
val delegate = RecordingTrackOutput(retainBytes = true)
val output = ZlibInflatingTrackOutput(delegate)
output.sampleData(ParsableByteArray(allBytes), allBytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(99L, 3, bytes.size, trailing.size, null)
assertArrayEquals(allBytes, delegate.retained.toByteArray())
assertEquals(bytes.size, delegate.sampleSize)
assertEquals(trailing.size, delegate.offset)
assertEquals(99L, delegate.timeUs)
}
private fun feed(output: ZlibInflatingTrackOutput, compressed: ByteArray) {
output.sampleData(
ParsableByteArray(compressed),
compressed.size,
TrackOutput.SAMPLE_DATA_PART_MAIN
)
output.sampleMetadata(1L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null)
}
private fun deflate(bytes: ByteArray, dictionary: ByteArray? = null): ByteArray {
val target = ByteArrayOutputStream()
val deflater = Deflater().apply {
if (dictionary != null) setDictionary(dictionary)
}
DeflaterOutputStream(target, deflater).use { it.write(bytes) }
return target.toByteArray()
}
private fun deflateRepeated(size: Int): ByteArray {
val target = ByteArrayOutputStream()
val chunk = ByteArray(8192) { (it % 7).toByte() }
DeflaterOutputStream(target).use { stream ->
var remaining = size
while (remaining > 0) {
val count = minOf(remaining, chunk.size)
stream.write(chunk, 0, count)
remaining -= count
}
}
return target.toByteArray()
}
private class RecordingTrackOutput(
private val retainBytes: Boolean = false
) : TrackOutput {
val retained = ByteArrayOutputStream()
var byteCount = 0
var metadataCount = 0
var timeUs = C.TIME_UNSET
var flags = 0
var sampleSize = -1
var offset = -1
override fun format(format: Format) = Unit
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
val buffer = ByteArray(length)
val read = input.read(buffer, 0, length)
if (read > 0) record(buffer, read)
return read
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
val buffer = ByteArray(length)
data.readBytes(buffer, 0, length)
record(buffer, length)
}
private fun record(buffer: ByteArray, length: Int) {
byteCount += length
if (retainBytes) retained.write(buffer, 0, length)
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
metadataCount++
this.timeUs = timeUs
this.flags = flags
sampleSize = size
this.offset = offset
}
}
}
@@ -0,0 +1,80 @@
package com.edde746.plezy.exoplayer
import java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import java.util.zip.DeflaterOutputStream
import java.util.zip.Inflater
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Unit spec for [rewriteZlibTextBlock]: the block header (track-number varint,
* timecode, flags) must survive byte-identically, the payload must inflate, and
* every block the rewriter cannot prove safe must pass through unchanged (null).
*/
class ZlibTextBlockTest {
private val inflater = Inflater()
private fun block(header: ByteArray, payload: ByteArray): ByteArray = header + payload
private fun deflate(bytes: ByteArray): ByteArray {
val target = ByteArrayOutputStream()
DeflaterOutputStream(target, Deflater()).use { it.write(bytes) }
return target.toByteArray()
}
// Track 3 (varint 0x83), relative timecode 0x0102, no lacing.
private val unlacedHeader = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x00)
@Test
fun inflatesUnlacedBlockPayloadAndPreservesHeader() {
val dialogue = "42,,Default,,0,0,0,,SUBTITLE LINE 1 (0:00:00.00)".toByteArray()
val data = block(unlacedHeader, deflate(dialogue))
val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
assertArrayEquals(unlacedHeader + dialogue, rewritten)
}
@Test
fun twoByteTrackNumberVarintIsPreserved() {
val dialogue = "1,,Default,,0,0,0,,hello".toByteArray()
val header = byteArrayOf(0x41, 0x2A, 0x01, 0x02, 0x00) // varint 0x412A = track 298
val data = block(header, deflate(dialogue))
val rewritten = rewriteZlibTextBlock(data, data.size, inflater)!!
assertArrayEquals(header + dialogue, rewritten)
}
@Test
fun lacedBlockPassesThrough() {
val laced = byteArrayOf(0x83.toByte(), 0x01, 0x02, 0x06) // EBML lacing bits set
val data = block(laced, deflate("payload".toByteArray()))
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
}
@Test
fun corruptStreamPassesThrough() {
val data = block(unlacedHeader, byteArrayOf(0x44, 0x69, 0x61, 0x6C)) // "Dial", not zlib
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
}
@Test
fun truncatedStreamPassesThrough() {
val compressed = deflate("a longer payload that spans several deflate symbols".toByteArray())
val truncated = compressed.copyOf(compressed.size - 4)
val data = block(unlacedHeader, truncated)
assertNull(rewriteZlibTextBlock(data, data.size, inflater))
}
@Test
fun headerOnlyBlockPassesThrough() {
assertNull(rewriteZlibTextBlock(unlacedHeader, unlacedHeader.size, inflater))
}
}
@@ -0,0 +1,170 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.DefaultExtractorInput
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.text.CueDecoder
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
import com.edde746.plezy.libass.media.AssHandler
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Extracts the committed fixtures — one ASS subtitle track muxed by mkvmerge with
* `--compression 0:zlib` (ContentCompAlgo 0, the anime-release convention that
* broke #2023) and an identical uncompressed mux — and verifies the zlib block
* payloads are inflated *before* MatroskaExtractor's subtitle sample assembly.
*
* Regenerate with:
* mkvmerge -o zlib_ssa.mkv --compression 0:zlib repro.ass
* mkvmerge -o plain_ssa.mkv --compression 0:none repro.ass
* where repro.ass holds 45 two-second "SUBTITLE LINE N (H:MM:SS.00)" events.
*
* Robolectric provides real android.util/android.os implementations —
* MatroskaExtractor uses SparseArray and CueDecoder uses Parcel, both of which
* are no-op stubs on plain JVM.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ZlibTextSubtitleExtractionTest {
private class CapturedSample(val timeUs: Long, val data: ByteArray)
private class FakeTrackOutput : TrackOutput {
val formats = mutableListOf<androidx.media3.common.Format>()
val samples = mutableListOf<CapturedSample>()
private var buf = ByteArray(64 * 1024)
private var bufLen = 0
override fun format(format: androidx.media3.common.Format) {
formats.add(format)
}
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
ensureCapacity(bufLen + length)
val read = input.read(buf, bufLen, length)
if (read > 0) bufLen += read
return read
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
ensureCapacity(bufLen + length)
data.readBytes(buf, bufLen, length)
bufLen += length
}
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
val start = bufLen - offset - size
samples.add(CapturedSample(timeUs, buf.copyOfRange(start, start + size)))
if (offset == 0) bufLen = 0
}
private fun ensureCapacity(needed: Int) {
if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2))
}
}
private class FakeExtractorOutput : ExtractorOutput {
val tracks = mutableMapOf<Int, FakeTrackOutput>()
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { FakeTrackOutput() }
override fun endTracks() {}
override fun seekMap(seekMap: SeekMap) {}
}
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
var position = 0L
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (position >= data.size) return C.RESULT_END_OF_INPUT
val toRead = minOf(length, data.size - position.toInt())
System.arraycopy(data, position.toInt(), buffer, offset, toRead)
position += toRead
return toRead
}
}
private fun extract(resource: String): FakeExtractorOutput {
val data = checkNotNull(javaClass.getResourceAsStream(resource)) {
"fixture $resource missing from test resources"
}.use { it.readBytes() }
// DefaultSubtitleParserFactory (not AssSubtitleParserFactory) so the SSA track
// transcodes to decodable media3 cues instead of loading native libass.
val extractor = ZlibMatroskaExtractor(DefaultSubtitleParserFactory(), AssHandler())
val output = FakeExtractorOutput()
extractor.init(output)
val reader = ByteArrayDataReader(data)
var input = DefaultExtractorInput(reader, 0, data.size.toLong())
val seekPosition = PositionHolder()
while (true) {
when (extractor.read(input, seekPosition)) {
Extractor.RESULT_END_OF_INPUT -> return output
Extractor.RESULT_SEEK -> {
reader.position = seekPosition.position
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
}
else -> {}
}
}
}
private class DecodedCue(val startTimeUs: Long, val durationUs: Long, val text: String)
private fun decodeCues(output: FakeExtractorOutput): List<DecodedCue> {
val track = output.tracks.values.single()
val format = track.formats.last()
assertEquals(MimeTypes.APPLICATION_MEDIA3_CUES, format.sampleMimeType)
assertEquals(MimeTypes.TEXT_SSA, format.codecs)
val decoder = CueDecoder()
return track.samples.map { sample ->
val cues = decoder.decode(sample.timeUs, sample.data, 0, sample.data.size)
DecodedCue(
cues.startTimeUs,
cues.durationUs,
cues.cues.joinToString("\n") { it.text.toString() }
)
}
}
@Test
fun zlibCompressedAssDialogueIsInflatedToParseableCues() {
val cues = decodeCues(extract("/zlib_ssa.mkv"))
assertEquals(45, cues.size)
assertEquals("SUBTITLE LINE 1 (0:00:00.00)", cues.first().text)
assertEquals(0L, cues.first().startTimeUs)
assertEquals(2_000_000L, cues.first().durationUs)
assertEquals("SUBTITLE LINE 45 (0:01:28.00)", cues.last().text)
assertEquals(88_000_000L, cues.last().startTimeUs)
cues.forEachIndexed { index, cue ->
assertEquals(index * 2_000_000L, cue.startTimeUs)
assertTrue("cue $index text: ${cue.text}", cue.text.startsWith("SUBTITLE LINE ${index + 1} "))
}
}
@Test
fun zlibAndPlainMuxesProduceIdenticalCues() {
val zlib = decodeCues(extract("/zlib_ssa.mkv"))
val plain = decodeCues(extract("/plain_ssa.mkv"))
assertEquals(plain.size, zlib.size)
plain.zip(zlib).forEachIndexed { index, (expected, actual) ->
assertEquals("startTimeUs of cue $index", expected.startTimeUs, actual.startTimeUs)
assertEquals("durationUs of cue $index", expected.durationUs, actual.durationUs)
assertEquals("text of cue $index", expected.text, actual.text)
}
}
}
@@ -0,0 +1,38 @@
package com.edde746.plezy.mpv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Boundaries are the contract; rationale on [DemuxerBudget]. */
class DemuxerBudgetTest {
private val mib = 1024L * 1024L
@Test
fun `unknown heap class keeps mpv defaults`() {
assertNull(DemuxerBudget.forHeapClassMB(0))
assertNull(DemuxerBudget.forHeapClassMB(-1))
}
@Test
fun `small heap class gets the tight tier`() {
val budget = DemuxerBudget.forHeapClassMB(256)!!
assertEquals(32 * mib, budget.aheadBytes)
assertEquals(16 * mib, budget.backBytes)
}
@Test
fun `mid heap class gets the middle tier`() {
assertEquals(64 * mib, DemuxerBudget.forHeapClassMB(257)!!.aheadBytes)
val budget = DemuxerBudget.forHeapClassMB(512)!!
assertEquals(64 * mib, budget.aheadBytes)
assertEquals(32 * mib, budget.backBytes)
}
@Test
fun `large heap class gets the full tier`() {
val budget = DemuxerBudget.forHeapClassMB(513)!!
assertEquals(100 * mib, budget.aheadBytes)
assertEquals(48 * mib, budget.backBytes)
}
}
@@ -0,0 +1,114 @@
package com.edde746.plezy.mpv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* P5 has no compatible base layer: on a device without native DV it must
* leave the video plane for gpu-next software reshaping, and nothing else
* may be routed (wrong-colors class verified on a Pixel 7). The vo target
* matrix keeps gpu-next away from hardware sessions (#2010) while giving
* the reshaping path the only vo that composites RPU metadata (#1902).
*/
class GpuVoPolicyTest {
@Test
fun `P5 without native support is routed`() {
assertTrue(GpuVoPolicy.needsDvReshaping(5L, "auto", canPlayP5Natively = false))
}
@Test
fun `P5 with native support stays on the plane`() {
assertFalse(GpuVoPolicy.needsDvReshaping(5L, "auto", canPlayP5Natively = true))
}
@Test
fun `base-layer-compatible profiles are never routed`() {
// P7/P8 strip to an HDR10/HLG base layer; non-DV content has no profile.
assertFalse(GpuVoPolicy.needsDvReshaping(7L, "auto", canPlayP5Natively = false))
assertFalse(GpuVoPolicy.needsDvReshaping(8L, "auto", canPlayP5Natively = false))
assertFalse(GpuVoPolicy.needsDvReshaping(null, "auto", canPlayP5Natively = false))
}
@Test
fun `explicit conversion modes are user overrides and stay native`() {
for (mode in listOf("disabled", "native", "dv81", "hevc", "hevc_strip")) {
assertFalse(mode, GpuVoPolicy.needsDvReshaping(5L, mode, canPlayP5Natively = false))
}
}
@Test
fun `hdr tone-mapping is needed only for a PQ or HLG signal on a non-HDR display`() {
assertTrue(GpuVoPolicy.needsHdrToneMapping("pq", displaySupportsHdr = false))
assertTrue(GpuVoPolicy.needsHdrToneMapping("hlg", displaySupportsHdr = false))
// An HDR display scans the signal out itself.
assertFalse(GpuVoPolicy.needsHdrToneMapping("pq", displaySupportsHdr = true))
assertFalse(GpuVoPolicy.needsHdrToneMapping("hlg", displaySupportsHdr = true))
// SDR transfers need no mapping, and mpv reports none before the first
// frame of a file.
assertFalse(GpuVoPolicy.needsHdrToneMapping("bt.1886", displaySupportsHdr = false))
assertFalse(GpuVoPolicy.needsHdrToneMapping("srgb", displaySupportsHdr = false))
assertFalse(GpuVoPolicy.needsHdrToneMapping(null, displaySupportsHdr = false))
assertFalse(GpuVoPolicy.needsHdrToneMapping("", displaySupportsHdr = false))
}
@Test
fun `only direct mediacodec output can stay on the plane`() {
// -copy also reads frames back into system memory, so it leaves too.
assertTrue(GpuVoPolicy.needsSoftwareRender("no"))
assertTrue(GpuVoPolicy.needsSoftwareRender("mediacodec-copy"))
assertFalse(GpuVoPolicy.needsSoftwareRender("mediacodec"))
// Unreported until the decoder initializes: stay on the plane.
assertFalse(GpuVoPolicy.needsSoftwareRender(null))
assertFalse(GpuVoPolicy.needsSoftwareRender(""))
}
@Test
fun `a software-decoding session targets gpu, not gpu-next`() {
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SW_DECODE)))
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS)))
assertEquals(
"gpu",
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_SW_DECODE))
)
}
@Test
fun `no reasons keeps the video plane`() {
assertNull(GpuVoPolicy.targetFor(emptySet()))
}
@Test
fun `dv reshaping targets gpu-next even alongside other reasons`() {
assertEquals("gpu-next", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_DV_RESHAPE)))
assertEquals(
"gpu-next",
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_DV_RESHAPE))
)
}
@Test
fun `shaders and chain failure target the hardware-safe gpu vo`() {
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS)))
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_CHAIN_FAILURE)))
assertEquals(
"gpu",
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_SHADERS, GpuVoPolicy.REASON_CHAIN_FAILURE))
)
}
@Test
fun `hdr tone-mapping targets gpu but yields to dv reshaping`() {
assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR)))
assertEquals(
"gpu",
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR, GpuVoPolicy.REASON_SHADERS))
)
assertEquals(
"gpu-next",
GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HDR_SDR, 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
@@ -24,6 +24,7 @@ import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.suspendCancellableCoroutine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -62,14 +63,24 @@ class MpvPlayerPluginTest {
}
@Test
fun hardwareDecodeSessionsKeepTheLegacyGpuVo() {
// gpu-next under hwdec=mediacodec fails every frame on Tegra (the
// cross-stage samplerExternalOES linker bug): solid blue screen with
// audio on the Shield (#2010). DV reshaping — the reason gpu-next exists
// on Android (#1902) — only happens under software decode anyway, so
// gpu-next is offered exactly there and nowhere else.
assertEquals("gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = true))
assertEquals("gpu-next,gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = false))
fun hardwareDecodeSessionsUseTheMediaCodecVoWithGpuFallback() {
// Rationale on MpvPlayerCore.initialVideoOutput.
assertEquals("mediacodec,gpu", MpvPlayerCore.initialVideoOutput(hardwareDecoding = true))
assertEquals("gpu,gpu-next", MpvPlayerCore.initialVideoOutput(hardwareDecoding = false))
}
@Test
fun hdrSurfaceIsWantedOnlyForPqAndHlgTransfers() {
// Rationale on MpvPlayerCore.wantsHdrSurface: both render into a PQ
// target; anything else stays on the sRGB surface, which renders every
// content correctly.
assertTrue(MpvPlayerCore.wantsHdrSurface("smpte2084"))
assertTrue(MpvPlayerCore.wantsHdrSurface("arib-std-b67"))
assertFalse(MpvPlayerCore.wantsHdrSurface("bt709"))
assertFalse(MpvPlayerCore.wantsHdrSurface("bt1886"))
assertFalse(MpvPlayerCore.wantsHdrSurface("unknown"))
assertFalse(MpvPlayerCore.wantsHdrSurface(""))
assertFalse(MpvPlayerCore.wantsHdrSurface(null))
}
@Test
@@ -622,6 +633,53 @@ class MpvPlayerPluginTest {
assertEquals(0, pending.size)
}
@Test
fun staleDisposeIsAcknowledgedWithoutTearingDownTheCore() {
// A dispose whose instanceId is not the core creator's lost the ownership
// race to a successor; tearing the core down anyway would kill that
// successor's session. It must be acknowledged as a no-op instead.
val plugin = MpvPlayerPlugin()
installCore(plugin, testCore { _, _ -> })
setPluginField(plugin, "coreInstanceId", 2L)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 1)), result)
awaitCompletion(result)
assertNull(result.errorCode)
assertNotNull(getPluginField(plugin, "playerCore"))
assertEquals(2L, getPluginField(plugin, "coreInstanceId"))
}
@Test
fun matchingDisposeTearsDownTheCoreAndClearsTheToken() {
val plugin = MpvPlayerPlugin()
installCore(plugin, testCore { _, _ -> })
setPluginField(plugin, "coreInstanceId", 7L)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", mapOf("instanceId" to 7)), result)
awaitCompletion(result)
assertNull(result.errorCode)
assertNull(getPluginField(plugin, "playerCore"))
assertNull(getPluginField(plugin, "coreInstanceId"))
}
@Test
fun tokenlessDisposeKeepsLegacySemanticsAndTearsDownTheCore() {
val plugin = MpvPlayerPlugin()
installCore(plugin, testCore { _, _ -> })
setPluginField(plugin, "coreInstanceId", 7L)
val result = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", null), result)
awaitCompletion(result)
assertNull(result.errorCode)
assertNull(getPluginField(plugin, "playerCore"))
}
@Test
fun configDetachThenEngineDetachTearsDownVideoCoreAndPendingInitOnce() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
@@ -891,6 +949,97 @@ class MpvPlayerPluginTest {
getBoolean(core)
}
private fun invokeSetGpuVoRequirement(core: MpvPlayerCore, reason: String, active: Boolean) {
MpvPlayerCore::class.java
.getDeclaredMethod("setGpuVoRequirement", String::class.java, Boolean::class.javaPrimitiveType)
.apply {
isAccessible = true
invoke(core, reason, active)
}
}
@Test
fun voTargetFollowsTheActiveReasonSet() {
// Reason precedence through the real property-write path: DV reshaping
// outranks HDR-on-SDR, dropping the winner falls back to the reason still
// active, and dropping the last one returns the session to the plane.
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)
fun lastVo(): String? = writes.toList().lastOrNull { it.first == "vo" }?.second
// HDR-on-SDR raises first, then the DV router wins the arbitration.
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_HDR_SDR, true)
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_DV_RESHAPE, true)
awaitCondition { lastVo() == "gpu-next" }
assertEquals("gpu-next", lastVo())
// Dropping the winning reason must fall back to the one still active,
// not to the plane.
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_DV_RESHAPE, false)
awaitCondition { lastVo() == "gpu" }
assertEquals("gpu", lastVo())
// Last reason dropping returns the session to the video plane.
invokeSetGpuVoRequirement(core, GpuVoPolicy.REASON_HDR_SDR, false)
awaitCondition { lastVo() == "mediacodec" }
assertEquals("mediacodec", lastVo())
}
@Test
fun dvConversionModeMapsOntoForkDecoderOptions() {
// The app-level `dv-conversion-mode` property must translate to the fork
// FFmpeg hevc_mediacodec options, mirroring the ExoPlayer DoviBridge
// modes. Robolectric reports no Dolby Vision display, so `auto` takes the
// no-DV branch deterministically.
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)
})
fun apply(mode: String): Result<Unit> {
writes.clear()
var outcome: Result<Unit>? = null
core.setProperty("dv-conversion-mode", mode) { outcome = it }
awaitCondition { outcome != null }
return outcome!!
}
assertTrue(apply("auto").isSuccess)
assertEquals(listOf("vd-lavc-o" to "dolby_vision=0,dv_p7_mode=strip"), writes.toList())
assertTrue(apply("disabled").isSuccess)
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=native"), writes.toList())
assertTrue(apply("dv81").isSuccess)
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=convert"), writes.toList())
assertTrue(apply("hevc_strip").isSuccess)
assertEquals(listOf("vd-lavc-o" to "dolby_vision=1,dv_p7_mode=strip"), writes.toList())
val invalid = apply("bogus")
assertTrue(invalid.isFailure)
assertTrue(writes.isEmpty())
}
@Test
fun decoderOptionsMergeKeepsUserEntriesFirst() {
// The property interface cannot append to a list option, so the write
// replaces it wholesale: a user's own mpv.conf `vd-lavc-o` entries must
// survive, with the app's keys last (FFmpeg keeps the last duplicate).
assertEquals(
"threads=4,dolby_vision=1",
MpvPlayerCore.mergeDecoderOptions("threads=4", "dolby_vision=1")
)
assertEquals("dolby_vision=1", MpvPlayerCore.mergeDecoderOptions(null, "dolby_vision=1"))
assertEquals("dolby_vision=1", MpvPlayerCore.mergeDecoderOptions(" ", "dolby_vision=1"))
}
private fun awaitQueueEntry(
queue: ConcurrentLinkedQueue<Pair<String, String>>,
expected: Pair<String, String>
@@ -0,0 +1,72 @@
package com.edde746.plezy.mpv
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class VideoRectPolicyTest {
@Test
fun `contain letterboxes wide content inside the container`() {
// 2.39:1 content on a 16:9 container: full width, bars top and bottom.
val size = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000)!!
assertEquals(1920, size.width)
assertEquals(803, size.height)
assertTrue(size.height < 1080)
}
@Test
fun `contain pillarboxes tall content inside the container`() {
val size = VideoRectPolicy.sizeFor(1920, 1080, 1000, 2390)!!
assertEquals(1080, size.height)
assertTrue(size.width < 1920)
}
@Test
fun `panscan 1 fills the container so the image is cropped, not letterboxed`() {
// The regression this exists for: cover used to be a no-op on the plane.
val size = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 1f)!!
assertTrue("cover must reach the container height", size.height >= 1080)
assertTrue("width must overflow and be clipped", size.width > 1920)
}
@Test
fun `panscan interpolates between contain and cover`() {
val contain = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000)!!
val half = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 0.5f)!!
val cover = VideoRectPolicy.sizeFor(1920, 1080, 2390, 1000, panscan = 1f)!!
assertTrue(half.height > contain.height)
assertTrue(half.height < cover.height)
}
@Test
fun `video-zoom is a log2 factor on top of the fit`() {
val base = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080)!!
val doubled = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = 1f)!!
val halved = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = -1f)!!
assertEquals(base.width * 2, doubled.width)
assertEquals(base.width / 2, halved.width)
}
@Test
fun `cover fills a container whose aspect is wildly mismatched`() {
// Scope content in a portrait container: cover/fit exceeds 4, which a
// fit-relative clamp would truncate back into letterboxing.
val size = VideoRectPolicy.sizeFor(1080, 2400, 2390, 1000, panscan = 1f)!!
assertTrue("height must reach the container", size.height >= 2400)
}
@Test
fun `an absurd zoom is bounded rather than allocating an unbounded surface`() {
val fit = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080)!!
val absurd = VideoRectPolicy.sizeFor(1920, 1080, 1920, 1080, videoZoomLog2 = 12f)!!
assertEquals(fit.width * 4, absurd.width)
}
@Test
fun `unknown dimensions yield no size instead of a degenerate one`() {
assertNull(VideoRectPolicy.sizeFor(0, 1080, 1920, 1080))
assertNull(VideoRectPolicy.sizeFor(1920, 1080, 0, 0))
assertNull(VideoRectPolicy.sizeFor(1920, 0, 1920, 1080))
}
}
@@ -0,0 +1,94 @@
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
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
/**
* Teardown ordering for frame-rate matching (#2172): restoring the display
* mode while the display is still signaling HDR folds the HDR exit and the
* mode change into one slow HDMI renegotiation. An HDR session therefore
* defers the restore; an SDR session restores immediately.
*/
@RunWith(RobolectricTestRunner::class)
class FrameRateManagerRestoreTest {
private fun buildManager(activity: Activity): FrameRateManager = FrameRateManager(activity, Handler(Looper.getMainLooper()))
private fun preferredModeId(activity: Activity): Int = activity.window.attributes.preferredDisplayModeId
private fun applyPreferredMode(activity: Activity, modeId: Int) {
val attrs = activity.window.attributes
attrs.preferredDisplayModeId = modeId
activity.window.attributes = attrs
}
@Test
fun sdrClearRestoresTheDefaultModeImmediately() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val manager = buildManager(activity)
applyPreferredMode(activity, 4)
manager.clearVideoFrameRate(hdrActive = false)
assertEquals(0, preferredModeId(activity))
}
@Test
fun hdrClearDefersTheRestoreUntilTheHdrExitSettles() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val manager = buildManager(activity)
applyPreferredMode(activity, 4)
manager.clearVideoFrameRate(hdrActive = true)
// Still at the content mode: the surface teardown must commit the HDR
// exit before the mode change renegotiates the link.
assertEquals(4, preferredModeId(activity))
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
assertEquals(0, preferredModeId(activity))
}
@Test
fun newSwitchRequestCancelsAPendingDeferredRestore() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val manager = buildManager(activity)
applyPreferredMode(activity, 4)
manager.clearVideoFrameRate(hdrActive = true)
// A new session's request arrives before the deferred restore fires.
manager.setVideoFrameRate(fps = 23.976f, videoDurationMs = 0L, extraDelayMs = 0L) { }
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
// The stale restore must not clobber whatever the new request applied.
// (Robolectric's display has no matching mode, so the request itself
// leaves the window untouched; only the cancelled restore could zero it.)
assertNotEquals(0, preferredModeId(activity))
}
@Test
fun hdrClearWithNoAppliedModePostsNoRestore() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val manager = buildManager(activity)
applyPreferredMode(activity, 7)
// Simulate "nothing applied by us": manager sees modeId 0.
applyPreferredMode(activity, 0)
manager.clearVideoFrameRate(hdrActive = true)
// A later, externally applied mode must not be clobbered by a stale
// deferred restore from a session that never switched.
applyPreferredMode(activity, 7)
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500))
assertEquals(7, preferredModeId(activity))
}
}
@@ -1,5 +1,6 @@
package com.edde746.plezy.shared
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -60,4 +61,18 @@ class MediaCodecQueryTest {
assertFalse(MediaCodecQuery.isHardwareAccelerated(28, true, "OMX.google.h264.decoder"))
assertTrue(MediaCodecQuery.isHardwareAccelerated(28, false, "OMX.qcom.video.decoder.avc"))
}
@Test
fun mapsGatedCodecsToTheMimeTypesDecodersAdvertise() {
assertEquals(
mapOf("hevc" to true, "av1" to true),
MediaCodecQuery.hardwareVideoDecodeSupport(
setOf("video/avc", "video/hevc", "video/av01", "audio/mp4a-latm")
)
)
assertEquals(
mapOf("hevc" to true, "av1" to false),
MediaCodecQuery.hardwareVideoDecodeSupport(setOf("video/avc", "video/hevc"))
)
}
}
@@ -4,6 +4,7 @@ import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
import android.widget.FrameLayout
import org.junit.Assert.assertEquals
@@ -41,4 +42,27 @@ class PlayerSurfaceHostTest {
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.width)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.height)
}
@Test
fun letterboxAreaIsCoveredByABufferlessPunchSurfaceBeneathTheVideo() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val callback = object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = Unit
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
override fun surfaceDestroyed(holder: SurfaceHolder) = Unit
}
val container = PlayerSurfaceHost.createContainer(activity)
val punch = container.getChildAt(0)
assertTrue(punch is SurfaceView)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.width)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.height)
// Cores append their surfaces after createContainer, so video (and the mpv
// OSD plane) always land above the punch layer in drawing order.
val video = PlayerSurfaceHost.createVideoSurface(activity, callback)
container.addView(video)
assertTrue(container.indexOfChild(video) > container.indexOfChild(punch))
}
}
+1 -3
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"
@@ -58,6 +58,4 @@ dependencies {
implementation("androidx.media3:media3-ui:1.11.0")
testImplementation("junit:junit:4.13.2")
// media3 Format/MimeTypes touch android.util in static initializers.
testImplementation("org.robolectric:robolectric:4.16.1")
}
@@ -1,31 +0,0 @@
package com.edde746.plezy.libass.media
/**
* Shared policy for embedded font attachments delivered to libass: which
* attachment mime types are fonts, and the per-font/aggregate byte budgets
* that keep a hostile container from ballooning the font store.
*/
object AssFonts {
const val MAX_FONT_BYTES = 16L * 1024 * 1024
const val MAX_TOTAL_FONT_BYTES = 32L * 1024 * 1024
val fontMimeTypes = listOf(
"font/ttf",
"font/otf",
"font/sfnt",
"font/woff",
"font/woff2",
"application/font-sfnt",
"application/font-woff",
"application/x-truetype-font",
"application/vnd.ms-opentype",
"application/x-font-ttf"
)
/** Null to accept the font, or a human-readable rejection reason. */
fun rejectionReason(size: Long, acceptedBytes: Long): String? = when {
size > MAX_FONT_BYTES -> "per-font limit"
size > MAX_TOTAL_FONT_BYTES - acceptedBytes -> "aggregate limit"
else -> null
}
}
@@ -0,0 +1,169 @@
package com.edde746.plezy.libass.media.extractor
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.ParserException
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.mkv.EbmlProcessor
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.text.AssSubtitleExtractorOutput
@OptIn(UnstableApi::class)
open class AssMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
private val assHandler: AssHandler,
flags: Int = 0
) : MatroskaExtractor(subtitleParserFactory, flags) {
private var currentAttachmentName: String? = null
private var currentAttachmentMime: String? = null
internal var acceptedFontBytes = 0L
private set
internal val subtitleSample = subtitleSampleField.get(this) as ParsableByteArray
override fun getElementType(id: Int): Int = when (id) {
ID_ATTACHMENTS -> EbmlProcessor.ELEMENT_TYPE_MASTER
ID_ATTACHED_FILE -> EbmlProcessor.ELEMENT_TYPE_MASTER
ID_FILE_NAME -> EbmlProcessor.ELEMENT_TYPE_STRING
ID_FILE_MIME_TYPE -> EbmlProcessor.ELEMENT_TYPE_STRING
ID_FILE_DATA -> EbmlProcessor.ELEMENT_TYPE_BINARY
else -> super.getElementType(id)
}
override fun isLevel1Element(id: Int): Boolean = super.isLevel1Element(id) || id == ID_ATTACHMENTS
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
when (id) {
ID_EBML -> {
val currentExtractor = extractorOutput.get(this) as ExtractorOutput
if (currentExtractor !is AssSubtitleExtractorOutput) {
extractorOutput.set(
this,
AssSubtitleExtractorOutput(currentExtractor, assHandler, this)
)
}
super.startMasterElement(id, contentPosition, contentSize)
}
ID_ATTACHED_FILE -> clearAttachment()
else -> super.startMasterElement(id, contentPosition, contentSize)
}
}
override fun endMasterElement(id: Int) {
when (id) {
ID_VIDEO -> {
// We need to get the video dimensions very early
val track = getCurrentTrack(id)
assHandler.setVideoSize(track.width, track.height)
super.endMasterElement(id)
}
ID_ATTACHED_FILE -> clearAttachment()
else -> super.endMasterElement(id)
}
}
override fun stringElement(id: Int, value: String) {
when (id) {
ID_FILE_NAME -> currentAttachmentName = value
ID_FILE_MIME_TYPE -> currentAttachmentMime = value
else -> super.stringElement(id, value)
}
}
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
when (id) {
ID_FILE_DATA -> {
if (contentSize < 0) {
throw ParserException.createForMalformedContainer(
"Negative Matroska attachment size",
null
)
}
val attachmentName = requireNotNull(currentAttachmentName)
val attachmentMime = requireNotNull(currentAttachmentMime)
if (attachmentMime !in fontMimeTypes) {
input.skipFully(contentSize)
return
}
if (contentSize == 0) {
input.skipFully(0)
return
}
val size = contentSize.toLong()
val rejectionReason = when {
size > MAX_FONT_BYTES -> "per-font limit"
size > MAX_TOTAL_FONT_BYTES - acceptedFontBytes -> "aggregate limit"
else -> null
}
if (rejectionReason != null) {
onFontRejected(contentSize, acceptedFontBytes, rejectionReason)
input.skipFully(contentSize)
return
}
val data = ByteArray(contentSize)
input.readFully(data, 0, contentSize)
acceptedFontBytes += size
assHandler.addFont(attachmentName, data)
}
else -> super.binaryElement(id, contentSize, input)
}
}
protected open fun onFontRejected(
contentSize: Int,
acceptedBytes: Long,
reason: String
) {
Log.w(
TAG,
"Skipping embedded font: $reason (bytes=$contentSize, accepted=$acceptedBytes)"
)
}
private fun clearAttachment() {
currentAttachmentName = null
currentAttachmentMime = null
}
companion object {
private const val TAG = "AssMatroskaExtractor"
internal const val MAX_FONT_BYTES = 16L * 1024 * 1024
internal const val MAX_TOTAL_FONT_BYTES = 32L * 1024 * 1024
const val ID_EBML = 0x1A45DFA3
const val ID_VIDEO = 0xE0
const val ID_ATTACHMENTS = 0x1941A469
const val ID_ATTACHED_FILE = 0x61A7
const val ID_FILE_NAME = 0x466E
const val ID_FILE_MIME_TYPE = 0x4660
const val ID_FILE_DATA = 0x465C
val fontMimeTypes = listOf(
"font/ttf",
"font/otf",
"font/sfnt",
"font/woff",
"font/woff2",
"application/font-sfnt",
"application/font-woff",
"application/x-truetype-font",
"application/vnd.ms-opentype",
"application/x-font-ttf"
)
val extractorOutput = MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
isAccessible = true
}
val subtitleSampleField = MatroskaExtractor::class.java.getDeclaredField("subtitleSample").apply {
isAccessible = true
}
}
}
@@ -5,22 +5,21 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.TrackOutput
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
/**
* Wraps every text track with [AssTrackOutput] so embedded ASS dialogue
* reaches libass. Install it on the raw-subtitle side of the output chain —
* between the extractor and the SubtitleTranscodingExtractorOutput — so the
* dialogue bytes are still unparsed when they pass through.
* This class is only used by the overlay renderer. It's needed to get the start time of the subtitles.
*/
@UnstableApi
class AssSubtitleExtractorOutput(
private val delegate: ExtractorOutput,
private val assHandler: AssHandler
private val assHandler: AssHandler,
private val extractor: AssMatroskaExtractor
) : ExtractorOutput by delegate {
override fun track(id: Int, type: Int): TrackOutput = if (type == C.TRACK_TYPE_TEXT) {
// We can't know at this time if the subtitle track is ASS or another
// format, so we wrap every subtitle track; non-ASS tracks pass through.
AssTrackOutput(delegate.track(id, type), assHandler::readTrackDialogue)
// We can't know at this time if the subtitle track is ASS or other format, so we wrap
// every subtitle track
AssTrackOutput(delegate.track(id, type), assHandler, extractor)
} else {
delegate.track(id, type)
}
@@ -1,49 +1,28 @@
package com.edde746.plezy.libass.media.text
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.extractor.TrackOutput
import java.io.EOFException
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
import java.util.regex.Pattern
/**
* Feeds embedded ASS dialogue to libass while forwarding every sample
* unchanged. Only used by the overlay renderer, which needs the start time and
* duration of each dialogue line.
*
* Samples must arrive in the media3 MatroskaExtractor subtitle shape
* `Dialogue: 0:00:00:00,<duration timecode>,<ReadOrder,Layer,Style,...>` — the
* second field carries the sample *duration* (MatroskaExtractor patches
* BlockDuration into it; the FFmpeg extractor writes the packet duration).
* The extractor beneath must therefore emit raw subtitle samples: media3's
* MatroskaExtractor with FLAG_EMIT_RAW_SUBTITLE_DATA, or the FFmpeg demuxer
* path. Cue transcoding happens downstream of this wrapper.
* This class is only used by the overlay renderer. It's needed to get the start time of the subtitles.
*/
@UnstableApi
class AssTrackOutput(
private val delegate: TrackOutput,
private val dialogueSink: DialogueSink
) : TrackOutput {
/** Receives one dialogue line; mirrors AssHandler.readTrackDialogue. */
fun interface DialogueSink {
fun onDialogue(trackId: String?, startMs: Long, durationMs: Long, data: ByteArray, offset: Int, length: Int)
}
private val assHandler: AssHandler,
private val extractor: AssMatroskaExtractor
) : TrackOutput by delegate {
private var isAss = false
private var trackId: String? = null
// Sample bytes buffered between sampleData and sampleMetadata; only
// populated for ASS tracks.
private var buf = ByteArray(INITIAL_BUFFER_SIZE)
private var bufLen = 0
private var readBuf = ByteArray(INITIAL_BUFFER_SIZE)
private val forwardParsable = ParsableByteArray()
private var trackId: String? = null
override fun format(format: Format) {
if (format.sampleMimeType == MimeTypes.TEXT_SSA || format.codecs == MimeTypes.TEXT_SSA) {
@@ -53,34 +32,6 @@ class AssTrackOutput(
delegate.format(format)
}
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
if (!isAss) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
if (readBuf.size < length) readBuf = ByteArray(length)
val bytesRead = input.read(readBuf, 0, length)
if (bytesRead == C.RESULT_END_OF_INPUT) {
if (!allowEndOfInput) throw EOFException()
return C.RESULT_END_OF_INPUT
}
append(readBuf, bytesRead)
forwardParsable.reset(readBuf, bytesRead)
delegate.sampleData(forwardParsable, bytesRead, sampleDataPart)
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (isAss) {
ensureCapacity(bufLen + length)
System.arraycopy(data.data, data.position, buf, bufLen, length)
bufLen += length
}
delegate.sampleData(data, length, sampleDataPart)
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
@@ -88,37 +39,26 @@ class AssTrackOutput(
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
if (isAss) {
// offset counts bytes buffered after this sample; the slice math matches
// media3's TrackOutput contract even though subtitle samples are
// normally delivered one at a time with offset 0.
val start = bufLen - offset - size
if (timeUs != C.TIME_UNSET && start >= 0) {
feedDialogue(timeUs, start, size)
}
if (offset == 0) bufLen = 0
if (isAss && timeUs.isValidTs) {
val sample = extractor.subtitleSample
val endIndex = findTokenIndex(sample.data, 1)
val lineIndex = findTokenIndex(sample.data, 2)
val rawDuration = sample.data.decodeToString(endIndex, lineIndex - 1)
val durationUs = parseTimecodeUs(rawDuration)
assHandler.readTrackDialogue(
trackId = trackId,
start = timeUs / 1000,
duration = durationUs / 1000,
data = sample.data,
offset = lineIndex,
length = sample.limit() - lineIndex
)
}
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
}
private fun feedDialogue(timeUs: Long, start: Int, size: Int) {
val end = start + size
val durationIndex = findTokenIndex(start, end, 1)
val lineIndex = findTokenIndex(start, end, 2)
if (durationIndex <= start || lineIndex <= durationIndex) return
val rawDuration = buf.decodeToString(durationIndex, lineIndex - 1)
val durationUs = parseTimecodeUs(rawDuration)
if (durationUs == C.TIME_UNSET) return
dialogueSink.onDialogue(
trackId,
timeUs / 1000,
durationUs / 1000,
buf,
lineIndex,
end - lineIndex
)
}
private fun parseTimecodeUs(timeString: String): Long {
val matcher = SSA_TIMECODE_PATTERN.matcher(timeString.trim { it <= ' ' })
if (!matcher.matches()) {
@@ -132,32 +72,24 @@ class AssTrackOutput(
return timestampUs
}
/** Index just past the [tokenNumber]th comma inside [from, to), or [from]. */
private fun findTokenIndex(from: Int, to: Int, tokenNumber: Int): Int {
private fun findTokenIndex(array: ByteArray, tokenNumber: Int): Int {
if (tokenNumber == 0) return 0
var tokensFound = 0
for (index in from until to) {
if (buf[index] == COMMA && ++tokensFound == tokenNumber) {
array.forEachIndexed { index, byte ->
if (byte == COMMA && ++tokensFound == tokenNumber) {
return index + 1
}
}
return from
return 0
}
private fun append(src: ByteArray, length: Int) {
ensureCapacity(bufLen + length)
System.arraycopy(src, 0, buf, bufLen, length)
bufLen += length
}
private fun ensureCapacity(needed: Int) {
if (buf.size < needed) buf = buf.copyOf(maxOf(needed, buf.size * 2))
}
private val Long.isValidTs
get() = this != C.TIME_UNSET
private companion object {
val SSA_TIMECODE_PATTERN: Pattern =
Pattern.compile("""(?:(\d+):)?(\d+):(\d+)[:.](\d+)""")
const val COMMA = ','.code.toByte()
const val INITIAL_BUFFER_SIZE = 1024
}
}
@@ -0,0 +1,156 @@
package com.edde746.plezy.libass.media.extractor
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.ParserException
import androidx.media3.extractor.DefaultExtractorInput
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
import com.edde746.plezy.libass.media.AssHandler
import java.io.EOFException
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class AssMatroskaExtractorTest {
@Test
fun smallFontIsDeliveredAndNonFontDoesNotConsumeBudget() {
val handler = AssHandler()
val extractor = extractor(handler)
attachment(extractor, "font/ttf")
extractor.fileData(4, input(4, seed = 11))
attachment(extractor, "application/octet-stream")
extractor.fileData(9, input(9, seed = 22))
val pending = handler.fontStore.pendingSnapshot()
assertEquals(1, pending.size)
assertEquals("fixture-font", pending.single().first)
assertArrayEquals(byteArrayOf(11, 12, 13, 14), pending.single().second)
assertEquals(4L, extractor.acceptedFontBytes)
}
@Test
fun perFontLimitAcceptsExactBoundaryAndSkipsOneByteOver() {
val handler = AssHandler()
val extractor = extractor(handler)
val limit = AssMatroskaExtractor.MAX_FONT_BYTES.toInt()
attachment(extractor, "font/otf")
extractor.fileData(limit, input(limit))
attachment(extractor, "font/otf")
extractor.fileData(limit + 1, input(limit + 1))
assertEquals(limit.toLong(), extractor.acceptedFontBytes)
assertEquals(1, handler.fontStore.pendingSnapshot().size)
}
@Test
fun aggregateLimitIsDeterministicAcrossAttachmentEntries() {
val handler = AssHandler()
val extractor = extractor(handler)
val perFont = AssMatroskaExtractor.MAX_FONT_BYTES.toInt()
repeat(2) {
attachment(extractor, "font/ttf")
extractor.fileData(perFont, input(perFont, seed = it))
extractor.endAttachment()
}
attachment(extractor, "font/ttf")
extractor.fileData(1, input(1))
assertEquals(AssMatroskaExtractor.MAX_TOTAL_FONT_BYTES, extractor.acceptedFontBytes)
assertEquals(2, handler.fontStore.pendingSnapshot().size)
}
@Test
fun negativeAndZeroSizesAllocateAndDeliverNothing() {
val handler = AssHandler()
val extractor = extractor(handler)
attachment(extractor, "font/woff2")
assertThrows(ParserException::class.java) {
extractor.fileData(-1, input(0))
}
attachment(extractor, "font/woff2")
extractor.fileData(0, input(0))
assertEquals(0L, extractor.acceptedFontBytes)
assertEquals(0, handler.fontStore.pendingSnapshot().size)
}
@Test
fun failedReadDoesNotChargeAggregateBudgetOrDeliverPartialFont() {
val handler = AssHandler()
val extractor = extractor(handler)
attachment(extractor, "font/ttf")
assertThrows(EOFException::class.java) {
extractor.fileData(1024, input(1024, available = 4))
}
assertEquals(0L, extractor.acceptedFontBytes)
assertEquals(0, handler.fontStore.pendingSnapshot().size)
attachment(extractor, "font/ttf")
extractor.fileData(1024, input(1024))
assertEquals(1024L, extractor.acceptedFontBytes)
assertEquals(1, handler.fontStore.pendingSnapshot().size)
}
private fun extractor(handler: AssHandler) = TestExtractor(handler)
private fun attachment(extractor: TestExtractor, mime: String) {
extractor.setAttachment(mime)
}
private class TestExtractor(handler: AssHandler) :
AssMatroskaExtractor(
DefaultSubtitleParserFactory(),
handler
) {
fun setAttachment(mime: String) {
startMasterElement(ID_ATTACHED_FILE, 0, 0)
stringElement(ID_FILE_NAME, "fixture-font")
stringElement(ID_FILE_MIME_TYPE, mime)
}
fun fileData(contentSize: Int, input: ExtractorInput) {
binaryElement(ID_FILE_DATA, contentSize, input)
}
fun endAttachment() {
endMasterElement(ID_ATTACHED_FILE)
}
override fun onFontRejected(contentSize: Int, acceptedBytes: Long, reason: String) = Unit
}
private fun input(
declared: Int,
available: Int = declared,
seed: Int = 0
): ExtractorInput = DefaultExtractorInput(
PatternDataReader(available, seed),
0,
declared.toLong()
)
private class PatternDataReader(
private val size: Int,
private val seed: Int
) : DataReader {
private var position = 0
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (position >= size) return C.RESULT_END_OF_INPUT
val count = minOf(length, size - position)
for (index in 0 until count) {
buffer[offset + index] = ((seed + position + index) and 0xFF).toByte()
}
position += count
return count
}
}
}
@@ -1,157 +0,0 @@
package com.edde746.plezy.libass.media.text
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Spec for the dialogue feed both demuxer paths rely on: samples arrive in
* media3's MatroskaExtractor shape ("Dialogue: 0:00:00:00,<duration>,<rest>")
* and the sink must receive the sample start, the parsed duration, and the
* payload after the second comma — while every byte is still forwarded
* unchanged to the delegate.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AssTrackOutputTest {
private class CapturedDialogue(val trackId: String?, val startMs: Long, val durationMs: Long, val line: ByteArray)
private class RecordingSink : AssTrackOutput.DialogueSink {
val dialogues = mutableListOf<CapturedDialogue>()
override fun onDialogue(trackId: String?, startMs: Long, durationMs: Long, data: ByteArray, offset: Int, length: Int) {
dialogues.add(CapturedDialogue(trackId, startMs, durationMs, data.copyOfRange(offset, offset + length)))
}
}
private class RecordingTrackOutput : TrackOutput {
val formats = mutableListOf<Format>()
val samples = mutableListOf<ByteArray>()
val sampleTimes = mutableListOf<Long>()
private var buf = ByteArray(1024)
private var bufLen = 0
override fun format(format: Format) {
formats.add(format)
}
override fun sampleData(input: androidx.media3.common.DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
if (buf.size < bufLen + length) buf = buf.copyOf(bufLen + length)
val read = input.read(buf, bufLen, length)
if (read > 0) bufLen += read
return read
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (buf.size < bufLen + length) buf = buf.copyOf(bufLen + length)
data.readBytes(buf, bufLen, length)
bufLen += length
}
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
val start = bufLen - offset - size
samples.add(buf.copyOfRange(start, start + size))
sampleTimes.add(timeUs)
if (offset == 0) bufLen = 0
}
}
private fun ssaFormat(id: String = "3"): Format = Format.Builder()
.setId(id)
.setSampleMimeType(MimeTypes.TEXT_SSA)
.build()
private fun deliver(output: AssTrackOutput, sample: String, timeUs: Long) {
val bytes = sample.toByteArray(Charsets.UTF_8)
output.sampleData(ParsableByteArray(bytes), bytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, bytes.size, 0, null)
}
@Test
fun feedsDialogueAndForwardsSampleUnchanged() {
val sink = RecordingSink()
val delegate = RecordingTrackOutput()
val output = AssTrackOutput(delegate, sink)
output.format(ssaFormat())
val sample = "Dialogue: 0:00:00:00,0:00:05:00,7,0,Default,,0,0,0,,Hello, world"
deliver(output, sample, 90_000_000L)
assertEquals(1, sink.dialogues.size)
val dialogue = sink.dialogues.first()
assertEquals("3", dialogue.trackId)
assertEquals(90_000L, dialogue.startMs)
assertEquals(5_000L, dialogue.durationMs)
// Payload after the second comma: the raw MKV/FFmpeg block, commas in the
// text field intact.
assertEquals("7,0,Default,,0,0,0,,Hello, world", dialogue.line.toString(Charsets.UTF_8))
// The delegate still receives the full untouched sample.
assertEquals(1, delegate.samples.size)
assertArrayEquals(sample.toByteArray(Charsets.UTF_8), delegate.samples.first())
assertEquals(90_000_000L, delegate.sampleTimes.first())
}
@Test
fun ignoresNonAssTracksAndMalformedSamples() {
val sink = RecordingSink()
val delegate = RecordingTrackOutput()
val output = AssTrackOutput(delegate, sink)
// SubRip track: forwarded, never fed to libass.
output.format(Format.Builder().setId("1").setSampleMimeType(MimeTypes.APPLICATION_SUBRIP).build())
deliver(output, "1\n00:00:00,000 --> 00:00:05,000\nplain text", 1_000_000L)
assertTrue(sink.dialogues.isEmpty())
assertEquals(1, delegate.samples.size)
// ASS track with an unparseable duration: forwarded, not fed.
val assOutput = AssTrackOutput(RecordingTrackOutput(), sink)
assOutput.format(ssaFormat())
deliver(assOutput, "Dialogue: bogus", 1_000_000L)
assertTrue(sink.dialogues.isEmpty())
}
@Test
fun handlesChunkedSampleDataAndDataReaderPath() {
val sink = RecordingSink()
val delegate = RecordingTrackOutput()
val output = AssTrackOutput(delegate, sink)
output.format(ssaFormat("0"))
val sample = "Dialogue: 0:00:00:00,0:00:01:50,1,0,Style,,0,0,0,,Line"
val bytes = sample.toByteArray(Charsets.UTF_8)
val split = bytes.size / 2
output.sampleData(ParsableByteArray(bytes.copyOfRange(0, split)), split, TrackOutput.SAMPLE_DATA_PART_MAIN)
val rest = bytes.copyOfRange(split, bytes.size)
var consumed = 0
val reader = androidx.media3.common.DataReader { buffer, offset, length ->
if (consumed >= rest.size) {
C.RESULT_END_OF_INPUT
} else {
val toRead = minOf(length, rest.size - consumed)
System.arraycopy(rest, consumed, buffer, offset, toRead)
consumed += toRead
toRead
}
}
while (consumed < rest.size) {
output.sampleData(reader, rest.size - consumed, false, TrackOutput.SAMPLE_DATA_PART_MAIN)
}
output.sampleMetadata(0L, C.BUFFER_FLAG_KEY_FRAME, bytes.size, 0, null)
assertEquals(1, sink.dialogues.size)
assertEquals(1_500L, sink.dialogues.first().durationMs)
assertEquals("1,0,Style,,0,0,0,,Line", sink.dialogues.first().line.toString(Charsets.UTF_8))
assertArrayEquals(bytes, delegate.samples.first())
}
}
+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")
}
+14
View File
@@ -0,0 +1,14 @@
# 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(...);
public static void onEvent(int);
public static void onEndFile(int);
public static void onLogMessage(java.lang.String, int, java.lang.String);
}
@@ -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 )
+99
View File
@@ -0,0 +1,99 @@
#include <jni.h>
#include <mpv/client.h>
#include "globals.h"
#include "jni_utils.h"
#include "log.h"
static void sendPropertyUpdateToJava(JNIEnv* env, mpv_event_property* prop) {
jstring jprop = new_java_string(env, prop->name);
jstring jvalue = NULL;
switch (prop->format) {
case MPV_FORMAT_NONE:
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_S, jprop);
break;
case MPV_FORMAT_FLAG:
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sb, jprop, *(int*)prop->data);
break;
case MPV_FORMAT_INT64:
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sl, jprop, *(int64_t*)prop->data);
break;
case MPV_FORMAT_DOUBLE:
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_Sd, jprop, *(double*)prop->data);
break;
case MPV_FORMAT_STRING:
jvalue = new_java_string(env, *(const char**)prop->data);
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onPropertyChanged_SS, jprop, jvalue);
break;
default:
break;
}
if (jprop) env->DeleteLocalRef(jprop);
if (jvalue) env->DeleteLocalRef(jvalue);
}
static void sendEventToJava(JNIEnv* env, int event) {
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onEvent, event);
}
static void sendEndFileToJava(JNIEnv* env, mpv_event* event) {
mpv_event_end_file* end_file = (mpv_event_end_file*)event->data;
int reason = end_file ? end_file->reason : -1;
env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onEndFile, (jint)reason);
}
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);
}
void* event_thread(void* arg) {
JNIEnv* env = NULL;
acquire_jni_env(g_vm, &env);
if (!env) die("failed to acquire java env");
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);
break;
case MPV_EVENT_END_FILE:
sendEndFileToJava(env, mp_event);
break;
case MPV_EVENT_START_FILE:
case MPV_EVENT_FILE_LOADED:
case MPV_EVENT_PLAYBACK_RESTART:
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
sendEventToJava(env, mp_event->event_id);
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
+69
View File
@@ -0,0 +1,69 @@
#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_S =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;)V");
mpv_MpvPlayer_onPropertyChanged_Sb =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Z)V");
mpv_MpvPlayer_onPropertyChanged_Sl =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;J)V");
mpv_MpvPlayer_onPropertyChanged_Sd =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;D)V");
mpv_MpvPlayer_onPropertyChanged_SS =
env->GetStaticMethodID(mpv_MpvPlayer, "onPropertyChanged", "(Ljava/lang/String;Ljava/lang/String;)V");
mpv_MpvPlayer_onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(I)V");
mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(I)V");
mpv_MpvPlayer_onLogMessage =
env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(Ljava/lang/String;ILjava/lang/String;)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_S, mpv_MpvPlayer_onPropertyChanged_Sb,
mpv_MpvPlayer_onPropertyChanged_Sl, mpv_MpvPlayer_onPropertyChanged_Sd, mpv_MpvPlayer_onPropertyChanged_SS,
mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage;
+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)
+157
View File
@@ -0,0 +1,157 @@
#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);
static void* destroy_mpv_thread(void* arg) {
mpv_handle* handle = (mpv_handle*)arg;
mpv_terminate_destroy(handle);
return NULL;
}
// Fire-and-forget mpv_terminate_destroy on a detached thread.
// Safe because the event thread has been joined and g_mpv cleared —
// no other code references this handle.
static void async_destroy(mpv_handle* handle) {
pthread_t tid;
if (pthread_create(&tid, NULL, destroy_mpv_thread, handle) == 0) {
pthread_detach(tid);
} else {
// Fallback: destroy synchronously if thread creation fails
mpv_terminate_destroy(handle);
}
}
extern "C" {
jni_func(void, nativeCreate, jobject appctx);
jni_func(void, nativeInit);
jni_func(void, nativeDestroy);
jni_func(void, nativeCommand, jobjectArray jarray);
};
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;
render_cleanup(env);
}
g_mpv = mpv_create();
if (!g_mpv) {
die("context init failed");
if (leaked_mpv) mpv_terminate_destroy(leaked_mpv);
return;
}
mpv_request_log_messages(g_mpv, "v");
// Async teardown of leaked handle — doesn't block caller
if (leaked_mpv) async_destroy(leaked_mpv);
}
jni_func(void, nativeInit) {
if (!g_mpv) {
die("mpv is not created");
return;
}
if (mpv_initialize(g_mpv) < 0) {
die("mpv init failed");
return;
}
g_event_thread_request_exit = false;
if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) {
die("thread create failed");
return;
}
pthread_setname_np(event_thread_id, "event_thread");
}
jni_func(void, nativeDestroy) {
std::lock_guard<std::mutex> lock(g_lifecycle_mutex);
if (!g_mpv) {
ALOGV("mpv destroy called but it's already destroyed");
return;
}
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;
render_cleanup(env);
// Async teardown — nativeDestroy returns immediately
async_destroy(local_mpv);
}
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);
}
+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,18 @@
package com.edde746.plezy.libmpv
sealed interface MpvEvent {
data object StartFile : MpvEvent
data class EndFile(val reason: EndFileReason?) : MpvEvent
data object FileLoaded : MpvEvent
data object PlaybackRestart : MpvEvent
companion object {
// Mirrors the ids event.cpp forwards; END_FILE arrives via its own JNI path.
internal fun fromId(id: Int): MpvEvent? = when (id) {
6 -> StartFile
8 -> FileLoaded
21 -> PlaybackRestart
else -> null
}
}
}
@@ -0,0 +1,3 @@
package com.edde746.plezy.libmpv
class MpvException(message: String) : RuntimeException(message)
@@ -0,0 +1,309 @@
package com.edde746.plezy.libmpv
import android.content.Context
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
class MpvPlayer private constructor() : AutoCloseable {
companion object {
init {
System.loadLibrary("mpv")
System.loadLibrary("player")
}
private val instance = AtomicReference<MpvPlayer?>(null)
suspend fun create(
context: Context,
configure: MpvPlayerConfig.() -> Unit = {}
): MpvPlayer = withContext(Dispatchers.IO) {
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) {
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.None(name))
}
@JvmStatic
fun onPropertyChanged(name: String, value: Boolean) {
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Flag(name, value))
}
@JvmStatic
fun onPropertyChanged(name: String, value: Long) {
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Int64(name, value))
}
@JvmStatic
fun onPropertyChanged(name: String, value: Double) {
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Double(name, value))
}
@JvmStatic
fun onPropertyChanged(name: String, value: String) {
instance.get()?.rawPropertyChanges?.trySend(PropertyChange.Str(name, value))
}
@JvmStatic
fun onEvent(eventId: Int) {
val event = MpvEvent.fromId(eventId) ?: return
instance.get()?.rawEvents?.trySend(event)
}
@JvmStatic
fun onEndFile(reason: Int) {
instance.get()?.rawEvents?.trySend(
MpvEvent.EndFile(EndFileReason.fromId(reason))
)
}
@JvmStatic
fun onLogMessage(prefix: String, level: Int, text: String) {
val logLevel = LogLevel.fromNative(level) ?: return
instance.get()?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd()))
}
// 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 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 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)
init {
pumpScope.launch { for (e in rawEvents) events.emit(e) }
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
override fun close() {
if (closed) return
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()
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,11 @@
package com.edde746.plezy.libmpv
sealed interface PropertyChange {
val name: String
data class None(override val name: String) : PropertyChange
data class Flag(override val name: String, val value: Boolean) : PropertyChange
data class Int64(override val name: String, val value: Long) : PropertyChange
data class Double(override val name: String, val value: kotlin.Double) : PropertyChange
data class Str(override val name: String, val value: String) : 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")
+1 -1
View File
@@ -19,7 +19,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/universal_gamepad/ios"
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
os_media_controls: 048eb9a75974191b2496d85575b6edcefc572d4e
universal_gamepad: 7c0cc0c2e3909dfb803d325387e42963b3ed842c
+9 -5
View File
@@ -25,6 +25,7 @@
B1D51A6A2F0011000000000C /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */; };
B1D51A6A2F0011000000000E /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */; };
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
@@ -80,6 +81,7 @@
B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.swift; sourceTree = "<group>"; };
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDecodeCapabilitiesPlugin.swift; sourceTree = "<group>"; };
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
DE48FF2E074644167608B23D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
F3553CDE70191F0FC81A782E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
@@ -197,6 +199,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
@@ -280,7 +283,7 @@
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */,
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "mpv-build" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@@ -430,6 +433,7 @@
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -793,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 = 0097fea015fe9b59bcfed866e61acb5896454bd4;
revision = dafa7762af20052031a7b512c9761a5d8bde327d;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -806,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" : "0097fea015fe9b59bcfed866e61acb5896454bd4"
"revision" : "dafa7762af20052031a7b512c9761a5d8bde327d"
}
},
{
@@ -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" : "0097fea015fe9b59bcfed866e61acb5896454bd4"
"revision" : "dafa7762af20052031a7b512c9761a5d8bde327d"
}
},
{

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