Merge pull request 'fix/lockscreen-mediasession-sync' (#7) from fix/lockscreen-mediasession-sync into master
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m47s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m40s

Reviewed-on: #7
This commit is contained in:
dtourolle 2026-06-27 21:57:22 +00:00
commit ef7be645b3
45 changed files with 432 additions and 86 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -87,48 +87,39 @@ class JellyTauPlaybackService : MediaSessionService() {
val jellyTauPlayer = JellyTauPlayer.getInstance() val jellyTauPlayer = JellyTauPlayer.getInstance()
val exoPlayer = jellyTauPlayer.getExoPlayer() val exoPlayer = jellyTauPlayer.getExoPlayer()
// Wrap the ExoPlayer to intercept commands // Wrap the ExoPlayer to intercept commands from Media3 controllers
// (e.g. Android Auto / Wear / system surfaces that bind to the Media3
// session rather than the MediaSessionCompat).
//
// We do NOT execute on ExoPlayer directly here. Every transport command
// is routed to Rust via nativeOnMediaCommand, which is the single decision
// point: in local mode Rust drives ExoPlayer, in remote (cast) mode Rust
// forwards to the remote Jellyfin session. Executing on ExoPlayer here too
// would double-handle local commands and incorrectly drive the local
// player while casting.
wrappedPlayer = object : ForwardingPlayer(exoPlayer) { wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
override fun play() { override fun play() {
// Execute immediately for instant lockscreen response
super.play()
// Then notify Rust for state management
nativeOnMediaCommand("play") nativeOnMediaCommand("play")
} }
override fun pause() { override fun pause() {
// Execute immediately for instant lockscreen response
super.pause()
// Then notify Rust for state management
nativeOnMediaCommand("pause") nativeOnMediaCommand("pause")
} }
override fun seekToNext() { override fun seekToNext() {
// Execute immediately for instant lockscreen response
super.seekToNext()
// Then notify Rust for queue management
nativeOnMediaCommand("next") nativeOnMediaCommand("next")
} }
override fun seekToPrevious() { override fun seekToPrevious() {
// Execute immediately for instant lockscreen response
super.seekToPrevious()
// Then notify Rust for queue management
nativeOnMediaCommand("previous") nativeOnMediaCommand("previous")
} }
override fun seekTo(positionMs: Long) { override fun seekTo(positionMs: Long) {
// Execute immediately for instant lockscreen response
super.seekTo(positionMs)
// Then notify Rust of seek
val positionSeconds = positionMs / 1000.0 val positionSeconds = positionMs / 1000.0
nativeOnMediaCommand("seek:$positionSeconds") nativeOnMediaCommand("seek:$positionSeconds")
} }
override fun stop() { override fun stop() {
// Execute immediately for instant lockscreen response
super.stop()
// Then notify Rust for state management
nativeOnMediaCommand("stop") nativeOnMediaCommand("stop")
} }
} }
@ -160,36 +151,44 @@ class JellyTauPlaybackService : MediaSessionService() {
) )
isActive = true isActive = true
// Set callback to handle lock screen button presses // Set callback to handle lock screen button presses.
//
// All transport commands are routed through Rust via nativeOnMediaCommand
// rather than directly to ExoPlayer. Rust is the single decision point:
// in local mode it drives ExoPlayer, in remote (cast) mode it forwards
// the command to the remote Jellyfin session. This keeps the lockscreen
// working identically for both, and avoids the ExoPlayer-only behaviour
// that left remote playback uncontrollable from the lockscreen.
setCallback(object : MediaSessionCompat.Callback() { setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() { override fun onPlay() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
wrappedPlayer?.play() nativeOnMediaCommand("play")
} }
override fun onPause() { override fun onPause() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
wrappedPlayer?.pause() nativeOnMediaCommand("pause")
} }
override fun onSkipToNext() { override fun onSkipToNext() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
wrappedPlayer?.seekToNext() nativeOnMediaCommand("next")
} }
override fun onSkipToPrevious() { override fun onSkipToPrevious() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
wrappedPlayer?.seekToPrevious() nativeOnMediaCommand("previous")
} }
override fun onStop() { override fun onStop() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
wrappedPlayer?.stop() nativeOnMediaCommand("stop")
} }
override fun onSeekTo(position: Long) { override fun onSeekTo(position: Long) {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
wrappedPlayer?.seekTo(position) val positionSeconds = position / 1000.0
nativeOnMediaCommand("seek:$positionSeconds")
} }
}) })
} }
@ -253,9 +252,19 @@ class JellyTauPlaybackService : MediaSessionService() {
.build() .build()
} }
// Last-known metadata/state, retained so lightweight position ticks can
// rebuild a correct PlaybackState without re-sending the (heavier) metadata
// and notification. Kept in sync by updateMediaMetadata().
private var lastTitle: String = ""
private var lastArtist: String = ""
private var lastIsPlaying: Boolean = false
/** /**
* Update the MediaSession metadata and playback state. * Update the MediaSession metadata and playback state, plus the notification.
* This updates both the MediaSession and the notification. *
* Call this when the track or play/pause state changes. For frequent position
* updates during playback, use [updatePlaybackPosition] instead, which is much
* cheaper (no metadata rebuild, no notification rebuild).
*/ */
fun updateMediaMetadata( fun updateMediaMetadata(
title: String, title: String,
@ -267,6 +276,10 @@ class JellyTauPlaybackService : MediaSessionService() {
) { ) {
val session = mediaSessionCompat ?: return val session = mediaSessionCompat ?: return
lastTitle = title
lastArtist = artist
lastIsPlaying = isPlaying
// Update MediaSession metadata // Update MediaSession metadata
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder() val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title) .putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
@ -280,7 +293,42 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build()) session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state // Update MediaSession playback state
val stateBuilder = PlaybackStateCompat.Builder() session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Update the notification
updateNotification(title, artist, isPlaying)
}
/**
* Update only the playback position (and play/pause state) on the MediaSession.
*
* This is the cheap path used for the periodic (250ms) position ticks: it
* refreshes the lockscreen scrubber without rebuilding metadata or the
* notification. Without this, the lockscreen scrubber freezes at the position
* from the last play/pause and drifts out of sync with actual playback.
*
* @param position Position in milliseconds
* @param isPlaying Whether playback is currently active
*/
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
val session = mediaSessionCompat ?: return
val notificationStateChanged = isPlaying != lastIsPlaying
lastIsPlaying = isPlaying
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Only rebuild the notification when the play/pause icon actually flips.
if (notificationStateChanged) {
updateNotification(lastTitle, lastArtist, isPlaying)
}
}
/**
* Build a PlaybackStateCompat with the standard transport actions.
*
* The reported playback speed is 1.0 while playing and 0.0 while paused so
* Android does not extrapolate the position past a paused track.
*/
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
return PlaybackStateCompat.Builder()
.setActions( .setActions(
PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PAUSE or
@ -292,13 +340,9 @@ class JellyTauPlaybackService : MediaSessionService() {
.setState( .setState(
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
position, position,
1.0f if (isPlaying) 1.0f else 0.0f
) )
.build()
session.setPlaybackState(stateBuilder.build())
// Update the notification
updateNotification(title, artist, isPlaying)
} }
/** /**

View File

@ -760,10 +760,16 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine") android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
while (isActive) { while (isActive) {
if (exoPlayer.isPlaying) { if (exoPlayer.isPlaying) {
val position = exoPlayer.currentPosition / 1000.0 val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
val position = positionMs / 1000.0
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0 val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration") android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
nativeOnPositionUpdate(position, duration) nativeOnPositionUpdate(position, duration)
// Keep the lockscreen scrubber live. Without this the
// MediaSession position only refreshes on play/pause, so the
// scrubber freezes mid-track and drifts out of sync.
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
} }
delay(POSITION_UPDATE_INTERVAL_MS) delay(POSITION_UPDATE_INTERVAL_MS)
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 915 KiB

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -148,54 +148,126 @@ use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler
/// Handler for media commands from Android MediaSession (lockscreen/notification controls). /// 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")] #[cfg(target_os = "android")]
struct MediaSessionHandler { struct MediaSessionHandler {
player: Arc<TokioMutex<PlayerController>>, 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")] #[cfg(target_os = "android")]
impl MediaCommandHandler for MediaSessionHandler { impl MediaCommandHandler for MediaSessionHandler {
fn on_command(&self, command: &str) { fn on_command(&self, command: &str) {
// Use blocking_lock since this is called from a non-async JNI callback match self.playback_mode.get_mode() {
let controller = self.player.blocking_lock(); playback_mode::PlaybackMode::Remote { session_id } => {
self.handle_remote_command(command, session_id);
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);
} }
_ => self.handle_local_command(command),
} }
} }
} }
@ -732,16 +804,11 @@ pub fn run() {
let player_arc = Arc::new(TokioMutex::new(player_controller)); 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")] #[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()); player::android::set_player_controller(player_arc.clone());
} }
@ -780,9 +847,19 @@ pub fn run() {
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc); let session_poller_wrapper = SessionPollerWrapper(session_poller_arc);
app.manage(session_poller_wrapper); 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")] #[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..."); info!("[INIT] Setting up remote volume handler for Android...");
let handler = Arc::new(RemoteVolumeSessionHandler { let handler = Arc::new(RemoteVolumeSessionHandler {
playback_mode: playback_mode_arc.clone(), playback_mode: playback_mode_arc.clone(),

View File

@ -1117,6 +1117,92 @@ pub fn disable_remote_volume() -> Result<(), String> {
Ok(()) 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 /// Stub implementations for non-Android platforms
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> { pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {

View File

@ -119,6 +119,12 @@ pub enum PlayerStatusEvent {
/// All active controllable sessions from Jellyfin /// All active controllable sessions from Jellyfin
sessions: Vec<crate::jellyfin::client::SessionInfo>, 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. /// Trait for emitting player events to the frontend.

View File

@ -46,6 +46,37 @@ pub use android::{
set_media_command_handler, set_remote_volume_handler, get_detected_codecs, 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 crate::utils::lock::MutexSafe;
use log::{debug, error, warn}; use log::{debug, error, warn};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};

View File

@ -112,6 +112,18 @@ impl SessionPollerManager {
match sessions_result { match sessions_result {
Ok(sessions) => { Ok(sessions) => {
debug!("[SessionPoller] Fetched {} sessions", sessions.len()); 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() { if let Some(em) = emitter.lock_safe().as_ref() {
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated { em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
sessions, sessions,
@ -150,6 +162,66 @@ impl SessionPollerManager {
*self.current_hint.write_safe() = hint; *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 /// Calculate polling interval based on mode and hint
fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 { fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 {
match hint { match hint {

View File

@ -1941,7 +1941,15 @@ export type PlayerStatusEvent =
/** /**
* Remote sessions updated (for cast/remote control UI) * Remote sessions updated (for cast/remote control UI)
*/ */
{ type: "sessions_updated"; sessions: SessionInfo[] } { type: "sessions_updated"; sessions: 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.
*/
{ type: "remote_disconnect_requested" }
/** /**
* Result of creating a playlist * Result of creating a playlist
* *

View File

@ -10,7 +10,7 @@
*/ */
import { writable, get, derived } from "svelte/store"; import { writable, get, derived } from "svelte/store";
import { commands } from "$lib/api/bindings"; import { commands, events } from "$lib/api/bindings";
import { sessions, selectedSession } from "./sessions"; import { sessions, selectedSession } from "./sessions";
import { auth } from "./auth"; import { auth } from "./auth";
import { ticksToSeconds } from "$lib/utils/playbackUnits"; import { ticksToSeconds } from "$lib/utils/playbackUnits";
@ -275,6 +275,22 @@ function createPlaybackModeStore() {
let consecutiveMisses = 0; let consecutiveMisses = 0;
const DISCONNECT_THRESHOLD = 3; // ~6s at 2s polling interval const DISCONNECT_THRESHOLD = 3; // ~6s at 2s polling interval
// The lockscreen Stop button (while casting) asks the native side to
// disconnect from the remote session and resume locally. The remote->local
// transfer must be driven here because it reloads the media item locally,
// which only the frontend can currently do.
events.playerStatusEvent.listen((event) => {
if (event.payload.type === "remote_disconnect_requested") {
const currentState = get({ subscribe });
if (currentState.mode === "remote") {
console.log("[PlaybackMode] Lockscreen requested disconnect; transferring to local");
transferToLocal().catch((e) =>
console.error("[PlaybackMode] Lockscreen-triggered transfer failed:", e),
);
}
}
});
selectedSession.subscribe((session) => { selectedSession.subscribe((session) => {
const currentState = get({ subscribe }); const currentState = get({ subscribe });