1pub mod autoplay;
6pub mod backend;
7pub mod background_policy;
8#[cfg(any(test, feature = "conformance"))]
9pub mod conformance;
10pub mod events;
11#[cfg(any(test, feature = "conformance"))]
12pub mod fake_player;
13#[cfg(test)]
14mod fake_player_conformance;
15pub mod legacy_player;
16pub mod media;
17pub mod media_player;
18#[cfg(target_os = "linux")]
19pub mod mpv_player;
20pub mod queue;
21pub mod seek;
22pub mod session;
23pub mod sleep_timer;
24pub mod state;
25pub mod stream_end;
26pub mod track_switch;
27
28#[cfg(test)]
29mod mpv_backend_test;
30
31#[cfg(target_os = "android")]
33pub mod android;
34
35#[cfg(target_os = "linux")]
36pub mod mpv_backend;
37
38pub mod native_video;
41
42#[cfg(target_os = "linux")]
48pub mod mpv_render;
49
50#[cfg(target_os = "linux")]
55pub mod video_surface;
56
57#[cfg(not(any(target_os = "linux", target_os = "android")))]
60pub mod webview_audio_backend;
61
62use crate::repository::stream_selection::StreamSelection;
64pub use autoplay::{AutoplayDecision, AutoplaySettings};
65pub use backend::{NullBackend, PlayerBackend, PlayerError};
66pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
67pub use legacy_player::LegacyPlayer;
68pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
69pub use media_player::{MediaPlayer, OpenRequest, Phase};
70pub use queue::{QueueManager, RepeatMode};
71pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
72pub use session::{MediaSessionManager, MediaSessionType};
73pub use sleep_timer::{SleepTimerMode, SleepTimerState};
74pub use state::{EndReason, PlayerState};
75pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchStrategy};
76
77#[cfg(target_os = "android")]
79pub use android::ExoPlayerBackend;
80
81#[cfg(target_os = "linux")]
82pub use mpv_backend::MpvBackend;
83
84#[cfg(not(any(target_os = "linux", target_os = "android")))]
85pub use webview_audio_backend::WebviewAudioBackend;
86
87#[cfg(target_os = "android")]
88pub use android::{
89 disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
90 set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
91};
92
93pub trait PlaybackReportSink: Send + Sync {
104 fn send(&self, operation: PlaybackOperation);
106}
107
108struct ReporterSink {
114 reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
115}
116
117impl PlaybackReportSink for ReporterSink {
118 fn send(&self, operation: PlaybackOperation) {
119 let reporter = self.reporter.clone();
120 let task = async move {
121 let guard = reporter.lock().await;
122 let Some(reporter) = guard.as_ref() else {
123 warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
124 return;
125 };
126 if let Err(e) = reporter.report(operation, true).await {
128 log::error!("[PlayerController] Failed to report playback: {}", e);
129 }
130 };
131
132 if let Ok(handle) = tokio::runtime::Handle::try_current() {
133 handle.spawn(task);
134 } else {
135 std::thread::spawn(move || match tokio::runtime::Runtime::new() {
136 Ok(rt) => rt.block_on(task),
137 Err(e) => log::error!(
138 "[PlayerController] No runtime available to report playback: {}",
139 e
140 ),
141 });
142 }
143 }
144}
145
146fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
156 match runtime {
157 Some(runtime) if runtime > 0.0 => runtime,
158 _ => last_position.max(0.0),
159 }
160}
161
162#[cfg_attr(not(target_os = "android"), allow(dead_code))]
170const RESUME_BACKOFF_STEP_SECS: u64 = 2;
171
172#[derive(Debug, Clone)]
181#[cfg_attr(not(target_os = "android"), allow(dead_code))]
184pub struct LockscreenMetadata {
185 pub title: String,
186 pub artist: String,
187 pub album: Option<String>,
188 pub duration_ms: i64,
190 pub position_ms: i64,
192 pub is_playing: bool,
193}
194
195pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
203 #[cfg(target_os = "android")]
204 {
205 return android::update_lockscreen_metadata(_meta);
206 }
207 #[cfg(not(target_os = "android"))]
208 {
209 Ok(())
210 }
211}
212
213pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
219 #[cfg(target_os = "android")]
220 {
221 return android::set_position_offset(_offset_seconds);
222 }
223 #[cfg(not(target_os = "android"))]
224 {
225 Ok(())
226 }
227}
228
229use crate::utils::lock::MutexSafe;
230use log::{debug, error, info, warn};
231use std::sync::{Arc, Mutex};
232use std::time::Duration;
233use tokio::sync::Mutex as TokioMutex;
234
235use crate::jellyfin::JellyfinClient;
236use crate::playback_reporting::{
237 EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
238};
239use crate::repository::MediaRepository;
240use crate::settings::AudioSettings;
241use crate::utils::conversions::seconds_to_ticks;
242
243pub struct PlayerController {
245 backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
248 queue: Arc<Mutex<QueueManager>>,
249 jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
250 muted: bool,
251
252 sleep_timer: Arc<Mutex<SleepTimerState>>,
254
255 autoplay_settings: Arc<Mutex<AutoplaySettings>>,
257
258 repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
260
261 event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
263
264 countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
266
267 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
269
270 reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
273
274 position_throttler: Arc<EventThrottler>,
277
278 end_reason: Arc<Mutex<Option<EndReason>>>,
280
281 autoplay_episode_count: Arc<Mutex<u32>>,
283
284 background_audio_base: Arc<Mutex<f64>>,
298
299 background_audio_active: Arc<Mutex<bool>>,
309
310 stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
318
319 html5_playing: Arc<Mutex<Option<bool>>>,
329
330 reported_time: Arc<Mutex<stream_end::ObservedTime>>,
342}
343
344impl PlayerController {
345 pub fn new(
346 backend: Box<dyn MediaPlayer>,
347 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
348 position_throttler: Arc<EventThrottler>,
349 ) -> Self {
350 let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
351 reporter: playback_reporter.clone(),
352 });
353 let controller = Self {
354 backend: Arc::new(Mutex::new(backend)),
355 queue: Arc::new(Mutex::new(QueueManager::new())),
356 jellyfin_client: Arc::new(Mutex::new(None)),
357 muted: false,
358 sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
359 autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
360 repository: Arc::new(Mutex::new(None)),
361 event_emitter: Arc::new(Mutex::new(None)),
362 countdown_cancel: Arc::new(Mutex::new(None)),
363 playback_reporter,
364 reports: Arc::new(Mutex::new(reports)),
365 position_throttler,
366 end_reason: Arc::new(Mutex::new(None)),
367 autoplay_episode_count: Arc::new(Mutex::new(0)),
368 background_audio_base: Arc::new(Mutex::new(0.0)),
369 background_audio_active: Arc::new(Mutex::new(false)),
370 stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
371 html5_playing: Arc::new(Mutex::new(None)),
372 reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
373 };
374
375 controller.start_timer_thread();
377
378 controller
379 }
380
381 pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
383 let mut jellyfin = self.jellyfin_client.lock_safe();
384 *jellyfin = client;
385 log::info!(
386 "[PlayerController] Jellyfin client configured: {}",
387 jellyfin.is_some()
388 );
389 }
390
391 pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
393 self.jellyfin_client.clone()
394 }
395
396 pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
403 *self.repository.lock_safe() = Some(repo);
404 }
405
406 pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
409 let mut reporter_guard = self.playback_reporter.lock().await;
410 *reporter_guard = reporter;
411 log::info!(
412 "[PlayerController] Playback reporter configured: {}",
413 reporter_guard.is_some()
414 );
415 }
416
417 #[allow(dead_code)]
420 pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
421 self.playback_reporter.clone()
422 }
423
424 #[allow(dead_code)]
427 pub fn position_throttler(&self) -> Arc<EventThrottler> {
428 self.position_throttler.clone()
429 }
430
431 fn set_end_reason(&self, reason: EndReason) {
433 log::debug!("[PlayerController] Setting end reason: {:?}", reason);
434 *self.end_reason.lock_safe() = Some(reason);
435 }
436
437 fn take_end_reason(&self) -> Option<EndReason> {
439 self.end_reason.lock_safe().take()
440 }
441
442 fn peek_end_reason(&self) -> Option<EndReason> {
449 *self.end_reason.lock_safe()
450 }
451
452 fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
465 log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
466 *end_reason.lock_safe() = Some(EndReason::UserStop);
467 }
468
469 fn increment_autoplay_count(&self) -> bool {
471 let max = self.autoplay_settings.lock_safe().max_episodes;
472
473 if max == 0 {
474 return false;
476 }
477
478 let mut count = self.autoplay_episode_count.lock_safe();
479 *count += 1;
480 debug!(
481 "[PlayerController] Autoplay episode count: {}/{}",
482 *count, max
483 );
484
485 *count >= max
486 }
487
488 fn reset_autoplay_count(&self) {
490 let mut count = self.autoplay_episode_count.lock_safe();
491 if *count > 0 {
492 debug!(
493 "[PlayerController] Resetting autoplay episode counter (was {})",
494 *count
495 );
496 }
497 *count = 0;
498 }
499
500 pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
502 debug!("[PlayerController] play_item: {}", item.title);
503
504 self.reset_autoplay_count();
506
507 {
509 let mut queue = self.queue.lock_safe();
510 queue.set_queue(vec![item.clone()], 0);
511 }
512
513 self.load_and_play(&item)?;
515
516 Ok(())
517 }
518
519 pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
531 debug!(
532 "[PlayerController] set_current_item (no backend load): {}",
533 item.title
534 );
535
536 self.reset_autoplay_count();
537 self.clear_reported_time();
543
544 let mut queue = self.queue.lock_safe();
545 queue.set_queue(vec![item], 0);
546
547 Ok(())
548 }
549
550 pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
553 debug!("[PlayerController] load_and_play: {}", item.title);
554
555 self.set_end_reason(EndReason::NewTrackLoaded);
557
558 *self.html5_playing.lock_safe() = None;
580
581 let mut backend = self.backend.lock_safe();
582 backend.open(OpenRequest::new(
585 item.clone(),
586 StreamSelection::for_queued_item(
587 item.playback_url(),
588 item.transport,
589 item.needs_transcoding,
590 ),
591 ))?;
592 drop(backend);
593
594 self.clear_reported_time();
597
598 if let Some(jellyfin_id) = item.jellyfin_id() {
600 let context = if item.album_id.is_some() {
602 Some(PlaybackContext {
603 context_type: "container".to_string(),
604 context_id: item.album_id.clone(),
605 })
606 } else {
607 None
608 };
609
610 let position = self.absolute_position();
616
617 log::info!(
618 "[PlayerController] Reporting playback start: {} @ {:.1}s",
619 jellyfin_id,
620 position
621 );
622 self.report(PlaybackOperation::Start {
623 item_id: jellyfin_id.to_string(),
624 position_ticks: seconds_to_ticks(position),
625 context,
626 });
627 }
628
629 Ok(())
630 }
631
632 pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
634 self.play_queue_from(items, start_index, None)
635 }
636
637 pub fn play_queue_from(
644 &self,
645 items: Vec<MediaItem>,
646 start_index: usize,
647 start_position: Option<f64>,
648 ) -> Result<(), PlayerError> {
649 debug!(
650 "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
651 items.len(),
652 start_index,
653 start_position
654 );
655
656 self.reset_autoplay_count();
658
659 {
660 let mut queue = self.queue.lock_safe();
661 queue.set_queue(items, start_index);
662 }
663
664 if let Some(item) = self.queue.lock_safe().current().cloned() {
666 self.load_and_play(&item)?;
667
668 if let Some(position) = start_position {
672 if position > 0.5 {
673 self.seek(position)?;
674 }
675 }
676 }
677
678 Ok(())
679 }
680
681 pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
687 debug!(
688 "[PlayerController] set_queue (no local playback): {} items, index {}",
689 items.len(),
690 start_index
691 );
692 self.reset_autoplay_count();
693 let mut queue = self.queue.lock_safe();
694 queue.set_queue(items, start_index);
695 Ok(())
696 }
697
698 pub fn is_html5_active(&self) -> bool {
703 self.html5_playing.lock_safe().is_some()
704 }
705
706 pub fn html5_is_playing(&self) -> bool {
711 self.html5_playing.lock_safe().unwrap_or(false)
712 }
713
714 fn emit_html5_control(&self, action: &str) {
716 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
717 emitter.emit(PlayerStatusEvent::ControlCommand {
718 action: action.to_string(),
719 position: None,
720 });
721 }
722 }
723
724 pub fn play(&self) -> Result<(), PlayerError> {
726 debug!("[PlayerController] play");
727 if self.is_html5_active() {
730 self.emit_html5_control("play");
731 return Ok(());
732 }
733 let mut backend = self.backend.lock_safe();
734 backend.play()
735 }
736
737 pub fn pause(&self) -> Result<(), PlayerError> {
739 if self.is_html5_active() {
740 self.emit_html5_control("pause");
741 return Ok(());
742 }
743 let mut backend = self.backend.lock_safe();
744 backend.pause()
745 }
746
747 pub fn toggle_playback(&self) -> Result<(), PlayerError> {
755 if self.is_html5_active() {
756 let action = if self.html5_is_playing() {
757 "pause"
758 } else {
759 "play"
760 };
761 self.emit_html5_control(action);
762 return Ok(());
763 }
764 let mut backend = self.backend.lock_safe();
765 if backend.snapshot().phase.is_active() {
766 backend.pause()
767 } else {
768 backend.play()
769 }
770 }
771
772 pub fn stop(&self) -> Result<(), PlayerError> {
774 self.set_end_reason(EndReason::UserStop);
776
777 let jellyfin_id = {
779 let queue = self.queue.lock_safe();
780 queue
781 .current()
782 .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
783 };
784
785 let position = self.absolute_position();
788
789 let mut backend = self.backend.lock_safe();
790 backend.close()?;
791 drop(backend);
792 self.clear_reported_time();
793
794 if self.is_background_audio_active() {
810 debug!("[PlayerController] stop: clearing an active background-audio handoff");
811 }
812 *self.background_audio_active.lock_safe() = false;
813 self.set_background_audio_base(0.0);
814 *self.html5_playing.lock_safe() = None;
815
816 if let Some(jellyfin_id) = jellyfin_id {
817 self.report_stopped_at(jellyfin_id, position);
818 }
819
820 Ok(())
821 }
822
823 fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
837 if position <= 0.0 {
838 debug!(
839 "[PlayerController] Withholding zero-position stop report for {} \
840 (nothing played; reporting it would clear the resume point)",
841 jellyfin_id
842 );
843 return;
844 }
845
846 log::info!(
847 "[PlayerController] Reporting playback stopped: {} @ {:.1}s",
848 jellyfin_id,
849 position
850 );
851 self.report(PlaybackOperation::Stopped {
852 item_id: jellyfin_id,
853 position_ticks: seconds_to_ticks(position),
854 });
855 }
856
857 pub fn next(&self) -> Result<(), PlayerError> {
862 self.reset_autoplay_count();
864
865 let next_item = {
866 let mut queue = self.queue.lock_safe();
867 queue.next().cloned()
868 };
869
870 debug!(
871 "[PlayerController] next: {:?}",
872 next_item.as_ref().map(|i| &i.title)
873 );
874
875 if let Some(item) = next_item {
876 self.load_and_play(&item)
877 } else {
878 debug!("[PlayerController] No next item, stopping");
879 self.stop()
880 }
881 }
882
883 pub fn previous(&self) -> Result<(), PlayerError> {
888 self.reset_autoplay_count();
890 {
892 let backend = self.backend.lock_safe();
893 if backend.snapshot().position.as_secs_f64() > 3.0 {
894 debug!("[PlayerController] previous: restarting current track (position > 3s)");
895 drop(backend);
896 return self.seek(0.0);
897 }
898 }
899
900 let prev_item = {
901 let mut queue = self.queue.lock_safe();
902 queue.previous().cloned()
903 };
904
905 debug!(
906 "[PlayerController] previous: {:?}",
907 prev_item.as_ref().map(|i| &i.title)
908 );
909
910 if let Some(item) = prev_item {
911 self.load_and_play(&item)
912 } else {
913 self.seek(0.0)
914 }
915 }
916
917 pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
924 let mut backend = self.backend.lock_safe();
925 backend.seek(Duration::from_secs_f64(position.max(0.0)))
926 }
927
928 pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
946 let rebuild = self.is_background_audio_active() && {
951 let queue = self.queue.lock_safe();
952 queue
953 .current()
954 .map(|item| {
955 Self::is_audio_only_video(item)
956 && matches!(item.source, MediaSource::Remote { .. })
957 })
958 .unwrap_or(false)
959 };
960
961 if rebuild {
962 return self.resume_stream_at(position.max(0.0)).await;
963 }
964
965 self.seek(position).map_err(|e| e.to_string())
966 }
967
968 pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
970 self.backend.lock_safe().set_volume(volume)
971 }
972
973 pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
975 let mut backend = self.backend.lock_safe();
976 backend.select_audio_track(Some(stream_index))
977 }
978
979 pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
981 let mut backend = self.backend.lock_safe();
982 backend.select_subtitle_track(stream_index)
983 }
984
985 pub fn state(&self) -> PlayerState {
987 let phase = self.backend.lock_safe().snapshot().phase;
988 let media = self.queue.lock_safe().current().cloned();
989 match (phase, media) {
990 (Phase::Playing, Some(media)) => PlayerState::Playing {
991 media,
992 position: self.position(),
993 duration: self.duration().unwrap_or(0.0),
994 },
995 (Phase::Paused, Some(media)) => PlayerState::Paused {
996 media,
997 position: self.position(),
998 duration: self.duration().unwrap_or(0.0),
999 },
1000 (Phase::Opening, Some(media)) => PlayerState::Loading { media },
1001 (Phase::Failed(error), media) => PlayerState::Error { media, error },
1002 _ => PlayerState::Idle,
1005 }
1006 }
1007
1008 pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
1012 self.backend.lock_safe().capabilities()
1013 }
1014
1015 pub fn position(&self) -> f64 {
1017 self.backend.lock_safe().snapshot().position.as_secs_f64()
1018 }
1019
1020 pub fn absolute_position(&self) -> f64 {
1043 let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
1044 let reported = self.reported_time.lock_safe().last_position();
1045 let base = if self.is_background_audio_active() {
1046 *self.background_audio_base.lock_safe()
1047 } else {
1048 0.0
1049 };
1050
1051 native.max(reported).max(base)
1052 }
1053
1054 pub fn observed_duration(&self) -> Option<f64> {
1058 self.reported_time.lock_safe().last_duration()
1059 }
1060
1061 fn clear_reported_time(&self) {
1069 self.reported_time.lock_safe().reset();
1070 }
1071
1072 #[cfg_attr(not(test), allow(dead_code))]
1077 pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
1078 *self.reports.lock_safe() = sink;
1079 }
1080
1081 fn report(&self, operation: PlaybackOperation) {
1086 let sink = self.reports.lock_safe().clone();
1087 sink.send(operation);
1088 }
1089
1090 fn current_jellyfin_id(&self) -> Option<String> {
1092 let queue = self.queue.lock_safe();
1093 queue
1094 .current()
1095 .and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
1096 }
1097
1098 pub fn duration(&self) -> Option<f64> {
1107 let usable = |d: f64| (d > 0.0).then_some(d);
1120
1121 self.backend
1122 .lock_safe()
1123 .snapshot()
1124 .duration
1125 .map(|d| d.as_secs_f64())
1126 .and_then(usable)
1127 .or_else(|| self.observed_duration().and_then(usable))
1128 .or_else(|| {
1129 self.queue
1130 .lock_safe()
1131 .current()
1132 .and_then(|item| item.duration)
1133 .and_then(usable)
1134 })
1135 }
1136
1137 pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
1139 self.queue.clone()
1140 }
1141
1142 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
1150 pub fn current_is_audio_episode(&self) -> bool {
1151 self.queue
1152 .lock_safe()
1153 .current()
1154 .map(|item| {
1155 item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
1156 })
1157 .unwrap_or(false)
1158 }
1159
1160 pub fn clear_queue(&self) {
1164 self.queue.lock_safe().clear();
1165 }
1166
1167 pub fn toggle_shuffle(&self) {
1169 self.queue.lock_safe().toggle_shuffle();
1170 }
1171
1172 pub fn cycle_repeat(&self) {
1174 self.queue.lock_safe().cycle_repeat();
1175 }
1176
1177 pub fn is_shuffle(&self) -> bool {
1179 self.queue.lock_safe().is_shuffle()
1180 }
1181
1182 pub fn repeat_mode(&self) -> RepeatMode {
1184 self.queue.lock_safe().repeat_mode()
1185 }
1186
1187 pub fn volume(&self) -> f32 {
1189 self.backend.lock_safe().snapshot().volume
1190 }
1191
1192 pub fn muted(&self) -> bool {
1194 self.muted
1195 }
1196
1197 pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
1199 self.backend.lock_safe().set_audio_settings(settings)
1200 }
1201
1202 pub fn audio_settings(&self) -> AudioSettings {
1204 self.backend.lock_safe().audio_settings()
1205 }
1206
1207 pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
1211 let mut event_emitter = self.event_emitter.lock_safe();
1212 *event_emitter = Some(emitter);
1213 }
1214
1215 pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
1217 self.event_emitter.lock_safe().clone()
1218 }
1219
1220 pub fn sleep_timer_state(&self) -> SleepTimerState {
1222 self.sleep_timer.lock_safe().clone()
1223 }
1224
1225 pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
1227 let mut timer = self.sleep_timer.lock_safe();
1228 timer.mode = mode.clone();
1229 if let SleepTimerMode::Time { end_time } = mode {
1230 let now = chrono::Utc::now().timestamp_millis();
1231 timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
1232 } else {
1233 timer.remaining_seconds = 0;
1234 }
1235 drop(timer);
1236
1237 self.emit_sleep_timer_changed();
1239 }
1240
1241 pub fn cancel_sleep_timer(&self) {
1243 self.set_sleep_timer(SleepTimerMode::Off);
1244 }
1245
1246 fn start_timer_thread(&self) {
1248 let sleep_timer = self.sleep_timer.clone();
1249 let event_emitter = self.event_emitter.clone();
1250 let backend = self.backend.clone();
1251 let end_reason = self.end_reason.clone();
1252
1253 std::thread::spawn(move || {
1254 loop {
1255 std::thread::sleep(Duration::from_secs(1));
1256
1257 let mut timer = sleep_timer.lock_safe();
1258 if timer.is_active() {
1259 timer.update_remaining_seconds();
1260
1261 if matches!(timer.mode, SleepTimerMode::Time { .. })
1263 && timer.remaining_seconds == 0
1264 {
1265 debug!("[SleepTimer] Time-based timer expired, stopping playback");
1266 timer.cancel();
1267
1268 Self::note_sleep_timer_stop(&end_reason);
1275
1276 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1278 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1279 mode: SleepTimerMode::Off,
1280 remaining_seconds: 0,
1281 });
1282 emitter.emit(PlayerStatusEvent::SleepTimerExpired);
1286 }
1287 drop(timer);
1288
1289 if let Err(e) = backend.lock_safe().close() {
1291 error!("[SleepTimer] Failed to stop playback: {}", e);
1292 }
1293 continue;
1294 }
1295
1296 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1298 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1299 mode: timer.mode.clone(),
1300 remaining_seconds: timer.remaining_seconds,
1301 });
1302 }
1303 }
1304 drop(timer);
1305 }
1306 });
1307 }
1308
1309 fn emit_sleep_timer_changed(&self) {
1311 let timer = self.sleep_timer.lock_safe().clone();
1312
1313 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1314 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1315 mode: timer.mode,
1316 remaining_seconds: timer.remaining_seconds,
1317 });
1318 }
1319 }
1320
1321 pub fn emit_queue_changed(&self) {
1323 let queue = self.queue.lock_safe();
1324
1325 debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
1326 queue.items().len(), queue.current_index());
1327
1328 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1329 emitter.emit(PlayerStatusEvent::QueueChanged {
1330 items: queue.items().to_vec(),
1331 current_index: queue.current_index(),
1332 shuffle: queue.is_shuffle(),
1333 repeat: queue.repeat_mode(),
1334 has_next: queue.has_next(),
1335 has_previous: queue.has_previous(),
1336 });
1337 } else {
1338 warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
1339 }
1340 }
1341
1342 pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
1357 if self.is_background_audio_active() {
1362 debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
1363 return;
1364 }
1365 let element_gone = {
1371 let mut tracked = self.html5_playing.lock_safe();
1372 *tracked = match state.as_str() {
1373 "playing" => Some(true),
1374 "paused" | "loading" => Some(false),
1378 _ => None,
1380 };
1381 tracked.is_none()
1382 };
1383 if element_gone {
1386 self.clear_reported_time();
1387 }
1388 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1389 emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
1390 }
1391 }
1392
1393 pub fn report_html5_position(&self, position: f64, duration: f64) {
1398 if self.is_background_audio_active() {
1401 return;
1402 }
1403 self.reported_time.lock_safe().record(position, duration);
1406
1407 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1408 emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
1409 }
1410
1411 self.report_progress_throttled(position);
1412 }
1413
1414 fn report_progress_throttled(&self, position: f64) {
1429 if position <= 0.0 {
1430 return;
1431 }
1432 let Some(item_id) = self.current_jellyfin_id() else {
1433 return;
1434 };
1435 if !self.position_throttler.should_report(&item_id) {
1436 return;
1437 }
1438
1439 self.report(PlaybackOperation::Progress {
1440 item_id: item_id.clone(),
1441 position_ticks: seconds_to_ticks(position),
1442 is_paused: false,
1445 });
1446 self.position_throttler.mark_reported(&item_id);
1447 }
1448
1449 pub fn report_html5_media_loaded(&self, duration: f64) {
1452 if self.is_background_audio_active() {
1454 return;
1455 }
1456 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1457 emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
1458 }
1459 }
1460
1461 pub fn autoplay_settings(&self) -> AutoplaySettings {
1465 self.autoplay_settings.lock_safe().clone()
1466 }
1467
1468 pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
1470 let validated = settings.with_validated_countdown();
1471 *self.autoplay_settings.lock_safe() = validated;
1472 }
1473
1474 pub fn cancel_autoplay_countdown(&self) {
1476 if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
1477 *cancel_flag.lock_safe() = true;
1478 }
1479 }
1480
1481 pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
1486 if let Some(position) = self.truncated_stream_resume_position() {
1492 return Ok(AutoplayDecision::ResumeStream { position });
1493 }
1494
1495 let end_reason = self.take_end_reason();
1497
1498 debug!(
1499 "[PlayerController] on_playback_ended: end_reason={:?}",
1500 end_reason
1501 );
1502
1503 match end_reason {
1505 None | Some(EndReason::Finished) => {
1506 debug!("[PlayerController] Track finished naturally, checking autoplay");
1508 }
1509 Some(EndReason::NewTrackLoaded) => {
1510 debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
1512 return Ok(AutoplayDecision::Stop);
1513 }
1514 Some(EndReason::UserStop) => {
1515 debug!("[PlayerController] UserStop - stopping without autoplay");
1517 return Ok(AutoplayDecision::Stop);
1518 }
1519 Some(EndReason::UserSkip) => {
1520 debug!("[PlayerController] UserSkip - stopping without autoplay");
1522 return Ok(AutoplayDecision::Stop);
1523 }
1524 Some(EndReason::Error) => {
1525 debug!("[PlayerController] Error - stopping without autoplay");
1527 return Ok(AutoplayDecision::Stop);
1528 }
1529 }
1530
1531 let current_item = {
1532 let queue = self.queue.lock_safe();
1533 queue.current().cloned()
1534 };
1535
1536 let Some(current) = current_item else {
1537 return Ok(AutoplayDecision::Stop);
1538 };
1539
1540 self.report_completion(¤t);
1544
1545 let timer_mode = {
1547 let timer = self.sleep_timer.lock_safe();
1548 timer.mode.clone()
1549 };
1550
1551 match &timer_mode {
1552 SleepTimerMode::Time { end_time } => {
1553 let now = chrono::Utc::now().timestamp_millis();
1555 if now >= *end_time {
1556 debug!("[PlayerController] Time-based sleep timer expired at track boundary");
1557 self.sleep_timer.lock_safe().cancel();
1558 self.emit_sleep_timer_changed();
1559 return Ok(AutoplayDecision::Stop);
1560 }
1561 }
1562 SleepTimerMode::EndOfTrack => {
1563 self.sleep_timer.lock_safe().cancel();
1565 self.emit_sleep_timer_changed();
1566 return Ok(AutoplayDecision::Stop);
1567 }
1568 SleepTimerMode::Episodes { .. } => {
1569 let is_episode = self.is_episode_item(¤t).await;
1574
1575 if is_episode {
1576 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
1577 self.emit_sleep_timer_changed();
1578
1579 if should_stop {
1580 return Ok(AutoplayDecision::Stop);
1581 }
1582 }
1583 }
1584 _ => {
1585 }
1587 }
1588
1589 if self.is_episode_item(¤t).await {
1595 let repo = self.repository.lock_safe().clone();
1596 let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
1597 let next_ep_result = if let Some(repo) = &repo {
1598 match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
1601 Ok(next) => next,
1602 Err(e) => {
1603 warn!(
1604 "[PlayerController] Next-episode lookup failed for {}: {}",
1605 jellyfin_id, e
1606 );
1607 None
1608 }
1609 }
1610 } else {
1611 warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
1612 None
1613 };
1614 if let Some(next_ep) = next_ep_result {
1615 let settings = self.autoplay_settings.lock_safe().clone();
1616
1617 let limit_reached = self.increment_autoplay_count();
1619 if limit_reached {
1620 debug!(
1621 "[PlayerController] Auto-play episode limit reached ({} episodes)",
1622 settings.max_episodes
1623 );
1624 }
1625
1626 return Ok(AutoplayDecision::ShowNextEpisodePopup {
1627 current_episode: next_ep.0, next_episode: next_ep.1,
1629 countdown_seconds: settings.countdown_seconds,
1630 auto_advance: settings.enabled && !limit_reached,
1631 });
1632 }
1633 return Ok(AutoplayDecision::Stop);
1635 }
1636
1637 let has_next = {
1639 let queue = self.queue.lock_safe();
1640 queue.has_next()
1641 };
1642
1643 if has_next {
1644 Ok(AutoplayDecision::AdvanceToNext)
1646 } else {
1647 Ok(AutoplayDecision::Stop)
1649 }
1650 }
1651
1652 fn report_completion(&self, item: &MediaItem) {
1673 if !Self::is_audio_only_video(item) {
1674 return;
1675 }
1676 let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
1677 return;
1678 };
1679
1680 let position = completion_report_position(item.duration, self.absolute_position());
1681 log::info!(
1682 "[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
1683 jellyfin_id,
1684 position
1685 );
1686 self.report_stopped_at(jellyfin_id, position);
1687 }
1688
1689 pub fn set_background_audio_base(&self, seconds: f64) {
1694 *self.background_audio_base.lock_safe() = seconds.max(0.0);
1695 }
1696
1697 pub fn enter_background_audio(&self, position: f64) {
1708 self.set_background_audio_base(position);
1709 *self.background_audio_active.lock_safe() = true;
1710 *self.html5_playing.lock_safe() = None;
1711 self.clear_reported_time();
1714 }
1715
1716 pub fn exit_background_audio(&self) -> f64 {
1724 *self.background_audio_active.lock_safe() = false;
1725 self.take_background_audio_base()
1726 }
1727
1728 pub fn is_background_audio_active(&self) -> bool {
1733 *self.background_audio_active.lock_safe()
1734 }
1735
1736 pub fn take_background_audio_base(&self) -> f64 {
1740 let mut base = self.background_audio_base.lock_safe();
1741 std::mem::replace(&mut *base, 0.0)
1742 }
1743
1744 pub async fn auto_advance_to_next_episode(
1755 &self,
1756 next_episode: crate::repository::types::MediaItem,
1757 countdown_seconds: u32,
1758 ) {
1759 if self.current_is_audio_episode() {
1764 info!(
1765 "[PlayerController] Background audio episode — advancing to {} in backend",
1766 next_episode.id
1767 );
1768 match self
1769 .advance_to_next_episode_audio_only(&next_episode.id)
1770 .await
1771 {
1772 Ok(()) => self.emit_queue_changed(),
1773 Err(e) => {
1774 error!(
1775 "[PlayerController] Background audio advance failed: {} — stopping",
1776 e
1777 );
1778 if let Some(emitter) = self.event_emitter() {
1779 emitter.emit(PlayerStatusEvent::PlaybackEnded);
1780 }
1781 }
1782 }
1783 return;
1784 }
1785
1786 self.start_autoplay_countdown(next_episode, countdown_seconds);
1788 }
1789
1790 fn is_audio_only_video(item: &MediaItem) -> bool {
1794 stream_end::is_audio_only_video(item)
1795 }
1796
1797 fn claim_stream_resume(&self) -> Option<(f64, u32)> {
1812 let current = {
1813 let queue = self.queue.lock_safe();
1814 queue.current().cloned()
1815 }?;
1816 if !matches!(current.source, MediaSource::Remote { .. }) {
1817 return None;
1818 }
1819
1820 let absolute = self.absolute_position();
1825
1826 match self.stream_resume.lock_safe().allow_attempt(absolute) {
1827 Some(attempt) => Some((absolute, attempt)),
1828 None => {
1829 warn!(
1830 "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
1831 current.id, absolute
1832 );
1833 None
1834 }
1835 }
1836 }
1837
1838 fn truncated_stream_resume_position(&self) -> Option<f64> {
1845 if matches!(
1847 self.peek_end_reason(),
1848 Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
1849 ) {
1850 return None;
1851 }
1852
1853 let item_duration = {
1857 let queue = self.queue.lock_safe();
1858 let current = queue.current()?;
1859 if !Self::is_audio_only_video(current) {
1860 return None;
1861 }
1862 current.duration
1863 };
1864 let absolute = self.position().max(0.0);
1866
1867 if !stream_end::is_truncated_end(
1870 absolute,
1871 item_duration,
1872 stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
1873 ) {
1874 return None;
1875 }
1876
1877 self.claim_stream_resume().map(|(position, _)| position)
1878 }
1879
1880 pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
1902 self.claim_stream_resume()
1903 .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
1904 }
1905
1906 pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
1933 let current = {
1934 let queue = self.queue.lock_safe();
1935 queue.current().cloned()
1936 }
1937 .ok_or_else(|| "No current item to resume".to_string())?;
1938
1939 let MediaSource::Remote { stream_url, .. } = ¤t.source else {
1940 return Err(format!(
1941 "Cannot resume a non-remote source for {}",
1942 current.id
1943 ));
1944 };
1945
1946 info!(
1947 "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
1948 current.id, position
1949 );
1950
1951 if !Self::is_audio_only_video(¤t) {
1952 self.load_and_play(¤t).map_err(|e| e.to_string())?;
1953 if position > 0.5 {
1954 self.seek(position).map_err(|e| e.to_string())?;
1955 }
1956 return Ok(());
1957 }
1958
1959 let restarted_url = stream_end::with_start_time(stream_url, position);
1960 {
1961 let queue_arc = self.queue.clone();
1962 let mut queue = queue_arc.lock_safe();
1963 if !queue.update_current_stream_url(restarted_url) {
1964 return Err(format!("Failed to update stream URL for {}", current.id));
1965 }
1966 }
1967 let resumed = {
1968 let queue = self.queue.lock_safe();
1969 queue.current().cloned()
1970 }
1971 .ok_or_else(|| "Current item vanished mid-resume".to_string())?;
1972
1973 self.set_background_audio_base(position);
1977 let _ = set_lockscreen_position_offset(position.max(0.0));
1978
1979 self.load_and_play(&resumed).map_err(|e| e.to_string())
1980 }
1981
1982 pub async fn advance_to_next_episode_audio_only(
1997 &self,
1998 next_episode_id: &str,
1999 ) -> Result<(), String> {
2000 crate::repository::online::clear_playback_quality_override();
2008
2009 let repo = self
2010 .repository
2011 .lock_safe()
2012 .clone()
2013 .ok_or_else(|| "No repository for background episode advance".to_string())?;
2014
2015 let next = repo
2017 .get_item(next_episode_id)
2018 .await
2019 .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
2020
2021 let stream_url = repo
2024 .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
2025 .await
2026 .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
2027
2028 let media_item = MediaItem {
2029 transport: None,
2031 id: next.id.clone(),
2032 title: next.name.clone(),
2033 name: Some(next.name.clone()),
2034 artist: next.series_name.clone(),
2035 album: None,
2036 album_name: None,
2037 album_id: None,
2038 artist_items: None,
2039 artists: None,
2040 primary_image_tag: next.primary_image_tag.clone(),
2041 image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
2042 item_type: Some("Episode".to_string()),
2044 playlist_id: None,
2045 duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
2046 artwork_url: None,
2047 media_type: MediaType::Audio,
2048 source: MediaSource::Remote {
2049 stream_url,
2050 jellyfin_item_id: next.id.clone(),
2051 },
2052 video_codec: None,
2053 needs_transcoding: false,
2054 video_width: None,
2055 video_height: None,
2056 subtitles: vec![],
2057 series_id: next.series_id.clone(),
2058 server_id: Some(next.server_id.clone()),
2059 };
2060
2061 self.set_background_audio_base(0.0);
2066 let _ = set_lockscreen_position_offset(0.0);
2067 self.stream_resume.lock_safe().reset();
2070
2071 self.play_item(media_item).map_err(|e| e.to_string())
2072 }
2073
2074 pub async fn on_video_playback_ended(
2081 &self,
2082 item_id: &str,
2083 repo: Arc<dyn crate::repository::MediaRepository>,
2084 ) -> Result<AutoplayDecision, String> {
2085 let stale_reason = self.take_end_reason();
2087 if stale_reason.is_some() {
2088 debug!(
2089 "[PlayerController] Cleared stale end_reason for video: {:?}",
2090 stale_reason
2091 );
2092 }
2093
2094 log::info!(
2095 "[PlayerController] on_video_playback_ended: item_id={}",
2096 item_id
2097 );
2098
2099 let timer_mode = {
2101 let timer = self.sleep_timer.lock_safe();
2102 timer.mode.clone()
2103 };
2104
2105 match &timer_mode {
2106 SleepTimerMode::Time { end_time } => {
2107 let now = chrono::Utc::now().timestamp_millis();
2108 if now >= *end_time {
2109 debug!("[PlayerController] Time-based sleep timer expired at video end");
2110 self.sleep_timer.lock_safe().cancel();
2111 self.emit_sleep_timer_changed();
2112 return Ok(AutoplayDecision::Stop);
2113 }
2114 }
2115 SleepTimerMode::EndOfTrack => {
2116 self.sleep_timer.lock_safe().cancel();
2117 self.emit_sleep_timer_changed();
2118 return Ok(AutoplayDecision::Stop);
2119 }
2120 SleepTimerMode::Episodes { .. } => {
2121 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
2122 self.emit_sleep_timer_changed();
2123 if should_stop {
2124 return Ok(AutoplayDecision::Stop);
2125 }
2126 }
2127 _ => {}
2128 }
2129
2130 let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
2134 Ok(next) => next,
2135 Err(e) => {
2136 warn!(
2137 "[PlayerController] Next-episode lookup failed for {}: {}",
2138 item_id, e
2139 );
2140 None
2141 }
2142 };
2143 if let Some(next_ep) = next_ep_result {
2144 let settings = self.autoplay_settings.lock_safe().clone();
2145
2146 let limit_reached = self.increment_autoplay_count();
2147 if limit_reached {
2148 debug!(
2149 "[PlayerController] Auto-play episode limit reached ({} episodes)",
2150 settings.max_episodes
2151 );
2152 }
2153
2154 return Ok(AutoplayDecision::ShowNextEpisodePopup {
2155 current_episode: next_ep.0,
2156 next_episode: next_ep.1,
2157 countdown_seconds: settings.countdown_seconds,
2158 auto_advance: settings.enabled && !limit_reached,
2159 });
2160 }
2161
2162 debug!("[PlayerController] No next episode found for {}", item_id);
2164 Ok(AutoplayDecision::Stop)
2165 }
2166
2167 async fn is_episode_item(&self, item: &MediaItem) -> bool {
2175 match item.item_type.as_deref() {
2176 Some("Episode") => true,
2177 Some(_) => item.media_type == MediaType::Video,
2178 None => item.media_type == MediaType::Video,
2179 }
2180 }
2181
2182 async fn fetch_next_episode_for_item(
2189 &self,
2190 item_id: &str,
2191 repo: &Arc<dyn crate::repository::MediaRepository>,
2192 ) -> Result<
2193 Option<(
2194 crate::repository::types::MediaItem,
2195 crate::repository::types::MediaItem,
2196 )>,
2197 String,
2198 > {
2199 use crate::repository::types::GetItemsOptions;
2200
2201 let current_repo_item = repo
2203 .get_item(item_id)
2204 .await
2205 .map_err(|e| format!("Failed to get current item: {}", e))?;
2206
2207 let season_id = match ¤t_repo_item.season_id {
2209 Some(sid) => sid.clone(),
2210 None => {
2211 log::info!(
2212 "[PlayerController] Current item has no season_id, cannot find next episode"
2213 );
2214 return Ok(None);
2215 }
2216 };
2217
2218 let options = GetItemsOptions {
2220 sort_by: Some("IndexNumber".to_string()),
2221 sort_order: Some("Ascending".to_string()),
2222 limit: Some(500),
2223 include_item_types: Some(vec!["Episode".to_string()]),
2224 ..Default::default()
2225 };
2226
2227 let result = repo
2228 .get_items(&season_id, Some(options))
2229 .await
2230 .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
2231
2232 let mut episodes = result.items;
2235 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2236 log::info!(
2237 "[PlayerController] Season has {} episodes, looking for next after {}",
2238 episodes.len(),
2239 current_repo_item.id
2240 );
2241
2242 if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
2244 if current_idx + 1 < episodes.len() {
2245 let next = &episodes[current_idx + 1];
2246 log::info!(
2247 "[PlayerController] Found next episode: {} (index {})",
2248 next.name,
2249 current_idx + 1
2250 );
2251 return Ok(Some((current_repo_item, next.clone())));
2252 } else {
2253 log::info!("[PlayerController] Current episode is the last in the season");
2254 if let Some(next) = self
2255 .first_episode_of_next_season(¤t_repo_item, repo)
2256 .await
2257 {
2258 return Ok(Some((current_repo_item, next)));
2259 }
2260 }
2261 } else {
2262 log::info!(
2263 "[PlayerController] Current episode not found in season episodes (ids: {:?})",
2264 episodes
2265 .iter()
2266 .map(|e| e.id.as_str())
2267 .take(20)
2268 .collect::<Vec<_>>()
2269 );
2270 }
2271
2272 Ok(None)
2273 }
2274
2275 async fn first_episode_of_next_season(
2288 &self,
2289 current: &crate::repository::types::MediaItem,
2290 repo: &Arc<dyn crate::repository::MediaRepository>,
2291 ) -> Option<crate::repository::types::MediaItem> {
2292 use crate::repository::types::GetItemsOptions;
2293
2294 let series_id = current.series_id.as_deref()?;
2295 let season_id = current.season_id.as_deref()?;
2296
2297 let season_options = GetItemsOptions {
2298 sort_by: Some("IndexNumber".to_string()),
2299 sort_order: Some("Ascending".to_string()),
2300 limit: Some(500),
2301 include_item_types: Some(vec!["Season".to_string()]),
2302 ..Default::default()
2303 };
2304 let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
2305 Ok(result) => result.items,
2306 Err(e) => {
2307 log::warn!(
2308 "[PlayerController] Season lookup failed for series {}: {}",
2309 series_id,
2310 e
2311 );
2312 return None;
2313 }
2314 };
2315 seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
2318
2319 let current_idx = seasons.iter().position(|s| s.id == season_id)?;
2320
2321 for season in &seasons[current_idx + 1..] {
2322 if season.index_number == Some(0) {
2327 continue;
2328 }
2329
2330 let episode_options = GetItemsOptions {
2331 sort_by: Some("IndexNumber".to_string()),
2332 sort_order: Some("Ascending".to_string()),
2333 limit: Some(500),
2334 include_item_types: Some(vec!["Episode".to_string()]),
2335 ..Default::default()
2336 };
2337 let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
2338 Ok(result) => result.items,
2339 Err(e) => {
2340 log::warn!(
2341 "[PlayerController] Episode lookup failed for season {}: {}",
2342 season.id,
2343 e
2344 );
2345 return None;
2346 }
2347 };
2348 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2349
2350 if let Some(first) = episodes.into_iter().next() {
2352 log::info!(
2353 "[PlayerController] Rolling over to {} of {}: {}",
2354 first.name,
2355 season.name,
2356 first.id
2357 );
2358 return Some(first);
2359 }
2360 }
2361
2362 log::info!("[PlayerController] No further season to roll over into");
2363 None
2364 }
2365
2366 pub fn start_autoplay_countdown(
2368 &self,
2369 _next_item: crate::repository::types::MediaItem,
2370 countdown_seconds: u32,
2371 ) {
2372 let cancel_flag = Arc::new(Mutex::new(false));
2374 *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
2375
2376 let event_emitter = self.event_emitter.clone();
2377
2378 std::thread::spawn(move || {
2379 let mut remaining = countdown_seconds;
2380
2381 while remaining > 0 {
2382 std::thread::sleep(Duration::from_secs(1));
2383
2384 if *cancel_flag.lock_safe() {
2386 log::info!("[PlayerController] Autoplay countdown cancelled");
2387 return;
2388 }
2389
2390 remaining -= 1;
2391
2392 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
2394 emitter.emit(PlayerStatusEvent::CountdownTick {
2395 remaining_seconds: remaining,
2396 });
2397 }
2398 }
2399
2400 log::info!("[PlayerController] Autoplay countdown finished");
2402 });
2403 }
2404}
2405
2406impl Default for PlayerController {
2407 fn default() -> Self {
2408 let playback_reporter = Arc::new(TokioMutex::new(None));
2409 let position_throttler = Arc::new(EventThrottler::new());
2410 Self::new(
2411 Box::new(LegacyPlayer::new(
2412 NullBackend::new(),
2413 crate::player::media_player::Capabilities::mpv(),
2414 )),
2415 playback_reporter,
2416 position_throttler,
2417 )
2418 }
2419}
2420
2421#[cfg(test)]
2422mod tests {
2423
2424 #[test]
2440 fn test_background_episode_advance_clears_the_quality_override() {
2441 let src = include_str!("mod.rs");
2442 let start = src
2443 .find("fn advance_to_next_episode_audio_only")
2444 .expect("advance_to_next_episode_audio_only not found");
2445 let rest = &src[start..];
2446 let end = rest.find("\n pub ").unwrap_or(rest.len());
2447 let body = &rest[..end];
2448
2449 assert!(
2450 body.contains("clear_playback_quality_override"),
2451 "the background episode advance does not clear the per-playback \
2452 quality override, so a ceiling chosen for one episode silently \
2453 caps every episode after it"
2454 );
2455 }
2456
2457 #[test]
2468 fn test_stop_clears_an_active_background_audio_handoff() {
2469 let controller = PlayerController::default();
2470 let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2471 {
2472 let queue_arc = controller.queue();
2473 let mut queue = queue_arc.lock_safe();
2474 queue.set_queue(vec![item], 0);
2475 }
2476
2477 controller.enter_background_audio(557.5);
2478 assert!(
2479 controller.is_background_audio_active(),
2480 "precondition: the handoff is active"
2481 );
2482
2483 controller.stop().expect("stop failed");
2484
2485 assert!(
2486 !controller.is_background_audio_active(),
2487 "a stop must not leave a handoff behind for the next position read"
2488 );
2489 assert_eq!(
2490 *controller.background_audio_base.lock_safe(),
2491 0.0,
2492 "the handoff base must be cleared with it"
2493 );
2494 }
2495
2496 #[test]
2507 fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
2508 let controller = PlayerController::default();
2509 let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2510 item.duration = Some(1800.0);
2511
2512 {
2513 let queue_arc = controller.queue();
2514 let mut queue = queue_arc.lock_safe();
2515 queue.set_queue(vec![item], 0);
2516 }
2517
2518 assert_eq!(
2519 controller.duration(),
2520 Some(1800.0),
2521 "an engine that cannot report a duration should not erase the one the item carries"
2522 );
2523 }
2524 use super::*;
2525
2526 struct CapturingEmitter {
2529 events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
2530 }
2531
2532 impl CapturingEmitter {
2533 fn new() -> Self {
2534 Self {
2535 events: std::sync::Mutex::new(Vec::new()),
2536 }
2537 }
2538 fn events(&self) -> Vec<PlayerStatusEvent> {
2539 self.events.lock_safe().clone()
2540 }
2541 }
2542
2543 impl PlayerEventEmitter for CapturingEmitter {
2544 fn emit(&self, event: PlayerStatusEvent) {
2545 self.events.lock_safe().push(event);
2546 }
2547 }
2548
2549 struct CapturingReports {
2552 operations: std::sync::Mutex<Vec<PlaybackOperation>>,
2553 }
2554
2555 impl CapturingReports {
2556 fn new() -> Self {
2557 Self {
2558 operations: std::sync::Mutex::new(Vec::new()),
2559 }
2560 }
2561
2562 fn stops(&self) -> Vec<(String, f64)> {
2564 self.operations
2565 .lock_safe()
2566 .iter()
2567 .filter_map(|op| match op {
2568 PlaybackOperation::Stopped {
2569 item_id,
2570 position_ticks,
2571 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2572 _ => None,
2573 })
2574 .collect()
2575 }
2576
2577 fn progress(&self) -> Vec<(String, f64)> {
2579 self.operations
2580 .lock_safe()
2581 .iter()
2582 .filter_map(|op| match op {
2583 PlaybackOperation::Progress {
2584 item_id,
2585 position_ticks,
2586 ..
2587 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2588 _ => None,
2589 })
2590 .collect()
2591 }
2592 }
2593
2594 impl PlaybackReportSink for CapturingReports {
2595 fn send(&self, operation: PlaybackOperation) {
2596 self.operations.lock_safe().push(operation);
2597 }
2598 }
2599
2600 #[test]
2601 fn test_report_html5_state_emits_state_changed() {
2602 let controller = PlayerController::default();
2603 let emitter = Arc::new(CapturingEmitter::new());
2604 controller.set_event_emitter(emitter.clone());
2605
2606 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2607
2608 let events = emitter.events();
2609 assert_eq!(events.len(), 1);
2610 match &events[0] {
2611 PlayerStatusEvent::StateChanged { state, media_id } => {
2612 assert_eq!(state, "playing");
2613 assert_eq!(media_id.as_deref(), Some("item-1"));
2614 }
2615 other => panic!("expected StateChanged, got {:?}", other),
2616 }
2617 }
2618
2619 #[test]
2620 fn test_report_html5_position_emits_position_update() {
2621 let controller = PlayerController::default();
2622 let emitter = Arc::new(CapturingEmitter::new());
2623 controller.set_event_emitter(emitter.clone());
2624
2625 controller.report_html5_position(12.5, 300.0);
2626
2627 let events = emitter.events();
2628 assert_eq!(events.len(), 1);
2629 match &events[0] {
2630 PlayerStatusEvent::PositionUpdate { position, duration } => {
2631 assert_eq!(*position, 12.5);
2632 assert_eq!(*duration, 300.0);
2633 }
2634 other => panic!("expected PositionUpdate, got {:?}", other),
2635 }
2636 }
2637
2638 #[test]
2639 fn test_report_html5_media_loaded_emits_media_loaded() {
2640 let controller = PlayerController::default();
2641 let emitter = Arc::new(CapturingEmitter::new());
2642 controller.set_event_emitter(emitter.clone());
2643
2644 controller.report_html5_media_loaded(420.0);
2645
2646 let events = emitter.events();
2647 assert_eq!(events.len(), 1);
2648 match &events[0] {
2649 PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
2650 other => panic!("expected MediaLoaded, got {:?}", other),
2651 }
2652 }
2653
2654 #[test]
2664 fn test_html5_state_is_tracked_from_reports() {
2665 let controller = PlayerController::default();
2666 let emitter = Arc::new(CapturingEmitter::new());
2667 controller.set_event_emitter(emitter.clone());
2668
2669 assert!(!controller.is_html5_active());
2671
2672 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2673 assert!(controller.is_html5_active());
2674 assert!(controller.html5_is_playing());
2675
2676 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2677 assert!(controller.is_html5_active());
2678 assert!(!controller.html5_is_playing());
2679 }
2680
2681 #[test]
2682 fn test_html5_toggle_from_paused_emits_play_control() {
2683 let controller = PlayerController::default();
2684 let emitter = Arc::new(CapturingEmitter::new());
2685 controller.set_event_emitter(emitter.clone());
2686 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2687
2688 controller.toggle_playback().unwrap();
2689
2690 let controls: Vec<_> = emitter
2691 .events()
2692 .into_iter()
2693 .filter_map(|e| match e {
2694 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2695 _ => None,
2696 })
2697 .collect();
2698 assert_eq!(controls, vec!["play".to_string()]);
2699 }
2700
2701 #[test]
2702 fn test_html5_toggle_from_playing_emits_pause_control() {
2703 let controller = PlayerController::default();
2704 let emitter = Arc::new(CapturingEmitter::new());
2705 controller.set_event_emitter(emitter.clone());
2706 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2707
2708 controller.toggle_playback().unwrap();
2709
2710 let controls: Vec<_> = emitter
2711 .events()
2712 .into_iter()
2713 .filter_map(|e| match e {
2714 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2715 _ => None,
2716 })
2717 .collect();
2718 assert_eq!(controls, vec!["pause".to_string()]);
2719 }
2720
2721 #[test]
2722 fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
2723 let controller = PlayerController::default();
2728 let emitter = Arc::new(CapturingEmitter::new());
2729 controller.set_event_emitter(emitter.clone());
2730 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2731
2732 controller.toggle_playback().unwrap();
2733 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2735 controller.toggle_playback().unwrap();
2736
2737 let controls: Vec<_> = emitter
2738 .events()
2739 .into_iter()
2740 .filter_map(|e| match e {
2741 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2742 _ => None,
2743 })
2744 .collect();
2745 assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
2746 }
2747
2748 #[test]
2749 fn test_html5_play_and_pause_emit_control_commands() {
2750 let controller = PlayerController::default();
2751 let emitter = Arc::new(CapturingEmitter::new());
2752 controller.set_event_emitter(emitter.clone());
2753 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2754
2755 controller.play().unwrap();
2756 controller.pause().unwrap();
2757
2758 let controls: Vec<_> = emitter
2759 .events()
2760 .into_iter()
2761 .filter_map(|e| match e {
2762 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2763 _ => None,
2764 })
2765 .collect();
2766 assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
2767 }
2768
2769 #[test]
2770 fn test_background_audio_handoff_moves_transport_to_native_backend() {
2771 let controller = PlayerController::default();
2779 let emitter = Arc::new(CapturingEmitter::new());
2780 controller.set_event_emitter(emitter.clone());
2781
2782 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2784 assert!(controller.is_html5_active());
2785
2786 controller.enter_background_audio(1200.0);
2788 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2789
2790 assert!(
2791 !controller.is_html5_active(),
2792 "native audio owns transport during a background-audio handoff"
2793 );
2794
2795 controller.pause().unwrap();
2796 let controls: Vec<_> = emitter
2797 .events()
2798 .into_iter()
2799 .filter_map(|e| match e {
2800 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2801 _ => None,
2802 })
2803 .collect();
2804 assert!(
2805 controls.is_empty(),
2806 "pause must drive the native backend, not a torn-down element: {:?}",
2807 controls
2808 );
2809 }
2810
2811 #[test]
2812 fn test_background_audio_handoff_suppresses_stale_element_events() {
2813 let controller = PlayerController::default();
2817 let emitter = Arc::new(CapturingEmitter::new());
2818 controller.set_event_emitter(emitter.clone());
2819
2820 controller.enter_background_audio(1200.0);
2821 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2822 controller.report_html5_position(1200.0, 2400.0);
2823
2824 assert!(
2825 emitter.events().is_empty(),
2826 "stale webview reports must not reach the event pipeline: {:?}",
2827 emitter.events()
2828 );
2829 }
2830
2831 #[test]
2832 fn test_exit_background_audio_returns_transport_to_the_webview() {
2833 let controller = PlayerController::default();
2836 let emitter = Arc::new(CapturingEmitter::new());
2837 controller.set_event_emitter(emitter.clone());
2838
2839 controller.enter_background_audio(1200.0);
2840 assert_eq!(controller.exit_background_audio(), 1200.0);
2841
2842 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2843 assert!(controller.is_html5_active());
2844 assert!(controller.html5_is_playing());
2845 }
2846
2847 #[test]
2848 fn test_html5_stopped_report_releases_transport_to_native_backend() {
2849 let controller = PlayerController::default();
2852 let emitter = Arc::new(CapturingEmitter::new());
2853 controller.set_event_emitter(emitter.clone());
2854
2855 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2856 assert!(controller.is_html5_active());
2857
2858 controller.report_html5_state("stopped".to_string(), None);
2859 assert!(!controller.is_html5_active());
2860 }
2861
2862 #[test]
2863 fn test_html5_transport_emits_exactly_one_control_per_intent() {
2864 let controller = PlayerController::default();
2868 let emitter = Arc::new(CapturingEmitter::new());
2869 controller.set_event_emitter(emitter.clone());
2870 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2871
2872 controller.pause().unwrap();
2873
2874 let controls = emitter
2875 .events()
2876 .into_iter()
2877 .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
2878 .count();
2879 assert_eq!(controls, 1, "one intent must produce exactly one control");
2880 }
2881
2882 #[test]
2883 fn test_controller_volume_default() {
2884 let controller = PlayerController::default();
2885 assert_eq!(controller.volume(), 1.0);
2886 }
2887
2888 #[test]
2889 fn test_controller_set_volume() {
2890 let controller = PlayerController::default();
2891 controller.set_volume(0.5).unwrap();
2892 assert_eq!(controller.volume(), 0.5);
2893 }
2894
2895 #[test]
2896 fn test_controller_muted_default() {
2897 let controller = PlayerController::default();
2898 assert!(!controller.muted());
2899 }
2900
2901 #[test]
2902 fn test_controller_volume_delegates_to_backend() {
2903 let controller = PlayerController::default();
2904
2905 controller.set_volume(0.75).unwrap();
2907
2908 assert_eq!(controller.volume(), 0.75);
2910 }
2911
2912 fn create_test_items(count: usize) -> Vec<MediaItem> {
2913 (0..count)
2914 .map(|i| MediaItem {
2915 transport: None,
2917 id: format!("item_{}", i),
2918 title: format!("Track {}", i + 1),
2919 name: Some(format!("Track {}", i + 1)),
2920 artist: Some("Test Artist".to_string()),
2921 album: Some("Test Album".to_string()),
2922 album_name: Some("Test Album".to_string()),
2923 album_id: None,
2924 artist_items: None,
2925 artists: Some(vec!["Test Artist".to_string()]),
2926 primary_image_tag: None,
2927 image_id: None,
2928 item_type: Some("Audio".to_string()),
2929 playlist_id: None,
2930 duration: Some(180.0),
2931 artwork_url: None,
2932 media_type: MediaType::Audio,
2933 source: MediaSource::DirectUrl {
2934 url: format!("http://example.com/track_{}.mp3", i),
2935 },
2936 video_codec: None,
2937 needs_transcoding: false,
2938 video_width: None,
2939 video_height: None,
2940 subtitles: vec![],
2941 series_id: None,
2942 server_id: None,
2943 })
2944 .collect()
2945 }
2946
2947 #[test]
2948 fn test_skip_preserves_queue() {
2949 let controller = PlayerController::default();
2950
2951 let items = create_test_items(5);
2953 let items_clone = items.clone();
2954
2955 controller.play_queue(items, 0).unwrap();
2957
2958 {
2960 let queue = controller.queue();
2961 let queue_lock = queue.lock_safe();
2962 assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
2963 assert_eq!(
2964 queue_lock.current_index(),
2965 Some(0),
2966 "Should start at index 0"
2967 );
2968 assert_eq!(
2969 queue_lock.current().unwrap().id,
2970 "item_0",
2971 "Current item should be item_0"
2972 );
2973 }
2974
2975 controller.next().unwrap();
2977
2978 {
2980 let queue = controller.queue();
2981 let queue_lock = queue.lock_safe();
2982 assert_eq!(
2983 queue_lock.items().len(),
2984 5,
2985 "Queue should still have 5 items after skip"
2986 );
2987 assert_eq!(
2988 queue_lock.current_index(),
2989 Some(1),
2990 "Index should advance to 1"
2991 );
2992 assert_eq!(
2993 queue_lock.current().unwrap().id,
2994 "item_1",
2995 "Current item should be item_1"
2996 );
2997
2998 let current_items = queue_lock.items();
3000 for (i, original) in items_clone.iter().enumerate() {
3001 assert_eq!(
3002 current_items[i].id, original.id,
3003 "Item {} should still be in queue",
3004 i
3005 );
3006 assert_eq!(
3007 current_items[i].title, original.title,
3008 "Item {} title should be unchanged",
3009 i
3010 );
3011 }
3012 }
3013
3014 controller.next().unwrap();
3016
3017 {
3019 let queue = controller.queue();
3020 let queue_lock = queue.lock_safe();
3021 assert_eq!(
3022 queue_lock.items().len(),
3023 5,
3024 "Queue should still have 5 items after second skip"
3025 );
3026 assert_eq!(
3027 queue_lock.current_index(),
3028 Some(2),
3029 "Index should advance to 2"
3030 );
3031 assert_eq!(
3032 queue_lock.current().unwrap().id,
3033 "item_2",
3034 "Current item should be item_2"
3035 );
3036 }
3037
3038 controller.next().unwrap(); controller.next().unwrap(); {
3044 let queue = controller.queue();
3045 let queue_lock = queue.lock_safe();
3046 assert_eq!(
3047 queue_lock.items().len(),
3048 5,
3049 "Queue should still have 5 items at end"
3050 );
3051 assert_eq!(
3052 queue_lock.current_index(),
3053 Some(4),
3054 "Index should be at last item (4)"
3055 );
3056 assert_eq!(
3057 queue_lock.current().unwrap().id,
3058 "item_4",
3059 "Current item should be item_4"
3060 );
3061 }
3062 }
3063
3064 #[test]
3065 fn test_skip_at_end_without_repeat() {
3066 let controller = PlayerController::default();
3067
3068 let items = create_test_items(3);
3070 controller.play_queue(items, 0).unwrap();
3071
3072 controller.next().unwrap(); controller.next().unwrap(); {
3078 let queue = controller.queue();
3079 let queue_lock = queue.lock_safe();
3080 assert_eq!(
3081 queue_lock.current_index(),
3082 Some(2),
3083 "Should be at last item"
3084 );
3085 }
3086
3087 controller.next().unwrap();
3090
3091 {
3093 let queue = controller.queue();
3094 let queue_lock = queue.lock_safe();
3095 assert_eq!(
3096 queue_lock.items().len(),
3097 3,
3098 "Queue should still have 3 items after skip at end"
3099 );
3100 }
3104 }
3105
3106 #[test]
3107 fn test_skip_with_repeat_all() {
3108 let controller = PlayerController::default();
3109
3110 let items = create_test_items(3);
3112 controller.play_queue(items, 0).unwrap();
3113
3114 controller.cycle_repeat();
3116
3117 controller.next().unwrap(); controller.next().unwrap(); controller.next().unwrap();
3123
3124 {
3126 let queue = controller.queue();
3127 let queue_lock = queue.lock_safe();
3128 assert_eq!(
3129 queue_lock.items().len(),
3130 3,
3131 "Queue should still have 3 items"
3132 );
3133 assert_eq!(
3134 queue_lock.current_index(),
3135 Some(0),
3136 "Should wrap to index 0"
3137 );
3138 assert_eq!(
3139 queue_lock.current().unwrap().id,
3140 "item_0",
3141 "Should be back at item_0"
3142 );
3143 }
3144 }
3145
3146 #[test]
3147 fn test_previous_preserves_queue() {
3148 let controller = PlayerController::default();
3149
3150 let items = create_test_items(5);
3152 let items_clone = items.clone();
3153 controller.play_queue(items, 3).unwrap();
3154
3155 {
3157 let queue = controller.queue();
3158 let queue_lock = queue.lock_safe();
3159 assert_eq!(
3160 queue_lock.current_index(),
3161 Some(3),
3162 "Should start at index 3"
3163 );
3164 }
3165
3166 controller.previous().unwrap();
3168
3169 {
3171 let queue = controller.queue();
3172 let queue_lock = queue.lock_safe();
3173 assert_eq!(
3174 queue_lock.items().len(),
3175 5,
3176 "Queue should still have 5 items after previous"
3177 );
3178 assert_eq!(
3179 queue_lock.current_index(),
3180 Some(2),
3181 "Index should move to 2"
3182 );
3183 assert_eq!(
3184 queue_lock.current().unwrap().id,
3185 "item_2",
3186 "Current item should be item_2"
3187 );
3188
3189 let current_items = queue_lock.items();
3191 for (i, original) in items_clone.iter().enumerate() {
3192 assert_eq!(
3193 current_items[i].id, original.id,
3194 "Item {} should still be in queue",
3195 i
3196 );
3197 }
3198 }
3199 }
3200
3201 #[test]
3202 fn test_seek_updates_position() {
3203 let controller = PlayerController::default();
3204
3205 let item = create_test_items(1).into_iter().next().unwrap();
3207 controller.play_item(item).unwrap();
3208
3209 assert_eq!(controller.position(), 0.0, "Initial position should be 0");
3211
3212 controller.seek(30.0).unwrap();
3214 assert_eq!(
3215 controller.position(),
3216 30.0,
3217 "Position should be 30 after seeking"
3218 );
3219
3220 controller.seek(60.0).unwrap();
3222 assert_eq!(
3223 controller.position(),
3224 60.0,
3225 "Position should be 60 after seeking"
3226 );
3227
3228 controller.seek(15.0).unwrap();
3230 assert_eq!(
3231 controller.position(),
3232 15.0,
3233 "Position should be 15 after seeking backward"
3234 );
3235 }
3236
3237 #[test]
3238 fn test_seek_while_paused() {
3239 let controller = PlayerController::default();
3240
3241 let item = create_test_items(1).into_iter().next().unwrap();
3243 controller.play_item(item).unwrap();
3244
3245 controller.pause().unwrap();
3247
3248 assert!(controller.state().is_paused(), "Should be paused");
3250
3251 controller.seek(45.0).unwrap();
3253 assert_eq!(
3254 controller.position(),
3255 45.0,
3256 "Position should update while paused"
3257 );
3258
3259 assert!(
3261 controller.state().is_paused(),
3262 "Should still be paused after seeking"
3263 );
3264 }
3265
3266 #[test]
3267 fn test_seek_while_playing() {
3268 let controller = PlayerController::default();
3269
3270 let item = create_test_items(1).into_iter().next().unwrap();
3272 controller.play_item(item).unwrap();
3273
3274 controller.play().unwrap();
3276
3277 assert!(controller.state().is_playing(), "Should be playing");
3279
3280 controller.seek(20.0).unwrap();
3282 assert_eq!(
3283 controller.position(),
3284 20.0,
3285 "Position should update while playing"
3286 );
3287
3288 assert!(
3290 controller.state().is_playing(),
3291 "Should still be playing after seeking"
3292 );
3293 }
3294
3295 #[test]
3296 fn test_multiple_sequential_seeks() {
3297 let controller = PlayerController::default();
3298
3299 let item = create_test_items(1).into_iter().next().unwrap();
3300 controller.play_item(item).unwrap();
3301
3302 let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
3304
3305 for pos in positions {
3306 controller.seek(pos).unwrap();
3307 assert_eq!(
3308 controller.position(),
3309 pos,
3310 "Position should match after seeking to {}",
3311 pos
3312 );
3313 }
3314 }
3315
3316 #[test]
3320 fn test_play_queue_from_resumes_at_position() {
3321 let controller = PlayerController::default();
3322 let items = create_test_items(3);
3323
3324 controller.play_queue_from(items, 1, Some(42.5)).unwrap();
3325
3326 {
3327 let queue = controller.queue();
3328 let queue_lock = queue.lock_safe();
3329 assert_eq!(
3330 queue_lock.current_index(),
3331 Some(1),
3332 "Should start at index 1"
3333 );
3334 }
3335 assert_eq!(
3336 controller.position(),
3337 42.5,
3338 "Should resume at the requested position"
3339 );
3340 }
3341
3342 #[test]
3344 fn test_play_queue_from_without_position_starts_at_zero() {
3345 let controller = PlayerController::default();
3346
3347 controller
3348 .play_queue_from(create_test_items(2), 0, None)
3349 .unwrap();
3350 assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
3351
3352 controller
3353 .play_queue_from(create_test_items(2), 0, Some(0.2))
3354 .unwrap();
3355 assert_eq!(
3356 controller.position(),
3357 0.0,
3358 "Sub-threshold resume position is ignored (starts at 0)"
3359 );
3360 }
3361
3362 #[test]
3363 fn test_seek_to_zero() {
3364 let controller = PlayerController::default();
3365
3366 let item = create_test_items(1).into_iter().next().unwrap();
3367 controller.play_item(item).unwrap();
3368
3369 controller.seek(60.0).unwrap();
3371 assert_eq!(controller.position(), 60.0);
3372
3373 controller.seek(0.0).unwrap();
3375 assert_eq!(
3376 controller.position(),
3377 0.0,
3378 "Should be able to seek to position 0"
3379 );
3380 }
3381
3382 #[tokio::test]
3384 async fn test_audio_with_next_advances() {
3385 let controller = PlayerController::default();
3386
3387 let items = create_test_items(2);
3389 controller.play_queue(items, 0).unwrap();
3390
3391 controller.take_end_reason();
3393
3394 let decision = controller.on_playback_ended().await.unwrap();
3396
3397 assert!(
3399 matches!(decision, AutoplayDecision::AdvanceToNext),
3400 "Expected AdvanceToNext decision when queue has next item"
3401 );
3402 }
3403
3404 #[tokio::test]
3405 async fn test_audio_at_end_stops() {
3406 let controller = PlayerController::default();
3407
3408 let items = create_test_items(2);
3410 controller.play_queue(items, 1).unwrap();
3411
3412 controller.take_end_reason();
3414
3415 let decision = controller.on_playback_ended().await.unwrap();
3417
3418 assert!(
3420 matches!(decision, AutoplayDecision::Stop),
3421 "Expected Stop decision when at end of queue without repeat"
3422 );
3423 }
3424
3425 #[tokio::test]
3426 async fn test_sleep_timer_end_of_track() {
3427 let controller = PlayerController::default();
3428
3429 let items = create_test_items(3);
3431 controller.play_queue(items, 0).unwrap();
3432
3433 controller.take_end_reason();
3435
3436 {
3438 let mut timer = controller.sleep_timer.lock_safe();
3439 timer.mode = SleepTimerMode::EndOfTrack;
3440 }
3441
3442 let decision = controller.on_playback_ended().await.unwrap();
3444
3445 assert!(
3447 matches!(decision, AutoplayDecision::Stop),
3448 "Expected Stop decision when sleep timer is EndOfTrack"
3449 );
3450
3451 {
3453 let timer = controller.sleep_timer.lock_safe();
3454 assert!(
3455 matches!(timer.mode, SleepTimerMode::Off),
3456 "Sleep timer should be cancelled after EndOfTrack"
3457 );
3458 }
3459 }
3460
3461 #[tokio::test]
3470 async fn test_expired_time_sleep_timer_stops_without_autoplay() {
3471 let controller = PlayerController::default();
3472
3473 let items = create_test_items(3);
3474 controller.play_queue(items, 0).unwrap();
3475 controller.take_end_reason();
3476
3477 let now = chrono::Utc::now().timestamp_millis();
3482 controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
3483
3484 for _ in 0..40 {
3486 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3487 if !controller.sleep_timer.lock_safe().is_active() {
3488 break;
3489 }
3490 }
3491 assert!(
3492 !controller.sleep_timer.lock_safe().is_active(),
3493 "Timer thread should have expired and cancelled the sleep timer"
3494 );
3495
3496 let decision = controller.on_playback_ended().await.unwrap();
3498
3499 assert!(
3500 matches!(decision, AutoplayDecision::Stop),
3501 "Expected Stop after an expired time-based sleep timer, got {:?}",
3502 decision
3503 );
3504 }
3505
3506 #[tokio::test]
3507 async fn test_empty_queue_stops() {
3508 let controller = PlayerController::default();
3509
3510 let decision = controller.on_playback_ended().await.unwrap();
3512
3513 assert!(
3515 matches!(decision, AutoplayDecision::Stop),
3516 "Expected Stop decision when queue is empty"
3517 );
3518 }
3519
3520 #[tokio::test]
3521 async fn test_repeat_all_advances_at_end() {
3522 let controller = PlayerController::default();
3523
3524 let items = create_test_items(2);
3526 controller.play_queue(items, 1).unwrap(); controller.cycle_repeat(); controller.take_end_reason();
3531
3532 let decision = controller.on_playback_ended().await.unwrap();
3534
3535 assert!(
3537 matches!(decision, AutoplayDecision::AdvanceToNext),
3538 "Expected AdvanceToNext decision at end of queue with repeat all"
3539 );
3540 }
3541
3542 #[tokio::test]
3543 async fn test_repeat_one_advances() {
3544 let controller = PlayerController::default();
3545
3546 let items = create_test_items(2);
3548 controller.play_queue(items, 0).unwrap();
3549
3550 controller.cycle_repeat(); controller.cycle_repeat(); controller.take_end_reason();
3556
3557 let decision = controller.on_playback_ended().await.unwrap();
3559
3560 assert!(
3562 matches!(decision, AutoplayDecision::AdvanceToNext),
3563 "Expected AdvanceToNext decision with repeat one (repeats same track)"
3564 );
3565 }
3566
3567 #[test]
3568 fn test_native_load_returns_transport_authority_to_the_backend() {
3569 let controller = PlayerController::default();
3588 let emitter = Arc::new(CapturingEmitter::new());
3589 controller.set_event_emitter(emitter.clone());
3590
3591 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
3593 assert!(controller.is_html5_active());
3594
3595 let item = create_test_items(1).into_iter().next().unwrap();
3597 controller.play_item(item).unwrap();
3598
3599 assert!(
3600 !controller.is_html5_active(),
3601 "loading into the native backend hands transport back to it"
3602 );
3603
3604 controller.toggle_playback().unwrap();
3606 let controls: Vec<_> = emitter
3607 .events()
3608 .into_iter()
3609 .filter_map(|e| match e {
3610 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
3611 _ => None,
3612 })
3613 .collect();
3614 assert!(
3615 controls.is_empty(),
3616 "transport went to a webview element that is not rendering: {controls:?}"
3617 );
3618 }
3619
3620 #[test]
3622 fn test_load_and_play_sets_new_track_loaded() {
3623 let controller = PlayerController::default();
3624 let item = create_test_items(1).into_iter().next().unwrap();
3625
3626 assert!(controller.take_end_reason().is_none());
3628
3629 controller.load_and_play(&item).unwrap();
3631
3632 let reason = controller.take_end_reason();
3634 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3635 }
3636
3637 #[test]
3638 fn test_stop_sets_user_stop() {
3639 let controller = PlayerController::default();
3640 let item = create_test_items(1).into_iter().next().unwrap();
3641
3642 controller.play_item(item).unwrap();
3644
3645 controller.take_end_reason();
3647
3648 controller.stop().unwrap();
3650
3651 let reason = controller.take_end_reason();
3653 assert_eq!(reason, Some(EndReason::UserStop));
3654 }
3655
3656 #[tokio::test]
3657 async fn test_on_playback_ended_with_new_track_loaded_stops() {
3658 let controller = PlayerController::default();
3659
3660 let items = create_test_items(2);
3662 controller.play_queue(items, 0).unwrap();
3663
3664 controller.set_end_reason(EndReason::NewTrackLoaded);
3666
3667 let decision = controller.on_playback_ended().await.unwrap();
3669
3670 assert!(
3672 matches!(decision, AutoplayDecision::Stop),
3673 "Expected Stop decision when EndReason is NewTrackLoaded"
3674 );
3675 }
3676
3677 #[tokio::test]
3678 async fn test_on_playback_ended_with_user_stop_stops() {
3679 let controller = PlayerController::default();
3680
3681 let items = create_test_items(2);
3683 controller.play_queue(items, 0).unwrap();
3684
3685 controller.set_end_reason(EndReason::UserStop);
3687
3688 let decision = controller.on_playback_ended().await.unwrap();
3690
3691 assert!(
3693 matches!(decision, AutoplayDecision::Stop),
3694 "Expected Stop decision when EndReason is UserStop"
3695 );
3696 }
3697
3698 #[tokio::test]
3699 async fn test_on_playback_ended_natural_end_advances() {
3700 let controller = PlayerController::default();
3701
3702 let items = create_test_items(2);
3704 controller.play_queue(items, 0).unwrap();
3705
3706 controller.take_end_reason();
3708
3709 let decision = controller.on_playback_ended().await.unwrap();
3711
3712 assert!(
3714 matches!(decision, AutoplayDecision::AdvanceToNext),
3715 "Expected AdvanceToNext decision when track ends naturally with next track available"
3716 );
3717 }
3718
3719 #[tokio::test]
3720 async fn test_on_playback_ended_with_user_skip_stops() {
3721 let controller = PlayerController::default();
3722
3723 let items = create_test_items(2);
3725 controller.play_queue(items, 0).unwrap();
3726
3727 controller.set_end_reason(EndReason::UserSkip);
3729
3730 let decision = controller.on_playback_ended().await.unwrap();
3732
3733 assert!(
3735 matches!(decision, AutoplayDecision::Stop),
3736 "Expected Stop decision when EndReason is UserSkip"
3737 );
3738 }
3739
3740 #[tokio::test]
3741 async fn test_on_playback_ended_with_error_stops() {
3742 let controller = PlayerController::default();
3743
3744 let items = create_test_items(2);
3746 controller.play_queue(items, 0).unwrap();
3747
3748 controller.set_end_reason(EndReason::Error);
3750
3751 let decision = controller.on_playback_ended().await.unwrap();
3753
3754 assert!(
3756 matches!(decision, AutoplayDecision::Stop),
3757 "Expected Stop decision when EndReason is Error"
3758 );
3759 }
3760
3761 #[tokio::test]
3762 async fn test_take_end_reason_clears_state() {
3763 let controller = PlayerController::default();
3764
3765 controller.set_end_reason(EndReason::NewTrackLoaded);
3767
3768 let reason = controller.take_end_reason();
3770 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3771
3772 let reason = controller.take_end_reason();
3774 assert!(reason.is_none(), "take_end_reason should clear the state");
3775 }
3776
3777 use crate::repository::types as repo_types;
3780
3781 struct MockEpisodeRepo {
3785 seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
3787 }
3788
3789 impl MockEpisodeRepo {
3790 fn season(count: usize) -> Self {
3792 Self::series(&[count])
3793 }
3794
3795 fn series(counts: &[usize]) -> Self {
3799 let seasons = counts
3800 .iter()
3801 .enumerate()
3802 .map(|(s, count)| {
3803 let season_number = s as i32 + 1;
3804 let episodes = (1..=*count)
3805 .map(|i| {
3806 let id = if season_number == 1 {
3807 format!("ep{}", i)
3808 } else {
3809 format!("s{}e{}", season_number, i)
3810 };
3811 make_repo_episode_in(season_number, &id, i as i32)
3812 })
3813 .collect();
3814 (make_repo_season(season_number), episodes)
3815 })
3816 .collect();
3817 Self { seasons }
3818 }
3819
3820 fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
3821 self.seasons.iter().flat_map(|(_, eps)| eps.iter())
3822 }
3823 }
3824
3825 fn make_repo_season(index: i32) -> repo_types::MediaItem {
3826 repo_types::MediaItem {
3827 id: format!("season{}", index),
3828 name: format!("Season {}", index),
3829 item_type: "Season".to_string(),
3830 kind: crate::domain::MediaKind::Season,
3831 is_folder: true,
3832 parent_id: Some("series1".to_string()),
3833 index_number: Some(index),
3834 season_id: None,
3835 season_name: None,
3836 parent_index_number: None,
3837 ..make_repo_episode(&format!("season{}", index), index)
3838 }
3839 }
3840
3841 fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
3842 make_repo_episode_in(1, id, index)
3843 }
3844
3845 fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
3846 repo_types::MediaItem {
3847 id: id.to_string(),
3848 name: format!("Episode {}", index),
3849 item_type: "Episode".to_string(),
3850 kind: crate::domain::MediaKind::Episode,
3851 is_folder: false,
3852 server_id: "server".to_string(),
3853 parent_id: Some(format!("season{}", season_number)),
3854 library_id: None,
3855 overview: None,
3856 genres: None,
3857 runtime_ticks: None,
3858 duration_ms: None,
3859 production_year: None,
3860 premiere_date: None,
3861 community_rating: None,
3862 official_rating: None,
3863 primary_image_tag: None,
3864 image_id: None,
3865 backdrop_image_tags: None,
3866 parent_backdrop_image_tags: None,
3867 album_id: None,
3868 album_name: None,
3869 album_artist: None,
3870 artists: None,
3871 artist_items: None,
3872 index_number: Some(index),
3873 series_id: Some("series1".to_string()),
3874 series_name: Some("Test Series".to_string()),
3875 season_id: Some(format!("season{}", season_number)),
3876 season_name: Some(format!("Season {}", season_number)),
3877 parent_index_number: Some(season_number),
3878 user_data: None,
3879 media_streams: None,
3880 media_sources: None,
3881 people: None,
3882 }
3883 }
3884
3885 #[async_trait::async_trait]
3886 impl crate::repository::MediaRepository for MockEpisodeRepo {
3887 async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
3888 unimplemented!()
3889 }
3890 async fn get_items(
3891 &self,
3892 parent_id: &str,
3893 _options: Option<repo_types::GetItemsOptions>,
3894 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3895 let items = if parent_id == "series1" {
3899 self.seasons.iter().map(|(s, _)| s.clone()).collect()
3900 } else {
3901 self.seasons
3902 .iter()
3903 .find(|(season, _)| season.id == parent_id)
3904 .map(|(_, eps)| eps.clone())
3905 .unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
3906 };
3907 let total_record_count = items.len();
3908 Ok(repo_types::SearchResult {
3909 items,
3910 total_record_count,
3911 })
3912 }
3913 async fn get_item(
3914 &self,
3915 item_id: &str,
3916 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3917 self.all_episodes()
3918 .find(|e| e.id == item_id)
3919 .cloned()
3920 .ok_or(repo_types::RepoError::NotFound {
3921 message: format!("{} not found", item_id),
3922 })
3923 }
3924 async fn get_latest_items(
3925 &self,
3926 _: &str,
3927 _: Option<usize>,
3928 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3929 unimplemented!()
3930 }
3931 async fn get_resume_items(
3932 &self,
3933 _: Option<&str>,
3934 _: Option<usize>,
3935 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3936 unimplemented!()
3937 }
3938 async fn get_next_up_episodes(
3939 &self,
3940 _: Option<&str>,
3941 _: Option<usize>,
3942 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3943 unimplemented!()
3944 }
3945 async fn get_recently_played_audio(
3946 &self,
3947 _: Option<usize>,
3948 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3949 unimplemented!()
3950 }
3951 async fn get_rediscover_albums(
3952 &self,
3953 _: Option<&str>,
3954 _: Option<usize>,
3955 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3956 unimplemented!()
3957 }
3958 async fn get_resume_movies(
3959 &self,
3960 _: Option<usize>,
3961 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3962 unimplemented!()
3963 }
3964 async fn get_genres(
3965 &self,
3966 _: Option<&str>,
3967 ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
3968 unimplemented!()
3969 }
3970 async fn search(
3971 &self,
3972 _: &str,
3973 _: Option<repo_types::SearchOptions>,
3974 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3975 unimplemented!()
3976 }
3977 async fn get_playback_info(
3978 &self,
3979 _: &str,
3980 ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
3981 unimplemented!()
3982 }
3983 async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
3984 unimplemented!()
3985 }
3986 async fn get_audio_only_stream_url_for_video(
3987 &self,
3988 item_id: &str,
3989 _media_source_id: Option<&str>,
3990 _start_time_seconds: Option<f64>,
3991 _audio_stream_index: Option<i32>,
3992 ) -> Result<String, repo_types::RepoError> {
3993 Ok(format!("http://example.com/{}-audio.mp3", item_id))
3994 }
3995 async fn get_live_tv_channels(
3996 &self,
3997 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3998 unimplemented!()
3999 }
4000 async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4001 unimplemented!()
4002 }
4003 async fn open_live_stream(
4004 &self,
4005 _: &str,
4006 ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
4007 unimplemented!()
4008 }
4009 async fn report_playback_start(
4010 &self,
4011 _: &str,
4012 _: i64,
4013 ) -> Result<(), repo_types::RepoError> {
4014 unimplemented!()
4015 }
4016 async fn report_playback_progress(
4017 &self,
4018 _: &str,
4019 _: i64,
4020 ) -> Result<(), repo_types::RepoError> {
4021 unimplemented!()
4022 }
4023 async fn report_playback_stopped(
4024 &self,
4025 _: &str,
4026 _: i64,
4027 ) -> Result<(), repo_types::RepoError> {
4028 unimplemented!()
4029 }
4030 fn get_image_url(
4031 &self,
4032 _: &str,
4033 _: repo_types::ImageType,
4034 _: Option<repo_types::ImageOptions>,
4035 ) -> String {
4036 unimplemented!()
4037 }
4038 fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
4039 unimplemented!()
4040 }
4041 fn get_video_download_url(
4042 &self,
4043 _: &str,
4044 _: &str,
4045 _: Option<&str>,
4046 _: Option<&str>,
4047 ) -> String {
4048 unimplemented!()
4049 }
4050 async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
4051 unimplemented!()
4052 }
4053 async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
4054 unimplemented!()
4055 }
4056 async fn get_favorites(
4057 &self,
4058 _: repo_types::SearchScope,
4059 _: Option<repo_types::GetItemsOptions>,
4060 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4061 unimplemented!()
4062 }
4063 async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
4064 unimplemented!()
4065 }
4066 async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
4067 unimplemented!()
4068 }
4069 async fn get_person(
4070 &self,
4071 _: &str,
4072 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
4073 unimplemented!()
4074 }
4075 async fn get_items_by_person(
4076 &self,
4077 _: &str,
4078 _: Option<repo_types::GetItemsOptions>,
4079 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4080 unimplemented!()
4081 }
4082 async fn get_similar_items(
4083 &self,
4084 _: &str,
4085 _: Option<usize>,
4086 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4087 unimplemented!()
4088 }
4089 async fn create_playlist(
4090 &self,
4091 _: &str,
4092 _: &[String],
4093 ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
4094 unimplemented!()
4095 }
4096 async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
4097 unimplemented!()
4098 }
4099 async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
4100 unimplemented!()
4101 }
4102 async fn get_playlist_items(
4103 &self,
4104 _: &str,
4105 ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
4106 unimplemented!()
4107 }
4108 async fn add_to_playlist(
4109 &self,
4110 _: &str,
4111 _: &[String],
4112 ) -> Result<(), repo_types::RepoError> {
4113 unimplemented!()
4114 }
4115 async fn remove_from_playlist(
4116 &self,
4117 _: &str,
4118 _: &[String],
4119 ) -> Result<(), repo_types::RepoError> {
4120 unimplemented!()
4121 }
4122 async fn move_playlist_item(
4123 &self,
4124 _: &str,
4125 _: &str,
4126 _: u32,
4127 ) -> Result<(), repo_types::RepoError> {
4128 unimplemented!()
4129 }
4130 }
4131
4132 #[tokio::test]
4135 async fn test_video_playback_ended_offers_next_episode() {
4136 let controller = PlayerController::default();
4137 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
4138
4139 let decision = controller
4140 .on_video_playback_ended("ep2", repo)
4141 .await
4142 .expect("decision should succeed");
4143
4144 match decision {
4145 AutoplayDecision::ShowNextEpisodePopup {
4146 current_episode,
4147 next_episode,
4148 auto_advance,
4149 ..
4150 } => {
4151 assert_eq!(current_episode.id, "ep2");
4152 assert_eq!(next_episode.id, "ep3");
4153 assert!(auto_advance, "default settings should auto-advance");
4154 }
4155 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4156 }
4157 }
4158
4159 #[tokio::test]
4166 async fn test_next_episode_rolls_over_to_the_next_season() {
4167 let controller = PlayerController::default();
4168 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
4169
4170 let decision = controller
4171 .on_video_playback_ended("ep2", repo)
4172 .await
4173 .expect("decision should succeed");
4174
4175 match decision {
4176 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4177 assert_eq!(
4178 next_episode.id, "s2e1",
4179 "the first episode of the next season follows the last of this one"
4180 );
4181 }
4182 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4183 }
4184 }
4185
4186 #[tokio::test]
4190 async fn test_next_episode_skips_an_empty_season() {
4191 let controller = PlayerController::default();
4192 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
4193
4194 let decision = controller
4195 .on_video_playback_ended("ep1", repo)
4196 .await
4197 .expect("decision should succeed");
4198
4199 match decision {
4200 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4201 assert_eq!(next_episode.id, "s3e1");
4202 }
4203 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4204 }
4205 }
4206
4207 #[tokio::test]
4213 async fn test_sleep_timer_still_stops_at_a_season_boundary() {
4214 let controller = PlayerController::default();
4215 controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
4216 controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
4217
4218 let episode = MediaItem {
4219 transport: None,
4220 media_type: MediaType::Audio,
4221 item_type: Some("Episode".to_string()),
4222 series_id: Some("series1".to_string()),
4223 duration: Some(180.0),
4224 source: MediaSource::Remote {
4225 stream_url: "http://example.com/ep1.mp3".to_string(),
4226 jellyfin_item_id: "ep1".to_string(),
4227 },
4228 ..create_test_items(1).remove(0)
4229 };
4230 controller.play_queue(vec![episode], 0).unwrap();
4231 controller.seek(180.0).unwrap();
4233 controller.take_end_reason();
4234
4235 let decision = controller.on_playback_ended().await.unwrap();
4236
4237 assert!(
4238 matches!(decision, AutoplayDecision::Stop),
4239 "the last episode the timer allows must stop, not roll into season 2 (got {:?})",
4240 decision
4241 );
4242 }
4243
4244 #[tokio::test]
4246 async fn test_video_playback_ended_last_episode_stops() {
4247 let controller = PlayerController::default();
4248 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
4249
4250 let decision = controller
4251 .on_video_playback_ended("ep3", repo)
4252 .await
4253 .expect("decision should succeed");
4254
4255 assert!(matches!(decision, AutoplayDecision::Stop));
4256 }
4257
4258 #[tokio::test]
4263 async fn test_playback_ended_uses_controller_repository_for_episodes() {
4264 let controller = PlayerController::default();
4265 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4266
4267 let episode = MediaItem {
4269 transport: None,
4271 media_type: MediaType::Video,
4272 source: MediaSource::Remote {
4273 stream_url: "http://example.com/ep1.mkv".to_string(),
4274 jellyfin_item_id: "ep1".to_string(),
4275 },
4276 ..create_test_items(1).remove(0)
4277 };
4278 controller.play_queue(vec![episode], 0).unwrap();
4279
4280 controller.take_end_reason();
4282
4283 let decision = controller.on_playback_ended().await.unwrap();
4284
4285 match decision {
4286 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4287 assert_eq!(next_episode.id, "ep2");
4288 }
4289 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4290 }
4291 }
4292
4293 #[tokio::test]
4300 async fn test_playback_ended_background_audio_episode_advances() {
4301 let controller = PlayerController::default();
4302 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4303
4304 let episode = MediaItem {
4306 transport: None,
4308 item_type: Some("Episode".to_string()),
4309 media_type: MediaType::Audio, series_id: Some("series1".to_string()),
4311 duration: Some(180.0),
4312 source: MediaSource::Remote {
4313 stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
4314 jellyfin_item_id: "ep2".to_string(),
4315 },
4316 ..create_test_items(1).remove(0)
4317 };
4318 controller.play_queue(vec![episode], 0).unwrap();
4319
4320 controller.seek(180.0).unwrap();
4322 controller.take_end_reason();
4324
4325 let decision = controller.on_playback_ended().await.unwrap();
4326
4327 match decision {
4328 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4329 assert_eq!(next_episode.id, "ep3");
4330 }
4331 other => panic!(
4332 "background-audio episode end must advance to the next episode, got {:?}",
4333 other
4334 ),
4335 }
4336 }
4337
4338 #[tokio::test]
4342 async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
4343 let controller = PlayerController::default();
4344 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4345
4346 controller
4347 .advance_to_next_episode_audio_only("ep2")
4348 .await
4349 .expect("advance should succeed");
4350
4351 let current = controller
4352 .queue
4353 .lock_safe()
4354 .current()
4355 .cloned()
4356 .expect("an item should be loaded");
4357 assert_eq!(current.id, "ep2");
4358 assert_eq!(current.media_type, MediaType::Audio);
4359 assert_eq!(current.item_type.as_deref(), Some("Episode"));
4360 assert_eq!(current.series_id.as_deref(), Some("series1"));
4361 match ¤t.source {
4363 MediaSource::Remote { stream_url, .. } => {
4364 assert!(
4365 stream_url.contains("audio"),
4366 "expected audio-only URL, got {}",
4367 stream_url
4368 );
4369 }
4370 other => panic!("expected Remote source, got {:?}", other),
4371 }
4372
4373 assert!(controller.current_is_audio_episode());
4376 }
4377
4378 #[tokio::test]
4386 async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
4387 let controller = PlayerController::default();
4388 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4389
4390 controller.set_background_audio_base(1200.0);
4392
4393 controller
4394 .advance_to_next_episode_audio_only("ep2")
4395 .await
4396 .expect("advance should succeed");
4397
4398 assert_eq!(
4399 controller.take_background_audio_base(),
4400 0.0,
4401 "the next episode starts at its own zero, so the previous handoff \
4402 base must not survive the advance"
4403 );
4404 }
4405
4406 #[tokio::test]
4418 async fn test_auto_advance_background_audio_episode_advances_in_backend() {
4419 let controller = PlayerController::default();
4420 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4421
4422 let episode = MediaItem {
4424 transport: None,
4426 id: "ep2".to_string(),
4427 item_type: Some("Episode".to_string()),
4428 media_type: MediaType::Audio,
4429 series_id: Some("series1".to_string()),
4430 source: MediaSource::Remote {
4431 stream_url: "http://example.com/ep2-audio.mp3".to_string(),
4432 jellyfin_item_id: "ep2".to_string(),
4433 },
4434 ..create_test_items(1).remove(0)
4435 };
4436 controller.play_queue(vec![episode], 0).unwrap();
4437
4438 let next = make_repo_episode("ep3", 3);
4439 controller.auto_advance_to_next_episode(next, 10).await;
4440
4441 let current = controller
4442 .queue
4443 .lock_safe()
4444 .current()
4445 .cloned()
4446 .expect("an item should still be loaded");
4447 assert_eq!(
4448 current.id, "ep3",
4449 "background audio-only episode must advance in the backend, not wait \
4450 for a frontend navigation that cannot happen while backgrounded"
4451 );
4452 assert_eq!(current.media_type, MediaType::Audio);
4453 assert!(controller.current_is_audio_episode());
4454 }
4455
4456 fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
4460 MediaItem {
4461 transport: None,
4463 id: "ep2".to_string(),
4464 item_type: Some("Episode".to_string()),
4465 media_type: MediaType::Audio,
4466 series_id: Some("series1".to_string()),
4467 duration: Some(runtime_seconds),
4468 source: MediaSource::Remote {
4469 stream_url:
4470 "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
4471 .to_string(),
4472 jellyfin_item_id: "ep2".to_string(),
4473 },
4474 ..create_test_items(1).remove(0)
4475 }
4476 }
4477
4478 fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
4482 MediaItem {
4483 transport: None,
4485 source: MediaSource::Local {
4486 file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
4487 jellyfin_item_id: Some("ep2".to_string()),
4488 },
4489 ..audio_only_episode(runtime_seconds)
4490 }
4491 }
4492
4493 #[tokio::test]
4500 async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
4501 let controller = PlayerController::default();
4502 controller
4503 .play_queue(vec![local_audio_only_episode(1500.0)], 0)
4504 .unwrap();
4505 controller.enter_background_audio(0.0);
4507
4508 controller.seek_absolute(900.0).await.unwrap();
4509
4510 assert_eq!(controller.position(), 900.0);
4511 }
4512
4513 #[tokio::test]
4525 async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
4526 let controller = PlayerController::default();
4527 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4528
4529 controller
4530 .play_queue(vec![audio_only_episode(1500.0)], 0)
4531 .unwrap();
4532 controller.seek(600.0).unwrap();
4534 controller.take_end_reason();
4535
4536 let decision = controller.on_playback_ended().await.unwrap();
4537
4538 match decision {
4539 AutoplayDecision::ResumeStream { position } => {
4540 assert_eq!(position, 600.0, "must resume where the stream died");
4541 }
4542 other => panic!(
4543 "a stream that ended 15 minutes short of the runtime must resume, \
4544 not run end-of-episode logic; got {:?}",
4545 other
4546 ),
4547 }
4548 }
4549
4550 #[tokio::test]
4563 async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
4564 let controller = PlayerController::default();
4565 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4566 controller
4567 .play_queue(vec![audio_only_episode(1500.0)], 0)
4568 .unwrap();
4569
4570 controller.enter_background_audio(1200.0);
4572
4573 controller.seek_absolute(1490.0).await.unwrap();
4575
4576 let url = {
4577 let queue = controller.queue();
4578 let queue = queue.lock_safe();
4579 match &queue.current().unwrap().source {
4580 MediaSource::Remote { stream_url, .. } => stream_url.clone(),
4581 other => panic!("expected a remote source, got {:?}", other),
4582 }
4583 };
4584 assert!(
4585 url.contains(&format!(
4586 "StartTimeTicks={}",
4587 (1490.0 * 10_000_000.0) as i64
4588 )),
4589 "the stream must be re-opened at the absolute position; got {}",
4590 url
4591 );
4592
4593 assert_eq!(
4594 *controller.background_audio_base.lock_safe(),
4595 1490.0,
4596 "the re-opened stream's zero is the position it was opened at, or \
4597 every later reading is off by the difference"
4598 );
4599 }
4600
4601 #[tokio::test]
4606 async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
4607 let controller = PlayerController::default();
4608 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4609 controller
4610 .play_queue(vec![audio_only_episode(1500.0)], 0)
4611 .unwrap();
4612
4613 controller.seek_absolute(300.0).await.unwrap();
4614
4615 assert_eq!(controller.position(), 300.0);
4616 assert_eq!(
4617 *controller.background_audio_base.lock_safe(),
4618 0.0,
4619 "an ordinary seek must not invent a handoff base"
4620 );
4621 }
4622
4623 #[test]
4643 fn test_absolute_position_floors_at_the_handoff_base() {
4644 let controller = PlayerController::default();
4645 controller.enter_background_audio(18.4);
4646
4647 assert_eq!(controller.position(), 0.0);
4649 assert_eq!(
4650 controller.absolute_position(),
4651 18.4,
4652 "the audio stream's zero IS the handoff point, so the position can \
4653 never legitimately read below it"
4654 );
4655 }
4656
4657 #[test]
4662 fn test_absolute_position_does_not_double_count_the_handoff_base() {
4663 let controller = PlayerController::default();
4664 controller.enter_background_audio(18.4);
4665
4666 controller.seek(120.0).unwrap();
4668
4669 assert_eq!(controller.absolute_position(), 120.0);
4670 }
4671
4672 #[test]
4679 fn test_webview_position_reports_become_the_controllers_position() {
4680 let controller = PlayerController::default();
4681
4682 controller.report_html5_position(253.4, 2640.0);
4683
4684 assert_eq!(controller.absolute_position(), 253.4);
4685 assert_eq!(controller.observed_duration(), Some(2640.0));
4686 }
4687
4688 #[test]
4694 fn test_webview_teardown_clears_the_observed_position() {
4695 let controller = PlayerController::default();
4696 controller.report_html5_position(253.4, 2640.0);
4697
4698 controller.report_html5_state("stopped".to_string(), None);
4699
4700 assert_eq!(controller.absolute_position(), 0.0);
4701 }
4702
4703 #[test]
4708 fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
4709 let controller = PlayerController::default();
4710 controller.report_html5_position(253.4, 2640.0);
4711
4712 controller.enter_background_audio(18.4);
4713
4714 assert_eq!(
4715 controller.absolute_position(),
4716 18.4,
4717 "the video element is gone; only the handoff base describes the \
4718 stream that is now playing"
4719 );
4720 }
4721
4722 #[tokio::test]
4730 async fn test_a_stop_at_zero_is_never_reported() {
4731 let controller = PlayerController::default();
4732 let reports = Arc::new(CapturingReports::new());
4733 controller.set_report_sink(reports.clone());
4734 controller
4735 .play_queue(vec![audio_only_episode(1500.0)], 0)
4736 .unwrap();
4737
4738 controller.stop().unwrap();
4740
4741 assert!(
4742 reports.stops().is_empty(),
4743 "a zero-position stop must be withheld, not sent; got {:?}",
4744 reports.stops()
4745 );
4746 }
4747
4748 #[tokio::test]
4757 async fn test_a_stop_reports_the_position_actually_reached() {
4758 let webview = PlayerController::default();
4760 let webview_reports = Arc::new(CapturingReports::new());
4761 webview.set_report_sink(webview_reports.clone());
4762 webview
4763 .play_queue(vec![audio_only_episode(1500.0)], 0)
4764 .unwrap();
4765 webview.report_html5_position(253.0, 1500.0);
4766
4767 webview.stop().unwrap();
4768
4769 assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4770
4771 let native = PlayerController::default();
4773 let native_reports = Arc::new(CapturingReports::new());
4774 native.set_report_sink(native_reports.clone());
4775 native
4776 .play_queue(vec![audio_only_episode(1500.0)], 0)
4777 .unwrap();
4778 native.seek(253.0).unwrap();
4779
4780 native.stop().unwrap();
4781
4782 assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4783 }
4784
4785 #[tokio::test]
4795 async fn test_a_finished_audio_only_episode_is_reported_complete() {
4796 let controller = PlayerController::default();
4797 let reports = Arc::new(CapturingReports::new());
4798 controller.set_report_sink(reports.clone());
4799 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4800 controller
4801 .play_queue(vec![audio_only_episode(1500.0)], 0)
4802 .unwrap();
4803 controller.seek(1499.0).unwrap();
4805 controller.take_end_reason();
4806
4807 controller.on_playback_ended().await.unwrap();
4808
4809 assert_eq!(
4810 reports.stops(),
4811 vec![("ep2".to_string(), 1500.0)],
4812 "the finished episode must be reported stopped at its runtime, or \
4813 Jellyfin's ≥90% rule never marks it played"
4814 );
4815 }
4816
4817 #[tokio::test]
4823 async fn test_a_truncated_stream_reports_no_completion() {
4824 let controller = PlayerController::default();
4825 let reports = Arc::new(CapturingReports::new());
4826 controller.set_report_sink(reports.clone());
4827 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4828 controller
4829 .play_queue(vec![audio_only_episode(1500.0)], 0)
4830 .unwrap();
4831 controller.seek(600.0).unwrap();
4832 controller.take_end_reason();
4833
4834 let decision = controller.on_playback_ended().await.unwrap();
4835
4836 assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
4837 assert!(
4838 reports.stops().is_empty(),
4839 "a dropped connection is not a finished episode; got {:?}",
4840 reports.stops()
4841 );
4842 }
4843
4844 #[tokio::test]
4852 async fn test_webview_position_ticks_report_progress_to_the_server() {
4853 let controller = PlayerController::default();
4854 let reports = Arc::new(CapturingReports::new());
4855 controller.set_report_sink(reports.clone());
4856 controller
4857 .play_queue(vec![audio_only_episode(1500.0)], 0)
4858 .unwrap();
4859
4860 controller.report_html5_position(253.4, 1500.0);
4861
4862 assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
4863 }
4864
4865 #[tokio::test]
4870 async fn test_progress_reports_are_throttled_not_sent_per_tick() {
4871 let controller = PlayerController::default();
4872 let reports = Arc::new(CapturingReports::new());
4873 controller.set_report_sink(reports.clone());
4874 controller
4875 .play_queue(vec![audio_only_episode(1500.0)], 0)
4876 .unwrap();
4877
4878 for tick in 0..12 {
4879 controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
4880 }
4881
4882 assert_eq!(
4883 reports.progress().len(),
4884 1,
4885 "twelve ticks inside one throttle window are one report"
4886 );
4887 }
4888
4889 #[tokio::test]
4899 async fn test_truncated_check_uses_the_absolute_position() {
4900 let controller = PlayerController::default();
4901 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4902
4903 controller
4904 .play_queue(vec![audio_only_episode(1500.0)], 0)
4905 .unwrap();
4906 controller.set_background_audio_base(1440.0);
4909 controller.seek(1496.0).unwrap();
4910 controller.take_end_reason();
4911
4912 let decision = controller.on_playback_ended().await.unwrap();
4913
4914 assert!(
4915 matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
4916 "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
4917 decision
4918 );
4919 }
4920
4921 #[tokio::test]
4925 async fn test_repeated_truncation_at_the_same_position_gives_up() {
4926 let controller = PlayerController::default();
4927 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4928
4929 controller
4930 .play_queue(vec![audio_only_episode(1500.0)], 0)
4931 .unwrap();
4932 controller.seek(600.0).unwrap();
4933
4934 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4935 controller.take_end_reason();
4936 let decision = controller.on_playback_ended().await.unwrap();
4937 assert!(
4938 matches!(decision, AutoplayDecision::ResumeStream { .. }),
4939 "attempt {} should still resume, got {:?}",
4940 attempt,
4941 decision
4942 );
4943 }
4944
4945 controller.take_end_reason();
4946 let decision = controller.on_playback_ended().await.unwrap();
4947 assert!(
4948 !matches!(decision, AutoplayDecision::ResumeStream { .. }),
4949 "a stream stuck at the same position must stop retrying, got {:?}",
4950 decision
4951 );
4952 }
4953
4954 #[tokio::test]
4958 async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
4959 let controller = PlayerController::default();
4960
4961 let mut items = create_test_items(2);
4962 items[0].duration = Some(1500.0);
4963 controller.play_queue(items, 0).unwrap();
4964 controller.seek(60.0).unwrap();
4965 controller.take_end_reason();
4966
4967 let decision = controller.on_playback_ended().await.unwrap();
4968 assert!(
4969 matches!(decision, AutoplayDecision::AdvanceToNext),
4970 "plain queue audio must keep advancing, got {:?}",
4971 decision
4972 );
4973 }
4974
4975 #[tokio::test]
4981 async fn test_recoverable_error_resumes_a_music_track() {
4982 let controller = PlayerController::default();
4983
4984 let mut items = create_test_items(3);
4985 for item in &mut items {
4986 item.source = MediaSource::Remote {
4987 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
4988 jellyfin_item_id: item.id.clone(),
4989 };
4990 }
4991 controller.play_queue(items, 1).unwrap();
4992 controller.seek(45.0).unwrap();
4993
4994 let (position, _) = controller
4995 .recoverable_error_resume()
4996 .expect("a streamed music track must be resumable after a network error");
4997 assert_eq!(position, 45.0);
4998 }
4999
5000 #[tokio::test]
5004 async fn test_resume_keeps_the_rest_of_the_queue() {
5005 let controller = PlayerController::default();
5006
5007 let mut items = create_test_items(3);
5008 for item in &mut items {
5009 item.source = MediaSource::Remote {
5010 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
5011 jellyfin_item_id: item.id.clone(),
5012 };
5013 }
5014 controller.play_queue(items, 1).unwrap();
5015
5016 controller
5017 .resume_stream_at(45.0)
5018 .await
5019 .expect("resume should succeed");
5020
5021 let queue = controller.queue.lock_safe();
5022 assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
5023 assert_eq!(queue.current_index(), Some(1), "still on the same track");
5024 assert_eq!(queue.current().unwrap().id, "item_1");
5025 }
5026
5027 #[tokio::test]
5031 async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
5032 let controller = PlayerController::default();
5033
5034 let mut items = create_test_items(1);
5035 items[0].source = MediaSource::Remote {
5036 stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
5037 jellyfin_item_id: "item_0".to_string(),
5038 };
5039 controller.play_queue(items, 0).unwrap();
5040
5041 controller.resume_stream_at(45.0).await.unwrap();
5042
5043 match &controller.queue.lock_safe().current().unwrap().source {
5044 MediaSource::Remote { stream_url, .. } => {
5045 assert_eq!(
5046 stream_url, "http://s/Audio/item_0/stream?Static=true",
5047 "a seekable stream's URL must be left alone"
5048 );
5049 }
5050 other => panic!("expected Remote source, got {:?}", other),
5051 }
5052 assert_eq!(
5053 controller.position(),
5054 45.0,
5055 "and it must land at the position"
5056 );
5057 }
5058
5059 #[tokio::test]
5062 async fn test_recoverable_error_ignores_local_media() {
5063 let controller = PlayerController::default();
5064
5065 let mut items = create_test_items(1);
5066 items[0].source = MediaSource::Local {
5067 file_path: "/music/track.flac".into(),
5068 jellyfin_item_id: Some("item_0".to_string()),
5069 };
5070 controller.play_queue(items, 0).unwrap();
5071 controller.seek(45.0).unwrap();
5072
5073 assert!(controller.recoverable_error_resume().is_none());
5074 }
5075
5076 #[tokio::test]
5081 async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
5082 let controller = PlayerController::default();
5083
5084 controller
5085 .play_queue(vec![audio_only_episode(1500.0)], 0)
5086 .unwrap();
5087 controller.seek(600.0).unwrap();
5088
5089 let mut waits = Vec::new();
5090 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
5091 let (position, delay) = controller
5092 .recoverable_error_resume()
5093 .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
5094 assert_eq!(position, 600.0);
5095 waits.push(delay);
5096 }
5097 assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
5098 assert!(
5099 controller.recoverable_error_resume().is_none(),
5100 "a stream that keeps failing at the same spot must surface the error"
5101 );
5102 }
5103
5104 #[tokio::test]
5107 async fn test_recoverable_error_ignores_direct_url_playback() {
5108 let controller = PlayerController::default();
5109 controller.play_queue(create_test_items(2), 0).unwrap();
5110
5111 assert!(controller.recoverable_error_resume().is_none());
5112 }
5113
5114 #[tokio::test]
5118 async fn test_resume_truncated_stream_reloads_at_position() {
5119 let controller = PlayerController::default();
5120
5121 controller
5122 .play_queue(vec![audio_only_episode(1500.0)], 0)
5123 .unwrap();
5124 controller.set_background_audio_base(0.0);
5125
5126 controller
5127 .resume_stream_at(600.0)
5128 .await
5129 .expect("resume should succeed");
5130
5131 let current = controller
5132 .queue
5133 .lock_safe()
5134 .current()
5135 .cloned()
5136 .expect("the same item should still be loaded");
5137 assert_eq!(current.id, "ep2", "resume must not change the item");
5138 match ¤t.source {
5139 MediaSource::Remote { stream_url, .. } => {
5140 assert!(
5141 stream_url.contains("StartTimeTicks=6000000000"),
5142 "stream must re-open at 600s, got {}",
5143 stream_url
5144 );
5145 assert!(
5146 stream_url.contains("AudioStreamIndex=2"),
5147 "the selected audio track must survive the resume, got {}",
5148 stream_url
5149 );
5150 }
5151 other => panic!("expected Remote source, got {:?}", other),
5152 }
5153 assert_eq!(
5154 controller.take_background_audio_base(),
5155 600.0,
5156 "the re-opened stream's zero is the resume position"
5157 );
5158 }
5159
5160 #[tokio::test]
5164 async fn test_auto_advance_foreground_video_episode_uses_countdown() {
5165 let controller = PlayerController::default();
5166 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
5167
5168 let episode = MediaItem {
5169 transport: None,
5171 id: "ep2".to_string(),
5172 item_type: Some("Episode".to_string()),
5173 media_type: MediaType::Video,
5174 series_id: Some("series1".to_string()),
5175 source: MediaSource::Remote {
5176 stream_url: "http://example.com/ep2.m3u8".to_string(),
5177 jellyfin_item_id: "ep2".to_string(),
5178 },
5179 ..create_test_items(1).remove(0)
5180 };
5181 controller.play_queue(vec![episode], 0).unwrap();
5182
5183 let next = make_repo_episode("ep3", 3);
5184 controller.auto_advance_to_next_episode(next, 10).await;
5185
5186 let current = controller
5187 .queue
5188 .lock_safe()
5189 .current()
5190 .cloned()
5191 .expect("an item should still be loaded");
5192 assert_eq!(
5193 current.id, "ep2",
5194 "foreground video advance is frontend-driven; the backend must not \
5195 swap the queue item out from under it"
5196 );
5197 }
5198
5199 #[tokio::test]
5202 async fn test_playback_ended_without_repository_stops() {
5203 let controller = PlayerController::default();
5204
5205 let episode = MediaItem {
5206 transport: None,
5208 media_type: MediaType::Video,
5209 source: MediaSource::Remote {
5210 stream_url: "http://example.com/ep1.mkv".to_string(),
5211 jellyfin_item_id: "ep1".to_string(),
5212 },
5213 ..create_test_items(1).remove(0)
5214 };
5215 controller.play_queue(vec![episode], 0).unwrap();
5216 controller.take_end_reason();
5217
5218 let decision = controller.on_playback_ended().await.unwrap();
5219 assert!(matches!(decision, AutoplayDecision::Stop));
5220 }
5221}