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
+49 -1
View File
@@ -19,6 +19,40 @@ export const commands = {
async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_play_item", { item });
},
/**
* Enter background-audio mode: hand playback of the currently-watched video off
* to the native ExoPlayer *audio* path so the audio keeps playing while the app
* is backgrounded/locked, with no client-side video decode (UR-040).
*
* `stream_url` MUST be an audio-only URL (see
* `get_audio_only_stream_url_for_video`). The item is created as
* `MediaType::Audio` so it starts an audio session and loads into the native
* backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
* frontend side, so exactly one audio source is ever active.
*
* This deliberately goes through the queue-based `play_item` path (NOT a
* side-channel) so end-of-track lands in `on_playback_ended`, which already
* honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
* The sleep-timer state is intentionally left untouched by the handoff.
*
* TRACES: UR-040 | DR-052 | UT-061, IT-013
*/
async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
},
/**
* Exit background-audio mode: stop the native audio player and return its final
* position so the frontend can reload the WebView `<video>` there (UR-040).
*
* Returns the position in seconds. The sleep timer is intentionally left
* untouched — if it fired while backgrounded, playback is already stopped and
* this simply reports the last position.
*
* TRACES: UR-040 | DR-052 | UT-061, IT-013
*/
async playerExitBackgroundAudio() : Promise<number> {
return await TAURI_INVOKE("player_exit_background_audio");
},
/**
* Play a queue of media items
*
@@ -1206,6 +1240,14 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
},
/**
* Get an audio-only stream URL for a *video* item (background-audio handoff).
*
* TRACES: UR-040 | JA-032 | UT-061
*/
async repositoryGetAudioOnlyStreamUrlForVideo(handle: string, itemId: string, mediaSourceId: string | null, startTimeSeconds: number | null, audioStreamIndex: number | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_only_stream_url_for_video", { handle, itemId, mediaSourceId, startTimeSeconds, audioStreamIndex });
},
/**
* Get Live TV channels (broadcast / IPTV) for browsing
*/
@@ -1760,7 +1802,13 @@ videoCodec: string;
/**
* Whether the video requires server-side transcoding
*/
needsTranscoding: boolean }
needsTranscoding: boolean;
/**
* Optional now-playing metadata. Used by the background-audio handoff so the
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
* existing video-only callers need not send them.
*/
artist?: string | null; primaryImageTag?: string | null; serverId?: string | null }
/**
* Queue context for remote transfer - what type of queue is this?
*/
+32
View File
@@ -390,6 +390,38 @@ describe("RepositoryClient", () => {
});
});
it("should get audio-only stream URL for a video item (camelCase params)", async () => {
// TRACES: UR-040 | JA-032 | UT-061
const mockUrl = "https://server.com/Audio/item123/universal?AudioStreamIndex=2";
(invoke as any).mockResolvedValueOnce(mockUrl);
const url = await client.getAudioOnlyStreamUrlForVideo("item123", "source456", 193, 2);
expect(url).toBe(mockUrl);
expect(invoke).toHaveBeenCalledWith("repository_get_audio_only_stream_url_for_video", {
handle: "test-handle-123",
itemId: "item123",
mediaSourceId: "source456",
startTimeSeconds: 193,
audioStreamIndex: 2,
});
});
it("should default optional params to null for audio-only stream URL", async () => {
// TRACES: UR-040 | JA-032 | UT-061
(invoke as any).mockResolvedValueOnce("https://server.com/Audio/item123/universal");
await client.getAudioOnlyStreamUrlForVideo("item123");
expect(invoke).toHaveBeenCalledWith("repository_get_audio_only_stream_url_for_video", {
handle: "test-handle-123",
itemId: "item123",
mediaSourceId: null,
startTimeSeconds: null,
audioStreamIndex: null,
});
});
it("should report playback progress", async () => {
(invoke as any).mockResolvedValueOnce(undefined);
+20
View File
@@ -172,6 +172,26 @@ export class RepositoryClient {
);
}
/**
* Audio-only stream URL for a video item, for the background-audio handoff.
* The server extracts just the audio track — no video is decoded on-device.
* TRACES: UR-040 | JA-032
*/
async getAudioOnlyStreamUrlForVideo(
itemId: string,
mediaSourceId?: string,
startTimeSeconds?: number,
audioStreamIndex?: number
): Promise<string> {
return commands.repositoryGetAudioOnlyStreamUrlForVideo(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
startTimeSeconds ?? null,
audioStreamIndex ?? null
);
}
// ===== Live TV / Channels =====
/** Browse Live TV channels (broadcast / IPTV). */