Files
jellytau/src/lib/utils/backgroundAudio.ts
T
dtourolle e5d3cc06f2 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
2026-07-28 01:32:57 +02:00

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);
}