//! ExoPlayer backend for Android via JNI. //! //! This module provides a `PlayerBackend` implementation using Android's ExoPlayer //! 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 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::{EventThrottler, PlaybackOperation, PlaybackReporter}; use crate::settings::{audio_settings_jni_payload, AudioSettings}; use crate::utils::conversions::seconds_to_ticks; /// 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, /// Channels the *current audio output route* accepts, as reported by /// media3's `AudioCapabilities`. Distinct from the codec lists: a device /// decodes 5.1 happily and still has only two channels to play it out of. /// `None` when the platform had no answer. max_audio_channels: Option, } impl DetectedCodecs { /// Create from comma-separated codec strings (from JNI) fn from_jni_strings( video_codecs: &str, audio_codecs: &str, max_audio_channels: Option, ) -> 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(), max_audio_channels, } } /// 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). /// /// Returns `(video, audio, max_audio_channels)` — the third element is how many /// channels the current audio output can actually voice, which bounds what the /// server may direct-play. pub fn get_detected_codecs() -> Option<(String, String, Option)> { DETECTED_CODECS.get().map(|codecs| { ( codecs.video_codecs_string(), codecs.audio_codecs_string(), codecs.max_audio_channels, ) }) } /// 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, /// Last applied audio settings. Unlike the fields above (which JNI callbacks /// push *in*), this is commanded *out* — audio settings are never reported /// by the player, so this is the authoritative copy for `audio_settings()`. audio_settings: AudioSettings, } impl ExoPlayerState { fn new() -> Self { Self { state: PlayerState::Idle, position: 0.0, duration: None, volume: 1.0, is_loaded: false, current_media: None, audio_settings: AudioSettings::default(), } } } /// 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_safe(); 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_safe(); 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_safe().volume = clamped; Ok(()) } fn position(&self) -> f64 { self.shared_state.lock_safe().position } fn duration(&self) -> Option { self.shared_state.lock_safe().duration } fn state(&self) -> PlayerState { self.shared_state.lock_safe().state.clone() } fn volume(&self) -> f32 { self.shared_state.lock_safe().volume } /// Apply audio settings to ExoPlayer (equalizer, normalization, gapless). /// /// Sent as JSON rather than a wide JNI signature so new fields do not change /// the method signature — the same approach `load()` uses for subtitles. The /// Kotlin side owns the *mechanics* (attaching AudioEffects to the audio /// session); the canonical band layout and preset curves stay in Rust. /// /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> { let json = audio_settings_jni_payload(settings).map_err(|e| { PlayerError::playback_failed(format!("Failed to serialize audio settings: {}", e)) })?; 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)))?; let json_jstring = env.new_string(&json).map_err(|e| { PlayerError::playback_failed(format!("Failed to create settings string: {}", e)) })?; env.call_method( &self.player_ref, "setAudioSettings", "(Ljava/lang/String;)V", &[JValue::Object(&json_jstring)], ) .map_err(|e| { PlayerError::playback_failed(format!("Failed to call setAudioSettings: {}", e)) })?; // Store the sanitised form so audio_settings() reflects what was applied, // not what was requested. self.shared_state.lock_safe().audio_settings = settings .clone() .with_crossfade_clamped() .with_equalizer_normalised(); Ok(()) } /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 fn audio_settings(&self) -> AudioSettings { self.shared_state.lock_safe().audio_settings.clone() } 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_safe(); 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!"); } // Throttled progress reporting to Jellyfin so playback position syncs and can // be resumed on another device. ExoPlayer only fires position updates while // playing, but guard on the stored state anyway. Mirrors the MPV backend's // progress loop; both share the same EventThrottler (every 30s per item). report_android_progress(position); } /// Report throttled playback progress to Jellyfin from the Android position /// callback. No-op until the reporter/throttler are wired (post-login) or when /// not actively playing. fn report_android_progress(position: f64) { let item_id = match SHARED_STATE.get() { Some(state) => { let state = state.lock_safe(); if !state.state.is_playing() { return; } match state .current_media .as_ref() .and_then(|m| m.jellyfin_id().map(|s| s.to_string())) { Some(id) => id, None => return, } } None => return, }; let throttler = match POSITION_THROTTLER.get() { Some(t) => t, None => return, }; if !throttler.should_report(&item_id) { return; } let reporter_arc = match PLAYBACK_REPORTER.get() { Some(r) => r.clone(), None => return, }; let position_ticks = seconds_to_ticks(position); let item_id_for_task = item_id.clone(); // The reporter is async; the JNI callback is sync. Spawn onto the Tokio // runtime when present, otherwise a throwaway runtime on a new thread. let spawn_report = move || async move { let reporter_guard = reporter_arc.lock().await; if let Some(reporter) = reporter_guard.as_ref() { let operation = PlaybackOperation::Progress { item_id: item_id_for_task.clone(), position_ticks, is_paused: false, }; match reporter.report(operation, true).await { Ok(_) => debug!("[Android] Reported progress for {}", item_id_for_task), Err(e) => log::warn!("[Android] Failed to report progress: {}", e), } } }; if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(spawn_report()); } else { std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(spawn_report()); }); } throttler.mark_reported(&item_id); } /// 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_safe(); 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_safe(); 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 { // Compute the autoplay decision and release the lock before matching. // Holding the guard across the match would deadlock the AdvanceToNext // arm, which re-locks the controller to call next() — leaving playback // stopped (paused at position 0) instead of advancing. let decision = controller.lock().await.on_playback_ended().await; match decision { 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_safe(); 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_safe(); 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, }); } if auto_advance { // Shared with the frontend-invoked command path // (player_on_playback_ended) so the two dispatchers cannot // disagree about how a background audio-only episode // advances — they did, and the command's copy was missing // the case entirely. That copy is the one that actually // decides here: the end reason set at load makes this // callback's own decision Stop, and the frontend echoes the // resulting PlaybackEnded back into the command. controller .lock() .await .auto_advance_to_next_episode(next_episode, countdown_seconds) .await; } } Ok(AutoplayDecision::ResumeStream { position }) => { // ExoPlayer reported ENDED because the progressive transcode's // connection dropped, not because the episode finished. This // is the arm that matters while backgrounded: it needs no // frontend echo, so the stream re-opens even with the webview // suspended — and playback never parks in STATE_ENDED, where // the next lockscreen/Bluetooth play restarts the item at 0:00. log::info!( "[Autoplay] Decision: Resume truncated stream at {:.1}s", position ); let ctrl = controller.lock().await; if let Err(e) = ctrl.resume_stream_at(position).await { log::error!("[Autoplay] Failed to resume truncated stream: {}", e); if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::PlaybackEnded); } } } 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()); let recoverable = recoverable != 0; // A background audio-only handoff is an mp3 the device was already decoding, // so a recoverable failure part-way through is the network. Surfacing it as a // player error stops playback for good (the frontend's handler calls // player_stop); re-opening the stream where it died is the "buffer and // resume" this actually is. Everything else keeps reporting the error. if recoverable { if let Some(controller) = PLAYER_CONTROLLER.get() { let controller = controller.clone(); let message_str = message_str.clone(); tauri::async_runtime::spawn(async move { let resume = controller.lock().await.recoverable_error_resume(); let Some((position, delay_secs)) = resume else { // Declined here, so report it as NOT recoverable: the frontend // would otherwise echo it into player_recover_stream and ask // the same question a second time. if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::Error { message: message_str, recoverable: false, }); } return; }; log::warn!( "[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s", message_str, position, delay_secs ); // Give a brief outage time to clear before asking the server for // the stream again; retrying instantly just burns the budget. tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await; let ctrl = controller.lock().await; if let Err(e) = ctrl.resume_stream_at(position).await { log::error!("[ExoPlayer] Failed to resume after error: {}", e); if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::Error { message: message_str, recoverable: false, }); } } }); return; } } if let Some(emitter) = EVENT_EMITTER.get() { emitter.emit(PlayerStatusEvent::Error { message: message_str, recoverable, }); } } /// 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_safe().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, max_audio_channels: jint, ) { 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(); // Kotlin sends 0 when AudioCapabilities had no answer for the current route. let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0); let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels); 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() ); log::info!( "[CodecDetection] Audio route max channels: {:?}", codecs.max_audio_channels ); // 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(()) } use crate::player::LockscreenMetadata; /// Push now-playing metadata and playback state to the Android lockscreen. /// /// Calls `JellyTauPlaybackService.updateMediaMetadata(...)`. The service must be /// running (in remote mode it is started via [`enable_remote_volume`]); if it /// isn't, this is a no-op rather than an error so it can be called freely on /// every poll tick. pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> 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 (e.g. nothing has played) - nothing to update. if service_obj.is_null() { return Ok(()); } let title = env .new_string(&meta.title) .map_err(|e| format!("Failed to create title string: {}", e))?; let artist = env .new_string(&meta.artist) .map_err(|e| format!("Failed to create artist string: {}", e))?; // album is nullable on the Kotlin side; pass a real String or JObject::null(). let album_obj = match &meta.album { Some(a) => env .new_string(a) .map_err(|e| format!("Failed to create album string: {}", e))? .into(), None => jni::objects::JObject::null(), }; env.call_method( &service_obj, "updateMediaMetadata", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZ)V", &[ JValue::Object(&title.into()), JValue::Object(&artist.into()), JValue::Object(&album_obj), JValue::Long(meta.duration_ms), JValue::Long(meta.position_ms), JValue::Bool(meta.is_playing as u8), ], ) .map_err(|e| format!("Failed to update lockscreen metadata: {}", e))?; Ok(()) } /// Set the background-audio handoff base (seconds) on the playback service. /// /// 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")?; 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, "setHandoffBase", "(D)V", &[JValue::Double(offset_seconds)], ) .map_err(|e| format!("Failed to set handoff base: {}", e))?; 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(()) }