diff --git a/docs/assets/logo.png b/docs/assets/logo.png index b789169..a9e44e5 100644 Binary files a/docs/assets/logo.png and b/docs/assets/logo.png differ diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt index 4578aa4..38de5fc 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt @@ -87,48 +87,39 @@ class JellyTauPlaybackService : MediaSessionService() { val jellyTauPlayer = JellyTauPlayer.getInstance() 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) { override fun play() { - // Execute immediately for instant lockscreen response - super.play() - // Then notify Rust for state management nativeOnMediaCommand("play") } override fun pause() { - // Execute immediately for instant lockscreen response - super.pause() - // Then notify Rust for state management nativeOnMediaCommand("pause") } override fun seekToNext() { - // Execute immediately for instant lockscreen response - super.seekToNext() - // Then notify Rust for queue management nativeOnMediaCommand("next") } override fun seekToPrevious() { - // Execute immediately for instant lockscreen response - super.seekToPrevious() - // Then notify Rust for queue management nativeOnMediaCommand("previous") } override fun seekTo(positionMs: Long) { - // Execute immediately for instant lockscreen response - super.seekTo(positionMs) - // Then notify Rust of seek val positionSeconds = positionMs / 1000.0 nativeOnMediaCommand("seek:$positionSeconds") } override fun stop() { - // Execute immediately for instant lockscreen response - super.stop() - // Then notify Rust for state management nativeOnMediaCommand("stop") } } @@ -160,36 +151,44 @@ class JellyTauPlaybackService : MediaSessionService() { ) 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() { override fun onPlay() { android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed") - wrappedPlayer?.play() + nativeOnMediaCommand("play") } override fun onPause() { android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed") - wrappedPlayer?.pause() + nativeOnMediaCommand("pause") } override fun onSkipToNext() { android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed") - wrappedPlayer?.seekToNext() + nativeOnMediaCommand("next") } override fun onSkipToPrevious() { android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed") - wrappedPlayer?.seekToPrevious() + nativeOnMediaCommand("previous") } override fun onStop() { android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed") - wrappedPlayer?.stop() + nativeOnMediaCommand("stop") } override fun onSeekTo(position: Long) { 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() } + // 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. - * This updates both the MediaSession and the notification. + * Update the MediaSession metadata and playback state, plus 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( title: String, @@ -267,6 +276,10 @@ class JellyTauPlaybackService : MediaSessionService() { ) { val session = mediaSessionCompat ?: return + lastTitle = title + lastArtist = artist + lastIsPlaying = isPlaying + // Update MediaSession metadata val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder() .putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title) @@ -280,7 +293,42 @@ class JellyTauPlaybackService : MediaSessionService() { session.setMetadata(metadataBuilder.build()) // 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( PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or @@ -292,13 +340,9 @@ class JellyTauPlaybackService : MediaSessionService() { .setState( if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, position, - 1.0f + if (isPlaying) 1.0f else 0.0f ) - - session.setPlaybackState(stateBuilder.build()) - - // Update the notification - updateNotification(title, artist, isPlaying) + .build() } /** diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt index cd8203b..874100b 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt @@ -760,10 +760,16 @@ class JellyTauPlayer(private val appContext: Context) { android.util.Log.d("JellyTauPlayer", "Started position updates coroutine") while (isActive) { 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 android.util.Log.v("JellyTauPlayer", "Position update: $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) } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 2dc5c94..6ca22b7 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 468cf0d..6b28b8d 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 9329f29..b86aafe 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index d016337..e107cb5 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index e590cdf..87f32b1 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index e085d08..105c109 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 7e5f8ff..2ef28eb 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 5411cac..49c9607 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index face76f..b772a6f 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 29089fb..3316fb3 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 5978d75..0949e2f 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 4d4aada..5e9448e 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index d32765e..91361f8 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 3f0e1cb..628a77b 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 01e6e50..3034118 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 69b38f3..ba2e46c 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index a8f5866..af61bcb 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png index b5edac4..7dd1068 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index b891bb2..0813ed1 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png index b891bb2..0813ed1 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png index 20cc9a4..b971743 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png index 8ee1157..8a879bf 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index 504eb2f..e5a5361 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png index 504eb2f..e5a5361 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png index 868f5fe..ee10d5a 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png index b891bb2..0813ed1 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index ee7c587..3cc8956 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png index ee7c587..3cc8956 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png index 625683a..2b7a1ab 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png index 949df8e..0137102 100644 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png index 625683a..2b7a1ab 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png index e667594..340e88e 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png index 8817bb0..da6e8ef 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png index 1de1203..362bcf6 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 6043e59..92527c9 100644 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a147da7..c470b73 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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>, + playback_mode: Arc, + event_emitter: Arc, +} + +#[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::() { + 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::() { + 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::() { - 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(), diff --git a/src-tauri/src/player/android/mod.rs b/src-tauri/src/player/android/mod.rs index f082755..a198a18 100644 --- a/src-tauri/src/player/android/mod.rs +++ b/src-tauri/src/player/android/mod.rs @@ -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> { diff --git a/src-tauri/src/player/events.rs b/src-tauri/src/player/events.rs index 343b9b9..e6205db 100644 --- a/src-tauri/src/player/events.rs +++ b/src-tauri/src/player/events.rs @@ -119,6 +119,12 @@ pub enum PlayerStatusEvent { /// All active controllable sessions from Jellyfin sessions: Vec, }, + /// 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. diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 0875708..427ca51 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -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, + /// 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}; diff --git a/src-tauri/src/session_poller/mod.rs b/src-tauri/src/session_poller/mod.rs index 4ebdf2b..5c22fc7 100644 --- a/src-tauri/src/session_poller/mod.rs +++ b/src-tauri/src/session_poller/mod.rs @@ -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 { diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 818ef4f..f831a0a 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -1941,7 +1941,15 @@ export type PlayerStatusEvent = /** * 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 * diff --git a/src/lib/stores/playbackMode.ts b/src/lib/stores/playbackMode.ts index 6b7bb6c..de10e17 100644 --- a/src/lib/stores/playbackMode.ts +++ b/src/lib/stores/playbackMode.ts @@ -10,7 +10,7 @@ */ 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 { auth } from "./auth"; import { ticksToSeconds } from "$lib/utils/playbackUnits"; @@ -275,6 +275,22 @@ function createPlaybackModeStore() { let consecutiveMisses = 0; 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) => { const currentState = get({ subscribe });