fix(android): register WebView JS bridges once; stop audio-focus fight

Locking the screen killed audio on video playback even with the
background-audio toggle armed.

configureWebViewForMedia() ran from onCreate's delayed post AND from
every onResume, re-calling addJavascriptInterface on each pass — five
times in a 45s session. WebView binds injected objects at page-load
time, so re-injecting over a live page leaves JS holding a stale proxy:
the object stays truthy (passing the `bridge()?.` optional chain) while
its methods vanish. Logcat showed 66 "WebView: Unknown object" errors
and, in JS, "TypeError: setEnabled is not a function".

So the toggle turned blue but never reached native. backgroundAudioEnabled
stayed false, onStop never dispatched 'jellytau-background', the handoff
never ran, and audio stopped the instant the screen locked. PiP and audio
focus broke identically.

- Register the bridges exactly once per WebView (identity-compared), and
  split the idempotent settings/chrome-client work into
  configureWebViewSettings() so it still runs on every resume.
- Forward WebView console output to logcat as "JellyTauWeb". The frontend
  was previously invisible to adb, which is what made this bug so hard to
  place; keep it for the next boundary-spanning diagnosis.
- setBackgroundAudioEnabled now reports whether native was actually
  reached instead of silently no-oping, so a dead bridge can never again
  masquerade as an armed toggle.

Removing the re-injection revived a latent conflict it had been masking:
the focus calls started working, and three AUDIOFOCUS_GAIN requesters
inside one uid began fighting — MainActivity, ExoPlayer, and Chromium's
own AudioFocusDelegate. The grant was followed ~45ms later by
AUDIOFOCUS_LOSS, whose handler paused playback, so arming background
audio (or just pressing play) paused the video in a loop.

WebView already manages focus for <video>. Drop the redundant
AndroidAudioFocus bridge, its listeners and its helpers entirely, and
leave focus to whichever engine is actually rendering — consistent with
the player-is-authoritative principle.

Also drops the dead AndroidBackgroundAudio.isSupported() probe, unused
since the button gate moved to platform().

TRACES: UR-040 | IR-025, DR-051 | UT-062
This commit is contained in:
2026-07-28 01:32:57 +02:00
parent 5759a97289
commit e5d3cc06f2
3 changed files with 162 additions and 103 deletions
@@ -1,10 +1,5 @@
package com.dtourolle.jellytau
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -19,8 +14,6 @@ class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper())
private var configAttempts = 0
private val maxConfigAttempts = 10
private var audioFocusRequest: AudioFocusRequest? = null
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
/**
* Coarse override for whether backgrounding the app should auto-enter PiP.
@@ -50,6 +43,15 @@ class MainActivity : TauriActivity() {
*/
private var mediaWebView: WebView? = null
/**
* The WebView the @JavascriptInterface bridges have been injected into.
*
* addJavascriptInterface must run once per WebView instance: re-injecting
* over an already-loaded page hands JS a stale proxy whose methods are gone.
* Compared by identity so a genuinely new WebView still gets its bridges.
*/
private var bridgesInstalledOn: WebView? = null
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -159,19 +161,36 @@ class MainActivity : TauriActivity() {
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
mediaWebView = webView
// Add JavaScript interface for audio focus control
webView.addJavascriptInterface(object : Any() {
@JavascriptInterface
fun requestAudioFocus() {
handler.post { this@MainActivity.requestAudioFocus() }
}
// 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.
if (webView === bridgesInstalledOn) {
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
configureWebViewSettings(webView)
return
}
bridgesInstalledOn = webView
@JavascriptInterface
fun abandonAudioFocus() {
handler.post { this@MainActivity.abandonAudioFocus() }
}
}, "AndroidAudioFocus")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
// NOTE: there is deliberately no "AndroidAudioFocus" bridge. Manual focus
// requests from the WebView competed with Chromium's own
// AudioFocusDelegate and with ExoPlayer, and the resulting
// AUDIOFOCUS_LOSS paused playback. See the comment on the video listeners
// in configureWebViewSettings().
// Add JavaScript interface for picture-in-picture control.
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
@@ -212,10 +231,6 @@ class MainActivity : TauriActivity() {
backgroundAudioEnabled = enabled
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
}
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidBackgroundAudio")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
@@ -248,6 +263,21 @@ class MainActivity : TauriActivity() {
dispatchWebEvent("jellytau-network-changed")
}
configureWebViewSettings(webView)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
}
}
/**
* WebView settings, chrome client and the video-unmute script.
*
* Split out from the bridge injection because this half is idempotent and
* must re-run on every resume, whereas addJavascriptInterface must not.
*/
private fun configureWebViewSettings(webView: WebView) {
try {
// Set WebChromeClient to handle video playback and audio focus
webView.webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
@@ -259,6 +289,21 @@ class MainActivity : TauriActivity() {
super.onHideCustomView()
android.util.Log.d("MainActivity", "Video exited fullscreen")
}
/**
* Forward WebView console output to logcat under the "JellyTauWeb" tag.
*
* Without this the frontend is invisible to `adb logcat`, which makes
* diagnosing anything that spans the JS/native boundary (the
* background-audio handoff in particular) guesswork.
*/
override fun onConsoleMessage(msg: android.webkit.ConsoleMessage): Boolean {
android.util.Log.d(
"JellyTauWeb",
"${msg.message()} (${msg.sourceId()}:${msg.lineNumber()})"
)
return true
}
}
android.util.Log.d("MainActivity", "WebChromeClient configured")
@@ -287,29 +332,18 @@ class MainActivity : TauriActivity() {
video.volume = 1.0;
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
// Add event listeners to manage audio focus
video.addEventListener('play', function() {
console.log('[Android] Video play event - requesting audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.requestAudioFocus();
}
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
});
video.addEventListener('pause', function() {
console.log('[Android] Video pause event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
video.addEventListener('ended', function() {
console.log('[Android] Video ended event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
// NOTE: deliberately no audio-focus calls here.
//
// WebView already manages audio focus for <video> through
// Chromium's own AudioFocusDelegate. Requesting AUDIOFOCUS_GAIN
// again from MainActivity made two requesters compete inside one
// uid: the grant was immediately followed by AUDIOFOCUS_LOSS
// (~45ms), whose handler paused playback - so arming background
// audio, or simply pressing play, paused the video in a loop.
//
// ExoPlayer is the third potential owner and stays authoritative
// for native playback (JellyTauPlayer manages its own focus).
// Leave focus to whichever engine is actually rendering.
video.addEventListener('volumechange', function() {
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
});
@@ -356,48 +390,4 @@ class MainActivity : TauriActivity() {
return null
}
private fun requestAudioFocus() {
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(audioAttributes)
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener { focusChange ->
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
}
.build()
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
android.util.Log.d("MainActivity", "Audio focus request result: $result")
} else {
@Suppress("DEPRECATION")
val result = audioManager.requestAudioFocus(
{ focusChange ->
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
},
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
}
}
private fun abandonAudioFocus() {
android.util.Log.d("MainActivity", "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let {
audioManager.abandonAudioFocusRequest(it)
}
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus { }
}
}
}