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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user