fix(player): let the background-audio toggle govern backgrounding again
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 10s
Build & Release / Run Tests (push) Successful in 14m48s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m19s
Build & Release / Build Linux (push) Successful in 20m20s
Build & Release / Build Windows (push) Successful in 15m36s
Build & Release / Build Android (push) Successful in 30m46s
Build & Release / Create Release (push) Successful in 38s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 10s
Build & Release / Run Tests (push) Successful in 14m48s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m19s
Build & Release / Build Linux (push) Successful in 20m20s
Build & Release / Build Windows (push) Successful in 15m36s
Build & Release / Build Android (push) Successful in 30m46s
Build & Release / Create Release (push) Successful in 38s
Locking the screen kept a video's audio playing whether or not the background-audio button was on. Reported as "audio only mode is always active even if not selected". The button (UR-040) was built for the WebView <video> path, where losing visibility kills the decode: it chose between handing off to a native audio stream and letting playback stop. Native video then became the default renderer (DR-188), and on that path playback runs through ExoPlayer inside a MediaSessionService -- a foreground media service whose entire purpose is to keep playing while the app is hidden. Nothing stopped it, and nothing in the codebase paused on background. So the button governed a handoff that no longer had a gap to bridge. There was no interruption to paper over, and a user who never touched it got background playback anyway. The gating made it self-concealing: MainActivity.onStop only dispatched 'jellytau-background' when backgroundAudioEnabled was already true. The one notification that the app had gone away was itself conditional on the setting, so with the button OFF nothing could react even in principle. onStop and onStart now fire unconditionally and carry the two facts only the activity knows -- whether the toggle is armed, and whether Android put the window into picture-in-picture. What to do about it is decided in Rust (player/background_policy.rs), because it depends on whether the item has a picture to lose: video + toggle off -> Pause video + toggle on -> HandOffToAudio music, either -> KeepPlaying (no picture to give up) picture-in-picture -> KeepPlaying (the window is still on screen) It takes no renderer parameter on purpose. Two renderers with two behaviours and one toggle reaching only one of them is what produced the defect; a rule that cannot see the renderer cannot reproduce it. Two failure modes are deliberate. A decision call that fails leaves playback alone rather than risking silence mid-listen. An event with no detail -- older Kotlin against newer JS -- reads as "armed, not PiP", degrading to the previous behaviour instead of pausing unexpectedly. Foregrounding resumes only what backgrounding paused: a video the user paused themselves before locking stays paused. Written test-first per CLAUDE.md. The stub encoded today's behaviour (nothing ever pauses) and failed exactly as reported -- `left: KeepPlaying, right: Pause` -- before the rule was implemented. Verified on a device, R8-minified, both directions: [player_background_action] video=true armed=false pip=false -> Pause [player_background_action] video=true armed=true pip=false -> HandOffToAudio UR-040 / DR-224 / UT-211.
This commit is contained in:
+42
-7
@@ -19,6 +19,31 @@ export const commands = {
|
||||
async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
|
||||
return await TAURI_INVOKE("player_play_item", { item });
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* What playback should do now that the app is no longer visible.
|
||||
*
|
||||
* The caller supplies only what it alone knows -- whether the per-player
|
||||
* toggle is armed, and whether Android put the window into picture-in-picture.
|
||||
* Everything else (what is playing, and therefore whether there is a picture to
|
||||
* lose) is read here, because it is domain state.
|
||||
*
|
||||
* The rule itself is in `player::background_policy`; this command is the wire.
|
||||
* Returning `KeepPlaying` for an empty queue is deliberate: with nothing
|
||||
* playing there is nothing to pause, and an error would make the frontend
|
||||
* handle a case that is not a failure.
|
||||
*
|
||||
* TRACES: UR-040, UR-041 | DR-224 | UT-211
|
||||
*/
|
||||
async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture: boolean) : Promise<BackgroundAction> {
|
||||
return await TAURI_INVOKE("player_background_action", { backgroundAudioArmed, inPictureInPicture });
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
@@ -41,13 +66,6 @@ async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number)
|
||||
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> {
|
||||
@@ -1976,6 +1994,23 @@ countdownSeconds: number;
|
||||
* Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
||||
*/
|
||||
maxEpisodes?: number }
|
||||
/**
|
||||
* What the player should do when the app is backgrounded.
|
||||
*/
|
||||
export type BackgroundAction =
|
||||
/**
|
||||
* Carry on. Music, and video the user explicitly asked to keep hearing
|
||||
* while it is in a picture-in-picture window.
|
||||
*/
|
||||
"keepPlaying" |
|
||||
/**
|
||||
* Swap the video stream for an audio-only one and keep playing.
|
||||
*/
|
||||
"handOffToAudio" |
|
||||
/**
|
||||
* Stop making sound. The user did not ask for background playback.
|
||||
*/
|
||||
"pause"
|
||||
/**
|
||||
* Smart caching configuration
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { get } from "svelte/store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { JRayActor, StreamingQuality } from "$lib/api/bindings";
|
||||
import type { JRayActor, StreamingQuality, BackgroundAction } from "$lib/api/bindings";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Hls from "hls.js";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
@@ -63,6 +63,7 @@
|
||||
import {
|
||||
setBackgroundAudioEnabled,
|
||||
subscribeAppBackgrounded,
|
||||
type BackgroundSignal,
|
||||
subscribeAppForegrounded,
|
||||
} from "$lib/utils/backgroundAudio";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
@@ -825,7 +826,7 @@
|
||||
// flip the component into HTML5 mode). Unsubscribers go into nativeUnlisteners
|
||||
// so onDestroy tears them down.
|
||||
if (backgroundAudioSupported) {
|
||||
nativeUnlisteners.push(subscribeAppBackgrounded(enterBackgroundAudioHandoff));
|
||||
nativeUnlisteners.push(subscribeAppBackgrounded(onAppBackgrounded));
|
||||
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
|
||||
}
|
||||
|
||||
@@ -1722,6 +1723,9 @@
|
||||
const backgroundAudioSupported = platform() === "android";
|
||||
let backgroundAudioOn = $state(false); // v1: default OFF each session
|
||||
let handoffState: BackgroundAudioState = { ...initialHandoffState };
|
||||
// Set when backgrounding paused playback, so foregrounding resumes only
|
||||
// what we stopped -- never something the user paused themselves.
|
||||
let pausedByBackgrounding = false;
|
||||
|
||||
function toggleBackgroundAudio() {
|
||||
backgroundAudioOn = !backgroundAudioOn;
|
||||
@@ -1737,6 +1741,43 @@
|
||||
|
||||
// App went to background/locked while background-audio is armed: hand off to
|
||||
// native audio and stop the WebView video decode.
|
||||
async function onAppBackgrounded(signal: BackgroundSignal) {
|
||||
// What to do is a domain decision, not a presentation one: it depends on
|
||||
// whether the item has a picture to lose, which is Rust's to know. This used
|
||||
// to be decided implicitly by Kotlin gating the event on the toggle, which
|
||||
// is why the native path -- whose media service keeps playing regardless --
|
||||
// ignored the toggle entirely (DR-224).
|
||||
let action: BackgroundAction;
|
||||
try {
|
||||
action = await commands.playerBackgroundAction(
|
||||
signal.backgroundAudioArmed,
|
||||
signal.inPictureInPicture,
|
||||
);
|
||||
} catch (e) {
|
||||
// Never leave playback in an undefined state because a decision call
|
||||
// failed. Continuing is the old behaviour and the safer default: it
|
||||
// cannot silently stop something the user is listening to.
|
||||
log.warn("Background action lookup failed; leaving playback alone:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Background action:", action);
|
||||
switch (action) {
|
||||
case "keepPlaying":
|
||||
return;
|
||||
case "pause":
|
||||
// The user did not ask for background playback. Remember that WE paused
|
||||
// it, so returning to the foreground can resume rather than leaving a
|
||||
// video mysteriously stopped.
|
||||
pausedByBackgrounding = isPlaying;
|
||||
if (isPlaying) await playerController.pause();
|
||||
return;
|
||||
case "handOffToAudio":
|
||||
await enterBackgroundAudioHandoff();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function enterBackgroundAudioHandoff() {
|
||||
if (!shouldEnterBackgroundAudio(backgroundAudioOn, handoffState)) return;
|
||||
// `currentTime` is the component's authoritative ABSOLUTE position (the RAF
|
||||
@@ -1797,6 +1838,20 @@
|
||||
// App returned to foreground: stop native audio, reload the WebView <video> at
|
||||
// the position native reached, and restore play/pause.
|
||||
async function exitBackgroundAudioHandoff() {
|
||||
// Resume what backgrounding paused, before the handoff check: the pause path
|
||||
// and the handoff path are mutually exclusive, and this one leaves no
|
||||
// handoff state to unwind. Only resumes if WE paused it -- a user who
|
||||
// paused before locking the screen stays paused.
|
||||
if (pausedByBackgrounding) {
|
||||
pausedByBackgrounding = false;
|
||||
try {
|
||||
await playerController.play();
|
||||
} catch (e) {
|
||||
log.warn("Failed to resume after backgrounding:", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldExitBackgroundAudio(handoffState)) return;
|
||||
// Read the native player's state BEFORE exiting — the exit stops it. If the
|
||||
// user hit pause on the lockscreen while backgrounded, that pause must
|
||||
|
||||
@@ -66,10 +66,31 @@ export function setBackgroundAudioEnabled(enabled: boolean): boolean {
|
||||
* Returns an unsubscribe function. No-op where unsupported (the event never
|
||||
* fires on non-Android platforms).
|
||||
*/
|
||||
export function subscribeAppBackgrounded(handler: () => void): () => void {
|
||||
export interface BackgroundSignal {
|
||||
/** Whether the per-player background-audio toggle was armed (UR-040). */
|
||||
backgroundAudioArmed: boolean;
|
||||
/** Whether Android put the window into picture-in-picture (UR-041). */
|
||||
inPictureInPicture: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Older builds dispatched this event with no detail, and only when the toggle
|
||||
* was already armed. Treat a missing detail as "armed, not PiP" so a mismatched
|
||||
* pair degrades to the previous behaviour rather than pausing unexpectedly.
|
||||
*/
|
||||
function readSignal(event: Event): BackgroundSignal {
|
||||
const detail = (event as CustomEvent).detail as Partial<BackgroundSignal> | null | undefined;
|
||||
return {
|
||||
backgroundAudioArmed: detail?.backgroundAudioArmed ?? true,
|
||||
inPictureInPicture: detail?.inPictureInPicture ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeAppBackgrounded(handler: (signal: BackgroundSignal) => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener("jellytau-background", handler);
|
||||
return () => window.removeEventListener("jellytau-background", handler);
|
||||
const listener = (event: Event) => handler(readSignal(event));
|
||||
window.addEventListener("jellytau-background", listener);
|
||||
return () => window.removeEventListener("jellytau-background", listener);
|
||||
}
|
||||
|
||||
/** Subscribe to the native "app foregrounded" signal. Returns an unsubscribe fn. */
|
||||
|
||||
Reference in New Issue
Block a user