1629 lines
60 KiB
Rust
1629 lines
60 KiB
Rust
// Player module - Complete playback control system
|
|
// TRACES: UR-003, UR-004, UR-005, UR-019, UR-023, UR-026 |
|
|
// IR-003, IR-004, IR-006, IR-008 |
|
|
// DR-001, DR-004, DR-005, DR-009, DR-028, DR-029, DR-047
|
|
pub mod autoplay;
|
|
pub mod backend;
|
|
pub mod events;
|
|
pub mod media;
|
|
pub mod queue;
|
|
pub mod session;
|
|
pub mod sleep_timer;
|
|
pub mod state;
|
|
|
|
#[cfg(test)]
|
|
mod mpv_backend_test;
|
|
|
|
// Platform-specific backends
|
|
#[cfg(target_os = "android")]
|
|
pub mod android;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
pub mod mpv_backend;
|
|
|
|
// Re-export commonly used types
|
|
pub use autoplay::{AutoplayDecision, AutoplaySettings};
|
|
pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
|
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
|
pub use media::{MediaItem, MediaSource, MediaType, QueueContext};
|
|
pub use queue::{QueueManager, RepeatMode};
|
|
pub use session::{MediaSessionManager, MediaSessionType};
|
|
pub use sleep_timer::{SleepTimerMode, SleepTimerState};
|
|
pub use state::{EndReason, PlayerState};
|
|
|
|
// Re-export platform-specific backends
|
|
#[cfg(target_os = "android")]
|
|
pub use android::ExoPlayerBackend;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
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,
|
|
};
|
|
|
|
use log::{debug, error, warn};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
|
|
use crate::jellyfin::JellyfinClient;
|
|
use crate::settings::AudioSettings;
|
|
use crate::repository::MediaRepository;
|
|
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation, PlaybackContext};
|
|
|
|
/// Central player controller that coordinates playback
|
|
pub struct PlayerController {
|
|
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
|
queue: Arc<Mutex<QueueManager>>,
|
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
|
muted: bool,
|
|
|
|
// Sleep timer state
|
|
sleep_timer: Arc<Mutex<SleepTimerState>>,
|
|
|
|
// Autoplay settings
|
|
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
|
|
|
|
// Repository for fetching next episodes
|
|
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
|
|
|
|
// Event emitter for notifications
|
|
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
|
|
|
// Countdown cancellation handle
|
|
countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
|
|
|
|
// Playback reporting (dual sync: local DB + server)
|
|
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
|
|
|
// Event throttler to prevent spam from position updates
|
|
// Will be used when position update hooks are added to backends
|
|
#[allow(dead_code)]
|
|
position_throttler: Arc<EventThrottler>,
|
|
|
|
// End reason tracking for autoplay decision making
|
|
end_reason: Arc<Mutex<Option<EndReason>>>,
|
|
|
|
// Auto-play episode counter (session-based, resets on manual play)
|
|
autoplay_episode_count: Arc<Mutex<u32>>,
|
|
}
|
|
|
|
impl PlayerController {
|
|
pub fn new(
|
|
backend: Box<dyn PlayerBackend>,
|
|
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
|
position_throttler: Arc<EventThrottler>,
|
|
) -> Self {
|
|
let controller = Self {
|
|
backend: Arc::new(Mutex::new(backend)),
|
|
queue: Arc::new(Mutex::new(QueueManager::new())),
|
|
jellyfin_client: Arc::new(Mutex::new(None)),
|
|
muted: false,
|
|
sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
|
|
autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
|
|
repository: Arc::new(Mutex::new(None)),
|
|
event_emitter: Arc::new(Mutex::new(None)),
|
|
countdown_cancel: Arc::new(Mutex::new(None)),
|
|
playback_reporter,
|
|
position_throttler,
|
|
end_reason: Arc::new(Mutex::new(None)),
|
|
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
|
};
|
|
|
|
// Start background timer thread for sleep timer countdown
|
|
controller.start_timer_thread();
|
|
|
|
controller
|
|
}
|
|
|
|
/// Configure the Jellyfin API client for automatic playback reporting
|
|
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
|
|
let mut jellyfin = self.jellyfin_client.lock().unwrap();
|
|
*jellyfin = client;
|
|
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
|
|
}
|
|
|
|
/// Get a reference to the Jellyfin client (for remote session control)
|
|
pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
|
|
self.jellyfin_client.clone()
|
|
}
|
|
|
|
/// Configure the playback reporter for dual sync (local DB + server)
|
|
/// Will be called from initialization commands after login
|
|
#[allow(dead_code)]
|
|
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());
|
|
}
|
|
|
|
/// Get a reference to the playback reporter (for backend position updates)
|
|
/// Will be used when position update hooks are added to backends
|
|
#[allow(dead_code)]
|
|
pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
|
|
self.playback_reporter.clone()
|
|
}
|
|
|
|
/// Get a reference to the position throttler (for backend position updates)
|
|
/// Will be used when position update hooks are added to backends
|
|
#[allow(dead_code)]
|
|
pub fn position_throttler(&self) -> Arc<EventThrottler> {
|
|
self.position_throttler.clone()
|
|
}
|
|
|
|
/// Set the end reason for the next playback end event
|
|
fn set_end_reason(&self, reason: EndReason) {
|
|
log::debug!("[PlayerController] Setting end reason: {:?}", reason);
|
|
*self.end_reason.lock().unwrap() = Some(reason);
|
|
}
|
|
|
|
/// Get and clear the current end reason
|
|
fn take_end_reason(&self) -> Option<EndReason> {
|
|
self.end_reason.lock().unwrap().take()
|
|
}
|
|
|
|
/// Increment autoplay episode counter. Returns true if limit is reached.
|
|
fn increment_autoplay_count(&self) -> bool {
|
|
let max = self.autoplay_settings.lock().unwrap().max_episodes;
|
|
|
|
if max == 0 {
|
|
// Unlimited
|
|
return false;
|
|
}
|
|
|
|
let mut count = self.autoplay_episode_count.lock().unwrap();
|
|
*count += 1;
|
|
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
|
|
|
|
*count >= max
|
|
}
|
|
|
|
/// Reset autoplay episode counter (called on manual play actions)
|
|
fn reset_autoplay_count(&self) {
|
|
let mut count = self.autoplay_episode_count.lock().unwrap();
|
|
if *count > 0 {
|
|
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
|
|
}
|
|
*count = 0;
|
|
}
|
|
|
|
/// Load and play a single item (also sets the queue to contain only this item)
|
|
pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
|
debug!("[PlayerController] play_item: {}", item.title);
|
|
|
|
// Reset autoplay counter on manual play
|
|
self.reset_autoplay_count();
|
|
|
|
// Update queue with this single item
|
|
{
|
|
let mut queue = self.queue.lock().unwrap();
|
|
queue.set_queue(vec![item.clone()], 0);
|
|
}
|
|
|
|
// Load and play the item
|
|
self.load_and_play(&item)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load and play an item without modifying the queue
|
|
/// Use this when the queue is already set up and you just want to play a specific item from it
|
|
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
|
|
debug!("[PlayerController] load_and_play: {}", item.title);
|
|
|
|
// Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
|
|
self.set_end_reason(EndReason::NewTrackLoaded);
|
|
|
|
let mut backend = self.backend.lock().unwrap();
|
|
backend.load(item)?;
|
|
backend.play()?;
|
|
drop(backend);
|
|
|
|
// Report playback start using PlaybackReporter (dual sync: local DB + server)
|
|
if let Some(jellyfin_id) = item.jellyfin_id() {
|
|
let jellyfin_id = jellyfin_id.to_string();
|
|
let reporter = self.playback_reporter.clone();
|
|
|
|
// Build playback context from item metadata
|
|
let context = if item.album_id.is_some() {
|
|
Some(PlaybackContext {
|
|
context_type: "container".to_string(),
|
|
context_id: item.album_id.clone(),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Spawn on Tokio runtime if available, otherwise use a new thread
|
|
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
|
handle.spawn(async move {
|
|
let reporter_guard = reporter.lock().await;
|
|
if let Some(reporter_instance) = reporter_guard.as_ref() {
|
|
log::info!("[PlayerController] Reporting playback start via PlaybackReporter: {}", jellyfin_id);
|
|
|
|
let operation = PlaybackOperation::Start {
|
|
item_id: jellyfin_id.clone(),
|
|
position_ticks: 0,
|
|
context,
|
|
};
|
|
|
|
// Note: PlaybackReporter internally checks connectivity
|
|
// We pass is_online=true as default; reporter will check actual status
|
|
match reporter_instance.report(operation, true).await {
|
|
Ok(_) => log::info!("[PlayerController] Successfully reported playback start (local DB + server sync)"),
|
|
Err(e) => log::error!("[PlayerController] Failed to report playback start: {}", e),
|
|
}
|
|
} else {
|
|
log::warn!("[PlayerController] PlaybackReporter not initialized - using fallback JellyfinClient");
|
|
// Fallback to legacy JellyfinClient (will be removed after full migration)
|
|
}
|
|
});
|
|
} else {
|
|
// Fallback: spawn in a new thread with its own runtime
|
|
std::thread::spawn(move || {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
rt.block_on(async move {
|
|
let reporter_guard = reporter.lock().await;
|
|
if let Some(reporter_instance) = reporter_guard.as_ref() {
|
|
log::info!("[PlayerController] Reporting playback start via PlaybackReporter: {}", jellyfin_id);
|
|
|
|
let operation = PlaybackOperation::Start {
|
|
item_id: jellyfin_id.clone(),
|
|
position_ticks: 0,
|
|
context,
|
|
};
|
|
|
|
match reporter_instance.report(operation, true).await {
|
|
Ok(_) => log::info!("[PlayerController] Successfully reported playback start"),
|
|
Err(e) => log::error!("[PlayerController] Failed to report playback start: {}", e),
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the queue and start playing from the specified index
|
|
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
|
|
debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index);
|
|
|
|
// Reset autoplay counter on manual queue start
|
|
self.reset_autoplay_count();
|
|
|
|
{
|
|
let mut queue = self.queue.lock().unwrap();
|
|
queue.set_queue(items, start_index);
|
|
}
|
|
|
|
// Play the current item (without modifying the queue we just set)
|
|
if let Some(item) = self.queue.lock().unwrap().current().cloned() {
|
|
self.load_and_play(&item)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Play/resume playback
|
|
pub fn play(&self) -> Result<(), PlayerError> {
|
|
debug!("[PlayerController] play");
|
|
let mut backend = self.backend.lock().unwrap();
|
|
backend.play()
|
|
}
|
|
|
|
/// Pause playback
|
|
pub fn pause(&self) -> Result<(), PlayerError> {
|
|
let mut backend = self.backend.lock().unwrap();
|
|
backend.pause()
|
|
}
|
|
|
|
/// Toggle play/pause
|
|
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
|
|
let mut backend = self.backend.lock().unwrap();
|
|
if backend.state().is_playing() {
|
|
backend.pause()
|
|
} else {
|
|
backend.play()
|
|
}
|
|
}
|
|
|
|
/// Stop playback
|
|
pub fn stop(&self) -> Result<(), PlayerError> {
|
|
// Set end reason to UserStop to prevent autoplay
|
|
self.set_end_reason(EndReason::UserStop);
|
|
|
|
// Get current playback info before stopping
|
|
let jellyfin_id = {
|
|
let queue = self.queue.lock().unwrap();
|
|
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
|
|
};
|
|
|
|
let position_ticks = {
|
|
let backend = self.backend.lock().unwrap();
|
|
(backend.position() * 10_000_000.0) as i64
|
|
};
|
|
|
|
let mut backend = self.backend.lock().unwrap();
|
|
backend.stop()?;
|
|
drop(backend);
|
|
|
|
// Report playback stopped using PlaybackReporter (dual sync: local DB + server)
|
|
if let Some(jellyfin_id) = jellyfin_id {
|
|
let reporter = self.playback_reporter.clone();
|
|
|
|
// Spawn on Tokio runtime if available, otherwise use a new thread
|
|
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
|
handle.spawn(async move {
|
|
let reporter_guard = reporter.lock().await;
|
|
if let Some(reporter_instance) = reporter_guard.as_ref() {
|
|
log::info!("[PlayerController] Reporting playback stopped via PlaybackReporter: {}", jellyfin_id);
|
|
|
|
let operation = PlaybackOperation::Stopped {
|
|
item_id: jellyfin_id.clone(),
|
|
position_ticks,
|
|
};
|
|
|
|
match reporter_instance.report(operation, true).await {
|
|
Ok(_) => log::info!("[PlayerController] Successfully reported playback stopped (local DB + server sync)"),
|
|
Err(e) => log::error!("[PlayerController] Failed to report playback stopped: {}", e),
|
|
}
|
|
} else {
|
|
log::warn!("[PlayerController] PlaybackReporter not initialized");
|
|
}
|
|
});
|
|
} else {
|
|
// Fallback: spawn in a new thread with its own runtime
|
|
std::thread::spawn(move || {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
rt.block_on(async move {
|
|
let reporter_guard = reporter.lock().await;
|
|
if let Some(reporter_instance) = reporter_guard.as_ref() {
|
|
log::info!("[PlayerController] Reporting playback stopped via PlaybackReporter: {}", jellyfin_id);
|
|
|
|
let operation = PlaybackOperation::Stopped {
|
|
item_id: jellyfin_id.clone(),
|
|
position_ticks,
|
|
};
|
|
|
|
match reporter_instance.report(operation, true).await {
|
|
Ok(_) => log::info!("[PlayerController] Successfully reported playback stopped"),
|
|
Err(e) => log::error!("[PlayerController] Failed to report playback stopped: {}", e),
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Skip to next track
|
|
///
|
|
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
|
|
/// from triggering when the current track's EndFile event fires
|
|
pub fn next(&self) -> Result<(), PlayerError> {
|
|
// Reset autoplay counter on manual skip
|
|
self.reset_autoplay_count();
|
|
|
|
let next_item = {
|
|
let mut queue = self.queue.lock().unwrap();
|
|
queue.next().cloned()
|
|
};
|
|
|
|
debug!("[PlayerController] next: {:?}", next_item.as_ref().map(|i| &i.title));
|
|
|
|
if let Some(item) = next_item {
|
|
self.load_and_play(&item)
|
|
} else {
|
|
debug!("[PlayerController] No next item, stopping");
|
|
self.stop()
|
|
}
|
|
}
|
|
|
|
/// Skip to previous track
|
|
///
|
|
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
|
|
/// from triggering when the current track's EndFile event fires
|
|
pub fn previous(&self) -> Result<(), PlayerError> {
|
|
// Reset autoplay counter on manual skip
|
|
self.reset_autoplay_count();
|
|
// If we're more than 3 seconds in, restart current track
|
|
{
|
|
let backend = self.backend.lock().unwrap();
|
|
if backend.position() > 3.0 {
|
|
debug!("[PlayerController] previous: restarting current track (position > 3s)");
|
|
drop(backend);
|
|
return self.seek(0.0);
|
|
}
|
|
}
|
|
|
|
let prev_item = {
|
|
let mut queue = self.queue.lock().unwrap();
|
|
queue.previous().cloned()
|
|
};
|
|
|
|
debug!("[PlayerController] previous: {:?}", prev_item.as_ref().map(|i| &i.title));
|
|
|
|
if let Some(item) = prev_item {
|
|
self.load_and_play(&item)
|
|
} else {
|
|
self.seek(0.0)
|
|
}
|
|
}
|
|
|
|
/// Seek to a position in seconds
|
|
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
|
let mut backend = self.backend.lock().unwrap();
|
|
backend.seek(position)
|
|
}
|
|
|
|
/// Set volume (0.0 - 1.0)
|
|
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
|
|
self.backend.lock().unwrap().set_volume(volume)
|
|
}
|
|
|
|
/// Set the active audio track by stream index
|
|
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
|
|
let mut backend = self.backend.lock().unwrap();
|
|
backend.set_audio_track(stream_index)
|
|
}
|
|
|
|
/// Set the active subtitle track by stream index (None to disable subtitles)
|
|
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
|
let mut backend = self.backend.lock().unwrap();
|
|
backend.set_subtitle_track(stream_index)
|
|
}
|
|
|
|
/// Get current state
|
|
pub fn state(&self) -> PlayerState {
|
|
self.backend.lock().unwrap().state()
|
|
}
|
|
|
|
/// Get current position
|
|
pub fn position(&self) -> f64 {
|
|
self.backend.lock().unwrap().position()
|
|
}
|
|
|
|
/// Get duration
|
|
pub fn duration(&self) -> Option<f64> {
|
|
self.backend.lock().unwrap().duration()
|
|
}
|
|
|
|
/// Get queue reference
|
|
pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
|
|
self.queue.clone()
|
|
}
|
|
|
|
/// Toggle shuffle
|
|
pub fn toggle_shuffle(&self) {
|
|
self.queue.lock().unwrap().toggle_shuffle();
|
|
}
|
|
|
|
/// Cycle repeat mode
|
|
pub fn cycle_repeat(&self) {
|
|
self.queue.lock().unwrap().cycle_repeat();
|
|
}
|
|
|
|
/// Check if shuffle is enabled
|
|
pub fn is_shuffle(&self) -> bool {
|
|
self.queue.lock().unwrap().is_shuffle()
|
|
}
|
|
|
|
/// Get repeat mode
|
|
pub fn repeat_mode(&self) -> RepeatMode {
|
|
self.queue.lock().unwrap().repeat_mode()
|
|
}
|
|
|
|
/// Get current volume (0.0 - 1.0)
|
|
pub fn volume(&self) -> f32 {
|
|
self.backend.lock().unwrap().volume()
|
|
}
|
|
|
|
/// Check if muted
|
|
pub fn muted(&self) -> bool {
|
|
self.muted
|
|
}
|
|
|
|
/// Set audio settings (crossfade, gapless, normalization)
|
|
pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
|
self.backend.lock().unwrap().set_audio_settings(settings)
|
|
}
|
|
|
|
/// Get current audio settings
|
|
pub fn audio_settings(&self) -> AudioSettings {
|
|
self.backend.lock().unwrap().audio_settings()
|
|
}
|
|
|
|
// ===== Sleep Timer Methods =====
|
|
|
|
/// Set the event emitter for notifications
|
|
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
|
|
let mut event_emitter = self.event_emitter.lock().unwrap();
|
|
*event_emitter = Some(emitter);
|
|
}
|
|
|
|
/// Get the event emitter
|
|
pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
|
|
self.event_emitter.lock().unwrap().clone()
|
|
}
|
|
|
|
/// Get sleep timer state
|
|
pub fn sleep_timer_state(&self) -> SleepTimerState {
|
|
self.sleep_timer.lock().unwrap().clone()
|
|
}
|
|
|
|
/// Set sleep timer mode (in-memory only, not persisted)
|
|
pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
|
|
let mut timer = self.sleep_timer.lock().unwrap();
|
|
timer.mode = mode.clone();
|
|
if let SleepTimerMode::Time { end_time } = mode {
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
|
|
} else {
|
|
timer.remaining_seconds = 0;
|
|
}
|
|
drop(timer);
|
|
|
|
// Emit event to frontend for display update
|
|
self.emit_sleep_timer_changed();
|
|
}
|
|
|
|
/// Cancel sleep timer
|
|
pub fn cancel_sleep_timer(&self) {
|
|
self.set_sleep_timer(SleepTimerMode::Off);
|
|
}
|
|
|
|
/// Start background timer thread for sleep timer countdown updates
|
|
fn start_timer_thread(&self) {
|
|
let sleep_timer = self.sleep_timer.clone();
|
|
let event_emitter = self.event_emitter.clone();
|
|
let backend = self.backend.clone();
|
|
|
|
std::thread::spawn(move || {
|
|
loop {
|
|
std::thread::sleep(Duration::from_secs(1));
|
|
|
|
let mut timer = sleep_timer.lock().unwrap();
|
|
if timer.is_active() {
|
|
timer.update_remaining_seconds();
|
|
|
|
// Time-based timer expired: stop playback
|
|
if matches!(timer.mode, SleepTimerMode::Time { .. }) && timer.remaining_seconds == 0 {
|
|
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
|
timer.cancel();
|
|
|
|
// Emit cancelled state
|
|
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
|
mode: SleepTimerMode::Off,
|
|
remaining_seconds: 0,
|
|
});
|
|
}
|
|
drop(timer);
|
|
|
|
// Stop the backend
|
|
if let Err(e) = backend.lock().unwrap().stop() {
|
|
error!("[SleepTimer] Failed to stop playback: {}", e);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Emit update event
|
|
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
|
mode: timer.mode.clone(),
|
|
remaining_seconds: timer.remaining_seconds,
|
|
});
|
|
}
|
|
}
|
|
drop(timer);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Emit sleep timer changed event to frontend
|
|
fn emit_sleep_timer_changed(&self) {
|
|
let timer = self.sleep_timer.lock().unwrap().clone();
|
|
|
|
if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
|
mode: timer.mode,
|
|
remaining_seconds: timer.remaining_seconds,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Emit queue changed event to frontend
|
|
pub fn emit_queue_changed(&self) {
|
|
let queue = self.queue.lock().unwrap();
|
|
|
|
debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
|
|
queue.items().len(), queue.current_index());
|
|
|
|
if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::QueueChanged {
|
|
items: queue.items().to_vec(),
|
|
current_index: queue.current_index(),
|
|
shuffle: queue.is_shuffle(),
|
|
repeat: queue.repeat_mode(),
|
|
has_next: queue.has_next(),
|
|
has_previous: queue.has_previous(),
|
|
});
|
|
} else {
|
|
warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
|
|
}
|
|
}
|
|
|
|
// ===== Autoplay Methods =====
|
|
|
|
/// Get autoplay settings
|
|
pub fn autoplay_settings(&self) -> AutoplaySettings {
|
|
self.autoplay_settings.lock().unwrap().clone()
|
|
}
|
|
|
|
/// Set autoplay settings (in-memory only, persistence handled by command layer)
|
|
pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
|
|
let validated = settings.with_validated_countdown();
|
|
*self.autoplay_settings.lock().unwrap() = validated;
|
|
}
|
|
|
|
/// Cancel active autoplay countdown
|
|
pub fn cancel_autoplay_countdown(&self) {
|
|
if let Some(cancel_flag) = self.countdown_cancel.lock().unwrap().as_ref() {
|
|
*cancel_flag.lock().unwrap() = true;
|
|
}
|
|
}
|
|
|
|
/// Handle playback ended event - decides what to do next
|
|
///
|
|
/// Only triggers autoplay if the track finished naturally (EndReason::Finished or None).
|
|
/// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.
|
|
pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
|
|
// Check why playback ended
|
|
let end_reason = self.take_end_reason();
|
|
|
|
debug!("[PlayerController] on_playback_ended: end_reason={:?}", end_reason);
|
|
|
|
// Only proceed with autoplay logic if track finished naturally
|
|
match end_reason {
|
|
None | Some(EndReason::Finished) => {
|
|
// Track ended naturally, proceed with autoplay logic
|
|
debug!("[PlayerController] Track finished naturally, checking autoplay");
|
|
}
|
|
Some(EndReason::NewTrackLoaded) => {
|
|
// User loaded a new track, don't autoplay
|
|
debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
Some(EndReason::UserStop) => {
|
|
// User stopped playback, don't autoplay
|
|
debug!("[PlayerController] UserStop - stopping without autoplay");
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
Some(EndReason::UserSkip) => {
|
|
// User skipped, already handled by next/previous
|
|
debug!("[PlayerController] UserSkip - stopping without autoplay");
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
Some(EndReason::Error) => {
|
|
// Playback error, don't autoplay
|
|
debug!("[PlayerController] Error - stopping without autoplay");
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
}
|
|
|
|
let current_item = {
|
|
let queue = self.queue.lock().unwrap();
|
|
queue.current().cloned()
|
|
};
|
|
|
|
let Some(current) = current_item else {
|
|
return Ok(AutoplayDecision::Stop);
|
|
};
|
|
|
|
// Check sleep timer state
|
|
let timer_mode = {
|
|
let timer = self.sleep_timer.lock().unwrap();
|
|
timer.mode.clone()
|
|
};
|
|
|
|
match &timer_mode {
|
|
SleepTimerMode::Time { end_time } => {
|
|
// If time has expired, stop instead of playing next
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
if now >= *end_time {
|
|
debug!("[PlayerController] Time-based sleep timer expired at track boundary");
|
|
self.sleep_timer.lock().unwrap().cancel();
|
|
self.emit_sleep_timer_changed();
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
}
|
|
SleepTimerMode::EndOfTrack => {
|
|
// Stop at end of track
|
|
self.sleep_timer.lock().unwrap().cancel();
|
|
self.emit_sleep_timer_changed();
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
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;
|
|
|
|
if is_episode {
|
|
let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
|
|
self.emit_sleep_timer_changed();
|
|
|
|
if should_stop {
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
// No action needed for other modes
|
|
}
|
|
}
|
|
|
|
// For video episodes, fetch next episode and show popup
|
|
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
|
|
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
|
|
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
|
let repo = self.repository.lock().unwrap().clone();
|
|
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
|
let next_ep_result = if let Some(repo) = &repo {
|
|
self.fetch_next_episode_for_item(jellyfin_id, repo).await?
|
|
} else {
|
|
debug!("[PlayerController] No repository available for audio-path episode lookup");
|
|
None
|
|
};
|
|
if let Some(next_ep) = next_ep_result {
|
|
let settings = self.autoplay_settings.lock().unwrap().clone();
|
|
|
|
// 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);
|
|
}
|
|
|
|
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
|
current_episode: next_ep.0, // Repository MediaItem
|
|
next_episode: next_ep.1,
|
|
countdown_seconds: settings.countdown_seconds,
|
|
auto_advance: settings.enabled && !limit_reached,
|
|
});
|
|
}
|
|
// No next episode found
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
|
|
// For audio/movies, check if there's a next track in the queue
|
|
let has_next = {
|
|
let queue = self.queue.lock().unwrap();
|
|
queue.has_next()
|
|
};
|
|
|
|
if has_next {
|
|
// Advance to next track
|
|
Ok(AutoplayDecision::AdvanceToNext)
|
|
} else {
|
|
// End of queue
|
|
Ok(AutoplayDecision::Stop)
|
|
}
|
|
}
|
|
|
|
/// Handle video playback ended from HTML5 video element.
|
|
///
|
|
/// HTML5 video plays independently of the Rust backend, so the backend
|
|
/// queue has no knowledge of the video item. This method bypasses the
|
|
/// queue lookup and end_reason check, using the provided Jellyfin item ID
|
|
/// to look up the item and check for next episodes.
|
|
pub async fn on_video_playback_ended(
|
|
&self,
|
|
item_id: &str,
|
|
repo: Arc<dyn crate::repository::MediaRepository>,
|
|
) -> Result<AutoplayDecision, String> {
|
|
// 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] on_video_playback_ended: item_id={}", item_id);
|
|
|
|
// Check sleep timer state
|
|
let timer_mode = {
|
|
let timer = self.sleep_timer.lock().unwrap();
|
|
timer.mode.clone()
|
|
};
|
|
|
|
match &timer_mode {
|
|
SleepTimerMode::Time { end_time } => {
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
if now >= *end_time {
|
|
debug!("[PlayerController] Time-based sleep timer expired at video end");
|
|
self.sleep_timer.lock().unwrap().cancel();
|
|
self.emit_sleep_timer_changed();
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
}
|
|
SleepTimerMode::EndOfTrack => {
|
|
self.sleep_timer.lock().unwrap().cancel();
|
|
self.emit_sleep_timer_changed();
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
SleepTimerMode::Episodes { .. } => {
|
|
let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
|
|
self.emit_sleep_timer_changed();
|
|
if should_stop {
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// Fetch next episode for the video that just ended
|
|
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
|
|
let settings = self.autoplay_settings.lock().unwrap().clone();
|
|
|
|
let limit_reached = self.increment_autoplay_count();
|
|
if limit_reached {
|
|
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
|
}
|
|
|
|
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
|
current_episode: next_ep.0,
|
|
next_episode: next_ep.1,
|
|
countdown_seconds: settings.countdown_seconds,
|
|
auto_advance: settings.enabled && !limit_reached,
|
|
});
|
|
}
|
|
|
|
// No next episode found
|
|
debug!("[PlayerController] No next episode found for {}", item_id);
|
|
Ok(AutoplayDecision::Stop)
|
|
}
|
|
|
|
/// Check if a media item is an episode (has Jellyfin ID to query)
|
|
async fn is_episode_item(&self, item: &MediaItem) -> bool {
|
|
// For now, assume video items are episodes
|
|
// In production, we'd check item metadata or query Jellyfin
|
|
item.media_type == MediaType::Video
|
|
}
|
|
|
|
/// Fetch next episode for a series by looking up the season's episodes
|
|
/// sorted by index number and picking the one after the current episode.
|
|
///
|
|
/// This is deterministic and doesn't depend on Jellyfin's "Next Up" API
|
|
/// (which relies on watch history that may not be updated yet due to
|
|
/// the async nature of playback progress reporting).
|
|
async fn fetch_next_episode_for_item(
|
|
&self,
|
|
item_id: &str,
|
|
repo: &Arc<dyn crate::repository::MediaRepository>,
|
|
) -> 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)
|
|
.await
|
|
.map_err(|e| format!("Failed to get current item: {}", e))?;
|
|
|
|
// Need season_id to fetch sibling episodes
|
|
let season_id = match ¤t_repo_item.season_id {
|
|
Some(sid) => sid.clone(),
|
|
None => {
|
|
debug!("[PlayerController] Current item has no season_id, cannot find next episode");
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
// Fetch all episodes in the season sorted by episode number
|
|
let options = GetItemsOptions {
|
|
sort_by: Some("IndexNumber".to_string()),
|
|
sort_order: Some("Ascending".to_string()),
|
|
limit: Some(500),
|
|
include_item_types: Some(vec!["Episode".to_string()]),
|
|
..Default::default()
|
|
};
|
|
|
|
let result = repo.get_items(&season_id, Some(options))
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
|
|
|
|
// Sort client-side by index_number to ensure correct ordering
|
|
// (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));
|
|
debug!("[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];
|
|
debug!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
|
|
return Ok(Some((current_repo_item, next.clone())));
|
|
} else {
|
|
debug!("[PlayerController] Current episode is the last in the season");
|
|
}
|
|
} else {
|
|
debug!("[PlayerController] Current episode not found in season episodes");
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
/// Start autoplay countdown thread
|
|
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().unwrap() = Some(cancel_flag.clone());
|
|
|
|
let event_emitter = self.event_emitter.clone();
|
|
|
|
std::thread::spawn(move || {
|
|
let mut remaining = countdown_seconds;
|
|
|
|
while remaining > 0 {
|
|
std::thread::sleep(Duration::from_secs(1));
|
|
|
|
// Check cancellation
|
|
if *cancel_flag.lock().unwrap() {
|
|
log::info!("[PlayerController] Autoplay countdown cancelled");
|
|
return;
|
|
}
|
|
|
|
remaining -= 1;
|
|
|
|
// Emit countdown tick event
|
|
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::CountdownTick {
|
|
remaining_seconds: remaining,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Countdown finished (final tick at 0 was already emitted inside the loop)
|
|
log::info!("[PlayerController] Autoplay countdown finished");
|
|
});
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_controller_volume_default() {
|
|
let controller = PlayerController::default();
|
|
assert_eq!(controller.volume(), 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_controller_set_volume() {
|
|
let controller = PlayerController::default();
|
|
controller.set_volume(0.5).unwrap();
|
|
assert_eq!(controller.volume(), 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_controller_muted_default() {
|
|
let controller = PlayerController::default();
|
|
assert!(!controller.muted());
|
|
}
|
|
|
|
#[test]
|
|
fn test_controller_volume_delegates_to_backend() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Set volume through controller
|
|
controller.set_volume(0.75).unwrap();
|
|
|
|
// Verify it's reflected in both controller.volume() and backend
|
|
assert_eq!(controller.volume(), 0.75);
|
|
}
|
|
|
|
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
|
(0..count)
|
|
.map(|i| MediaItem {
|
|
id: format!("item_{}", i),
|
|
title: format!("Track {}", i + 1),
|
|
name: Some(format!("Track {}", i + 1)),
|
|
artist: Some("Test Artist".to_string()),
|
|
album: Some("Test Album".to_string()),
|
|
album_name: Some("Test Album".to_string()),
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: Some(vec!["Test Artist".to_string()]),
|
|
primary_image_tag: None,
|
|
item_type: Some("Audio".to_string()),
|
|
playlist_id: None,
|
|
duration: Some(180.0),
|
|
artwork_url: None,
|
|
media_type: MediaType::Audio,
|
|
source: MediaSource::DirectUrl {
|
|
url: format!("http://example.com/track_{}.mp3", i),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_skip_preserves_queue() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create a queue with 5 items
|
|
let items = create_test_items(5);
|
|
let items_clone = items.clone();
|
|
|
|
// Play the queue starting at index 0
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Verify initial state
|
|
{
|
|
let queue = controller.queue();
|
|
let queue_lock = queue.lock().unwrap();
|
|
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");
|
|
}
|
|
|
|
// Skip to next track
|
|
controller.next().unwrap();
|
|
|
|
// Verify queue is intact and index advanced
|
|
{
|
|
let queue = controller.queue();
|
|
let queue_lock = queue.lock().unwrap();
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Skip again
|
|
controller.next().unwrap();
|
|
|
|
// Verify queue still intact and index advanced again
|
|
{
|
|
let queue = controller.queue();
|
|
let queue_lock = queue.lock().unwrap();
|
|
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
|
|
controller.next().unwrap(); // -> item_3
|
|
controller.next().unwrap(); // -> item_4
|
|
|
|
// Verify we're at the last item
|
|
{
|
|
let queue = controller.queue();
|
|
let queue_lock = queue.lock().unwrap();
|
|
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");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_skip_at_end_without_repeat() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create a queue with 3 items
|
|
let items = create_test_items(3);
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Skip to last item
|
|
controller.next().unwrap(); // -> item_1
|
|
controller.next().unwrap(); // -> item_2
|
|
|
|
// Verify we're at the last item
|
|
{
|
|
let queue = controller.queue();
|
|
let queue_lock = queue.lock().unwrap();
|
|
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
|
|
}
|
|
|
|
// Try to skip past the end (without repeat mode)
|
|
// This should succeed but stop playback while preserving the queue
|
|
controller.next().unwrap();
|
|
|
|
// Verify queue is still intact
|
|
{
|
|
let queue = controller.queue();
|
|
let queue_lock = queue.lock().unwrap();
|
|
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
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_skip_with_repeat_all() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create a queue with 3 items
|
|
let items = create_test_items(3);
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Enable repeat all
|
|
controller.cycle_repeat();
|
|
|
|
// Skip to last item
|
|
controller.next().unwrap(); // -> item_1
|
|
controller.next().unwrap(); // -> item_2
|
|
|
|
// Skip again - should wrap to beginning
|
|
controller.next().unwrap();
|
|
|
|
// Verify we wrapped to the first item
|
|
{
|
|
let queue = controller.queue();
|
|
let queue_lock = queue.lock().unwrap();
|
|
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");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_previous_preserves_queue() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create a queue with 5 items, start at item 3
|
|
let items = create_test_items(5);
|
|
let items_clone = items.clone();
|
|
controller.play_queue(items, 3).unwrap();
|
|
|
|
// Verify starting position
|
|
{
|
|
let queue = controller.queue();
|
|
let queue_lock = queue.lock().unwrap();
|
|
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
|
|
}
|
|
|
|
// Go to previous track
|
|
controller.previous().unwrap();
|
|
|
|
// Verify queue is intact and index moved back
|
|
{
|
|
let queue = controller.queue();
|
|
let queue_lock = queue.lock().unwrap();
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_seek_updates_position() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create and play a single item
|
|
let item = create_test_items(1).into_iter().next().unwrap();
|
|
controller.play_item(item).unwrap();
|
|
|
|
// Verify initial position
|
|
assert_eq!(controller.position(), 0.0, "Initial position should be 0");
|
|
|
|
// Seek to 30 seconds
|
|
controller.seek(30.0).unwrap();
|
|
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");
|
|
|
|
// Seek backward to 15 seconds
|
|
controller.seek(15.0).unwrap();
|
|
assert_eq!(controller.position(), 15.0, "Position should be 15 after seeking backward");
|
|
}
|
|
|
|
#[test]
|
|
fn test_seek_while_paused() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create and play a single item
|
|
let item = create_test_items(1).into_iter().next().unwrap();
|
|
controller.play_item(item).unwrap();
|
|
|
|
// Pause playback
|
|
controller.pause().unwrap();
|
|
|
|
// Verify paused state
|
|
assert!(controller.state().is_paused(), "Should be paused");
|
|
|
|
// Seek while paused
|
|
controller.seek(45.0).unwrap();
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
fn test_seek_while_playing() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create and play a single item
|
|
let item = create_test_items(1).into_iter().next().unwrap();
|
|
controller.play_item(item).unwrap();
|
|
|
|
// Ensure playing
|
|
controller.play().unwrap();
|
|
|
|
// Verify playing state
|
|
assert!(controller.state().is_playing(), "Should be playing");
|
|
|
|
// Seek while playing
|
|
controller.seek(20.0).unwrap();
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_sequential_seeks() {
|
|
let controller = PlayerController::default();
|
|
|
|
let item = create_test_items(1).into_iter().next().unwrap();
|
|
controller.play_item(item).unwrap();
|
|
|
|
// Perform multiple seeks in sequence
|
|
let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
|
|
|
|
for pos in positions {
|
|
controller.seek(pos).unwrap();
|
|
assert_eq!(controller.position(), pos, "Position should match after seeking to {}", pos);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_seek_to_zero() {
|
|
let controller = PlayerController::default();
|
|
|
|
let item = create_test_items(1).into_iter().next().unwrap();
|
|
controller.play_item(item).unwrap();
|
|
|
|
// Seek forward
|
|
controller.seek(60.0).unwrap();
|
|
assert_eq!(controller.position(), 60.0);
|
|
|
|
// Seek back to zero
|
|
controller.seek(0.0).unwrap();
|
|
assert_eq!(controller.position(), 0.0, "Should be able to seek to position 0");
|
|
}
|
|
|
|
// Autoplay decision tests
|
|
#[tokio::test]
|
|
async fn test_audio_with_next_advances() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create queue with 2 audio items
|
|
let items = create_test_items(2);
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Clear the NewTrackLoaded reason set by play_queue to simulate natural track end
|
|
controller.take_end_reason();
|
|
|
|
// Simulate first track ending naturally
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should decide to advance to next
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::AdvanceToNext),
|
|
"Expected AdvanceToNext decision when queue has next item"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_audio_at_end_stops() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create queue with 2 items, start at last one
|
|
let items = create_test_items(2);
|
|
controller.play_queue(items, 1).unwrap();
|
|
|
|
// Clear the NewTrackLoaded reason to simulate natural track end
|
|
controller.take_end_reason();
|
|
|
|
// Simulate last track ending naturally
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should decide to stop (no more items)
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::Stop),
|
|
"Expected Stop decision when at end of queue without repeat"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sleep_timer_end_of_track() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create queue with next items
|
|
let items = create_test_items(3);
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Clear the NewTrackLoaded reason to simulate natural track end
|
|
controller.take_end_reason();
|
|
|
|
// Set sleep timer to end of track
|
|
{
|
|
let mut timer = controller.sleep_timer.lock().unwrap();
|
|
timer.mode = SleepTimerMode::EndOfTrack;
|
|
}
|
|
|
|
// Simulate track ending naturally
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should stop despite having next items
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::Stop),
|
|
"Expected Stop decision when sleep timer is EndOfTrack"
|
|
);
|
|
|
|
// Verify timer was cancelled
|
|
{
|
|
let timer = controller.sleep_timer.lock().unwrap();
|
|
assert!(
|
|
matches!(timer.mode, SleepTimerMode::Off),
|
|
"Sleep timer should be cancelled after EndOfTrack"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_empty_queue_stops() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Don't set up any queue
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should stop (no current item)
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::Stop),
|
|
"Expected Stop decision when queue is empty"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_repeat_all_advances_at_end() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create queue with 2 items, enable repeat all
|
|
let items = create_test_items(2);
|
|
controller.play_queue(items, 1).unwrap(); // Start at last item
|
|
controller.cycle_repeat(); // Enable repeat all
|
|
|
|
// Clear the NewTrackLoaded reason to simulate natural track end
|
|
controller.take_end_reason();
|
|
|
|
// Simulate last track ending naturally
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should advance (will wrap to beginning due to repeat all)
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::AdvanceToNext),
|
|
"Expected AdvanceToNext decision at end of queue with repeat all"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_repeat_one_advances() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create queue with 2 items
|
|
let items = create_test_items(2);
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Enable repeat one
|
|
controller.cycle_repeat(); // Once for all
|
|
controller.cycle_repeat(); // Twice for one
|
|
|
|
// Clear the NewTrackLoaded reason to simulate natural track end
|
|
controller.take_end_reason();
|
|
|
|
// Simulate track ending naturally
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should advance (which repeats the same track)
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::AdvanceToNext),
|
|
"Expected AdvanceToNext decision with repeat one (repeats same track)"
|
|
);
|
|
}
|
|
|
|
// EndReason state machine tests
|
|
#[test]
|
|
fn test_load_and_play_sets_new_track_loaded() {
|
|
let controller = PlayerController::default();
|
|
let item = create_test_items(1).into_iter().next().unwrap();
|
|
|
|
// End reason should be None initially
|
|
assert!(controller.take_end_reason().is_none());
|
|
|
|
// Load and play should set NewTrackLoaded
|
|
controller.load_and_play(&item).unwrap();
|
|
|
|
// Verify end reason was set
|
|
let reason = controller.take_end_reason();
|
|
assert_eq!(reason, Some(EndReason::NewTrackLoaded));
|
|
}
|
|
|
|
#[test]
|
|
fn test_stop_sets_user_stop() {
|
|
let controller = PlayerController::default();
|
|
let item = create_test_items(1).into_iter().next().unwrap();
|
|
|
|
// Play an item first
|
|
controller.play_item(item).unwrap();
|
|
|
|
// Clear any end reason from load_and_play
|
|
controller.take_end_reason();
|
|
|
|
// Stop should set UserStop
|
|
controller.stop().unwrap();
|
|
|
|
// Verify end reason was set
|
|
let reason = controller.take_end_reason();
|
|
assert_eq!(reason, Some(EndReason::UserStop));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_on_playback_ended_with_new_track_loaded_stops() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create queue with 2 items
|
|
let items = create_test_items(2);
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Manually set end reason to NewTrackLoaded
|
|
controller.set_end_reason(EndReason::NewTrackLoaded);
|
|
|
|
// Call on_playback_ended
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should stop without advancing
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::Stop),
|
|
"Expected Stop decision when EndReason is NewTrackLoaded"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_on_playback_ended_with_user_stop_stops() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create queue with 2 items
|
|
let items = create_test_items(2);
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Manually set end reason to UserStop
|
|
controller.set_end_reason(EndReason::UserStop);
|
|
|
|
// Call on_playback_ended
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should stop without advancing
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::Stop),
|
|
"Expected Stop decision when EndReason is UserStop"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_on_playback_ended_natural_end_advances() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create queue with 2 items
|
|
let items = create_test_items(2);
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Clear the NewTrackLoaded reason to simulate natural track end
|
|
controller.take_end_reason();
|
|
|
|
// Call on_playback_ended (no end reason set = natural end)
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should advance to next (natural end with next track available)
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::AdvanceToNext),
|
|
"Expected AdvanceToNext decision when track ends naturally with next track available"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_on_playback_ended_with_user_skip_stops() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create queue with 2 items
|
|
let items = create_test_items(2);
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Set end reason to UserSkip
|
|
controller.set_end_reason(EndReason::UserSkip);
|
|
|
|
// Call on_playback_ended
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should stop without advancing (skip already handled)
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::Stop),
|
|
"Expected Stop decision when EndReason is UserSkip"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_on_playback_ended_with_error_stops() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Create queue with 2 items
|
|
let items = create_test_items(2);
|
|
controller.play_queue(items, 0).unwrap();
|
|
|
|
// Set end reason to Error
|
|
controller.set_end_reason(EndReason::Error);
|
|
|
|
// Call on_playback_ended
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
// Should stop without advancing
|
|
assert!(
|
|
matches!(decision, AutoplayDecision::Stop),
|
|
"Expected Stop decision when EndReason is Error"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_take_end_reason_clears_state() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Set a reason
|
|
controller.set_end_reason(EndReason::NewTrackLoaded);
|
|
|
|
// Take it once
|
|
let reason = controller.take_end_reason();
|
|
assert_eq!(reason, Some(EndReason::NewTrackLoaded));
|
|
|
|
// Take it again - should be None
|
|
let reason = controller.take_end_reason();
|
|
assert!(reason.is_none(), "take_end_reason should clear the state");
|
|
}
|
|
}
|