1pub mod autoplay;
6pub mod backend;
7pub mod background_policy;
8pub mod events;
9pub mod media;
10pub mod queue;
11pub mod seek;
12pub mod session;
13pub mod sleep_timer;
14pub mod state;
15pub mod stream_end;
16
17#[cfg(test)]
18mod mpv_backend_test;
19
20#[cfg(target_os = "android")]
22pub mod android;
23
24#[cfg(target_os = "linux")]
25pub mod mpv_backend;
26
27#[cfg(not(any(target_os = "linux", target_os = "android")))]
30pub mod webview_audio_backend;
31
32pub use autoplay::{AutoplayDecision, AutoplaySettings};
34pub use backend::{NullBackend, PlayerBackend, PlayerError};
35pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
36pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
37pub use queue::{QueueManager, RepeatMode};
38pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
39pub use session::{MediaSessionManager, MediaSessionType};
40pub use sleep_timer::{SleepTimerMode, SleepTimerState};
41pub use state::{EndReason, PlayerState};
42
43#[cfg(target_os = "android")]
45pub use android::ExoPlayerBackend;
46
47#[cfg(target_os = "linux")]
48pub use mpv_backend::MpvBackend;
49
50#[cfg(not(any(target_os = "linux", target_os = "android")))]
51pub use webview_audio_backend::WebviewAudioBackend;
52
53#[cfg(target_os = "android")]
54pub use android::{
55 disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
56 set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
57};
58
59pub trait PlaybackReportSink: Send + Sync {
70 fn send(&self, operation: PlaybackOperation);
72}
73
74struct ReporterSink {
80 reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
81}
82
83impl PlaybackReportSink for ReporterSink {
84 fn send(&self, operation: PlaybackOperation) {
85 let reporter = self.reporter.clone();
86 let task = async move {
87 let guard = reporter.lock().await;
88 let Some(reporter) = guard.as_ref() else {
89 warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
90 return;
91 };
92 if let Err(e) = reporter.report(operation, true).await {
94 log::error!("[PlayerController] Failed to report playback: {}", e);
95 }
96 };
97
98 if let Ok(handle) = tokio::runtime::Handle::try_current() {
99 handle.spawn(task);
100 } else {
101 std::thread::spawn(move || match tokio::runtime::Runtime::new() {
102 Ok(rt) => rt.block_on(task),
103 Err(e) => log::error!(
104 "[PlayerController] No runtime available to report playback: {}",
105 e
106 ),
107 });
108 }
109 }
110}
111
112fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
122 match runtime {
123 Some(runtime) if runtime > 0.0 => runtime,
124 _ => last_position.max(0.0),
125 }
126}
127
128#[cfg_attr(not(target_os = "android"), allow(dead_code))]
136const RESUME_BACKOFF_STEP_SECS: u64 = 2;
137
138#[derive(Debug, Clone)]
147#[cfg_attr(not(target_os = "android"), allow(dead_code))]
150pub struct LockscreenMetadata {
151 pub title: String,
152 pub artist: String,
153 pub album: Option<String>,
154 pub duration_ms: i64,
156 pub position_ms: i64,
158 pub is_playing: bool,
159}
160
161pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
169 #[cfg(target_os = "android")]
170 {
171 return android::update_lockscreen_metadata(_meta);
172 }
173 #[cfg(not(target_os = "android"))]
174 {
175 Ok(())
176 }
177}
178
179pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
185 #[cfg(target_os = "android")]
186 {
187 return android::set_position_offset(_offset_seconds);
188 }
189 #[cfg(not(target_os = "android"))]
190 {
191 Ok(())
192 }
193}
194
195use crate::utils::lock::MutexSafe;
196use log::{debug, error, info, warn};
197use std::sync::{Arc, Mutex};
198use std::time::Duration;
199use tokio::sync::Mutex as TokioMutex;
200
201use crate::jellyfin::JellyfinClient;
202use crate::playback_reporting::{
203 EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
204};
205use crate::repository::MediaRepository;
206use crate::settings::AudioSettings;
207use crate::utils::conversions::seconds_to_ticks;
208
209pub struct PlayerController {
211 backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
212 queue: Arc<Mutex<QueueManager>>,
213 jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
214 muted: bool,
215
216 sleep_timer: Arc<Mutex<SleepTimerState>>,
218
219 autoplay_settings: Arc<Mutex<AutoplaySettings>>,
221
222 repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
224
225 event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
227
228 countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
230
231 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
233
234 reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
237
238 position_throttler: Arc<EventThrottler>,
241
242 end_reason: Arc<Mutex<Option<EndReason>>>,
244
245 autoplay_episode_count: Arc<Mutex<u32>>,
247
248 background_audio_base: Arc<Mutex<f64>>,
262
263 background_audio_active: Arc<Mutex<bool>>,
273
274 stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
282
283 html5_playing: Arc<Mutex<Option<bool>>>,
293
294 reported_time: Arc<Mutex<stream_end::ObservedTime>>,
306}
307
308impl PlayerController {
309 pub fn new(
310 backend: Box<dyn PlayerBackend>,
311 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
312 position_throttler: Arc<EventThrottler>,
313 ) -> Self {
314 let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
315 reporter: playback_reporter.clone(),
316 });
317 let controller = Self {
318 backend: Arc::new(Mutex::new(backend)),
319 queue: Arc::new(Mutex::new(QueueManager::new())),
320 jellyfin_client: Arc::new(Mutex::new(None)),
321 muted: false,
322 sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
323 autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
324 repository: Arc::new(Mutex::new(None)),
325 event_emitter: Arc::new(Mutex::new(None)),
326 countdown_cancel: Arc::new(Mutex::new(None)),
327 playback_reporter,
328 reports: Arc::new(Mutex::new(reports)),
329 position_throttler,
330 end_reason: Arc::new(Mutex::new(None)),
331 autoplay_episode_count: Arc::new(Mutex::new(0)),
332 background_audio_base: Arc::new(Mutex::new(0.0)),
333 background_audio_active: Arc::new(Mutex::new(false)),
334 stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
335 html5_playing: Arc::new(Mutex::new(None)),
336 reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
337 };
338
339 controller.start_timer_thread();
341
342 controller
343 }
344
345 pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
347 let mut jellyfin = self.jellyfin_client.lock_safe();
348 *jellyfin = client;
349 log::info!(
350 "[PlayerController] Jellyfin client configured: {}",
351 jellyfin.is_some()
352 );
353 }
354
355 pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
357 self.jellyfin_client.clone()
358 }
359
360 pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
367 *self.repository.lock_safe() = Some(repo);
368 }
369
370 pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
373 let mut reporter_guard = self.playback_reporter.lock().await;
374 *reporter_guard = reporter;
375 log::info!(
376 "[PlayerController] Playback reporter configured: {}",
377 reporter_guard.is_some()
378 );
379 }
380
381 #[allow(dead_code)]
384 pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
385 self.playback_reporter.clone()
386 }
387
388 #[allow(dead_code)]
391 pub fn position_throttler(&self) -> Arc<EventThrottler> {
392 self.position_throttler.clone()
393 }
394
395 fn set_end_reason(&self, reason: EndReason) {
397 log::debug!("[PlayerController] Setting end reason: {:?}", reason);
398 *self.end_reason.lock_safe() = Some(reason);
399 }
400
401 fn take_end_reason(&self) -> Option<EndReason> {
403 self.end_reason.lock_safe().take()
404 }
405
406 fn peek_end_reason(&self) -> Option<EndReason> {
413 *self.end_reason.lock_safe()
414 }
415
416 fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
429 log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
430 *end_reason.lock_safe() = Some(EndReason::UserStop);
431 }
432
433 fn increment_autoplay_count(&self) -> bool {
435 let max = self.autoplay_settings.lock_safe().max_episodes;
436
437 if max == 0 {
438 return false;
440 }
441
442 let mut count = self.autoplay_episode_count.lock_safe();
443 *count += 1;
444 debug!(
445 "[PlayerController] Autoplay episode count: {}/{}",
446 *count, max
447 );
448
449 *count >= max
450 }
451
452 fn reset_autoplay_count(&self) {
454 let mut count = self.autoplay_episode_count.lock_safe();
455 if *count > 0 {
456 debug!(
457 "[PlayerController] Resetting autoplay episode counter (was {})",
458 *count
459 );
460 }
461 *count = 0;
462 }
463
464 pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
466 debug!("[PlayerController] play_item: {}", item.title);
467
468 self.reset_autoplay_count();
470
471 {
473 let mut queue = self.queue.lock_safe();
474 queue.set_queue(vec![item.clone()], 0);
475 }
476
477 self.load_and_play(&item)?;
479
480 Ok(())
481 }
482
483 #[cfg(target_os = "linux")]
489 pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
490 debug!(
491 "[PlayerController] set_current_item (no backend load): {}",
492 item.title
493 );
494
495 self.reset_autoplay_count();
496 self.clear_reported_time();
502
503 let mut queue = self.queue.lock_safe();
504 queue.set_queue(vec![item], 0);
505
506 Ok(())
507 }
508
509 pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
512 debug!("[PlayerController] load_and_play: {}", item.title);
513
514 self.set_end_reason(EndReason::NewTrackLoaded);
516
517 *self.html5_playing.lock_safe() = None;
539
540 let mut backend = self.backend.lock_safe();
541 backend.load(item)?;
542 backend.play()?;
543 drop(backend);
544
545 self.clear_reported_time();
548
549 if let Some(jellyfin_id) = item.jellyfin_id() {
551 let context = if item.album_id.is_some() {
553 Some(PlaybackContext {
554 context_type: "container".to_string(),
555 context_id: item.album_id.clone(),
556 })
557 } else {
558 None
559 };
560
561 let position = self.absolute_position();
567
568 log::info!(
569 "[PlayerController] Reporting playback start: {} @ {:.1}s",
570 jellyfin_id,
571 position
572 );
573 self.report(PlaybackOperation::Start {
574 item_id: jellyfin_id.to_string(),
575 position_ticks: seconds_to_ticks(position),
576 context,
577 });
578 }
579
580 Ok(())
581 }
582
583 pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
585 self.play_queue_from(items, start_index, None)
586 }
587
588 pub fn play_queue_from(
595 &self,
596 items: Vec<MediaItem>,
597 start_index: usize,
598 start_position: Option<f64>,
599 ) -> Result<(), PlayerError> {
600 debug!(
601 "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
602 items.len(),
603 start_index,
604 start_position
605 );
606
607 self.reset_autoplay_count();
609
610 {
611 let mut queue = self.queue.lock_safe();
612 queue.set_queue(items, start_index);
613 }
614
615 if let Some(item) = self.queue.lock_safe().current().cloned() {
617 self.load_and_play(&item)?;
618
619 if let Some(position) = start_position {
623 if position > 0.5 {
624 self.seek(position)?;
625 }
626 }
627 }
628
629 Ok(())
630 }
631
632 pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
638 debug!(
639 "[PlayerController] set_queue (no local playback): {} items, index {}",
640 items.len(),
641 start_index
642 );
643 self.reset_autoplay_count();
644 let mut queue = self.queue.lock_safe();
645 queue.set_queue(items, start_index);
646 Ok(())
647 }
648
649 pub fn is_html5_active(&self) -> bool {
654 self.html5_playing.lock_safe().is_some()
655 }
656
657 pub fn html5_is_playing(&self) -> bool {
662 self.html5_playing.lock_safe().unwrap_or(false)
663 }
664
665 fn emit_html5_control(&self, action: &str) {
667 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
668 emitter.emit(PlayerStatusEvent::ControlCommand {
669 action: action.to_string(),
670 position: None,
671 });
672 }
673 }
674
675 pub fn play(&self) -> Result<(), PlayerError> {
677 debug!("[PlayerController] play");
678 if self.is_html5_active() {
681 self.emit_html5_control("play");
682 return Ok(());
683 }
684 let mut backend = self.backend.lock_safe();
685 backend.play()
686 }
687
688 pub fn pause(&self) -> Result<(), PlayerError> {
690 if self.is_html5_active() {
691 self.emit_html5_control("pause");
692 return Ok(());
693 }
694 let mut backend = self.backend.lock_safe();
695 backend.pause()
696 }
697
698 pub fn toggle_playback(&self) -> Result<(), PlayerError> {
706 if self.is_html5_active() {
707 let action = if self.html5_is_playing() {
708 "pause"
709 } else {
710 "play"
711 };
712 self.emit_html5_control(action);
713 return Ok(());
714 }
715 let mut backend = self.backend.lock_safe();
716 if backend.state().is_playing() {
717 backend.pause()
718 } else {
719 backend.play()
720 }
721 }
722
723 pub fn stop(&self) -> Result<(), PlayerError> {
725 self.set_end_reason(EndReason::UserStop);
727
728 let jellyfin_id = {
730 let queue = self.queue.lock_safe();
731 queue
732 .current()
733 .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
734 };
735
736 let position = self.absolute_position();
739
740 let mut backend = self.backend.lock_safe();
741 backend.stop()?;
742 drop(backend);
743 self.clear_reported_time();
744
745 if let Some(jellyfin_id) = jellyfin_id {
746 self.report_stopped_at(jellyfin_id, position);
747 }
748
749 Ok(())
750 }
751
752 fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
766 if position <= 0.0 {
767 debug!(
768 "[PlayerController] Withholding zero-position stop report for {} \
769 (nothing played; reporting it would clear the resume point)",
770 jellyfin_id
771 );
772 return;
773 }
774
775 log::info!(
776 "[PlayerController] Reporting playback stopped: {} @ {:.1}s",
777 jellyfin_id,
778 position
779 );
780 self.report(PlaybackOperation::Stopped {
781 item_id: jellyfin_id,
782 position_ticks: seconds_to_ticks(position),
783 });
784 }
785
786 pub fn next(&self) -> Result<(), PlayerError> {
791 self.reset_autoplay_count();
793
794 let next_item = {
795 let mut queue = self.queue.lock_safe();
796 queue.next().cloned()
797 };
798
799 debug!(
800 "[PlayerController] next: {:?}",
801 next_item.as_ref().map(|i| &i.title)
802 );
803
804 if let Some(item) = next_item {
805 self.load_and_play(&item)
806 } else {
807 debug!("[PlayerController] No next item, stopping");
808 self.stop()
809 }
810 }
811
812 pub fn previous(&self) -> Result<(), PlayerError> {
817 self.reset_autoplay_count();
819 {
821 let backend = self.backend.lock_safe();
822 if backend.position() > 3.0 {
823 debug!("[PlayerController] previous: restarting current track (position > 3s)");
824 drop(backend);
825 return self.seek(0.0);
826 }
827 }
828
829 let prev_item = {
830 let mut queue = self.queue.lock_safe();
831 queue.previous().cloned()
832 };
833
834 debug!(
835 "[PlayerController] previous: {:?}",
836 prev_item.as_ref().map(|i| &i.title)
837 );
838
839 if let Some(item) = prev_item {
840 self.load_and_play(&item)
841 } else {
842 self.seek(0.0)
843 }
844 }
845
846 pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
853 let mut backend = self.backend.lock_safe();
854 backend.seek(position)
855 }
856
857 pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
875 let rebuild = self.is_background_audio_active() && {
880 let queue = self.queue.lock_safe();
881 queue
882 .current()
883 .map(|item| {
884 Self::is_audio_only_video(item)
885 && matches!(item.source, MediaSource::Remote { .. })
886 })
887 .unwrap_or(false)
888 };
889
890 if rebuild {
891 return self.resume_stream_at(position.max(0.0)).await;
892 }
893
894 self.seek(position).map_err(|e| e.to_string())
895 }
896
897 pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
899 self.backend.lock_safe().set_volume(volume)
900 }
901
902 pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
904 let mut backend = self.backend.lock_safe();
905 backend.set_audio_track(stream_index)
906 }
907
908 pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
910 let mut backend = self.backend.lock_safe();
911 backend.set_subtitle_track(stream_index)
912 }
913
914 pub fn state(&self) -> PlayerState {
916 self.backend.lock_safe().state()
917 }
918
919 pub fn position(&self) -> f64 {
921 self.backend.lock_safe().position()
922 }
923
924 pub fn absolute_position(&self) -> f64 {
947 let native = self.backend.lock_safe().position().max(0.0);
948 let reported = self.reported_time.lock_safe().last_position();
949 let base = if self.is_background_audio_active() {
950 *self.background_audio_base.lock_safe()
951 } else {
952 0.0
953 };
954
955 native.max(reported).max(base)
956 }
957
958 pub fn observed_duration(&self) -> Option<f64> {
962 self.reported_time.lock_safe().last_duration()
963 }
964
965 fn clear_reported_time(&self) {
973 self.reported_time.lock_safe().reset();
974 }
975
976 #[cfg_attr(not(test), allow(dead_code))]
981 pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
982 *self.reports.lock_safe() = sink;
983 }
984
985 fn report(&self, operation: PlaybackOperation) {
990 let sink = self.reports.lock_safe().clone();
991 sink.send(operation);
992 }
993
994 fn current_jellyfin_id(&self) -> Option<String> {
996 let queue = self.queue.lock_safe();
997 queue
998 .current()
999 .and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
1000 }
1001
1002 pub fn duration(&self) -> Option<f64> {
1011 self.backend
1012 .lock_safe()
1013 .duration()
1014 .or_else(|| self.observed_duration())
1015 }
1016
1017 pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
1019 self.queue.clone()
1020 }
1021
1022 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
1030 pub fn current_is_audio_episode(&self) -> bool {
1031 self.queue
1032 .lock_safe()
1033 .current()
1034 .map(|item| {
1035 item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
1036 })
1037 .unwrap_or(false)
1038 }
1039
1040 pub fn clear_queue(&self) {
1044 self.queue.lock_safe().clear();
1045 }
1046
1047 pub fn toggle_shuffle(&self) {
1049 self.queue.lock_safe().toggle_shuffle();
1050 }
1051
1052 pub fn cycle_repeat(&self) {
1054 self.queue.lock_safe().cycle_repeat();
1055 }
1056
1057 pub fn is_shuffle(&self) -> bool {
1059 self.queue.lock_safe().is_shuffle()
1060 }
1061
1062 pub fn repeat_mode(&self) -> RepeatMode {
1064 self.queue.lock_safe().repeat_mode()
1065 }
1066
1067 pub fn volume(&self) -> f32 {
1069 self.backend.lock_safe().volume()
1070 }
1071
1072 pub fn muted(&self) -> bool {
1074 self.muted
1075 }
1076
1077 pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
1079 self.backend.lock_safe().set_audio_settings(settings)
1080 }
1081
1082 pub fn audio_settings(&self) -> AudioSettings {
1084 self.backend.lock_safe().audio_settings()
1085 }
1086
1087 pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
1091 let mut event_emitter = self.event_emitter.lock_safe();
1092 *event_emitter = Some(emitter);
1093 }
1094
1095 pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
1097 self.event_emitter.lock_safe().clone()
1098 }
1099
1100 pub fn sleep_timer_state(&self) -> SleepTimerState {
1102 self.sleep_timer.lock_safe().clone()
1103 }
1104
1105 pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
1107 let mut timer = self.sleep_timer.lock_safe();
1108 timer.mode = mode.clone();
1109 if let SleepTimerMode::Time { end_time } = mode {
1110 let now = chrono::Utc::now().timestamp_millis();
1111 timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
1112 } else {
1113 timer.remaining_seconds = 0;
1114 }
1115 drop(timer);
1116
1117 self.emit_sleep_timer_changed();
1119 }
1120
1121 pub fn cancel_sleep_timer(&self) {
1123 self.set_sleep_timer(SleepTimerMode::Off);
1124 }
1125
1126 fn start_timer_thread(&self) {
1128 let sleep_timer = self.sleep_timer.clone();
1129 let event_emitter = self.event_emitter.clone();
1130 let backend = self.backend.clone();
1131 let end_reason = self.end_reason.clone();
1132
1133 std::thread::spawn(move || {
1134 loop {
1135 std::thread::sleep(Duration::from_secs(1));
1136
1137 let mut timer = sleep_timer.lock_safe();
1138 if timer.is_active() {
1139 timer.update_remaining_seconds();
1140
1141 if matches!(timer.mode, SleepTimerMode::Time { .. })
1143 && timer.remaining_seconds == 0
1144 {
1145 debug!("[SleepTimer] Time-based timer expired, stopping playback");
1146 timer.cancel();
1147
1148 Self::note_sleep_timer_stop(&end_reason);
1155
1156 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1158 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1159 mode: SleepTimerMode::Off,
1160 remaining_seconds: 0,
1161 });
1162 emitter.emit(PlayerStatusEvent::SleepTimerExpired);
1166 }
1167 drop(timer);
1168
1169 if let Err(e) = backend.lock_safe().stop() {
1171 error!("[SleepTimer] Failed to stop playback: {}", e);
1172 }
1173 continue;
1174 }
1175
1176 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1178 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1179 mode: timer.mode.clone(),
1180 remaining_seconds: timer.remaining_seconds,
1181 });
1182 }
1183 }
1184 drop(timer);
1185 }
1186 });
1187 }
1188
1189 fn emit_sleep_timer_changed(&self) {
1191 let timer = self.sleep_timer.lock_safe().clone();
1192
1193 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1194 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1195 mode: timer.mode,
1196 remaining_seconds: timer.remaining_seconds,
1197 });
1198 }
1199 }
1200
1201 pub fn emit_queue_changed(&self) {
1203 let queue = self.queue.lock_safe();
1204
1205 debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
1206 queue.items().len(), queue.current_index());
1207
1208 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1209 emitter.emit(PlayerStatusEvent::QueueChanged {
1210 items: queue.items().to_vec(),
1211 current_index: queue.current_index(),
1212 shuffle: queue.is_shuffle(),
1213 repeat: queue.repeat_mode(),
1214 has_next: queue.has_next(),
1215 has_previous: queue.has_previous(),
1216 });
1217 } else {
1218 warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
1219 }
1220 }
1221
1222 pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
1237 if self.is_background_audio_active() {
1242 debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
1243 return;
1244 }
1245 let element_gone = {
1251 let mut tracked = self.html5_playing.lock_safe();
1252 *tracked = match state.as_str() {
1253 "playing" => Some(true),
1254 "paused" | "loading" => Some(false),
1258 _ => None,
1260 };
1261 tracked.is_none()
1262 };
1263 if element_gone {
1266 self.clear_reported_time();
1267 }
1268 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1269 emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
1270 }
1271 }
1272
1273 pub fn report_html5_position(&self, position: f64, duration: f64) {
1278 if self.is_background_audio_active() {
1281 return;
1282 }
1283 self.reported_time.lock_safe().record(position, duration);
1286
1287 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1288 emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
1289 }
1290
1291 self.report_progress_throttled(position);
1292 }
1293
1294 fn report_progress_throttled(&self, position: f64) {
1309 if position <= 0.0 {
1310 return;
1311 }
1312 let Some(item_id) = self.current_jellyfin_id() else {
1313 return;
1314 };
1315 if !self.position_throttler.should_report(&item_id) {
1316 return;
1317 }
1318
1319 self.report(PlaybackOperation::Progress {
1320 item_id: item_id.clone(),
1321 position_ticks: seconds_to_ticks(position),
1322 is_paused: false,
1325 });
1326 self.position_throttler.mark_reported(&item_id);
1327 }
1328
1329 pub fn report_html5_media_loaded(&self, duration: f64) {
1332 if self.is_background_audio_active() {
1334 return;
1335 }
1336 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1337 emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
1338 }
1339 }
1340
1341 pub fn autoplay_settings(&self) -> AutoplaySettings {
1345 self.autoplay_settings.lock_safe().clone()
1346 }
1347
1348 pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
1350 let validated = settings.with_validated_countdown();
1351 *self.autoplay_settings.lock_safe() = validated;
1352 }
1353
1354 pub fn cancel_autoplay_countdown(&self) {
1356 if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
1357 *cancel_flag.lock_safe() = true;
1358 }
1359 }
1360
1361 pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
1366 if let Some(position) = self.truncated_stream_resume_position() {
1372 return Ok(AutoplayDecision::ResumeStream { position });
1373 }
1374
1375 let end_reason = self.take_end_reason();
1377
1378 debug!(
1379 "[PlayerController] on_playback_ended: end_reason={:?}",
1380 end_reason
1381 );
1382
1383 match end_reason {
1385 None | Some(EndReason::Finished) => {
1386 debug!("[PlayerController] Track finished naturally, checking autoplay");
1388 }
1389 Some(EndReason::NewTrackLoaded) => {
1390 debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
1392 return Ok(AutoplayDecision::Stop);
1393 }
1394 Some(EndReason::UserStop) => {
1395 debug!("[PlayerController] UserStop - stopping without autoplay");
1397 return Ok(AutoplayDecision::Stop);
1398 }
1399 Some(EndReason::UserSkip) => {
1400 debug!("[PlayerController] UserSkip - stopping without autoplay");
1402 return Ok(AutoplayDecision::Stop);
1403 }
1404 Some(EndReason::Error) => {
1405 debug!("[PlayerController] Error - stopping without autoplay");
1407 return Ok(AutoplayDecision::Stop);
1408 }
1409 }
1410
1411 let current_item = {
1412 let queue = self.queue.lock_safe();
1413 queue.current().cloned()
1414 };
1415
1416 let Some(current) = current_item else {
1417 return Ok(AutoplayDecision::Stop);
1418 };
1419
1420 self.report_completion(¤t);
1424
1425 let timer_mode = {
1427 let timer = self.sleep_timer.lock_safe();
1428 timer.mode.clone()
1429 };
1430
1431 match &timer_mode {
1432 SleepTimerMode::Time { end_time } => {
1433 let now = chrono::Utc::now().timestamp_millis();
1435 if now >= *end_time {
1436 debug!("[PlayerController] Time-based sleep timer expired at track boundary");
1437 self.sleep_timer.lock_safe().cancel();
1438 self.emit_sleep_timer_changed();
1439 return Ok(AutoplayDecision::Stop);
1440 }
1441 }
1442 SleepTimerMode::EndOfTrack => {
1443 self.sleep_timer.lock_safe().cancel();
1445 self.emit_sleep_timer_changed();
1446 return Ok(AutoplayDecision::Stop);
1447 }
1448 SleepTimerMode::Episodes { .. } => {
1449 let is_episode = self.is_episode_item(¤t).await;
1454
1455 if is_episode {
1456 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
1457 self.emit_sleep_timer_changed();
1458
1459 if should_stop {
1460 return Ok(AutoplayDecision::Stop);
1461 }
1462 }
1463 }
1464 _ => {
1465 }
1467 }
1468
1469 if self.is_episode_item(¤t).await {
1475 let repo = self.repository.lock_safe().clone();
1476 let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
1477 let next_ep_result = if let Some(repo) = &repo {
1478 match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
1481 Ok(next) => next,
1482 Err(e) => {
1483 warn!(
1484 "[PlayerController] Next-episode lookup failed for {}: {}",
1485 jellyfin_id, e
1486 );
1487 None
1488 }
1489 }
1490 } else {
1491 warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
1492 None
1493 };
1494 if let Some(next_ep) = next_ep_result {
1495 let settings = self.autoplay_settings.lock_safe().clone();
1496
1497 let limit_reached = self.increment_autoplay_count();
1499 if limit_reached {
1500 debug!(
1501 "[PlayerController] Auto-play episode limit reached ({} episodes)",
1502 settings.max_episodes
1503 );
1504 }
1505
1506 return Ok(AutoplayDecision::ShowNextEpisodePopup {
1507 current_episode: next_ep.0, next_episode: next_ep.1,
1509 countdown_seconds: settings.countdown_seconds,
1510 auto_advance: settings.enabled && !limit_reached,
1511 });
1512 }
1513 return Ok(AutoplayDecision::Stop);
1515 }
1516
1517 let has_next = {
1519 let queue = self.queue.lock_safe();
1520 queue.has_next()
1521 };
1522
1523 if has_next {
1524 Ok(AutoplayDecision::AdvanceToNext)
1526 } else {
1527 Ok(AutoplayDecision::Stop)
1529 }
1530 }
1531
1532 fn report_completion(&self, item: &MediaItem) {
1553 if !Self::is_audio_only_video(item) {
1554 return;
1555 }
1556 let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
1557 return;
1558 };
1559
1560 let position = completion_report_position(item.duration, self.absolute_position());
1561 log::info!(
1562 "[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
1563 jellyfin_id,
1564 position
1565 );
1566 self.report_stopped_at(jellyfin_id, position);
1567 }
1568
1569 pub fn set_background_audio_base(&self, seconds: f64) {
1574 *self.background_audio_base.lock_safe() = seconds.max(0.0);
1575 }
1576
1577 pub fn enter_background_audio(&self, position: f64) {
1588 self.set_background_audio_base(position);
1589 *self.background_audio_active.lock_safe() = true;
1590 *self.html5_playing.lock_safe() = None;
1591 self.clear_reported_time();
1594 }
1595
1596 pub fn exit_background_audio(&self) -> f64 {
1604 *self.background_audio_active.lock_safe() = false;
1605 self.take_background_audio_base()
1606 }
1607
1608 pub fn is_background_audio_active(&self) -> bool {
1613 *self.background_audio_active.lock_safe()
1614 }
1615
1616 pub fn take_background_audio_base(&self) -> f64 {
1620 let mut base = self.background_audio_base.lock_safe();
1621 std::mem::replace(&mut *base, 0.0)
1622 }
1623
1624 pub async fn auto_advance_to_next_episode(
1635 &self,
1636 next_episode: crate::repository::types::MediaItem,
1637 countdown_seconds: u32,
1638 ) {
1639 if self.current_is_audio_episode() {
1644 info!(
1645 "[PlayerController] Background audio episode — advancing to {} in backend",
1646 next_episode.id
1647 );
1648 match self
1649 .advance_to_next_episode_audio_only(&next_episode.id)
1650 .await
1651 {
1652 Ok(()) => self.emit_queue_changed(),
1653 Err(e) => {
1654 error!(
1655 "[PlayerController] Background audio advance failed: {} — stopping",
1656 e
1657 );
1658 if let Some(emitter) = self.event_emitter() {
1659 emitter.emit(PlayerStatusEvent::PlaybackEnded);
1660 }
1661 }
1662 }
1663 return;
1664 }
1665
1666 self.start_autoplay_countdown(next_episode, countdown_seconds);
1668 }
1669
1670 fn is_audio_only_video(item: &MediaItem) -> bool {
1674 stream_end::is_audio_only_video(item)
1675 }
1676
1677 fn claim_stream_resume(&self) -> Option<(f64, u32)> {
1692 let current = {
1693 let queue = self.queue.lock_safe();
1694 queue.current().cloned()
1695 }?;
1696 if !matches!(current.source, MediaSource::Remote { .. }) {
1697 return None;
1698 }
1699
1700 let absolute = self.absolute_position();
1705
1706 match self.stream_resume.lock_safe().allow_attempt(absolute) {
1707 Some(attempt) => Some((absolute, attempt)),
1708 None => {
1709 warn!(
1710 "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
1711 current.id, absolute
1712 );
1713 None
1714 }
1715 }
1716 }
1717
1718 fn truncated_stream_resume_position(&self) -> Option<f64> {
1725 if matches!(
1727 self.peek_end_reason(),
1728 Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
1729 ) {
1730 return None;
1731 }
1732
1733 let item_duration = {
1737 let queue = self.queue.lock_safe();
1738 let current = queue.current()?;
1739 if !Self::is_audio_only_video(current) {
1740 return None;
1741 }
1742 current.duration
1743 };
1744 let absolute = self.position().max(0.0);
1746
1747 if !stream_end::is_truncated_end(
1750 absolute,
1751 item_duration,
1752 stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
1753 ) {
1754 return None;
1755 }
1756
1757 self.claim_stream_resume().map(|(position, _)| position)
1758 }
1759
1760 pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
1782 self.claim_stream_resume()
1783 .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
1784 }
1785
1786 pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
1813 let current = {
1814 let queue = self.queue.lock_safe();
1815 queue.current().cloned()
1816 }
1817 .ok_or_else(|| "No current item to resume".to_string())?;
1818
1819 let MediaSource::Remote { stream_url, .. } = ¤t.source else {
1820 return Err(format!(
1821 "Cannot resume a non-remote source for {}",
1822 current.id
1823 ));
1824 };
1825
1826 info!(
1827 "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
1828 current.id, position
1829 );
1830
1831 if !Self::is_audio_only_video(¤t) {
1832 self.load_and_play(¤t).map_err(|e| e.to_string())?;
1833 if position > 0.5 {
1834 self.seek(position).map_err(|e| e.to_string())?;
1835 }
1836 return Ok(());
1837 }
1838
1839 let restarted_url = stream_end::with_start_time(stream_url, position);
1840 {
1841 let queue_arc = self.queue.clone();
1842 let mut queue = queue_arc.lock_safe();
1843 if !queue.update_current_stream_url(restarted_url) {
1844 return Err(format!("Failed to update stream URL for {}", current.id));
1845 }
1846 }
1847 let resumed = {
1848 let queue = self.queue.lock_safe();
1849 queue.current().cloned()
1850 }
1851 .ok_or_else(|| "Current item vanished mid-resume".to_string())?;
1852
1853 self.set_background_audio_base(position);
1857 let _ = set_lockscreen_position_offset(position.max(0.0));
1858
1859 self.load_and_play(&resumed).map_err(|e| e.to_string())
1860 }
1861
1862 pub async fn advance_to_next_episode_audio_only(
1877 &self,
1878 next_episode_id: &str,
1879 ) -> Result<(), String> {
1880 let repo = self
1881 .repository
1882 .lock_safe()
1883 .clone()
1884 .ok_or_else(|| "No repository for background episode advance".to_string())?;
1885
1886 let next = repo
1888 .get_item(next_episode_id)
1889 .await
1890 .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
1891
1892 let stream_url = repo
1895 .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
1896 .await
1897 .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
1898
1899 let media_item = MediaItem {
1900 id: next.id.clone(),
1901 title: next.name.clone(),
1902 name: Some(next.name.clone()),
1903 artist: next.series_name.clone(),
1904 album: None,
1905 album_name: None,
1906 album_id: None,
1907 artist_items: None,
1908 artists: None,
1909 primary_image_tag: next.primary_image_tag.clone(),
1910 image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
1911 item_type: Some("Episode".to_string()),
1913 playlist_id: None,
1914 duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
1915 artwork_url: None,
1916 media_type: MediaType::Audio,
1917 source: MediaSource::Remote {
1918 stream_url,
1919 jellyfin_item_id: next.id.clone(),
1920 },
1921 video_codec: None,
1922 needs_transcoding: false,
1923 video_width: None,
1924 video_height: None,
1925 subtitles: vec![],
1926 series_id: next.series_id.clone(),
1927 server_id: Some(next.server_id.clone()),
1928 };
1929
1930 self.set_background_audio_base(0.0);
1935 let _ = set_lockscreen_position_offset(0.0);
1936 self.stream_resume.lock_safe().reset();
1939
1940 self.play_item(media_item).map_err(|e| e.to_string())
1941 }
1942
1943 pub async fn on_video_playback_ended(
1950 &self,
1951 item_id: &str,
1952 repo: Arc<dyn crate::repository::MediaRepository>,
1953 ) -> Result<AutoplayDecision, String> {
1954 let stale_reason = self.take_end_reason();
1956 if stale_reason.is_some() {
1957 debug!(
1958 "[PlayerController] Cleared stale end_reason for video: {:?}",
1959 stale_reason
1960 );
1961 }
1962
1963 log::info!(
1964 "[PlayerController] on_video_playback_ended: item_id={}",
1965 item_id
1966 );
1967
1968 let timer_mode = {
1970 let timer = self.sleep_timer.lock_safe();
1971 timer.mode.clone()
1972 };
1973
1974 match &timer_mode {
1975 SleepTimerMode::Time { end_time } => {
1976 let now = chrono::Utc::now().timestamp_millis();
1977 if now >= *end_time {
1978 debug!("[PlayerController] Time-based sleep timer expired at video end");
1979 self.sleep_timer.lock_safe().cancel();
1980 self.emit_sleep_timer_changed();
1981 return Ok(AutoplayDecision::Stop);
1982 }
1983 }
1984 SleepTimerMode::EndOfTrack => {
1985 self.sleep_timer.lock_safe().cancel();
1986 self.emit_sleep_timer_changed();
1987 return Ok(AutoplayDecision::Stop);
1988 }
1989 SleepTimerMode::Episodes { .. } => {
1990 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
1991 self.emit_sleep_timer_changed();
1992 if should_stop {
1993 return Ok(AutoplayDecision::Stop);
1994 }
1995 }
1996 _ => {}
1997 }
1998
1999 let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
2003 Ok(next) => next,
2004 Err(e) => {
2005 warn!(
2006 "[PlayerController] Next-episode lookup failed for {}: {}",
2007 item_id, e
2008 );
2009 None
2010 }
2011 };
2012 if let Some(next_ep) = next_ep_result {
2013 let settings = self.autoplay_settings.lock_safe().clone();
2014
2015 let limit_reached = self.increment_autoplay_count();
2016 if limit_reached {
2017 debug!(
2018 "[PlayerController] Auto-play episode limit reached ({} episodes)",
2019 settings.max_episodes
2020 );
2021 }
2022
2023 return Ok(AutoplayDecision::ShowNextEpisodePopup {
2024 current_episode: next_ep.0,
2025 next_episode: next_ep.1,
2026 countdown_seconds: settings.countdown_seconds,
2027 auto_advance: settings.enabled && !limit_reached,
2028 });
2029 }
2030
2031 debug!("[PlayerController] No next episode found for {}", item_id);
2033 Ok(AutoplayDecision::Stop)
2034 }
2035
2036 async fn is_episode_item(&self, item: &MediaItem) -> bool {
2044 match item.item_type.as_deref() {
2045 Some("Episode") => true,
2046 Some(_) => item.media_type == MediaType::Video,
2047 None => item.media_type == MediaType::Video,
2048 }
2049 }
2050
2051 async fn fetch_next_episode_for_item(
2058 &self,
2059 item_id: &str,
2060 repo: &Arc<dyn crate::repository::MediaRepository>,
2061 ) -> Result<
2062 Option<(
2063 crate::repository::types::MediaItem,
2064 crate::repository::types::MediaItem,
2065 )>,
2066 String,
2067 > {
2068 use crate::repository::types::GetItemsOptions;
2069
2070 let current_repo_item = repo
2072 .get_item(item_id)
2073 .await
2074 .map_err(|e| format!("Failed to get current item: {}", e))?;
2075
2076 let season_id = match ¤t_repo_item.season_id {
2078 Some(sid) => sid.clone(),
2079 None => {
2080 log::info!(
2081 "[PlayerController] Current item has no season_id, cannot find next episode"
2082 );
2083 return Ok(None);
2084 }
2085 };
2086
2087 let options = GetItemsOptions {
2089 sort_by: Some("IndexNumber".to_string()),
2090 sort_order: Some("Ascending".to_string()),
2091 limit: Some(500),
2092 include_item_types: Some(vec!["Episode".to_string()]),
2093 ..Default::default()
2094 };
2095
2096 let result = repo
2097 .get_items(&season_id, Some(options))
2098 .await
2099 .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
2100
2101 let mut episodes = result.items;
2104 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2105 log::info!(
2106 "[PlayerController] Season has {} episodes, looking for next after {}",
2107 episodes.len(),
2108 current_repo_item.id
2109 );
2110
2111 if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
2113 if current_idx + 1 < episodes.len() {
2114 let next = &episodes[current_idx + 1];
2115 log::info!(
2116 "[PlayerController] Found next episode: {} (index {})",
2117 next.name,
2118 current_idx + 1
2119 );
2120 return Ok(Some((current_repo_item, next.clone())));
2121 } else {
2122 log::info!("[PlayerController] Current episode is the last in the season");
2123 }
2124 } else {
2125 log::info!(
2126 "[PlayerController] Current episode not found in season episodes (ids: {:?})",
2127 episodes
2128 .iter()
2129 .map(|e| e.id.as_str())
2130 .take(20)
2131 .collect::<Vec<_>>()
2132 );
2133 }
2134
2135 Ok(None)
2136 }
2137
2138 pub fn start_autoplay_countdown(
2140 &self,
2141 _next_item: crate::repository::types::MediaItem,
2142 countdown_seconds: u32,
2143 ) {
2144 let cancel_flag = Arc::new(Mutex::new(false));
2146 *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
2147
2148 let event_emitter = self.event_emitter.clone();
2149
2150 std::thread::spawn(move || {
2151 let mut remaining = countdown_seconds;
2152
2153 while remaining > 0 {
2154 std::thread::sleep(Duration::from_secs(1));
2155
2156 if *cancel_flag.lock_safe() {
2158 log::info!("[PlayerController] Autoplay countdown cancelled");
2159 return;
2160 }
2161
2162 remaining -= 1;
2163
2164 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
2166 emitter.emit(PlayerStatusEvent::CountdownTick {
2167 remaining_seconds: remaining,
2168 });
2169 }
2170 }
2171
2172 log::info!("[PlayerController] Autoplay countdown finished");
2174 });
2175 }
2176}
2177
2178impl Default for PlayerController {
2179 fn default() -> Self {
2180 let playback_reporter = Arc::new(TokioMutex::new(None));
2181 let position_throttler = Arc::new(EventThrottler::new());
2182 Self::new(
2183 Box::new(NullBackend::new()),
2184 playback_reporter,
2185 position_throttler,
2186 )
2187 }
2188}
2189
2190#[cfg(test)]
2191mod tests {
2192 use super::*;
2193
2194 struct CapturingEmitter {
2197 events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
2198 }
2199
2200 impl CapturingEmitter {
2201 fn new() -> Self {
2202 Self {
2203 events: std::sync::Mutex::new(Vec::new()),
2204 }
2205 }
2206 fn events(&self) -> Vec<PlayerStatusEvent> {
2207 self.events.lock_safe().clone()
2208 }
2209 }
2210
2211 impl PlayerEventEmitter for CapturingEmitter {
2212 fn emit(&self, event: PlayerStatusEvent) {
2213 self.events.lock_safe().push(event);
2214 }
2215 }
2216
2217 struct CapturingReports {
2220 operations: std::sync::Mutex<Vec<PlaybackOperation>>,
2221 }
2222
2223 impl CapturingReports {
2224 fn new() -> Self {
2225 Self {
2226 operations: std::sync::Mutex::new(Vec::new()),
2227 }
2228 }
2229
2230 fn stops(&self) -> Vec<(String, f64)> {
2232 self.operations
2233 .lock_safe()
2234 .iter()
2235 .filter_map(|op| match op {
2236 PlaybackOperation::Stopped {
2237 item_id,
2238 position_ticks,
2239 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2240 _ => None,
2241 })
2242 .collect()
2243 }
2244
2245 fn progress(&self) -> Vec<(String, f64)> {
2247 self.operations
2248 .lock_safe()
2249 .iter()
2250 .filter_map(|op| match op {
2251 PlaybackOperation::Progress {
2252 item_id,
2253 position_ticks,
2254 ..
2255 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2256 _ => None,
2257 })
2258 .collect()
2259 }
2260 }
2261
2262 impl PlaybackReportSink for CapturingReports {
2263 fn send(&self, operation: PlaybackOperation) {
2264 self.operations.lock_safe().push(operation);
2265 }
2266 }
2267
2268 #[test]
2269 fn test_report_html5_state_emits_state_changed() {
2270 let controller = PlayerController::default();
2271 let emitter = Arc::new(CapturingEmitter::new());
2272 controller.set_event_emitter(emitter.clone());
2273
2274 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2275
2276 let events = emitter.events();
2277 assert_eq!(events.len(), 1);
2278 match &events[0] {
2279 PlayerStatusEvent::StateChanged { state, media_id } => {
2280 assert_eq!(state, "playing");
2281 assert_eq!(media_id.as_deref(), Some("item-1"));
2282 }
2283 other => panic!("expected StateChanged, got {:?}", other),
2284 }
2285 }
2286
2287 #[test]
2288 fn test_report_html5_position_emits_position_update() {
2289 let controller = PlayerController::default();
2290 let emitter = Arc::new(CapturingEmitter::new());
2291 controller.set_event_emitter(emitter.clone());
2292
2293 controller.report_html5_position(12.5, 300.0);
2294
2295 let events = emitter.events();
2296 assert_eq!(events.len(), 1);
2297 match &events[0] {
2298 PlayerStatusEvent::PositionUpdate { position, duration } => {
2299 assert_eq!(*position, 12.5);
2300 assert_eq!(*duration, 300.0);
2301 }
2302 other => panic!("expected PositionUpdate, got {:?}", other),
2303 }
2304 }
2305
2306 #[test]
2307 fn test_report_html5_media_loaded_emits_media_loaded() {
2308 let controller = PlayerController::default();
2309 let emitter = Arc::new(CapturingEmitter::new());
2310 controller.set_event_emitter(emitter.clone());
2311
2312 controller.report_html5_media_loaded(420.0);
2313
2314 let events = emitter.events();
2315 assert_eq!(events.len(), 1);
2316 match &events[0] {
2317 PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
2318 other => panic!("expected MediaLoaded, got {:?}", other),
2319 }
2320 }
2321
2322 #[test]
2332 fn test_html5_state_is_tracked_from_reports() {
2333 let controller = PlayerController::default();
2334 let emitter = Arc::new(CapturingEmitter::new());
2335 controller.set_event_emitter(emitter.clone());
2336
2337 assert!(!controller.is_html5_active());
2339
2340 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2341 assert!(controller.is_html5_active());
2342 assert!(controller.html5_is_playing());
2343
2344 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2345 assert!(controller.is_html5_active());
2346 assert!(!controller.html5_is_playing());
2347 }
2348
2349 #[test]
2350 fn test_html5_toggle_from_paused_emits_play_control() {
2351 let controller = PlayerController::default();
2352 let emitter = Arc::new(CapturingEmitter::new());
2353 controller.set_event_emitter(emitter.clone());
2354 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2355
2356 controller.toggle_playback().unwrap();
2357
2358 let controls: Vec<_> = emitter
2359 .events()
2360 .into_iter()
2361 .filter_map(|e| match e {
2362 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2363 _ => None,
2364 })
2365 .collect();
2366 assert_eq!(controls, vec!["play".to_string()]);
2367 }
2368
2369 #[test]
2370 fn test_html5_toggle_from_playing_emits_pause_control() {
2371 let controller = PlayerController::default();
2372 let emitter = Arc::new(CapturingEmitter::new());
2373 controller.set_event_emitter(emitter.clone());
2374 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2375
2376 controller.toggle_playback().unwrap();
2377
2378 let controls: Vec<_> = emitter
2379 .events()
2380 .into_iter()
2381 .filter_map(|e| match e {
2382 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2383 _ => None,
2384 })
2385 .collect();
2386 assert_eq!(controls, vec!["pause".to_string()]);
2387 }
2388
2389 #[test]
2390 fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
2391 let controller = PlayerController::default();
2396 let emitter = Arc::new(CapturingEmitter::new());
2397 controller.set_event_emitter(emitter.clone());
2398 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2399
2400 controller.toggle_playback().unwrap();
2401 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2403 controller.toggle_playback().unwrap();
2404
2405 let controls: Vec<_> = emitter
2406 .events()
2407 .into_iter()
2408 .filter_map(|e| match e {
2409 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2410 _ => None,
2411 })
2412 .collect();
2413 assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
2414 }
2415
2416 #[test]
2417 fn test_html5_play_and_pause_emit_control_commands() {
2418 let controller = PlayerController::default();
2419 let emitter = Arc::new(CapturingEmitter::new());
2420 controller.set_event_emitter(emitter.clone());
2421 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2422
2423 controller.play().unwrap();
2424 controller.pause().unwrap();
2425
2426 let controls: Vec<_> = emitter
2427 .events()
2428 .into_iter()
2429 .filter_map(|e| match e {
2430 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2431 _ => None,
2432 })
2433 .collect();
2434 assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
2435 }
2436
2437 #[test]
2438 fn test_background_audio_handoff_moves_transport_to_native_backend() {
2439 let controller = PlayerController::default();
2447 let emitter = Arc::new(CapturingEmitter::new());
2448 controller.set_event_emitter(emitter.clone());
2449
2450 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2452 assert!(controller.is_html5_active());
2453
2454 controller.enter_background_audio(1200.0);
2456 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2457
2458 assert!(
2459 !controller.is_html5_active(),
2460 "native audio owns transport during a background-audio handoff"
2461 );
2462
2463 controller.pause().unwrap();
2464 let controls: Vec<_> = emitter
2465 .events()
2466 .into_iter()
2467 .filter_map(|e| match e {
2468 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2469 _ => None,
2470 })
2471 .collect();
2472 assert!(
2473 controls.is_empty(),
2474 "pause must drive the native backend, not a torn-down element: {:?}",
2475 controls
2476 );
2477 }
2478
2479 #[test]
2480 fn test_background_audio_handoff_suppresses_stale_element_events() {
2481 let controller = PlayerController::default();
2485 let emitter = Arc::new(CapturingEmitter::new());
2486 controller.set_event_emitter(emitter.clone());
2487
2488 controller.enter_background_audio(1200.0);
2489 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2490 controller.report_html5_position(1200.0, 2400.0);
2491
2492 assert!(
2493 emitter.events().is_empty(),
2494 "stale webview reports must not reach the event pipeline: {:?}",
2495 emitter.events()
2496 );
2497 }
2498
2499 #[test]
2500 fn test_exit_background_audio_returns_transport_to_the_webview() {
2501 let controller = PlayerController::default();
2504 let emitter = Arc::new(CapturingEmitter::new());
2505 controller.set_event_emitter(emitter.clone());
2506
2507 controller.enter_background_audio(1200.0);
2508 assert_eq!(controller.exit_background_audio(), 1200.0);
2509
2510 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2511 assert!(controller.is_html5_active());
2512 assert!(controller.html5_is_playing());
2513 }
2514
2515 #[test]
2516 fn test_html5_stopped_report_releases_transport_to_native_backend() {
2517 let controller = PlayerController::default();
2520 let emitter = Arc::new(CapturingEmitter::new());
2521 controller.set_event_emitter(emitter.clone());
2522
2523 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2524 assert!(controller.is_html5_active());
2525
2526 controller.report_html5_state("stopped".to_string(), None);
2527 assert!(!controller.is_html5_active());
2528 }
2529
2530 #[test]
2531 fn test_html5_transport_emits_exactly_one_control_per_intent() {
2532 let controller = PlayerController::default();
2536 let emitter = Arc::new(CapturingEmitter::new());
2537 controller.set_event_emitter(emitter.clone());
2538 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2539
2540 controller.pause().unwrap();
2541
2542 let controls = emitter
2543 .events()
2544 .into_iter()
2545 .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
2546 .count();
2547 assert_eq!(controls, 1, "one intent must produce exactly one control");
2548 }
2549
2550 #[test]
2551 fn test_controller_volume_default() {
2552 let controller = PlayerController::default();
2553 assert_eq!(controller.volume(), 1.0);
2554 }
2555
2556 #[test]
2557 fn test_controller_set_volume() {
2558 let controller = PlayerController::default();
2559 controller.set_volume(0.5).unwrap();
2560 assert_eq!(controller.volume(), 0.5);
2561 }
2562
2563 #[test]
2564 fn test_controller_muted_default() {
2565 let controller = PlayerController::default();
2566 assert!(!controller.muted());
2567 }
2568
2569 #[test]
2570 fn test_controller_volume_delegates_to_backend() {
2571 let controller = PlayerController::default();
2572
2573 controller.set_volume(0.75).unwrap();
2575
2576 assert_eq!(controller.volume(), 0.75);
2578 }
2579
2580 fn create_test_items(count: usize) -> Vec<MediaItem> {
2581 (0..count)
2582 .map(|i| MediaItem {
2583 id: format!("item_{}", i),
2584 title: format!("Track {}", i + 1),
2585 name: Some(format!("Track {}", i + 1)),
2586 artist: Some("Test Artist".to_string()),
2587 album: Some("Test Album".to_string()),
2588 album_name: Some("Test Album".to_string()),
2589 album_id: None,
2590 artist_items: None,
2591 artists: Some(vec!["Test Artist".to_string()]),
2592 primary_image_tag: None,
2593 image_id: None,
2594 item_type: Some("Audio".to_string()),
2595 playlist_id: None,
2596 duration: Some(180.0),
2597 artwork_url: None,
2598 media_type: MediaType::Audio,
2599 source: MediaSource::DirectUrl {
2600 url: format!("http://example.com/track_{}.mp3", i),
2601 },
2602 video_codec: None,
2603 needs_transcoding: false,
2604 video_width: None,
2605 video_height: None,
2606 subtitles: vec![],
2607 series_id: None,
2608 server_id: None,
2609 })
2610 .collect()
2611 }
2612
2613 #[test]
2614 fn test_skip_preserves_queue() {
2615 let controller = PlayerController::default();
2616
2617 let items = create_test_items(5);
2619 let items_clone = items.clone();
2620
2621 controller.play_queue(items, 0).unwrap();
2623
2624 {
2626 let queue = controller.queue();
2627 let queue_lock = queue.lock_safe();
2628 assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
2629 assert_eq!(
2630 queue_lock.current_index(),
2631 Some(0),
2632 "Should start at index 0"
2633 );
2634 assert_eq!(
2635 queue_lock.current().unwrap().id,
2636 "item_0",
2637 "Current item should be item_0"
2638 );
2639 }
2640
2641 controller.next().unwrap();
2643
2644 {
2646 let queue = controller.queue();
2647 let queue_lock = queue.lock_safe();
2648 assert_eq!(
2649 queue_lock.items().len(),
2650 5,
2651 "Queue should still have 5 items after skip"
2652 );
2653 assert_eq!(
2654 queue_lock.current_index(),
2655 Some(1),
2656 "Index should advance to 1"
2657 );
2658 assert_eq!(
2659 queue_lock.current().unwrap().id,
2660 "item_1",
2661 "Current item should be item_1"
2662 );
2663
2664 let current_items = queue_lock.items();
2666 for (i, original) in items_clone.iter().enumerate() {
2667 assert_eq!(
2668 current_items[i].id, original.id,
2669 "Item {} should still be in queue",
2670 i
2671 );
2672 assert_eq!(
2673 current_items[i].title, original.title,
2674 "Item {} title should be unchanged",
2675 i
2676 );
2677 }
2678 }
2679
2680 controller.next().unwrap();
2682
2683 {
2685 let queue = controller.queue();
2686 let queue_lock = queue.lock_safe();
2687 assert_eq!(
2688 queue_lock.items().len(),
2689 5,
2690 "Queue should still have 5 items after second skip"
2691 );
2692 assert_eq!(
2693 queue_lock.current_index(),
2694 Some(2),
2695 "Index should advance to 2"
2696 );
2697 assert_eq!(
2698 queue_lock.current().unwrap().id,
2699 "item_2",
2700 "Current item should be item_2"
2701 );
2702 }
2703
2704 controller.next().unwrap(); controller.next().unwrap(); {
2710 let queue = controller.queue();
2711 let queue_lock = queue.lock_safe();
2712 assert_eq!(
2713 queue_lock.items().len(),
2714 5,
2715 "Queue should still have 5 items at end"
2716 );
2717 assert_eq!(
2718 queue_lock.current_index(),
2719 Some(4),
2720 "Index should be at last item (4)"
2721 );
2722 assert_eq!(
2723 queue_lock.current().unwrap().id,
2724 "item_4",
2725 "Current item should be item_4"
2726 );
2727 }
2728 }
2729
2730 #[test]
2731 fn test_skip_at_end_without_repeat() {
2732 let controller = PlayerController::default();
2733
2734 let items = create_test_items(3);
2736 controller.play_queue(items, 0).unwrap();
2737
2738 controller.next().unwrap(); controller.next().unwrap(); {
2744 let queue = controller.queue();
2745 let queue_lock = queue.lock_safe();
2746 assert_eq!(
2747 queue_lock.current_index(),
2748 Some(2),
2749 "Should be at last item"
2750 );
2751 }
2752
2753 controller.next().unwrap();
2756
2757 {
2759 let queue = controller.queue();
2760 let queue_lock = queue.lock_safe();
2761 assert_eq!(
2762 queue_lock.items().len(),
2763 3,
2764 "Queue should still have 3 items after skip at end"
2765 );
2766 }
2770 }
2771
2772 #[test]
2773 fn test_skip_with_repeat_all() {
2774 let controller = PlayerController::default();
2775
2776 let items = create_test_items(3);
2778 controller.play_queue(items, 0).unwrap();
2779
2780 controller.cycle_repeat();
2782
2783 controller.next().unwrap(); controller.next().unwrap(); controller.next().unwrap();
2789
2790 {
2792 let queue = controller.queue();
2793 let queue_lock = queue.lock_safe();
2794 assert_eq!(
2795 queue_lock.items().len(),
2796 3,
2797 "Queue should still have 3 items"
2798 );
2799 assert_eq!(
2800 queue_lock.current_index(),
2801 Some(0),
2802 "Should wrap to index 0"
2803 );
2804 assert_eq!(
2805 queue_lock.current().unwrap().id,
2806 "item_0",
2807 "Should be back at item_0"
2808 );
2809 }
2810 }
2811
2812 #[test]
2813 fn test_previous_preserves_queue() {
2814 let controller = PlayerController::default();
2815
2816 let items = create_test_items(5);
2818 let items_clone = items.clone();
2819 controller.play_queue(items, 3).unwrap();
2820
2821 {
2823 let queue = controller.queue();
2824 let queue_lock = queue.lock_safe();
2825 assert_eq!(
2826 queue_lock.current_index(),
2827 Some(3),
2828 "Should start at index 3"
2829 );
2830 }
2831
2832 controller.previous().unwrap();
2834
2835 {
2837 let queue = controller.queue();
2838 let queue_lock = queue.lock_safe();
2839 assert_eq!(
2840 queue_lock.items().len(),
2841 5,
2842 "Queue should still have 5 items after previous"
2843 );
2844 assert_eq!(
2845 queue_lock.current_index(),
2846 Some(2),
2847 "Index should move to 2"
2848 );
2849 assert_eq!(
2850 queue_lock.current().unwrap().id,
2851 "item_2",
2852 "Current item should be item_2"
2853 );
2854
2855 let current_items = queue_lock.items();
2857 for (i, original) in items_clone.iter().enumerate() {
2858 assert_eq!(
2859 current_items[i].id, original.id,
2860 "Item {} should still be in queue",
2861 i
2862 );
2863 }
2864 }
2865 }
2866
2867 #[test]
2868 fn test_seek_updates_position() {
2869 let controller = PlayerController::default();
2870
2871 let item = create_test_items(1).into_iter().next().unwrap();
2873 controller.play_item(item).unwrap();
2874
2875 assert_eq!(controller.position(), 0.0, "Initial position should be 0");
2877
2878 controller.seek(30.0).unwrap();
2880 assert_eq!(
2881 controller.position(),
2882 30.0,
2883 "Position should be 30 after seeking"
2884 );
2885
2886 controller.seek(60.0).unwrap();
2888 assert_eq!(
2889 controller.position(),
2890 60.0,
2891 "Position should be 60 after seeking"
2892 );
2893
2894 controller.seek(15.0).unwrap();
2896 assert_eq!(
2897 controller.position(),
2898 15.0,
2899 "Position should be 15 after seeking backward"
2900 );
2901 }
2902
2903 #[test]
2904 fn test_seek_while_paused() {
2905 let controller = PlayerController::default();
2906
2907 let item = create_test_items(1).into_iter().next().unwrap();
2909 controller.play_item(item).unwrap();
2910
2911 controller.pause().unwrap();
2913
2914 assert!(controller.state().is_paused(), "Should be paused");
2916
2917 controller.seek(45.0).unwrap();
2919 assert_eq!(
2920 controller.position(),
2921 45.0,
2922 "Position should update while paused"
2923 );
2924
2925 assert!(
2927 controller.state().is_paused(),
2928 "Should still be paused after seeking"
2929 );
2930 }
2931
2932 #[test]
2933 fn test_seek_while_playing() {
2934 let controller = PlayerController::default();
2935
2936 let item = create_test_items(1).into_iter().next().unwrap();
2938 controller.play_item(item).unwrap();
2939
2940 controller.play().unwrap();
2942
2943 assert!(controller.state().is_playing(), "Should be playing");
2945
2946 controller.seek(20.0).unwrap();
2948 assert_eq!(
2949 controller.position(),
2950 20.0,
2951 "Position should update while playing"
2952 );
2953
2954 assert!(
2956 controller.state().is_playing(),
2957 "Should still be playing after seeking"
2958 );
2959 }
2960
2961 #[test]
2962 fn test_multiple_sequential_seeks() {
2963 let controller = PlayerController::default();
2964
2965 let item = create_test_items(1).into_iter().next().unwrap();
2966 controller.play_item(item).unwrap();
2967
2968 let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
2970
2971 for pos in positions {
2972 controller.seek(pos).unwrap();
2973 assert_eq!(
2974 controller.position(),
2975 pos,
2976 "Position should match after seeking to {}",
2977 pos
2978 );
2979 }
2980 }
2981
2982 #[test]
2986 fn test_play_queue_from_resumes_at_position() {
2987 let controller = PlayerController::default();
2988 let items = create_test_items(3);
2989
2990 controller.play_queue_from(items, 1, Some(42.5)).unwrap();
2991
2992 {
2993 let queue = controller.queue();
2994 let queue_lock = queue.lock_safe();
2995 assert_eq!(
2996 queue_lock.current_index(),
2997 Some(1),
2998 "Should start at index 1"
2999 );
3000 }
3001 assert_eq!(
3002 controller.position(),
3003 42.5,
3004 "Should resume at the requested position"
3005 );
3006 }
3007
3008 #[test]
3010 fn test_play_queue_from_without_position_starts_at_zero() {
3011 let controller = PlayerController::default();
3012
3013 controller
3014 .play_queue_from(create_test_items(2), 0, None)
3015 .unwrap();
3016 assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
3017
3018 controller
3019 .play_queue_from(create_test_items(2), 0, Some(0.2))
3020 .unwrap();
3021 assert_eq!(
3022 controller.position(),
3023 0.0,
3024 "Sub-threshold resume position is ignored (starts at 0)"
3025 );
3026 }
3027
3028 #[test]
3029 fn test_seek_to_zero() {
3030 let controller = PlayerController::default();
3031
3032 let item = create_test_items(1).into_iter().next().unwrap();
3033 controller.play_item(item).unwrap();
3034
3035 controller.seek(60.0).unwrap();
3037 assert_eq!(controller.position(), 60.0);
3038
3039 controller.seek(0.0).unwrap();
3041 assert_eq!(
3042 controller.position(),
3043 0.0,
3044 "Should be able to seek to position 0"
3045 );
3046 }
3047
3048 #[tokio::test]
3050 async fn test_audio_with_next_advances() {
3051 let controller = PlayerController::default();
3052
3053 let items = create_test_items(2);
3055 controller.play_queue(items, 0).unwrap();
3056
3057 controller.take_end_reason();
3059
3060 let decision = controller.on_playback_ended().await.unwrap();
3062
3063 assert!(
3065 matches!(decision, AutoplayDecision::AdvanceToNext),
3066 "Expected AdvanceToNext decision when queue has next item"
3067 );
3068 }
3069
3070 #[tokio::test]
3071 async fn test_audio_at_end_stops() {
3072 let controller = PlayerController::default();
3073
3074 let items = create_test_items(2);
3076 controller.play_queue(items, 1).unwrap();
3077
3078 controller.take_end_reason();
3080
3081 let decision = controller.on_playback_ended().await.unwrap();
3083
3084 assert!(
3086 matches!(decision, AutoplayDecision::Stop),
3087 "Expected Stop decision when at end of queue without repeat"
3088 );
3089 }
3090
3091 #[tokio::test]
3092 async fn test_sleep_timer_end_of_track() {
3093 let controller = PlayerController::default();
3094
3095 let items = create_test_items(3);
3097 controller.play_queue(items, 0).unwrap();
3098
3099 controller.take_end_reason();
3101
3102 {
3104 let mut timer = controller.sleep_timer.lock_safe();
3105 timer.mode = SleepTimerMode::EndOfTrack;
3106 }
3107
3108 let decision = controller.on_playback_ended().await.unwrap();
3110
3111 assert!(
3113 matches!(decision, AutoplayDecision::Stop),
3114 "Expected Stop decision when sleep timer is EndOfTrack"
3115 );
3116
3117 {
3119 let timer = controller.sleep_timer.lock_safe();
3120 assert!(
3121 matches!(timer.mode, SleepTimerMode::Off),
3122 "Sleep timer should be cancelled after EndOfTrack"
3123 );
3124 }
3125 }
3126
3127 #[tokio::test]
3136 async fn test_expired_time_sleep_timer_stops_without_autoplay() {
3137 let controller = PlayerController::default();
3138
3139 let items = create_test_items(3);
3140 controller.play_queue(items, 0).unwrap();
3141 controller.take_end_reason();
3142
3143 let now = chrono::Utc::now().timestamp_millis();
3148 controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
3149
3150 for _ in 0..40 {
3152 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3153 if !controller.sleep_timer.lock_safe().is_active() {
3154 break;
3155 }
3156 }
3157 assert!(
3158 !controller.sleep_timer.lock_safe().is_active(),
3159 "Timer thread should have expired and cancelled the sleep timer"
3160 );
3161
3162 let decision = controller.on_playback_ended().await.unwrap();
3164
3165 assert!(
3166 matches!(decision, AutoplayDecision::Stop),
3167 "Expected Stop after an expired time-based sleep timer, got {:?}",
3168 decision
3169 );
3170 }
3171
3172 #[tokio::test]
3173 async fn test_empty_queue_stops() {
3174 let controller = PlayerController::default();
3175
3176 let decision = controller.on_playback_ended().await.unwrap();
3178
3179 assert!(
3181 matches!(decision, AutoplayDecision::Stop),
3182 "Expected Stop decision when queue is empty"
3183 );
3184 }
3185
3186 #[tokio::test]
3187 async fn test_repeat_all_advances_at_end() {
3188 let controller = PlayerController::default();
3189
3190 let items = create_test_items(2);
3192 controller.play_queue(items, 1).unwrap(); controller.cycle_repeat(); controller.take_end_reason();
3197
3198 let decision = controller.on_playback_ended().await.unwrap();
3200
3201 assert!(
3203 matches!(decision, AutoplayDecision::AdvanceToNext),
3204 "Expected AdvanceToNext decision at end of queue with repeat all"
3205 );
3206 }
3207
3208 #[tokio::test]
3209 async fn test_repeat_one_advances() {
3210 let controller = PlayerController::default();
3211
3212 let items = create_test_items(2);
3214 controller.play_queue(items, 0).unwrap();
3215
3216 controller.cycle_repeat(); controller.cycle_repeat(); controller.take_end_reason();
3222
3223 let decision = controller.on_playback_ended().await.unwrap();
3225
3226 assert!(
3228 matches!(decision, AutoplayDecision::AdvanceToNext),
3229 "Expected AdvanceToNext decision with repeat one (repeats same track)"
3230 );
3231 }
3232
3233 #[test]
3234 fn test_native_load_returns_transport_authority_to_the_backend() {
3235 let controller = PlayerController::default();
3254 let emitter = Arc::new(CapturingEmitter::new());
3255 controller.set_event_emitter(emitter.clone());
3256
3257 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
3259 assert!(controller.is_html5_active());
3260
3261 let item = create_test_items(1).into_iter().next().unwrap();
3263 controller.play_item(item).unwrap();
3264
3265 assert!(
3266 !controller.is_html5_active(),
3267 "loading into the native backend hands transport back to it"
3268 );
3269
3270 controller.toggle_playback().unwrap();
3272 let controls: Vec<_> = emitter
3273 .events()
3274 .into_iter()
3275 .filter_map(|e| match e {
3276 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
3277 _ => None,
3278 })
3279 .collect();
3280 assert!(
3281 controls.is_empty(),
3282 "transport went to a webview element that is not rendering: {controls:?}"
3283 );
3284 }
3285
3286 #[test]
3288 fn test_load_and_play_sets_new_track_loaded() {
3289 let controller = PlayerController::default();
3290 let item = create_test_items(1).into_iter().next().unwrap();
3291
3292 assert!(controller.take_end_reason().is_none());
3294
3295 controller.load_and_play(&item).unwrap();
3297
3298 let reason = controller.take_end_reason();
3300 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3301 }
3302
3303 #[test]
3304 fn test_stop_sets_user_stop() {
3305 let controller = PlayerController::default();
3306 let item = create_test_items(1).into_iter().next().unwrap();
3307
3308 controller.play_item(item).unwrap();
3310
3311 controller.take_end_reason();
3313
3314 controller.stop().unwrap();
3316
3317 let reason = controller.take_end_reason();
3319 assert_eq!(reason, Some(EndReason::UserStop));
3320 }
3321
3322 #[tokio::test]
3323 async fn test_on_playback_ended_with_new_track_loaded_stops() {
3324 let controller = PlayerController::default();
3325
3326 let items = create_test_items(2);
3328 controller.play_queue(items, 0).unwrap();
3329
3330 controller.set_end_reason(EndReason::NewTrackLoaded);
3332
3333 let decision = controller.on_playback_ended().await.unwrap();
3335
3336 assert!(
3338 matches!(decision, AutoplayDecision::Stop),
3339 "Expected Stop decision when EndReason is NewTrackLoaded"
3340 );
3341 }
3342
3343 #[tokio::test]
3344 async fn test_on_playback_ended_with_user_stop_stops() {
3345 let controller = PlayerController::default();
3346
3347 let items = create_test_items(2);
3349 controller.play_queue(items, 0).unwrap();
3350
3351 controller.set_end_reason(EndReason::UserStop);
3353
3354 let decision = controller.on_playback_ended().await.unwrap();
3356
3357 assert!(
3359 matches!(decision, AutoplayDecision::Stop),
3360 "Expected Stop decision when EndReason is UserStop"
3361 );
3362 }
3363
3364 #[tokio::test]
3365 async fn test_on_playback_ended_natural_end_advances() {
3366 let controller = PlayerController::default();
3367
3368 let items = create_test_items(2);
3370 controller.play_queue(items, 0).unwrap();
3371
3372 controller.take_end_reason();
3374
3375 let decision = controller.on_playback_ended().await.unwrap();
3377
3378 assert!(
3380 matches!(decision, AutoplayDecision::AdvanceToNext),
3381 "Expected AdvanceToNext decision when track ends naturally with next track available"
3382 );
3383 }
3384
3385 #[tokio::test]
3386 async fn test_on_playback_ended_with_user_skip_stops() {
3387 let controller = PlayerController::default();
3388
3389 let items = create_test_items(2);
3391 controller.play_queue(items, 0).unwrap();
3392
3393 controller.set_end_reason(EndReason::UserSkip);
3395
3396 let decision = controller.on_playback_ended().await.unwrap();
3398
3399 assert!(
3401 matches!(decision, AutoplayDecision::Stop),
3402 "Expected Stop decision when EndReason is UserSkip"
3403 );
3404 }
3405
3406 #[tokio::test]
3407 async fn test_on_playback_ended_with_error_stops() {
3408 let controller = PlayerController::default();
3409
3410 let items = create_test_items(2);
3412 controller.play_queue(items, 0).unwrap();
3413
3414 controller.set_end_reason(EndReason::Error);
3416
3417 let decision = controller.on_playback_ended().await.unwrap();
3419
3420 assert!(
3422 matches!(decision, AutoplayDecision::Stop),
3423 "Expected Stop decision when EndReason is Error"
3424 );
3425 }
3426
3427 #[tokio::test]
3428 async fn test_take_end_reason_clears_state() {
3429 let controller = PlayerController::default();
3430
3431 controller.set_end_reason(EndReason::NewTrackLoaded);
3433
3434 let reason = controller.take_end_reason();
3436 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3437
3438 let reason = controller.take_end_reason();
3440 assert!(reason.is_none(), "take_end_reason should clear the state");
3441 }
3442
3443 use crate::repository::types as repo_types;
3446
3447 struct MockEpisodeRepo {
3451 episodes: Vec<repo_types::MediaItem>,
3452 }
3453
3454 impl MockEpisodeRepo {
3455 fn season(count: usize) -> Self {
3456 let episodes = (1..=count)
3457 .map(|i| {
3458 let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
3459 item.name = format!("Episode {}", i);
3460 item
3461 })
3462 .collect();
3463 Self { episodes }
3464 }
3465 }
3466
3467 fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
3468 repo_types::MediaItem {
3469 id: id.to_string(),
3470 name: format!("Episode {}", index),
3471 item_type: "Episode".to_string(),
3472 kind: crate::domain::MediaKind::Episode,
3473 is_folder: false,
3474 server_id: "server".to_string(),
3475 parent_id: Some("season1".to_string()),
3476 library_id: None,
3477 overview: None,
3478 genres: None,
3479 runtime_ticks: None,
3480 duration_ms: None,
3481 production_year: None,
3482 premiere_date: None,
3483 community_rating: None,
3484 official_rating: None,
3485 primary_image_tag: None,
3486 image_id: None,
3487 backdrop_image_tags: None,
3488 parent_backdrop_image_tags: None,
3489 album_id: None,
3490 album_name: None,
3491 album_artist: None,
3492 artists: None,
3493 artist_items: None,
3494 index_number: Some(index),
3495 series_id: Some("series1".to_string()),
3496 series_name: Some("Test Series".to_string()),
3497 season_id: Some("season1".to_string()),
3498 season_name: Some("Season 1".to_string()),
3499 parent_index_number: Some(1),
3500 user_data: None,
3501 media_streams: None,
3502 media_sources: None,
3503 people: None,
3504 }
3505 }
3506
3507 #[async_trait::async_trait]
3508 impl crate::repository::MediaRepository for MockEpisodeRepo {
3509 async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
3510 unimplemented!()
3511 }
3512 async fn get_items(
3513 &self,
3514 parent_id: &str,
3515 _options: Option<repo_types::GetItemsOptions>,
3516 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3517 assert_eq!(parent_id, "season1", "episode lookup must query the season");
3518 Ok(repo_types::SearchResult {
3519 items: self.episodes.clone(),
3520 total_record_count: self.episodes.len(),
3521 })
3522 }
3523 async fn get_item(
3524 &self,
3525 item_id: &str,
3526 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3527 self.episodes
3528 .iter()
3529 .find(|e| e.id == item_id)
3530 .cloned()
3531 .ok_or(repo_types::RepoError::NotFound {
3532 message: format!("{} not found", item_id),
3533 })
3534 }
3535 async fn get_latest_items(
3536 &self,
3537 _: &str,
3538 _: Option<usize>,
3539 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3540 unimplemented!()
3541 }
3542 async fn get_resume_items(
3543 &self,
3544 _: Option<&str>,
3545 _: Option<usize>,
3546 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3547 unimplemented!()
3548 }
3549 async fn get_next_up_episodes(
3550 &self,
3551 _: Option<&str>,
3552 _: Option<usize>,
3553 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3554 unimplemented!()
3555 }
3556 async fn get_recently_played_audio(
3557 &self,
3558 _: Option<usize>,
3559 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3560 unimplemented!()
3561 }
3562 async fn get_rediscover_albums(
3563 &self,
3564 _: Option<&str>,
3565 _: Option<usize>,
3566 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3567 unimplemented!()
3568 }
3569 async fn get_resume_movies(
3570 &self,
3571 _: Option<usize>,
3572 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3573 unimplemented!()
3574 }
3575 async fn get_genres(
3576 &self,
3577 _: Option<&str>,
3578 ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
3579 unimplemented!()
3580 }
3581 async fn search(
3582 &self,
3583 _: &str,
3584 _: Option<repo_types::SearchOptions>,
3585 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3586 unimplemented!()
3587 }
3588 async fn get_playback_info(
3589 &self,
3590 _: &str,
3591 ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
3592 unimplemented!()
3593 }
3594 async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
3595 unimplemented!()
3596 }
3597 async fn get_audio_only_stream_url_for_video(
3598 &self,
3599 item_id: &str,
3600 _media_source_id: Option<&str>,
3601 _start_time_seconds: Option<f64>,
3602 _audio_stream_index: Option<i32>,
3603 ) -> Result<String, repo_types::RepoError> {
3604 Ok(format!("http://example.com/{}-audio.mp3", item_id))
3605 }
3606 async fn get_live_tv_channels(
3607 &self,
3608 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3609 unimplemented!()
3610 }
3611 async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3612 unimplemented!()
3613 }
3614 async fn open_live_stream(
3615 &self,
3616 _: &str,
3617 ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
3618 unimplemented!()
3619 }
3620 async fn report_playback_start(
3621 &self,
3622 _: &str,
3623 _: i64,
3624 ) -> Result<(), repo_types::RepoError> {
3625 unimplemented!()
3626 }
3627 async fn report_playback_progress(
3628 &self,
3629 _: &str,
3630 _: i64,
3631 ) -> Result<(), repo_types::RepoError> {
3632 unimplemented!()
3633 }
3634 async fn report_playback_stopped(
3635 &self,
3636 _: &str,
3637 _: i64,
3638 ) -> Result<(), repo_types::RepoError> {
3639 unimplemented!()
3640 }
3641 fn get_image_url(
3642 &self,
3643 _: &str,
3644 _: repo_types::ImageType,
3645 _: Option<repo_types::ImageOptions>,
3646 ) -> String {
3647 unimplemented!()
3648 }
3649 fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
3650 unimplemented!()
3651 }
3652 fn get_video_download_url(
3653 &self,
3654 _: &str,
3655 _: &str,
3656 _: Option<&str>,
3657 _: Option<&str>,
3658 ) -> String {
3659 unimplemented!()
3660 }
3661 async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
3662 unimplemented!()
3663 }
3664 async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
3665 unimplemented!()
3666 }
3667 async fn get_favorites(
3668 &self,
3669 _: repo_types::SearchScope,
3670 _: Option<repo_types::GetItemsOptions>,
3671 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3672 unimplemented!()
3673 }
3674 async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
3675 unimplemented!()
3676 }
3677 async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
3678 unimplemented!()
3679 }
3680 async fn get_person(
3681 &self,
3682 _: &str,
3683 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3684 unimplemented!()
3685 }
3686 async fn get_items_by_person(
3687 &self,
3688 _: &str,
3689 _: Option<repo_types::GetItemsOptions>,
3690 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3691 unimplemented!()
3692 }
3693 async fn get_similar_items(
3694 &self,
3695 _: &str,
3696 _: Option<usize>,
3697 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3698 unimplemented!()
3699 }
3700 async fn create_playlist(
3701 &self,
3702 _: &str,
3703 _: &[String],
3704 ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
3705 unimplemented!()
3706 }
3707 async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
3708 unimplemented!()
3709 }
3710 async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
3711 unimplemented!()
3712 }
3713 async fn get_playlist_items(
3714 &self,
3715 _: &str,
3716 ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
3717 unimplemented!()
3718 }
3719 async fn add_to_playlist(
3720 &self,
3721 _: &str,
3722 _: &[String],
3723 ) -> Result<(), repo_types::RepoError> {
3724 unimplemented!()
3725 }
3726 async fn remove_from_playlist(
3727 &self,
3728 _: &str,
3729 _: &[String],
3730 ) -> Result<(), repo_types::RepoError> {
3731 unimplemented!()
3732 }
3733 async fn move_playlist_item(
3734 &self,
3735 _: &str,
3736 _: &str,
3737 _: u32,
3738 ) -> Result<(), repo_types::RepoError> {
3739 unimplemented!()
3740 }
3741 }
3742
3743 #[tokio::test]
3746 async fn test_video_playback_ended_offers_next_episode() {
3747 let controller = PlayerController::default();
3748 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
3749
3750 let decision = controller
3751 .on_video_playback_ended("ep2", repo)
3752 .await
3753 .expect("decision should succeed");
3754
3755 match decision {
3756 AutoplayDecision::ShowNextEpisodePopup {
3757 current_episode,
3758 next_episode,
3759 auto_advance,
3760 ..
3761 } => {
3762 assert_eq!(current_episode.id, "ep2");
3763 assert_eq!(next_episode.id, "ep3");
3764 assert!(auto_advance, "default settings should auto-advance");
3765 }
3766 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
3767 }
3768 }
3769
3770 #[tokio::test]
3772 async fn test_video_playback_ended_last_episode_stops() {
3773 let controller = PlayerController::default();
3774 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
3775
3776 let decision = controller
3777 .on_video_playback_ended("ep3", repo)
3778 .await
3779 .expect("decision should succeed");
3780
3781 assert!(matches!(decision, AutoplayDecision::Stop));
3782 }
3783
3784 #[tokio::test]
3789 async fn test_playback_ended_uses_controller_repository_for_episodes() {
3790 let controller = PlayerController::default();
3791 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
3792
3793 let episode = MediaItem {
3795 media_type: MediaType::Video,
3796 source: MediaSource::Remote {
3797 stream_url: "http://example.com/ep1.mkv".to_string(),
3798 jellyfin_item_id: "ep1".to_string(),
3799 },
3800 ..create_test_items(1).remove(0)
3801 };
3802 controller.play_queue(vec![episode], 0).unwrap();
3803
3804 controller.take_end_reason();
3806
3807 let decision = controller.on_playback_ended().await.unwrap();
3808
3809 match decision {
3810 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
3811 assert_eq!(next_episode.id, "ep2");
3812 }
3813 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
3814 }
3815 }
3816
3817 #[tokio::test]
3824 async fn test_playback_ended_background_audio_episode_advances() {
3825 let controller = PlayerController::default();
3826 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
3827
3828 let episode = MediaItem {
3830 item_type: Some("Episode".to_string()),
3831 media_type: MediaType::Audio, series_id: Some("series1".to_string()),
3833 duration: Some(180.0),
3834 source: MediaSource::Remote {
3835 stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
3836 jellyfin_item_id: "ep2".to_string(),
3837 },
3838 ..create_test_items(1).remove(0)
3839 };
3840 controller.play_queue(vec![episode], 0).unwrap();
3841
3842 controller.seek(180.0).unwrap();
3844 controller.take_end_reason();
3846
3847 let decision = controller.on_playback_ended().await.unwrap();
3848
3849 match decision {
3850 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
3851 assert_eq!(next_episode.id, "ep3");
3852 }
3853 other => panic!(
3854 "background-audio episode end must advance to the next episode, got {:?}",
3855 other
3856 ),
3857 }
3858 }
3859
3860 #[tokio::test]
3864 async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
3865 let controller = PlayerController::default();
3866 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
3867
3868 controller
3869 .advance_to_next_episode_audio_only("ep2")
3870 .await
3871 .expect("advance should succeed");
3872
3873 let current = controller
3874 .queue
3875 .lock_safe()
3876 .current()
3877 .cloned()
3878 .expect("an item should be loaded");
3879 assert_eq!(current.id, "ep2");
3880 assert_eq!(current.media_type, MediaType::Audio);
3881 assert_eq!(current.item_type.as_deref(), Some("Episode"));
3882 assert_eq!(current.series_id.as_deref(), Some("series1"));
3883 match ¤t.source {
3885 MediaSource::Remote { stream_url, .. } => {
3886 assert!(
3887 stream_url.contains("audio"),
3888 "expected audio-only URL, got {}",
3889 stream_url
3890 );
3891 }
3892 other => panic!("expected Remote source, got {:?}", other),
3893 }
3894
3895 assert!(controller.current_is_audio_episode());
3898 }
3899
3900 #[tokio::test]
3908 async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
3909 let controller = PlayerController::default();
3910 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
3911
3912 controller.set_background_audio_base(1200.0);
3914
3915 controller
3916 .advance_to_next_episode_audio_only("ep2")
3917 .await
3918 .expect("advance should succeed");
3919
3920 assert_eq!(
3921 controller.take_background_audio_base(),
3922 0.0,
3923 "the next episode starts at its own zero, so the previous handoff \
3924 base must not survive the advance"
3925 );
3926 }
3927
3928 #[tokio::test]
3940 async fn test_auto_advance_background_audio_episode_advances_in_backend() {
3941 let controller = PlayerController::default();
3942 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
3943
3944 let episode = MediaItem {
3946 id: "ep2".to_string(),
3947 item_type: Some("Episode".to_string()),
3948 media_type: MediaType::Audio,
3949 series_id: Some("series1".to_string()),
3950 source: MediaSource::Remote {
3951 stream_url: "http://example.com/ep2-audio.mp3".to_string(),
3952 jellyfin_item_id: "ep2".to_string(),
3953 },
3954 ..create_test_items(1).remove(0)
3955 };
3956 controller.play_queue(vec![episode], 0).unwrap();
3957
3958 let next = make_repo_episode("ep3", 3);
3959 controller.auto_advance_to_next_episode(next, 10).await;
3960
3961 let current = controller
3962 .queue
3963 .lock_safe()
3964 .current()
3965 .cloned()
3966 .expect("an item should still be loaded");
3967 assert_eq!(
3968 current.id, "ep3",
3969 "background audio-only episode must advance in the backend, not wait \
3970 for a frontend navigation that cannot happen while backgrounded"
3971 );
3972 assert_eq!(current.media_type, MediaType::Audio);
3973 assert!(controller.current_is_audio_episode());
3974 }
3975
3976 fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
3980 MediaItem {
3981 id: "ep2".to_string(),
3982 item_type: Some("Episode".to_string()),
3983 media_type: MediaType::Audio,
3984 series_id: Some("series1".to_string()),
3985 duration: Some(runtime_seconds),
3986 source: MediaSource::Remote {
3987 stream_url:
3988 "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
3989 .to_string(),
3990 jellyfin_item_id: "ep2".to_string(),
3991 },
3992 ..create_test_items(1).remove(0)
3993 }
3994 }
3995
3996 fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
4000 MediaItem {
4001 source: MediaSource::Local {
4002 file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
4003 jellyfin_item_id: Some("ep2".to_string()),
4004 },
4005 ..audio_only_episode(runtime_seconds)
4006 }
4007 }
4008
4009 #[tokio::test]
4016 async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
4017 let controller = PlayerController::default();
4018 controller
4019 .play_queue(vec![local_audio_only_episode(1500.0)], 0)
4020 .unwrap();
4021 controller.enter_background_audio(0.0);
4023
4024 controller.seek_absolute(900.0).await.unwrap();
4025
4026 assert_eq!(controller.position(), 900.0);
4027 }
4028
4029 #[tokio::test]
4041 async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
4042 let controller = PlayerController::default();
4043 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4044
4045 controller
4046 .play_queue(vec![audio_only_episode(1500.0)], 0)
4047 .unwrap();
4048 controller.seek(600.0).unwrap();
4050 controller.take_end_reason();
4051
4052 let decision = controller.on_playback_ended().await.unwrap();
4053
4054 match decision {
4055 AutoplayDecision::ResumeStream { position } => {
4056 assert_eq!(position, 600.0, "must resume where the stream died");
4057 }
4058 other => panic!(
4059 "a stream that ended 15 minutes short of the runtime must resume, \
4060 not run end-of-episode logic; got {:?}",
4061 other
4062 ),
4063 }
4064 }
4065
4066 #[tokio::test]
4079 async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
4080 let controller = PlayerController::default();
4081 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4082 controller
4083 .play_queue(vec![audio_only_episode(1500.0)], 0)
4084 .unwrap();
4085
4086 controller.enter_background_audio(1200.0);
4088
4089 controller.seek_absolute(1490.0).await.unwrap();
4091
4092 let url = {
4093 let queue = controller.queue();
4094 let queue = queue.lock_safe();
4095 match &queue.current().unwrap().source {
4096 MediaSource::Remote { stream_url, .. } => stream_url.clone(),
4097 other => panic!("expected a remote source, got {:?}", other),
4098 }
4099 };
4100 assert!(
4101 url.contains(&format!(
4102 "StartTimeTicks={}",
4103 (1490.0 * 10_000_000.0) as i64
4104 )),
4105 "the stream must be re-opened at the absolute position; got {}",
4106 url
4107 );
4108
4109 assert_eq!(
4110 *controller.background_audio_base.lock_safe(),
4111 1490.0,
4112 "the re-opened stream's zero is the position it was opened at, or \
4113 every later reading is off by the difference"
4114 );
4115 }
4116
4117 #[tokio::test]
4122 async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
4123 let controller = PlayerController::default();
4124 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4125 controller
4126 .play_queue(vec![audio_only_episode(1500.0)], 0)
4127 .unwrap();
4128
4129 controller.seek_absolute(300.0).await.unwrap();
4130
4131 assert_eq!(controller.position(), 300.0);
4132 assert_eq!(
4133 *controller.background_audio_base.lock_safe(),
4134 0.0,
4135 "an ordinary seek must not invent a handoff base"
4136 );
4137 }
4138
4139 #[test]
4159 fn test_absolute_position_floors_at_the_handoff_base() {
4160 let controller = PlayerController::default();
4161 controller.enter_background_audio(18.4);
4162
4163 assert_eq!(controller.position(), 0.0);
4165 assert_eq!(
4166 controller.absolute_position(),
4167 18.4,
4168 "the audio stream's zero IS the handoff point, so the position can \
4169 never legitimately read below it"
4170 );
4171 }
4172
4173 #[test]
4178 fn test_absolute_position_does_not_double_count_the_handoff_base() {
4179 let controller = PlayerController::default();
4180 controller.enter_background_audio(18.4);
4181
4182 controller.seek(120.0).unwrap();
4184
4185 assert_eq!(controller.absolute_position(), 120.0);
4186 }
4187
4188 #[test]
4195 fn test_webview_position_reports_become_the_controllers_position() {
4196 let controller = PlayerController::default();
4197
4198 controller.report_html5_position(253.4, 2640.0);
4199
4200 assert_eq!(controller.absolute_position(), 253.4);
4201 assert_eq!(controller.observed_duration(), Some(2640.0));
4202 }
4203
4204 #[test]
4210 fn test_webview_teardown_clears_the_observed_position() {
4211 let controller = PlayerController::default();
4212 controller.report_html5_position(253.4, 2640.0);
4213
4214 controller.report_html5_state("stopped".to_string(), None);
4215
4216 assert_eq!(controller.absolute_position(), 0.0);
4217 }
4218
4219 #[test]
4224 fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
4225 let controller = PlayerController::default();
4226 controller.report_html5_position(253.4, 2640.0);
4227
4228 controller.enter_background_audio(18.4);
4229
4230 assert_eq!(
4231 controller.absolute_position(),
4232 18.4,
4233 "the video element is gone; only the handoff base describes the \
4234 stream that is now playing"
4235 );
4236 }
4237
4238 #[tokio::test]
4246 async fn test_a_stop_at_zero_is_never_reported() {
4247 let controller = PlayerController::default();
4248 let reports = Arc::new(CapturingReports::new());
4249 controller.set_report_sink(reports.clone());
4250 controller
4251 .play_queue(vec![audio_only_episode(1500.0)], 0)
4252 .unwrap();
4253
4254 controller.stop().unwrap();
4256
4257 assert!(
4258 reports.stops().is_empty(),
4259 "a zero-position stop must be withheld, not sent; got {:?}",
4260 reports.stops()
4261 );
4262 }
4263
4264 #[tokio::test]
4273 async fn test_a_stop_reports_the_position_actually_reached() {
4274 let webview = PlayerController::default();
4276 let webview_reports = Arc::new(CapturingReports::new());
4277 webview.set_report_sink(webview_reports.clone());
4278 webview
4279 .play_queue(vec![audio_only_episode(1500.0)], 0)
4280 .unwrap();
4281 webview.report_html5_position(253.0, 1500.0);
4282
4283 webview.stop().unwrap();
4284
4285 assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4286
4287 let native = PlayerController::default();
4289 let native_reports = Arc::new(CapturingReports::new());
4290 native.set_report_sink(native_reports.clone());
4291 native
4292 .play_queue(vec![audio_only_episode(1500.0)], 0)
4293 .unwrap();
4294 native.seek(253.0).unwrap();
4295
4296 native.stop().unwrap();
4297
4298 assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4299 }
4300
4301 #[tokio::test]
4311 async fn test_a_finished_audio_only_episode_is_reported_complete() {
4312 let controller = PlayerController::default();
4313 let reports = Arc::new(CapturingReports::new());
4314 controller.set_report_sink(reports.clone());
4315 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4316 controller
4317 .play_queue(vec![audio_only_episode(1500.0)], 0)
4318 .unwrap();
4319 controller.seek(1499.0).unwrap();
4321 controller.take_end_reason();
4322
4323 controller.on_playback_ended().await.unwrap();
4324
4325 assert_eq!(
4326 reports.stops(),
4327 vec![("ep2".to_string(), 1500.0)],
4328 "the finished episode must be reported stopped at its runtime, or \
4329 Jellyfin's ≥90% rule never marks it played"
4330 );
4331 }
4332
4333 #[tokio::test]
4339 async fn test_a_truncated_stream_reports_no_completion() {
4340 let controller = PlayerController::default();
4341 let reports = Arc::new(CapturingReports::new());
4342 controller.set_report_sink(reports.clone());
4343 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4344 controller
4345 .play_queue(vec![audio_only_episode(1500.0)], 0)
4346 .unwrap();
4347 controller.seek(600.0).unwrap();
4348 controller.take_end_reason();
4349
4350 let decision = controller.on_playback_ended().await.unwrap();
4351
4352 assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
4353 assert!(
4354 reports.stops().is_empty(),
4355 "a dropped connection is not a finished episode; got {:?}",
4356 reports.stops()
4357 );
4358 }
4359
4360 #[tokio::test]
4368 async fn test_webview_position_ticks_report_progress_to_the_server() {
4369 let controller = PlayerController::default();
4370 let reports = Arc::new(CapturingReports::new());
4371 controller.set_report_sink(reports.clone());
4372 controller
4373 .play_queue(vec![audio_only_episode(1500.0)], 0)
4374 .unwrap();
4375
4376 controller.report_html5_position(253.4, 1500.0);
4377
4378 assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
4379 }
4380
4381 #[tokio::test]
4386 async fn test_progress_reports_are_throttled_not_sent_per_tick() {
4387 let controller = PlayerController::default();
4388 let reports = Arc::new(CapturingReports::new());
4389 controller.set_report_sink(reports.clone());
4390 controller
4391 .play_queue(vec![audio_only_episode(1500.0)], 0)
4392 .unwrap();
4393
4394 for tick in 0..12 {
4395 controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
4396 }
4397
4398 assert_eq!(
4399 reports.progress().len(),
4400 1,
4401 "twelve ticks inside one throttle window are one report"
4402 );
4403 }
4404
4405 #[tokio::test]
4415 async fn test_truncated_check_uses_the_absolute_position() {
4416 let controller = PlayerController::default();
4417 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4418
4419 controller
4420 .play_queue(vec![audio_only_episode(1500.0)], 0)
4421 .unwrap();
4422 controller.set_background_audio_base(1440.0);
4425 controller.seek(1496.0).unwrap();
4426 controller.take_end_reason();
4427
4428 let decision = controller.on_playback_ended().await.unwrap();
4429
4430 assert!(
4431 matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
4432 "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
4433 decision
4434 );
4435 }
4436
4437 #[tokio::test]
4441 async fn test_repeated_truncation_at_the_same_position_gives_up() {
4442 let controller = PlayerController::default();
4443 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4444
4445 controller
4446 .play_queue(vec![audio_only_episode(1500.0)], 0)
4447 .unwrap();
4448 controller.seek(600.0).unwrap();
4449
4450 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4451 controller.take_end_reason();
4452 let decision = controller.on_playback_ended().await.unwrap();
4453 assert!(
4454 matches!(decision, AutoplayDecision::ResumeStream { .. }),
4455 "attempt {} should still resume, got {:?}",
4456 attempt,
4457 decision
4458 );
4459 }
4460
4461 controller.take_end_reason();
4462 let decision = controller.on_playback_ended().await.unwrap();
4463 assert!(
4464 !matches!(decision, AutoplayDecision::ResumeStream { .. }),
4465 "a stream stuck at the same position must stop retrying, got {:?}",
4466 decision
4467 );
4468 }
4469
4470 #[tokio::test]
4474 async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
4475 let controller = PlayerController::default();
4476
4477 let mut items = create_test_items(2);
4478 items[0].duration = Some(1500.0);
4479 controller.play_queue(items, 0).unwrap();
4480 controller.seek(60.0).unwrap();
4481 controller.take_end_reason();
4482
4483 let decision = controller.on_playback_ended().await.unwrap();
4484 assert!(
4485 matches!(decision, AutoplayDecision::AdvanceToNext),
4486 "plain queue audio must keep advancing, got {:?}",
4487 decision
4488 );
4489 }
4490
4491 #[tokio::test]
4497 async fn test_recoverable_error_resumes_a_music_track() {
4498 let controller = PlayerController::default();
4499
4500 let mut items = create_test_items(3);
4501 for item in &mut items {
4502 item.source = MediaSource::Remote {
4503 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
4504 jellyfin_item_id: item.id.clone(),
4505 };
4506 }
4507 controller.play_queue(items, 1).unwrap();
4508 controller.seek(45.0).unwrap();
4509
4510 let (position, _) = controller
4511 .recoverable_error_resume()
4512 .expect("a streamed music track must be resumable after a network error");
4513 assert_eq!(position, 45.0);
4514 }
4515
4516 #[tokio::test]
4520 async fn test_resume_keeps_the_rest_of_the_queue() {
4521 let controller = PlayerController::default();
4522
4523 let mut items = create_test_items(3);
4524 for item in &mut items {
4525 item.source = MediaSource::Remote {
4526 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
4527 jellyfin_item_id: item.id.clone(),
4528 };
4529 }
4530 controller.play_queue(items, 1).unwrap();
4531
4532 controller
4533 .resume_stream_at(45.0)
4534 .await
4535 .expect("resume should succeed");
4536
4537 let queue = controller.queue.lock_safe();
4538 assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
4539 assert_eq!(queue.current_index(), Some(1), "still on the same track");
4540 assert_eq!(queue.current().unwrap().id, "item_1");
4541 }
4542
4543 #[tokio::test]
4547 async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
4548 let controller = PlayerController::default();
4549
4550 let mut items = create_test_items(1);
4551 items[0].source = MediaSource::Remote {
4552 stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
4553 jellyfin_item_id: "item_0".to_string(),
4554 };
4555 controller.play_queue(items, 0).unwrap();
4556
4557 controller.resume_stream_at(45.0).await.unwrap();
4558
4559 match &controller.queue.lock_safe().current().unwrap().source {
4560 MediaSource::Remote { stream_url, .. } => {
4561 assert_eq!(
4562 stream_url, "http://s/Audio/item_0/stream?Static=true",
4563 "a seekable stream's URL must be left alone"
4564 );
4565 }
4566 other => panic!("expected Remote source, got {:?}", other),
4567 }
4568 assert_eq!(
4569 controller.position(),
4570 45.0,
4571 "and it must land at the position"
4572 );
4573 }
4574
4575 #[tokio::test]
4578 async fn test_recoverable_error_ignores_local_media() {
4579 let controller = PlayerController::default();
4580
4581 let mut items = create_test_items(1);
4582 items[0].source = MediaSource::Local {
4583 file_path: "/music/track.flac".into(),
4584 jellyfin_item_id: Some("item_0".to_string()),
4585 };
4586 controller.play_queue(items, 0).unwrap();
4587 controller.seek(45.0).unwrap();
4588
4589 assert!(controller.recoverable_error_resume().is_none());
4590 }
4591
4592 #[tokio::test]
4597 async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
4598 let controller = PlayerController::default();
4599
4600 controller
4601 .play_queue(vec![audio_only_episode(1500.0)], 0)
4602 .unwrap();
4603 controller.seek(600.0).unwrap();
4604
4605 let mut waits = Vec::new();
4606 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4607 let (position, delay) = controller
4608 .recoverable_error_resume()
4609 .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
4610 assert_eq!(position, 600.0);
4611 waits.push(delay);
4612 }
4613 assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
4614 assert!(
4615 controller.recoverable_error_resume().is_none(),
4616 "a stream that keeps failing at the same spot must surface the error"
4617 );
4618 }
4619
4620 #[tokio::test]
4623 async fn test_recoverable_error_ignores_direct_url_playback() {
4624 let controller = PlayerController::default();
4625 controller.play_queue(create_test_items(2), 0).unwrap();
4626
4627 assert!(controller.recoverable_error_resume().is_none());
4628 }
4629
4630 #[tokio::test]
4634 async fn test_resume_truncated_stream_reloads_at_position() {
4635 let controller = PlayerController::default();
4636
4637 controller
4638 .play_queue(vec![audio_only_episode(1500.0)], 0)
4639 .unwrap();
4640 controller.set_background_audio_base(0.0);
4641
4642 controller
4643 .resume_stream_at(600.0)
4644 .await
4645 .expect("resume should succeed");
4646
4647 let current = controller
4648 .queue
4649 .lock_safe()
4650 .current()
4651 .cloned()
4652 .expect("the same item should still be loaded");
4653 assert_eq!(current.id, "ep2", "resume must not change the item");
4654 match ¤t.source {
4655 MediaSource::Remote { stream_url, .. } => {
4656 assert!(
4657 stream_url.contains("StartTimeTicks=6000000000"),
4658 "stream must re-open at 600s, got {}",
4659 stream_url
4660 );
4661 assert!(
4662 stream_url.contains("AudioStreamIndex=2"),
4663 "the selected audio track must survive the resume, got {}",
4664 stream_url
4665 );
4666 }
4667 other => panic!("expected Remote source, got {:?}", other),
4668 }
4669 assert_eq!(
4670 controller.take_background_audio_base(),
4671 600.0,
4672 "the re-opened stream's zero is the resume position"
4673 );
4674 }
4675
4676 #[tokio::test]
4680 async fn test_auto_advance_foreground_video_episode_uses_countdown() {
4681 let controller = PlayerController::default();
4682 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4683
4684 let episode = MediaItem {
4685 id: "ep2".to_string(),
4686 item_type: Some("Episode".to_string()),
4687 media_type: MediaType::Video,
4688 series_id: Some("series1".to_string()),
4689 source: MediaSource::Remote {
4690 stream_url: "http://example.com/ep2.m3u8".to_string(),
4691 jellyfin_item_id: "ep2".to_string(),
4692 },
4693 ..create_test_items(1).remove(0)
4694 };
4695 controller.play_queue(vec![episode], 0).unwrap();
4696
4697 let next = make_repo_episode("ep3", 3);
4698 controller.auto_advance_to_next_episode(next, 10).await;
4699
4700 let current = controller
4701 .queue
4702 .lock_safe()
4703 .current()
4704 .cloned()
4705 .expect("an item should still be loaded");
4706 assert_eq!(
4707 current.id, "ep2",
4708 "foreground video advance is frontend-driven; the backend must not \
4709 swap the queue item out from under it"
4710 );
4711 }
4712
4713 #[tokio::test]
4716 async fn test_playback_ended_without_repository_stops() {
4717 let controller = PlayerController::default();
4718
4719 let episode = MediaItem {
4720 media_type: MediaType::Video,
4721 source: MediaSource::Remote {
4722 stream_url: "http://example.com/ep1.mkv".to_string(),
4723 jellyfin_item_id: "ep1".to_string(),
4724 },
4725 ..create_test_items(1).remove(0)
4726 };
4727 controller.play_queue(vec![episode], 0).unwrap();
4728 controller.take_end_reason();
4729
4730 let decision = controller.on_playback_ended().await.unwrap();
4731 assert!(matches!(decision, AutoplayDecision::Stop));
4732 }
4733}