Skip to content

android: swap exoplayer for libmpv#6

Merged
1337raspberry merged 5 commits into
mainfrom
android-refactor
May 14, 2026
Merged

android: swap exoplayer for libmpv#6
1337raspberry merged 5 commits into
mainfrom
android-refactor

Conversation

@1337raspberry
Copy link
Copy Markdown
Owner

android now runs libmpv via dev.jdtech.mpv:libmpv:1.0.0 behind a media3 SimpleBasePlayer. single audio engine across desktop / ios / android.

commits

  • kotlin gradle plugin 1.9 → 2.2 (build prereq)
  • exoplayer → libmpv swap (new LibmpvSimplePlayer.kt, 10-band lavfi EQ, settings migration, proguard keep)
  • mpv options harmonised across all three platforms
  • android-only buffering-scanning indicator removed (paused-for-cache feeds media3 natively now)

minSdk bumps 24 → 26 (libmpv's floor). +12 MB per ABI in apk size, not a constraint.

test plan

  • direct-play flac
  • plex chunked-opus transcode (mid-track seek)
  • background / lock-screen / bluetooth controls
  • 10-band EQ sliders + presets
  • settings migration from 5-band legacy shape
  • cellular failover + stall watchdog

prereq for the libmpv swap landing in the next commit —
dev.jdtech.mpv:libmpv:1.0.0 ships kotlin 2.2 metadata and
the 1.9 compiler bails with "actual metadata version is
2.2.0, compiler can read up to 2.0.0".
single audio engine across desktop/ios/android. android now runs
libmpv 0.41 (+ ffmpeg 8.1, libass, libplacebo, mbedtls) packaged
in the dev.jdtech.mpv:libmpv:1.0.0 maven central aar — 47mb
universal, all 4 abis. minSdk bumps 24 → 26 (libmpv's floor,
android 8.0, ≥99% device coverage).

what this kills:
- pre-download-and-swap dance for plex chunked-opus transcodes
  (libmpv handles mid-stream seek via the demuxer buffer +
  lavf reconnect chain, no more 30s pre-buffer transcode files)
- system android.media.audiofx.Equalizer 5-band wrapper, plus
  the audioSessionId deferred-init race
- suppressNextIdleEvent latch for exoplayer's mid-flight stop
- the entire `mpvBufferingChange` / waveform scanning indicator
  (next commit) — libmpv's paused-for-cache feeds media3's
  STATE_BUFFERING natively
- DefaultLoadControl tuning, position-poller-on-a-runnable, the
  PlayerListener bridge dance

what this adds:
- LibmpvSimplePlayer: a media3 SimpleBasePlayer wrapping MPVLib.
  state mutations + invalidateState() hop to applicationLooper
  via mainHandler (mandatory — mpv events fire on the event-loop
  thread). property observers cover time-pos, duration, pause,
  playlist-pos, idle-active, paused-for-cache. media session,
  foreground service, notification provider, accent tinting all
  unchanged — only the Player implementation behind them swaps.
- 10-band lavfi EQ (50/100/200/400/800/1600/3200/6400/12800/
  16000 hz, ±12dB) identical to desktop/ios via `af=equalizer=...`.
  no more 5-band system fx surprise; bluetooth headphone dsp
  still chains on top of audiotrack output.
- real `mpvGetDemuxerCacheTime` — prefetch.rs::wait_for_live_drain
  finally gets a non-sentinel value on android, stops hitting the
  30s ceiling.
- proguard keep for dev.jdtech.mpv.** because MPVLib uses jni
  reflection to call eventProperty/event/logMessage from native.
  without the keep, release builds silently SIGABRT on first
  property observation.

settings migration: eq_bands.len() != 10 forces back to ten
zeros on load (existing android users had 5 from the system
fx). EQ resets to flat once, no crash, ui re-renders against
the new 10-band schema.
ios was diverging on two options: `prefetch-playlist=yes` and
missing `demuxer-readahead-secs`/`demuxer-max-bytes` overrides.
brought ios into line with desktop's `default_mpv_options()`,
which is also what the new android `LibmpvSimplePlayer` set in
the previous commit.

- `prefetch-playlist=no` everywhere. ramus's own reqwest prefetch
  worker rewrites playlist entries to file:// urls as they cache,
  so mpv's internal playlist prefetch would just race it. single
  prefetch strategy across all five platforms.
- `demuxer-readahead-secs=1200` + `demuxer-max-bytes=2GiB` so the
  whole file lands in mpv's demuxer cache and seek-within-buffered
  is instant. matters most on plex chunked transcodes because
  `accept-ranges: none` means seek-forward outside the cache
  triggers a full re-get.

remaining genuine ios divergence: `ao=audiounit` (the platform's
AO) and `audio-exclusive=yes` (the avaudiosession category
interlocks cleanly with the lock-screen widget — desktop handles
exclusivity through OS-level audio session apis, not mpv).
android matches desktop on everything except `ao=audiotrack,opensles`.
40d95ed added a waveform "scanning" sweep driven by an android-only
`mpvBufferingChange` event because exoplayer didn't expose anything
equivalent to mpv's `paused-for-cache`. with libmpv on android the
event is no longer special — paused-for-cache feeds media3's
STATE_BUFFERING natively, which is the same surface desktop and ios
have always used.

ripping the now-orphan plumbing in full so android matches the
other two platforms exactly:
- `MpvCallbacks.on_buffering_change` field + the lib.rs wiring
- `emit_buffering_state` / `BufferingStatePayload` in events.rs
- `BufferingPayload` parsing + listener registration in mpv_mobile.rs
- `buffering-state` event listener in ui/src/lib/usePlaybackEvents.ts
- `isBuffering` + `setBuffering` in playbackStore
- the sweep RAF loop, `bufferingFraction` state, and both buffering
  branches of WaveformSeekBar

net: one buffer model across five platforms (status === "buffering"
from playback-state). seek bar shows the same thing on every host.
three findings from /ultrareview on #6:

- (security, normal) `MediaItem.setMediaId(args.url)` was leaking
  the plex token across the MediaSession AIDL boundary. mediaId is
  one of the few fields media3 serialises to bound MediaController
  clients — any app with BIND_NOTIFICATION_LISTENER_SERVICE plus
  `adb dumpsys media_session` could pluck `X-Plex-Token` /
  `X-Plex-Headers` out of the session timeline. dropped both
  `setMediaId` calls; LibmpvSimplePlayer.getState falls back to
  `item.hashCode().toString()` for the playlist uid so the empty
  mediaId is harmless. previous (exoplayer) code used
  `MediaItem.fromUri` which left mediaId empty by default — the
  leak is a regression from this branch's switch to `Builder()`.

- (nit) `fileLoadedEmitted` latch was only reset on idx change,
  so a same-position queue replace (queue=[A] → queue=[B] with B
  at idx 0) suppressed `mpvFileLoaded` for the new track. now
  tracks `lastReportedUri` alongside `lastReportedIndex` and
  resets on either change — metadata-only refreshes (same URI,
  same idx) still pass through silently.

- (nit) `append-play` branch in `mpvLoadFile` didn't actually
  start playback from idle. media3's Player interface has no
  `append-play` equivalent: `addMediaItem` always maps to bare
  `loadfile append` (play=false in mpv), and `play()` on a
  SimpleBasePlayer in STATE_IDLE just flips `pause` without
  loading. now seeks to the appended index and plays — matches
  mpv's native append-play semantics. dead code in current rust
  callers but closes the contract gap.
@1337raspberry 1337raspberry merged commit 3b67d22 into main May 14, 2026
5 checks passed
@1337raspberry 1337raspberry deleted the android-refactor branch May 14, 2026 20:36
1337raspberry added a commit that referenced this pull request May 15, 2026
readme/contributing/security/plugin-readme and the release.yml
header comment still described the android audio stack as
media3/exoplayer. since #6 it's libmpv via the
dev.jdtech.mpv:libmpv aar wrapped behind a media3
SimpleBasePlayer, so the plumbing and the gotchas are different.

also bump the README's android requirement from "7.0 nougat (api
24)" to "8.0 oreo (api 26)" — libmpv-android's floor. note in the
download table that the apk now ships the libmpv + ffmpeg/codec
stack inline (similar to the macos .app bundle).

