fix(player): make Android native video actually visible, and usable

DR-172 reverted native video to opt-in after it shipped as audio with no
picture, naming the compositing as the suspect. The compositing was fine. Five
separate defects sat between ExoPlayer and the screen, each able to produce that
exact symptom on its own, and each invisible to the others.

DR-185 — the app shell painted over the surface. app.css clears the page's
opaque layers through three selectors, one of which targets `[data-app-shell]`,
an attribute NO component has ever set, in any commit. The shell paints
--color-background across the whole viewport and VideoPlayer stacks above it, so
the WebView composited opaque no matter what else was cleared. Invisible three
ways over: the CSS is valid, the selector is plausible, and a rule matching
nothing looks exactly like a rule matching something already transparent.

DR-182 — nothing could lift the poster card. Every markMediaReady() call site is
an HTML5 <video> event, and the native branch renders no element, so the black
title card covered the surface for the entire session. The first fix hooked
`player://position-update` / `player://state-changed`; those channels are never
emitted by the backend, so it passed a test that fired them by hand and did
nothing on a device. Driven from the player store now, as the seek bar already
was.

DR-183 — the JS bridges raced the page load. Installed 500ms after onCreate by
walking the view tree, while WebView binds injected objects at page-load time,
and the identity guard then declined to re-inject forever. setTransparent(true)
could never arrive. Installed from WryActivity.onWebViewCreate instead, which
wry calls immediately before the first loadUrl.

DR-184 — the SurfaceView was never detached. detachVideoSurface had no callers
anywhere, mirroring the DR-151 defect: every native video left its surface
parented to the content view and the next one stacked another beneath it.

