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
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
/**
|
|
* Background-audio support, Android only.
|
|
*
|
|
* TRACES: UR-040 | IR-025, DR-051
|
|
*
|
|
* Keeps a video's *audio* playing when the app is backgrounded or the screen is
|
|
* locked, while video decode stops. This is a HANDOFF: the WebView `<video>`
|
|
* element (which decodes video) is torn down and the same item is played back
|
|
* audio-only through the native ExoPlayer foreground service. It is NOT the
|
|
* WebView staying alive — an Android WebView `<video>` does not keep audio
|
|
* playing once the app is backgrounded.
|
|
*
|
|
* The `AndroidBackgroundAudio` @JavascriptInterface (installed by MainActivity)
|
|
* carries the toggle state to native; native signals background/foreground back
|
|
* to the frontend as DOM CustomEvents (`jellytau-background` /
|
|
* `jellytau-foreground`) — see subscribeAppBackgrounded/Foregrounded below.
|
|
*
|
|
* Unsupported (no-op) on every non-Android platform.
|
|
*/
|
|
|
|
interface AndroidBackgroundAudioBridge {
|
|
setEnabled(enabled: boolean): void;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
AndroidBackgroundAudio?: AndroidBackgroundAudioBridge;
|
|
}
|
|
}
|
|
|
|
function bridge(): AndroidBackgroundAudioBridge | undefined {
|
|
if (typeof window === "undefined") return undefined;
|
|
return window.AndroidBackgroundAudio;
|
|
}
|
|
|
|
/**
|
|
* Arm/disarm background-audio mode for the current video. When armed, the native
|
|
* side runs the audio handoff on background instead of entering PiP.
|
|
*/
|
|
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 {
|
|
b.setEnabled(enabled);
|
|
console.log("[BgAudio] setEnabled ->", enabled);
|
|
return true;
|
|
} catch (err) {
|
|
console.warn("[BgAudio] Failed to set enabled:", err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to the native "app backgrounded" signal (Home/app-switch/lock).
|
|
* Returns an unsubscribe function. No-op where unsupported (the event never
|
|
* fires on non-Android platforms).
|
|
*/
|
|
export function subscribeAppBackgrounded(handler: () => void): () => void {
|
|
if (typeof window === "undefined") return () => {};
|
|
window.addEventListener("jellytau-background", handler);
|
|
return () => window.removeEventListener("jellytau-background", handler);
|
|
}
|
|
|
|
/** Subscribe to the native "app foregrounded" signal. Returns an unsubscribe fn. */
|
|
export function subscribeAppForegrounded(handler: () => void): () => void {
|
|
if (typeof window === "undefined") return () => {};
|
|
window.addEventListener("jellytau-foreground", handler);
|
|
return () => window.removeEventListener("jellytau-foreground", handler);
|
|
}
|