the contributing.md "single-thread contract" gotcha gets reworded
in the same shape: the rule (every player.* call hits the main
looper) is unchanged, the reason is now libmpv's event-loop
thread + SimpleBasePlayer.applicationLooper, not exoplayer's
internal threading model.

the security.md mediasession-metadata bullet got broader phrasing
to cover the AIDL-surface token leak class of bug (#6's
ultrareview fix) without naming a specific engine.
1337raspberry added a commit that referenced this pull request May 15, 2026
the libmpv section of licenses/NOTICE.md was scoped to desktop
("ramus dynamically loads libmpv at runtime to provide audio
playback") even though ios shipped MPVKit and android shipped
Media3 well before this. now that android has joined the libmpv
boat via the dev.jdtech.mpv:libmpv aar, broadened the section to
say "every platform" and split out the per-platform substitution
story (libpath search on desktop, MPVKit SPM on ios, gradle on
android).

added an explicit table for the supporting libraries bundled
inside the android aar — ffmpeg 8.1, libplacebo, libass,
fribidi, libunibreak, harfbuzz, freetype, fontconfig, mbedtls,
dav1d, libxml2, lua — with their versions, licenses, and
upstream urls. all permissive or LGPL-2.1+, same posture
licenses/LICENSE.LGPL-2.1 already covers. version list verified
against jarnedemeulemeester/libmpv-android v1.0.0's release
notes — that's the upstream of the aar we pin in #6.

THIRD_PARTY_LICENSES.md's preamble pointer to NOTICE.md got
matching language ("libmpv on every platform, plus the
supporting libraries shipped inside the android aar") and the
file is regenerated. version field in the regen also bumps
ramus-core/ramus-tauri from 1.0.0 → 1.0.1 (drift from 397df71)
— that was just stale.

THIRD_PARTY_LICENSES.md continues to be generated from Cargo.lock
+ pnpm-lock only; android gradle deps are documented manually in
NOTICE.md, which is the cheaper and cleaner spot for them given
the AAR ships a known fixed set and the generator can't see
gradle's graph deterministically.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant