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:
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 -->
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040 | DR-010, DR-023, DR-024, DR-051, DR-052 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -18,7 +18,20 @@
|
||||
import { playerController } from "$lib/player";
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||
import { isPipSupported, enterPip } from "$lib/utils/pictureInPicture";
|
||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||
import {
|
||||
isBackgroundAudioSupported,
|
||||
setBackgroundAudioEnabled,
|
||||
subscribeAppBackgrounded,
|
||||
subscribeAppForegrounded,
|
||||
} from "$lib/utils/backgroundAudio";
|
||||
import {
|
||||
computeHandoffPosition,
|
||||
initialHandoffState,
|
||||
shouldEnterBackgroundAudio,
|
||||
shouldExitBackgroundAudio,
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
@@ -489,6 +502,15 @@
|
||||
|
||||
// Set up progress reporting interval
|
||||
onMount(async () => {
|
||||
// Background-audio lifecycle listeners MUST be registered synchronously —
|
||||
// before any await below — per the native-mode pitfall (an await here can
|
||||
// flip the component into HTML5 mode). Unsubscribers go into nativeUnlisteners
|
||||
// so onDestroy tears them down.
|
||||
if (backgroundAudioSupported) {
|
||||
nativeUnlisteners.push(subscribeAppBackgrounded(enterBackgroundAudioHandoff));
|
||||
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
|
||||
}
|
||||
|
||||
// Initialize player via Rust - Rust will decide which backend to use based on platform
|
||||
if (media && currentStreamUrl) {
|
||||
try {
|
||||
@@ -681,12 +703,19 @@
|
||||
clearInterval(debugLogInterval);
|
||||
}
|
||||
|
||||
// Remove native backend event listeners
|
||||
// Remove native backend event listeners (incl. background-audio lifecycle subs)
|
||||
for (const unlisten of nativeUnlisteners) {
|
||||
unlisten();
|
||||
}
|
||||
nativeUnlisteners = [];
|
||||
|
||||
// Re-assert defaults so this player's background-audio choice can't leak into
|
||||
// the next one: disarm background audio and restore auto-PiP.
|
||||
if (backgroundAudioSupported) {
|
||||
setBackgroundAudioEnabled(false);
|
||||
setAutoEnterEnabled(true);
|
||||
}
|
||||
|
||||
// Clean up HLS.js instance - prevent dual audio on unmount
|
||||
if (hls) {
|
||||
console.log("[VideoPlayer] Destroying HLS.js instance on unmount");
|
||||
@@ -815,6 +844,25 @@
|
||||
console.log("[VideoPlayer] Video unmuted on canplay, volume: 1.0");
|
||||
}
|
||||
|
||||
// Returning from background audio: resume the <video> at the position native
|
||||
// audio reached, restoring the prior play/pause state. Takes precedence over
|
||||
// the resume-point seek below (which is for a fresh load, not a handoff).
|
||||
if (pendingForegroundSeek !== null && videoElement) {
|
||||
const seekTo = pendingForegroundSeek;
|
||||
const shouldPlay = pendingForegroundPlay;
|
||||
pendingForegroundSeek = null;
|
||||
pendingForegroundPlay = false;
|
||||
hasPerformedInitialSeek = true;
|
||||
try {
|
||||
videoElement.currentTime = seekTo;
|
||||
currentTime = seekTo;
|
||||
if (shouldPlay) await videoElement.play();
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to resume after background audio:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Seek to initial position if resuming playback
|
||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||
console.log("[VideoPlayer] Seeking to initial position:", initialPosition);
|
||||
@@ -1092,6 +1140,104 @@
|
||||
enterPip();
|
||||
}
|
||||
|
||||
// ===== Background audio (UR-040, Android) =====
|
||||
// Keep the video's audio playing when the app is backgrounded/locked by handing
|
||||
// playback off to the native ExoPlayer audio service; the WebView <video> is
|
||||
// torn down so no video is decoded. Mutually exclusive with auto-PiP.
|
||||
//
|
||||
// Resolved synchronously (no await) for the same native-mode reason as PiP.
|
||||
const backgroundAudioSupported = isBackgroundAudioSupported();
|
||||
let backgroundAudioOn = $state(false); // v1: default OFF each session
|
||||
let handoffState: BackgroundAudioState = { ...initialHandoffState };
|
||||
|
||||
function toggleBackgroundAudio() {
|
||||
backgroundAudioOn = !backgroundAudioOn;
|
||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
||||
// so exactly one background behavior is active.
|
||||
setBackgroundAudioEnabled(backgroundAudioOn);
|
||||
setAutoEnterEnabled(!backgroundAudioOn);
|
||||
}
|
||||
|
||||
// App went to background/locked while background-audio is armed: hand off to
|
||||
// native audio and stop the WebView video decode.
|
||||
async function enterBackgroundAudioHandoff() {
|
||||
if (!shouldEnterBackgroundAudio(backgroundAudioOn, handoffState)) return;
|
||||
// `currentTime` is the component's authoritative ABSOLUTE position (the RAF
|
||||
// loop keeps it at seekOffset + element.currentTime, and it survives HLS
|
||||
// transcode segment resets). Reading videoElement.currentTime directly is
|
||||
// wrong for transcoded streams (it's the in-segment offset) and can read 0
|
||||
// if the element is mid-teardown — which shipped audio starting from 0:00.
|
||||
const pos = computeHandoffPosition(currentTime, 0);
|
||||
const wasPlaying = isPlaying;
|
||||
console.log("[VideoPlayer] Background-audio handoff at position:", pos.toFixed(1));
|
||||
handoffState = { active: true, wasPlaying };
|
||||
try {
|
||||
if (!media) return;
|
||||
// Ask the server for an audio-only stream of this video item (no video
|
||||
// decode), carrying the selected audio track and resume position.
|
||||
const audioUrl = await auth.getRepository().getAudioOnlyStreamUrlForVideo(
|
||||
media.id,
|
||||
mediaSourceId ?? undefined,
|
||||
pos,
|
||||
selectedAudioTrackIndex ?? undefined,
|
||||
);
|
||||
await commands.playerEnterBackgroundAudio(
|
||||
{
|
||||
id: media.id,
|
||||
title: media.name,
|
||||
streamUrl: audioUrl,
|
||||
videoCodec: "aac",
|
||||
needsTranscoding: false,
|
||||
// Now-playing metadata so the lockscreen/miniplayer show the item.
|
||||
artist: media.seriesName ?? null,
|
||||
primaryImageTag: media.primaryImageTag ?? null,
|
||||
serverId: media.serverId ?? null,
|
||||
},
|
||||
pos,
|
||||
);
|
||||
// Tear down the WebView <video>/HLS decode AFTER native audio has started,
|
||||
// so there is never a gap — and exactly one audio source is ever live.
|
||||
tearDownHls();
|
||||
if (videoElement) {
|
||||
videoElement.pause();
|
||||
videoElement.removeAttribute("src");
|
||||
videoElement.load();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Background-audio handoff failed:", err);
|
||||
handoffState = { ...initialHandoffState };
|
||||
}
|
||||
}
|
||||
|
||||
// App returned to foreground: stop native audio, reload the WebView <video> at
|
||||
// the position native reached, and restore play/pause.
|
||||
async function exitBackgroundAudioHandoff() {
|
||||
if (!shouldExitBackgroundAudio(handoffState)) return;
|
||||
const wasPlaying = handoffState.wasPlaying;
|
||||
handoffState = { ...initialHandoffState };
|
||||
try {
|
||||
const pos = await commands.playerExitBackgroundAudio();
|
||||
// Reload the video at the returned position. Resetting these re-runs the
|
||||
// HLS init $effect and reveals/seeks the element as on a fresh load.
|
||||
hasPerformedInitialSeek = false;
|
||||
lastAppliedInitialPosition = undefined;
|
||||
seekOffset = 0;
|
||||
isMediaReady = false;
|
||||
// Re-point the element at the (unchanged) video stream URL; assigning a new
|
||||
// reference restarts the HLS effect even if the string is identical.
|
||||
currentStreamUrl = streamUrl;
|
||||
// Seek to where native audio left off once the element is ready again.
|
||||
pendingForegroundSeek = pos;
|
||||
pendingForegroundPlay = wasPlaying;
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Background-audio return failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Consumed by handleCanPlay after the <video> reloads on foreground.
|
||||
let pendingForegroundSeek: number | null = null;
|
||||
let pendingForegroundPlay = false;
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
@@ -1745,6 +1891,21 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Background audio (Android only) — keep audio playing when the app is
|
||||
backgrounded/locked; video decode stops. Suppresses auto-PiP while on. -->
|
||||
{#if backgroundAudioSupported}
|
||||
<button
|
||||
onclick={toggleBackgroundAudio}
|
||||
class={backgroundAudioOn ? "text-blue-400 hover:text-blue-300" : "text-white hover:text-gray-300"}
|
||||
aria-label="Background audio"
|
||||
aria-pressed={backgroundAudioOn}
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Fullscreen -->
|
||||
<button onclick={toggleFullscreen} class="text-white hover:text-gray-300" aria-label="Toggle fullscreen">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeHandoffPosition,
|
||||
initialHandoffState,
|
||||
shouldEnterBackgroundAudio,
|
||||
shouldExitBackgroundAudio,
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
|
||||
// TRACES: UR-040 | DR-052 | UT-060
|
||||
|
||||
describe("backgroundAudioHandoff", () => {
|
||||
describe("computeHandoffPosition", () => {
|
||||
it("sums element time and transcode seekOffset (absolute position)", () => {
|
||||
// Transcoded HLS resets element time to 0 after a reload; seekOffset carries
|
||||
// the cumulative offset. The audio stream must resume at the absolute pos.
|
||||
expect(computeHandoffPosition(12, 180)).toBe(192);
|
||||
});
|
||||
|
||||
it("handles a direct stream with no offset", () => {
|
||||
expect(computeHandoffPosition(45, 0)).toBe(45);
|
||||
});
|
||||
|
||||
it("never returns a negative position", () => {
|
||||
expect(computeHandoffPosition(-5, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldEnterBackgroundAudio", () => {
|
||||
it("enters when toggle is on and not already handed off", () => {
|
||||
expect(shouldEnterBackgroundAudio(true, initialHandoffState)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enter when the toggle is off", () => {
|
||||
expect(shouldEnterBackgroundAudio(false, initialHandoffState)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not double-enter when already active", () => {
|
||||
const active: BackgroundAudioState = { active: true, wasPlaying: true };
|
||||
expect(shouldEnterBackgroundAudio(true, active)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldExitBackgroundAudio", () => {
|
||||
it("exits when a handoff is active", () => {
|
||||
const active: BackgroundAudioState = { active: true, wasPlaying: false };
|
||||
expect(shouldExitBackgroundAudio(active)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not exit when no handoff happened", () => {
|
||||
expect(shouldExitBackgroundAudio(initialHandoffState)).toBe(false);
|
||||
});
|
||||
|
||||
it("exits even if the toggle was turned off while backgrounded", () => {
|
||||
// shouldExit ignores the toggle by design, so turning it off mid-background
|
||||
// still returns cleanly to video on foreground.
|
||||
const active: BackgroundAudioState = { active: true, wasPlaying: true };
|
||||
expect(shouldExitBackgroundAudio(active)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Pure helpers for the video → background-audio handoff (UR-040).
|
||||
*
|
||||
* TRACES: UR-040 | DR-052 | UT-060
|
||||
*
|
||||
* Kept free of Svelte/DOM so the handoff arithmetic and state transitions are
|
||||
* unit-testable without mounting the player. The component
|
||||
* (VideoPlayer.svelte) owns the actual `<video>` teardown and IPC calls.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Absolute playback position to resume the audio stream at.
|
||||
*
|
||||
* Transcoded HLS playback tracks time as `videoElement.currentTime + seekOffset`
|
||||
* (the element resets to 0 after each transcode reload; `seekOffset` carries the
|
||||
* cumulative offset). Background audio must resume at that ABSOLUTE position, so
|
||||
* both terms are summed here — mirroring the `effectiveTime` used elsewhere in
|
||||
* the player.
|
||||
*/
|
||||
export function computeHandoffPosition(elementCurrentTime: number, seekOffset: number): number {
|
||||
const pos = elementCurrentTime + seekOffset;
|
||||
return pos > 0 ? pos : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The handoff state. `wasPlaying` is captured on the way out so play/pause is
|
||||
* restored when the app returns to the foreground.
|
||||
*/
|
||||
export interface BackgroundAudioState {
|
||||
active: boolean;
|
||||
wasPlaying: boolean;
|
||||
}
|
||||
|
||||
export const initialHandoffState: BackgroundAudioState = {
|
||||
active: false,
|
||||
wasPlaying: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a background signal should trigger the audio handoff right now.
|
||||
* Only when the toggle is on and we're not already handed off.
|
||||
*/
|
||||
export function shouldEnterBackgroundAudio(
|
||||
toggleOn: boolean,
|
||||
state: BackgroundAudioState
|
||||
): boolean {
|
||||
return toggleOn && !state.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a foreground signal should trigger the return to WebView video.
|
||||
* Only when we actually handed off (regardless of the current toggle value, so
|
||||
* turning the toggle off while backgrounded still returns cleanly).
|
||||
*/
|
||||
export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean {
|
||||
return state.active;
|
||||
}
|
||||
Reference in New Issue
Block a user