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:
+49
-1
@@ -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?
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user