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>
This commit is contained in:
+125
-48
@@ -148,54 +148,126 @@ use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler
|
||||
|
||||
/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
|
||||
///
|
||||
/// Routes commands from the system media controls back to the PlayerController.
|
||||
/// Routes commands from the system media controls to the right place depending on
|
||||
/// playback mode: in local mode it drives the local `PlayerController`; in remote
|
||||
/// (cast) mode it forwards transport commands to the remote Jellyfin session so
|
||||
/// the lockscreen can control whatever is casting. Stop while casting requests a
|
||||
/// disconnect back to local playback.
|
||||
#[cfg(target_os = "android")]
|
||||
struct MediaSessionHandler {
|
||||
player: Arc<TokioMutex<PlayerController>>,
|
||||
playback_mode: Arc<PlaybackModeManager>,
|
||||
event_emitter: Arc<TauriEventEmitter>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl MediaSessionHandler {
|
||||
/// Forward a transport command to the active remote Jellyfin session.
|
||||
///
|
||||
/// Runs async on the Tauri runtime because JNI callbacks arrive on arbitrary
|
||||
/// threads without a Tokio context.
|
||||
fn handle_remote_command(&self, command: &str, session_id: String) {
|
||||
use crate::player::{PlayerEventEmitter, PlayerStatusEvent};
|
||||
|
||||
// Stop while casting means "disconnect and resume locally". The frontend
|
||||
// owns the remote->local transfer (it reloads the item locally), so we
|
||||
// just signal intent.
|
||||
if command == "stop" {
|
||||
self.event_emitter
|
||||
.emit(PlayerStatusEvent::RemoteDisconnectRequested);
|
||||
return;
|
||||
}
|
||||
|
||||
let jellyfin_client = {
|
||||
let player = self.player.blocking_lock();
|
||||
player.jellyfin_client()
|
||||
};
|
||||
let command = command.to_string();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let client = {
|
||||
let guard = match jellyfin_client.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => {
|
||||
error!("[MediaSession] Failed to lock Jellyfin client: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match guard.as_ref() {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
warn!("[MediaSession] No Jellyfin client for remote command");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Map lockscreen transport commands onto Jellyfin session commands.
|
||||
let result = match command.as_str() {
|
||||
"play" => client.send_session_command(session_id, "Unpause").await,
|
||||
"pause" => client.send_session_command(session_id, "Pause").await,
|
||||
"next" => client.send_session_command(session_id, "NextTrack").await,
|
||||
"previous" => client.send_session_command(session_id, "PreviousTrack").await,
|
||||
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
|
||||
Ok(seconds) => {
|
||||
let ticks = (seconds * 10_000_000.0) as i64;
|
||||
client.session_seek(session_id, ticks).await
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("[MediaSession] Bad seek command: {}", command);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
warn!("[MediaSession] Unknown remote command: {}", command);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
error!("[MediaSession] Remote command '{}' failed: {}", command, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Drive the local player for a transport command.
|
||||
fn handle_local_command(&self, command: &str) {
|
||||
// Use blocking_lock since this is called from a non-async JNI callback
|
||||
let controller = self.player.blocking_lock();
|
||||
|
||||
let result = match command {
|
||||
"play" => controller.play(),
|
||||
"pause" => controller.pause(),
|
||||
"next" => controller.next(),
|
||||
"previous" => controller.previous(),
|
||||
"stop" => controller.stop(),
|
||||
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
|
||||
Ok(pos) => controller.seek(pos),
|
||||
Err(_) => {
|
||||
warn!("[MediaSession] Bad seek command: {}", command);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
warn!("[MediaSession] Unknown command: {}", command);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
error!("[MediaSession] Command '{}' failed: {}", command, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl MediaCommandHandler for MediaSessionHandler {
|
||||
fn on_command(&self, command: &str) {
|
||||
// Use blocking_lock since this is called from a non-async JNI callback
|
||||
let controller = self.player.blocking_lock();
|
||||
|
||||
match command {
|
||||
"play" => {
|
||||
if let Err(e) = controller.play() {
|
||||
error!("[MediaSession] Play failed: {}", e);
|
||||
}
|
||||
}
|
||||
"pause" => {
|
||||
if let Err(e) = controller.pause() {
|
||||
error!("[MediaSession] Pause failed: {}", e);
|
||||
}
|
||||
}
|
||||
"next" => {
|
||||
if let Err(e) = controller.next() {
|
||||
error!("[MediaSession] Next failed: {}", e);
|
||||
}
|
||||
}
|
||||
"previous" => {
|
||||
if let Err(e) = controller.previous() {
|
||||
error!("[MediaSession] Previous failed: {}", e);
|
||||
}
|
||||
}
|
||||
"stop" => {
|
||||
if let Err(e) = controller.stop() {
|
||||
error!("[MediaSession] Stop failed: {}", e);
|
||||
}
|
||||
}
|
||||
cmd if cmd.starts_with("seek:") => {
|
||||
if let Ok(pos) = cmd[5..].parse::<f64>() {
|
||||
if let Err(e) = controller.seek(pos) {
|
||||
error!("[MediaSession] Seek failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("[MediaSession] Unknown command: {}", command);
|
||||
match self.playback_mode.get_mode() {
|
||||
playback_mode::PlaybackMode::Remote { session_id } => {
|
||||
self.handle_remote_command(command, session_id);
|
||||
}
|
||||
_ => self.handle_local_command(command),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -732,16 +804,11 @@ pub fn run() {
|
||||
|
||||
let player_arc = Arc::new(TokioMutex::new(player_controller));
|
||||
|
||||
// On Android, set up the MediaSession handler for lockscreen controls
|
||||
// On Android, register the player controller for autoplay decisions.
|
||||
// The MediaSession (lockscreen) handler is set up later, once the
|
||||
// playback mode manager exists, so it can route to remote sessions.
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
|
||||
let handler = Arc::new(MediaSessionHandler {
|
||||
player: player_arc.clone(),
|
||||
});
|
||||
set_media_command_handler(handler);
|
||||
|
||||
// Register player controller for autoplay decisions
|
||||
player::android::set_player_controller(player_arc.clone());
|
||||
}
|
||||
|
||||
@@ -780,9 +847,19 @@ pub fn run() {
|
||||
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc);
|
||||
app.manage(session_poller_wrapper);
|
||||
|
||||
// On Android, set up remote volume handler for volume button intercept in remote mode
|
||||
// On Android, set up the MediaSession (lockscreen) handler and the
|
||||
// remote volume handler. Both need the playback mode manager so they
|
||||
// can route to the active remote session while casting.
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
|
||||
let media_handler = Arc::new(MediaSessionHandler {
|
||||
player: player_arc.clone(),
|
||||
playback_mode: playback_mode_arc.clone(),
|
||||
event_emitter: event_emitter.clone(),
|
||||
});
|
||||
set_media_command_handler(media_handler);
|
||||
|
||||
info!("[INIT] Setting up remote volume handler for Android...");
|
||||
let handler = Arc::new(RemoteVolumeSessionHandler {
|
||||
playback_mode: playback_mode_arc.clone(),
|
||||
|
||||
@@ -1117,6 +1117,92 @@ pub fn disable_remote_volume() -> Result<(), String> {
|
||||
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> {
|
||||
|
||||
@@ -119,6 +119,12 @@ pub enum PlayerStatusEvent {
|
||||
/// All active controllable sessions from Jellyfin
|
||||
sessions: Vec<crate::jellyfin::client::SessionInfo>,
|
||||
},
|
||||
/// The user asked to disconnect from the remote session and resume locally.
|
||||
///
|
||||
/// Emitted when the lockscreen Stop button is pressed while casting. The
|
||||
/// frontend owns the two-step remote->local transfer (it must reload the
|
||||
/// media item locally), so the native side only signals intent here.
|
||||
RemoteDisconnectRequested,
|
||||
}
|
||||
|
||||
/// Trait for emitting player events to the frontend.
|
||||
|
||||
@@ -46,6 +46,37 @@ pub use android::{
|
||||
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
|
||||
};
|
||||
|
||||
/// Metadata for the lockscreen / media notification.
|
||||
///
|
||||
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
|
||||
/// the local ExoPlayer is idle and so can't supply now-playing info. The session
|
||||
/// poller fills this in from the remote Jellyfin session and pushes it to the
|
||||
/// notification so the lockscreen stays in sync while casting.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LockscreenMetadata {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
/// Track duration in milliseconds.
|
||||
pub duration_ms: i64,
|
||||
/// Current playback position in milliseconds.
|
||||
pub position_ms: i64,
|
||||
pub is_playing: bool,
|
||||
}
|
||||
|
||||
/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
|
||||
/// session poller can call it unconditionally and stay platform-agnostic.
|
||||
pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
return android::update_lockscreen_metadata(_meta);
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::{debug, error, warn};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -112,6 +112,18 @@ impl SessionPollerManager {
|
||||
match sessions_result {
|
||||
Ok(sessions) => {
|
||||
debug!("[SessionPoller] Fetched {} sessions", sessions.len());
|
||||
|
||||
// In remote (cast) mode, mirror the remote session's
|
||||
// now-playing onto the Android lockscreen. The local
|
||||
// ExoPlayer is idle while casting, so without this the
|
||||
// lockscreen shows stale local metadata and a frozen
|
||||
// scrubber. Driving it here (native poll thread) rather
|
||||
// than from the WebView keeps it live even when the
|
||||
// screen is locked and JS timers are throttled.
|
||||
if let PlaybackMode::Remote { session_id } = mode_manager.get_mode() {
|
||||
Self::push_remote_lockscreen(&sessions, &session_id);
|
||||
}
|
||||
|
||||
if let Some(em) = emitter.lock_safe().as_ref() {
|
||||
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
|
||||
sessions,
|
||||
@@ -150,6 +162,66 @@ impl SessionPollerManager {
|
||||
*self.current_hint.write_safe() = hint;
|
||||
}
|
||||
|
||||
/// Push the remote session's now-playing onto the Android lockscreen.
|
||||
///
|
||||
/// Looks up the active remote session by id and forwards its title/artist/
|
||||
/// album, duration and position to the media notification. Silently does
|
||||
/// nothing if the session isn't found or has no now-playing item (e.g. the
|
||||
/// remote stopped) - the next state change will refresh it.
|
||||
fn push_remote_lockscreen(
|
||||
sessions: &[crate::jellyfin::client::SessionInfo],
|
||||
session_id: &str,
|
||||
) {
|
||||
// 100ns Jellyfin ticks -> milliseconds.
|
||||
const TICKS_PER_MS: i64 = 10_000;
|
||||
|
||||
let Some(session) = sessions
|
||||
.iter()
|
||||
.find(|s| s.id.as_deref() == Some(session_id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(now_playing) = session.now_playing_item.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let title = now_playing.name.clone().unwrap_or_default();
|
||||
let artist = now_playing
|
||||
.artists
|
||||
.as_ref()
|
||||
.map(|a| a.join(", "))
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| now_playing.album_artist.clone())
|
||||
.unwrap_or_default();
|
||||
let album = now_playing.album.clone();
|
||||
let duration_ms = now_playing.run_time_ticks.unwrap_or(0) / TICKS_PER_MS;
|
||||
|
||||
let (position_ms, is_playing) = session
|
||||
.play_state
|
||||
.as_ref()
|
||||
.map(|ps| {
|
||||
(
|
||||
ps.position_ticks.unwrap_or(0) / TICKS_PER_MS,
|
||||
!ps.is_paused.unwrap_or(false),
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, false));
|
||||
|
||||
let meta = crate::player::LockscreenMetadata {
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
duration_ms,
|
||||
position_ms,
|
||||
is_playing,
|
||||
};
|
||||
|
||||
if let Err(e) = crate::player::update_lockscreen_metadata(&meta) {
|
||||
warn!("[SessionPoller] Failed to update lockscreen metadata: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate polling interval based on mode and hint
|
||||
fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 {
|
||||
match hint {
|
||||
|
||||
Reference in New Issue
Block a user