fix resuming video playback after background audio only mode.
Traceability Validation / Check Requirement Traces (pull_request) Failing after 3h14m1s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (pull_request) Has been cancelled

This commit is contained in:
2026-07-22 22:28:07 +02:00
parent 3fbf6afdbc
commit acf1bb200d
7 changed files with 249 additions and 39 deletions
+51 -11
View File
@@ -57,6 +57,18 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
/// Base offset (seconds) for the active background-audio handoff.
///
/// The audio-only stream is requested with `StartTimeTicks` = the handoff
/// position, so the server makes that point the stream's zero. ExoPlayer then
/// reports position RELATIVE to that zero. To convert back to an absolute
/// position on exit (so the video resumes where the audio actually reached), we
/// add this stored base to the native player's reported position.
///
/// TRACES: UR-040 | DR-052
#[derive(Default)]
pub struct BackgroundAudioOffset(pub Mutex<f64>);
/// Response for player state queries
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -184,6 +196,11 @@ pub struct PlayItemRequest {
pub primary_image_tag: Option<String>,
#[serde(default)]
pub server_id: Option<String>,
/// Total media duration (seconds). Threaded through the background-audio
/// handoff so the lockscreen MediaSession advertises a real duration — a
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
#[serde(default)]
pub duration_seconds: Option<f64>,
}
/// Queue context for remote transfer - what type of queue is this?
@@ -555,6 +572,7 @@ pub async fn player_play_item(
pub async fn player_enter_background_audio(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
bg_offset: State<'_, BackgroundAudioOffset>,
item: PlayItemRequest,
position_seconds: f64,
) -> Result<PlayerStatus, String> {
@@ -579,7 +597,8 @@ pub async fn player_enter_background_audio(
primary_image_tag: item.primary_image_tag.clone(),
item_type: None,
playlist_id: None,
duration: None,
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
duration: item.duration_seconds,
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
@@ -600,16 +619,23 @@ pub async fn player_enter_background_audio(
session_mgr.start_audio_session(media_item.clone());
}
// Remember where the video was: the audio stream's zero == this position
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
// this base to the native player's relative position to get the absolute one.
*bg_offset.0.lock().map_err(|e| e.to_string())? = position_seconds.max(0.0);
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
// relative to the stream's StartTimeTicks zero, but the metadata duration is
// absolute, so shift the reported position back to absolute for the scrubber.
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
let controller = player.0.lock().await;
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
// Resume at the handoff position.
if position_seconds > 0.0 {
controller
.seek(position_seconds)
.map_err(|e| e.to_string())?;
}
// NOTE: do NOT seek here. The audio-only URL already starts at the handoff
// position via StartTimeTicks; the stream's timeline begins at 0 == that
// point, so an extra seek(position_seconds) would jump PAST the content.
controller.emit_queue_changed();
if let Some(emitter) = controller.event_emitter() {
@@ -634,17 +660,31 @@ pub async fn player_enter_background_audio(
#[specta::specta]
pub async fn player_exit_background_audio(
player: State<'_, PlayerStateWrapper>,
bg_offset: State<'_, BackgroundAudioOffset>,
) -> Result<f64, String> {
// The base offset (handoff position) + native player's relative position =
// the absolute position to resume the video at. Read/reset the base first.
let base = {
let mut off = bg_offset.0.lock().map_err(|e| e.to_string())?;
let b = *off;
*off = 0.0;
b
};
// 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;
// Capture position into a `let` BEFORE stop() — never hold work across a lock
// re-entrant call (deadlock discipline, CLAUDE.md).
let position = controller.position();
let relative = controller.position();
controller.stop().map_err(|e| e.to_string())?;
let absolute = base + relative;
info!(
"player_exit_background_audio: returning position {:.1}s",
position
"player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s",
base, relative, absolute
);
Ok(position)
Ok(absolute)
}
/// Play a queue of media items
+3
View File
@@ -1166,6 +1166,9 @@ pub fn run() {
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
app.manage(video_settings);
// Background-audio handoff base offset (UR-040).
app.manage(commands::player::BackgroundAudioOffset::default());
// Initialize thumbnail cache
info!("[INIT] Initializing thumbnail cache...");
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
+60
View File
@@ -1315,6 +1315,66 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
Ok(())
}
/// Set the base position offset (seconds) on the lockscreen MediaSession.
///
/// Calls `JellyTauPlaybackService.setPositionOffset(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")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
let service_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let service_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&service_class_name.into())],
)
.map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let service_class = JClass::from(service_class_obj);
let service_obj = env
.call_static_method(
&service_class,
"getInstance",
"()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
&[],
)
.map_err(|e| format!("Failed to get service instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
// Service not running yet - nothing to offset.
if service_obj.is_null() {
return Ok(());
}
env.call_method(
&service_obj,
"setPositionOffset",
"(D)V",
&[JValue::Double(offset_seconds)],
)
.map_err(|e| format!("Failed to set position offset: {}", e))?;
Ok(())
}
/// Stub implementations for non-Android platforms
#[cfg(not(target_os = "android"))]
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {
+16
View File
@@ -80,6 +80,22 @@ pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), Stri
}
}
/// Set the base offset (seconds) added to positions reported to the Android
/// lockscreen scrubber. Used by the background-audio handoff: the audio stream
/// starts at the handoff point (StartTimeTicks), so ExoPlayer's position is
/// relative and must be shifted back to absolute to match the full duration.
/// Pass 0.0 to clear on exit. No-op off Android.
pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
#[cfg(target_os = "android")]
{
return android::set_position_offset(_offset_seconds);
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
use crate::utils::lock::MutexSafe;
use log::{debug, error, warn};
use std::sync::{Arc, Mutex};