diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt index 469e7fbb..fe5f37b7 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt @@ -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