Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef6180776 | ||
|
|
878ac5fa59 |
@@ -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
|
||||
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
|
||||
|
||||
### ✨ Features
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
|
||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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.
|
||||
// The controller owns it so a backend-driven advance to the next episode
|
||||
// clears it along with the stream it described.
|
||||
controller.set_background_audio_base(position_seconds);
|
||||
controller.enter_background_audio(position_seconds);
|
||||
controller
|
||||
.play_item(media_item)
|
||||
.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 absolute position to resume the video at. Zero after a backend-driven
|
||||
// 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
|
||||
// re-entrant call (deadlock discipline, CLAUDE.md).
|
||||
let relative = controller.position();
|
||||
|
||||
@@ -179,6 +179,17 @@ pub struct PlayerController {
|
||||
// TRACES: UR-040 | DR-052
|
||||
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.
|
||||
//
|
||||
// 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)),
|
||||
autoplay_episode_count: Arc::new(Mutex::new(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())),
|
||||
html5_playing: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
@@ -1000,6 +1012,14 @@ impl PlayerController {
|
||||
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
||||
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
||||
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
|
||||
// webview-rendered media, and what transport decisions read (DR-097).
|
||||
// "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
|
||||
/// position updates (the adapter is expected to throttle to ~250ms like MPV).
|
||||
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() {
|
||||
emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
|
||||
}
|
||||
@@ -1035,6 +1060,10 @@ impl PlayerController {
|
||||
/// Report that the HTML5 <video> element finished loading and knows its
|
||||
/// duration. Mirrors the native `MediaLoaded` event.
|
||||
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() {
|
||||
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
||||
}
|
||||
@@ -1234,6 +1263,42 @@ impl PlayerController {
|
||||
*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.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-052
|
||||
@@ -2002,6 +2067,84 @@ mod tests {
|
||||
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]
|
||||
fn test_html5_stopped_report_releases_transport_to_native_backend() {
|
||||
// When webview video goes away, transport must fall back to the native
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
@@ -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 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { JRayActor } from "$lib/api/bindings";
|
||||
@@ -14,7 +15,7 @@
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
import { videoFitClass } from "./videoFit";
|
||||
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 { playerController } from "$lib/player";
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
||||
@@ -42,6 +43,7 @@
|
||||
initialHandoffState,
|
||||
shouldEnterBackgroundAudio,
|
||||
shouldExitBackgroundAudio,
|
||||
shouldResumeOnForeground,
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
|
||||
@@ -1333,7 +1335,11 @@
|
||||
// the position native reached, and restore play/pause.
|
||||
async function exitBackgroundAudioHandoff() {
|
||||
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 };
|
||||
try {
|
||||
// Absolute position the native audio reached (base offset applied in Rust).
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
initialHandoffState,
|
||||
shouldEnterBackgroundAudio,
|
||||
shouldExitBackgroundAudio,
|
||||
shouldResumeOnForeground,
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
|
||||
@@ -58,4 +59,27 @@ describe("backgroundAudioHandoff", () => {
|
||||
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 {
|
||||
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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user