1124 lines
40 KiB
Rust

//! 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<JavaVM> = OnceLock::new();
/// Global reference to the Android Context for accessing app classes
static APP_CONTEXT: OnceLock<GlobalRef> = OnceLock::new();
/// Global event emitter for callbacks from Kotlin
static EVENT_EMITTER: OnceLock<SharedEventEmitter> = OnceLock::new();
/// Shared state updated by JNI callbacks
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();
/// Global handler for remote volume changes from Android volume buttons
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();
/// Global storage for detected device codecs
static DETECTED_CODECS: OnceLock<DetectedCodecs> = OnceLock::new();
/// Global playback reporter for dual sync (local DB + server)
static PLAYBACK_REPORTER: OnceLock<Arc<TokioMutex<Option<PlaybackReporter>>>> = OnceLock::new();
/// Global position throttler for progress reporting
static POSITION_THROTTLER: OnceLock<Arc<EventThrottler>> = OnceLock::new();
/// Detected codec capabilities from Android MediaCodecList
#[derive(Debug, Clone)]
struct DetectedCodecs {
video_codecs: Vec<String>,
audio_codecs: Vec<String>,
}
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<dyn MediaCommandHandler + Send + Sync>) {
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<TokioMutex<super::PlayerController>>) {
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<dyn RemoteVolumeHandler + Send + Sync>) {
let _ = REMOTE_VOLUME_HANDLER.set(handler);
}
/// State tracking for ExoPlayer
#[derive(Default)]
struct ExoPlayerState {
state: PlayerState,
position: f64,
duration: Option<f64>,
volume: f32,
is_loaded: bool,
current_media: Option<MediaItem>,
}
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<Mutex<ExoPlayerState>>,
}
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<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
) -> Result<Self, PlayerError> {
// 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<JString> 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<f64> {
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<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)))?;
// 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<String> = 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(())
}