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
|
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.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -19,8 +14,6 @@ class MainActivity : TauriActivity() {
|
|||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var configAttempts = 0
|
private var configAttempts = 0
|
||||||
private val maxConfigAttempts = 10
|
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.
|
* Coarse override for whether backgrounding the app should auto-enter PiP.
|
||||||
@@ -50,6 +43,15 @@ class MainActivity : TauriActivity() {
|
|||||||
*/
|
*/
|
||||||
private var mediaWebView: WebView? = null
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -159,19 +161,36 @@ class MainActivity : TauriActivity() {
|
|||||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||||
mediaWebView = webView
|
mediaWebView = webView
|
||||||
|
|
||||||
// Add JavaScript interface for audio focus control
|
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
||||||
webView.addJavascriptInterface(object : Any() {
|
//
|
||||||
@JavascriptInterface
|
// configureWebViewForMedia() runs from onCreate's delayed post AND from
|
||||||
fun requestAudioFocus() {
|
// every onResume (plus each WebView re-find), so this used to re-inject
|
||||||
handler.post { this@MainActivity.requestAudioFocus() }
|
// 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
|
// NOTE: there is deliberately no "AndroidAudioFocus" bridge. Manual focus
|
||||||
fun abandonAudioFocus() {
|
// requests from the WebView competed with Chromium's own
|
||||||
handler.post { this@MainActivity.abandonAudioFocus() }
|
// AudioFocusDelegate and with ExoPlayer, and the resulting
|
||||||
}
|
// AUDIOFOCUS_LOSS paused playback. See the comment on the video listeners
|
||||||
}, "AndroidAudioFocus")
|
// in configureWebViewSettings().
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
|
||||||
|
|
||||||
// Add JavaScript interface for picture-in-picture control.
|
// Add JavaScript interface for picture-in-picture control.
|
||||||
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
||||||
@@ -212,10 +231,6 @@ class MainActivity : TauriActivity() {
|
|||||||
backgroundAudioEnabled = enabled
|
backgroundAudioEnabled = enabled
|
||||||
android.util.Log.d("MainActivity", "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")
|
}, "AndroidBackgroundAudio")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
||||||
|
|
||||||
@@ -248,6 +263,21 @@ class MainActivity : TauriActivity() {
|
|||||||
dispatchWebEvent("jellytau-network-changed")
|
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
|
// Set WebChromeClient to handle video playback and audio focus
|
||||||
webView.webChromeClient = object : WebChromeClient() {
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||||
@@ -259,6 +289,21 @@ class MainActivity : TauriActivity() {
|
|||||||
super.onHideCustomView()
|
super.onHideCustomView()
|
||||||
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
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")
|
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
||||||
|
|
||||||
@@ -287,29 +332,18 @@ class MainActivity : TauriActivity() {
|
|||||||
video.volume = 1.0;
|
video.volume = 1.0;
|
||||||
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
||||||
|
|
||||||
// Add event listeners to manage audio focus
|
// NOTE: deliberately no audio-focus calls here.
|
||||||
video.addEventListener('play', function() {
|
//
|
||||||
console.log('[Android] Video play event - requesting audio focus');
|
// WebView already manages audio focus for <video> through
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
// Chromium's own AudioFocusDelegate. Requesting AUDIOFOCUS_GAIN
|
||||||
AndroidAudioFocus.requestAudioFocus();
|
// again from MainActivity made two requesters compete inside one
|
||||||
}
|
// uid: the grant was immediately followed by AUDIOFOCUS_LOSS
|
||||||
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
|
// (~45ms), whose handler paused playback - so arming background
|
||||||
});
|
// audio, or simply pressing play, paused the video in a loop.
|
||||||
|
//
|
||||||
video.addEventListener('pause', function() {
|
// ExoPlayer is the third potential owner and stays authoritative
|
||||||
console.log('[Android] Video pause event - abandoning audio focus');
|
// for native playback (JellyTauPlayer manages its own focus).
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
// Leave focus to whichever engine is actually rendering.
|
||||||
AndroidAudioFocus.abandonAudioFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('ended', function() {
|
|
||||||
console.log('[Android] Video ended event - abandoning audio focus');
|
|
||||||
if (typeof AndroidAudioFocus !== 'undefined') {
|
|
||||||
AndroidAudioFocus.abandonAudioFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('volumechange', function() {
|
video.addEventListener('volumechange', function() {
|
||||||
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
||||||
});
|
});
|
||||||
@@ -356,48 +390,4 @@ class MainActivity : TauriActivity() {
|
|||||||
return null
|
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 { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { setBackgroundAudioEnabled } from "./backgroundAudio";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge-reporting contract for the background-audio toggle.
|
||||||
|
*
|
||||||
|
* TRACES: UR-040 | IR-025, DR-051 | UT-062
|
||||||
|
*
|
||||||
|
* Regression guard for the "screen lock kills video audio" bug: MainActivity
|
||||||
|
* re-ran configureWebViewForMedia() on every onResume, re-calling
|
||||||
|
* addJavascriptInterface over a live page. WebView then served a stale proxy —
|
||||||
|
* `window.AndroidBackgroundAudio` stayed truthy but its methods were gone, so
|
||||||
|
* `setEnabled` threw `TypeError: e.setEnabled is not a function`.
|
||||||
|
*
|
||||||
|
* The old implementation swallowed that with `bridge()?.setEnabled(...)` inside
|
||||||
|
* a try/catch returning void, so the UI showed "armed" while native never got
|
||||||
|
* the flag — and onStop's `if (backgroundAudioEnabled)` guard never dispatched
|
||||||
|
* `jellytau-background`. Audio died the instant the screen locked.
|
||||||
|
*
|
||||||
|
* setBackgroundAudioEnabled must therefore REPORT whether native was actually
|
||||||
|
* reached, so a dead bridge can never masquerade as an armed toggle.
|
||||||
|
*/
|
||||||
|
describe("setBackgroundAudioEnabled", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
delete (window as unknown as Record<string, unknown>).AndroidBackgroundAudio;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports success when the bridge is present and the call lands", () => {
|
||||||
|
const setEnabled = vi.fn();
|
||||||
|
window.AndroidBackgroundAudio = { setEnabled };
|
||||||
|
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(true);
|
||||||
|
expect(setEnabled).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failure when the bridge object is absent entirely", () => {
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failure for a stale proxy whose methods are gone", () => {
|
||||||
|
// The exact shape of the bug: object present (so `?.` passes) but the
|
||||||
|
// method is missing after re-injection over a live page.
|
||||||
|
window.AndroidBackgroundAudio = {} as unknown as typeof window.AndroidBackgroundAudio;
|
||||||
|
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failure when the bridge method throws", () => {
|
||||||
|
window.AndroidBackgroundAudio = {
|
||||||
|
setEnabled: () => {
|
||||||
|
throw new TypeError("e.setEnabled is not a function");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(setBackgroundAudioEnabled(true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws out to the caller — the toggle must not break the player", () => {
|
||||||
|
window.AndroidBackgroundAudio = {
|
||||||
|
setEnabled: () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => setBackgroundAudioEnabled(false)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
|
|
||||||
interface AndroidBackgroundAudioBridge {
|
interface AndroidBackgroundAudioBridge {
|
||||||
setEnabled(enabled: boolean): void;
|
setEnabled(enabled: boolean): void;
|
||||||
isSupported(): boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -34,25 +33,27 @@ function bridge(): AndroidBackgroundAudioBridge | undefined {
|
|||||||
return window.AndroidBackgroundAudio;
|
return window.AndroidBackgroundAudio;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether background audio is available — used to decide if the toggle renders. */
|
|
||||||
export function isBackgroundAudioSupported(): boolean {
|
|
||||||
try {
|
|
||||||
return bridge()?.isSupported() ?? false;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[BgAudio] isSupported check failed:", err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arm/disarm background-audio mode for the current video. When armed, the native
|
* Arm/disarm background-audio mode for the current video. When armed, the native
|
||||||
* side runs the audio handoff on background instead of entering PiP.
|
* side runs the audio handoff on background instead of entering PiP.
|
||||||
*/
|
*/
|
||||||
export function setBackgroundAudioEnabled(enabled: boolean): void {
|
export function setBackgroundAudioEnabled(enabled: boolean): boolean {
|
||||||
|
const b = bridge();
|
||||||
|
if (!b) {
|
||||||
|
// The button is gated on platform(), not on this bridge, so it can render
|
||||||
|
// before/without the bridge existing. Silently no-oping here leaves the UI
|
||||||
|
// showing "armed" while native never learns — and the handoff then never
|
||||||
|
// fires on lock. Report it so callers can retry.
|
||||||
|
console.warn("[BgAudio] setEnabled: bridge missing, native NOT armed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
bridge()?.setEnabled(enabled);
|
b.setEnabled(enabled);
|
||||||
|
console.log("[BgAudio] setEnabled ->", enabled);
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[BgAudio] Failed to set enabled:", err);
|
console.warn("[BgAudio] Failed to set enabled:", err);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user