Investigation into unifying the playback backends (Linux/MPV, Android/ExoPlayer, Windows/webview) onto one engine with hardware acceleration. Conclusion: video cannot be unified onto a native engine; audio can. The blocker is not mpv-specific. WebKitGTK, WebView2 and Android WebView each draw into their own compositor surface, so a native video surface sits either entirely above or entirely below the webview and cannot interleave with HTML. GStreamer and libVLC fail identically. mpv would additionally regress streaming: it has no adaptive bitrate, while the current hls.js path does. Six specs added: - playback-backend-unification: the analysis and decision, with evidence - android-audio-settings-parity: set_audio_settings on ExoPlayerBackend - android-native-video-spike: timeboxed test of SurfaceView compositing - windows-native-audio-backend: replace the webview <audio> shim with libmpv - libmpv2-migration: dead libmpv git pin -> libmpv2, plus a LICENSE file - playback-docs-corrections: the requirement-status fixes applied here Corrections to requirements.md, all verified against source: - UR-031/DR-034 claimed crossfade was "Done (Linux only)". It is implemented nowhere (mpv_backend.rs has a bare TODO) and is architecturally blocked on mpv, whose single-stream audio chain cannot feed acrossfade's two inputs. - Parity matrix listed crossfade as a Linux/Android gap; it is neither. - The matrix omitted the equalizer, which has the same Linux-only shape. - The suggested ConcatenatingMediaSource is deprecated in current Media3. nativeAdapter.ts cited tauri#10152 as an upstream blocker for native Android video. That issue is a stale feature request, dead since 2024-07-01; the capability shipped in tauri 27d01834 (2024-09-02), and the related black-screen bug was fixed in wry 0.39.4 (we ship 0.55.x). What is genuinely unproven is SurfaceView-behind-WebView compositing, which the spike now tracks.
9.6 KiB
Spec: Android native video — transparent-webview spike
Status: Proposed (spike — timeboxed, may conclude "not viable")
Requirements: IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
UX spec: n/a — no intended visual change; the video surface must land exactly where the <video> element is today
Supersedes / revises: acts on finding 2 of playback-backend-unification.md
Summary
Test whether ExoPlayer's existing SurfaceView video path can be composited
behind a transparent Tauri WebView on Android. If it works, Android regains
hardware video decoding (MediaCodec) and libass-quality ASS/SSA subtitles, both
of which the current webview path lacks. If it does not, we document why and
delete the dead code.
This is a spike, not a feature commitment. The deliverable is a yes/no answer with evidence, plus either a working path behind a flag or a removal.
Motivation
createAdapter() hardcodes const effectiveKind = "html5" and does
void backendKind, discarding the use_html5_element value Rust computes in
get_player_status. As a result:
NativePlayerAdapteris dead code.JellyTauPlayer.kt'sgetOrCreateSurfaceView()— which already callssetZOrderMediaOverlay(false)and wiressetVideoSurfaceHolder— is unreachable.- Android video decodes in the WebView instead of via MediaCodec, despite
CodecDetector.ktgoing to the trouble of reporting hardware codec capabilities back to Rust for DeviceProfile generation.
The code comment in nativeAdapter.ts:11-14 justifies this by citing
tauri#10152 as an upstream blocker. That justification is stale.
Why the blocker no longer holds
- tauri#10152 is open but dead since 2024-07-01, and it is a feature request ("Support transparent webviews on mobile"), not a bug report about compositing.
- The capability shipped in tauri commit
27d01834(2024-09-02) — a clippy cleanup that movedtransparent()out of the desktop-gated impl block, fencing only the tao call behind#[cfg(desktop)]. Because it landed as unrelated cleanup, nobody closed the issue. - The black/white-screen reports (tauri#8381, tauri#9408) were a real but
different bug: a broken JNI signature for
setBackgroundColor, fixed in wry 0.39.4 (PR #1237). We ship wry 0.55.x. - Current wry calls
setBackgroundColor(0)unconditionally on Android when transparency is requested.
The honest caveat
Nobody has demonstrated SurfaceView-behind-WebView on Tauri Android. A search
of both tauri-apps/tauri and tauri-apps/wry issues for surfaceview returns
zero results, and the one native-video Tauri plugin
(YeonV/tauri-plugin-videoplayer) sidesteps compositing by launching a separate
fullscreen Activity. Nothing upstream blocks this; nothing upstream proves it.
Hence: spike, not feature.
Note this is the Android question only. The equivalent Linux compositing problem is maintainer-declared unfixable and is not in scope — see the unification spec.
Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| Which video backend this platform uses | Rust (existing) | get_player_status already computes use_html5_element. The frontend must consume it, not decide it. Restoring that is the point of the spike. |
Surface creation, z-ordering, setVideoSurfaceHolder lifecycle |
Kotlin | Android platform mechanics; already written in JellyTauPlayer.kt. |
| Seek/audio-track strategy | Rust (existing) | Already returned by player_seek_video / player_switch_audio_track; NativePlayerAdapter executes the chosen primitive. Unchanged — this is exactly what the PlayerAdapter contract was built for. |
| Positioning the surface under the video viewport | Frontend | Pure presentation/layout. This is the risk area — see Design. |
Design
Phase 1 — prove compositing (no app changes)
Before touching the adapter factory, verify the primitive works at all:
- Set
"transparent": trueintauri.conf.jsonfor the Android build, plushtml, body { background: transparent; }. - Confirm the WebView is genuinely transparent (a native view behind it is visible) and that the app does not regress to a black/white screen.
If this fails, stop — everything downstream is moot, and the finding is that Tauri Android transparency is still broken in practice despite the shipped fix.
Phase 2 — un-hardcode the factory
// src/lib/player/adapters/index.ts
export function createAdapter({ backendKind, host, bridge }: CreateAdapterArgs): PlayerAdapter {
return backendKind === "native"
? new NativePlayerAdapter(host)
: new Html5PlayerAdapter(host, bridge);
}
backendKind comes from get_player_status (VideoBackend::Native on Android).
Gate behind a setting — experimentalNativeVideo, default off — so a broken
spike cannot ship as a regression. Rust already owns this decision; the flag only
suppresses it.
Also in scope: remove the user-agent sniffing in
src/lib/services/webviewAudio.ts:30-41. It re-derives which audio backend the
platform has from navigator.userAgent ("matching the Rust cfg gate", per its own
comment) — the frontend deciding a backend fact it should be told. Same root cause
as the hardcode above, same fix: consume the value Rust already computes. Fold it
in here rather than leaving a second, subtler copy of the bug behind. If
get_player_status does not currently expose enough to cover the audio case, add
the field — that is backend work, and correct.
Phase 3 — surface positioning
The hard part, and where this most likely fails. The webview's <video> element
occupies a laid-out box; the SurfaceView must be positioned to match it, and
kept matched through scroll, rotation, and mini-player transitions.
Approach: the video view reports its getBoundingClientRect() to Rust, which
forwards the rect to Kotlin to position the SurfaceView. This is the same
"faking it" technique the ecosystem uses on desktop — acceptable here only if
the video is effectively fullscreen on Android, which it is in the player route.
Explicit failure criterion: if the surface cannot be kept aligned during rotation or the mini-player transition without visible artefacts, the spike fails and we keep HTML5. Do not ship a janky native path for a codec win.
What we gain if it works
- Hardware decode via MediaCodec —
CodecDetector.ktalready reports capabilities; the DeviceProfile would finally match what actually plays. - ASS/SSA subtitles are not automatic. ExoPlayer cannot render them; that would require libmpv, which is a separate and much larger decision (see the unification spec's engine comparison). Scope this spike to hardware decode only, and do not claim subtitle improvements from it.
Out of scope
- Linux native video. Maintainer-declared unfixable on WebKitGTK/Wayland.
- Replacing ExoPlayer with libmpv on Android.
- Windows native video.
- Removing the HTML5 path. It stays as the default and the fallback.
Acceptance criteria
The spike is complete when one of these is true:
Success path
- Transparent WebView confirmed working on a physical device.
experimentalNativeVideooff → behaviour byte-identical to today.webviewAudio.tsno longer inspectsnavigator.userAgent; the platform's audio backend is read from Rust.experimentalNativeVideoon → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existingPlayerAdaptercontract.- No artefacts on rotation, background/foreground, or mini-player transition.
adb shell dumpsys media.metrics(or logcat) confirms a hardware decoder is in use.- Measured battery/thermal or CPU improvement over the HTML5 path on the same clip.
Failure path
- The blocking behaviour is documented in this spec with evidence.
NativePlayerAdapterand the unreachableSurfaceViewcode are deleted, or explicitly retained with a correct comment.nativeAdapter.ts:11-14no longer cites tauri#10152.
Either way:
bun run check,bun run test,bun run check:boundarypass.cargo fmt/cargo clippyclean;bun run test:rustpasses.
Testing
Adapter-selection logic is pure and testable without a device: assert
createAdapter returns NativePlayerAdapter for backendKind: "native" with
the flag on, and Html5PlayerAdapter in every other combination — including that
the flag off forces HTML5 even when Rust says native. That last case is the
regression guard.
Everything else is manual on-device; there is no meaningful way to unit-test surface compositing. Test on at least two devices — compositing behaviour varies by OEM and Android version.
Per CLAUDE.md, if the spike turns into a bug fix (e.g. seek breaks under the native adapter), write the failing test first.
TRACES
createAdapter→// TRACES: UR-003, UR-004 | DR-023, DR-024- Adapter-selection tests →
UT-xxx - No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
Notes for the implementer
- Do not skip Phase 1. If transparency does not work, phases 2 and 3 are wasted effort.
VideoPlayer.sveltehas a documented hazard: no lifecycle calls after anawaitinonMount— it flips to HTML5 mode and breaks Android seek. The adapter swap touches exactly this code path.- tauri-specta tagged responses keep Rust field names (
new_url, notnewUrl). - Android source edits go in
src-tauri/android/src, then runscripts/sync-android-sources.sh. - A parallel Claude session may be active —
git difffirst.