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:
@@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user