Commit Graph
404 Commits
Author SHA1 Message Date
edde746 99f4fda55a fix(android): retire mpv sessions without blocking hook callbacks 2026-09-06 19:11:25 +02:00
edde746 908ccbb233 fix(android): retain mpv callbacks and end dismissed TV input sessions
Minified MPV initialization aborted because callback keep rules retained obsolete JNI signatures. Android TV IME dismissal also left editing active, requiring an extra Back press to leave the screen.

Keep only the named JNI callbacks without duplicating argument signatures and exercise them through the permanent minified reachability gate. Centralize native editing-session termination, observe owning-view keyboard dismissal, and reject stale completion or reopening work.

Verified the minified gate on Galaxy Z Fold3, debug MPV lifecycle and log-level suites on SHIELD and Pixel 7, native Done/Previous and two-Back behavior on SHIELD and Fire TV, 76 focused Flutter tests, analyzer parity, and shrinker checks. Both new regressions reject the original implementations.
2026-09-06 17:53:30 +02:00
edde746 54438c8d7a test(android): drive fallback opens through an explicit native-command seam
Three ExoPlayerPluginTest fallback cases failed after commandForSource started rejecting a load with no native player: the test core seamed property writes but not commands, so every fallback loadfile reported OPEN_FAILED. The core now takes an optional command runner beside the property writer; with none, a command without a native player still fails. The tests assert what mpv was handed — the loadfile URI as open ownership and its pause option as playback intent — instead of reflecting private flags, and one sets up its held state through setPauseIntentForLoad. ExoPlayer production behavior is unchanged.
2026-09-06 14:53:05 +02:00
edde746 1d5b175cc8 fix(android): bind every libmpv JNI call and callback to its native session
Tearing down the mpv player while a hook handler or property write was still in flight could reach the replacement player: the native side kept one process-global handle, the Kotlin wrapper was published before the native session existed, callbacks were routed to whichever wrapper was current, and hook continuations were validated only against a `closed` flag read before the native call. A retiring player's on_preloaded handler could therefore reconfigure the successor's tracks, its late `mpv_hook_continue` could land on the successor's core, and the successor could ingest the predecessor's end-file and property tail.

Every native session now has an immutable monotonic id minted by nativeCreate. Create/init/destroy hold a write lock; every other JNI entry takes a read lock and is refused when it names a retired session, so a call admitted under a live session keeps that handle alive until it returns and retirement waits for it. The event thread is bound to its session before it starts and stamps every callback with it; Kotlin dispatches a callback only to the wrapper published for that session and passes its session on every native call. create() publishes the wrapper only after the native session exists, and a superseded create fails instead of publishing a dead session.
2026-09-06 14:50:51 +02:00
edde746 baa31742cb fix(player): bind live TV clock generations to the source the load reports
Live-TV clock generations were matched to mpv sources first-in-first-out: every
start-file popped the oldest registered open, on the assumption of exactly one
start-file per open in dispatch order. Opens without a generation on the same
player, an Android loadfile rejected silently by nativeCommand, and the
independent delivery of the command ack and the start-file event all broke
that, so a seek that reopened the stream could calibrate against the wrong
source.

The loadfile reply now carries mpv's playlist_entry_id on Android, Apple, Linux
and Windows, PlayerNative.open resolves with it, and the live session binds
each generation to that id explicitly. Source events that land before the
reply are buffered per id and replayed on binding; a rejected or unreachable
load fails its generation instead of leaving a phantom; opens with no
generation are invisible to clock binding. Android now reports a rejected mpv
command as COMMAND_FAILED like the other cores.
2026-09-06 13:29:37 +02:00
edde746 ac7fb87ade fix(android): route decode policy by the video track mpv will select
The Dolby Vision and Hi10 decode policies run in mpv's on_preloaded hook,
before track selection, and read the first demuxed video track. A file whose
first video track is not the one mpv picks (attached pictures, a
default-flagged second track, --vlang, an explicit vid) got the wrong
routing.

