Background-audio handoff for video + repository/player refactor

Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
This commit is contained in:
2026-07-22 21:52:07 +02:00
parent 4e6ab017d4
commit 3fbf6afdbc
72 changed files with 6728 additions and 2338 deletions
+75
View File
@@ -0,0 +1,75 @@
/**
* 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;
isSupported(): boolean;
}
declare global {
interface Window {
AndroidBackgroundAudio?: AndroidBackgroundAudioBridge;
}
}
function bridge(): AndroidBackgroundAudioBridge | undefined {
if (typeof window === "undefined") return 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 {
try {
bridge()?.setEnabled(enabled);
} catch (err) {
console.warn("[BgAudio] Failed to set enabled:", err);
}
}
/**
* 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);
}