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:
@@ -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 {
|
||||
setEnabled(enabled: boolean): void;
|
||||
isSupported(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -34,25 +33,27 @@ function bridge(): AndroidBackgroundAudioBridge | undefined {
|
||||
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
|
||||
* 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 {
|
||||
bridge()?.setEnabled(enabled);
|
||||
b.setEnabled(enabled);
|
||||
console.log("[BgAudio] setEnabled ->", enabled);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("[BgAudio] Failed to set enabled:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user