Background-audio handoff for video + repository/player refactor
Hand video playback off to a native audio-only stream when the app is backgrounded or locked, with no on-device video decode (UR-040). Adds player_enter/exit_background_audio commands, an audio-only stream URL for video items across the repository layer, and the frontend handoff state machine wired into VideoPlayer. Includes accompanying repository/offline/player refactoring and regenerates the traceability matrix.
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
//! through JNI calls to Kotlin code.
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::debug;
|
||||
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};
|
||||
@@ -17,7 +17,7 @@ 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, PlaybackOperation};
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Global reference to the JavaVM for JNI callbacks
|
||||
@@ -33,10 +33,12 @@ static EVENT_EMITTER: OnceLock<SharedEventEmitter> = OnceLock::new();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
@@ -87,9 +89,9 @@ impl DetectedCodecs {
|
||||
|
||||
/// 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())
|
||||
})
|
||||
DETECTED_CODECS
|
||||
.get()
|
||||
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
|
||||
}
|
||||
|
||||
/// Trait for handling media commands from Android MediaSession.
|
||||
@@ -194,9 +196,9 @@ impl ExoPlayerBackend {
|
||||
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 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
|
||||
@@ -217,11 +219,16 @@ impl ExoPlayerBackend {
|
||||
.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)))?;
|
||||
.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_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(
|
||||
@@ -230,9 +237,13 @@ impl ExoPlayerBackend {
|
||||
"(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)))?
|
||||
.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)))?;
|
||||
.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);
|
||||
@@ -244,7 +255,9 @@ impl ExoPlayerBackend {
|
||||
"(Landroid/content/Context;)V",
|
||||
&[JValue::Object(context)],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e))
|
||||
})?;
|
||||
|
||||
// Get the singleton instance
|
||||
let player_obj = env
|
||||
@@ -254,14 +267,21 @@ impl ExoPlayerBackend {
|
||||
"()Lcom/dtourolle/jellytau/player/JellyTauPlayer;",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to get JellyTauPlayer instance: {}", e)))?
|
||||
.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)))?;
|
||||
.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)))?;
|
||||
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,
|
||||
@@ -271,16 +291,18 @@ impl ExoPlayerBackend {
|
||||
|
||||
/// 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 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)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call {}: {}", method, e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -314,43 +336,46 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
state.is_loaded = false;
|
||||
}
|
||||
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
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 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 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)))?;
|
||||
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)))?),
|
||||
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)))?),
|
||||
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)))?),
|
||||
Some(a) => Some(env.new_string(a).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create artwork string: {}", e))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -376,16 +401,16 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
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)))?;
|
||||
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)))?;
|
||||
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={}",
|
||||
@@ -414,7 +439,10 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
env.exception_describe().ok();
|
||||
env.exception_clear().ok();
|
||||
}
|
||||
return Err(PlayerError::playback_failed(format!("Failed to call loadWithMetadata: {}", e)));
|
||||
return Err(PlayerError::playback_failed(format!(
|
||||
"Failed to call loadWithMetadata: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
debug!("[Android] Successfully called loadWithMetadata");
|
||||
@@ -443,9 +471,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
}
|
||||
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -465,9 +493,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
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 vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -502,9 +530,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
}
|
||||
|
||||
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 vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -516,15 +544,17 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
"(I)V",
|
||||
&[JValue::Int(stream_index)],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e)))?;
|
||||
.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 vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -539,7 +569,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
"(I)V",
|
||||
&[JValue::Int(index)],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -603,7 +635,11 @@ fn report_android_progress(position: f64) {
|
||||
if !state.state.is_playing() {
|
||||
return;
|
||||
}
|
||||
match state.current_media.as_ref().and_then(|m| m.jellyfin_id().map(|s| s.to_string())) {
|
||||
match state
|
||||
.current_media
|
||||
.as_ref()
|
||||
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
}
|
||||
@@ -664,10 +700,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
state: JString,
|
||||
media_id: JString,
|
||||
) {
|
||||
let state_str: String = env
|
||||
.get_string(&state)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
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
|
||||
@@ -769,7 +802,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
// Log queue state before advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
||||
format!(
|
||||
"current_index={:?}, len={}",
|
||||
queue.current_index(),
|
||||
queue.items().len()
|
||||
)
|
||||
};
|
||||
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
|
||||
|
||||
@@ -779,7 +816,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
// Log queue state after advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
||||
format!(
|
||||
"current_index={:?}, len={}",
|
||||
queue.current_index(),
|
||||
queue.items().len()
|
||||
)
|
||||
};
|
||||
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
|
||||
|
||||
@@ -1002,7 +1043,8 @@ fn start_playback_service() -> Result<(), String> {
|
||||
.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")
|
||||
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
|
||||
@@ -1036,13 +1078,8 @@ fn start_playback_service() -> Result<(), String> {
|
||||
}
|
||||
|
||||
// Call startPlaybackService() on the player instance
|
||||
env.call_method(
|
||||
&player_obj,
|
||||
"startPlaybackService",
|
||||
"()V",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to start playback service: {}", e))?;
|
||||
env.call_method(&player_obj, "startPlaybackService", "()V", &[])
|
||||
.map_err(|e| format!("Failed to start playback service: {}", e))?;
|
||||
|
||||
log::info!("[Android] JellyTauPlaybackService start requested");
|
||||
Ok(())
|
||||
@@ -1056,7 +1093,10 @@ fn start_playback_service() -> Result<(), String> {
|
||||
/// @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);
|
||||
log::info!(
|
||||
"[Android] Enabling remote volume control (volume={})",
|
||||
initial_volume
|
||||
);
|
||||
|
||||
// Ensure the playback service is started first
|
||||
start_playback_service()?;
|
||||
@@ -1078,7 +1118,8 @@ pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
|
||||
.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")
|
||||
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
|
||||
@@ -1146,7 +1187,8 @@ pub fn disable_remote_volume() -> Result<(), String> {
|
||||
.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")
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Autoplay decision logic
|
||||
// TRACES: UR-023, UR-026 | DR-047, DR-048, DR-029
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::repository::types::MediaItem;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Autoplay decision result - determines what happens after playback ends
|
||||
#[derive(specta::Type, Debug, Clone, Serialize)]
|
||||
|
||||
@@ -163,7 +163,12 @@ impl PlayerBackend for NullBackend {
|
||||
}
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
if let PlayerState::Paused { media, position, duration } = &self.state {
|
||||
if let PlayerState::Paused {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
} = &self.state
|
||||
{
|
||||
self.state = PlayerState::Playing {
|
||||
media: media.clone(),
|
||||
position: *position,
|
||||
@@ -174,7 +179,12 @@ impl PlayerBackend for NullBackend {
|
||||
}
|
||||
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
if let PlayerState::Playing { media, position, duration } = &self.state {
|
||||
if let PlayerState::Playing {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
} = &self.state
|
||||
{
|
||||
self.state = PlayerState::Paused {
|
||||
media: media.clone(),
|
||||
position: *position,
|
||||
|
||||
@@ -138,8 +138,12 @@ impl MediaItem {
|
||||
/// Get the Jellyfin item ID if available
|
||||
pub fn jellyfin_id(&self) -> Option<&str> {
|
||||
match &self.source {
|
||||
MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id),
|
||||
MediaSource::Local { jellyfin_item_id, .. } => jellyfin_item_id.as_deref(),
|
||||
MediaSource::Remote {
|
||||
jellyfin_item_id, ..
|
||||
} => Some(jellyfin_item_id),
|
||||
MediaSource::Local {
|
||||
jellyfin_item_id, ..
|
||||
} => jellyfin_item_id.as_deref(),
|
||||
MediaSource::DirectUrl { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -151,9 +155,7 @@ impl MediaItem {
|
||||
pub fn playback_url(&self) -> String {
|
||||
match &self.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
MediaSource::Local { file_path, .. } => {
|
||||
file_path.to_string_lossy().to_string()
|
||||
}
|
||||
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
|
||||
MediaSource::DirectUrl { url } => url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
+397
-90
@@ -42,8 +42,8 @@ pub use mpv_backend::MpvBackend;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::{
|
||||
MediaCommandHandler, RemoteVolumeHandler, enable_remote_volume, disable_remote_volume,
|
||||
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
|
||||
disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
|
||||
set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
|
||||
};
|
||||
|
||||
/// Metadata for the lockscreen / media notification.
|
||||
@@ -53,6 +53,9 @@ pub use android::{
|
||||
/// 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)]
|
||||
// Fields are read only by the Android MediaSession bridge; on other platforms
|
||||
// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub struct LockscreenMetadata {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
@@ -84,9 +87,11 @@ use std::time::Duration;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::playback_reporting::{
|
||||
EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
|
||||
};
|
||||
use crate::repository::MediaRepository;
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation, PlaybackContext};
|
||||
use crate::settings::AudioSettings;
|
||||
|
||||
/// Central player controller that coordinates playback
|
||||
pub struct PlayerController {
|
||||
@@ -157,7 +162,10 @@ impl PlayerController {
|
||||
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
|
||||
let mut jellyfin = self.jellyfin_client.lock_safe();
|
||||
*jellyfin = client;
|
||||
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
|
||||
log::info!(
|
||||
"[PlayerController] Jellyfin client configured: {}",
|
||||
jellyfin.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a reference to the Jellyfin client (for remote session control)
|
||||
@@ -180,7 +188,10 @@ impl PlayerController {
|
||||
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
|
||||
let mut reporter_guard = self.playback_reporter.lock().await;
|
||||
*reporter_guard = reporter;
|
||||
log::info!("[PlayerController] Playback reporter configured: {}", reporter_guard.is_some());
|
||||
log::info!(
|
||||
"[PlayerController] Playback reporter configured: {}",
|
||||
reporter_guard.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a reference to the playback reporter (for backend position updates)
|
||||
@@ -219,7 +230,10 @@ impl PlayerController {
|
||||
|
||||
let mut count = self.autoplay_episode_count.lock_safe();
|
||||
*count += 1;
|
||||
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
|
||||
debug!(
|
||||
"[PlayerController] Autoplay episode count: {}/{}",
|
||||
*count, max
|
||||
);
|
||||
|
||||
*count >= max
|
||||
}
|
||||
@@ -228,7 +242,10 @@ impl PlayerController {
|
||||
fn reset_autoplay_count(&self) {
|
||||
let mut count = self.autoplay_episode_count.lock_safe();
|
||||
if *count > 0 {
|
||||
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
|
||||
debug!(
|
||||
"[PlayerController] Resetting autoplay episode counter (was {})",
|
||||
*count
|
||||
);
|
||||
}
|
||||
*count = 0;
|
||||
}
|
||||
@@ -259,7 +276,10 @@ impl PlayerController {
|
||||
/// item, but MPV must not start a redundant decode for it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
||||
debug!("[PlayerController] set_current_item (no backend load): {}", item.title);
|
||||
debug!(
|
||||
"[PlayerController] set_current_item (no backend load): {}",
|
||||
item.title
|
||||
);
|
||||
|
||||
self.reset_autoplay_count();
|
||||
|
||||
@@ -446,7 +466,9 @@ impl PlayerController {
|
||||
// Get current playback info before stopping
|
||||
let jellyfin_id = {
|
||||
let queue = self.queue.lock_safe();
|
||||
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
|
||||
queue
|
||||
.current()
|
||||
.and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
|
||||
};
|
||||
|
||||
let position_ticks = {
|
||||
@@ -522,7 +544,10 @@ impl PlayerController {
|
||||
queue.next().cloned()
|
||||
};
|
||||
|
||||
debug!("[PlayerController] next: {:?}", next_item.as_ref().map(|i| &i.title));
|
||||
debug!(
|
||||
"[PlayerController] next: {:?}",
|
||||
next_item.as_ref().map(|i| &i.title)
|
||||
);
|
||||
|
||||
if let Some(item) = next_item {
|
||||
self.load_and_play(&item)
|
||||
@@ -554,7 +579,10 @@ impl PlayerController {
|
||||
queue.previous().cloned()
|
||||
};
|
||||
|
||||
debug!("[PlayerController] previous: {:?}", prev_item.as_ref().map(|i| &i.title));
|
||||
debug!(
|
||||
"[PlayerController] previous: {:?}",
|
||||
prev_item.as_ref().map(|i| &i.title)
|
||||
);
|
||||
|
||||
if let Some(item) = prev_item {
|
||||
self.load_and_play(&item)
|
||||
@@ -707,7 +735,9 @@ impl PlayerController {
|
||||
timer.update_remaining_seconds();
|
||||
|
||||
// Time-based timer expired: stop playback
|
||||
if matches!(timer.mode, SleepTimerMode::Time { .. }) && timer.remaining_seconds == 0 {
|
||||
if matches!(timer.mode, SleepTimerMode::Time { .. })
|
||||
&& timer.remaining_seconds == 0
|
||||
{
|
||||
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
||||
timer.cancel();
|
||||
|
||||
@@ -843,7 +873,10 @@ impl PlayerController {
|
||||
// Check why playback ended
|
||||
let end_reason = self.take_end_reason();
|
||||
|
||||
debug!("[PlayerController] on_playback_ended: end_reason={:?}", end_reason);
|
||||
debug!(
|
||||
"[PlayerController] on_playback_ended: end_reason={:?}",
|
||||
end_reason
|
||||
);
|
||||
|
||||
// Only proceed with autoplay logic if track finished naturally
|
||||
match end_reason {
|
||||
@@ -907,8 +940,8 @@ impl PlayerController {
|
||||
}
|
||||
SleepTimerMode::Episodes { .. } => {
|
||||
// Only count TV episodes (not audio tracks or movies)
|
||||
let is_episode = current.media_type == MediaType::Video
|
||||
&& self.is_episode_item(¤t).await;
|
||||
let is_episode =
|
||||
current.media_type == MediaType::Video && self.is_episode_item(¤t).await;
|
||||
|
||||
if is_episode {
|
||||
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
||||
@@ -936,7 +969,10 @@ impl PlayerController {
|
||||
match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
warn!("[PlayerController] Next-episode lookup failed for {}: {}", jellyfin_id, e);
|
||||
warn!(
|
||||
"[PlayerController] Next-episode lookup failed for {}: {}",
|
||||
jellyfin_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -950,11 +986,14 @@ impl PlayerController {
|
||||
// Check if auto-play episode limit is reached
|
||||
let limit_reached = self.increment_autoplay_count();
|
||||
if limit_reached {
|
||||
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||
debug!(
|
||||
"[PlayerController] Auto-play episode limit reached ({} episodes)",
|
||||
settings.max_episodes
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
current_episode: next_ep.0, // Repository MediaItem
|
||||
current_episode: next_ep.0, // Repository MediaItem
|
||||
next_episode: next_ep.1,
|
||||
countdown_seconds: settings.countdown_seconds,
|
||||
auto_advance: settings.enabled && !limit_reached,
|
||||
@@ -993,10 +1032,16 @@ impl PlayerController {
|
||||
// Clear any stale end_reason (e.g., UserStop from stopping audio before video)
|
||||
let stale_reason = self.take_end_reason();
|
||||
if stale_reason.is_some() {
|
||||
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
|
||||
debug!(
|
||||
"[PlayerController] Cleared stale end_reason for video: {:?}",
|
||||
stale_reason
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
|
||||
log::info!(
|
||||
"[PlayerController] on_video_playback_ended: item_id={}",
|
||||
item_id
|
||||
);
|
||||
|
||||
// Check sleep timer state
|
||||
let timer_mode = {
|
||||
@@ -1035,7 +1080,10 @@ impl PlayerController {
|
||||
let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
warn!("[PlayerController] Next-episode lookup failed for {}: {}", item_id, e);
|
||||
warn!(
|
||||
"[PlayerController] Next-episode lookup failed for {}: {}",
|
||||
item_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -1044,7 +1092,10 @@ impl PlayerController {
|
||||
|
||||
let limit_reached = self.increment_autoplay_count();
|
||||
if limit_reached {
|
||||
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||
debug!(
|
||||
"[PlayerController] Auto-play episode limit reached ({} episodes)",
|
||||
settings.max_episodes
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
@@ -1077,11 +1128,18 @@ impl PlayerController {
|
||||
&self,
|
||||
item_id: &str,
|
||||
repo: &Arc<dyn crate::repository::MediaRepository>,
|
||||
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
|
||||
) -> Result<
|
||||
Option<(
|
||||
crate::repository::types::MediaItem,
|
||||
crate::repository::types::MediaItem,
|
||||
)>,
|
||||
String,
|
||||
> {
|
||||
use crate::repository::types::GetItemsOptions;
|
||||
|
||||
// Get the current item details from repository
|
||||
let current_repo_item = repo.get_item(item_id)
|
||||
let current_repo_item = repo
|
||||
.get_item(item_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get current item: {}", e))?;
|
||||
|
||||
@@ -1089,7 +1147,9 @@ impl PlayerController {
|
||||
let season_id = match ¤t_repo_item.season_id {
|
||||
Some(sid) => sid.clone(),
|
||||
None => {
|
||||
log::info!("[PlayerController] Current item has no season_id, cannot find next episode");
|
||||
log::info!(
|
||||
"[PlayerController] Current item has no season_id, cannot find next episode"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
@@ -1103,7 +1163,8 @@ impl PlayerController {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = repo.get_items(&season_id, Some(options))
|
||||
let result = repo
|
||||
.get_items(&season_id, Some(options))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
|
||||
|
||||
@@ -1111,26 +1172,45 @@ impl PlayerController {
|
||||
// (offline repo ignores sort_by and sorts by sort_name instead)
|
||||
let mut episodes = result.items;
|
||||
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
|
||||
log::info!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
|
||||
log::info!(
|
||||
"[PlayerController] Season has {} episodes, looking for next after {}",
|
||||
episodes.len(),
|
||||
current_repo_item.id
|
||||
);
|
||||
|
||||
// Find the current episode by ID and return the next one
|
||||
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
|
||||
if current_idx + 1 < episodes.len() {
|
||||
let next = &episodes[current_idx + 1];
|
||||
log::info!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
|
||||
log::info!(
|
||||
"[PlayerController] Found next episode: {} (index {})",
|
||||
next.name,
|
||||
current_idx + 1
|
||||
);
|
||||
return Ok(Some((current_repo_item, next.clone())));
|
||||
} else {
|
||||
log::info!("[PlayerController] Current episode is the last in the season");
|
||||
}
|
||||
} else {
|
||||
log::info!("[PlayerController] Current episode not found in season episodes (ids: {:?})", episodes.iter().map(|e| e.id.as_str()).take(20).collect::<Vec<_>>());
|
||||
log::info!(
|
||||
"[PlayerController] Current episode not found in season episodes (ids: {:?})",
|
||||
episodes
|
||||
.iter()
|
||||
.map(|e| e.id.as_str())
|
||||
.take(20)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Start autoplay countdown thread
|
||||
pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) {
|
||||
pub fn start_autoplay_countdown(
|
||||
&self,
|
||||
_next_item: crate::repository::types::MediaItem,
|
||||
countdown_seconds: u32,
|
||||
) {
|
||||
// Create cancellation flag
|
||||
let cancel_flag = Arc::new(Mutex::new(false));
|
||||
*self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
|
||||
@@ -1169,7 +1249,11 @@ impl Default for PlayerController {
|
||||
fn default() -> Self {
|
||||
let playback_reporter = Arc::new(TokioMutex::new(None));
|
||||
let position_throttler = Arc::new(EventThrottler::new());
|
||||
Self::new(Box::new(NullBackend::new()), playback_reporter, position_throttler)
|
||||
Self::new(
|
||||
Box::new(NullBackend::new()),
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,8 +1416,16 @@ mod tests {
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
|
||||
assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0");
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(0),
|
||||
"Should start at index 0"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_0",
|
||||
"Current item should be item_0"
|
||||
);
|
||||
}
|
||||
|
||||
// Skip to next track
|
||||
@@ -1343,15 +1435,35 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip");
|
||||
assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
5,
|
||||
"Queue should still have 5 items after skip"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(1),
|
||||
"Index should advance to 1"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_1",
|
||||
"Current item should be item_1"
|
||||
);
|
||||
|
||||
// Verify all original items are still present
|
||||
let current_items = queue_lock.items();
|
||||
for (i, original) in items_clone.iter().enumerate() {
|
||||
assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i);
|
||||
assert_eq!(current_items[i].title, original.title, "Item {} title should be unchanged", i);
|
||||
assert_eq!(
|
||||
current_items[i].id, original.id,
|
||||
"Item {} should still be in queue",
|
||||
i
|
||||
);
|
||||
assert_eq!(
|
||||
current_items[i].title, original.title,
|
||||
"Item {} title should be unchanged",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1362,9 +1474,21 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip");
|
||||
assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
5,
|
||||
"Queue should still have 5 items after second skip"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(2),
|
||||
"Index should advance to 2"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_2",
|
||||
"Current item should be item_2"
|
||||
);
|
||||
}
|
||||
|
||||
// Skip multiple times to reach the end
|
||||
@@ -1375,9 +1499,21 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end");
|
||||
assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
5,
|
||||
"Queue should still have 5 items at end"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(4),
|
||||
"Index should be at last item (4)"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_4",
|
||||
"Current item should be item_4"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1397,7 +1533,11 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(2),
|
||||
"Should be at last item"
|
||||
);
|
||||
}
|
||||
|
||||
// Try to skip past the end (without repeat mode)
|
||||
@@ -1408,7 +1548,11 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
3,
|
||||
"Queue should still have 3 items after skip at end"
|
||||
);
|
||||
// When we skip past the end, the queue index should stay at the last item
|
||||
// or become None (depending on implementation)
|
||||
// The key is the queue items themselves should be preserved
|
||||
@@ -1437,9 +1581,21 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items");
|
||||
assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
3,
|
||||
"Queue should still have 3 items"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(0),
|
||||
"Should wrap to index 0"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_0",
|
||||
"Should be back at item_0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,7 +1612,11 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(3),
|
||||
"Should start at index 3"
|
||||
);
|
||||
}
|
||||
|
||||
// Go to previous track
|
||||
@@ -1466,14 +1626,30 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous");
|
||||
assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2");
|
||||
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
||||
assert_eq!(
|
||||
queue_lock.items().len(),
|
||||
5,
|
||||
"Queue should still have 5 items after previous"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(2),
|
||||
"Index should move to 2"
|
||||
);
|
||||
assert_eq!(
|
||||
queue_lock.current().unwrap().id,
|
||||
"item_2",
|
||||
"Current item should be item_2"
|
||||
);
|
||||
|
||||
// Verify all original items are still present
|
||||
let current_items = queue_lock.items();
|
||||
for (i, original) in items_clone.iter().enumerate() {
|
||||
assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i);
|
||||
assert_eq!(
|
||||
current_items[i].id, original.id,
|
||||
"Item {} should still be in queue",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1491,15 +1667,27 @@ mod tests {
|
||||
|
||||
// Seek to 30 seconds
|
||||
controller.seek(30.0).unwrap();
|
||||
assert_eq!(controller.position(), 30.0, "Position should be 30 after seeking");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
30.0,
|
||||
"Position should be 30 after seeking"
|
||||
);
|
||||
|
||||
// Seek to 60 seconds
|
||||
controller.seek(60.0).unwrap();
|
||||
assert_eq!(controller.position(), 60.0, "Position should be 60 after seeking");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
60.0,
|
||||
"Position should be 60 after seeking"
|
||||
);
|
||||
|
||||
// Seek backward to 15 seconds
|
||||
controller.seek(15.0).unwrap();
|
||||
assert_eq!(controller.position(), 15.0, "Position should be 15 after seeking backward");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
15.0,
|
||||
"Position should be 15 after seeking backward"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1518,10 +1706,17 @@ mod tests {
|
||||
|
||||
// Seek while paused
|
||||
controller.seek(45.0).unwrap();
|
||||
assert_eq!(controller.position(), 45.0, "Position should update while paused");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
45.0,
|
||||
"Position should update while paused"
|
||||
);
|
||||
|
||||
// Verify still paused after seeking
|
||||
assert!(controller.state().is_paused(), "Should still be paused after seeking");
|
||||
assert!(
|
||||
controller.state().is_paused(),
|
||||
"Should still be paused after seeking"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1540,10 +1735,17 @@ mod tests {
|
||||
|
||||
// Seek while playing
|
||||
controller.seek(20.0).unwrap();
|
||||
assert_eq!(controller.position(), 20.0, "Position should update while playing");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
20.0,
|
||||
"Position should update while playing"
|
||||
);
|
||||
|
||||
// Verify still playing after seeking
|
||||
assert!(controller.state().is_playing(), "Should still be playing after seeking");
|
||||
assert!(
|
||||
controller.state().is_playing(),
|
||||
"Should still be playing after seeking"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1558,7 +1760,12 @@ mod tests {
|
||||
|
||||
for pos in positions {
|
||||
controller.seek(pos).unwrap();
|
||||
assert_eq!(controller.position(), pos, "Position should match after seeking to {}", pos);
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
pos,
|
||||
"Position should match after seeking to {}",
|
||||
pos
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1575,9 +1782,17 @@ mod tests {
|
||||
{
|
||||
let queue = controller.queue();
|
||||
let queue_lock = queue.lock_safe();
|
||||
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
|
||||
assert_eq!(
|
||||
queue_lock.current_index(),
|
||||
Some(1),
|
||||
"Should start at index 1"
|
||||
);
|
||||
}
|
||||
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
42.5,
|
||||
"Should resume at the requested position"
|
||||
);
|
||||
}
|
||||
|
||||
/// A None / near-zero start position starts the track from the beginning.
|
||||
@@ -1613,7 +1828,11 @@ mod tests {
|
||||
|
||||
// Seek back to zero
|
||||
controller.seek(0.0).unwrap();
|
||||
assert_eq!(controller.position(), 0.0, "Should be able to seek to position 0");
|
||||
assert_eq!(
|
||||
controller.position(),
|
||||
0.0,
|
||||
"Should be able to seek to position 0"
|
||||
);
|
||||
}
|
||||
|
||||
// Autoplay decision tests
|
||||
@@ -1990,7 +2209,10 @@ mod tests {
|
||||
total_record_count: self.episodes.len(),
|
||||
})
|
||||
}
|
||||
async fn get_item(&self, item_id: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
async fn get_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
self.episodes
|
||||
.iter()
|
||||
.find(|e| e.id == item_id)
|
||||
@@ -1999,55 +2221,109 @@ mod tests {
|
||||
message: format!("{} not found", item_id),
|
||||
})
|
||||
}
|
||||
async fn get_latest_items(&self, _: &str, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_latest_items(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_resume_items(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_resume_items(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_next_up_episodes(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_next_up_episodes(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_recently_played_audio(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_recently_played_audio(
|
||||
&self,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_rediscover_albums(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_rediscover_albums(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_resume_movies(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_resume_movies(
|
||||
&self,
|
||||
_: Option<usize>,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_genres(&self, _: Option<&str>) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
|
||||
async fn get_genres(
|
||||
&self,
|
||||
_: Option<&str>,
|
||||
) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn search(&self, _: &str, _: Option<repo_types::SearchOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
async fn search(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<repo_types::SearchOptions>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_playback_info(&self, _: &str) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
|
||||
async fn get_playback_info(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
async fn get_live_tv_channels(
|
||||
&self,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn open_live_stream(&self, _: &str) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
|
||||
async fn open_live_stream(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn report_playback_start(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
|
||||
async fn report_playback_start(
|
||||
&self,
|
||||
_: &str,
|
||||
_: i64,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn report_playback_progress(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
|
||||
async fn report_playback_progress(
|
||||
&self,
|
||||
_: &str,
|
||||
_: i64,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn report_playback_stopped(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
|
||||
async fn report_playback_stopped(
|
||||
&self,
|
||||
_: &str,
|
||||
_: i64,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
fn get_image_url(&self, _: &str, _: repo_types::ImageType, _: Option<repo_types::ImageOptions>) -> String {
|
||||
fn get_image_url(
|
||||
&self,
|
||||
_: &str,
|
||||
_: repo_types::ImageType,
|
||||
_: Option<repo_types::ImageOptions>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
|
||||
@@ -2062,16 +2338,31 @@ mod tests {
|
||||
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_person(&self, _: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
async fn get_person(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_items_by_person(&self, _: &str, _: Option<repo_types::GetItemsOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
async fn get_items_by_person(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<repo_types::GetItemsOptions>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_similar_items(&self, _: &str, _: Option<usize>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
async fn get_similar_items(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<usize>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn create_playlist(&self, _: &str, _: &[String]) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
|
||||
async fn create_playlist(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &[String],
|
||||
) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
@@ -2080,16 +2371,32 @@ mod tests {
|
||||
async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_playlist_items(&self, _: &str) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
|
||||
async fn get_playlist_items(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn add_to_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
|
||||
async fn add_to_playlist(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &[String],
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn remove_from_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
|
||||
async fn remove_from_playlist(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &[String],
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn move_playlist_item(&self, _: &str, _: &str, _: u32) -> Result<(), repo_types::RepoError> {
|
||||
async fn move_playlist_item(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: u32,
|
||||
) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::{debug, error, info, warn};
|
||||
use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||
use super::media::{MediaItem, MediaSource};
|
||||
use super::state::PlayerState;
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
|
||||
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use libmpv::Mpv;
|
||||
use log::{debug, error, info, warn};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
@@ -104,11 +104,17 @@ impl MpvBackend {
|
||||
|
||||
// Detect and configure audio output
|
||||
let audio_driver = detect_audio_system();
|
||||
info!("[MpvBackend] Configuring audio output driver: {}", audio_driver);
|
||||
info!(
|
||||
"[MpvBackend] Configuring audio output driver: {}",
|
||||
audio_driver
|
||||
);
|
||||
|
||||
mpv.set_property("ao", audio_driver.as_str())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set audio output to '{}': {:?}. Make sure audio system is working.", audio_driver, e),
|
||||
message: format!(
|
||||
"Failed to set audio output to '{}': {:?}. Make sure audio system is working.",
|
||||
audio_driver, e
|
||||
),
|
||||
})?;
|
||||
|
||||
// Enable verbose logging for audio initialization
|
||||
@@ -123,10 +129,9 @@ impl MpvBackend {
|
||||
message: format!("Failed to configure MPV audio-display: {:?}", e),
|
||||
})?;
|
||||
|
||||
mpv.set_property("video", "no")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
mpv.set_property("video", "no").map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Set volume to 100% (we'll control via MPV's volume property)
|
||||
mpv.set_property("volume", 100i64)
|
||||
@@ -191,7 +196,11 @@ impl MpvBackend {
|
||||
libmpv::events::Event::PlaybackRestart => {
|
||||
debug!("[MpvBackend] Playback started/resumed");
|
||||
|
||||
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
|
||||
let media_id = state
|
||||
.lock_safe()
|
||||
.current_media
|
||||
.as_ref()
|
||||
.map(|m| m.id.clone());
|
||||
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
@@ -203,11 +212,16 @@ impl MpvBackend {
|
||||
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
|
||||
// Handle pause state changes
|
||||
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
|
||||
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
|
||||
let media_id = state
|
||||
.lock_safe()
|
||||
.current_media
|
||||
.as_ref()
|
||||
.map(|m| m.id.clone());
|
||||
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: if is_paused { "paused" } else { "playing" }.to_string(),
|
||||
state: if is_paused { "paused" } else { "playing" }
|
||||
.to_string(),
|
||||
media_id,
|
||||
});
|
||||
}
|
||||
@@ -303,14 +317,18 @@ impl MpvBackend {
|
||||
}
|
||||
|
||||
// Check if we're playing for progress reporting
|
||||
let is_paused = mpv_for_position.get_property::<bool>("pause").unwrap_or(true);
|
||||
let is_paused = mpv_for_position
|
||||
.get_property::<bool>("pause")
|
||||
.unwrap_or(true);
|
||||
|
||||
// Only report progress to server when playing (not paused)
|
||||
if !is_paused {
|
||||
// Throttled progress reporting (every 30s)
|
||||
let jellyfin_id = {
|
||||
let state = state_for_position.lock_safe();
|
||||
state.current_media.as_ref()
|
||||
state
|
||||
.current_media
|
||||
.as_ref()
|
||||
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
||||
};
|
||||
|
||||
@@ -333,8 +351,14 @@ impl MpvBackend {
|
||||
};
|
||||
|
||||
match reporter_instance.report(operation, true).await {
|
||||
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
|
||||
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
|
||||
Ok(_) => debug!(
|
||||
"[MpvBackend] Reported progress for {}",
|
||||
item_id_clone
|
||||
),
|
||||
Err(e) => warn!(
|
||||
"[MpvBackend] Failed to report progress: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -468,9 +492,7 @@ impl PlayerBackend for MpvBackend {
|
||||
}
|
||||
|
||||
fn position(&self) -> f64 {
|
||||
self.mpv
|
||||
.get_property::<f64>("time-pos")
|
||||
.unwrap_or(0.0)
|
||||
self.mpv.get_property::<f64>("time-pos").unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn duration(&self) -> Option<f64> {
|
||||
|
||||
@@ -86,7 +86,10 @@ mod tests {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
let count = *counter.lock().unwrap();
|
||||
assert_eq!(count, 1, "Fallback pattern should execute async code successfully");
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"Fallback pattern should execute async code successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that position update logic works in a thread
|
||||
@@ -113,7 +116,11 @@ mod tests {
|
||||
handle.join().unwrap();
|
||||
|
||||
let recorded_positions = positions.lock().unwrap();
|
||||
assert_eq!(recorded_positions.len(), 5, "Should have recorded 5 position updates");
|
||||
assert_eq!(
|
||||
recorded_positions.len(),
|
||||
5,
|
||||
"Should have recorded 5 position updates"
|
||||
);
|
||||
|
||||
// Verify positions are increasing
|
||||
for (i, pos) in recorded_positions.iter().enumerate() {
|
||||
|
||||
@@ -149,7 +149,10 @@ impl QueueManager {
|
||||
}
|
||||
|
||||
let insert_index = match position {
|
||||
AddPosition::Next => self.current_index.map(|i| i + 1).unwrap_or(self.items.len()),
|
||||
AddPosition::Next => self
|
||||
.current_index
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(self.items.len()),
|
||||
AddPosition::End => self.items.len(),
|
||||
};
|
||||
|
||||
@@ -167,10 +170,7 @@ impl QueueManager {
|
||||
|
||||
// Regenerate shuffle order if shuffle is on
|
||||
if self.shuffle {
|
||||
self.shuffle_order = self.generate_shuffle_order(
|
||||
self.items.len(),
|
||||
self.current_index,
|
||||
);
|
||||
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,8 @@ impl QueueManager {
|
||||
|
||||
// Update shuffle order
|
||||
if self.shuffle {
|
||||
self.shuffle_order = self.shuffle_order
|
||||
self.shuffle_order = self
|
||||
.shuffle_order
|
||||
.iter()
|
||||
.filter(|&&i| i != index)
|
||||
.map(|&i| if i > index { i - 1 } else { i })
|
||||
@@ -239,7 +240,10 @@ impl QueueManager {
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
0
|
||||
} else {
|
||||
log::debug!("[Queue] next() at end of queue (index {}), no next track", current);
|
||||
log::debug!(
|
||||
"[Queue] next() at end of queue (index {}), no next track",
|
||||
current
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -269,8 +273,11 @@ impl QueueManager {
|
||||
if let Some(prev) = self.history.pop() {
|
||||
// Safety check: ensure the history entry is valid
|
||||
if prev >= self.items.len() {
|
||||
log::warn!("[Queue] Invalid history entry {} (queue has {} items), clearing history",
|
||||
prev, self.items.len());
|
||||
log::warn!(
|
||||
"[Queue] Invalid history entry {} (queue has {} items), clearing history",
|
||||
prev,
|
||||
self.items.len()
|
||||
);
|
||||
self.history.clear();
|
||||
return None;
|
||||
}
|
||||
@@ -334,10 +341,7 @@ impl QueueManager {
|
||||
self.shuffle = !self.shuffle;
|
||||
|
||||
if self.shuffle && !self.items.is_empty() {
|
||||
self.shuffle_order = self.generate_shuffle_order(
|
||||
self.items.len(),
|
||||
self.current_index,
|
||||
);
|
||||
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
|
||||
} else {
|
||||
self.shuffle_order.clear();
|
||||
}
|
||||
@@ -371,7 +375,8 @@ impl QueueManager {
|
||||
true
|
||||
} else if self.shuffle {
|
||||
let pos = self.shuffle_order.iter().position(|&i| i == current);
|
||||
pos.map(|p| p + 1 < self.shuffle_order.len()).unwrap_or(false)
|
||||
pos.map(|p| p + 1 < self.shuffle_order.len())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
current + 1 < self.items.len()
|
||||
}
|
||||
@@ -473,8 +478,7 @@ impl QueueManager {
|
||||
// Update shuffle order if shuffle is on
|
||||
if self.shuffle && !self.shuffle_order.is_empty() {
|
||||
// Regenerate shuffle order to maintain consistency
|
||||
self.shuffle_order =
|
||||
self.generate_shuffle_order(self.items.len(), self.current_index);
|
||||
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
|
||||
}
|
||||
|
||||
true
|
||||
@@ -486,7 +490,10 @@ impl QueueManager {
|
||||
if let Some(current_index) = self.current_index {
|
||||
if let Some(item) = self.items.get_mut(current_index) {
|
||||
// Only update if it's a Remote source
|
||||
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
|
||||
if let MediaSource::Remote {
|
||||
jellyfin_item_id, ..
|
||||
} = &item.source
|
||||
{
|
||||
item.source = MediaSource::Remote {
|
||||
stream_url: new_url,
|
||||
jellyfin_item_id: jellyfin_item_id.clone(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::media::MediaItem;
|
||||
/**
|
||||
* Media Session Management
|
||||
*
|
||||
@@ -7,10 +8,8 @@
|
||||
*
|
||||
* See docs/architecture/01-rust-backend.md for the state machine diagram.
|
||||
*/
|
||||
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::media::MediaItem;
|
||||
|
||||
/// Media session type tracking the high-level playback context
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -98,7 +97,10 @@ impl MediaSessionManager {
|
||||
/// Start an audio session with a queue
|
||||
/// Transitions: Idle → Audio(active), Any → Audio(active)
|
||||
pub fn start_audio_session(&mut self, first_item: MediaItem) {
|
||||
info!("[MediaSession] Starting audio session: {}", first_item.title);
|
||||
info!(
|
||||
"[MediaSession] Starting audio session: {}",
|
||||
first_item.title
|
||||
);
|
||||
self.current = MediaSessionType::Audio {
|
||||
last_item: Some(first_item),
|
||||
is_active: true,
|
||||
@@ -107,7 +109,11 @@ impl MediaSessionManager {
|
||||
|
||||
/// Update audio session with new track (during playback)
|
||||
pub fn update_audio_track(&mut self, item: MediaItem) {
|
||||
if let MediaSessionType::Audio { last_item, is_active } = &mut self.current {
|
||||
if let MediaSessionType::Audio {
|
||||
last_item,
|
||||
is_active,
|
||||
} = &mut self.current
|
||||
{
|
||||
info!("[MediaSession] Updating audio track: {}", item.title);
|
||||
*last_item = Some(item);
|
||||
*is_active = true;
|
||||
@@ -171,8 +177,14 @@ impl MediaSessionManager {
|
||||
|
||||
/// Advance to next episode in TV session
|
||||
pub fn tv_session_next_episode(&mut self, next_item: MediaItem) {
|
||||
if let MediaSessionType::TvShow { item, is_active, .. } = &mut self.current {
|
||||
info!("[MediaSession] Advancing to next episode: {}", next_item.title);
|
||||
if let MediaSessionType::TvShow {
|
||||
item, is_active, ..
|
||||
} = &mut self.current
|
||||
{
|
||||
info!(
|
||||
"[MediaSession] Advancing to next episode: {}",
|
||||
next_item.title
|
||||
);
|
||||
*item = next_item;
|
||||
*is_active = true;
|
||||
}
|
||||
@@ -294,7 +306,10 @@ mod tests {
|
||||
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Audio { is_active: true, .. }
|
||||
MediaSessionType::Audio {
|
||||
is_active: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(manager.should_show_miniplayer());
|
||||
|
||||
@@ -307,7 +322,10 @@ mod tests {
|
||||
manager.audio_session_inactive();
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Audio { is_active: false, .. }
|
||||
MediaSessionType::Audio {
|
||||
is_active: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(manager.should_show_miniplayer()); // Still shows!
|
||||
|
||||
@@ -330,7 +348,10 @@ mod tests {
|
||||
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Movie { is_active: true, .. }
|
||||
MediaSessionType::Movie {
|
||||
is_active: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(manager.should_show_video_player());
|
||||
|
||||
|
||||
@@ -199,7 +199,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_player_state_loading() {
|
||||
let media = create_test_media_item("item-1", "Test Item");
|
||||
let state = PlayerState::Loading { media: media.clone() };
|
||||
let state = PlayerState::Loading {
|
||||
media: media.clone(),
|
||||
};
|
||||
assert!(matches!(state, PlayerState::Loading { .. }));
|
||||
assert_eq!(state.position(), None);
|
||||
assert!(!state.is_playing());
|
||||
|
||||
Reference in New Issue
Block a user