1pub mod autoplay;
6pub mod backend;
7pub mod background_policy;
8#[cfg(any(test, feature = "conformance"))]
9pub mod conformance;
10pub mod events;
11#[cfg(any(test, feature = "conformance"))]
12pub mod fake_player;
13#[cfg(test)]
14mod fake_player_conformance;
15pub mod legacy_player;
16pub mod media;
17pub mod media_player;
18#[cfg(target_os = "linux")]
19pub mod mpv_player;
20pub mod queue;
21pub mod seek;
22pub mod session;
23pub mod sleep_timer;
24pub mod state;
25pub mod stream_end;
26
27#[cfg(test)]
28mod mpv_backend_test;
29
30#[cfg(target_os = "android")]
32pub mod android;
33
34#[cfg(target_os = "linux")]
35pub mod mpv_backend;
36
37pub mod native_video;
40
41#[cfg(target_os = "linux")]
47pub mod mpv_render;
48
49#[cfg(target_os = "linux")]
54pub mod video_surface;
55
56#[cfg(not(any(target_os = "linux", target_os = "android")))]
59pub mod webview_audio_backend;
60
61use crate::repository::stream_selection::StreamSelection;
63pub use autoplay::{AutoplayDecision, AutoplaySettings};
64pub use backend::{NullBackend, PlayerBackend, PlayerError};
65pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
66pub use legacy_player::LegacyPlayer;
67pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
68pub use media_player::{MediaPlayer, OpenRequest, Phase};
69pub use queue::{QueueManager, RepeatMode};
70pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
71pub use session::{MediaSessionManager, MediaSessionType};
72pub use sleep_timer::{SleepTimerMode, SleepTimerState};
73pub use state::{EndReason, PlayerState};
74
75#[cfg(target_os = "android")]
77pub use android::ExoPlayerBackend;
78
79#[cfg(target_os = "linux")]
80pub use mpv_backend::MpvBackend;
81
82#[cfg(not(any(target_os = "linux", target_os = "android")))]
83pub use webview_audio_backend::WebviewAudioBackend;
84
85#[cfg(target_os = "android")]
86pub use android::{
87 disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
88 set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
89};
90
91pub trait PlaybackReportSink: Send + Sync {
102 fn send(&self, operation: PlaybackOperation);
104}
105
106struct ReporterSink {
112 reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
113}
114
115impl PlaybackReportSink for ReporterSink {
116 fn send(&self, operation: PlaybackOperation) {
117 let reporter = self.reporter.clone();
118 let task = async move {
119 let guard = reporter.lock().await;
120 let Some(reporter) = guard.as_ref() else {
121 warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
122 return;
123 };
124 if let Err(e) = reporter.report(operation, true).await {
126 log::error!("[PlayerController] Failed to report playback: {}", e);
127 }
128 };
129
130 if let Ok(handle) = tokio::runtime::Handle::try_current() {
131 handle.spawn(task);
132 } else {
133 std::thread::spawn(move || match tokio::runtime::Runtime::new() {
134 Ok(rt) => rt.block_on(task),
135 Err(e) => log::error!(
136 "[PlayerController] No runtime available to report playback: {}",
137 e
138 ),
139 });
140 }
141 }
142}
143
144fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
154 match runtime {
155 Some(runtime) if runtime > 0.0 => runtime,
156 _ => last_position.max(0.0),
157 }
158}
159
160#[cfg_attr(not(target_os = "android"), allow(dead_code))]
168const RESUME_BACKOFF_STEP_SECS: u64 = 2;
169
170#[derive(Debug, Clone)]
179#[cfg_attr(not(target_os = "android"), allow(dead_code))]
182pub struct LockscreenMetadata {
183 pub title: String,
184 pub artist: String,
185 pub album: Option<String>,
186 pub duration_ms: i64,
188 pub position_ms: i64,
190 pub is_playing: bool,
191}
192
193pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
201 #[cfg(target_os = "android")]
202 {
203 return android::update_lockscreen_metadata(_meta);
204 }
205 #[cfg(not(target_os = "android"))]
206 {
207 Ok(())
208 }
209}
210
211pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
217 #[cfg(target_os = "android")]
218 {
219 return android::set_position_offset(_offset_seconds);
220 }
221 #[cfg(not(target_os = "android"))]
222 {
223 Ok(())
224 }
225}
226
227use crate::utils::lock::MutexSafe;
228use log::{debug, error, info, warn};
229use std::sync::{Arc, Mutex};
230use std::time::Duration;
231use tokio::sync::Mutex as TokioMutex;
232
233use crate::jellyfin::JellyfinClient;
234use crate::playback_reporting::{
235 EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
236};
237use crate::repository::MediaRepository;
238use crate::settings::AudioSettings;
239use crate::utils::conversions::seconds_to_ticks;
240
241pub struct PlayerController {
243 backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
246 queue: Arc<Mutex<QueueManager>>,
247 jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
248 muted: bool,
249
250 sleep_timer: Arc<Mutex<SleepTimerState>>,
252
253 autoplay_settings: Arc<Mutex<AutoplaySettings>>,
255
256 repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
258
259 event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
261
262 countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
264
265 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
267
268 reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
271
272 position_throttler: Arc<EventThrottler>,
275
276 end_reason: Arc<Mutex<Option<EndReason>>>,
278
279 autoplay_episode_count: Arc<Mutex<u32>>,
281
282 background_audio_base: Arc<Mutex<f64>>,
296
297 background_audio_active: Arc<Mutex<bool>>,
307
308 stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
316
317 html5_playing: Arc<Mutex<Option<bool>>>,
327
328 reported_time: Arc<Mutex<stream_end::ObservedTime>>,
340}
341
342impl PlayerController {
343 pub fn new(
344 backend: Box<dyn MediaPlayer>,
345 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
346 position_throttler: Arc<EventThrottler>,
347 ) -> Self {
348 let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
349 reporter: playback_reporter.clone(),
350 });
351 let controller = Self {
352 backend: Arc::new(Mutex::new(backend)),
353 queue: Arc::new(Mutex::new(QueueManager::new())),
354 jellyfin_client: Arc::new(Mutex::new(None)),
355 muted: false,
356 sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
357 autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
358 repository: Arc::new(Mutex::new(None)),
359 event_emitter: Arc::new(Mutex::new(None)),
360 countdown_cancel: Arc::new(Mutex::new(None)),
361 playback_reporter,
362 reports: Arc::new(Mutex::new(reports)),
363 position_throttler,
364 end_reason: Arc::new(Mutex::new(None)),
365 autoplay_episode_count: Arc::new(Mutex::new(0)),
366 background_audio_base: Arc::new(Mutex::new(0.0)),
367 background_audio_active: Arc::new(Mutex::new(false)),
368 stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
369 html5_playing: Arc::new(Mutex::new(None)),
370 reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
371 };
372
373 controller.start_timer_thread();
375
376 controller
377 }
378
379 pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
381 let mut jellyfin = self.jellyfin_client.lock_safe();
382 *jellyfin = client;
383 log::info!(
384 "[PlayerController] Jellyfin client configured: {}",
385 jellyfin.is_some()
386 );
387 }
388
389 pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
391 self.jellyfin_client.clone()
392 }
393
394 pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
401 *self.repository.lock_safe() = Some(repo);
402 }
403
404 pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
407 let mut reporter_guard = self.playback_reporter.lock().await;
408 *reporter_guard = reporter;
409 log::info!(
410 "[PlayerController] Playback reporter configured: {}",
411 reporter_guard.is_some()
412 );
413 }
414
415 #[allow(dead_code)]
418 pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
419 self.playback_reporter.clone()
420 }
421
422 #[allow(dead_code)]
425 pub fn position_throttler(&self) -> Arc<EventThrottler> {
426 self.position_throttler.clone()
427 }
428
429 fn set_end_reason(&self, reason: EndReason) {
431 log::debug!("[PlayerController] Setting end reason: {:?}", reason);
432 *self.end_reason.lock_safe() = Some(reason);
433 }
434
435 fn take_end_reason(&self) -> Option<EndReason> {
437 self.end_reason.lock_safe().take()
438 }
439
440 fn peek_end_reason(&self) -> Option<EndReason> {
447 *self.end_reason.lock_safe()
448 }
449
450 fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
463 log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
464 *end_reason.lock_safe() = Some(EndReason::UserStop);
465 }
466
467 fn increment_autoplay_count(&self) -> bool {
469 let max = self.autoplay_settings.lock_safe().max_episodes;
470
471 if max == 0 {
472 return false;
474 }
475
476 let mut count = self.autoplay_episode_count.lock_safe();
477 *count += 1;
478 debug!(
479 "[PlayerController] Autoplay episode count: {}/{}",
480 *count, max
481 );
482
483 *count >= max
484 }
485
486 fn reset_autoplay_count(&self) {
488 let mut count = self.autoplay_episode_count.lock_safe();
489 if *count > 0 {
490 debug!(
491 "[PlayerController] Resetting autoplay episode counter (was {})",
492 *count
493 );
494 }
495 *count = 0;
496 }
497
498 pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
500 debug!("[PlayerController] play_item: {}", item.title);
501
502 self.reset_autoplay_count();
504
505 {
507 let mut queue = self.queue.lock_safe();
508 queue.set_queue(vec![item.clone()], 0);
509 }
510
511 self.load_and_play(&item)?;
513
514 Ok(())
515 }
516
517 pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
529 debug!(
530 "[PlayerController] set_current_item (no backend load): {}",
531 item.title
532 );
533
534 self.reset_autoplay_count();
535 self.clear_reported_time();
541
542 let mut queue = self.queue.lock_safe();
543 queue.set_queue(vec![item], 0);
544
545 Ok(())
546 }
547
548 pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
551 debug!("[PlayerController] load_and_play: {}", item.title);
552
553 self.set_end_reason(EndReason::NewTrackLoaded);
555
556 *self.html5_playing.lock_safe() = None;
578
579 let mut backend = self.backend.lock_safe();
580 backend.open(OpenRequest::new(
583 item.clone(),
584 StreamSelection::for_queued_item(
585 item.playable_url(),
586 item.transport,
587 item.needs_transcoding,
588 ),
589 ))?;
590 drop(backend);
591
592 self.clear_reported_time();
595
596 if let Some(jellyfin_id) = item.jellyfin_id() {
598 let context = if item.album_id.is_some() {
600 Some(PlaybackContext {
601 context_type: "container".to_string(),
602 context_id: item.album_id.clone(),
603 })
604 } else {
605 None
606 };
607
608 let position = self.absolute_position();
614
615 log::info!(
616 "[PlayerController] Reporting playback start: {} @ {:.1}s",
617 jellyfin_id,
618 position
619 );
620 self.report(PlaybackOperation::Start {
621 item_id: jellyfin_id.to_string(),
622 position_ticks: seconds_to_ticks(position),
623 context,
624 });
625 }
626
627 Ok(())
628 }
629
630 pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
632 self.play_queue_from(items, start_index, None)
633 }
634
635 pub fn play_queue_from(
642 &self,
643 items: Vec<MediaItem>,
644 start_index: usize,
645 start_position: Option<f64>,
646 ) -> Result<(), PlayerError> {
647 debug!(
648 "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
649 items.len(),
650 start_index,
651 start_position
652 );
653
654 self.reset_autoplay_count();
656
657 {
658 let mut queue = self.queue.lock_safe();
659 queue.set_queue(items, start_index);
660 }
661
662 if let Some(item) = self.queue.lock_safe().current().cloned() {
664 self.load_and_play(&item)?;
665
666 if let Some(position) = start_position {
670 if position > 0.5 {
671 self.seek(position)?;
672 }
673 }
674 }
675
676 Ok(())
677 }
678
679 pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
685 debug!(
686 "[PlayerController] set_queue (no local playback): {} items, index {}",
687 items.len(),
688 start_index
689 );
690 self.reset_autoplay_count();
691 let mut queue = self.queue.lock_safe();
692 queue.set_queue(items, start_index);
693 Ok(())
694 }
695
696 pub fn is_html5_active(&self) -> bool {
701 self.html5_playing.lock_safe().is_some()
702 }
703
704 pub fn html5_is_playing(&self) -> bool {
709 self.html5_playing.lock_safe().unwrap_or(false)
710 }
711
712 fn emit_html5_control(&self, action: &str) {
714 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
715 emitter.emit(PlayerStatusEvent::ControlCommand {
716 action: action.to_string(),
717 position: None,
718 });
719 }
720 }
721
722 pub fn play(&self) -> Result<(), PlayerError> {
724 debug!("[PlayerController] play");
725 if self.is_html5_active() {
728 self.emit_html5_control("play");
729 return Ok(());
730 }
731 let mut backend = self.backend.lock_safe();
732 backend.play()
733 }
734
735 pub fn pause(&self) -> Result<(), PlayerError> {
737 if self.is_html5_active() {
738 self.emit_html5_control("pause");
739 return Ok(());
740 }
741 let mut backend = self.backend.lock_safe();
742 backend.pause()
743 }
744
745 pub fn toggle_playback(&self) -> Result<(), PlayerError> {
753 if self.is_html5_active() {
754 let action = if self.html5_is_playing() {
755 "pause"
756 } else {
757 "play"
758 };
759 self.emit_html5_control(action);
760 return Ok(());
761 }
762 let mut backend = self.backend.lock_safe();
763 if backend.snapshot().phase.is_active() {
764 backend.pause()
765 } else {
766 backend.play()
767 }
768 }
769
770 pub fn stop(&self) -> Result<(), PlayerError> {
772 self.set_end_reason(EndReason::UserStop);
774
775 let jellyfin_id = {
777 let queue = self.queue.lock_safe();
778 queue
779 .current()
780 .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
781 };
782
783 let position = self.absolute_position();
786
787 let mut backend = self.backend.lock_safe();
788 backend.close()?;
789 drop(backend);
790 self.clear_reported_time();
791
792 if self.is_background_audio_active() {
808 debug!("[PlayerController] stop: clearing an active background-audio handoff");
809 }
810 *self.background_audio_active.lock_safe() = false;
811 self.set_background_audio_base(0.0);
812 *self.html5_playing.lock_safe() = None;
813
814 if let Some(jellyfin_id) = jellyfin_id {
815 self.report_stopped_at(jellyfin_id, position);
816 }
817
818 Ok(())
819 }
820
821 fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
835 if position <= 0.0 {
836 debug!(
837 "[PlayerController] Withholding zero-position stop report for {} \
838 (nothing played; reporting it would clear the resume point)",
839 jellyfin_id
840 );
841 return;
842 }
843
844 log::info!(
845 "[PlayerController] Reporting playback stopped: {} @ {:.1}s",
846 jellyfin_id,
847 position
848 );
849 self.report(PlaybackOperation::Stopped {
850 item_id: jellyfin_id,
851 position_ticks: seconds_to_ticks(position),
852 });
853 }
854
855 pub fn next(&self) -> Result<(), PlayerError> {
860 self.reset_autoplay_count();
862
863 let next_item = {
864 let mut queue = self.queue.lock_safe();
865 queue.next().cloned()
866 };
867
868 debug!(
869 "[PlayerController] next: {:?}",
870 next_item.as_ref().map(|i| &i.title)
871 );
872
873 if let Some(item) = next_item {
874 self.load_and_play(&item)
875 } else {
876 debug!("[PlayerController] No next item, stopping");
877 self.stop()
878 }
879 }
880
881 pub fn previous(&self) -> Result<(), PlayerError> {
886 self.reset_autoplay_count();
888 {
890 let backend = self.backend.lock_safe();
891 if backend.snapshot().position.as_secs_f64() > 3.0 {
892 debug!("[PlayerController] previous: restarting current track (position > 3s)");
893 drop(backend);
894 return self.seek(0.0);
895 }
896 }
897
898 let prev_item = {
899 let mut queue = self.queue.lock_safe();
900 queue.previous().cloned()
901 };
902
903 debug!(
904 "[PlayerController] previous: {:?}",
905 prev_item.as_ref().map(|i| &i.title)
906 );
907
908 if let Some(item) = prev_item {
909 self.load_and_play(&item)
910 } else {
911 self.seek(0.0)
912 }
913 }
914
915 pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
922 let mut backend = self.backend.lock_safe();
923 backend.seek(Duration::from_secs_f64(position.max(0.0)))
924 }
925
926 pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
944 let rebuild = self.is_background_audio_active() && {
949 let queue = self.queue.lock_safe();
950 queue
951 .current()
952 .map(|item| {
953 Self::is_audio_only_video(item)
954 && matches!(item.source, MediaSource::Remote { .. })
955 })
956 .unwrap_or(false)
957 };
958
959 if rebuild {
960 return self.resume_stream_at(position.max(0.0)).await;
961 }
962
963 self.seek(position).map_err(|e| e.to_string())
964 }
965
966 pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
968 self.backend.lock_safe().set_volume(volume)
969 }
970
971 pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
973 let mut backend = self.backend.lock_safe();
974 backend.select_audio_track(Some(stream_index))
975 }
976
977 pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
979 let mut backend = self.backend.lock_safe();
980 backend.select_subtitle_track(stream_index)
981 }
982
983 pub fn state(&self) -> PlayerState {
985 let phase = self.backend.lock_safe().snapshot().phase;
986 let media = self.queue.lock_safe().current().cloned();
987 match (phase, media) {
988 (Phase::Playing, Some(media)) => PlayerState::Playing {
989 media,
990 position: self.position(),
991 duration: self.duration().unwrap_or(0.0),
992 },
993 (Phase::Paused, Some(media)) => PlayerState::Paused {
994 media,
995 position: self.position(),
996 duration: self.duration().unwrap_or(0.0),
997 },
998 (Phase::Opening, Some(media)) => PlayerState::Loading { media },
999 (Phase::Failed(error), media) => PlayerState::Error { media, error },
1000 _ => PlayerState::Idle,
1003 }
1004 }
1005
1006 pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
1010 self.backend.lock_safe().capabilities()
1011 }
1012
1013 pub fn position(&self) -> f64 {
1015 self.backend.lock_safe().snapshot().position.as_secs_f64()
1016 }
1017
1018 pub fn absolute_position(&self) -> f64 {
1041 let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
1042 let reported = self.reported_time.lock_safe().last_position();
1043 let base = if self.is_background_audio_active() {
1044 *self.background_audio_base.lock_safe()
1045 } else {
1046 0.0
1047 };
1048
1049 native.max(reported).max(base)
1050 }
1051
1052 pub fn observed_duration(&self) -> Option<f64> {
1056 self.reported_time.lock_safe().last_duration()
1057 }
1058
1059 fn clear_reported_time(&self) {
1067 self.reported_time.lock_safe().reset();
1068 }
1069
1070 #[cfg_attr(not(test), allow(dead_code))]
1075 pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
1076 *self.reports.lock_safe() = sink;
1077 }
1078
1079 fn report(&self, operation: PlaybackOperation) {
1084 let sink = self.reports.lock_safe().clone();
1085 sink.send(operation);
1086 }
1087
1088 fn current_jellyfin_id(&self) -> Option<String> {
1090 let queue = self.queue.lock_safe();
1091 queue
1092 .current()
1093 .and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
1094 }
1095
1096 pub fn duration(&self) -> Option<f64> {
1105 let usable = |d: f64| (d > 0.0).then_some(d);
1118
1119 self.backend
1120 .lock_safe()
1121 .snapshot()
1122 .duration
1123 .map(|d| d.as_secs_f64())
1124 .and_then(usable)
1125 .or_else(|| self.observed_duration().and_then(usable))
1126 .or_else(|| {
1127 self.queue
1128 .lock_safe()
1129 .current()
1130 .and_then(|item| item.duration)
1131 .and_then(usable)
1132 })
1133 }
1134
1135 pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
1137 self.queue.clone()
1138 }
1139
1140 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
1148 pub fn current_is_audio_episode(&self) -> bool {
1149 self.queue
1150 .lock_safe()
1151 .current()
1152 .map(|item| {
1153 item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
1154 })
1155 .unwrap_or(false)
1156 }
1157
1158 pub fn clear_queue(&self) {
1162 self.queue.lock_safe().clear();
1163 }
1164
1165 pub fn toggle_shuffle(&self) {
1167 self.queue.lock_safe().toggle_shuffle();
1168 }
1169
1170 pub fn cycle_repeat(&self) {
1172 self.queue.lock_safe().cycle_repeat();
1173 }
1174
1175 pub fn is_shuffle(&self) -> bool {
1177 self.queue.lock_safe().is_shuffle()
1178 }
1179
1180 pub fn repeat_mode(&self) -> RepeatMode {
1182 self.queue.lock_safe().repeat_mode()
1183 }
1184
1185 pub fn volume(&self) -> f32 {
1187 self.backend.lock_safe().snapshot().volume
1188 }
1189
1190 pub fn muted(&self) -> bool {
1192 self.muted
1193 }
1194
1195 pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
1197 self.backend.lock_safe().set_audio_settings(settings)
1198 }
1199
1200 pub fn audio_settings(&self) -> AudioSettings {
1202 self.backend.lock_safe().audio_settings()
1203 }
1204
1205 pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
1209 let mut event_emitter = self.event_emitter.lock_safe();
1210 *event_emitter = Some(emitter);
1211 }
1212
1213 pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
1215 self.event_emitter.lock_safe().clone()
1216 }
1217
1218 pub fn sleep_timer_state(&self) -> SleepTimerState {
1220 self.sleep_timer.lock_safe().clone()
1221 }
1222
1223 pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
1225 let mut timer = self.sleep_timer.lock_safe();
1226 timer.mode = mode.clone();
1227 if let SleepTimerMode::Time { end_time } = mode {
1228 let now = chrono::Utc::now().timestamp_millis();
1229 timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
1230 } else {
1231 timer.remaining_seconds = 0;
1232 }
1233 drop(timer);
1234
1235 self.emit_sleep_timer_changed();
1237 }
1238
1239 pub fn cancel_sleep_timer(&self) {
1241 self.set_sleep_timer(SleepTimerMode::Off);
1242 }
1243
1244 fn start_timer_thread(&self) {
1246 let sleep_timer = self.sleep_timer.clone();
1247 let event_emitter = self.event_emitter.clone();
1248 let backend = self.backend.clone();
1249 let end_reason = self.end_reason.clone();
1250
1251 std::thread::spawn(move || {
1252 loop {
1253 std::thread::sleep(Duration::from_secs(1));
1254
1255 let mut timer = sleep_timer.lock_safe();
1256 if timer.is_active() {
1257 timer.update_remaining_seconds();
1258
1259 if matches!(timer.mode, SleepTimerMode::Time { .. })
1261 && timer.remaining_seconds == 0
1262 {
1263 debug!("[SleepTimer] Time-based timer expired, stopping playback");
1264 timer.cancel();
1265
1266 Self::note_sleep_timer_stop(&end_reason);
1273
1274 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1276 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1277 mode: SleepTimerMode::Off,
1278 remaining_seconds: 0,
1279 });
1280 emitter.emit(PlayerStatusEvent::SleepTimerExpired);
1284 }
1285 drop(timer);
1286
1287 if let Err(e) = backend.lock_safe().close() {
1289 error!("[SleepTimer] Failed to stop playback: {}", e);
1290 }
1291 continue;
1292 }
1293
1294 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1296 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1297 mode: timer.mode.clone(),
1298 remaining_seconds: timer.remaining_seconds,
1299 });
1300 }
1301 }
1302 drop(timer);
1303 }
1304 });
1305 }
1306
1307 fn emit_sleep_timer_changed(&self) {
1309 let timer = self.sleep_timer.lock_safe().clone();
1310
1311 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1312 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1313 mode: timer.mode,
1314 remaining_seconds: timer.remaining_seconds,
1315 });
1316 }
1317 }
1318
1319 pub fn emit_queue_changed(&self) {
1321 let queue = self.queue.lock_safe();
1322
1323 debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
1324 queue.items().len(), queue.current_index());
1325
1326 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1327 emitter.emit(PlayerStatusEvent::QueueChanged {
1328 items: queue.items().to_vec(),
1329 current_index: queue.current_index(),
1330 shuffle: queue.is_shuffle(),
1331 repeat: queue.repeat_mode(),
1332 has_next: queue.has_next(),
1333 has_previous: queue.has_previous(),
1334 });
1335 } else {
1336 warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
1337 }
1338 }
1339
1340 pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
1355 if self.is_background_audio_active() {
1360 debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
1361 return;
1362 }
1363 let element_gone = {
1369 let mut tracked = self.html5_playing.lock_safe();
1370 *tracked = match state.as_str() {
1371 "playing" => Some(true),
1372 "paused" | "loading" => Some(false),
1376 _ => None,
1378 };
1379 tracked.is_none()
1380 };
1381 if element_gone {
1384 self.clear_reported_time();
1385 }
1386 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1387 emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
1388 }
1389 }
1390
1391 pub fn report_html5_position(&self, position: f64, duration: f64) {
1396 if self.is_background_audio_active() {
1399 return;
1400 }
1401 self.reported_time.lock_safe().record(position, duration);
1404
1405 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1406 emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
1407 }
1408
1409 self.report_progress_throttled(position);
1410 }
1411
1412 fn report_progress_throttled(&self, position: f64) {
1427 if position <= 0.0 {
1428 return;
1429 }
1430 let Some(item_id) = self.current_jellyfin_id() else {
1431 return;
1432 };
1433 if !self.position_throttler.should_report(&item_id) {
1434 return;
1435 }
1436
1437 self.report(PlaybackOperation::Progress {
1438 item_id: item_id.clone(),
1439 position_ticks: seconds_to_ticks(position),
1440 is_paused: false,
1443 });
1444 self.position_throttler.mark_reported(&item_id);
1445 }
1446
1447 pub fn report_html5_media_loaded(&self, duration: f64) {
1450 if self.is_background_audio_active() {
1452 return;
1453 }
1454 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1455 emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
1456 }
1457 }
1458
1459 pub fn autoplay_settings(&self) -> AutoplaySettings {
1463 self.autoplay_settings.lock_safe().clone()
1464 }
1465
1466 pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
1468 let validated = settings.with_validated_countdown();
1469 *self.autoplay_settings.lock_safe() = validated;
1470 }
1471
1472 pub fn cancel_autoplay_countdown(&self) {
1474 if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
1475 *cancel_flag.lock_safe() = true;
1476 }
1477 }
1478
1479 pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
1484 if let Some(position) = self.truncated_stream_resume_position() {
1490 return Ok(AutoplayDecision::ResumeStream { position });
1491 }
1492
1493 let end_reason = self.take_end_reason();
1495
1496 debug!(
1497 "[PlayerController] on_playback_ended: end_reason={:?}",
1498 end_reason
1499 );
1500
1501 match end_reason {
1503 None | Some(EndReason::Finished) => {
1504 debug!("[PlayerController] Track finished naturally, checking autoplay");
1506 }
1507 Some(EndReason::NewTrackLoaded) => {
1508 debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
1510 return Ok(AutoplayDecision::Stop);
1511 }
1512 Some(EndReason::UserStop) => {
1513 debug!("[PlayerController] UserStop - stopping without autoplay");
1515 return Ok(AutoplayDecision::Stop);
1516 }
1517 Some(EndReason::UserSkip) => {
1518 debug!("[PlayerController] UserSkip - stopping without autoplay");
1520 return Ok(AutoplayDecision::Stop);
1521 }
1522 Some(EndReason::Error) => {
1523 debug!("[PlayerController] Error - stopping without autoplay");
1525 return Ok(AutoplayDecision::Stop);
1526 }
1527 }
1528
1529 let current_item = {
1530 let queue = self.queue.lock_safe();
1531 queue.current().cloned()
1532 };
1533
1534 let Some(current) = current_item else {
1535 return Ok(AutoplayDecision::Stop);
1536 };
1537
1538 self.report_completion(¤t);
1542
1543 let timer_mode = {
1545 let timer = self.sleep_timer.lock_safe();
1546 timer.mode.clone()
1547 };
1548
1549 match &timer_mode {
1550 SleepTimerMode::Time { end_time } => {
1551 let now = chrono::Utc::now().timestamp_millis();
1553 if now >= *end_time {
1554 debug!("[PlayerController] Time-based sleep timer expired at track boundary");
1555 self.sleep_timer.lock_safe().cancel();
1556 self.emit_sleep_timer_changed();
1557 return Ok(AutoplayDecision::Stop);
1558 }
1559 }
1560 SleepTimerMode::EndOfTrack => {
1561 self.sleep_timer.lock_safe().cancel();
1563 self.emit_sleep_timer_changed();
1564 return Ok(AutoplayDecision::Stop);
1565 }
1566 SleepTimerMode::Episodes { .. } => {
1567 let is_episode = self.is_episode_item(¤t).await;
1572
1573 if is_episode {
1574 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
1575 self.emit_sleep_timer_changed();
1576
1577 if should_stop {
1578 return Ok(AutoplayDecision::Stop);
1579 }
1580 }
1581 }
1582 _ => {
1583 }
1585 }
1586
1587 if self.is_episode_item(¤t).await {
1593 let repo = self.repository.lock_safe().clone();
1594 let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
1595 let next_ep_result = if let Some(repo) = &repo {
1596 match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
1599 Ok(next) => next,
1600 Err(e) => {
1601 warn!(
1602 "[PlayerController] Next-episode lookup failed for {}: {}",
1603 jellyfin_id, e
1604 );
1605 None
1606 }
1607 }
1608 } else {
1609 warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
1610 None
1611 };
1612 if let Some(next_ep) = next_ep_result {
1613 let settings = self.autoplay_settings.lock_safe().clone();
1614
1615 let limit_reached = self.increment_autoplay_count();
1617 if limit_reached {
1618 debug!(
1619 "[PlayerController] Auto-play episode limit reached ({} episodes)",
1620 settings.max_episodes
1621 );
1622 }
1623
1624 return Ok(AutoplayDecision::ShowNextEpisodePopup {
1625 current_episode: next_ep.0, next_episode: next_ep.1,
1627 countdown_seconds: settings.countdown_seconds,
1628 auto_advance: settings.enabled && !limit_reached,
1629 });
1630 }
1631 return Ok(AutoplayDecision::Stop);
1633 }
1634
1635 let has_next = {
1637 let queue = self.queue.lock_safe();
1638 queue.has_next()
1639 };
1640
1641 if has_next {
1642 Ok(AutoplayDecision::AdvanceToNext)
1644 } else {
1645 Ok(AutoplayDecision::Stop)
1647 }
1648 }
1649
1650 fn report_completion(&self, item: &MediaItem) {
1671 if !Self::is_audio_only_video(item) {
1672 return;
1673 }
1674 let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
1675 return;
1676 };
1677
1678 let position = completion_report_position(item.duration, self.absolute_position());
1679 log::info!(
1680 "[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
1681 jellyfin_id,
1682 position
1683 );
1684 self.report_stopped_at(jellyfin_id, position);
1685 }
1686
1687 pub fn set_background_audio_base(&self, seconds: f64) {
1692 *self.background_audio_base.lock_safe() = seconds.max(0.0);
1693 }
1694
1695 pub fn enter_background_audio(&self, position: f64) {
1706 self.set_background_audio_base(position);
1707 *self.background_audio_active.lock_safe() = true;
1708 *self.html5_playing.lock_safe() = None;
1709 self.clear_reported_time();
1712 }
1713
1714 pub fn exit_background_audio(&self) -> f64 {
1722 *self.background_audio_active.lock_safe() = false;
1723 self.take_background_audio_base()
1724 }
1725
1726 pub fn is_background_audio_active(&self) -> bool {
1731 *self.background_audio_active.lock_safe()
1732 }
1733
1734 pub fn take_background_audio_base(&self) -> f64 {
1738 let mut base = self.background_audio_base.lock_safe();
1739 std::mem::replace(&mut *base, 0.0)
1740 }
1741
1742 pub async fn auto_advance_to_next_episode(
1753 &self,
1754 next_episode: crate::repository::types::MediaItem,
1755 countdown_seconds: u32,
1756 ) {
1757 if self.current_is_audio_episode() {
1762 info!(
1763 "[PlayerController] Background audio episode — advancing to {} in backend",
1764 next_episode.id
1765 );
1766 match self
1767 .advance_to_next_episode_audio_only(&next_episode.id)
1768 .await
1769 {
1770 Ok(()) => self.emit_queue_changed(),
1771 Err(e) => {
1772 error!(
1773 "[PlayerController] Background audio advance failed: {} — stopping",
1774 e
1775 );
1776 if let Some(emitter) = self.event_emitter() {
1777 emitter.emit(PlayerStatusEvent::PlaybackEnded);
1778 }
1779 }
1780 }
1781 return;
1782 }
1783
1784 self.start_autoplay_countdown(next_episode, countdown_seconds);
1786 }
1787
1788 fn is_audio_only_video(item: &MediaItem) -> bool {
1792 stream_end::is_audio_only_video(item)
1793 }
1794
1795 fn claim_stream_resume(&self) -> Option<(f64, u32)> {
1810 let current = {
1811 let queue = self.queue.lock_safe();
1812 queue.current().cloned()
1813 }?;
1814 if !matches!(current.source, MediaSource::Remote { .. }) {
1815 return None;
1816 }
1817
1818 let absolute = self.absolute_position();
1823
1824 match self.stream_resume.lock_safe().allow_attempt(absolute) {
1825 Some(attempt) => Some((absolute, attempt)),
1826 None => {
1827 warn!(
1828 "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
1829 current.id, absolute
1830 );
1831 None
1832 }
1833 }
1834 }
1835
1836 fn truncated_stream_resume_position(&self) -> Option<f64> {
1843 if matches!(
1845 self.peek_end_reason(),
1846 Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
1847 ) {
1848 return None;
1849 }
1850
1851 let item_duration = {
1855 let queue = self.queue.lock_safe();
1856 let current = queue.current()?;
1857 if !Self::is_audio_only_video(current) {
1858 return None;
1859 }
1860 current.duration
1861 };
1862 let absolute = self.position().max(0.0);
1864
1865 if !stream_end::is_truncated_end(
1868 absolute,
1869 item_duration,
1870 stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
1871 ) {
1872 return None;
1873 }
1874
1875 self.claim_stream_resume().map(|(position, _)| position)
1876 }
1877
1878 pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
1900 self.claim_stream_resume()
1901 .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
1902 }
1903
1904 pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
1931 let current = {
1932 let queue = self.queue.lock_safe();
1933 queue.current().cloned()
1934 }
1935 .ok_or_else(|| "No current item to resume".to_string())?;
1936
1937 let MediaSource::Remote { stream_url, .. } = ¤t.source else {
1938 return Err(format!(
1939 "Cannot resume a non-remote source for {}",
1940 current.id
1941 ));
1942 };
1943
1944 info!(
1945 "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
1946 current.id, position
1947 );
1948
1949 if !Self::is_audio_only_video(¤t) {
1950 self.load_and_play(¤t).map_err(|e| e.to_string())?;
1951 if position > 0.5 {
1952 self.seek(position).map_err(|e| e.to_string())?;
1953 }
1954 return Ok(());
1955 }
1956
1957 let restarted_url = stream_end::with_start_time(stream_url, position);
1958 {
1959 let queue_arc = self.queue.clone();
1960 let mut queue = queue_arc.lock_safe();
1961 if !queue.update_current_stream_url(restarted_url) {
1962 return Err(format!("Failed to update stream URL for {}", current.id));
1963 }
1964 }
1965 let resumed = {
1966 let queue = self.queue.lock_safe();
1967 queue.current().cloned()
1968 }
1969 .ok_or_else(|| "Current item vanished mid-resume".to_string())?;
1970
1971 self.set_background_audio_base(position);
1975 let _ = set_lockscreen_position_offset(position.max(0.0));
1976
1977 self.load_and_play(&resumed).map_err(|e| e.to_string())
1978 }
1979
1980 pub async fn advance_to_next_episode_audio_only(
1995 &self,
1996 next_episode_id: &str,
1997 ) -> Result<(), String> {
1998 let repo = self
1999 .repository
2000 .lock_safe()
2001 .clone()
2002 .ok_or_else(|| "No repository for background episode advance".to_string())?;
2003
2004 let next = repo
2006 .get_item(next_episode_id)
2007 .await
2008 .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
2009
2010 let stream_url = repo
2013 .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
2014 .await
2015 .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
2016
2017 let media_item = MediaItem {
2018 transport: None,
2020 id: next.id.clone(),
2021 title: next.name.clone(),
2022 name: Some(next.name.clone()),
2023 artist: next.series_name.clone(),
2024 album: None,
2025 album_name: None,
2026 album_id: None,
2027 artist_items: None,
2028 artists: None,
2029 primary_image_tag: next.primary_image_tag.clone(),
2030 image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
2031 item_type: Some("Episode".to_string()),
2033 playlist_id: None,
2034 duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
2035 artwork_url: None,
2036 media_type: MediaType::Audio,
2037 source: MediaSource::Remote {
2038 stream_url,
2039 jellyfin_item_id: next.id.clone(),
2040 },
2041 video_codec: None,
2042 needs_transcoding: false,
2043 video_width: None,
2044 video_height: None,
2045 subtitles: vec![],
2046 series_id: next.series_id.clone(),
2047 server_id: Some(next.server_id.clone()),
2048 };
2049
2050 self.set_background_audio_base(0.0);
2055 let _ = set_lockscreen_position_offset(0.0);
2056 self.stream_resume.lock_safe().reset();
2059
2060 self.play_item(media_item).map_err(|e| e.to_string())
2061 }
2062
2063 pub async fn on_video_playback_ended(
2070 &self,
2071 item_id: &str,
2072 repo: Arc<dyn crate::repository::MediaRepository>,
2073 ) -> Result<AutoplayDecision, String> {
2074 let stale_reason = self.take_end_reason();
2076 if stale_reason.is_some() {
2077 debug!(
2078 "[PlayerController] Cleared stale end_reason for video: {:?}",
2079 stale_reason
2080 );
2081 }
2082
2083 log::info!(
2084 "[PlayerController] on_video_playback_ended: item_id={}",
2085 item_id
2086 );
2087
2088 let timer_mode = {
2090 let timer = self.sleep_timer.lock_safe();
2091 timer.mode.clone()
2092 };
2093
2094 match &timer_mode {
2095 SleepTimerMode::Time { end_time } => {
2096 let now = chrono::Utc::now().timestamp_millis();
2097 if now >= *end_time {
2098 debug!("[PlayerController] Time-based sleep timer expired at video end");
2099 self.sleep_timer.lock_safe().cancel();
2100 self.emit_sleep_timer_changed();
2101 return Ok(AutoplayDecision::Stop);
2102 }
2103 }
2104 SleepTimerMode::EndOfTrack => {
2105 self.sleep_timer.lock_safe().cancel();
2106 self.emit_sleep_timer_changed();
2107 return Ok(AutoplayDecision::Stop);
2108 }
2109 SleepTimerMode::Episodes { .. } => {
2110 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
2111 self.emit_sleep_timer_changed();
2112 if should_stop {
2113 return Ok(AutoplayDecision::Stop);
2114 }
2115 }
2116 _ => {}
2117 }
2118
2119 let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
2123 Ok(next) => next,
2124 Err(e) => {
2125 warn!(
2126 "[PlayerController] Next-episode lookup failed for {}: {}",
2127 item_id, e
2128 );
2129 None
2130 }
2131 };
2132 if let Some(next_ep) = next_ep_result {
2133 let settings = self.autoplay_settings.lock_safe().clone();
2134
2135 let limit_reached = self.increment_autoplay_count();
2136 if limit_reached {
2137 debug!(
2138 "[PlayerController] Auto-play episode limit reached ({} episodes)",
2139 settings.max_episodes
2140 );
2141 }
2142
2143 return Ok(AutoplayDecision::ShowNextEpisodePopup {
2144 current_episode: next_ep.0,
2145 next_episode: next_ep.1,
2146 countdown_seconds: settings.countdown_seconds,
2147 auto_advance: settings.enabled && !limit_reached,
2148 });
2149 }
2150
2151 debug!("[PlayerController] No next episode found for {}", item_id);
2153 Ok(AutoplayDecision::Stop)
2154 }
2155
2156 async fn is_episode_item(&self, item: &MediaItem) -> bool {
2164 match item.item_type.as_deref() {
2165 Some("Episode") => true,
2166 Some(_) => item.media_type == MediaType::Video,
2167 None => item.media_type == MediaType::Video,
2168 }
2169 }
2170
2171 async fn fetch_next_episode_for_item(
2178 &self,
2179 item_id: &str,
2180 repo: &Arc<dyn crate::repository::MediaRepository>,
2181 ) -> Result<
2182 Option<(
2183 crate::repository::types::MediaItem,
2184 crate::repository::types::MediaItem,
2185 )>,
2186 String,
2187 > {
2188 use crate::repository::types::GetItemsOptions;
2189
2190 let current_repo_item = repo
2192 .get_item(item_id)
2193 .await
2194 .map_err(|e| format!("Failed to get current item: {}", e))?;
2195
2196 let season_id = match ¤t_repo_item.season_id {
2198 Some(sid) => sid.clone(),
2199 None => {
2200 log::info!(
2201 "[PlayerController] Current item has no season_id, cannot find next episode"
2202 );
2203 return Ok(None);
2204 }
2205 };
2206
2207 let options = GetItemsOptions {
2209 sort_by: Some("IndexNumber".to_string()),
2210 sort_order: Some("Ascending".to_string()),
2211 limit: Some(500),
2212 include_item_types: Some(vec!["Episode".to_string()]),
2213 ..Default::default()
2214 };
2215
2216 let result = repo
2217 .get_items(&season_id, Some(options))
2218 .await
2219 .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
2220
2221 let mut episodes = result.items;
2224 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2225 log::info!(
2226 "[PlayerController] Season has {} episodes, looking for next after {}",
2227 episodes.len(),
2228 current_repo_item.id
2229 );
2230
2231 if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
2233 if current_idx + 1 < episodes.len() {
2234 let next = &episodes[current_idx + 1];
2235 log::info!(
2236 "[PlayerController] Found next episode: {} (index {})",
2237 next.name,
2238 current_idx + 1
2239 );
2240 return Ok(Some((current_repo_item, next.clone())));
2241 } else {
2242 log::info!("[PlayerController] Current episode is the last in the season");
2243 }
2244 } else {
2245 log::info!(
2246 "[PlayerController] Current episode not found in season episodes (ids: {:?})",
2247 episodes
2248 .iter()
2249 .map(|e| e.id.as_str())
2250 .take(20)
2251 .collect::<Vec<_>>()
2252 );
2253 }
2254
2255 Ok(None)
2256 }
2257
2258 pub fn start_autoplay_countdown(
2260 &self,
2261 _next_item: crate::repository::types::MediaItem,
2262 countdown_seconds: u32,
2263 ) {
2264 let cancel_flag = Arc::new(Mutex::new(false));
2266 *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
2267
2268 let event_emitter = self.event_emitter.clone();
2269
2270 std::thread::spawn(move || {
2271 let mut remaining = countdown_seconds;
2272
2273 while remaining > 0 {
2274 std::thread::sleep(Duration::from_secs(1));
2275
2276 if *cancel_flag.lock_safe() {
2278 log::info!("[PlayerController] Autoplay countdown cancelled");
2279 return;
2280 }
2281
2282 remaining -= 1;
2283
2284 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
2286 emitter.emit(PlayerStatusEvent::CountdownTick {
2287 remaining_seconds: remaining,
2288 });
2289 }
2290 }
2291
2292 log::info!("[PlayerController] Autoplay countdown finished");
2294 });
2295 }
2296}
2297
2298impl Default for PlayerController {
2299 fn default() -> Self {
2300 let playback_reporter = Arc::new(TokioMutex::new(None));
2301 let position_throttler = Arc::new(EventThrottler::new());
2302 Self::new(
2303 Box::new(LegacyPlayer::new(
2304 NullBackend::new(),
2305 crate::player::media_player::Capabilities::mpv(),
2306 )),
2307 playback_reporter,
2308 position_throttler,
2309 )
2310 }
2311}
2312
2313#[cfg(test)]
2314mod tests {
2315
2316 #[test]
2327 fn test_stop_clears_an_active_background_audio_handoff() {
2328 let controller = PlayerController::default();
2329 let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2330 {
2331 let queue_arc = controller.queue();
2332 let mut queue = queue_arc.lock_safe();
2333 queue.set_queue(vec![item], 0);
2334 }
2335
2336 controller.enter_background_audio(557.5);
2337 assert!(
2338 controller.is_background_audio_active(),
2339 "precondition: the handoff is active"
2340 );
2341
2342 controller.stop().expect("stop failed");
2343
2344 assert!(
2345 !controller.is_background_audio_active(),
2346 "a stop must not leave a handoff behind for the next position read"
2347 );
2348 assert_eq!(
2349 *controller.background_audio_base.lock_safe(),
2350 0.0,
2351 "the handoff base must be cleared with it"
2352 );
2353 }
2354
2355 #[test]
2366 fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
2367 let controller = PlayerController::default();
2368 let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2369 item.duration = Some(1800.0);
2370
2371 {
2372 let queue_arc = controller.queue();
2373 let mut queue = queue_arc.lock_safe();
2374 queue.set_queue(vec![item], 0);
2375 }
2376
2377 assert_eq!(
2378 controller.duration(),
2379 Some(1800.0),
2380 "an engine that cannot report a duration should not erase the one the item carries"
2381 );
2382 }
2383 use super::*;
2384
2385 struct CapturingEmitter {
2388 events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
2389 }
2390
2391 impl CapturingEmitter {
2392 fn new() -> Self {
2393 Self {
2394 events: std::sync::Mutex::new(Vec::new()),
2395 }
2396 }
2397 fn events(&self) -> Vec<PlayerStatusEvent> {
2398 self.events.lock_safe().clone()
2399 }
2400 }
2401
2402 impl PlayerEventEmitter for CapturingEmitter {
2403 fn emit(&self, event: PlayerStatusEvent) {
2404 self.events.lock_safe().push(event);
2405 }
2406 }
2407
2408 struct CapturingReports {
2411 operations: std::sync::Mutex<Vec<PlaybackOperation>>,
2412 }
2413
2414 impl CapturingReports {
2415 fn new() -> Self {
2416 Self {
2417 operations: std::sync::Mutex::new(Vec::new()),
2418 }
2419 }
2420
2421 fn stops(&self) -> Vec<(String, f64)> {
2423 self.operations
2424 .lock_safe()
2425 .iter()
2426 .filter_map(|op| match op {
2427 PlaybackOperation::Stopped {
2428 item_id,
2429 position_ticks,
2430 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2431 _ => None,
2432 })
2433 .collect()
2434 }
2435
2436 fn progress(&self) -> Vec<(String, f64)> {
2438 self.operations
2439 .lock_safe()
2440 .iter()
2441 .filter_map(|op| match op {
2442 PlaybackOperation::Progress {
2443 item_id,
2444 position_ticks,
2445 ..
2446 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2447 _ => None,
2448 })
2449 .collect()
2450 }
2451 }
2452
2453 impl PlaybackReportSink for CapturingReports {
2454 fn send(&self, operation: PlaybackOperation) {
2455 self.operations.lock_safe().push(operation);
2456 }
2457 }
2458
2459 #[test]
2460 fn test_report_html5_state_emits_state_changed() {
2461 let controller = PlayerController::default();
2462 let emitter = Arc::new(CapturingEmitter::new());
2463 controller.set_event_emitter(emitter.clone());
2464
2465 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2466
2467 let events = emitter.events();
2468 assert_eq!(events.len(), 1);
2469 match &events[0] {
2470 PlayerStatusEvent::StateChanged { state, media_id } => {
2471 assert_eq!(state, "playing");
2472 assert_eq!(media_id.as_deref(), Some("item-1"));
2473 }
2474 other => panic!("expected StateChanged, got {:?}", other),
2475 }
2476 }
2477
2478 #[test]
2479 fn test_report_html5_position_emits_position_update() {
2480 let controller = PlayerController::default();
2481 let emitter = Arc::new(CapturingEmitter::new());
2482 controller.set_event_emitter(emitter.clone());
2483
2484 controller.report_html5_position(12.5, 300.0);
2485
2486 let events = emitter.events();
2487 assert_eq!(events.len(), 1);
2488 match &events[0] {
2489 PlayerStatusEvent::PositionUpdate { position, duration } => {
2490 assert_eq!(*position, 12.5);
2491 assert_eq!(*duration, 300.0);
2492 }
2493 other => panic!("expected PositionUpdate, got {:?}", other),
2494 }
2495 }
2496
2497 #[test]
2498 fn test_report_html5_media_loaded_emits_media_loaded() {
2499 let controller = PlayerController::default();
2500 let emitter = Arc::new(CapturingEmitter::new());
2501 controller.set_event_emitter(emitter.clone());
2502
2503 controller.report_html5_media_loaded(420.0);
2504
2505 let events = emitter.events();
2506 assert_eq!(events.len(), 1);
2507 match &events[0] {
2508 PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
2509 other => panic!("expected MediaLoaded, got {:?}", other),
2510 }
2511 }
2512
2513 #[test]
2523 fn test_html5_state_is_tracked_from_reports() {
2524 let controller = PlayerController::default();
2525 let emitter = Arc::new(CapturingEmitter::new());
2526 controller.set_event_emitter(emitter.clone());
2527
2528 assert!(!controller.is_html5_active());
2530
2531 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2532 assert!(controller.is_html5_active());
2533 assert!(controller.html5_is_playing());
2534
2535 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2536 assert!(controller.is_html5_active());
2537 assert!(!controller.html5_is_playing());
2538 }
2539
2540 #[test]
2541 fn test_html5_toggle_from_paused_emits_play_control() {
2542 let controller = PlayerController::default();
2543 let emitter = Arc::new(CapturingEmitter::new());
2544 controller.set_event_emitter(emitter.clone());
2545 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2546
2547 controller.toggle_playback().unwrap();
2548
2549 let controls: Vec<_> = emitter
2550 .events()
2551 .into_iter()
2552 .filter_map(|e| match e {
2553 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2554 _ => None,
2555 })
2556 .collect();
2557 assert_eq!(controls, vec!["play".to_string()]);
2558 }
2559
2560 #[test]
2561 fn test_html5_toggle_from_playing_emits_pause_control() {
2562 let controller = PlayerController::default();
2563 let emitter = Arc::new(CapturingEmitter::new());
2564 controller.set_event_emitter(emitter.clone());
2565 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2566
2567 controller.toggle_playback().unwrap();
2568
2569 let controls: Vec<_> = emitter
2570 .events()
2571 .into_iter()
2572 .filter_map(|e| match e {
2573 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2574 _ => None,
2575 })
2576 .collect();
2577 assert_eq!(controls, vec!["pause".to_string()]);
2578 }
2579
2580 #[test]
2581 fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
2582 let controller = PlayerController::default();
2587 let emitter = Arc::new(CapturingEmitter::new());
2588 controller.set_event_emitter(emitter.clone());
2589 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2590
2591 controller.toggle_playback().unwrap();
2592 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2594 controller.toggle_playback().unwrap();
2595
2596 let controls: Vec<_> = emitter
2597 .events()
2598 .into_iter()
2599 .filter_map(|e| match e {
2600 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2601 _ => None,
2602 })
2603 .collect();
2604 assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
2605 }
2606
2607 #[test]
2608 fn test_html5_play_and_pause_emit_control_commands() {
2609 let controller = PlayerController::default();
2610 let emitter = Arc::new(CapturingEmitter::new());
2611 controller.set_event_emitter(emitter.clone());
2612 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2613
2614 controller.play().unwrap();
2615 controller.pause().unwrap();
2616
2617 let controls: Vec<_> = emitter
2618 .events()
2619 .into_iter()
2620 .filter_map(|e| match e {
2621 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2622 _ => None,
2623 })
2624 .collect();
2625 assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
2626 }
2627
2628 #[test]
2629 fn test_background_audio_handoff_moves_transport_to_native_backend() {
2630 let controller = PlayerController::default();
2638 let emitter = Arc::new(CapturingEmitter::new());
2639 controller.set_event_emitter(emitter.clone());
2640
2641 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2643 assert!(controller.is_html5_active());
2644
2645 controller.enter_background_audio(1200.0);
2647 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2648
2649 assert!(
2650 !controller.is_html5_active(),
2651 "native audio owns transport during a background-audio handoff"
2652 );
2653
2654 controller.pause().unwrap();
2655 let controls: Vec<_> = emitter
2656 .events()
2657 .into_iter()
2658 .filter_map(|e| match e {
2659 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2660 _ => None,
2661 })
2662 .collect();
2663 assert!(
2664 controls.is_empty(),
2665 "pause must drive the native backend, not a torn-down element: {:?}",
2666 controls
2667 );
2668 }
2669
2670 #[test]
2671 fn test_background_audio_handoff_suppresses_stale_element_events() {
2672 let controller = PlayerController::default();
2676 let emitter = Arc::new(CapturingEmitter::new());
2677 controller.set_event_emitter(emitter.clone());
2678
2679 controller.enter_background_audio(1200.0);
2680 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2681 controller.report_html5_position(1200.0, 2400.0);
2682
2683 assert!(
2684 emitter.events().is_empty(),
2685 "stale webview reports must not reach the event pipeline: {:?}",
2686 emitter.events()
2687 );
2688 }
2689
2690 #[test]
2691 fn test_exit_background_audio_returns_transport_to_the_webview() {
2692 let controller = PlayerController::default();
2695 let emitter = Arc::new(CapturingEmitter::new());
2696 controller.set_event_emitter(emitter.clone());
2697
2698 controller.enter_background_audio(1200.0);
2699 assert_eq!(controller.exit_background_audio(), 1200.0);
2700
2701 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2702 assert!(controller.is_html5_active());
2703 assert!(controller.html5_is_playing());
2704 }
2705
2706 #[test]
2707 fn test_html5_stopped_report_releases_transport_to_native_backend() {
2708 let controller = PlayerController::default();
2711 let emitter = Arc::new(CapturingEmitter::new());
2712 controller.set_event_emitter(emitter.clone());
2713
2714 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2715 assert!(controller.is_html5_active());
2716
2717 controller.report_html5_state("stopped".to_string(), None);
2718 assert!(!controller.is_html5_active());
2719 }
2720
2721 #[test]
2722 fn test_html5_transport_emits_exactly_one_control_per_intent() {
2723 let controller = PlayerController::default();
2727 let emitter = Arc::new(CapturingEmitter::new());
2728 controller.set_event_emitter(emitter.clone());
2729 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2730
2731 controller.pause().unwrap();
2732
2733 let controls = emitter
2734 .events()
2735 .into_iter()
2736 .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
2737 .count();
2738 assert_eq!(controls, 1, "one intent must produce exactly one control");
2739 }
2740
2741 #[test]
2742 fn test_controller_volume_default() {
2743 let controller = PlayerController::default();
2744 assert_eq!(controller.volume(), 1.0);
2745 }
2746
2747 #[test]
2748 fn test_controller_set_volume() {
2749 let controller = PlayerController::default();
2750 controller.set_volume(0.5).unwrap();
2751 assert_eq!(controller.volume(), 0.5);
2752 }
2753
2754 #[test]
2755 fn test_controller_muted_default() {
2756 let controller = PlayerController::default();
2757 assert!(!controller.muted());
2758 }
2759
2760 #[test]
2761 fn test_controller_volume_delegates_to_backend() {
2762 let controller = PlayerController::default();
2763
2764 controller.set_volume(0.75).unwrap();
2766
2767 assert_eq!(controller.volume(), 0.75);
2769 }
2770
2771 fn create_test_items(count: usize) -> Vec<MediaItem> {
2772 (0..count)
2773 .map(|i| MediaItem {
2774 transport: None,
2776 id: format!("item_{}", i),
2777 title: format!("Track {}", i + 1),
2778 name: Some(format!("Track {}", i + 1)),
2779 artist: Some("Test Artist".to_string()),
2780 album: Some("Test Album".to_string()),
2781 album_name: Some("Test Album".to_string()),
2782 album_id: None,
2783 artist_items: None,
2784 artists: Some(vec!["Test Artist".to_string()]),
2785 primary_image_tag: None,
2786 image_id: None,
2787 item_type: Some("Audio".to_string()),
2788 playlist_id: None,
2789 duration: Some(180.0),
2790 artwork_url: None,
2791 media_type: MediaType::Audio,
2792 source: MediaSource::DirectUrl {
2793 url: format!("http://example.com/track_{}.mp3", i),
2794 },
2795 video_codec: None,
2796 needs_transcoding: false,
2797 video_width: None,
2798 video_height: None,
2799 subtitles: vec![],
2800 series_id: None,
2801 server_id: None,
2802 })
2803 .collect()
2804 }
2805
2806 #[test]
2807 fn test_skip_preserves_queue() {
2808 let controller = PlayerController::default();
2809
2810 let items = create_test_items(5);
2812 let items_clone = items.clone();
2813
2814 controller.play_queue(items, 0).unwrap();
2816
2817 {
2819 let queue = controller.queue();
2820 let queue_lock = queue.lock_safe();
2821 assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
2822 assert_eq!(
2823 queue_lock.current_index(),
2824 Some(0),
2825 "Should start at index 0"
2826 );
2827 assert_eq!(
2828 queue_lock.current().unwrap().id,
2829 "item_0",
2830 "Current item should be item_0"
2831 );
2832 }
2833
2834 controller.next().unwrap();
2836
2837 {
2839 let queue = controller.queue();
2840 let queue_lock = queue.lock_safe();
2841 assert_eq!(
2842 queue_lock.items().len(),
2843 5,
2844 "Queue should still have 5 items after skip"
2845 );
2846 assert_eq!(
2847 queue_lock.current_index(),
2848 Some(1),
2849 "Index should advance to 1"
2850 );
2851 assert_eq!(
2852 queue_lock.current().unwrap().id,
2853 "item_1",
2854 "Current item should be item_1"
2855 );
2856
2857 let current_items = queue_lock.items();
2859 for (i, original) in items_clone.iter().enumerate() {
2860 assert_eq!(
2861 current_items[i].id, original.id,
2862 "Item {} should still be in queue",
2863 i
2864 );
2865 assert_eq!(
2866 current_items[i].title, original.title,
2867 "Item {} title should be unchanged",
2868 i
2869 );
2870 }
2871 }
2872
2873 controller.next().unwrap();
2875
2876 {
2878 let queue = controller.queue();
2879 let queue_lock = queue.lock_safe();
2880 assert_eq!(
2881 queue_lock.items().len(),
2882 5,
2883 "Queue should still have 5 items after second skip"
2884 );
2885 assert_eq!(
2886 queue_lock.current_index(),
2887 Some(2),
2888 "Index should advance to 2"
2889 );
2890 assert_eq!(
2891 queue_lock.current().unwrap().id,
2892 "item_2",
2893 "Current item should be item_2"
2894 );
2895 }
2896
2897 controller.next().unwrap(); controller.next().unwrap(); {
2903 let queue = controller.queue();
2904 let queue_lock = queue.lock_safe();
2905 assert_eq!(
2906 queue_lock.items().len(),
2907 5,
2908 "Queue should still have 5 items at end"
2909 );
2910 assert_eq!(
2911 queue_lock.current_index(),
2912 Some(4),
2913 "Index should be at last item (4)"
2914 );
2915 assert_eq!(
2916 queue_lock.current().unwrap().id,
2917 "item_4",
2918 "Current item should be item_4"
2919 );
2920 }
2921 }
2922
2923 #[test]
2924 fn test_skip_at_end_without_repeat() {
2925 let controller = PlayerController::default();
2926
2927 let items = create_test_items(3);
2929 controller.play_queue(items, 0).unwrap();
2930
2931 controller.next().unwrap(); controller.next().unwrap(); {
2937 let queue = controller.queue();
2938 let queue_lock = queue.lock_safe();
2939 assert_eq!(
2940 queue_lock.current_index(),
2941 Some(2),
2942 "Should be at last item"
2943 );
2944 }
2945
2946 controller.next().unwrap();
2949
2950 {
2952 let queue = controller.queue();
2953 let queue_lock = queue.lock_safe();
2954 assert_eq!(
2955 queue_lock.items().len(),
2956 3,
2957 "Queue should still have 3 items after skip at end"
2958 );
2959 }
2963 }
2964
2965 #[test]
2966 fn test_skip_with_repeat_all() {
2967 let controller = PlayerController::default();
2968
2969 let items = create_test_items(3);
2971 controller.play_queue(items, 0).unwrap();
2972
2973 controller.cycle_repeat();
2975
2976 controller.next().unwrap(); controller.next().unwrap(); controller.next().unwrap();
2982
2983 {
2985 let queue = controller.queue();
2986 let queue_lock = queue.lock_safe();
2987 assert_eq!(
2988 queue_lock.items().len(),
2989 3,
2990 "Queue should still have 3 items"
2991 );
2992 assert_eq!(
2993 queue_lock.current_index(),
2994 Some(0),
2995 "Should wrap to index 0"
2996 );
2997 assert_eq!(
2998 queue_lock.current().unwrap().id,
2999 "item_0",
3000 "Should be back at item_0"
3001 );
3002 }
3003 }
3004
3005 #[test]
3006 fn test_previous_preserves_queue() {
3007 let controller = PlayerController::default();
3008
3009 let items = create_test_items(5);
3011 let items_clone = items.clone();
3012 controller.play_queue(items, 3).unwrap();
3013
3014 {
3016 let queue = controller.queue();
3017 let queue_lock = queue.lock_safe();
3018 assert_eq!(
3019 queue_lock.current_index(),
3020 Some(3),
3021 "Should start at index 3"
3022 );
3023 }
3024
3025 controller.previous().unwrap();
3027
3028 {
3030 let queue = controller.queue();
3031 let queue_lock = queue.lock_safe();
3032 assert_eq!(
3033 queue_lock.items().len(),
3034 5,
3035 "Queue should still have 5 items after previous"
3036 );
3037 assert_eq!(
3038 queue_lock.current_index(),
3039 Some(2),
3040 "Index should move to 2"
3041 );
3042 assert_eq!(
3043 queue_lock.current().unwrap().id,
3044 "item_2",
3045 "Current item should be item_2"
3046 );
3047
3048 let current_items = queue_lock.items();
3050 for (i, original) in items_clone.iter().enumerate() {
3051 assert_eq!(
3052 current_items[i].id, original.id,
3053 "Item {} should still be in queue",
3054 i
3055 );
3056 }
3057 }
3058 }
3059
3060 #[test]
3061 fn test_seek_updates_position() {
3062 let controller = PlayerController::default();
3063
3064 let item = create_test_items(1).into_iter().next().unwrap();
3066 controller.play_item(item).unwrap();
3067
3068 assert_eq!(controller.position(), 0.0, "Initial position should be 0");
3070
3071 controller.seek(30.0).unwrap();
3073 assert_eq!(
3074 controller.position(),
3075 30.0,
3076 "Position should be 30 after seeking"
3077 );
3078
3079 controller.seek(60.0).unwrap();
3081 assert_eq!(
3082 controller.position(),
3083 60.0,
3084 "Position should be 60 after seeking"
3085 );
3086
3087 controller.seek(15.0).unwrap();
3089 assert_eq!(
3090 controller.position(),
3091 15.0,
3092 "Position should be 15 after seeking backward"
3093 );
3094 }
3095
3096 #[test]
3097 fn test_seek_while_paused() {
3098 let controller = PlayerController::default();
3099
3100 let item = create_test_items(1).into_iter().next().unwrap();
3102 controller.play_item(item).unwrap();
3103
3104 controller.pause().unwrap();
3106
3107 assert!(controller.state().is_paused(), "Should be paused");
3109
3110 controller.seek(45.0).unwrap();
3112 assert_eq!(
3113 controller.position(),
3114 45.0,
3115 "Position should update while paused"
3116 );
3117
3118 assert!(
3120 controller.state().is_paused(),
3121 "Should still be paused after seeking"
3122 );
3123 }
3124
3125 #[test]
3126 fn test_seek_while_playing() {
3127 let controller = PlayerController::default();
3128
3129 let item = create_test_items(1).into_iter().next().unwrap();
3131 controller.play_item(item).unwrap();
3132
3133 controller.play().unwrap();
3135
3136 assert!(controller.state().is_playing(), "Should be playing");
3138
3139 controller.seek(20.0).unwrap();
3141 assert_eq!(
3142 controller.position(),
3143 20.0,
3144 "Position should update while playing"
3145 );
3146
3147 assert!(
3149 controller.state().is_playing(),
3150 "Should still be playing after seeking"
3151 );
3152 }
3153
3154 #[test]
3155 fn test_multiple_sequential_seeks() {
3156 let controller = PlayerController::default();
3157
3158 let item = create_test_items(1).into_iter().next().unwrap();
3159 controller.play_item(item).unwrap();
3160
3161 let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
3163
3164 for pos in positions {
3165 controller.seek(pos).unwrap();
3166 assert_eq!(
3167 controller.position(),
3168 pos,
3169 "Position should match after seeking to {}",
3170 pos
3171 );
3172 }
3173 }
3174
3175 #[test]
3179 fn test_play_queue_from_resumes_at_position() {
3180 let controller = PlayerController::default();
3181 let items = create_test_items(3);
3182
3183 controller.play_queue_from(items, 1, Some(42.5)).unwrap();
3184
3185 {
3186 let queue = controller.queue();
3187 let queue_lock = queue.lock_safe();
3188 assert_eq!(
3189 queue_lock.current_index(),
3190 Some(1),
3191 "Should start at index 1"
3192 );
3193 }
3194 assert_eq!(
3195 controller.position(),
3196 42.5,
3197 "Should resume at the requested position"
3198 );
3199 }
3200
3201 #[test]
3203 fn test_play_queue_from_without_position_starts_at_zero() {
3204 let controller = PlayerController::default();
3205
3206 controller
3207 .play_queue_from(create_test_items(2), 0, None)
3208 .unwrap();
3209 assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
3210
3211 controller
3212 .play_queue_from(create_test_items(2), 0, Some(0.2))
3213 .unwrap();
3214 assert_eq!(
3215 controller.position(),
3216 0.0,
3217 "Sub-threshold resume position is ignored (starts at 0)"
3218 );
3219 }
3220
3221 #[test]
3222 fn test_seek_to_zero() {
3223 let controller = PlayerController::default();
3224
3225 let item = create_test_items(1).into_iter().next().unwrap();
3226 controller.play_item(item).unwrap();
3227
3228 controller.seek(60.0).unwrap();
3230 assert_eq!(controller.position(), 60.0);
3231
3232 controller.seek(0.0).unwrap();
3234 assert_eq!(
3235 controller.position(),
3236 0.0,
3237 "Should be able to seek to position 0"
3238 );
3239 }
3240
3241 #[tokio::test]
3243 async fn test_audio_with_next_advances() {
3244 let controller = PlayerController::default();
3245
3246 let items = create_test_items(2);
3248 controller.play_queue(items, 0).unwrap();
3249
3250 controller.take_end_reason();
3252
3253 let decision = controller.on_playback_ended().await.unwrap();
3255
3256 assert!(
3258 matches!(decision, AutoplayDecision::AdvanceToNext),
3259 "Expected AdvanceToNext decision when queue has next item"
3260 );
3261 }
3262
3263 #[tokio::test]
3264 async fn test_audio_at_end_stops() {
3265 let controller = PlayerController::default();
3266
3267 let items = create_test_items(2);
3269 controller.play_queue(items, 1).unwrap();
3270
3271 controller.take_end_reason();
3273
3274 let decision = controller.on_playback_ended().await.unwrap();
3276
3277 assert!(
3279 matches!(decision, AutoplayDecision::Stop),
3280 "Expected Stop decision when at end of queue without repeat"
3281 );
3282 }
3283
3284 #[tokio::test]
3285 async fn test_sleep_timer_end_of_track() {
3286 let controller = PlayerController::default();
3287
3288 let items = create_test_items(3);
3290 controller.play_queue(items, 0).unwrap();
3291
3292 controller.take_end_reason();
3294
3295 {
3297 let mut timer = controller.sleep_timer.lock_safe();
3298 timer.mode = SleepTimerMode::EndOfTrack;
3299 }
3300
3301 let decision = controller.on_playback_ended().await.unwrap();
3303
3304 assert!(
3306 matches!(decision, AutoplayDecision::Stop),
3307 "Expected Stop decision when sleep timer is EndOfTrack"
3308 );
3309
3310 {
3312 let timer = controller.sleep_timer.lock_safe();
3313 assert!(
3314 matches!(timer.mode, SleepTimerMode::Off),
3315 "Sleep timer should be cancelled after EndOfTrack"
3316 );
3317 }
3318 }
3319
3320 #[tokio::test]
3329 async fn test_expired_time_sleep_timer_stops_without_autoplay() {
3330 let controller = PlayerController::default();
3331
3332 let items = create_test_items(3);
3333 controller.play_queue(items, 0).unwrap();
3334 controller.take_end_reason();
3335
3336 let now = chrono::Utc::now().timestamp_millis();
3341 controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
3342
3343 for _ in 0..40 {
3345 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3346 if !controller.sleep_timer.lock_safe().is_active() {
3347 break;
3348 }
3349 }
3350 assert!(
3351 !controller.sleep_timer.lock_safe().is_active(),
3352 "Timer thread should have expired and cancelled the sleep timer"
3353 );
3354
3355 let decision = controller.on_playback_ended().await.unwrap();
3357
3358 assert!(
3359 matches!(decision, AutoplayDecision::Stop),
3360 "Expected Stop after an expired time-based sleep timer, got {:?}",
3361 decision
3362 );
3363 }
3364
3365 #[tokio::test]
3366 async fn test_empty_queue_stops() {
3367 let controller = PlayerController::default();
3368
3369 let decision = controller.on_playback_ended().await.unwrap();
3371
3372 assert!(
3374 matches!(decision, AutoplayDecision::Stop),
3375 "Expected Stop decision when queue is empty"
3376 );
3377 }
3378
3379 #[tokio::test]
3380 async fn test_repeat_all_advances_at_end() {
3381 let controller = PlayerController::default();
3382
3383 let items = create_test_items(2);
3385 controller.play_queue(items, 1).unwrap(); controller.cycle_repeat(); controller.take_end_reason();
3390
3391 let decision = controller.on_playback_ended().await.unwrap();
3393
3394 assert!(
3396 matches!(decision, AutoplayDecision::AdvanceToNext),
3397 "Expected AdvanceToNext decision at end of queue with repeat all"
3398 );
3399 }
3400
3401 #[tokio::test]
3402 async fn test_repeat_one_advances() {
3403 let controller = PlayerController::default();
3404
3405 let items = create_test_items(2);
3407 controller.play_queue(items, 0).unwrap();
3408
3409 controller.cycle_repeat(); controller.cycle_repeat(); controller.take_end_reason();
3415
3416 let decision = controller.on_playback_ended().await.unwrap();
3418
3419 assert!(
3421 matches!(decision, AutoplayDecision::AdvanceToNext),
3422 "Expected AdvanceToNext decision with repeat one (repeats same track)"
3423 );
3424 }
3425
3426 #[test]
3427 fn test_native_load_returns_transport_authority_to_the_backend() {
3428 let controller = PlayerController::default();
3447 let emitter = Arc::new(CapturingEmitter::new());
3448 controller.set_event_emitter(emitter.clone());
3449
3450 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
3452 assert!(controller.is_html5_active());
3453
3454 let item = create_test_items(1).into_iter().next().unwrap();
3456 controller.play_item(item).unwrap();
3457
3458 assert!(
3459 !controller.is_html5_active(),
3460 "loading into the native backend hands transport back to it"
3461 );
3462
3463 controller.toggle_playback().unwrap();
3465 let controls: Vec<_> = emitter
3466 .events()
3467 .into_iter()
3468 .filter_map(|e| match e {
3469 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
3470 _ => None,
3471 })
3472 .collect();
3473 assert!(
3474 controls.is_empty(),
3475 "transport went to a webview element that is not rendering: {controls:?}"
3476 );
3477 }
3478
3479 #[test]
3481 fn test_load_and_play_sets_new_track_loaded() {
3482 let controller = PlayerController::default();
3483 let item = create_test_items(1).into_iter().next().unwrap();
3484
3485 assert!(controller.take_end_reason().is_none());
3487
3488 controller.load_and_play(&item).unwrap();
3490
3491 let reason = controller.take_end_reason();
3493 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3494 }
3495
3496 #[test]
3497 fn test_stop_sets_user_stop() {
3498 let controller = PlayerController::default();
3499 let item = create_test_items(1).into_iter().next().unwrap();
3500
3501 controller.play_item(item).unwrap();
3503
3504 controller.take_end_reason();
3506
3507 controller.stop().unwrap();
3509
3510 let reason = controller.take_end_reason();
3512 assert_eq!(reason, Some(EndReason::UserStop));
3513 }
3514
3515 #[tokio::test]
3516 async fn test_on_playback_ended_with_new_track_loaded_stops() {
3517 let controller = PlayerController::default();
3518
3519 let items = create_test_items(2);
3521 controller.play_queue(items, 0).unwrap();
3522
3523 controller.set_end_reason(EndReason::NewTrackLoaded);
3525
3526 let decision = controller.on_playback_ended().await.unwrap();
3528
3529 assert!(
3531 matches!(decision, AutoplayDecision::Stop),
3532 "Expected Stop decision when EndReason is NewTrackLoaded"
3533 );
3534 }
3535
3536 #[tokio::test]
3537 async fn test_on_playback_ended_with_user_stop_stops() {
3538 let controller = PlayerController::default();
3539
3540 let items = create_test_items(2);
3542 controller.play_queue(items, 0).unwrap();
3543
3544 controller.set_end_reason(EndReason::UserStop);
3546
3547 let decision = controller.on_playback_ended().await.unwrap();
3549
3550 assert!(
3552 matches!(decision, AutoplayDecision::Stop),
3553 "Expected Stop decision when EndReason is UserStop"
3554 );
3555 }
3556
3557 #[tokio::test]
3558 async fn test_on_playback_ended_natural_end_advances() {
3559 let controller = PlayerController::default();
3560
3561 let items = create_test_items(2);
3563 controller.play_queue(items, 0).unwrap();
3564
3565 controller.take_end_reason();
3567
3568 let decision = controller.on_playback_ended().await.unwrap();
3570
3571 assert!(
3573 matches!(decision, AutoplayDecision::AdvanceToNext),
3574 "Expected AdvanceToNext decision when track ends naturally with next track available"
3575 );
3576 }
3577
3578 #[tokio::test]
3579 async fn test_on_playback_ended_with_user_skip_stops() {
3580 let controller = PlayerController::default();
3581
3582 let items = create_test_items(2);
3584 controller.play_queue(items, 0).unwrap();
3585
3586 controller.set_end_reason(EndReason::UserSkip);
3588
3589 let decision = controller.on_playback_ended().await.unwrap();
3591
3592 assert!(
3594 matches!(decision, AutoplayDecision::Stop),
3595 "Expected Stop decision when EndReason is UserSkip"
3596 );
3597 }
3598
3599 #[tokio::test]
3600 async fn test_on_playback_ended_with_error_stops() {
3601 let controller = PlayerController::default();
3602
3603 let items = create_test_items(2);
3605 controller.play_queue(items, 0).unwrap();
3606
3607 controller.set_end_reason(EndReason::Error);
3609
3610 let decision = controller.on_playback_ended().await.unwrap();
3612
3613 assert!(
3615 matches!(decision, AutoplayDecision::Stop),
3616 "Expected Stop decision when EndReason is Error"
3617 );
3618 }
3619
3620 #[tokio::test]
3621 async fn test_take_end_reason_clears_state() {
3622 let controller = PlayerController::default();
3623
3624 controller.set_end_reason(EndReason::NewTrackLoaded);
3626
3627 let reason = controller.take_end_reason();
3629 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3630
3631 let reason = controller.take_end_reason();
3633 assert!(reason.is_none(), "take_end_reason should clear the state");
3634 }
3635
3636 use crate::repository::types as repo_types;
3639
3640 struct MockEpisodeRepo {
3644 episodes: Vec<repo_types::MediaItem>,
3645 }
3646
3647 impl MockEpisodeRepo {
3648 fn season(count: usize) -> Self {
3649 let episodes = (1..=count)
3650 .map(|i| {
3651 let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
3652 item.name = format!("Episode {}", i);
3653 item
3654 })
3655 .collect();
3656 Self { episodes }
3657 }
3658 }
3659
3660 fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
3661 repo_types::MediaItem {
3662 id: id.to_string(),
3663 name: format!("Episode {}", index),
3664 item_type: "Episode".to_string(),
3665 kind: crate::domain::MediaKind::Episode,
3666 is_folder: false,
3667 server_id: "server".to_string(),
3668 parent_id: Some("season1".to_string()),
3669 library_id: None,
3670 overview: None,
3671 genres: None,
3672 runtime_ticks: None,
3673 duration_ms: None,
3674 production_year: None,
3675 premiere_date: None,
3676 community_rating: None,
3677 official_rating: None,
3678 primary_image_tag: None,
3679 image_id: None,
3680 backdrop_image_tags: None,
3681 parent_backdrop_image_tags: None,
3682 album_id: None,
3683 album_name: None,
3684 album_artist: None,
3685 artists: None,
3686 artist_items: None,
3687 index_number: Some(index),
3688 series_id: Some("series1".to_string()),
3689 series_name: Some("Test Series".to_string()),
3690 season_id: Some("season1".to_string()),
3691 season_name: Some("Season 1".to_string()),
3692 parent_index_number: Some(1),
3693 user_data: None,
3694 media_streams: None,
3695 media_sources: None,
3696 people: None,
3697 }
3698 }
3699
3700 #[async_trait::async_trait]
3701 impl crate::repository::MediaRepository for MockEpisodeRepo {
3702 async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
3703 unimplemented!()
3704 }
3705 async fn get_items(
3706 &self,
3707 parent_id: &str,
3708 _options: Option<repo_types::GetItemsOptions>,
3709 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3710 assert_eq!(parent_id, "season1", "episode lookup must query the season");
3711 Ok(repo_types::SearchResult {
3712 items: self.episodes.clone(),
3713 total_record_count: self.episodes.len(),
3714 })
3715 }
3716 async fn get_item(
3717 &self,
3718 item_id: &str,
3719 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3720 self.episodes
3721 .iter()
3722 .find(|e| e.id == item_id)
3723 .cloned()
3724 .ok_or(repo_types::RepoError::NotFound {
3725 message: format!("{} not found", item_id),
3726 })
3727 }
3728 async fn get_latest_items(
3729 &self,
3730 _: &str,
3731 _: Option<usize>,
3732 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3733 unimplemented!()
3734 }
3735 async fn get_resume_items(
3736 &self,
3737 _: Option<&str>,
3738 _: Option<usize>,
3739 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3740 unimplemented!()
3741 }
3742 async fn get_next_up_episodes(
3743 &self,
3744 _: Option<&str>,
3745 _: Option<usize>,
3746 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3747 unimplemented!()
3748 }
3749 async fn get_recently_played_audio(
3750 &self,
3751 _: Option<usize>,
3752 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3753 unimplemented!()
3754 }
3755 async fn get_rediscover_albums(
3756 &self,
3757 _: Option<&str>,
3758 _: Option<usize>,
3759 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3760 unimplemented!()
3761 }
3762 async fn get_resume_movies(
3763 &self,
3764 _: Option<usize>,
3765 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3766 unimplemented!()
3767 }
3768 async fn get_genres(
3769 &self,
3770 _: Option<&str>,
3771 ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
3772 unimplemented!()
3773 }
3774 async fn search(
3775 &self,
3776 _: &str,
3777 _: Option<repo_types::SearchOptions>,
3778 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3779 unimplemented!()
3780 }
3781 async fn get_playback_info(
3782 &self,
3783 _: &str,
3784 ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
3785 unimplemented!()
3786 }
3787 async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
3788 unimplemented!()
3789 }
3790 async fn get_audio_only_stream_url_for_video(
3791 &self,
3792 item_id: &str,
3793 _media_source_id: Option<&str>,
3794 _start_time_seconds: Option<f64>,
3795 _audio_stream_index: Option<i32>,
3796 ) -> Result<String, repo_types::RepoError> {
3797 Ok(format!("http://example.com/{}-audio.mp3", item_id))
3798 }
3799 async fn get_live_tv_channels(
3800 &self,
3801 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3802 unimplemented!()
3803 }
3804 async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3805 unimplemented!()
3806 }
3807 async fn open_live_stream(
3808 &self,
3809 _: &str,
3810 ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
3811 unimplemented!()
3812 }
3813 async fn report_playback_start(
3814 &self,
3815 _: &str,
3816 _: i64,
3817 ) -> Result<(), repo_types::RepoError> {
3818 unimplemented!()
3819 }
3820 async fn report_playback_progress(
3821 &self,
3822 _: &str,
3823 _: i64,
3824 ) -> Result<(), repo_types::RepoError> {
3825 unimplemented!()
3826 }
3827 async fn report_playback_stopped(
3828 &self,
3829 _: &str,
3830 _: i64,
3831 ) -> Result<(), repo_types::RepoError> {
3832 unimplemented!()
3833 }
3834 fn get_image_url(
3835 &self,
3836 _: &str,
3837 _: repo_types::ImageType,
3838 _: Option<repo_types::ImageOptions>,
3839 ) -> String {
3840 unimplemented!()
3841 }
3842 fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
3843 unimplemented!()
3844 }
3845 fn get_video_download_url(
3846 &self,
3847 _: &str,
3848 _: &str,
3849 _: Option<&str>,
3850 _: Option<&str>,
3851 ) -> String {
3852 unimplemented!()
3853 }
3854 async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
3855 unimplemented!()
3856 }
3857 async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
3858 unimplemented!()
3859 }
3860 async fn get_favorites(
3861 &self,
3862 _: repo_types::SearchScope,
3863 _: Option<repo_types::GetItemsOptions>,
3864 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3865 unimplemented!()
3866 }
3867 async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
3868 unimplemented!()
3869 }
3870 async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
3871 unimplemented!()
3872 }
3873 async fn get_person(
3874 &self,
3875 _: &str,
3876 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3877 unimplemented!()
3878 }
3879 async fn get_items_by_person(
3880 &self,
3881 _: &str,
3882 _: Option<repo_types::GetItemsOptions>,
3883 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3884 unimplemented!()
3885 }
3886 async fn get_similar_items(
3887 &self,
3888 _: &str,
3889 _: Option<usize>,
3890 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3891 unimplemented!()
3892 }
3893 async fn create_playlist(
3894 &self,
3895 _: &str,
3896 _: &[String],
3897 ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
3898 unimplemented!()
3899 }
3900 async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
3901 unimplemented!()
3902 }
3903 async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
3904 unimplemented!()
3905 }
3906 async fn get_playlist_items(
3907 &self,
3908 _: &str,
3909 ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
3910 unimplemented!()
3911 }
3912 async fn add_to_playlist(
3913 &self,
3914 _: &str,
3915 _: &[String],
3916 ) -> Result<(), repo_types::RepoError> {
3917 unimplemented!()
3918 }
3919 async fn remove_from_playlist(
3920 &self,
3921 _: &str,
3922 _: &[String],
3923 ) -> Result<(), repo_types::RepoError> {
3924 unimplemented!()
3925 }
3926 async fn move_playlist_item(
3927 &self,
3928 _: &str,
3929 _: &str,
3930 _: u32,
3931 ) -> Result<(), repo_types::RepoError> {
3932 unimplemented!()
3933 }
3934 }
3935
3936 #[tokio::test]
3939 async fn test_video_playback_ended_offers_next_episode() {
3940 let controller = PlayerController::default();
3941 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
3942
3943 let decision = controller
3944 .on_video_playback_ended("ep2", repo)
3945 .await
3946 .expect("decision should succeed");
3947
3948 match decision {
3949 AutoplayDecision::ShowNextEpisodePopup {
3950 current_episode,
3951 next_episode,
3952 auto_advance,
3953 ..
3954 } => {
3955 assert_eq!(current_episode.id, "ep2");
3956 assert_eq!(next_episode.id, "ep3");
3957 assert!(auto_advance, "default settings should auto-advance");
3958 }
3959 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
3960 }
3961 }
3962
3963 #[tokio::test]
3965 async fn test_video_playback_ended_last_episode_stops() {
3966 let controller = PlayerController::default();
3967 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
3968
3969 let decision = controller
3970 .on_video_playback_ended("ep3", repo)
3971 .await
3972 .expect("decision should succeed");
3973
3974 assert!(matches!(decision, AutoplayDecision::Stop));
3975 }
3976
3977 #[tokio::test]
3982 async fn test_playback_ended_uses_controller_repository_for_episodes() {
3983 let controller = PlayerController::default();
3984 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
3985
3986 let episode = MediaItem {
3988 transport: None,
3990 media_type: MediaType::Video,
3991 source: MediaSource::Remote {
3992 stream_url: "http://example.com/ep1.mkv".to_string(),
3993 jellyfin_item_id: "ep1".to_string(),
3994 },
3995 ..create_test_items(1).remove(0)
3996 };
3997 controller.play_queue(vec![episode], 0).unwrap();
3998
3999 controller.take_end_reason();
4001
4002 let decision = controller.on_playback_ended().await.unwrap();
4003
4004 match decision {
4005 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4006 assert_eq!(next_episode.id, "ep2");
4007 }
4008 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4009 }
4010 }
4011
4012 #[tokio::test]
4019 async fn test_playback_ended_background_audio_episode_advances() {
4020 let controller = PlayerController::default();
4021 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4022
4023 let episode = MediaItem {
4025 transport: None,
4027 item_type: Some("Episode".to_string()),
4028 media_type: MediaType::Audio, series_id: Some("series1".to_string()),
4030 duration: Some(180.0),
4031 source: MediaSource::Remote {
4032 stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
4033 jellyfin_item_id: "ep2".to_string(),
4034 },
4035 ..create_test_items(1).remove(0)
4036 };
4037 controller.play_queue(vec![episode], 0).unwrap();
4038
4039 controller.seek(180.0).unwrap();
4041 controller.take_end_reason();
4043
4044 let decision = controller.on_playback_ended().await.unwrap();
4045
4046 match decision {
4047 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4048 assert_eq!(next_episode.id, "ep3");
4049 }
4050 other => panic!(
4051 "background-audio episode end must advance to the next episode, got {:?}",
4052 other
4053 ),
4054 }
4055 }
4056
4057 #[tokio::test]
4061 async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
4062 let controller = PlayerController::default();
4063 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4064
4065 controller
4066 .advance_to_next_episode_audio_only("ep2")
4067 .await
4068 .expect("advance should succeed");
4069
4070 let current = controller
4071 .queue
4072 .lock_safe()
4073 .current()
4074 .cloned()
4075 .expect("an item should be loaded");
4076 assert_eq!(current.id, "ep2");
4077 assert_eq!(current.media_type, MediaType::Audio);
4078 assert_eq!(current.item_type.as_deref(), Some("Episode"));
4079 assert_eq!(current.series_id.as_deref(), Some("series1"));
4080 match ¤t.source {
4082 MediaSource::Remote { stream_url, .. } => {
4083 assert!(
4084 stream_url.contains("audio"),
4085 "expected audio-only URL, got {}",
4086 stream_url
4087 );
4088 }
4089 other => panic!("expected Remote source, got {:?}", other),
4090 }
4091
4092 assert!(controller.current_is_audio_episode());
4095 }
4096
4097 #[tokio::test]
4105 async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
4106 let controller = PlayerController::default();
4107 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4108
4109 controller.set_background_audio_base(1200.0);
4111
4112 controller
4113 .advance_to_next_episode_audio_only("ep2")
4114 .await
4115 .expect("advance should succeed");
4116
4117 assert_eq!(
4118 controller.take_background_audio_base(),
4119 0.0,
4120 "the next episode starts at its own zero, so the previous handoff \
4121 base must not survive the advance"
4122 );
4123 }
4124
4125 #[tokio::test]
4137 async fn test_auto_advance_background_audio_episode_advances_in_backend() {
4138 let controller = PlayerController::default();
4139 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4140
4141 let episode = MediaItem {
4143 transport: None,
4145 id: "ep2".to_string(),
4146 item_type: Some("Episode".to_string()),
4147 media_type: MediaType::Audio,
4148 series_id: Some("series1".to_string()),
4149 source: MediaSource::Remote {
4150 stream_url: "http://example.com/ep2-audio.mp3".to_string(),
4151 jellyfin_item_id: "ep2".to_string(),
4152 },
4153 ..create_test_items(1).remove(0)
4154 };
4155 controller.play_queue(vec![episode], 0).unwrap();
4156
4157 let next = make_repo_episode("ep3", 3);
4158 controller.auto_advance_to_next_episode(next, 10).await;
4159
4160 let current = controller
4161 .queue
4162 .lock_safe()
4163 .current()
4164 .cloned()
4165 .expect("an item should still be loaded");
4166 assert_eq!(
4167 current.id, "ep3",
4168 "background audio-only episode must advance in the backend, not wait \
4169 for a frontend navigation that cannot happen while backgrounded"
4170 );
4171 assert_eq!(current.media_type, MediaType::Audio);
4172 assert!(controller.current_is_audio_episode());
4173 }
4174
4175 fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
4179 MediaItem {
4180 transport: None,
4182 id: "ep2".to_string(),
4183 item_type: Some("Episode".to_string()),
4184 media_type: MediaType::Audio,
4185 series_id: Some("series1".to_string()),
4186 duration: Some(runtime_seconds),
4187 source: MediaSource::Remote {
4188 stream_url:
4189 "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
4190 .to_string(),
4191 jellyfin_item_id: "ep2".to_string(),
4192 },
4193 ..create_test_items(1).remove(0)
4194 }
4195 }
4196
4197 fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
4201 MediaItem {
4202 transport: None,
4204 source: MediaSource::Local {
4205 file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
4206 jellyfin_item_id: Some("ep2".to_string()),
4207 },
4208 ..audio_only_episode(runtime_seconds)
4209 }
4210 }
4211
4212 #[tokio::test]
4219 async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
4220 let controller = PlayerController::default();
4221 controller
4222 .play_queue(vec![local_audio_only_episode(1500.0)], 0)
4223 .unwrap();
4224 controller.enter_background_audio(0.0);
4226
4227 controller.seek_absolute(900.0).await.unwrap();
4228
4229 assert_eq!(controller.position(), 900.0);
4230 }
4231
4232 #[tokio::test]
4244 async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
4245 let controller = PlayerController::default();
4246 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4247
4248 controller
4249 .play_queue(vec![audio_only_episode(1500.0)], 0)
4250 .unwrap();
4251 controller.seek(600.0).unwrap();
4253 controller.take_end_reason();
4254
4255 let decision = controller.on_playback_ended().await.unwrap();
4256
4257 match decision {
4258 AutoplayDecision::ResumeStream { position } => {
4259 assert_eq!(position, 600.0, "must resume where the stream died");
4260 }
4261 other => panic!(
4262 "a stream that ended 15 minutes short of the runtime must resume, \
4263 not run end-of-episode logic; got {:?}",
4264 other
4265 ),
4266 }
4267 }
4268
4269 #[tokio::test]
4282 async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
4283 let controller = PlayerController::default();
4284 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4285 controller
4286 .play_queue(vec![audio_only_episode(1500.0)], 0)
4287 .unwrap();
4288
4289 controller.enter_background_audio(1200.0);
4291
4292 controller.seek_absolute(1490.0).await.unwrap();
4294
4295 let url = {
4296 let queue = controller.queue();
4297 let queue = queue.lock_safe();
4298 match &queue.current().unwrap().source {
4299 MediaSource::Remote { stream_url, .. } => stream_url.clone(),
4300 other => panic!("expected a remote source, got {:?}", other),
4301 }
4302 };
4303 assert!(
4304 url.contains(&format!(
4305 "StartTimeTicks={}",
4306 (1490.0 * 10_000_000.0) as i64
4307 )),
4308 "the stream must be re-opened at the absolute position; got {}",
4309 url
4310 );
4311
4312 assert_eq!(
4313 *controller.background_audio_base.lock_safe(),
4314 1490.0,
4315 "the re-opened stream's zero is the position it was opened at, or \
4316 every later reading is off by the difference"
4317 );
4318 }
4319
4320 #[tokio::test]
4325 async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
4326 let controller = PlayerController::default();
4327 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4328 controller
4329 .play_queue(vec![audio_only_episode(1500.0)], 0)
4330 .unwrap();
4331
4332 controller.seek_absolute(300.0).await.unwrap();
4333
4334 assert_eq!(controller.position(), 300.0);
4335 assert_eq!(
4336 *controller.background_audio_base.lock_safe(),
4337 0.0,
4338 "an ordinary seek must not invent a handoff base"
4339 );
4340 }
4341
4342 #[test]
4362 fn test_absolute_position_floors_at_the_handoff_base() {
4363 let controller = PlayerController::default();
4364 controller.enter_background_audio(18.4);
4365
4366 assert_eq!(controller.position(), 0.0);
4368 assert_eq!(
4369 controller.absolute_position(),
4370 18.4,
4371 "the audio stream's zero IS the handoff point, so the position can \
4372 never legitimately read below it"
4373 );
4374 }
4375
4376 #[test]
4381 fn test_absolute_position_does_not_double_count_the_handoff_base() {
4382 let controller = PlayerController::default();
4383 controller.enter_background_audio(18.4);
4384
4385 controller.seek(120.0).unwrap();
4387
4388 assert_eq!(controller.absolute_position(), 120.0);
4389 }
4390
4391 #[test]
4398 fn test_webview_position_reports_become_the_controllers_position() {
4399 let controller = PlayerController::default();
4400
4401 controller.report_html5_position(253.4, 2640.0);
4402
4403 assert_eq!(controller.absolute_position(), 253.4);
4404 assert_eq!(controller.observed_duration(), Some(2640.0));
4405 }
4406
4407 #[test]
4413 fn test_webview_teardown_clears_the_observed_position() {
4414 let controller = PlayerController::default();
4415 controller.report_html5_position(253.4, 2640.0);
4416
4417 controller.report_html5_state("stopped".to_string(), None);
4418
4419 assert_eq!(controller.absolute_position(), 0.0);
4420 }
4421
4422 #[test]
4427 fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
4428 let controller = PlayerController::default();
4429 controller.report_html5_position(253.4, 2640.0);
4430
4431 controller.enter_background_audio(18.4);
4432
4433 assert_eq!(
4434 controller.absolute_position(),
4435 18.4,
4436 "the video element is gone; only the handoff base describes the \
4437 stream that is now playing"
4438 );
4439 }
4440
4441 #[tokio::test]
4449 async fn test_a_stop_at_zero_is_never_reported() {
4450 let controller = PlayerController::default();
4451 let reports = Arc::new(CapturingReports::new());
4452 controller.set_report_sink(reports.clone());
4453 controller
4454 .play_queue(vec![audio_only_episode(1500.0)], 0)
4455 .unwrap();
4456
4457 controller.stop().unwrap();
4459
4460 assert!(
4461 reports.stops().is_empty(),
4462 "a zero-position stop must be withheld, not sent; got {:?}",
4463 reports.stops()
4464 );
4465 }
4466
4467 #[tokio::test]
4476 async fn test_a_stop_reports_the_position_actually_reached() {
4477 let webview = PlayerController::default();
4479 let webview_reports = Arc::new(CapturingReports::new());
4480 webview.set_report_sink(webview_reports.clone());
4481 webview
4482 .play_queue(vec![audio_only_episode(1500.0)], 0)
4483 .unwrap();
4484 webview.report_html5_position(253.0, 1500.0);
4485
4486 webview.stop().unwrap();
4487
4488 assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4489
4490 let native = PlayerController::default();
4492 let native_reports = Arc::new(CapturingReports::new());
4493 native.set_report_sink(native_reports.clone());
4494 native
4495 .play_queue(vec![audio_only_episode(1500.0)], 0)
4496 .unwrap();
4497 native.seek(253.0).unwrap();
4498
4499 native.stop().unwrap();
4500
4501 assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4502 }
4503
4504 #[tokio::test]
4514 async fn test_a_finished_audio_only_episode_is_reported_complete() {
4515 let controller = PlayerController::default();
4516 let reports = Arc::new(CapturingReports::new());
4517 controller.set_report_sink(reports.clone());
4518 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4519 controller
4520 .play_queue(vec![audio_only_episode(1500.0)], 0)
4521 .unwrap();
4522 controller.seek(1499.0).unwrap();
4524 controller.take_end_reason();
4525
4526 controller.on_playback_ended().await.unwrap();
4527
4528 assert_eq!(
4529 reports.stops(),
4530 vec![("ep2".to_string(), 1500.0)],
4531 "the finished episode must be reported stopped at its runtime, or \
4532 Jellyfin's ≥90% rule never marks it played"
4533 );
4534 }
4535
4536 #[tokio::test]
4542 async fn test_a_truncated_stream_reports_no_completion() {
4543 let controller = PlayerController::default();
4544 let reports = Arc::new(CapturingReports::new());
4545 controller.set_report_sink(reports.clone());
4546 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4547 controller
4548 .play_queue(vec![audio_only_episode(1500.0)], 0)
4549 .unwrap();
4550 controller.seek(600.0).unwrap();
4551 controller.take_end_reason();
4552
4553 let decision = controller.on_playback_ended().await.unwrap();
4554
4555 assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
4556 assert!(
4557 reports.stops().is_empty(),
4558 "a dropped connection is not a finished episode; got {:?}",
4559 reports.stops()
4560 );
4561 }
4562
4563 #[tokio::test]
4571 async fn test_webview_position_ticks_report_progress_to_the_server() {
4572 let controller = PlayerController::default();
4573 let reports = Arc::new(CapturingReports::new());
4574 controller.set_report_sink(reports.clone());
4575 controller
4576 .play_queue(vec![audio_only_episode(1500.0)], 0)
4577 .unwrap();
4578
4579 controller.report_html5_position(253.4, 1500.0);
4580
4581 assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
4582 }
4583
4584 #[tokio::test]
4589 async fn test_progress_reports_are_throttled_not_sent_per_tick() {
4590 let controller = PlayerController::default();
4591 let reports = Arc::new(CapturingReports::new());
4592 controller.set_report_sink(reports.clone());
4593 controller
4594 .play_queue(vec![audio_only_episode(1500.0)], 0)
4595 .unwrap();
4596
4597 for tick in 0..12 {
4598 controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
4599 }
4600
4601 assert_eq!(
4602 reports.progress().len(),
4603 1,
4604 "twelve ticks inside one throttle window are one report"
4605 );
4606 }
4607
4608 #[tokio::test]
4618 async fn test_truncated_check_uses_the_absolute_position() {
4619 let controller = PlayerController::default();
4620 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4621
4622 controller
4623 .play_queue(vec![audio_only_episode(1500.0)], 0)
4624 .unwrap();
4625 controller.set_background_audio_base(1440.0);
4628 controller.seek(1496.0).unwrap();
4629 controller.take_end_reason();
4630
4631 let decision = controller.on_playback_ended().await.unwrap();
4632
4633 assert!(
4634 matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
4635 "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
4636 decision
4637 );
4638 }
4639
4640 #[tokio::test]
4644 async fn test_repeated_truncation_at_the_same_position_gives_up() {
4645 let controller = PlayerController::default();
4646 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4647
4648 controller
4649 .play_queue(vec![audio_only_episode(1500.0)], 0)
4650 .unwrap();
4651 controller.seek(600.0).unwrap();
4652
4653 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4654 controller.take_end_reason();
4655 let decision = controller.on_playback_ended().await.unwrap();
4656 assert!(
4657 matches!(decision, AutoplayDecision::ResumeStream { .. }),
4658 "attempt {} should still resume, got {:?}",
4659 attempt,
4660 decision
4661 );
4662 }
4663
4664 controller.take_end_reason();
4665 let decision = controller.on_playback_ended().await.unwrap();
4666 assert!(
4667 !matches!(decision, AutoplayDecision::ResumeStream { .. }),
4668 "a stream stuck at the same position must stop retrying, got {:?}",
4669 decision
4670 );
4671 }
4672
4673 #[tokio::test]
4677 async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
4678 let controller = PlayerController::default();
4679
4680 let mut items = create_test_items(2);
4681 items[0].duration = Some(1500.0);
4682 controller.play_queue(items, 0).unwrap();
4683 controller.seek(60.0).unwrap();
4684 controller.take_end_reason();
4685
4686 let decision = controller.on_playback_ended().await.unwrap();
4687 assert!(
4688 matches!(decision, AutoplayDecision::AdvanceToNext),
4689 "plain queue audio must keep advancing, got {:?}",
4690 decision
4691 );
4692 }
4693
4694 #[tokio::test]
4700 async fn test_recoverable_error_resumes_a_music_track() {
4701 let controller = PlayerController::default();
4702
4703 let mut items = create_test_items(3);
4704 for item in &mut items {
4705 item.source = MediaSource::Remote {
4706 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
4707 jellyfin_item_id: item.id.clone(),
4708 };
4709 }
4710 controller.play_queue(items, 1).unwrap();
4711 controller.seek(45.0).unwrap();
4712
4713 let (position, _) = controller
4714 .recoverable_error_resume()
4715 .expect("a streamed music track must be resumable after a network error");
4716 assert_eq!(position, 45.0);
4717 }
4718
4719 #[tokio::test]
4723 async fn test_resume_keeps_the_rest_of_the_queue() {
4724 let controller = PlayerController::default();
4725
4726 let mut items = create_test_items(3);
4727 for item in &mut items {
4728 item.source = MediaSource::Remote {
4729 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
4730 jellyfin_item_id: item.id.clone(),
4731 };
4732 }
4733 controller.play_queue(items, 1).unwrap();
4734
4735 controller
4736 .resume_stream_at(45.0)
4737 .await
4738 .expect("resume should succeed");
4739
4740 let queue = controller.queue.lock_safe();
4741 assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
4742 assert_eq!(queue.current_index(), Some(1), "still on the same track");
4743 assert_eq!(queue.current().unwrap().id, "item_1");
4744 }
4745
4746 #[tokio::test]
4750 async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
4751 let controller = PlayerController::default();
4752
4753 let mut items = create_test_items(1);
4754 items[0].source = MediaSource::Remote {
4755 stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
4756 jellyfin_item_id: "item_0".to_string(),
4757 };
4758 controller.play_queue(items, 0).unwrap();
4759
4760 controller.resume_stream_at(45.0).await.unwrap();
4761
4762 match &controller.queue.lock_safe().current().unwrap().source {
4763 MediaSource::Remote { stream_url, .. } => {
4764 assert_eq!(
4765 stream_url, "http://s/Audio/item_0/stream?Static=true",
4766 "a seekable stream's URL must be left alone"
4767 );
4768 }
4769 other => panic!("expected Remote source, got {:?}", other),
4770 }
4771 assert_eq!(
4772 controller.position(),
4773 45.0,
4774 "and it must land at the position"
4775 );
4776 }
4777
4778 #[tokio::test]
4781 async fn test_recoverable_error_ignores_local_media() {
4782 let controller = PlayerController::default();
4783
4784 let mut items = create_test_items(1);
4785 items[0].source = MediaSource::Local {
4786 file_path: "/music/track.flac".into(),
4787 jellyfin_item_id: Some("item_0".to_string()),
4788 };
4789 controller.play_queue(items, 0).unwrap();
4790 controller.seek(45.0).unwrap();
4791
4792 assert!(controller.recoverable_error_resume().is_none());
4793 }
4794
4795 #[tokio::test]
4800 async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
4801 let controller = PlayerController::default();
4802
4803 controller
4804 .play_queue(vec![audio_only_episode(1500.0)], 0)
4805 .unwrap();
4806 controller.seek(600.0).unwrap();
4807
4808 let mut waits = Vec::new();
4809 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4810 let (position, delay) = controller
4811 .recoverable_error_resume()
4812 .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
4813 assert_eq!(position, 600.0);
4814 waits.push(delay);
4815 }
4816 assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
4817 assert!(
4818 controller.recoverable_error_resume().is_none(),
4819 "a stream that keeps failing at the same spot must surface the error"
4820 );
4821 }
4822
4823 #[tokio::test]
4826 async fn test_recoverable_error_ignores_direct_url_playback() {
4827 let controller = PlayerController::default();
4828 controller.play_queue(create_test_items(2), 0).unwrap();
4829
4830 assert!(controller.recoverable_error_resume().is_none());
4831 }
4832
4833 #[tokio::test]
4837 async fn test_resume_truncated_stream_reloads_at_position() {
4838 let controller = PlayerController::default();
4839
4840 controller
4841 .play_queue(vec![audio_only_episode(1500.0)], 0)
4842 .unwrap();
4843 controller.set_background_audio_base(0.0);
4844
4845 controller
4846 .resume_stream_at(600.0)
4847 .await
4848 .expect("resume should succeed");
4849
4850 let current = controller
4851 .queue
4852 .lock_safe()
4853 .current()
4854 .cloned()
4855 .expect("the same item should still be loaded");
4856 assert_eq!(current.id, "ep2", "resume must not change the item");
4857 match ¤t.source {
4858 MediaSource::Remote { stream_url, .. } => {
4859 assert!(
4860 stream_url.contains("StartTimeTicks=6000000000"),
4861 "stream must re-open at 600s, got {}",
4862 stream_url
4863 );
4864 assert!(
4865 stream_url.contains("AudioStreamIndex=2"),
4866 "the selected audio track must survive the resume, got {}",
4867 stream_url
4868 );
4869 }
4870 other => panic!("expected Remote source, got {:?}", other),
4871 }
4872 assert_eq!(
4873 controller.take_background_audio_base(),
4874 600.0,
4875 "the re-opened stream's zero is the resume position"
4876 );
4877 }
4878
4879 #[tokio::test]
4883 async fn test_auto_advance_foreground_video_episode_uses_countdown() {
4884 let controller = PlayerController::default();
4885 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4886
4887 let episode = MediaItem {
4888 transport: None,
4890 id: "ep2".to_string(),
4891 item_type: Some("Episode".to_string()),
4892 media_type: MediaType::Video,
4893 series_id: Some("series1".to_string()),
4894 source: MediaSource::Remote {
4895 stream_url: "http://example.com/ep2.m3u8".to_string(),
4896 jellyfin_item_id: "ep2".to_string(),
4897 },
4898 ..create_test_items(1).remove(0)
4899 };
4900 controller.play_queue(vec![episode], 0).unwrap();
4901
4902 let next = make_repo_episode("ep3", 3);
4903 controller.auto_advance_to_next_episode(next, 10).await;
4904
4905 let current = controller
4906 .queue
4907 .lock_safe()
4908 .current()
4909 .cloned()
4910 .expect("an item should still be loaded");
4911 assert_eq!(
4912 current.id, "ep2",
4913 "foreground video advance is frontend-driven; the backend must not \
4914 swap the queue item out from under it"
4915 );
4916 }
4917
4918 #[tokio::test]
4921 async fn test_playback_ended_without_repository_stops() {
4922 let controller = PlayerController::default();
4923
4924 let episode = MediaItem {
4925 transport: None,
4927 media_type: MediaType::Video,
4928 source: MediaSource::Remote {
4929 stream_url: "http://example.com/ep1.mkv".to_string(),
4930 jellyfin_item_id: "ep1".to_string(),
4931 },
4932 ..create_test_items(1).remove(0)
4933 };
4934 controller.play_queue(vec![episode], 0).unwrap();
4935 controller.take_end_reason();
4936
4937 let decision = controller.on_playback_ended().await.unwrap();
4938 assert!(matches!(decision, AutoplayDecision::Stop));
4939 }
4940}