//! ExoPlayer backend for Android via JNI. //! //! This module provides a `PlayerBackend` implementation using Android's ExoPlayer //! through JNI calls to Kotlin code. 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}; use jni::{JNIEnv, JavaVM}; use super::autoplay::AutoplayDecision; 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}; /// Global reference to the JavaVM for JNI callbacks static JAVA_VM: OnceLock = OnceLock::new(); /// Global reference to the Android Context for accessing app classes static APP_CONTEXT: OnceLock = OnceLock::new(); /// Global event emitter for callbacks from Kotlin static EVENT_EMITTER: OnceLock = OnceLock::new(); /// Shared state updated by JNI callbacks static SHARED_STATE: OnceLock>> = OnceLock::new(); /// Global handler for media session commands from Android lockscreen/notification static MEDIA_COMMAND_HANDLER: OnceLock> = OnceLock::new(); /// Global handler for remote volume changes from Android volume buttons static REMOTE_VOLUME_HANDLER: OnceLock> = OnceLock::new(); /// Global player controller for autoplay decisions static PLAYER_CONTROLLER: OnceLock>> = OnceLock::new(); /// Global storage for detected device codecs static DETECTED_CODECS: OnceLock = OnceLock::new(); /// Global playback reporter for dual sync (local DB + server) static PLAYBACK_REPORTER: OnceLock>>> = OnceLock::new(); /// Global position throttler for progress reporting static POSITION_THROTTLER: OnceLock> = OnceLock::new(); /// Detected codec capabilities from Android MediaCodecList #[derive(Debug, Clone)] struct DetectedCodecs { video_codecs: Vec, audio_codecs: Vec, } impl DetectedCodecs { /// Create from comma-separated codec strings (from JNI) fn from_jni_strings(video_codecs: &str, audio_codecs: &str) -> Self { Self { video_codecs: video_codecs .split(',') .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .collect(), audio_codecs: audio_codecs .split(',') .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .collect(), } } /// Get video codecs as comma-separated string (for DeviceProfile) fn video_codecs_string(&self) -> String { self.video_codecs.join(",") } /// Get audio codecs as comma-separated string (for DeviceProfile) fn audio_codecs_string(&self) -> String { self.audio_codecs.join(",") } } /// 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()) }) } /// Trait for handling media commands from Android MediaSession. /// /// This is implemented by the application to receive commands from /// lockscreen controls, notification buttons, and Bluetooth devices. pub trait MediaCommandHandler: Send + Sync { /// Handle a media command. /// /// Commands: "play", "pause", "next", "previous", "stop", "seek:123.45" fn on_command(&self, command: &str); } /// Trait for handling remote volume changes when in remote playback mode. /// /// This is implemented by the application to forward volume button presses /// to the remote Jellyfin session. pub trait RemoteVolumeHandler: Send + Sync { /// Handle a remote volume change command. /// /// Commands: "SetVolume", "VolumeUp", "VolumeDown" /// @param command The volume command /// @param volume The volume level (0-100) fn on_remote_volume_change(&self, command: &str, volume: i32); } /// Set the global media command handler. /// /// This should be called during app initialization after creating the PlayerController. pub fn set_media_command_handler(handler: Arc) { let _ = MEDIA_COMMAND_HANDLER.set(handler); } /// Set the global player controller. /// /// This should be called during app initialization after creating the PlayerController. pub fn set_player_controller(controller: Arc>) { let _ = PLAYER_CONTROLLER.set(controller); } /// Set the global remote volume handler. /// /// This should be called during app initialization to handle remote volume changes. pub fn set_remote_volume_handler(handler: Arc) { let _ = REMOTE_VOLUME_HANDLER.set(handler); } /// State tracking for ExoPlayer #[derive(Default)] struct ExoPlayerState { state: PlayerState, position: f64, duration: Option, volume: f32, is_loaded: bool, current_media: Option, } impl ExoPlayerState { fn new() -> Self { Self { state: PlayerState::Idle, position: 0.0, duration: None, volume: 1.0, is_loaded: false, current_media: None, } } } /// ExoPlayer-based player backend for Android. pub struct ExoPlayerBackend { /// Reference to the JellyTauPlayer Kotlin object player_ref: GlobalRef, /// Shared state shared_state: Arc>, } impl ExoPlayerBackend { /// Initialize the ExoPlayer backend. /// /// This must be called from the Android main thread with the Activity context. /// /// # Arguments /// * `env` - JNI environment /// * `context` - Android Context (Activity or Application) /// * `event_emitter` - Event emitter for frontend notifications /// * `playback_reporter` - Playback reporter for dual sync /// * `position_throttler` - Event throttler for position updates pub fn new( env: &mut JNIEnv, context: &JObject, event_emitter: SharedEventEmitter, playback_reporter: Arc>>, position_throttler: Arc, ) -> Result { // Store the JavaVM for callbacks let vm = env .get_java_vm() .map_err(|e| PlayerError::playback_failed(format!("Failed to get JavaVM: {}", e)))?; 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 _ = APP_CONTEXT.set(context_global); // Store the event emitter let _ = EVENT_EMITTER.set(event_emitter); // Store the playback reporter and throttler let _ = PLAYBACK_REPORTER.set(playback_reporter); let _ = POSITION_THROTTLER.set(position_throttler); // Create shared state let shared_state = Arc::new(Mutex::new(ExoPlayerState::new())); let _ = SHARED_STATE.set(shared_state.clone()); // Get the ClassLoader from the Context to load app classes // This is necessary because find_class() uses the system class loader // which doesn't have access to app classes when called from native threads let class_loader = env .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)))?; // 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_obj = env .call_method( &class_loader, "loadClass", "(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)))? .l() .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); // Call JellyTauPlayer.initialize(context) env.call_static_method( &player_class, "initialize", "(Landroid/content/Context;)V", &[JValue::Object(context)], ) .map_err(|e| PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e)))?; // Get the singleton instance let player_obj = env .call_static_method( &player_class, "getInstance", "()Lcom/dtourolle/jellytau/player/JellyTauPlayer;", &[], ) .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)))?; // 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)))?; Ok(Self { player_ref, shared_state, }) } /// 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 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)))?; Ok(()) } } impl Drop for ExoPlayerBackend { fn drop(&mut self) { let _ = self.call_player_method("release"); } } impl PlayerBackend for ExoPlayerBackend { fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> { let url = media.playback_url(); let media_id = media.id.clone(); let title = media.title.clone(); let artist = media.artist.clone(); let album = media.album.clone(); let artwork_url = media.artwork_url.clone(); // Convert duration from seconds to milliseconds let duration_ms = media.duration.map(|d| (d * 1000.0) as i64).unwrap_or(0); // Update local state { let mut state = self.shared_state.lock().unwrap(); state.current_media = Some(media.clone()); state.state = PlayerState::Loading { media: media.clone(), }; state.position = 0.0; state.is_loaded = false; } 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 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)))?; // 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)))?), 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)))?), 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)))?), None => None, }; // Create null object binding for use in match arms let null_obj = JObject::null(); // Get JValue for nullable strings - convert Option to JValue::Object let artist_value = match &artist_jstring { Some(s) => JValue::Object(s.as_ref()), None => JValue::Object(&null_obj), }; let album_value = match &album_jstring { Some(s) => JValue::Object(s.as_ref()), None => JValue::Object(&null_obj), }; let artwork_value = match &artwork_jstring { Some(s) => JValue::Object(s.as_ref()), None => JValue::Object(&null_obj), }; // Determine media type string for JNI let media_type_str = match media.media_type { 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)))?; // 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)))?; // Call loadWithMetadata for MediaSession support (lockscreen controls) debug!("[Android] Loading media: url={}, id={}, title={}, artist={:?}, album={:?}, duration_ms={}, type={}, subtitles={}", &url, &media_id, &title, &artist, &album, duration_ms, media_type_str, &subtitles_json); let result = env.call_method( &self.player_ref, "loadWithMetadata", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;)V", &[ JValue::Object(&url_jstring), JValue::Object(&media_id_jstring), JValue::Object(&title_jstring), artist_value, album_value, artwork_value, JValue::Long(duration_ms), JValue::Object(&media_type_jstring), JValue::Object(&subtitles_jstring), ], ); if let Err(e) = result { // Check for pending exceptions if let Ok(true) = env.exception_check() { env.exception_describe().ok(); env.exception_clear().ok(); } return Err(PlayerError::playback_failed(format!("Failed to call loadWithMetadata: {}", e))); } debug!("[Android] Successfully called loadWithMetadata"); Ok(()) } fn play(&mut self) -> Result<(), PlayerError> { self.call_player_method("play") } fn pause(&mut self) -> Result<(), PlayerError> { self.call_player_method("pause") } fn stop(&mut self) -> Result<(), PlayerError> { { let mut state = self.shared_state.lock().unwrap(); state.state = PlayerState::Idle; state.is_loaded = false; state.current_media = None; state.position = 0.0; state.duration = None; } self.call_player_method("stop") } fn seek(&mut self, position: f64) -> Result<(), PlayerError> { 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, "seek", "(D)V", &[JValue::Double(position)], ) .map_err(|e| PlayerError::playback_failed(format!("Failed to call seek: {}", e)))?; Ok(()) } 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 mut env = vm .attach_current_thread() .map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?; env.call_method( &self.player_ref, "setVolume", "(FZ)V", &[JValue::Float(clamped), JValue::Bool(0)], ) .map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?; self.shared_state.lock().unwrap().volume = clamped; Ok(()) } fn position(&self) -> f64 { self.shared_state.lock().unwrap().position } fn duration(&self) -> Option { self.shared_state.lock().unwrap().duration } fn state(&self) -> PlayerState { self.shared_state.lock().unwrap().state.clone() } fn volume(&self) -> f32 { self.shared_state.lock().unwrap().volume } 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 mut env = vm .attach_current_thread() .map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?; env.call_method( &self.player_ref, "setAudioTrack", "(I)V", &[JValue::Int(stream_index)], ) .map_err(|e| PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e)))?; Ok(()) } fn set_subtitle_track(&mut self, stream_index: Option) -> Result<(), PlayerError> { 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)))?; // Use -1 to disable subtitles if stream_index is None let index = stream_index.unwrap_or(-1); env.call_method( &self.player_ref, "setSubtitleTrack", "(I)V", &[JValue::Int(index)], ) .map_err(|e| PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e)))?; Ok(()) } } // JNI callback functions called from Kotlin /// Called when position updates during playback. #[no_mangle] pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate( _env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble, ) { // Debug: Log every 10th update to avoid spam static mut UPDATE_COUNTER: u32 = 0; unsafe { UPDATE_COUNTER += 1; if UPDATE_COUNTER % 10 == 0 { log::debug!("[Android] Position update {} / {}", position, duration); } } // Update state and get the preserved duration to emit let duration_to_emit = if let Some(state) = SHARED_STATE.get() { let mut state = state.lock().unwrap(); state.position = position; if duration > 0.0 { state.duration = Some(duration); } // Use preserved duration from state, or fall back to the received value state.duration.unwrap_or(duration) } else { duration }; if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration: duration_to_emit, }); } else { log::error!("[Android] WARNING: No event emitter for position update!"); } } /// Called when player state changes. #[no_mangle] pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnStateChanged( mut env: JNIEnv, _class: JClass, state: JString, media_id: JString, ) { let state_str: String = env .get_string(&state) .map(|s| s.into()) .unwrap_or_default(); let media_id_opt: Option = if media_id.is_null() { None } else { env.get_string(&media_id).map(|s| s.into()).ok() }; // Update shared state if let Some(shared) = SHARED_STATE.get() { let mut shared = shared.lock().unwrap(); if let Some(media) = shared.current_media.clone() { let duration = shared.duration.unwrap_or(0.0); let position = shared.position; match state_str.as_str() { "playing" => { shared.state = PlayerState::Playing { media, position, duration, }; shared.is_loaded = true; } "paused" => { shared.state = PlayerState::Paused { media, position, duration, }; } "idle" => { shared.state = PlayerState::Idle; shared.is_loaded = false; } _ => {} } } } if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::StateChanged { state: state_str, media_id: media_id_opt, }); } } /// Called when media has finished loading. #[no_mangle] pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnMediaLoaded( _env: JNIEnv, _class: JClass, duration: jdouble, ) { if let Some(state) = SHARED_STATE.get() { let mut state = state.lock().unwrap(); state.duration = Some(duration); state.is_loaded = true; } if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::MediaLoaded { duration }); } } /// Called when playback reaches the end. #[no_mangle] pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPlaybackEnded( _env: JNIEnv, _class: JClass, ) { log::info!("[ExoPlayer] Playback ended - processing autoplay decision"); // Get player controller and handle autoplay decision if let Some(controller) = PLAYER_CONTROLLER.get() { let controller = controller.clone(); // Spawn async task to handle autoplay decision // Use tauri::async_runtime::spawn instead of tokio::spawn // JNI callbacks happen on arbitrary threads without a Tokio runtime tauri::async_runtime::spawn(async move { match controller.lock().await.on_playback_ended().await { Ok(AutoplayDecision::Stop) => { log::debug!("[Autoplay] Decision: Stop playback"); // Emit PlaybackEnded event to frontend if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::PlaybackEnded); } } Ok(AutoplayDecision::AdvanceToNext) => { log::debug!("[Autoplay] Decision: Advance to next track"); // Advance to next track in queue let ctrl = controller.lock().await; // Log queue state before advancing let queue_info = { let queue = ctrl.queue.lock().unwrap(); format!("current_index={:?}, len={}", queue.current_index(), queue.items().len()) }; log::debug!("[Autoplay] Queue state before next(): {}", queue_info); match ctrl.next() { Ok(_) => { log::info!("[Autoplay] Successfully advanced to next track"); // Log queue state after advancing let queue_info = { let queue = ctrl.queue.lock().unwrap(); format!("current_index={:?}, len={}", queue.current_index(), queue.items().len()) }; log::debug!("[Autoplay] Queue state after next(): {}", queue_info); // Emit queue changed event so frontend updates UI with new current track ctrl.emit_queue_changed(); } Err(e) => { log::error!("[Autoplay] Failed to advance to next track: {}", e); // Emit PlaybackEnded event on error if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::PlaybackEnded); } } } } Ok(AutoplayDecision::ShowNextEpisodePopup { current_episode, next_episode, countdown_seconds, auto_advance, }) => { log::info!( "[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})", countdown_seconds, auto_advance ); // Emit popup event to frontend if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup { current_episode: current_episode.clone(), next_episode: next_episode.clone(), countdown_seconds, auto_advance, }); } // Start countdown if auto_advance enabled if auto_advance { controller .lock() .await .start_autoplay_countdown(next_episode, countdown_seconds); } } Err(e) => { log::error!("[Autoplay] Decision failed: {}", e); // Emit PlaybackEnded event on error if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::PlaybackEnded); } } } }); } else { log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded"); // Fallback: just emit PlaybackEnded event if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::PlaybackEnded); } } } /// Called when buffering state changes. #[no_mangle] pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnBuffering( _env: JNIEnv, _class: JClass, percent: jint, ) { if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::Buffering { percent: percent as u8, }); } } /// Called when a playback error occurs. #[no_mangle] pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnError( mut env: JNIEnv, _class: JClass, message: JString, recoverable: jboolean, ) { let message_str: String = env .get_string(&message) .map(|s| s.into()) .unwrap_or_else(|_| "Unknown error".to_string()); if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::Error { message: message_str, recoverable: recoverable != 0, }); } } /// Called when volume changes. #[no_mangle] pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnVolumeChanged( _env: JNIEnv, _class: JClass, volume: jfloat, muted: jboolean, ) { if let Some(state) = SHARED_STATE.get() { state.lock().unwrap().volume = volume; } if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::VolumeChanged { volume, muted: muted != 0, }); } } // JNI callback for MediaSession commands from JellyTauPlaybackService /// Called when a media command is received from the Android MediaSession. /// /// Commands from lockscreen controls, notification buttons, and Bluetooth /// devices are routed through here to the Rust PlayerController. #[no_mangle] pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand( mut env: JNIEnv, _class: JClass, command: JString, ) { let command_str: String = env .get_string(&command) .map(|s| s.into()) .unwrap_or_default(); if let Some(handler) = MEDIA_COMMAND_HANDLER.get() { handler.on_command(&command_str); } } /// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode. /// /// This is called when the user presses volume up/down buttons or adjusts volume /// while in remote playback mode (casting to a Jellyfin session). #[no_mangle] pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnRemoteVolumeChange( mut env: JNIEnv, _class: JClass, command: JString, volume: jint, ) { let command_str: String = env .get_string(&command) .map(|s| s.into()) .unwrap_or_default(); if let Some(handler) = REMOTE_VOLUME_HANDLER.get() { handler.on_remote_volume_change(&command_str, volume as i32); } } /// JNI callback from Kotlin when codec detection completes. /// /// Called once during JellyTauPlayer initialization to store the device's /// hardware codec capabilities for use in DeviceProfile generation. #[no_mangle] pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Companion_nativeOnCodecsDetected( mut env: JNIEnv, _class: JClass, video_codecs: JString, audio_codecs: JString, ) { let video_str: String = env .get_string(&video_codecs) .map(|s| s.into()) .unwrap_or_default(); let audio_str: String = env .get_string(&audio_codecs) .map(|s| s.into()) .unwrap_or_default(); let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str); log::info!( "[CodecDetection] Detected {} video codecs: {}", codecs.video_codecs.len(), codecs.video_codecs_string() ); log::info!( "[CodecDetection] Detected {} audio codecs: {}", codecs.audio_codecs.len(), codecs.audio_codecs_string() ); // Store in global state if DETECTED_CODECS.set(codecs).is_err() { log::error!("[CodecDetection] Failed to store codecs - already initialized"); } } /// Start the JellyTauPlaybackService if not already running. /// /// This service is required for remote volume control to work. #[cfg(target_os = "android")] fn start_playback_service() -> Result<(), String> { log::info!("[Android] Starting JellyTauPlaybackService"); let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?; let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?; // Get the stored Context let context = APP_CONTEXT.get().ok_or("Context not initialized")?; // Get the ClassLoader from the Context to load app classes 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))?; // 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| format!("Failed to create class name string: {}", e))?; let player_class_obj = env .call_method( &class_loader, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;", &[JValue::Object(&player_class_name.into())], ) .map_err(|e| format!("Failed to load JellyTauPlayer class: {}", e))? .l() .map_err(|e| format!("Failed to convert to Class: {}", e))?; // Cast to JClass for static method calls let player_class = JClass::from(player_class_obj); // Get the singleton instance let player_obj = env .call_static_method( &player_class, "getInstance", "()Lcom/dtourolle/jellytau/player/JellyTauPlayer;", &[], ) .map_err(|e| format!("Failed to get JellyTauPlayer instance: {}", e))? .l() .map_err(|e| format!("Failed to convert to object: {}", e))?; if player_obj.is_null() { return Err("JellyTauPlayer not initialized".to_string()); } // Call startPlaybackService() on the player instance env.call_method( &player_obj, "startPlaybackService", "()V", &[], ) .map_err(|e| format!("Failed to start playback service: {}", e))?; log::info!("[Android] JellyTauPlaybackService start requested"); Ok(()) } /// Enable remote volume control on Android. /// /// This switches the MediaSession to remote playback mode, intercepting /// volume button presses and routing them to the RemoteVolumeHandler. /// /// @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); // Ensure the playback service is started first start_playback_service()?; // Give the service a moment to initialize std::thread::sleep(std::time::Duration::from_millis(100)); let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?; let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?; // Get the stored Context for ClassLoader access let context = APP_CONTEXT.get().ok_or("Context not initialized")?; // Get the ClassLoader from the Context to load app classes 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))?; // Load the JellyTauPlaybackService class using the app's class loader 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))?; // Cast to JClass for static method calls let service_class = JClass::from(service_class_obj); // Get the JellyTauPlaybackService instance 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))?; if service_obj.is_null() { return Err("JellyTauPlaybackService not running after start attempt".to_string()); } // Call enableRemoteVolume(int) env.call_method( &service_obj, "enableRemoteVolume", "(I)V", &[JValue::Int(initial_volume)], ) .map_err(|e| format!("Failed to enable remote volume: {}", e))?; log::info!("[Android] Remote volume control enabled"); Ok(()) } /// Disable remote volume control on Android. /// /// This switches the MediaSession back to local playback mode, /// allowing system volume buttons to control ExoPlayer volume. #[cfg(target_os = "android")] pub fn disable_remote_volume() -> Result<(), String> { log::info!("[Android] Disabling remote volume control"); let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?; let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?; // Get the stored Context for ClassLoader access let context = APP_CONTEXT.get().ok_or("Context not initialized")?; // Get the ClassLoader from the Context to load app classes 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))?; // Load the JellyTauPlaybackService class using the app's class loader 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))?; // Cast to JClass for static method calls let service_class = JClass::from(service_class_obj); // Get the JellyTauPlaybackService instance 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))?; if service_obj.is_null() { return Err("JellyTauPlaybackService not running".to_string()); } // Call disableRemoteVolume() env.call_method(&service_obj, "disableRemoteVolume", "()V", &[]) .map_err(|e| format!("Failed to disable remote volume: {}", e))?; log::info!("[Android] Remote volume control disabled"); Ok(()) } /// Stub implementations for non-Android platforms #[cfg(not(target_os = "android"))] pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> { Ok(()) } #[cfg(not(target_os = "android"))] pub fn disable_remote_volume() -> Result<(), String> { Ok(()) }