fix(player): return from background audio onto the episode it advanced to
An episode that ends while backgrounded in audio-only mode advances in the
backend, but player_exit_background_audio returned only a position, so the
video page reloaded the episode it was mounted with -- the previous one, at
the new episode's timestamp.
The command now returns BackgroundAudioResume { itemId, positionSeconds }.
planHandoffReturn yields "other-item" when the id differs from the mounted
one, and the player page navigates to that episode with resumeAt=<seconds>,
marking the outgoing episode watched and suppressing its stale stop report.
TRACES: UR-040, UR-023 | DR-296 | UT-265, UT-266
This commit is contained in:
@@ -497,6 +497,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-293 | Android plays the original file: ExoPlayer decodes AC-3, E-AC-3, DTS and TrueHD in software through the FFmpeg extension, so neither a download nor a stream needs the server to re-encode its audio. These are licensed codecs that Android does not ship — the ROD2-W09 tablet has a vendor DTS decoder and no AC-3/E-AC-3 at all — so the download policy (DR-171) judged audio against the webview's list and turned most films into a server transcode: generated as it is sent, no `Content-Length`, `Range` ignored, measured at ~1 MB/s and restarting from zero on every network blip, against a direct copy that moved a 910 MB episode in 94 s. The renderer is `DefaultRenderersFactory` in `EXTENSION_RENDERER_MODE_ON` (platform decoders first, FFmpeg for what they lack), and `CodecDetector` reports the extension's codecs beside `MediaCodecList`'s, so the device profile and the download policy — now `renderer_can_decode_audio`, DR-234's per-platform answer, instead of the webview's list — agree with what actually decodes. The webview video path is gone on Android: it decodes none of those codecs, so an original-file download would play there as a silent film; `webview_video_fallback` (Rust) is false on Android and the frontend neither offers the switch nor honours a stored "off". Linux keeps the webview fallback beside mpv native video, and with it the server transcode for undecodable audio. Rejected: re-encoding audio on the device after download — minutes of CPU and twice the disk per film, and it would not have helped streaming. The decoder is Jellyfin's `media3-ffmpeg-decoder` build (GPL-3.0; the distributed APK carries its terms, the source stays MIT) and must be versioned in step with media3 | Playback | UR-004, UR-071 | Done |
|
| DR-293 | Android plays the original file: ExoPlayer decodes AC-3, E-AC-3, DTS and TrueHD in software through the FFmpeg extension, so neither a download nor a stream needs the server to re-encode its audio. These are licensed codecs that Android does not ship — the ROD2-W09 tablet has a vendor DTS decoder and no AC-3/E-AC-3 at all — so the download policy (DR-171) judged audio against the webview's list and turned most films into a server transcode: generated as it is sent, no `Content-Length`, `Range` ignored, measured at ~1 MB/s and restarting from zero on every network blip, against a direct copy that moved a 910 MB episode in 94 s. The renderer is `DefaultRenderersFactory` in `EXTENSION_RENDERER_MODE_ON` (platform decoders first, FFmpeg for what they lack), and `CodecDetector` reports the extension's codecs beside `MediaCodecList`'s, so the device profile and the download policy — now `renderer_can_decode_audio`, DR-234's per-platform answer, instead of the webview's list — agree with what actually decodes. The webview video path is gone on Android: it decodes none of those codecs, so an original-file download would play there as a silent film; `webview_video_fallback` (Rust) is false on Android and the frontend neither offers the switch nor honours a stored "off". Linux keeps the webview fallback beside mpv native video, and with it the server transcode for undecodable audio. Rejected: re-encoding audio on the device after download — minutes of CPU and twice the disk per film, and it would not have helped streaming. The decoder is Jellyfin's `media3-ffmpeg-decoder` build (GPL-3.0; the distributed APK carries its terms, the source stays MIT) and must be versioned in step with media3 | Playback | UR-004, UR-071 | Done |
|
||||||
| DR-294 | A download plays with no network. Playing a downloaded item asked the server for its `PlaybackInfo` — only to read the media-source id that subtitle URLs are keyed by — and `HybridRepository::get_playback_info` went to the server alone, so offline the call retried for seven seconds, failed, and the file on disk was never opened. A completed download for the current user now answers playback info from its download row, first and regardless of reachability: the local path, direct play, and the item id as media-source id (a download names no source, so the server served its default, which carries the item's id). Next Up had the same shape — server-only — and the TV landing page loads it in one `Promise.all` with its other rows, so offline that single failure blanked the whole page with Continue Watching and Latest sitting in the cache; it now falls back to the cache when the server cannot answer. And a slow cache read is waited for, never discarded: the cache is one SQLite connection behind one mutex, so any write in progress (the catalog sync at every launch, a download finishing) pushes a read past the 100 ms fast path, and `get_items`, the library list, genres and playlist items discarded such a read, waited on the server, and offline returned its error over data on disk — "More info" on a downloaded show failed exactly so. They keep the read running (`cache_try`) and wait for it when the server fails (`settle`); the cache-only reads (search, favourites) simply await the cache | Repository | UR-002, UR-071 | Done |
|
| DR-294 | A download plays with no network. Playing a downloaded item asked the server for its `PlaybackInfo` — only to read the media-source id that subtitle URLs are keyed by — and `HybridRepository::get_playback_info` went to the server alone, so offline the call retried for seven seconds, failed, and the file on disk was never opened. A completed download for the current user now answers playback info from its download row, first and regardless of reachability: the local path, direct play, and the item id as media-source id (a download names no source, so the server served its default, which carries the item's id). Next Up had the same shape — server-only — and the TV landing page loads it in one `Promise.all` with its other rows, so offline that single failure blanked the whole page with Continue Watching and Latest sitting in the cache; it now falls back to the cache when the server cannot answer. And a slow cache read is waited for, never discarded: the cache is one SQLite connection behind one mutex, so any write in progress (the catalog sync at every launch, a download finishing) pushes a read past the 100 ms fast path, and `get_items`, the library list, genres and playlist items discarded such a read, waited on the server, and offline returned its error over data on disk — "More info" on a downloaded show failed exactly so. They keep the read running (`cache_try`) and wait for it when the server fails (`settle`); the cache-only reads (search, favourites) simply await the cache | Repository | UR-002, UR-071 | Done |
|
||||||
| DR-295 | A series page lists its episodes with one concurrent season fan-out. "More info" on Frasier took ~10 s: the page asked Rust for the episodes and for the current episode as two commands, each of which walked every season, and each walk fetched the eleven seasons one after another — so the wait was the sum of twenty-two listings, each a cache read slowed by whatever the database was writing (the catalog sync at launch measured it at ~4 s per walk). The seasons are now fetched together (`gather_season_episodes`, so the wait is the slowest season), and `repository_get_series_view` returns the episodes and the current episode from one walk, with Next Up and resume fetched alongside it | Repository | UR-062 | Done |
|
| DR-295 | A series page lists its episodes with one concurrent season fan-out. "More info" on Frasier took ~10 s: the page asked Rust for the episodes and for the current episode as two commands, each of which walked every season, and each walk fetched the eleven seasons one after another — so the wait was the sum of twenty-two listings, each a cache read slowed by whatever the database was writing (the catalog sync at launch measured it at ~4 s per walk). The seasons are now fetched together (`gather_season_episodes`, so the wait is the slowest season), and `repository_get_series_view` returns the episodes and the current episode from one walk, with Next Up and resume fetched alongside it | Repository | UR-062 | Done |
|
||||||
|
| DR-296 | Returning from background audio resumes the item the native player is actually on, not the one the video page was mounted with. An episode that ends while backgrounded advances in the backend (`advance_to_next_episode_audio_only`), but `player_exit_background_audio` returned only a position, so the webview reloaded the *previous* episode at the new episode's timestamp. The command now returns `BackgroundAudioResume { itemId, positionSeconds }` (`PlayerController::background_audio_resume`); `planHandoffReturn` yields `other-item` when the id differs from the mounted one, and the player page navigates to that episode with `resumeAt=<seconds>`, recording the outgoing episode as watched and suppressing the stale unmount stop report | Playback | UR-040, UR-023 | Done (pending device verification) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -528,7 +529,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
||||||
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
||||||
| UR-022 | IR-017 | DR-025 |
|
| UR-022 | IR-017 | DR-025 |
|
||||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263 |
|
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263, DR-296 |
|
||||||
| UR-024 | IR-010 | DR-027 |
|
| UR-024 | IR-010 | DR-027 |
|
||||||
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
||||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||||
@@ -545,7 +546,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-037 | IR-010 | DR-042 |
|
| UR-037 | IR-010 | DR-042 |
|
||||||
| UR-038 | IR-010 | DR-043 |
|
| UR-038 | IR-010 | DR-043 |
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
| UR-039 | - | DR-045, DR-046 |
|
||||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266 |
|
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266, DR-296 |
|
||||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266 |
|
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266 |
|
||||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||||
| UR-043 | IR-027 | DR-055, DR-291 |
|
| UR-043 | IR-027 | DR-055, DR-291 |
|
||||||
@@ -856,6 +857,8 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Done |
|
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Done |
|
||||||
| UT-263 | With the database held past the 100 ms fast path and the server unreachable, `get_items`, the library list, a cache-only search and cache-only favourites all answer from the cache instead of failing | DR-294 | Done |
|
| UT-263 | With the database held past the 100 ms fast path and the server unreachable, `get_items`, the library list, a cache-only search and cache-only favourites all answer from the cache instead of failing | DR-294 | Done |
|
||||||
| UT-264 | Ten seasons whose listings each take 100 ms are gathered in well under the 1 s a sequential walk takes, and a season that fails to load leaves the other nine seasons' episodes in the result | DR-295 | Done |
|
| UT-264 | Ten seasons whose listings each take 100 ms are gathered in well under the 1 s a sequential walk takes, and a season that fails to load leaves the other nine seasons' episodes in the result | DR-295 | Done |
|
||||||
|
| UT-265 | `planHandoffReturn` switches to the item the backend advanced to while backgrounded, and reloads in place when the backend is still on the mounted item or reports none | DR-296 | Done |
|
||||||
|
| UT-266 | After a background-audio episode advance, the controller's resume point names the new episode and carries no base from the previous one | DR-296 | Done |
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|||||||
@@ -939,12 +939,17 @@ pub async fn player_background_action(
|
|||||||
Ok(action)
|
Ok(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
|
/// Returns the item the native player is on and its absolute position. The
|
||||||
|
/// item matters: an episode that ended while backgrounded has already advanced
|
||||||
|
/// in the backend, so reloading the video the webview was mounted with would
|
||||||
|
/// bring back the previous episode. (DR-296)
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_exit_background_audio(
|
pub async fn player_exit_background_audio(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
) -> Result<f64, String> {
|
) -> Result<crate::player::BackgroundAudioResume, String> {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
|
||||||
// Read the position BEFORE clearing either base. The position tick applies the
|
// Read the position BEFORE clearing either base. The position tick applies the
|
||||||
@@ -954,23 +959,23 @@ pub async fn player_exit_background_audio(
|
|||||||
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
||||||
// (DR-159)
|
// (DR-159)
|
||||||
//
|
//
|
||||||
// `absolute_position` rather than `position`, because a tick that has not
|
// `background_audio_resume` reads `absolute_position` rather than `position`, because a tick that has not
|
||||||
// landed *yet* is the same hazard from the other side: returning to the
|
// landed *yet* is the same hazard from the other side: returning to the
|
||||||
// foreground while the audio-only transcode is still opening read 0.0, and
|
// foreground while the audio-only transcode is still opening read 0.0, and
|
||||||
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
|
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
|
||||||
// beginning. Flooring at the handoff base cannot overshoot: the stream is
|
// beginning. Flooring at the handoff base cannot overshoot: the stream is
|
||||||
// physically incapable of being behind its own starting point. (DR-178)
|
// physically incapable of being behind its own starting point. (DR-178)
|
||||||
let absolute = controller.absolute_position();
|
let resume = controller.background_audio_resume();
|
||||||
|
|
||||||
// Now safe to tear the handoff down, native side first.
|
// Now safe to tear the handoff down, native side first.
|
||||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||||
controller.exit_background_audio();
|
controller.exit_background_audio();
|
||||||
controller.stop().map_err(|e| e.to_string())?;
|
controller.stop().map_err(|e| e.to_string())?;
|
||||||
info!(
|
info!(
|
||||||
"player_exit_background_audio: resuming the video at {:.1}s",
|
"player_exit_background_audio: resuming {:?} at {:.1}s",
|
||||||
absolute
|
resume.item_id, resume.position_seconds
|
||||||
);
|
);
|
||||||
Ok(absolute)
|
Ok(resume)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play a queue of media items
|
/// Play a queue of media items
|
||||||
|
|||||||
@@ -177,6 +177,20 @@ fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
|
|||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
||||||
|
|
||||||
|
/// Where playback stands when a background-audio handoff returns to the
|
||||||
|
/// foreground. See [`PlayerController::background_audio_resume`].
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-023 | DR-296
|
||||||
|
#[derive(specta::Type, Debug, Clone, PartialEq, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BackgroundAudioResume {
|
||||||
|
/// Item the native audio player is on — `None` if the queue emptied (e.g.
|
||||||
|
/// the sleep timer stopped playback while backgrounded).
|
||||||
|
pub item_id: Option<String>,
|
||||||
|
/// Absolute position in that item, in seconds.
|
||||||
|
pub position_seconds: f64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Metadata for the lockscreen / media notification.
|
/// Metadata for the lockscreen / media notification.
|
||||||
///
|
///
|
||||||
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
|
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
|
||||||
@@ -1728,6 +1742,23 @@ impl PlayerController {
|
|||||||
/// reports are honoured from here on.
|
/// reports are honoured from here on.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-040, UR-005 | DR-052, DR-097
|
/// TRACES: UR-040, UR-005 | DR-052, DR-097
|
||||||
|
/// Where the foreground should pick up from a background-audio handoff: the
|
||||||
|
/// item the native player is on now, and its absolute position.
|
||||||
|
///
|
||||||
|
/// The item is not necessarily the one the handoff started from — an episode
|
||||||
|
/// that ends while backgrounded advances in the backend
|
||||||
|
/// (`advance_to_next_episode_audio_only`) — so the webview must not assume
|
||||||
|
/// it can reload the video it was mounted with. Read-only: call it before
|
||||||
|
/// `exit_background_audio` clears the base the position depends on.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-023 | DR-296 | UT-266
|
||||||
|
pub fn background_audio_resume(&self) -> BackgroundAudioResume {
|
||||||
|
BackgroundAudioResume {
|
||||||
|
item_id: self.queue.lock_safe().current().map(|item| item.id.clone()),
|
||||||
|
position_seconds: self.absolute_position(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn exit_background_audio(&self) -> f64 {
|
pub fn exit_background_audio(&self) -> f64 {
|
||||||
*self.background_audio_active.lock_safe() = false;
|
*self.background_audio_active.lock_safe() = false;
|
||||||
self.take_background_audio_base()
|
self.take_background_audio_base()
|
||||||
@@ -4411,6 +4442,52 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returning to the foreground after the backend advanced to the next episode
|
||||||
|
/// must bring back THAT episode, not the one the handoff started from.
|
||||||
|
///
|
||||||
|
/// The return used to carry only a position; the webview reloaded the video it
|
||||||
|
/// was mounted with, so the user came back to the previous episode — at the new
|
||||||
|
/// episode's timestamp. The resume point therefore names the item the native
|
||||||
|
/// player is actually on.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040 | DR-296 | UT-266
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_background_audio_resume_names_the_advanced_episode() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||||
|
|
||||||
|
let episode = MediaItem {
|
||||||
|
transport: None,
|
||||||
|
id: "ep1".to_string(),
|
||||||
|
item_type: Some("Episode".to_string()),
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
series_id: Some("series1".to_string()),
|
||||||
|
..create_test_items(1).remove(0)
|
||||||
|
};
|
||||||
|
controller.play_queue(vec![episode], 0).unwrap();
|
||||||
|
controller.enter_background_audio(1200.0);
|
||||||
|
|
||||||
|
let before = controller.background_audio_resume();
|
||||||
|
assert_eq!(before.item_id.as_deref(), Some("ep1"));
|
||||||
|
assert_eq!(before.position_seconds, 1200.0);
|
||||||
|
|
||||||
|
controller
|
||||||
|
.advance_to_next_episode_audio_only("ep2")
|
||||||
|
.await
|
||||||
|
.expect("advance should succeed");
|
||||||
|
|
||||||
|
let after = controller.background_audio_resume();
|
||||||
|
assert_eq!(
|
||||||
|
after.item_id.as_deref(),
|
||||||
|
Some("ep2"),
|
||||||
|
"the foreground must resume the episode the backend advanced to"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
after.position_seconds < 1200.0,
|
||||||
|
"the previous episode's handoff base must not leak into the new one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A background audio-only episode must advance IN THE BACKEND when the
|
/// A background audio-only episode must advance IN THE BACKEND when the
|
||||||
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
|
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
|
||||||
/// countdown the frontend is supposed to act on.
|
/// countdown the frontend is supposed to act on.
|
||||||
|
|||||||
+23
-2
@@ -66,9 +66,14 @@ async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number)
|
|||||||
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
|
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* TRACES: UR-040 | DR-052 | UT-061, IT-013
|
* Returns the item the native player is on and its absolute position. The
|
||||||
|
* item matters: an episode that ended while backgrounded has already advanced
|
||||||
|
* in the backend, so reloading the video the webview was mounted with would
|
||||||
|
* bring back the previous episode. (DR-296)
|
||||||
|
*
|
||||||
|
* TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
|
||||||
*/
|
*/
|
||||||
async playerExitBackgroundAudio() : Promise<number> {
|
async playerExitBackgroundAudio() : Promise<BackgroundAudioResume> {
|
||||||
return await TAURI_INVOKE("player_exit_background_audio");
|
return await TAURI_INVOKE("player_exit_background_audio");
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -2190,6 +2195,22 @@ export type BackgroundAction =
|
|||||||
* Stop making sound. The user did not ask for background playback.
|
* Stop making sound. The user did not ask for background playback.
|
||||||
*/
|
*/
|
||||||
"pause"
|
"pause"
|
||||||
|
/**
|
||||||
|
* Where playback stands when a background-audio handoff returns to the
|
||||||
|
* foreground. See [`PlayerController::background_audio_resume`].
|
||||||
|
*
|
||||||
|
* TRACES: UR-040, UR-023 | DR-296
|
||||||
|
*/
|
||||||
|
export type BackgroundAudioResume = {
|
||||||
|
/**
|
||||||
|
* Item the native audio player is on — `None` if the queue emptied (e.g.
|
||||||
|
* the sleep timer stopped playback while backgrounded).
|
||||||
|
*/
|
||||||
|
itemId: string | null;
|
||||||
|
/**
|
||||||
|
* Absolute position in that item, in seconds.
|
||||||
|
*/
|
||||||
|
positionSeconds: number }
|
||||||
/**
|
/**
|
||||||
* Smart caching configuration
|
* Smart caching configuration
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -120,6 +120,12 @@
|
|||||||
onNext?: () => void; // Called when user clicks next episode button
|
onNext?: () => void; // Called when user clicks next episode button
|
||||||
hasNext?: boolean; // Whether there is a next episode available
|
hasNext?: boolean; // Whether there is a next episode available
|
||||||
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
|
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
|
||||||
|
/**
|
||||||
|
* Returning from background audio found the backend on a different item
|
||||||
|
* (the episode advanced while backgrounded). The page switches to it; this
|
||||||
|
* player must not reload the one it was mounted with. (DR-296)
|
||||||
|
*/
|
||||||
|
onResumeOtherItem?: (itemId: string, positionSeconds: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -137,6 +143,7 @@
|
|||||||
onNext,
|
onNext,
|
||||||
hasNext = false,
|
hasNext = false,
|
||||||
isLive = false,
|
isLive = false,
|
||||||
|
onResumeOtherItem,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// The id this player instance reports progress against. Snapshotted from the
|
// The id this player instance reports progress against. Snapshotted from the
|
||||||
@@ -2035,9 +2042,12 @@
|
|||||||
const wasPlaying = shouldResumeOnForeground(handoffState.wasPlaying, get(playerState).kind);
|
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).
|
// The item the native audio is on and the absolute position it reached
|
||||||
const pos = await commands.playerExitBackgroundAudio();
|
// (base offset applied in Rust). The item may not be `media`: an episode
|
||||||
log.debug("Returning from background audio at:", pos.toFixed(1));
|
// that ended while backgrounded has already advanced in the backend.
|
||||||
|
const resume = await commands.playerExitBackgroundAudio();
|
||||||
|
const pos = resume.positionSeconds;
|
||||||
|
log.debug("Returning from background audio at:", pos.toFixed(1), "item:", resume.itemId);
|
||||||
|
|
||||||
isMediaReady = false;
|
isMediaReady = false;
|
||||||
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
|
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
|
||||||
@@ -2056,8 +2066,18 @@
|
|||||||
position: pos,
|
position: pos,
|
||||||
wasPlaying,
|
wasPlaying,
|
||||||
nativeStateKind: get(playerState).kind,
|
nativeStateKind: get(playerState).kind,
|
||||||
|
mountedItemId: media?.id ?? null,
|
||||||
|
resumeItemId: resume.itemId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reloading `media` here would bring back the previous episode at the new
|
||||||
|
// one's timestamp. Hand the switch to the page instead.
|
||||||
|
// TRACES: UR-040, UR-023 | DR-296
|
||||||
|
if (plan.target === "other-item" && plan.itemId) {
|
||||||
|
onResumeOtherItem?.(plan.itemId, plan.position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
pendingForegroundPlay = plan.shouldPlay;
|
pendingForegroundPlay = plan.shouldPlay;
|
||||||
|
|
||||||
// Determine the target stream + how the element/offset should be
|
// Determine the target stream + how the element/offset should be
|
||||||
|
|||||||
@@ -138,6 +138,51 @@ describe("backgroundAudioHandoff", () => {
|
|||||||
});
|
});
|
||||||
expect(plan.position).toBe(0);
|
expect(plan.position).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An episode that ends while backgrounded advances in the backend. Reloading
|
||||||
|
// the video the player was mounted with brought back the PREVIOUS episode,
|
||||||
|
// at the new episode's timestamp.
|
||||||
|
//
|
||||||
|
// TRACES: UR-040, UR-023 | DR-296 | UT-265
|
||||||
|
it("switches to the item the backend advanced to", () => {
|
||||||
|
const plan = planHandoffReturn({
|
||||||
|
useHtml5Element: false,
|
||||||
|
position: 95,
|
||||||
|
wasPlaying: true,
|
||||||
|
nativeStateKind: "playing",
|
||||||
|
mountedItemId: "ep1",
|
||||||
|
resumeItemId: "ep2",
|
||||||
|
});
|
||||||
|
expect(plan.target).toBe("other-item");
|
||||||
|
expect(plan.itemId).toBe("ep2");
|
||||||
|
expect(plan.position).toBe(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads in place when the backend is still on the mounted item", () => {
|
||||||
|
const plan = planHandoffReturn({
|
||||||
|
useHtml5Element: true,
|
||||||
|
position: 95,
|
||||||
|
wasPlaying: true,
|
||||||
|
nativeStateKind: "playing",
|
||||||
|
mountedItemId: "ep1",
|
||||||
|
resumeItemId: "ep1",
|
||||||
|
});
|
||||||
|
expect(plan.target).toBe("html5-element");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads in place when the backend reports no item", () => {
|
||||||
|
// Queue emptied while backgrounded (e.g. the sleep timer): there is no
|
||||||
|
// other item to go to, so the mounted one is the best we have.
|
||||||
|
const plan = planHandoffReturn({
|
||||||
|
useHtml5Element: false,
|
||||||
|
position: 95,
|
||||||
|
wasPlaying: false,
|
||||||
|
nativeStateKind: undefined,
|
||||||
|
mountedItemId: "ep1",
|
||||||
|
resumeItemId: null,
|
||||||
|
});
|
||||||
|
expect(plan.target).toBe("native-backend");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,14 @@ export function shouldResumeOnForeground(
|
|||||||
|
|
||||||
/** What has to be restarted to put picture back on screen, and how. */
|
/** What has to be restarted to put picture back on screen, and how. */
|
||||||
export interface HandoffReturn {
|
export interface HandoffReturn {
|
||||||
/** Which renderer must be brought back. */
|
/**
|
||||||
target: "html5-element" | "native-backend";
|
* Which renderer must be brought back — or `other-item` when the backend is
|
||||||
|
* no longer on the item this player was mounted with, so the player must
|
||||||
|
* switch to `itemId` instead of reloading itself.
|
||||||
|
*/
|
||||||
|
target: "html5-element" | "native-backend" | "other-item";
|
||||||
|
/** The item to switch to; set only for `other-item`. */
|
||||||
|
itemId?: string;
|
||||||
/** Absolute position the background audio reached. */
|
/** Absolute position the background audio reached. */
|
||||||
position: number;
|
position: number;
|
||||||
/** Whether playback should be running once it is back. */
|
/** Whether playback should be running once it is back. */
|
||||||
@@ -107,18 +113,31 @@ export interface HandoffReturn {
|
|||||||
* `shouldPlay` folds in [shouldResumeOnForeground], so a lockscreen pause during
|
* `shouldPlay` folds in [shouldResumeOnForeground], so a lockscreen pause during
|
||||||
* the handoff still wins over the snapshot taken on the way out.
|
* the handoff still wins over the snapshot taken on the way out.
|
||||||
*
|
*
|
||||||
* TRACES: UR-040, UR-003 | DR-196 | UT-060
|
* An episode that ends while backgrounded advances in the backend, so the item
|
||||||
|
* the native player returns on (`resumeItemId`) can differ from the one this
|
||||||
|
* player was mounted with. Reloading the mounted one brought back the previous
|
||||||
|
* episode at the new one's timestamp; in that case the plan is `other-item`.
|
||||||
|
* A missing `resumeItemId` (queue emptied) keeps the in-place reload.
|
||||||
|
*
|
||||||
|
* TRACES: UR-040, UR-003, UR-023 | DR-196, DR-296 | UT-060, UT-265
|
||||||
*/
|
*/
|
||||||
export function planHandoffReturn(opts: {
|
export function planHandoffReturn(opts: {
|
||||||
useHtml5Element: boolean;
|
useHtml5Element: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
wasPlaying: boolean;
|
wasPlaying: boolean;
|
||||||
nativeStateKind: string | undefined;
|
nativeStateKind: string | undefined;
|
||||||
|
mountedItemId?: string | null;
|
||||||
|
resumeItemId?: string | null;
|
||||||
}): HandoffReturn {
|
}): HandoffReturn {
|
||||||
|
const position = opts.position > 0 ? opts.position : 0;
|
||||||
|
const shouldPlay = shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind);
|
||||||
|
if (opts.resumeItemId && opts.resumeItemId !== opts.mountedItemId) {
|
||||||
|
return { target: "other-item", itemId: opts.resumeItemId, position, shouldPlay };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
target: opts.useHtml5Element ? "html5-element" : "native-backend",
|
target: opts.useHtml5Element ? "html5-element" : "native-backend",
|
||||||
position: opts.position > 0 ? opts.position : 0,
|
position,
|
||||||
shouldPlay: shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind),
|
shouldPlay,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ describe("background-audio player commands (param naming)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("player_exit_background_audio takes no params and returns a position", async () => {
|
it("player_exit_background_audio takes no params and returns the resume point", async () => {
|
||||||
(invoke as any).mockResolvedValueOnce(193.5);
|
(invoke as any).mockResolvedValueOnce({ itemId: "ep2", positionSeconds: 193.5 });
|
||||||
|
|
||||||
const pos = await commands.playerExitBackgroundAudio();
|
const resume = await commands.playerExitBackgroundAudio();
|
||||||
|
|
||||||
expect(pos).toBe(193.5);
|
expect(resume).toEqual({ itemId: "ep2", positionSeconds: 193.5 });
|
||||||
expect(invoke).toHaveBeenCalledWith("player_exit_background_audio");
|
expect(invoke).toHaveBeenCalledWith("player_exit_background_audio");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
// When advancing to a next episode we always start from the beginning,
|
// When advancing to a next episode we always start from the beginning,
|
||||||
// even if the episode was previously started or watched.
|
// even if the episode was previously started or watched.
|
||||||
const restartParam = $derived($page.url.searchParams.get("restart") === "true");
|
const restartParam = $derived($page.url.searchParams.get("restart") === "true");
|
||||||
|
// Explicit start position in seconds — set when returning from background
|
||||||
|
// audio onto an episode the backend advanced to (DR-296).
|
||||||
|
const resumeAtParam = $derived(Number($page.url.searchParams.get("resumeAt")) || 0);
|
||||||
|
|
||||||
// Derive playback context from URL query params
|
// Derive playback context from URL query params
|
||||||
const playbackContext = $derived.by(() => {
|
const playbackContext = $derived.by(() => {
|
||||||
@@ -121,6 +124,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = itemId;
|
const id = itemId;
|
||||||
const restart = restartParam;
|
const restart = restartParam;
|
||||||
|
const resumeAt = resumeAtParam;
|
||||||
if (id && id !== loadedItemId) {
|
if (id && id !== loadedItemId) {
|
||||||
autoPlayLog.debug(
|
autoPlayLog.debug(
|
||||||
"$effect triggered: loading new item",
|
"$effect triggered: loading new item",
|
||||||
@@ -132,8 +136,12 @@
|
|||||||
);
|
);
|
||||||
// restart=true (advancing to next episode) forces start-from-beginning,
|
// restart=true (advancing to next episode) forces start-from-beginning,
|
||||||
// bypassing the resume-progress check.
|
// bypassing the resume-progress check.
|
||||||
|
if (resumeAt > 0) {
|
||||||
|
loadAndPlay(id, resumeAt);
|
||||||
|
} else {
|
||||||
loadAndPlay(id, restart ? 0 : undefined, restart);
|
loadAndPlay(id, restart ? 0 : undefined, restart);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update currentMedia when queue item changes (for skip/next/previous)
|
// Update currentMedia when queue item changes (for skip/next/previous)
|
||||||
@@ -797,6 +805,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background audio advanced to another episode; show that one where the audio
|
||||||
|
* left off, rather than the episode this page was opened on.
|
||||||
|
*
|
||||||
|
* The outgoing episode was played out (the backend advances only past its
|
||||||
|
* end), so it is recorded as watched and the VideoPlayer's unmount stop
|
||||||
|
* report — which would carry the stale handoff position — is suppressed, as
|
||||||
|
* for a manual skip.
|
||||||
|
*
|
||||||
|
* TRACES: UR-040, UR-023 | DR-296
|
||||||
|
*/
|
||||||
|
function handleResumeOtherItem(nextId: string, positionSeconds: number) {
|
||||||
|
void reportSkippedEpisode(currentMedia?.id ?? itemId ?? null);
|
||||||
|
const start = positionSeconds > 0 ? `resumeAt=${Math.floor(positionSeconds)}` : "restart=true";
|
||||||
|
goto(`/player/${nextId}?${start}`, { replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
function handleSkipToNextEpisode() {
|
function handleSkipToNextEpisode() {
|
||||||
if (nextEpisode) {
|
if (nextEpisode) {
|
||||||
// Skipping means "I'm done with this one" — record the outgoing episode as
|
// Skipping means "I'm done with this one" — record the outgoing episode as
|
||||||
@@ -889,6 +914,7 @@
|
|||||||
onEnded={handleVideoEnded}
|
onEnded={handleVideoEnded}
|
||||||
hasNext={nextEpisode !== null}
|
hasNext={nextEpisode !== null}
|
||||||
onNext={handleSkipToNextEpisode}
|
onNext={handleSkipToNextEpisode}
|
||||||
|
onResumeOtherItem={handleResumeOtherItem}
|
||||||
/>
|
/>
|
||||||
<NextEpisodePopup />
|
<NextEpisodePopup />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Reference in New Issue
Block a user