The hook now resolves one pending track for both policies: an explicit
vid=no or vid=N is authoritative on its own, and auto asks the fork's
pending-vid property, which runs mpv's own default selection ahead of time.
On a libmpv without that property the first-track guess stays, logged once.
The predecoder gate is unchanged (#2065). The fork patch lands separately
through the native pin flow.
2026-09-06 13:22:20 +02:00
edde746 98c6a09b41 chore(player): remove temporary native playback diagnostics
Production playback still carried recurring Windows HDR probes, Android subtitle profiling, and verbose mpv logs with debug logging disabled.

Remove the Windows probe, pin the published mpv-build profiler cleanup, and honor Android debug logging preferences for video and music while preserving warnings and errors.
2026-09-06 02:30:08 +02:00
edde746 77422f2e6e style(android): format GL capability and surface helpers
Native formatting rejects the release baseline's GL capability and surface helpers. Apply the repository ktlint formatting without changing behavior.
2026-09-06 01:37:54 +02:00
edde746 6147736bba build(android): compile the MPV lifecycle device test only for the debug instrumentation variant
The minified instrumentation variant, which the R8 reachability gate builds, no longer compiled: MpvLifecycleDeviceTest references MpvLifecycleTestActivity, which exists only in the debug source set. The test moves to androidTestDebug so it is compiled only when instrumentation targets debug.
2026-09-05 23:14:11 +02:00
edde746 36e898067a fix(subtitles): honor Render Resolution on the Android mpv OSD plane
The subtitle "Render Resolution" setting was shown to every Android user
but only reached the ExoPlayer overlay; the mpv vo=mediacodec OSD plane
always rasterized at the full surface size. On a Fire TV Stick 4K Max
Gen 2 the edde746/plezy#2242 phone sign then cost up to 2 s of libass
render plus composite per frame at 1080p, showing late and lingering past
the cut.

Pass the fraction to the mpv core on initialize and give the OSD
SurfaceView a fixed buffer size below its view size; mpv rasterizes at
that size and the compositor scales the plane. At 1/2 the same sign
tracks its zoom at 12-24 updates/s and clears on the cut.
2026-09-05 23:12:28 +02:00
edde746 a629a3407a fix(player): route Hi10 to software up front and drop to bilinear on GPUs without norm16
H.264 High 10 on Android TV boxes without a 10-bit hardware decoder
(Amlogic S905X4 class: onn 4K Pro, Homatics Box R) started black for over
ten seconds and then played choppy: mpv tried MediaCodec first, hit
"Could not initialize video chain", and only then fell back to software
decode on the GL vo, where the Mali-G31 driver has no
GL_EXT_texture_norm16 and pays an integer-texture conversion pass plus
lanczos scaling it cannot afford at 1080p.

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

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

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

close #2065
2026-09-05 14:08:05 +02:00
edde746 af9fb3f17c fix(live-tv): calibrate replacement stream clocks 2026-09-04 16:49:55 +02:00
edde746 781fb66421 fix(player): keep blocking MPV lifecycle work off main thread
A disposal race could call blocking native destruction after initialization resumed on Android's main thread. Finish that teardown on IO, enforce the lifecycle threading invariant, and bound exit responsiveness and teardown latency in the device regression test.
2026-09-04 14:43:54 +02:00
edde746 1b45588d3b fix(player): keep MediaCodec surface alive through MPV teardown
Setting vo=null during disposal could reinitialize MediaCodec and deadlock vendor decoders while the surface was already being released. Terminate libmpv synchronously with its surface references intact, then release the Android surfaces, and cover repeated hardware-decoded teardown on devices.
2026-09-04 14:43:54 +02:00
1a3dc3d3c9 fix(android): gate the DTS-HD IEC carrier on advertised DTS-HD support (#2231)
The ExoPlayer carrier gate only asked whether the route could open a
192kHz/7.1 IEC 61937 track. TrueHD rides that same tuple, so a sink that
decodes Dolby but no DTS-HD passed the probe and was handed a DTS-HD
burst it discards: the AudioTrack initialises and drains, Android reports
no error, and neither the audio-recovery ladder nor the mpv fallback
fires, so the file plays with no audio at all.

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

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

Co-authored-by: claude-opus-5 (high effort, 1M context) <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 12:48:34 +02:00
edde746 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
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 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
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
edde746 84bc4b2618 fix(i18n): translate the strings that still reached the UI in English
Non-English users saw English text in a dozen places and blank labels in
sixteen more.

The English came from sites that produce their copy away from the widget
that renders it, which is what the structural hardcoded-string check
cannot see: picture-in-picture refused with a raw literal instead of the
pipErrors.notSupported key that already existed; the two Jellyfin/Emby
auth throws missing display: rendered their developer message on the
add-server form; ServerParsingException.toString() fed its English into
the localized "Failed to load servers" wrapper; Watch Together
interpolated the whole PeerError, so a failed create read "Failed to
create session: PeerError(PeerErrorType.timeout): Timed out creating
session" and join printed its prefix twice; the hub and playlist
continuation footers rendered exception.toString(); shader rows showed an
English title over an already translated subtitle; the player queue fell
back to the raw Dart enum name; a Plex home user with no title showed
"Unknown"; a failed player start showed "Exception: Failed to initialize
player"; and the tvOS top-shelf header was hardcoded in an extension that
has no Flutter engine.

The blanks came from three recent features that added English keys
without translations. clean_translations.py filled all 21 siblings with
empty strings, so the Android TV resolution switch, every Jellyfin/Emby
recording-rule field, the demuxer row, the Companion Remote address
caption and the Seerr blocklist pill rendered nothing at all.

Two fixes are structural rather than key swaps. ContinuationStatusSliver
now takes an errorContext and calls a new non-logging
localizedLoadErrorText, so no future throw can leak through it. lib/mpv
stays free of user-facing copy: it raises a PlayerInitializationException
sentinel and a PlayerError.playerInitFailed cause tag that the player
screen resolves to localized text. The tvOS section title travels in the
shelf payload, additively, so an older cache still renders.
2026-08-24 05:23:05 +02:00
edde746 6da1f83891 feat(player): switch the display to the video's native resolution on Android TV
Sub-4K content on a 4K TV was always upscaled by the playback device, so the TV's own (usually better) upscaler never received the native signal and could not apply resolution-specific processing.

Adds a Match Content Resolution setting on Android TV: playback picks the smallest display mode that still contains the video — never downscaling it — and rate-matches within that resolution when frame rate matching is also enabled. A resolution-only switch keeps the refresh rate as close to the current one as possible, and the settle watchdog now verifies the requested mode id so such switches still trigger the Android MPV decoder refresh. Transcodes get their target from decoded dimensions on the post-first-frame path. Mode selection is extracted into a pure, JVM-tested DisplayModeSelector.

close #2073
2026-08-24 02:10:35 +02:00
edde746 b4b36c7e18 style(native): clang-format the mpv executor test, display-mode manager, and demuxer policy 2026-08-23 10:30:43 +02:00
edde746 3f55cc461e fix(player): show audio codec and bitrate in the performance overlay on ExoPlayer
The Android performance overlay showed only sample rate and channels for
EAC3, FLAC, DTS, TrueHD, and Opus tracks. The overlay reads the codec
from media3's Format.codecs, an RFC 6381 string only MP4/HLS provide
(hence AAC working), and the bitrate from Format.bitrate, which Matroska
carries only when the muxer wrote BPS statistics tags.

Fall back to the already-transmitted sample MIME type for the codec name
(audio and video), and measure the audio bitrate in the FFmpeg demuxer
from packet sizes over their pts span — the same source mpv uses for its
audio-bitrate property — when the container declares none.

close #2063
2026-08-23 03:44:45 +02:00
edde746 c201bc2f03 feat(player): default every container to the ffmpeg demuxer
The Auto demuxer mode routed only media3's weak container families
(AVI, ASF/WMV, MPEG-PS, Matroska/WebM) through FFmpeg and kept MP4/TS
on media3's extractors, with a second any-container FFmpeg instance
behind media3's list. With the goal of fully switching to the ffmpeg
demuxer, the split served no purpose and the catch-all could never add
coverage once the primary accepts everything.

Collapse the preference to two modes: FFmpeg (default), which demuxes
every progressive container ahead of media3's list, and media3 only as
the user-facing escape hatch in case a file misbehaves. media3's
extractors stay behind FFmpeg, so anything FFmpeg cannot sniff still
reaches them. The catch-all role and the Auto container list are
removed; unknown persisted wire values (including the retired "auto")
resolve to FFmpeg.

Verified on a Pixel 7: MP4, TS, and MKV all demux through FFmpeg under
the default ("sniff accepted mov,mp4 / mpegts / matroska,webm") with
forward and backward seeks landing; JVM suites and analyzer pass.
2026-08-23 01:42:36 +02:00
edde746 75c095a5b2 feat(player): demux direct-played containers with ffmpeg on Android
Direct play on Android kept accumulating container patches: AVI with
XviD packed-bitstream timestamps and missing VOL csd played broken,
ASF/WMV and MPEG-PS/VOB had no media3 extractor at all, and MKV needed
a custom extractor stack for zlib-compressed subtitles, LOAS/LATM
audio, cueless seeking, and font attachments.

Demux progressive containers with libavformat behind media3's
extractor API. An AVIO bridge serves libavformat from the
ExtractorInput: every position divergence defers into a RESULT_SEEK
round trip, header reads replay from a block cache while
avformat_open_input restarts, and avformat_seek_file executes seeks
with bounded loader round trips. Packets feed media3's TrackOutputs,
so decoders, passthrough carriers, Dolby Vision RPU/EL conversion
(dvh1 codecs string surfaced from DOVI side data), and the subtitle
pipeline are untouched: embedded fonts feed AssHandler over JNI, ASS
reaches libass as per-sample dialogue, SRT/VTT render as cues, VobSub
maps through media3's VobsubParser, and LOAS/LATM AAC unwraps via
LatmTrackOutput.

Under the default Auto preference FFmpeg demuxes AVI, ASF/WMV,
MPEG-PS, and Matroska/WebM and sits behind media3's list as an
any-container fallback; MP4/TS keep media3's extractors. A playback
setting exposes auto / FFmpeg first / media3 only, and the JNI
load-failure path falls back to stock media3. The custom Matroska
extractor stack and its reflection keeps are retired.

Verified on Pixel 7 (API 36), Box R 4K Plus TV (API 34), and SHIELD
TV (API 30): instrumentation playback suites, the minified R8
reachability gate, and an on-device container matrix with forward and
backward seek landings plus visual subtitle checks.

close #2052
2026-08-23 01:22:24 +02:00
edde746 da21481ff3 fix(android): keep the reflected media3 MatroskaExtractor fields under R8
AssMatroskaExtractor and MatroskaLatmSupport reflect MatroskaExtractor's private extractorOutput/subtitleSample fields by name, but no keep rule covered them: R8 renames the fields and every MKV direct-play in a release build fails constructing the extractor. Adds the keepclassmembernames rule (descriptors verified against media3 1.11.0 in the Gradle cache) and extends check_shrinker_rules.py to scan Kotlin/Java reflection across all android src/main roots including libass.
2026-08-21 19:23:40 +02:00
edde746 fc96ddc1ab style(android): fold initialVideoOutput body onto its signature per ktlint 2026-08-19 23:06:08 +02:00
edde746 810ef002ad fix(player): show source track audio in stats during passthrough
When mpv bitstreams (audio-spdif), audio-params describe the IEC 61937
carrier - 192 kHz "stereo" for E-AC-3 - so the performance overlay told
users their 5.1 track was playing as 2ch stereo. Every recent "EAC3
downgraded to PCM" report in #1300 reads back this string while the
receiver route stays healthy.

Show the source track's channel layout and sample rate instead, plus an
explicit Passthrough row naming the bitstreamed codec, on both the
mpv-channel and Android mpv-fallback stats paths.
2026-08-19 20:14:48 +02:00
edde746 761303de11 fix(subtitles): render zlib-compressed embedded MKV subtitles on the ExoPlayer path
Embedded subtitles on mkvmerge-muxed files with compressed subtitle tracks
(the anime-release convention, reported against AV1 encodes) never rendered:
the track selected fine but nothing ever appeared on screen.

Two defects compounded. ContentCompAlgo 0 (zlib) is the Matroska default
value, so mkvmerge omits the element and the explicit-value detection never
engaged - media3 ignores an empty ContentCompression and silently emits
compressed samples. And for text subtitle tracks TrackOutput-level inflation
can never work: MatroskaExtractor prepends the plaintext timecode prefix to
the still-compressed payload and truncates the sample at the first NUL byte
before any TrackOutput runs, while AssTrackOutput feeds that same internal
buffer to libass, which then parses zero events and reports every frame as a
changed empty render.

Treat the presence of ContentCompression as zlib until an explicit
ContentCompAlgo says otherwise, and for SRT/ASS/SSA/VTT tracks inflate the
block frame payload before the parent's subtitle sample assembly, leaving the
TrackOutput wrapper to the formats it can actually handle. Blocks that are
laced, corrupt, or over-bound pass through byte-identical. The detection fix
also makes the existing whole-sample TrackOutput inflation engage for
PGS/VobSub zlib tracks, which media3 does not truncate.

Verified with mkvmerge zlib/plain fixture extraction tests and end-to-end on
an API 36 emulator against a Jellyfin direct-play mux mirroring the reported
file.

close #2023
close #2022
2026-08-19 12:34:40 +02:00
edde746 05ef93c55b fix(player): keep gpu-next off the Android hardware-decode path
HEVC files played through the mpv backend on the Nvidia Shield show a
solid blue screen with audio: under hwdec=mediacodec, gpu-next samples
the decoder output as a samplerExternalOES that libplacebo declares in
both shader stages, and the Tegra GLES linker rejects the pair ("struct
type mismatch between shaders for uniform"), failing every frame. The
in-chain gpu fallback never engages because gpu-next initializes fine,
and mpv/libplacebo master reproduce it unchanged.

DV reshaping — the reason gpu-next exists on Android — only happens
under software decode, so offer gpu-next exactly there and keep the
legacy gpu VO for hardware sessions on both the primary mpv backend and
the ExoPlayer fallback core.

close #2010
2026-08-18 23:21:19 +02:00