Compare commits

..
2 Commits
Author SHA1 Message Date
dtourolle 1ef6180776 chore(release): bump to 0.4.1
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m39s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m40s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 6m1s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 9m26s
Build & Release / Build Linux (push) Successful in 19m41s
Build & Release / Build Windows (push) Successful in 13m57s
Build & Release / Build Android (push) Successful in 30m6s
Build & Release / Create Release (push) Successful in 24s
2026-08-05 12:31:16 +02:00
dtourolle 878ac5fa59 fix(player): make lockscreen transport reach background audio (DR-097)
Pausing from the lockscreen did nothing while a video's audio played in
the background. The handoff starts native ExoPlayer audio and only then
tears the WebView <video> down, and that teardown fires a DOM `pause`
the frontend reports like any other — leaving html5_playing = Some(false).
Transport therefore stayed aimed at the element: the lockscreen pause
emitted a ControlCommand into a <video> that no longer existed while the
native player carried on.

The controller now tracks a background-audio handoff explicitly. Entering
one hands transport authority to the native backend and drops the dying
element's state/position/media-loaded reports, which also stop flipping
the UI to paused and dragging the position backwards. Exiting restores
the element as the player.

A lockscreen pause also has to survive the return to the foreground: the
video used to resume from a snapshot taken at handoff time, undoing the
pause on the way back in. shouldResumeOnForeground() lets an explicit
`paused` from the player override that snapshot.

