1124 lines
40 KiB
Rust
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(())
|
|
}
|