Background-audio handoff for video + repository/player refactor

Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
This commit is contained in:
2026-07-22 21:52:07 +02:00
parent 4e6ab017d4
commit 3fbf6afdbc
72 changed files with 6728 additions and 2338 deletions
+126 -84
View File
@@ -4,9 +4,9 @@
//! through JNI calls to Kotlin code.
use crate::utils::lock::MutexSafe;
use log::debug;
use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Mutex as TokioMutex;
use log::debug;
use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
use jni::sys::{jboolean, jdouble, jfloat, jint};
@@ -17,7 +17,7 @@ use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType};
use super::state::PlayerState;
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::utils::conversions::seconds_to_ticks;
/// Global reference to the JavaVM for JNI callbacks
@@ -33,10 +33,12 @@ static EVENT_EMITTER: OnceLock<SharedEventEmitter> = OnceLock::new();
static SHARED_STATE: OnceLock<Arc<Mutex<ExoPlayerState>>> = OnceLock::new();
/// Global handler for media session commands from Android lockscreen/notification
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> = OnceLock::new();
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> =
OnceLock::new();
/// Global handler for remote volume changes from Android volume buttons
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> = OnceLock::new();
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> =
OnceLock::new();
/// Global player controller for autoplay decisions
static PLAYER_CONTROLLER: OnceLock<Arc<TokioMutex<super::PlayerController>>> = OnceLock::new();
@@ -87,9 +89,9 @@ impl DetectedCodecs {
/// Public function to get detected codecs (for use in repository layer)
pub fn get_detected_codecs() -> Option<(String, String)> {
DETECTED_CODECS.get().map(|codecs| {
(codecs.video_codecs_string(), codecs.audio_codecs_string())
})
DETECTED_CODECS
.get()
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
}
/// Trait for handling media commands from Android MediaSession.
@@ -194,9 +196,9 @@ impl ExoPlayerBackend {
let _ = JAVA_VM.set(vm);
// Store the Context as a global reference for later use
let context_global = env
.new_global_ref(context)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create global context ref: {}", e)))?;
let context_global = env.new_global_ref(context).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create global context ref: {}", e))
})?;
let _ = APP_CONTEXT.set(context_global);
// Store the event emitter
@@ -217,11 +219,16 @@ impl ExoPlayerBackend {
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| PlayerError::playback_failed(format!("Failed to get ClassLoader: {}", e)))?
.l()
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e))
})?;
// Load the JellyTauPlayer class using the app's class loader
let player_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| PlayerError::playback_failed(format!("Failed to create class name string: {}", e)))?;
let player_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to create class name string: {}", e))
})?;
let player_class_obj = env
.call_method(
@@ -230,9 +237,13 @@ impl ExoPlayerBackend {
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&player_class_name.into())],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e)))?
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e))
})?
.l()
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert to Class: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert to Class: {}", e))
})?;
// Cast to JClass for static method calls
let player_class = JClass::from(player_class_obj);
@@ -244,7 +255,9 @@ impl ExoPlayerBackend {
"(Landroid/content/Context;)V",
&[JValue::Object(context)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e))
})?;
// Get the singleton instance
let player_obj = env
@@ -254,14 +267,21 @@ impl ExoPlayerBackend {
"()Lcom/dtourolle/jellytau/player/JellyTauPlayer;",
&[],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to get JellyTauPlayer instance: {}", e)))?
.map_err(|e| {
PlayerError::playback_failed(format!(
"Failed to get JellyTauPlayer instance: {}",
e
))
})?
.l()
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert to object: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert to object: {}", e))
})?;
// Create a global reference to keep the player alive
let player_ref = env
.new_global_ref(player_obj)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create global ref: {}", e)))?;
let player_ref = env.new_global_ref(player_obj).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create global ref: {}", e))
})?;
Ok(Self {
player_ref,
@@ -271,16 +291,18 @@ impl ExoPlayerBackend {
/// Call a void method on the player with no arguments
fn call_player_method(&self, method: &str) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
env.call_method(&self.player_ref, method, "()V", &[])
.map_err(|e| PlayerError::playback_failed(format!("Failed to call {}: {}", method, e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call {}: {}", method, e))
})?;
Ok(())
}
@@ -314,43 +336,46 @@ impl PlayerBackend for ExoPlayerBackend {
state.is_loaded = false;
}
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
// Create JNI strings for required parameters
let url_jstring = env
.new_string(&url)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create URL string: {}", e)))?;
let url_jstring = env.new_string(&url).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create URL string: {}", e))
})?;
let media_id_jstring = env
.new_string(&media_id)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create media ID string: {}", e)))?;
let media_id_jstring = env.new_string(&media_id).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create media ID string: {}", e))
})?;
let title_jstring = env
.new_string(&title)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create title string: {}", e)))?;
let title_jstring = env.new_string(&title).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create title string: {}", e))
})?;
// Create JNI strings for optional parameters (null if None)
let artist_jstring = match &artist {
Some(a) => Some(env.new_string(a)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create artist string: {}", e)))?),
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create artist string: {}", e))
})?),
None => None,
};
let album_jstring = match &album {
Some(a) => Some(env.new_string(a)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create album string: {}", e)))?),
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create album string: {}", e))
})?),
None => None,
};
let artwork_jstring = match &artwork_url {
Some(a) => Some(env.new_string(a)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create artwork string: {}", e)))?),
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create artwork string: {}", e))
})?),
None => None,
};
@@ -376,16 +401,16 @@ impl PlayerBackend for ExoPlayerBackend {
MediaType::Video => "video",
MediaType::Audio => "audio",
};
let media_type_jstring = env
.new_string(media_type_str)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create media type string: {}", e)))?;
let media_type_jstring = env.new_string(media_type_str).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create media type string: {}", e))
})?;
// Serialize subtitles to JSON for passing to Kotlin
let subtitles_json = serde_json::to_string(&media.subtitles)
.unwrap_or_else(|_| "[]".to_string());
let subtitles_jstring = env
.new_string(&subtitles_json)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e)))?;
let subtitles_json =
serde_json::to_string(&media.subtitles).unwrap_or_else(|_| "[]".to_string());
let subtitles_jstring = env.new_string(&subtitles_json).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e))
})?;
// Call loadWithMetadata for MediaSession support (lockscreen controls)
debug!("[Android] Loading media: url={}, id={}, title={}, artist={:?}, album={:?}, duration_ms={}, type={}, subtitles={}",
@@ -414,7 +439,10 @@ impl PlayerBackend for ExoPlayerBackend {
env.exception_describe().ok();
env.exception_clear().ok();
}
return Err(PlayerError::playback_failed(format!("Failed to call loadWithMetadata: {}", e)));
return Err(PlayerError::playback_failed(format!(
"Failed to call loadWithMetadata: {}",
e
)));
}
debug!("[Android] Successfully called loadWithMetadata");
@@ -443,9 +471,9 @@ impl PlayerBackend for ExoPlayerBackend {
}
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -465,9 +493,9 @@ impl PlayerBackend for ExoPlayerBackend {
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -502,9 +530,9 @@ impl PlayerBackend for ExoPlayerBackend {
}
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -516,15 +544,17 @@ impl PlayerBackend for ExoPlayerBackend {
"(I)V",
&[JValue::Int(stream_index)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e))
})?;
Ok(())
}
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -539,7 +569,9 @@ impl PlayerBackend for ExoPlayerBackend {
"(I)V",
&[JValue::Int(index)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e))
})?;
Ok(())
}
@@ -603,7 +635,11 @@ fn report_android_progress(position: f64) {
if !state.state.is_playing() {
return;
}
match state.current_media.as_ref().and_then(|m| m.jellyfin_id().map(|s| s.to_string())) {
match state
.current_media
.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
{
Some(id) => id,
None => return,
}
@@ -664,10 +700,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
state: JString,
media_id: JString,
) {
let state_str: String = env
.get_string(&state)
.map(|s| s.into())
.unwrap_or_default();
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
let media_id_opt: Option<String> = if media_id.is_null() {
None
@@ -769,7 +802,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
@@ -779,7 +816,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Log queue state after advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
@@ -1002,7 +1043,8 @@ fn start_playback_service() -> Result<(), String> {
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlayer class using the app's class loader
let player_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
let player_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let player_class_obj = env
@@ -1036,13 +1078,8 @@ fn start_playback_service() -> Result<(), String> {
}
// Call startPlaybackService() on the player instance
env.call_method(
&player_obj,
"startPlaybackService",
"()V",
&[],
)
.map_err(|e| format!("Failed to start playback service: {}", e))?;
env.call_method(&player_obj, "startPlaybackService", "()V", &[])
.map_err(|e| format!("Failed to start playback service: {}", e))?;
log::info!("[Android] JellyTauPlaybackService start requested");
Ok(())
@@ -1056,7 +1093,10 @@ fn start_playback_service() -> Result<(), String> {
/// @param initial_volume Initial volume level (0-100)
#[cfg(target_os = "android")]
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
log::info!("[Android] Enabling remote volume control (volume={})", initial_volume);
log::info!(
"[Android] Enabling remote volume control (volume={})",
initial_volume
);
// Ensure the playback service is started first
start_playback_service()?;
@@ -1078,7 +1118,8 @@ pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlaybackService class using the app's class loader
let service_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
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
@@ -1146,7 +1187,8 @@ pub fn disable_remote_volume() -> Result<(), String> {
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlaybackService class using the app's class loader
let service_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
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