TRACES: UR-040, UR-005 | DR-052, DR-097
2026-08-05 12:26:25 +02:00
10 changed files with 217 additions and 8 deletions
+17
View File
@@ -6,6 +6,23 @@ Entries are grouped by the capability they change, not by commit. Requirement
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
generated trace matrix lives in [docs/traceability.md](docs/traceability.md). generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
## v0.4.1
### 🐛 Fixes
- **The lockscreen pause works while a video's audio plays in the background.**
The handoff starts native audio and only then tears the WebView `<video>`
down — and that teardown fires a DOM `pause` the frontend reports like any
other, which left the controller believing webview media was still the
player. Transport stayed aimed at it: pressing pause on the lockscreen sent a
control command to a `<video>` that no longer existed while the native player
carried on, and the element's parting position report dragged the displayed
time backwards. A handoff is now tracked explicitly, so it hands transport to
the native backend and ignores what the dying element still reports. A pause
made from the lockscreen also survives the return to the app, instead of being
undone by the play state captured when the handoff began.
(UR-040, UR-005 → DR-052, DR-097)
## v0.4.0 ## v0.4.0
### ✨ Features ### ✨ Features
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jellytau", "name": "jellytau",
"version": "0.4.0", "version": "0.4.1",
"description": "", "description": "",
"type": "module", "type": "module",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
+1 -1
View File
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]] [[package]]
name = "jellytau" name = "jellytau"
version = "0.4.0" version = "0.4.1"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jellytau" name = "jellytau"
version = "0.4.0" version = "0.4.1"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+2 -2
View File
@@ -714,7 +714,7 @@ pub async fn player_enter_background_audio(
// this base to the native player's relative position to get the absolute one. // this base to the native player's relative position to get the absolute one.
// The controller owns it so a backend-driven advance to the next episode // The controller owns it so a backend-driven advance to the next episode
// clears it along with the stream it described. // clears it along with the stream it described.
controller.set_background_audio_base(position_seconds); controller.enter_background_audio(position_seconds);
controller controller
.play_item(media_item) .play_item(media_item)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -753,7 +753,7 @@ pub async fn player_exit_background_audio(
// The base offset (handoff position) + native player's relative position = // The base offset (handoff position) + native player's relative position =
// the absolute position to resume the video at. Zero after a backend-driven // the absolute position to resume the video at. Zero after a backend-driven
// episode advance, whose stream already starts at its own zero. // episode advance, whose stream already starts at its own zero.
let base = controller.take_background_audio_base(); let base = controller.exit_background_audio();
// Capture position into a `let` BEFORE stop() — never hold work across a lock // Capture position into a `let` BEFORE stop() — never hold work across a lock
// re-entrant call (deadlock discipline, CLAUDE.md). // re-entrant call (deadlock discipline, CLAUDE.md).
let relative = controller.position(); let relative = controller.position();
+143
View File
@@ -179,6 +179,17 @@ pub struct PlayerController {
// TRACES: UR-040 | DR-052 // TRACES: UR-040 | DR-052
background_audio_base: Arc<Mutex<f64>>, background_audio_base: Arc<Mutex<f64>>,
// True while a background-audio handoff owns playback: the native audio
// player is the real player and the webview <video> has been torn down.
//
// The teardown is what makes this necessary. It fires a DOM `pause` that the
// frontend reports like any other, which would otherwise leave the controller
// believing webview media is still active — aiming lockscreen transport at an
// element that no longer exists (see `is_html5_active`).
//
// TRACES: UR-040 | DR-052, DR-097
background_audio_active: Arc<Mutex<bool>>,
// Budget for re-opening a stream that ended short of the item's runtime. // Budget for re-opening a stream that ended short of the item's runtime.
// //
// A resume re-requests the same URL, so a server that is genuinely gone would // A resume re-requests the same URL, so a server that is genuinely gone would
@@ -221,6 +232,7 @@ impl PlayerController {
end_reason: Arc::new(Mutex::new(None)), end_reason: Arc::new(Mutex::new(None)),
autoplay_episode_count: Arc::new(Mutex::new(0)), autoplay_episode_count: Arc::new(Mutex::new(0)),
background_audio_base: Arc::new(Mutex::new(0.0)), background_audio_base: Arc::new(Mutex::new(0.0)),
background_audio_active: Arc::new(Mutex::new(false)),
stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())), stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
html5_playing: Arc::new(Mutex::new(None)), html5_playing: Arc::new(Mutex::new(None)),
}; };
@@ -1000,6 +1012,14 @@ impl PlayerController {
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer /// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch. /// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
pub fn report_html5_state(&self, state: String, media_id: Option<String>) { pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
// A background-audio handoff has already moved playback to the native
// player and torn the element down; anything it still reports describes
// a video that is no longer playing. Dropping it keeps the UI on the
// audio that IS playing and leaves transport with the native backend.
if self.is_background_audio_active() {
debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
return;
}
// Track it: this is the authoritative play/pause state for // Track it: this is the authoritative play/pause state for
// webview-rendered media, and what transport decisions read (DR-097). // webview-rendered media, and what transport decisions read (DR-097).
// "stopped"/"idle" mean the element is gone, so hand authority back to // "stopped"/"idle" mean the element is gone, so hand authority back to
@@ -1027,6 +1047,11 @@ impl PlayerController {
/// Re-emits a `PositionUpdate` event mirroring the native backends' periodic /// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
/// position updates (the adapter is expected to throttle to ~250ms like MPV). /// position updates (the adapter is expected to throttle to ~250ms like MPV).
pub fn report_html5_position(&self, position: f64, duration: f64) { pub fn report_html5_position(&self, position: f64, duration: f64) {
// Stale by definition during a handoff — the native player's ticks are
// the real position. See `report_html5_state`.
if self.is_background_audio_active() {
return;
}
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() { if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration }); emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
} }
@@ -1035,6 +1060,10 @@ impl PlayerController {
/// Report that the HTML5 <video> element finished loading and knows its /// Report that the HTML5 <video> element finished loading and knows its
/// duration. Mirrors the native `MediaLoaded` event. /// duration. Mirrors the native `MediaLoaded` event.
pub fn report_html5_media_loaded(&self, duration: f64) { pub fn report_html5_media_loaded(&self, duration: f64) {
// See `report_html5_state` — the element is not the player right now.
if self.is_background_audio_active() {
return;
}
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() { if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration }); emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
} }
@@ -1234,6 +1263,42 @@ impl PlayerController {
*self.background_audio_base.lock_safe() = seconds.max(0.0); *self.background_audio_base.lock_safe() = seconds.max(0.0);
} }
/// Enter a background-audio handoff at `position` (the video's position, and
/// therefore the audio stream's zero).
///
/// Hands transport authority to the native audio player: the webview
/// `<video>` is about to be torn down, so its last reports — including the
/// `pause` the teardown itself fires — must not keep it looking like the
/// player. Without this the lockscreen pause emitted a ControlCommand at a
/// dead element and the audio played straight through it.
///
/// TRACES: UR-040, UR-005 | DR-052, DR-097
pub fn enter_background_audio(&self, position: f64) {
self.set_background_audio_base(position);
*self.background_audio_active.lock_safe() = true;
*self.html5_playing.lock_safe() = None;
}
/// Leave a background-audio handoff, returning the base offset to add to the
/// native player's relative position.
///
/// The webview `<video>` becomes the player again once it reloads, so its
/// reports are honoured from here on.
///
/// TRACES: UR-040, UR-005 | DR-052, DR-097
pub fn exit_background_audio(&self) -> f64 {
*self.background_audio_active.lock_safe() = false;
self.take_background_audio_base()
}
/// True while the native audio player owns playback via a background-audio
/// handoff.
///
/// TRACES: UR-040 | DR-052
pub fn is_background_audio_active(&self) -> bool {
*self.background_audio_active.lock_safe()
}
/// Read and clear the background-audio base offset. /// Read and clear the background-audio base offset.
/// ///
/// TRACES: UR-040 | DR-052 /// TRACES: UR-040 | DR-052
@@ -2002,6 +2067,84 @@ mod tests {
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]); assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
} }
#[test]
fn test_background_audio_handoff_moves_transport_to_native_backend() {
// Lockscreen pause while playing a video's audio in the background.
//
// The handoff tears the WebView <video> down AFTER native audio starts,
// and that teardown fires a DOM `pause` the frontend dutifully reports.
// That report used to leave `html5_playing = Some(false)`, so transport
// kept being aimed at an element that no longer exists: the lockscreen
// pause emitted a ControlCommand into the void and the audio played on.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
// Video was playing in the webview.
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
assert!(controller.is_html5_active());
// Hand off to the native audio player, then tear the element down.
controller.enter_background_audio(1200.0);
controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
assert!(
!controller.is_html5_active(),
"native audio owns transport during a background-audio handoff"
);
controller.pause().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert!(
controls.is_empty(),
"pause must drive the native backend, not a torn-down element: {:?}",
controls
);
}
#[test]
fn test_background_audio_handoff_suppresses_stale_element_events() {
// The dying element's pause/position reports describe the video, not the
// audio now playing — re-emitting them flips the UI to paused and yanks
// the position backwards while native audio keeps going.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.enter_background_audio(1200.0);
controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
controller.report_html5_position(1200.0, 2400.0);
assert!(
emitter.events().is_empty(),
"stale webview reports must not reach the event pipeline: {:?}",
emitter.events()
);
}
#[test]
fn test_exit_background_audio_returns_transport_to_the_webview() {
// Back in the foreground the <video> is the player again, so its reports
// must be honoured — and the base offset still comes back for the resume.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.enter_background_audio(1200.0);
assert_eq!(controller.exit_background_audio(), 1200.0);
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
assert!(controller.is_html5_active());
assert!(controller.html5_is_playing());
}
#[test] #[test]
fn test_html5_stopped_report_releases_transport_to_native_backend() { fn test_html5_stopped_report_releases_transport_to_native_backend() {
// When webview video goes away, transport must fall back to the native // When webview video goes away, transport must fall back to the native
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau", "productName": "jellytau",
"version": "0.4.0", "version": "0.4.1",
"identifier": "com.dtourolle.jellytau", "identifier": "com.dtourolle.jellytau",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+8 -2
View File
@@ -1,6 +1,7 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 --> <!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, untrack } from "svelte"; import { onMount, onDestroy, untrack } from "svelte";
import { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import type { JRayActor } from "$lib/api/bindings"; import type { JRayActor } from "$lib/api/bindings";
@@ -14,7 +15,7 @@
import CachedImage from "../common/CachedImage.svelte"; import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit"; import { videoFitClass } from "./videoFit";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer"; import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition } from "$lib/stores/player"; import { playbackPosition, playerState } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter"; import * as html5Adapter from "$lib/player/html5Adapter";
import { playerController } from "$lib/player"; import { playerController } from "$lib/player";
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters"; import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
@@ -42,6 +43,7 @@
initialHandoffState, initialHandoffState,
shouldEnterBackgroundAudio, shouldEnterBackgroundAudio,
shouldExitBackgroundAudio, shouldExitBackgroundAudio,
shouldResumeOnForeground,
type BackgroundAudioState, type BackgroundAudioState,
} from "./backgroundAudioHandoff"; } from "./backgroundAudioHandoff";
@@ -1333,7 +1335,11 @@
// the position native reached, and restore play/pause. // the position native reached, and restore play/pause.
async function exitBackgroundAudioHandoff() { async function exitBackgroundAudioHandoff() {
if (!shouldExitBackgroundAudio(handoffState)) return; if (!shouldExitBackgroundAudio(handoffState)) return;
const wasPlaying = handoffState.wasPlaying; // 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
// survive the return to video rather than being overwritten by whatever the
// <video> was doing when we handed off.
const wasPlaying = shouldResumeOnForeground(handoffState.wasPlaying, get(playerState).kind);
handoffState = { ...initialHandoffState }; handoffState = { ...initialHandoffState };
try { try {
// Absolute position the native audio reached (base offset applied in Rust). // Absolute position the native audio reached (base offset applied in Rust).
@@ -4,6 +4,7 @@ import {
initialHandoffState, initialHandoffState,
shouldEnterBackgroundAudio, shouldEnterBackgroundAudio,
shouldExitBackgroundAudio, shouldExitBackgroundAudio,
shouldResumeOnForeground,
type BackgroundAudioState, type BackgroundAudioState,
} from "./backgroundAudioHandoff"; } from "./backgroundAudioHandoff";
@@ -58,4 +59,27 @@ describe("backgroundAudioHandoff", () => {
expect(shouldExitBackgroundAudio(active)).toBe(true); expect(shouldExitBackgroundAudio(active)).toBe(true);
}); });
}); });
describe("shouldResumeOnForeground", () => {
it("resumes when it was playing and the native audio still is", () => {
expect(shouldResumeOnForeground(true, "playing")).toBe(true);
});
it("stays paused when the lockscreen paused the native audio", () => {
// The whole point of the lockscreen pause: coming back to the app must not
// undo it just because the video was playing when we handed off.
expect(shouldResumeOnForeground(true, "paused")).toBe(false);
});
it("stays paused when the video was already paused at handoff", () => {
expect(shouldResumeOnForeground(false, "playing")).toBe(false);
});
it("falls back to the captured state when native state is unknown", () => {
// Loading/seeking/idle say nothing about intent — the handoff snapshot is
// the best evidence we have, so a playing video still resumes.
expect(shouldResumeOnForeground(true, "loading")).toBe(true);
expect(shouldResumeOnForeground(true, undefined)).toBe(true);
});
});
}); });
@@ -55,3 +55,22 @@ export function shouldEnterBackgroundAudio(
export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean { export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean {
return state.active; return state.active;
} }
/**
* Whether the `<video>` should start playing again once it reloads on foreground.
*
* `wasPlaying` is what the video was doing when we handed off, but the native
* audio player kept going after that and the lockscreen/notification can pause
* it while backgrounded. The player is the authoritative source of play/pause,
* so an explicit `paused` from it overrides the handoff snapshot; anything less
* definite (loading, seeking, already-stopped, no state at all) falls back to
* the snapshot.
*
* TRACES: UR-040, UR-005 | DR-052 | UT-060
*/
export function shouldResumeOnForeground(
wasPlaying: boolean,
nativeStateKind: string | undefined
): boolean {
return wasPlaying && nativeStateKind !== "paused";
}