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
+164 -3
View File
@@ -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">