fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements. UI - Pages no longer inherit the previous page's scroll position (DR-156, UR-072). The shell keeps its scrollers alive across navigation by design, so the element never remounts and its scrollTop survived the route change; SvelteKit restores window scroll, which this app never uses. ScrollMemory records the offset per route and per container: forward moves reset to the top, Back restores where the route was left. - Season header stacks on narrow screens, and the title span gets min-w-0 so it actually truncates instead of overflowing under the action buttons. - Favourites gets a labelled tile at the head of the library grid rather than only an unlabelled heart icon in the header. Playback - Full-screen video on Android hides the system bars (DR-157, UR-066). requestFullscreen() cannot touch the Activity window from inside a WebView, so the control did nothing visible while the bars stayed painted over the video. ImmersiveModeBridge hides them, restored on exit, Escape and teardown. - Background-audio handoff stops leaking its relative timeline (DR-159). background_audio_base was a display-only correction applied in two places while progress reports to Jellyfin, the frontend and media3's own seeks all worked in the relative timeline treating it as absolute — each crossing losing exactly `base` seconds. The conversion now happens once, in the position tick, and inbound seeks resolve through seek_absolute, which re-opens the stream at the requested position because the handoff transcode cannot seek. - Picture-in-picture works on the path that actually plays video (DR-160). canEnterPip demanded a native ExoPlayer surface, but that path is behind a flag defaulting to off, so PiP could never engage. It now accepts the WebView <video> too, keeping the WebView visible and routing play/pause to the element. - Native video is now the default so PiP has a real surface (DR-161). The scrub-regression tests pinned the flag-off path implicitly; they now mock it off explicitly. The native scrub/seek path is not covered by the suite and needs device verification. Watched state - Watched toggle on the episode row, season header, series and movie hero, and the Episode Focus View (DR-158, UR-073). Both backend halves already existed with no caller. storage_set_watched covers a container's episodes so the toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the missing direction. Release - Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002 under an earlier minor*1000 scheme, but the current minor*100 formula yields 1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from it was an un-installable downgrade for anyone already on v0.5.2. Widened to 10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003). - Bump to 0.5.3.
This commit is contained in:
@@ -770,22 +770,23 @@ pub async fn player_enter_background_audio(
|
||||
pub async fn player_exit_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
) -> Result<f64, String> {
|
||||
// Back to foreground playback: the lockscreen scrubber is absolute again.
|
||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
// 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.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();
|
||||
|
||||
// Read the position BEFORE clearing either base. The position tick applies the
|
||||
// base natively, so a tick landing between "base cleared" and "position read"
|
||||
// would hand back a relative position — the whole bug, reintroduced at the one
|
||||
// moment it matters most. Capturing into a `let` before stop() is also the
|
||||
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
||||
// (DR-159)
|
||||
let absolute = controller.position();
|
||||
|
||||
// Now safe to tear the handoff down, native side first.
|
||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||
controller.exit_background_audio();
|
||||
controller.stop().map_err(|e| e.to_string())?;
|
||||
let absolute = base + relative;
|
||||
info!(
|
||||
"player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s",
|
||||
base, relative, absolute
|
||||
"player_exit_background_audio: resuming the video at {:.1}s",
|
||||
absolute
|
||||
);
|
||||
Ok(absolute)
|
||||
}
|
||||
@@ -1207,9 +1208,12 @@ pub async fn player_seek(
|
||||
let position_ticks = (position * 10_000_000.0) as i64;
|
||||
client.session_seek(session_id, position_ticks).await?;
|
||||
} else {
|
||||
// Local playback
|
||||
// Local playback. seek_absolute, not seek: the position came from the UI,
|
||||
// which shows the whole item, so during a background-audio handoff it has
|
||||
// to be resolved against the episode's timeline rather than the handoff
|
||||
// stream's. (DR-159)
|
||||
let controller = player.0.lock().await;
|
||||
controller.seek(position).map_err(|e| e.to_string())?;
|
||||
controller.seek_absolute(position).await?;
|
||||
}
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
|
||||
@@ -867,6 +867,86 @@ pub async fn storage_mark_played(
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the watched flag locally for an item **and everything inside it**.
|
||||
///
|
||||
/// This backs the watched toggle, and is deliberately separate from
|
||||
/// [`storage_mark_played`] — which reports a single track/episode finishing and
|
||||
/// increments `play_count` — because the toggle has two directions and applies
|
||||
/// to containers.
|
||||
///
|
||||
/// The recursion is what makes the toggle honest offline. Jellyfin applies
|
||||
/// `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
|
||||
/// online the server fixes up the children on the next read; with no server to
|
||||
/// ask, marking a season watched would otherwise tick the season and leave every
|
||||
/// episode inside it unwatched. Targets are drawn from `items` by the same link
|
||||
/// columns the rest of the offline layer uses, so an id that is not cached
|
||||
/// selects nothing and the statement is a no-op rather than a foreign-key error.
|
||||
///
|
||||
/// Un-marking clears the resume position too, matching the server, so an item
|
||||
/// un-marked offline does not come back offering to resume from a position it is
|
||||
/// no longer meant to have.
|
||||
///
|
||||
/// `pending_sync = 1` hands the rows to the sync drain.
|
||||
///
|
||||
/// TRACES: UR-073 | DR-158
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn storage_set_watched(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
item_id: String,
|
||||
watched: bool,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// The item itself plus its descendants: a season's episodes reach it by
|
||||
// season_id, a series' by series_id, its seasons by parent_id, an album's
|
||||
// tracks by album_id.
|
||||
let targets = "SELECT id FROM items
|
||||
WHERE id = ? OR parent_id = ? OR album_id = ?
|
||||
OR season_id = ? OR series_id = ?";
|
||||
|
||||
let sql = if watched {
|
||||
format!(
|
||||
"INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
|
||||
SELECT ?, id, 1, 1, CURRENT_TIMESTAMP, 1 FROM ({targets})
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_played = 1,
|
||||
play_count = MAX(user_data.play_count, 1),
|
||||
last_played_at = CURRENT_TIMESTAMP,
|
||||
pending_sync = 1"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"INSERT INTO user_data (user_id, item_id, is_played, play_count, playback_position_ticks, pending_sync)
|
||||
SELECT ?, id, 0, 0, 0, 1 FROM ({targets})
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_played = 0,
|
||||
play_count = 0,
|
||||
playback_position_ticks = 0,
|
||||
pending_sync = 1"
|
||||
)
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
sql,
|
||||
vec![
|
||||
QueryParam::String(user_id),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get playback progress for an item
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -52,6 +52,12 @@ pub enum QueuedOp {
|
||||
MarkPlayed {
|
||||
item_id: String,
|
||||
},
|
||||
/// The inverse, queued by the watched toggle. Pushes as `clear_watch_history`
|
||||
/// (Jellyfin's mark-unplayed), which also zeroes the resume position — so an
|
||||
/// item un-marked offline does not come back carrying a stale position.
|
||||
MarkUnplayed {
|
||||
item_id: String,
|
||||
},
|
||||
/// Legacy rows only — live favourite toggles drain via `user_data.pending_sync`
|
||||
/// (DR-120). Supported so a row written by an older build still lands.
|
||||
Favorite {
|
||||
@@ -105,6 +111,7 @@ pub fn parse_queued_op(
|
||||
position_ticks: ticks(),
|
||||
}),
|
||||
"mark_played" => Ok(QueuedOp::MarkPlayed { item_id }),
|
||||
"mark_unplayed" => Ok(QueuedOp::MarkUnplayed { item_id }),
|
||||
"mark_favorite" => Ok(QueuedOp::Favorite {
|
||||
item_id,
|
||||
is_favorite: true,
|
||||
@@ -137,6 +144,7 @@ impl<T: MediaRepository + ?Sized> SyncSink for T {
|
||||
position_ticks,
|
||||
} => self.report_playback_stopped(item_id, *position_ticks).await,
|
||||
QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await,
|
||||
QueuedOp::MarkUnplayed { item_id } => self.clear_watch_history(item_id).await,
|
||||
QueuedOp::Favorite {
|
||||
item_id,
|
||||
is_favorite,
|
||||
@@ -1031,4 +1039,55 @@ mod tests {
|
||||
assert!(parse_queued_op("mark_played", None, None).is_err());
|
||||
assert!(parse_queued_op("teleport", Some("ep1"), None).is_err());
|
||||
}
|
||||
|
||||
/// Un-marking watched queues like marking watched does, so the toggle works
|
||||
/// in both directions while the server is unreachable rather than only one.
|
||||
///
|
||||
/// TRACES: UR-073 | DR-158 | UT-154
|
||||
#[test]
|
||||
fn test_parse_accepts_mark_unplayed() {
|
||||
assert_eq!(
|
||||
parse_queued_op("mark_unplayed", Some("ep1"), None).unwrap(),
|
||||
QueuedOp::MarkUnplayed {
|
||||
item_id: "ep1".to_string()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(parse_queued_op("mark_unplayed", None, None).is_err());
|
||||
}
|
||||
|
||||
/// The queued un-mark reaches the server as `clear_watch_history` — Jellyfin's
|
||||
/// mark-unplayed, which also zeroes the resume position, so a series returns
|
||||
/// to "never watched" rather than keeping a stale position.
|
||||
///
|
||||
/// TRACES: UR-073 | DR-158 | UT-154
|
||||
#[tokio::test]
|
||||
async fn test_drain_pushes_mark_unplayed() {
|
||||
let db = test_db();
|
||||
seed(
|
||||
&db,
|
||||
&[(
|
||||
"u1",
|
||||
"mark_unplayed",
|
||||
"ep9",
|
||||
None,
|
||||
"pending",
|
||||
0,
|
||||
"2026-08-01T10:00:00Z",
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let sink = RecordingSink::new();
|
||||
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sink.calls(),
|
||||
vec![QueuedOp::MarkUnplayed {
|
||||
item_id: "ep9".to_string()
|
||||
}],
|
||||
);
|
||||
assert_eq!(report.pushed, 1);
|
||||
assert_eq!(report.remaining, 0);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-7
@@ -265,6 +265,7 @@ use commands::{
|
||||
storage_save_user,
|
||||
storage_search_items,
|
||||
storage_set_active_user,
|
||||
storage_set_watched,
|
||||
storage_toggle_favorite,
|
||||
storage_update_playback_context,
|
||||
storage_update_playback_progress,
|
||||
@@ -424,6 +425,28 @@ impl MediaSessionHandler {
|
||||
|
||||
/// Drive the local player for a transport command.
|
||||
fn handle_local_command(&self, command: &str) {
|
||||
// A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
|
||||
// whole episode — and resolving it during a background-audio handoff means
|
||||
// re-opening the stream, which is async. So it runs on the runtime and,
|
||||
// critically, is handled *before* the blocking lock below: taking that
|
||||
// guard and then spawning a task that waits for the same mutex would
|
||||
// deadlock the media session. (DR-159)
|
||||
if let Some(raw) = command.strip_prefix("seek:") {
|
||||
match raw.parse::<f64>() {
|
||||
Ok(position) => {
|
||||
let player = self.player.clone();
|
||||
tokio::spawn(async move {
|
||||
let controller = player.lock().await;
|
||||
if let Err(e) = controller.seek_absolute(position).await {
|
||||
error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Use blocking_lock since this is called from a non-async JNI callback
|
||||
let controller = self.player.blocking_lock();
|
||||
|
||||
@@ -433,13 +456,6 @@ impl MediaSessionHandler {
|
||||
"next" => controller.next(),
|
||||
"previous" => controller.previous(),
|
||||
"stop" => controller.stop(),
|
||||
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
|
||||
Ok(pos) => controller.seek(pos),
|
||||
Err(_) => {
|
||||
warn!("[MediaSession] Bad seek command: {}", command);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
warn!("[MediaSession] Unknown command: {}", command);
|
||||
Ok(())
|
||||
@@ -789,6 +805,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
storage_update_playback_progress,
|
||||
storage_update_playback_context,
|
||||
storage_mark_played,
|
||||
storage_set_watched,
|
||||
storage_get_playback_progress,
|
||||
storage_mark_synced,
|
||||
storage_toggle_favorite,
|
||||
|
||||
@@ -1474,9 +1474,11 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the base position offset (seconds) on the lockscreen MediaSession.
|
||||
/// Set the background-audio handoff base (seconds) on the playback service.
|
||||
///
|
||||
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
|
||||
/// The service holds it for `JellyTauPlayer`'s position tick, which is the one
|
||||
/// place the relative handoff timeline is converted to the episode's own — see
|
||||
/// DR-159. Calls `JellyTauPlaybackService.setHandoffBase(double)`. No-op if the
|
||||
/// service isn't running yet, so it's safe to call unconditionally.
|
||||
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||
@@ -1525,11 +1527,11 @@ pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||
|
||||
env.call_method(
|
||||
&service_obj,
|
||||
"setPositionOffset",
|
||||
"setHandoffBase",
|
||||
"(D)V",
|
||||
&[JValue::Double(offset_seconds)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set position offset: {}", e))?;
|
||||
.map_err(|e| format!("Failed to set handoff base: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+130
-11
@@ -754,12 +754,50 @@ impl PlayerController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Seek to a position in seconds
|
||||
/// Seek to a position in seconds, **on the player's own timeline**.
|
||||
///
|
||||
/// During a background-audio handoff that timeline is relative to the handoff
|
||||
/// point, so this is not the call a lockscreen scrub or a UI seek wants — use
|
||||
/// [`seek_absolute`](Self::seek_absolute), which speaks the episode's
|
||||
/// timeline and is what every caller outside the player itself means.
|
||||
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.seek(position)
|
||||
}
|
||||
|
||||
/// Seek to an **absolute** position on the item's own timeline.
|
||||
///
|
||||
/// This is the boundary every outside seek comes through — the UI, the
|
||||
/// lockscreen scrubber, a headset gesture — because all of them are looking
|
||||
/// at the whole episode, not at whatever fragment of it the player happens to
|
||||
/// be streaming.
|
||||
///
|
||||
/// Outside a background-audio handoff the two timelines are the same and this
|
||||
/// is an ordinary seek. Inside one they differ by the handoff base, and the
|
||||
/// stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked
|
||||
/// transcode with no length, so ExoPlayer either refuses or clamps — and a
|
||||
/// clamped seek lands at stream zero, which is the handoff point. That is the
|
||||
/// "jumps back to where I locked the screen" symptom. Honouring the seek means
|
||||
/// re-opening the URL at the new position, which is exactly what the
|
||||
/// truncation recovery already does, so it shares `resume_stream_at`.
|
||||
///
|
||||
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
|
||||
pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
|
||||
let rebuild = self.is_background_audio_active() && {
|
||||
let queue = self.queue.lock_safe();
|
||||
queue
|
||||
.current()
|
||||
.map(Self::is_audio_only_video)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if rebuild {
|
||||
return self.resume_stream_at(position.max(0.0)).await;
|
||||
}
|
||||
|
||||
self.seek(position).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set volume (0.0 - 1.0)
|
||||
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
|
||||
self.backend.lock_safe().set_volume(volume)
|
||||
@@ -1384,8 +1422,10 @@ impl PlayerController {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base = *self.background_audio_base.lock_safe();
|
||||
let absolute = (base + self.position()).max(0.0);
|
||||
// Already absolute: the Android position tick shifts by the handoff base
|
||||
// before anything sees the value, so adding it again here would
|
||||
// double-count it. (DR-159)
|
||||
let absolute = self.position().max(0.0);
|
||||
|
||||
match self.stream_resume.lock_safe().allow_attempt(absolute) {
|
||||
Some(attempt) => Some((absolute, attempt)),
|
||||
@@ -1425,8 +1465,8 @@ impl PlayerController {
|
||||
}
|
||||
current.duration
|
||||
};
|
||||
let base = *self.background_audio_base.lock_safe();
|
||||
let absolute = (base + self.position()).max(0.0);
|
||||
// Already absolute — see claim_stream_resume. (DR-159)
|
||||
let absolute = self.position().max(0.0);
|
||||
|
||||
// Only spend a resume attempt once the runtime says this really was cut
|
||||
// short — a genuine end must stay a genuine end.
|
||||
@@ -3604,10 +3644,88 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The handoff stream's timeline starts at the handoff position, so the
|
||||
/// player reports a *relative* position. The runtime it is compared against
|
||||
/// is absolute — the base has to be added back, or every handoff looks like a
|
||||
/// truncation.
|
||||
/// A seek arriving during a background-audio handoff is **absolute** — the
|
||||
/// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
|
||||
/// 25:00 of the episode, not 25:00 into the handoff stream.
|
||||
///
|
||||
/// The handoff stream cannot be seeked at all (a chunked, length-less
|
||||
/// transcode), so honouring it means re-opening the URL at the new position,
|
||||
/// exactly as the truncation recovery does. Passing the number through to
|
||||
/// ExoPlayer instead — which is what used to happen — asked a stream that
|
||||
/// cannot seek to jump past its own end, and a clamped seek lands at stream
|
||||
/// zero: the handoff point.
|
||||
///
|
||||
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
|
||||
#[tokio::test]
|
||||
async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
|
||||
// Handed off 20 minutes in, so the stream's zero is 1200s.
|
||||
controller.enter_background_audio(1200.0);
|
||||
|
||||
// The viewer scrubs the lockscreen to 25:00 absolute.
|
||||
controller.seek_absolute(1490.0).await.unwrap();
|
||||
|
||||
let url = {
|
||||
let queue = controller.queue();
|
||||
let queue = queue.lock_safe();
|
||||
match &queue.current().unwrap().source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
other => panic!("expected a remote source, got {:?}", other),
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
url.contains(&format!(
|
||||
"StartTimeTicks={}",
|
||||
(1490.0 * 10_000_000.0) as i64
|
||||
)),
|
||||
"the stream must be re-opened at the absolute position; got {}",
|
||||
url
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
*controller.background_audio_base.lock_safe(),
|
||||
1490.0,
|
||||
"the re-opened stream's zero is the position it was opened at, or \
|
||||
every later reading is off by the difference"
|
||||
);
|
||||
}
|
||||
|
||||
/// Outside a handoff there is no base and nothing to re-open: an absolute
|
||||
/// seek is just a seek, and must not be turned into a stream rebuild.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-159 | UT-155
|
||||
#[tokio::test]
|
||||
async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
|
||||
controller.seek_absolute(300.0).await.unwrap();
|
||||
|
||||
assert_eq!(controller.position(), 300.0);
|
||||
assert_eq!(
|
||||
*controller.background_audio_base.lock_safe(),
|
||||
0.0,
|
||||
"an ordinary seek must not invent a handoff base"
|
||||
);
|
||||
}
|
||||
|
||||
/// The truncation check compares the position against the item's runtime, so
|
||||
/// both must be on the same timeline.
|
||||
///
|
||||
/// They now are by construction: the Android position tick shifts by the
|
||||
/// handoff base before anything sees the value, so what the player reports is
|
||||
/// already a position on the episode. The base is therefore *not* added here —
|
||||
/// doing so would double-count it and make the last minute of a handoff look
|
||||
/// like a truncation. What the mock backend holds is what the real one would
|
||||
/// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
|
||||
#[tokio::test]
|
||||
async fn test_truncated_check_uses_the_absolute_position() {
|
||||
let controller = PlayerController::default();
|
||||
@@ -3616,9 +3734,10 @@ mod tests {
|
||||
controller
|
||||
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
||||
.unwrap();
|
||||
// Handed off at 24:00; the stream then played its last 56 seconds out.
|
||||
// Handed off at 24:00; the stream then played its last 56 seconds out, so
|
||||
// the player reports 24:56 of the episode.
|
||||
controller.set_background_audio_base(1440.0);
|
||||
controller.seek(56.0).unwrap();
|
||||
controller.seek(1496.0).unwrap();
|
||||
controller.take_end_reason();
|
||||
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user