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:
@@ -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