Duncan Tourolle 385d2270c9 fix(android): keep lockscreen/media controls in sync with playback
The lockscreen controls drifted out of sync, especially while casting, and
couldn't control remote playback. Two media sessions were competing (a Media3
MediaSession driving transport vs a MediaSessionCompat driving the notification),
position was only pushed on play/pause so the scrubber froze mid-track, and
remote mode showed stale local metadata with dead buttons.

- Make MediaSessionCompat the single source of truth; route all transport
  commands (both the Compat callback and the Media3 wrappedPlayer) through Rust
  via nativeOnMediaCommand instead of touching ExoPlayer directly.
- Push position on every 250ms tick via a lightweight updatePlaybackPosition,
  and report 0.0 playback speed when paused so Android stops extrapolating.
- Mirror the remote session's now-playing onto the lockscreen from the native
  session poller (works while the screen is locked, unlike WebView timers) via
  a new player::update_lockscreen_metadata JNI bridge.
- Make MediaSessionHandler mode-aware: in remote mode forward play/pause/next/
  prev/seek to the remote Jellyfin session; Stop while casting emits
  RemoteDisconnectRequested, which the frontend handles by transferring to local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:55:26 +02:00

1216 lines
43 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 crate::utils::lock::MutexSafe;
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_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<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_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<f64> {
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
}
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_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!");
}
}
/// 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_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,
});
}
// 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_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,
) {
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(())
}
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(())
}
/// 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(())
}