DR-191 — the overlay stopped repainting. Incremental damage (the clock's text,
the control bar's opacity) never reached the screen while structural changes did,
so the progress bar froze, the controls would not fade, and the play overlay
appeared to work because it is added and removed from the DOM. Driven from the
Activity via postInvalidateOnAnimation while compositing is on.

Two UI defects only this path could reveal came with them: isPlaying froze at
its initial value, leaving the play overlay dimming and covering the video
(DR-186), and the control bar's auto-hide was armed solely by mousemove, which a
touchscreen never fires (DR-189). Immersive mode now applies on entering the
player rather than only via the fullscreen button (DR-187).

Verified on a device (Honor ROD2-W09, Android 16): logcat carries
`WebView transparent = true` and `Marking media ready` with video on screen —
the pair DR-172 went looking for and could not find — and skip, seek, rotation
and subtitle rendering were exercised by hand.

The default stays OFF (DR-188). Turning it on surfaced a further unverified
sub-path: returning from background audio is HTML5-only, so playback stays dead
(DR-190, proposed). Shipping it would have repeated DR-161 exactly — a verified
sub-path made default over an unverified one.
This commit is contained in:
2026-08-16 15:28:10 +02:00
parent f0f98feae8
commit 95129d04a3
18 changed files with 5628 additions and 4552 deletions
@@ -10,6 +10,13 @@ import android.webkit.WebView
import android.view.View
import androidx.activity.enableEdgeToEdge
/**
* How often to ask the WebView overlay to redraw while a native video surface is
* behind it. Roughly one display frame — `postInvalidateOnAnimation` coalesces
* to vsync, so this only governs how often we *ask*. See [MainActivity.overlayRepaint].
*/
private const val OVERLAY_REPAINT_MS = 16L
class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper())
private var configAttempts = 0
@@ -52,6 +59,96 @@ class MainActivity : TauriActivity() {
*/
private var bridgesInstalledOn: WebView? = null
/**
* wry hands us the WebView here, and this is the only point at which the
* bridges can be installed *deterministically*.
*
* WebView binds an injected object into JS at **page-load time**: an
* addJavascriptInterface call that lands after the page has loaded does not
* appear to that page at all. The bridges used to be installed from
* [configureWebViewForMedia], which finds the WebView by walking the view
* tree 500 ms after onCreate — a race against Tauri's own page load, and one
* that is *permanent* when lost, because the identity guard then declines to
* re-inject on the resume passes. The whole set (`AndroidVideoSurface`,
* `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`,
* `AndroidImmersive`, `AndroidInsets`) simply would not exist in `window`,
* silently: every one of them is called through an optional chain, so a
* missing bridge is a no-op rather than an error. That is a candidate
* explanation for DR-172's central piece of evidence — native video shipped
* with `WebView transparent = false` logged and `= true` never appearing,
* i.e. the enable call never reaching Kotlin.
*
* `WryActivity.setWebView()` calls this immediately before wry issues the
* first `loadUrl`, so a bridge installed here is bound by the time any page
* runs. Note this can fire during `super.onCreate()`, i.e. *before* the rest
* of our own onCreate — so only work that needs nothing but the WebView
* belongs here. Insets are deliberately left to
* [configureWebViewForMedia], which runs later and on every resume.
*
* TRACES: UR-003, UR-004 | DR-183
*/
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
mediaWebView = webView
installJavascriptBridges(webView)
configureWebViewSettings(webView)
}
/**
* Keeps the WebView overlay repainting while a native video surface is behind
* it. Null when not running.
*
* With the ExoPlayer SurfaceView composited *under* a transparent WebView, the
* WebView's ordinary damage stops reaching the screen: the page kept mutating
* — the clock text changing every second, the control bar's opacity going to
* 0 — and none of it appeared, while the video underneath animated fine. The
* overlay froze on whatever frame it last managed to present, which is why the
* progress bar "stayed there and did not update" and the controls would not
* fade. It is not a state bug: reading the DOM over the devtools socket showed
* the slider value advancing (476 → 479 over three seconds) behind a screen
* showing neither.
*
* A *structural* change did force it through — injecting one element made the
* whole overlay catch up at once, jumping the displayed clock from 6:39 to
* 19:35 — so the pixels are reachable; it is the incremental damage that gets
* dropped. A CSS animation does not do it: opacity animates on the compositor
* without repainting the layer, which is exactly why that attempt changed
* nothing.
*
* So the redraw is driven from here instead. `postInvalidateOnAnimation`
* rather than a fixed-rate timer, so it rides the display's vsync and cannot
* outpace the frames it is asking for, and it runs *only* while compositing is
* on — the cost belongs to native video playback, which is already decoding.
*
* This is a workaround for platform compositing behaviour, not a fix for a
* defect of ours; the honest form of it is narrow and self-cancelling.
*
* TRACES: UR-003, UR-004 | DR-191
*/
private var overlayRepaint: Runnable? = null
private fun startOverlayRepaint() {
if (overlayRepaint != null) return
val tick = object : Runnable {
override fun run() {
val webView = mediaWebView ?: return
webView.postInvalidateOnAnimation()
// Re-post through the same field so stopOverlayRepaint() can cancel it.
overlayRepaint?.let { handler.postDelayed(it, OVERLAY_REPAINT_MS) }
}
}
overlayRepaint = tick
handler.post(tick)
android.util.Log.d("MainActivity", "Overlay repaint started")
}
private fun stopOverlayRepaint() {
overlayRepaint?.let { handler.removeCallbacks(it) }
overlayRepaint = null
android.util.Log.d("MainActivity", "Overlay repaint stopped")
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -165,7 +262,9 @@ class MainActivity : TauriActivity() {
private fun configureWebViewForMedia() {
try {
val webView = findWebView(window.decorView)
// onWebViewCreate normally got here first; the tree walk is the fallback
// for a WebView we were never handed.
val webView = mediaWebView ?: findWebView(window.decorView)
if (webView == null) {
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
@@ -183,33 +282,47 @@ class MainActivity : TauriActivity() {
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
mediaWebView = webView
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
//
// configureWebViewForMedia() runs from onCreate's delayed post AND from
// every onResume (plus each WebView re-find), so this used to re-inject
// all four bridges repeatedly - 5 times in a 45s session. WebView binds
// injected objects at page-load time; re-injecting over a live page
// leaves JS holding a stale proxy. The object stays truthy while its
// methods vanish, which surfaced as a flood of
// "WebView: Unknown object" chromium errors and, in JS,
// "TypeError: setEnabled is not a function".
//
// The visible bug: the background-audio toggle turned blue but never
// reached native, so backgroundAudioEnabled stayed false, onStop never
// dispatched 'jellytau-background', and a locked screen killed audio
// instantly (UR-040). Audio focus and PiP broke the same way.
//
// The settings/WebChromeClient work below is idempotent and must keep
// running on resume; only the bridge injection is one-shot.
// Re-push the safe-area insets. Unlike addJavascriptInterface this is
// idempotent and MUST re-run: a page load discards the inline style the
// last push set, so the WebView would otherwise be left with no insets.
WindowInsetsBridge.attachWebView(webView)
// Normally already done by onWebViewCreate; this is the fallback path.
installJavascriptBridges(webView)
configureWebViewSettings(webView)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
}
}
/**
* Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
*
* This runs from [onWebViewCreate] — the only point early enough to be bound
* before the first page load — and from [configureWebViewForMedia] as a
* fallback. The latter runs from onCreate's delayed post AND from every
* onResume (plus each WebView re-find), so without the identity guard this
* re-injected every bridge repeatedly — 5 times in a 45s session. WebView
* binds injected objects at page-load time; re-injecting over a live page
* leaves JS holding a stale proxy. The object stays truthy while its methods
* vanish, which surfaced as a flood of "WebView: Unknown object" chromium
* errors and, in JS, "TypeError: setEnabled is not a function".
*
* The visible bug: the background-audio toggle turned blue but never reached
* native, so backgroundAudioEnabled stayed false, onStop never dispatched
* 'jellytau-background', and a locked screen killed audio instantly (UR-040).
* Audio focus and PiP broke the same way.
*
* Settings/WebChromeClient work is idempotent and must keep running on
* resume, so it lives in [configureWebViewSettings], not here.
*
* TRACES: UR-003, UR-004, UR-040, UR-041 | DR-183
*/
private fun installJavascriptBridges(webView: WebView) {
try {
if (webView === bridgesInstalledOn) {
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
configureWebViewSettings(webView)
return
}
bridgesInstalledOn = webView
@@ -329,6 +442,7 @@ class MainActivity : TauriActivity() {
android.graphics.drawable.ColorDrawable(color)
)
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
if (transparent) startOverlayRepaint() else stopOverlayRepaint()
}
}
@@ -371,10 +485,8 @@ class MainActivity : TauriActivity() {
dispatchWebEvent("jellytau-network-changed")
}
configureWebViewSettings(webView)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
android.util.Log.e("MainActivity", "Failed to install JavaScript bridges", e)
}
}
@@ -77,16 +77,29 @@ object VideoOverlayManager {
}
/**
* Detach the video SurfaceView from the Activity's view hierarchy.
* Detach the video SurfaceView from the view hierarchy.
*
* @param activity The Activity to detach the surface from
* Must be called on the main thread.
*
* This had **no callers at all**, which made [attachVideoSurface] one-way:
* `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference
* without removing the view, so every native video left its SurfaceView
* parented to the content view for the life of the process and the next one
* added another beneath it. The stack was invisible while the WebView was
* opaque, and [isVideoSurfaceAttached] — which gates
* `PictureInPictureManager.canEnterPip` — stayed true forever afterwards.
*
* Removes from the view's *own* parent rather than looking the content view
* up from an Activity, so it cannot leave a view behind when the Activity
* has been recreated under it.
*
* TRACES: UR-003, UR-041 | DR-184
*/
fun detachVideoSurface(activity: Activity) {
fun detachVideoSurface() {
try {
removeLayoutListener()
attachedSurfaceView?.let { surfaceView ->
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
contentView.removeView(surfaceView)
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
attachedSurfaceView = null
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
}
@@ -1228,14 +1228,25 @@ class JellyTauPlayer(private val appContext: Context) {
}
/**
* Clear the video surface when switching to audio playback.
* Clear the video surface when switching to audio playback, or on stop.
*
* Detaching is not optional bookkeeping: dropping the reference without
* removing the view left the SurfaceView parented to the content view for
* the life of the process, and the next video stacked another one under it.
* See VideoOverlayManager.detachVideoSurface.
*
* Always called on the main thread (every caller runs inside a
* `mainHandler.post`), which is what touching the view hierarchy requires.
*
* TRACES: UR-003, UR-041 | DR-184
*/
private fun clearVideoSurface() {
surfaceView?.let {
exoPlayer.clearVideoSurface()
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
surfaceView = null
surfaceHolder = null
android.util.Log.d("JellyTauPlayer", "Video surface cleared")
android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
}
}