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(any(target_os = "linux", target_os = "windows"))]
19pub mod mpv_command;
20#[cfg(target_os = "linux")]
21pub mod mpv_player;
22#[cfg(any(target_os = "linux", target_os = "windows"))]
23pub mod mpv_tracks;
24pub mod queue;
25pub mod seek;
26pub mod session;
27pub mod sleep_timer;
28pub mod state;
29pub mod stream_end;
30pub mod track_switch;
31
32#[cfg(test)]
33mod mpv_backend_test;
34
35pub mod lock_order;
38
39pub mod jni_guard;
42
43#[cfg(target_os = "android")]
45pub mod android;
46
47#[cfg(any(target_os = "linux", target_os = "windows"))]
48pub mod mpv_backend;
49
50pub mod native_video;
53
54#[cfg(target_os = "linux")]
60pub mod mpv_render;
61
62#[cfg(target_os = "linux")]
67pub mod video_surface;
68
69#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
73pub mod webview_audio_backend;
74
75use crate::repository::stream_selection::StreamSelection;
77pub use autoplay::{AutoplayDecision, AutoplaySettings};
78pub use backend::{NullBackend, PlayerBackend, PlayerError};
79pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
80pub use legacy_player::LegacyPlayer;
81pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
82pub use media_player::{MediaPlayer, OpenRequest, Phase};
83pub use queue::{QueueManager, RepeatMode};
84pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
85pub use session::{MediaSessionManager, MediaSessionType};
86pub use sleep_timer::{SleepTimerMode, SleepTimerState};
87pub use state::{EndReason, PlayerState};
88pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchStrategy};
89
90#[cfg(target_os = "android")]
92pub use android::ExoPlayerBackend;
93
94#[cfg(any(target_os = "linux", target_os = "windows"))]
95pub use mpv_backend::MpvBackend;
96
97#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
98pub use webview_audio_backend::WebviewAudioBackend;
99
100#[cfg(target_os = "android")]
101pub use android::{
102 disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
103 set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
104};
105
106pub trait PlaybackReportSink: Send + Sync {
117 fn send(&self, operation: PlaybackOperation);
119}
120
121struct ReporterSink {
127 reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
128}
129
130impl PlaybackReportSink for ReporterSink {
131 fn send(&self, operation: PlaybackOperation) {
132 let reporter = self.reporter.clone();
133 let task = async move {
134 let guard = reporter.lock().await;
135 let Some(reporter) = guard.as_ref() else {
136 warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
137 return;
138 };
139 if let Err(e) = reporter.report(operation, true).await {
141 log::error!("[PlayerController] Failed to report playback: {}", e);
142 }
143 };
144
145 if let Ok(handle) = tokio::runtime::Handle::try_current() {
146 handle.spawn(task);
147 } else {
148 std::thread::spawn(move || match tokio::runtime::Runtime::new() {
149 Ok(rt) => rt.block_on(task),
150 Err(e) => log::error!(
151 "[PlayerController] No runtime available to report playback: {}",
152 e
153 ),
154 });
155 }
156 }
157}
158
159fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
169 match runtime {
170 Some(runtime) if runtime > 0.0 => runtime,
171 _ => last_position.max(0.0),
172 }
173}
174
175#[cfg_attr(not(target_os = "android"), allow(dead_code))]
183const RESUME_BACKOFF_STEP_SECS: u64 = 2;
184
185#[derive(specta::Type, Debug, Clone, PartialEq, serde::Serialize)]
190#[serde(rename_all = "camelCase")]
191pub struct BackgroundAudioResume {
192 pub item_id: Option<String>,
195 pub position_seconds: f64,
197}
198
199#[derive(Debug, Clone)]
208#[cfg_attr(not(target_os = "android"), allow(dead_code))]
211pub struct LockscreenMetadata {
212 pub title: String,
213 pub artist: String,
214 pub album: Option<String>,
215 pub duration_ms: i64,
217 pub position_ms: i64,
219 pub is_playing: bool,
220}
221
222pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
230 #[cfg(target_os = "android")]
231 {
232 return android::update_lockscreen_metadata(_meta);
233 }
234 #[cfg(not(target_os = "android"))]
235 {
236 Ok(())
237 }
238}
239
240pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
246 #[cfg(target_os = "android")]
247 {
248 return android::set_position_offset(_offset_seconds);
249 }
250 #[cfg(not(target_os = "android"))]
251 {
252 Ok(())
253 }
254}
255
256use crate::utils::lock::MutexSafe;
257use log::{debug, error, info, warn};
258use std::sync::{Arc, Mutex};
259use std::time::Duration;
260use tokio::sync::Mutex as TokioMutex;
261
262use crate::jellyfin::JellyfinClient;
263use crate::playback_reporting::{
264 EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
265};
266use crate::repository::MediaRepository;
267use crate::settings::AudioSettings;
268use crate::utils::conversions::seconds_to_ticks;
269
270pub struct PlayerController {
272 backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
275 queue: Arc<Mutex<QueueManager>>,
276 jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
277 muted: bool,
278
279 sleep_timer: Arc<Mutex<SleepTimerState>>,
281
282 autoplay_settings: Arc<Mutex<AutoplaySettings>>,
284
285 repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
287
288 event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
290
291 countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
293
294 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
296
297 reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
300
301 position_throttler: Arc<EventThrottler>,
304
305 end_reason: Arc<Mutex<Option<EndReason>>>,
307
308 autoplay_episode_count: Arc<Mutex<u32>>,
310
311 background_audio_base: Arc<Mutex<f64>>,
325
326 background_audio_active: Arc<Mutex<bool>>,
337
338 stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
346
347 html5_playing: Arc<Mutex<Option<bool>>>,
358
359 reported_time: Arc<Mutex<stream_end::ObservedTime>>,
371}
372
373impl PlayerController {
374 pub fn new(
375 backend: Box<dyn MediaPlayer>,
376 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
377 position_throttler: Arc<EventThrottler>,
378 ) -> Self {
379 let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
380 reporter: playback_reporter.clone(),
381 });
382 let controller = Self {
383 backend: Arc::new(Mutex::new(backend)),
384 queue: Arc::new(Mutex::new(QueueManager::new())),
385 jellyfin_client: Arc::new(Mutex::new(None)),
386 muted: false,
387 sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
388 autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
389 repository: Arc::new(Mutex::new(None)),
390 event_emitter: Arc::new(Mutex::new(None)),
391 countdown_cancel: Arc::new(Mutex::new(None)),
392 playback_reporter,
393 reports: Arc::new(Mutex::new(reports)),
394 position_throttler,
395 end_reason: Arc::new(Mutex::new(None)),
396 autoplay_episode_count: Arc::new(Mutex::new(0)),
397 background_audio_base: Arc::new(Mutex::new(0.0)),
398 background_audio_active: Arc::new(Mutex::new(false)),
399 stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
400 html5_playing: Arc::new(Mutex::new(None)),
401 reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
402 };
403
404 controller.start_timer_thread();
406
407 controller
408 }
409
410 pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
412 let mut jellyfin = self.jellyfin_client.lock_safe();
413 *jellyfin = client;
414 log::info!(
415 "[PlayerController] Jellyfin client configured: {}",
416 jellyfin.is_some()
417 );
418 }
419
420 pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
422 self.jellyfin_client.clone()
423 }
424
425 pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
432 *self.repository.lock_safe() = Some(repo);
433 }
434
435 pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
438 let mut reporter_guard = self.playback_reporter.lock().await;
439 *reporter_guard = reporter;
440 log::info!(
441 "[PlayerController] Playback reporter configured: {}",
442 reporter_guard.is_some()
443 );
444 }
445
446 #[allow(dead_code)]
449 pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
450 self.playback_reporter.clone()
451 }
452
453 #[allow(dead_code)]
456 pub fn position_throttler(&self) -> Arc<EventThrottler> {
457 self.position_throttler.clone()
458 }
459
460 fn set_end_reason(&self, reason: EndReason) {
462 log::debug!("[PlayerController] Setting end reason: {:?}", reason);
463 *self.end_reason.lock_safe() = Some(reason);
464 }
465
466 fn take_end_reason(&self) -> Option<EndReason> {
468 self.end_reason.lock_safe().take()
469 }
470
471 fn peek_end_reason(&self) -> Option<EndReason> {
478 *self.end_reason.lock_safe()
479 }
480
481 fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
494 log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
495 *end_reason.lock_safe() = Some(EndReason::UserStop);
496 }
497
498 fn increment_autoplay_count(&self) -> bool {
500 let max = self.autoplay_settings.lock_safe().max_episodes;
501
502 if max == 0 {
503 return false;
505 }
506
507 let mut count = self.autoplay_episode_count.lock_safe();
508 *count += 1;
509 debug!(
510 "[PlayerController] Autoplay episode count: {}/{}",
511 *count, max
512 );
513
514 *count >= max
515 }
516
517 fn reset_autoplay_count(&self) {
519 let mut count = self.autoplay_episode_count.lock_safe();
520 if *count > 0 {
521 debug!(
522 "[PlayerController] Resetting autoplay episode counter (was {})",
523 *count
524 );
525 }
526 *count = 0;
527 }
528
529 pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
531 debug!("[PlayerController] play_item: {}", item.title);
532
533 self.reset_autoplay_count();
535
536 {
538 let mut queue = self.queue.lock_safe();
539 queue.set_queue(vec![item.clone()], 0);
540 }
541
542 self.load_and_play(&item)?;
544
545 Ok(())
546 }
547
548 pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
551 debug!("[PlayerController] load_and_play: {}", item.title);
552
553 self.set_end_reason(EndReason::NewTrackLoaded);
555
556 *self.html5_playing.lock_safe() = None;
578
579 let mut backend = self.backend.lock_safe();
580 backend.open(OpenRequest::new(
583 item.clone(),
584 StreamSelection::for_queued_item(
585 item.playback_url(),
586 item.transport,
587 item.needs_transcoding,
588 ),
589 ))?;
590 drop(backend);
591
592 self.clear_reported_time();
595
596 if let Some(jellyfin_id) = item.jellyfin_id() {
598 let context = if item.album_id.is_some() {
600 Some(PlaybackContext {
601 context_type: "container".to_string(),
602 context_id: item.album_id.clone(),
603 })
604 } else {
605 None
606 };
607
608 let position = self.absolute_position();
614
615 log::info!(
616 "[PlayerController] Reporting playback start: {} @ {:.1}s",
617 jellyfin_id,
618 position
619 );
620 self.report(PlaybackOperation::Start {
621 item_id: jellyfin_id.to_string(),
622 position_ticks: seconds_to_ticks(position),
623 context,
624 });
625 }
626
627 Ok(())
628 }
629
630 pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
632 self.play_queue_from(items, start_index, None)
633 }
634
635 pub fn play_queue_from(
642 &self,
643 items: Vec<MediaItem>,
644 start_index: usize,
645 start_position: Option<f64>,
646 ) -> Result<(), PlayerError> {
647 debug!(
648 "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
649 items.len(),
650 start_index,
651 start_position
652 );
653
654 self.reset_autoplay_count();
656
657 {
658 let mut queue = self.queue.lock_safe();
659 queue.set_queue(items, start_index);
660 }
661
662 if let Some(item) = self.queue.lock_safe().current().cloned() {
664 self.load_and_play(&item)?;
665
666 if let Some(position) = start_position {
670 if position > 0.5 {
671 self.seek(position)?;
672 }
673 }
674 }
675
676 Ok(())
677 }
678
679 pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
685 debug!(
686 "[PlayerController] set_queue (no local playback): {} items, index {}",
687 items.len(),
688 start_index
689 );
690 self.reset_autoplay_count();
691 let mut queue = self.queue.lock_safe();
692 queue.set_queue(items, start_index);
693 Ok(())
694 }
695
696 pub fn is_html5_active(&self) -> bool {
701 self.html5_playing.lock_safe().is_some()
702 }
703
704 pub fn html5_is_playing(&self) -> bool {
709 self.html5_playing.lock_safe().unwrap_or(false)
710 }
711
712 fn emit_html5_control(&self, action: &str) {
714 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
715 emitter.emit(PlayerStatusEvent::ControlCommand {
716 action: action.to_string(),
717 position: None,
718 });
719 }
720 }
721
722 pub fn play(&self) -> Result<(), PlayerError> {
724 debug!("[PlayerController] play");
725 if self.is_html5_active() {
728 self.emit_html5_control("play");
729 return Ok(());
730 }
731 let mut backend = self.backend.lock_safe();
732 backend.play()
733 }
734
735 pub fn pause(&self) -> Result<(), PlayerError> {
737 if self.is_html5_active() {
738 self.emit_html5_control("pause");
739 return Ok(());
740 }
741 let mut backend = self.backend.lock_safe();
742 backend.pause()
743 }
744
745 pub fn toggle_playback(&self) -> Result<(), PlayerError> {
753 if self.is_html5_active() {
754 let action = if self.html5_is_playing() {
755 "pause"
756 } else {
757 "play"
758 };
759 self.emit_html5_control(action);
760 return Ok(());
761 }
762 let mut backend = self.backend.lock_safe();
763 if backend.snapshot().phase.is_active() {
764 backend.pause()
765 } else {
766 backend.play()
767 }
768 }
769
770 pub fn stop(&self) -> Result<(), PlayerError> {
772 self.set_end_reason(EndReason::UserStop);
774
775 let jellyfin_id = {
777 let queue = self.queue.lock_safe();
778 queue
779 .current()
780 .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
781 };
782
783 let position = self.absolute_position();
786
787 let mut backend = self.backend.lock_safe();
788 backend.close()?;
789 drop(backend);
790 self.clear_reported_time();
791
792 if self.is_background_audio_active() {
808 debug!("[PlayerController] stop: clearing an active background-audio handoff");
809 }
810 *self.background_audio_active.lock_safe() = false;
811 self.set_background_audio_base(0.0);
812 *self.html5_playing.lock_safe() = None;
813
814 if let Some(jellyfin_id) = jellyfin_id {
815 self.report_stopped_at(jellyfin_id, position);
816 }
817
818 Ok(())
819 }
820
821 fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
835 if position <= 0.0 {
836 debug!(
837 "[PlayerController] Withholding zero-position stop report for {} \
838 (nothing played; reporting it would clear the resume point)",
839 jellyfin_id
840 );
841 return;
842 }
843
844 log::info!(
845 "[PlayerController] Reporting playback stopped: {} @ {:.1}s",
846 jellyfin_id,
847 position
848 );
849 self.report(PlaybackOperation::Stopped {
850 item_id: jellyfin_id,
851 position_ticks: seconds_to_ticks(position),
852 });
853 }
854
855 pub fn next(&self) -> Result<(), PlayerError> {
860 self.reset_autoplay_count();
862
863 let next_item = {
864 let mut queue = self.queue.lock_safe();
865 queue.next().cloned()
866 };
867
868 debug!(
869 "[PlayerController] next: {:?}",
870 next_item.as_ref().map(|i| &i.title)
871 );
872
873 if let Some(item) = next_item {
874 self.load_and_play(&item)
875 } else {
876 debug!("[PlayerController] No next item, stopping");
877 self.stop()
878 }
879 }
880
881 pub fn previous(&self) -> Result<(), PlayerError> {
886 self.reset_autoplay_count();
888 {
890 let backend = self.backend.lock_safe();
891 if backend.snapshot().position.as_secs_f64() > 3.0 {
892 debug!("[PlayerController] previous: restarting current track (position > 3s)");
893 drop(backend);
894 return self.seek(0.0);
895 }
896 }
897
898 let prev_item = {
899 let mut queue = self.queue.lock_safe();
900 queue.previous().cloned()
901 };
902
903 debug!(
904 "[PlayerController] previous: {:?}",
905 prev_item.as_ref().map(|i| &i.title)
906 );
907
908 if let Some(item) = prev_item {
909 self.load_and_play(&item)
910 } else {
911 self.seek(0.0)
912 }
913 }
914
915 pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
922 let mut backend = self.backend.lock_safe();
923 backend.seek(Duration::from_secs_f64(position.max(0.0)))
924 }
925
926 pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
944 let rebuild = self.is_background_audio_active() && {
949 let queue = self.queue.lock_safe();
950 queue
951 .current()
952 .map(|item| {
953 Self::is_audio_only_video(item)
954 && matches!(item.source, MediaSource::Remote { .. })
955 })
956 .unwrap_or(false)
957 };
958
959 if rebuild {
960 return self.resume_stream_at(position.max(0.0)).await;
961 }
962
963 self.seek(position).map_err(|e| e.to_string())
964 }
965
966 pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
968 self.backend.lock_safe().set_volume(volume)
969 }
970
971 pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
973 let mut backend = self.backend.lock_safe();
974 backend.select_audio_track(Some(stream_index))
975 }
976
977 pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
979 let mut backend = self.backend.lock_safe();
980 backend.select_subtitle_track(stream_index)
981 }
982
983 pub fn state(&self) -> PlayerState {
985 let phase = self.backend.lock_safe().snapshot().phase;
986 let media = self.queue.lock_safe().current().cloned();
987 match (phase, media) {
988 (Phase::Playing, Some(media)) => PlayerState::Playing {
989 media,
990 position: self.position(),
991 duration: self.duration().unwrap_or(0.0),
992 },
993 (Phase::Paused, Some(media)) => PlayerState::Paused {
994 media,
995 position: self.position(),
996 duration: self.duration().unwrap_or(0.0),
997 },
998 (Phase::Opening, Some(media)) => PlayerState::Loading { media },
999 (Phase::Failed(error), media) => PlayerState::Error { media, error },
1000 _ => PlayerState::Idle,
1003 }
1004 }
1005
1006 pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
1010 self.backend.lock_safe().capabilities()
1011 }
1012
1013 pub fn position(&self) -> f64 {
1015 self.backend.lock_safe().snapshot().position.as_secs_f64()
1016 }
1017
1018 pub fn absolute_position(&self) -> f64 {
1041 let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
1042 let reported = self.reported_time.lock_safe().last_position();
1043 let base = if self.is_background_audio_active() {
1044 *self.background_audio_base.lock_safe()
1045 } else {
1046 0.0
1047 };
1048
1049 native.max(reported).max(base)
1050 }
1051
1052 pub fn observed_duration(&self) -> Option<f64> {
1056 self.reported_time.lock_safe().last_duration()
1057 }
1058
1059 fn clear_reported_time(&self) {
1067 self.reported_time.lock_safe().reset();
1068 }
1069
1070 #[cfg_attr(not(test), allow(dead_code))]
1075 pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
1076 *self.reports.lock_safe() = sink;
1077 }
1078
1079 fn report(&self, operation: PlaybackOperation) {
1084 let sink = self.reports.lock_safe().clone();
1085 sink.send(operation);
1086 }
1087
1088 fn current_jellyfin_id(&self) -> Option<String> {
1090 let queue = self.queue.lock_safe();
1091 queue
1092 .current()
1093 .and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
1094 }
1095
1096 pub fn duration(&self) -> Option<f64> {
1105 let usable = |d: f64| (d > 0.0).then_some(d);
1118
1119 self.backend
1120 .lock_safe()
1121 .snapshot()
1122 .duration
1123 .map(|d| d.as_secs_f64())
1124 .and_then(usable)
1125 .or_else(|| self.observed_duration().and_then(usable))
1126 .or_else(|| {
1127 self.queue
1128 .lock_safe()
1129 .current()
1130 .and_then(|item| item.duration)
1131 .and_then(usable)
1132 })
1133 }
1134
1135 pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
1137 self.queue.clone()
1138 }
1139
1140 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
1148 pub fn current_is_audio_episode(&self) -> bool {
1149 self.queue
1150 .lock_safe()
1151 .current()
1152 .map(|item| {
1153 item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
1154 })
1155 .unwrap_or(false)
1156 }
1157
1158 pub fn clear_queue(&self) {
1162 self.queue.lock_safe().clear();
1163 }
1164
1165 pub fn toggle_shuffle(&self) {
1167 self.queue.lock_safe().toggle_shuffle();
1168 }
1169
1170 pub fn cycle_repeat(&self) {
1172 self.queue.lock_safe().cycle_repeat();
1173 }
1174
1175 pub fn is_shuffle(&self) -> bool {
1177 self.queue.lock_safe().is_shuffle()
1178 }
1179
1180 pub fn repeat_mode(&self) -> RepeatMode {
1182 self.queue.lock_safe().repeat_mode()
1183 }
1184
1185 pub fn volume(&self) -> f32 {
1187 self.backend.lock_safe().snapshot().volume
1188 }
1189
1190 pub fn muted(&self) -> bool {
1192 self.muted
1193 }
1194
1195 pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
1197 self.backend.lock_safe().set_audio_settings(settings)
1198 }
1199
1200 pub fn audio_settings(&self) -> AudioSettings {
1202 self.backend.lock_safe().audio_settings()
1203 }
1204
1205 pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
1209 let mut event_emitter = self.event_emitter.lock_safe();
1210 *event_emitter = Some(emitter);
1211 }
1212
1213 pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
1215 self.event_emitter.lock_safe().clone()
1216 }
1217
1218 pub fn sleep_timer_state(&self) -> SleepTimerState {
1220 self.sleep_timer.lock_safe().clone()
1221 }
1222
1223 pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
1225 let mut timer = self.sleep_timer.lock_safe();
1226 timer.mode = mode.clone();
1227 if let SleepTimerMode::Time { end_time } = mode {
1228 let now = chrono::Utc::now().timestamp_millis();
1229 timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
1230 } else {
1231 timer.remaining_seconds = 0;
1232 }
1233 drop(timer);
1234
1235 self.emit_sleep_timer_changed();
1237 }
1238
1239 pub fn cancel_sleep_timer(&self) {
1241 self.set_sleep_timer(SleepTimerMode::Off);
1242 }
1243
1244 fn start_timer_thread(&self) {
1246 let sleep_timer = self.sleep_timer.clone();
1247 let event_emitter = self.event_emitter.clone();
1248 let backend = self.backend.clone();
1249 let end_reason = self.end_reason.clone();
1250
1251 std::thread::spawn(move || {
1252 loop {
1253 std::thread::sleep(Duration::from_secs(1));
1254
1255 let mut timer = sleep_timer.lock_safe();
1256 if timer.is_active() {
1257 timer.update_remaining_seconds();
1258
1259 if matches!(timer.mode, SleepTimerMode::Time { .. })
1261 && timer.remaining_seconds == 0
1262 {
1263 debug!("[SleepTimer] Time-based timer expired, stopping playback");
1264 timer.cancel();
1265
1266 Self::note_sleep_timer_stop(&end_reason);
1273
1274 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1276 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1277 mode: SleepTimerMode::Off,
1278 remaining_seconds: 0,
1279 });
1280 emitter.emit(PlayerStatusEvent::SleepTimerExpired);
1284 }
1285 drop(timer);
1286
1287 if let Err(e) = backend.lock_safe().close() {
1289 error!("[SleepTimer] Failed to stop playback: {}", e);
1290 }
1291 continue;
1292 }
1293
1294 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1296 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1297 mode: timer.mode.clone(),
1298 remaining_seconds: timer.remaining_seconds,
1299 });
1300 }
1301 }
1302 drop(timer);
1303 }
1304 });
1305 }
1306
1307 fn emit_sleep_timer_changed(&self) {
1309 let timer = self.sleep_timer.lock_safe().clone();
1310
1311 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1312 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1313 mode: timer.mode,
1314 remaining_seconds: timer.remaining_seconds,
1315 });
1316 }
1317 }
1318
1319 pub fn emit_queue_changed(&self) {
1321 let queue = self.queue.lock_safe();
1322
1323 debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
1324 queue.items().len(), queue.current_index());
1325
1326 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1327 emitter.emit(PlayerStatusEvent::QueueChanged {
1328 items: queue.items().to_vec(),
1329 current_index: queue.current_index(),
1330 shuffle: queue.is_shuffle(),
1331 repeat: queue.repeat_mode(),
1332 has_next: queue.has_next(),
1333 has_previous: queue.has_previous(),
1334 });
1335 } else {
1336 warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
1337 }
1338 }
1339
1340 pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
1355 if self.is_background_audio_active() {
1360 debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
1361 return;
1362 }
1363 let element_gone = {
1369 let mut tracked = self.html5_playing.lock_safe();
1370 *tracked = match state.as_str() {
1371 "playing" => Some(true),
1372 "paused" | "loading" => Some(false),
1376 _ => None,
1378 };
1379 tracked.is_none()
1380 };
1381 if element_gone {
1384 self.clear_reported_time();
1385 }
1386 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1387 emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
1388 }
1389 }
1390
1391 pub fn report_html5_position(&self, position: f64, duration: f64) {
1396 if self.is_background_audio_active() {
1399 return;
1400 }
1401 self.reported_time.lock_safe().record(position, duration);
1404
1405 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1406 emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
1407 }
1408
1409 self.report_progress_throttled(position);
1410 }
1411
1412 fn report_progress_throttled(&self, position: f64) {
1427 if position <= 0.0 {
1428 return;
1429 }
1430 let Some(item_id) = self.current_jellyfin_id() else {
1431 return;
1432 };
1433 if !self.position_throttler.should_report(&item_id) {
1434 return;
1435 }
1436
1437 self.report(PlaybackOperation::Progress {
1438 item_id: item_id.clone(),
1439 position_ticks: seconds_to_ticks(position),
1440 is_paused: false,
1443 });
1444 self.position_throttler.mark_reported(&item_id);
1445 }
1446
1447 pub fn report_html5_media_loaded(&self, duration: f64) {
1450 if self.is_background_audio_active() {
1452 return;
1453 }
1454 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1455 emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
1456 }
1457 }
1458
1459 pub fn autoplay_settings(&self) -> AutoplaySettings {
1463 self.autoplay_settings.lock_safe().clone()
1464 }
1465
1466 pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
1468 let validated = settings.with_validated_countdown();
1469 *self.autoplay_settings.lock_safe() = validated;
1470 }
1471
1472 pub fn cancel_autoplay_countdown(&self) {
1474 if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
1475 *cancel_flag.lock_safe() = true;
1476 }
1477 }
1478
1479 pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
1484 if let Some(position) = self.truncated_stream_resume_position() {
1490 return Ok(AutoplayDecision::ResumeStream { position });
1491 }
1492
1493 let end_reason = self.take_end_reason();
1495
1496 debug!(
1497 "[PlayerController] on_playback_ended: end_reason={:?}",
1498 end_reason
1499 );
1500
1501 match end_reason {
1503 None | Some(EndReason::Finished) => {
1504 debug!("[PlayerController] Track finished naturally, checking autoplay");
1506 }
1507 Some(EndReason::NewTrackLoaded) => {
1508 debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
1510 return Ok(AutoplayDecision::Stop);
1511 }
1512 Some(EndReason::UserStop) => {
1513 debug!("[PlayerController] UserStop - stopping without autoplay");
1515 return Ok(AutoplayDecision::Stop);
1516 }
1517 Some(EndReason::UserSkip) => {
1518 debug!("[PlayerController] UserSkip - stopping without autoplay");
1520 return Ok(AutoplayDecision::Stop);
1521 }
1522 Some(EndReason::Error) => {
1523 debug!("[PlayerController] Error - stopping without autoplay");
1525 return Ok(AutoplayDecision::Stop);
1526 }
1527 }
1528
1529 let current_item = {
1530 let queue = self.queue.lock_safe();
1531 queue.current().cloned()
1532 };
1533
1534 let Some(current) = current_item else {
1535 return Ok(AutoplayDecision::Stop);
1536 };
1537
1538 self.report_completion(¤t);
1542
1543 let timer_mode = {
1545 let timer = self.sleep_timer.lock_safe();
1546 timer.mode.clone()
1547 };
1548
1549 match &timer_mode {
1550 SleepTimerMode::Time { end_time } => {
1551 let now = chrono::Utc::now().timestamp_millis();
1553 if now >= *end_time {
1554 debug!("[PlayerController] Time-based sleep timer expired at track boundary");
1555 self.sleep_timer.lock_safe().cancel();
1556 self.emit_sleep_timer_changed();
1557 return Ok(AutoplayDecision::Stop);
1558 }
1559 }
1560 SleepTimerMode::EndOfTrack => {
1561 self.sleep_timer.lock_safe().cancel();
1563 self.emit_sleep_timer_changed();
1564 return Ok(AutoplayDecision::Stop);
1565 }
1566 SleepTimerMode::Episodes { .. } => {
1567 let is_episode = self.is_episode_item(¤t).await;
1572
1573 if is_episode {
1574 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
1575 self.emit_sleep_timer_changed();
1576
1577 if should_stop {
1578 return Ok(AutoplayDecision::Stop);
1579 }
1580 }
1581 }
1582 _ => {
1583 }
1585 }
1586
1587 if self.is_episode_item(¤t).await {
1593 let repo = self.repository.lock_safe().clone();
1594 let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
1595 let next_ep_result = if let Some(repo) = &repo {
1596 match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
1599 Ok(next) => next,
1600 Err(e) => {
1601 warn!(
1602 "[PlayerController] Next-episode lookup failed for {}: {}",
1603 jellyfin_id, e
1604 );
1605 None
1606 }
1607 }
1608 } else {
1609 warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
1610 None
1611 };
1612 if let Some(next_ep) = next_ep_result {
1613 let settings = self.autoplay_settings.lock_safe().clone();
1614
1615 let limit_reached = self.increment_autoplay_count();
1617 if limit_reached {
1618 debug!(
1619 "[PlayerController] Auto-play episode limit reached ({} episodes)",
1620 settings.max_episodes
1621 );
1622 }
1623
1624 return Ok(AutoplayDecision::ShowNextEpisodePopup {
1625 current_episode: next_ep.0, next_episode: next_ep.1,
1627 countdown_seconds: settings.countdown_seconds,
1628 auto_advance: settings.enabled && !limit_reached,
1629 });
1630 }
1631 return Ok(AutoplayDecision::Stop);
1633 }
1634
1635 let has_next = {
1637 let queue = self.queue.lock_safe();
1638 queue.has_next()
1639 };
1640
1641 if has_next {
1642 Ok(AutoplayDecision::AdvanceToNext)
1644 } else {
1645 Ok(AutoplayDecision::Stop)
1647 }
1648 }
1649
1650 fn report_completion(&self, item: &MediaItem) {
1671 if !Self::is_audio_only_video(item) {
1672 return;
1673 }
1674 let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
1675 return;
1676 };
1677
1678 let position = completion_report_position(item.duration, self.absolute_position());
1679 log::info!(
1680 "[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
1681 jellyfin_id,
1682 position
1683 );
1684 self.report_stopped_at(jellyfin_id, position);
1685 }
1686
1687 pub fn set_background_audio_base(&self, seconds: f64) {
1692 *self.background_audio_base.lock_safe() = seconds.max(0.0);
1693 }
1694
1695 pub fn enter_background_audio(&self, position: f64) {
1706 self.set_background_audio_base(position);
1707 *self.background_audio_active.lock_safe() = true;
1708 *self.html5_playing.lock_safe() = None;
1709 self.clear_reported_time();
1712 }
1713
1714 pub fn background_audio_resume(&self) -> BackgroundAudioResume {
1732 BackgroundAudioResume {
1733 item_id: self.queue.lock_safe().current().map(|item| item.id.clone()),
1734 position_seconds: self.absolute_position(),
1735 }
1736 }
1737
1738 pub fn exit_background_audio(&self) -> f64 {
1739 *self.background_audio_active.lock_safe() = false;
1740 self.take_background_audio_base()
1741 }
1742
1743 pub fn is_background_audio_active(&self) -> bool {
1748 *self.background_audio_active.lock_safe()
1749 }
1750
1751 pub fn take_background_audio_base(&self) -> f64 {
1755 let mut base = self.background_audio_base.lock_safe();
1756 std::mem::replace(&mut *base, 0.0)
1757 }
1758
1759 pub async fn auto_advance_to_next_episode(
1770 &self,
1771 next_episode: crate::repository::types::MediaItem,
1772 countdown_seconds: u32,
1773 ) {
1774 if self.current_is_audio_episode() {
1779 info!(
1780 "[PlayerController] Background audio episode — advancing to {} in backend",
1781 next_episode.id
1782 );
1783 match self
1784 .advance_to_next_episode_audio_only(&next_episode.id)
1785 .await
1786 {
1787 Ok(()) => self.emit_queue_changed(),
1788 Err(e) => {
1789 error!(
1790 "[PlayerController] Background audio advance failed: {} — stopping",
1791 e
1792 );
1793 if let Some(emitter) = self.event_emitter() {
1794 emitter.emit(PlayerStatusEvent::PlaybackEnded);
1795 }
1796 }
1797 }
1798 return;
1799 }
1800
1801 self.start_autoplay_countdown(next_episode, countdown_seconds);
1803 }
1804
1805 fn is_audio_only_video(item: &MediaItem) -> bool {
1809 stream_end::is_audio_only_video(item)
1810 }
1811
1812 fn claim_stream_resume(&self) -> Option<(f64, u32)> {
1827 let current = {
1828 let queue = self.queue.lock_safe();
1829 queue.current().cloned()
1830 }?;
1831 if !matches!(current.source, MediaSource::Remote { .. }) {
1832 return None;
1833 }
1834
1835 let absolute = self.absolute_position();
1840
1841 match self.stream_resume.lock_safe().allow_attempt(absolute) {
1842 Some(attempt) => Some((absolute, attempt)),
1843 None => {
1844 warn!(
1845 "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
1846 current.id, absolute
1847 );
1848 None
1849 }
1850 }
1851 }
1852
1853 fn truncated_stream_resume_position(&self) -> Option<f64> {
1860 if matches!(
1862 self.peek_end_reason(),
1863 Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
1864 ) {
1865 return None;
1866 }
1867
1868 let item_duration = {
1872 let queue = self.queue.lock_safe();
1873 let current = queue.current()?;
1874 if !Self::is_audio_only_video(current) {
1875 return None;
1876 }
1877 current.duration
1878 };
1879 let absolute = self.position().max(0.0);
1881
1882 if !stream_end::is_truncated_end(
1885 absolute,
1886 item_duration,
1887 stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
1888 ) {
1889 return None;
1890 }
1891
1892 self.claim_stream_resume().map(|(position, _)| position)
1893 }
1894
1895 pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
1917 self.claim_stream_resume()
1918 .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
1919 }
1920
1921 pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
1948 let current = {
1949 let queue = self.queue.lock_safe();
1950 queue.current().cloned()
1951 }
1952 .ok_or_else(|| "No current item to resume".to_string())?;
1953
1954 let MediaSource::Remote { stream_url, .. } = ¤t.source else {
1955 return Err(format!(
1956 "Cannot resume a non-remote source for {}",
1957 current.id
1958 ));
1959 };
1960
1961 info!(
1962 "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
1963 current.id, position
1964 );
1965
1966 if !Self::is_audio_only_video(¤t) {
1967 self.load_and_play(¤t).map_err(|e| e.to_string())?;
1968 if position > 0.5 {
1969 self.seek(position).map_err(|e| e.to_string())?;
1970 }
1971 return Ok(());
1972 }
1973
1974 let restarted_url = stream_end::with_start_time(stream_url, position);
1975 {
1976 let queue_arc = self.queue.clone();
1977 let mut queue = queue_arc.lock_safe();
1978 if !queue.update_current_stream_url(restarted_url) {
1979 return Err(format!("Failed to update stream URL for {}", current.id));
1980 }
1981 }
1982 let resumed = {
1983 let queue = self.queue.lock_safe();
1984 queue.current().cloned()
1985 }
1986 .ok_or_else(|| "Current item vanished mid-resume".to_string())?;
1987
1988 self.set_background_audio_base(position);
1992 let _ = set_lockscreen_position_offset(position.max(0.0));
1993
1994 self.load_and_play(&resumed).map_err(|e| e.to_string())
1995 }
1996
1997 pub async fn advance_to_next_episode_audio_only(
2012 &self,
2013 next_episode_id: &str,
2014 ) -> Result<(), String> {
2015 crate::repository::online::clear_playback_quality_override();
2023
2024 let repo = self
2025 .repository
2026 .lock_safe()
2027 .clone()
2028 .ok_or_else(|| "No repository for background episode advance".to_string())?;
2029
2030 let next = repo
2032 .get_item(next_episode_id)
2033 .await
2034 .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
2035
2036 let stream_url = repo
2039 .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
2040 .await
2041 .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
2042
2043 let media_item = MediaItem {
2044 transport: None,
2046 id: next.id.clone(),
2047 title: next.name.clone(),
2048 name: Some(next.name.clone()),
2049 artist: next.series_name.clone(),
2050 album: None,
2051 album_name: None,
2052 album_id: None,
2053 artist_items: None,
2054 artists: None,
2055 primary_image_tag: next.primary_image_tag.clone(),
2056 image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
2057 item_type: Some("Episode".to_string()),
2059 playlist_id: None,
2060 duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
2061 artwork_url: None,
2062 media_type: MediaType::Audio,
2063 source: MediaSource::Remote {
2064 stream_url,
2065 jellyfin_item_id: next.id.clone(),
2066 },
2067 video_codec: None,
2068 needs_transcoding: false,
2069 video_width: None,
2070 video_height: None,
2071 subtitles: vec![],
2072 series_id: next.series_id.clone(),
2073 server_id: Some(next.server_id.clone()),
2074 };
2075
2076 self.set_background_audio_base(0.0);
2081 let _ = set_lockscreen_position_offset(0.0);
2082 self.stream_resume.lock_safe().reset();
2085
2086 self.play_item(media_item).map_err(|e| e.to_string())
2087 }
2088
2089 pub async fn on_video_playback_ended(
2096 &self,
2097 item_id: &str,
2098 repo: Arc<dyn crate::repository::MediaRepository>,
2099 ) -> Result<AutoplayDecision, String> {
2100 let stale_reason = self.take_end_reason();
2102 if stale_reason.is_some() {
2103 debug!(
2104 "[PlayerController] Cleared stale end_reason for video: {:?}",
2105 stale_reason
2106 );
2107 }
2108
2109 log::info!(
2110 "[PlayerController] on_video_playback_ended: item_id={}",
2111 item_id
2112 );
2113
2114 let timer_mode = {
2116 let timer = self.sleep_timer.lock_safe();
2117 timer.mode.clone()
2118 };
2119
2120 match &timer_mode {
2121 SleepTimerMode::Time { end_time } => {
2122 let now = chrono::Utc::now().timestamp_millis();
2123 if now >= *end_time {
2124 debug!("[PlayerController] Time-based sleep timer expired at video end");
2125 self.sleep_timer.lock_safe().cancel();
2126 self.emit_sleep_timer_changed();
2127 return Ok(AutoplayDecision::Stop);
2128 }
2129 }
2130 SleepTimerMode::EndOfTrack => {
2131 self.sleep_timer.lock_safe().cancel();
2132 self.emit_sleep_timer_changed();
2133 return Ok(AutoplayDecision::Stop);
2134 }
2135 SleepTimerMode::Episodes { .. } => {
2136 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
2137 self.emit_sleep_timer_changed();
2138 if should_stop {
2139 return Ok(AutoplayDecision::Stop);
2140 }
2141 }
2142 _ => {}
2143 }
2144
2145 let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
2149 Ok(next) => next,
2150 Err(e) => {
2151 warn!(
2152 "[PlayerController] Next-episode lookup failed for {}: {}",
2153 item_id, e
2154 );
2155 None
2156 }
2157 };
2158 if let Some(next_ep) = next_ep_result {
2159 let settings = self.autoplay_settings.lock_safe().clone();
2160
2161 let limit_reached = self.increment_autoplay_count();
2162 if limit_reached {
2163 debug!(
2164 "[PlayerController] Auto-play episode limit reached ({} episodes)",
2165 settings.max_episodes
2166 );
2167 }
2168
2169 return Ok(AutoplayDecision::ShowNextEpisodePopup {
2170 current_episode: next_ep.0,
2171 next_episode: next_ep.1,
2172 countdown_seconds: settings.countdown_seconds,
2173 auto_advance: settings.enabled && !limit_reached,
2174 });
2175 }
2176
2177 debug!("[PlayerController] No next episode found for {}", item_id);
2179 Ok(AutoplayDecision::Stop)
2180 }
2181
2182 async fn is_episode_item(&self, item: &MediaItem) -> bool {
2190 match item.item_type.as_deref() {
2191 Some("Episode") => true,
2192 Some(_) => item.media_type == MediaType::Video,
2193 None => item.media_type == MediaType::Video,
2194 }
2195 }
2196
2197 async fn fetch_next_episode_for_item(
2204 &self,
2205 item_id: &str,
2206 repo: &Arc<dyn crate::repository::MediaRepository>,
2207 ) -> Result<
2208 Option<(
2209 crate::repository::types::MediaItem,
2210 crate::repository::types::MediaItem,
2211 )>,
2212 String,
2213 > {
2214 use crate::repository::types::GetItemsOptions;
2215
2216 let current_repo_item = repo
2218 .get_item(item_id)
2219 .await
2220 .map_err(|e| format!("Failed to get current item: {}", e))?;
2221
2222 let season_id = match ¤t_repo_item.season_id {
2224 Some(sid) => sid.clone(),
2225 None => {
2226 log::info!(
2227 "[PlayerController] Current item has no season_id, cannot find next episode"
2228 );
2229 return Ok(None);
2230 }
2231 };
2232
2233 let options = GetItemsOptions {
2235 sort_by: Some("IndexNumber".to_string()),
2236 sort_order: Some("Ascending".to_string()),
2237 limit: Some(500),
2238 include_item_types: Some(vec!["Episode".to_string()]),
2239 ..Default::default()
2240 };
2241
2242 let result = repo
2243 .get_items(&season_id, Some(options))
2244 .await
2245 .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
2246
2247 let mut episodes = result.items;
2250 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2251 log::info!(
2252 "[PlayerController] Season has {} episodes, looking for next after {}",
2253 episodes.len(),
2254 current_repo_item.id
2255 );
2256
2257 if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
2259 if current_idx + 1 < episodes.len() {
2260 let next = &episodes[current_idx + 1];
2261 log::info!(
2262 "[PlayerController] Found next episode: {} (index {})",
2263 next.name,
2264 current_idx + 1
2265 );
2266 return Ok(Some((current_repo_item, next.clone())));
2267 } else {
2268 log::info!("[PlayerController] Current episode is the last in the season");
2269 if let Some(next) = self
2270 .first_episode_of_next_season(¤t_repo_item, repo)
2271 .await
2272 {
2273 return Ok(Some((current_repo_item, next)));
2274 }
2275 }
2276 } else {
2277 log::info!(
2278 "[PlayerController] Current episode not found in season episodes (ids: {:?})",
2279 episodes
2280 .iter()
2281 .map(|e| e.id.as_str())
2282 .take(20)
2283 .collect::<Vec<_>>()
2284 );
2285 }
2286
2287 Ok(None)
2288 }
2289
2290 async fn first_episode_of_next_season(
2303 &self,
2304 current: &crate::repository::types::MediaItem,
2305 repo: &Arc<dyn crate::repository::MediaRepository>,
2306 ) -> Option<crate::repository::types::MediaItem> {
2307 use crate::repository::types::GetItemsOptions;
2308
2309 let series_id = current.series_id.as_deref()?;
2310 let season_id = current.season_id.as_deref()?;
2311
2312 let season_options = GetItemsOptions {
2313 sort_by: Some("IndexNumber".to_string()),
2314 sort_order: Some("Ascending".to_string()),
2315 limit: Some(500),
2316 include_item_types: Some(vec!["Season".to_string()]),
2317 ..Default::default()
2318 };
2319 let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
2320 Ok(result) => result.items,
2321 Err(e) => {
2322 log::warn!(
2323 "[PlayerController] Season lookup failed for series {}: {}",
2324 series_id,
2325 e
2326 );
2327 return None;
2328 }
2329 };
2330 seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
2333
2334 let current_idx = seasons.iter().position(|s| s.id == season_id)?;
2335
2336 for season in &seasons[current_idx + 1..] {
2337 if season.index_number == Some(0) {
2342 continue;
2343 }
2344
2345 let episode_options = GetItemsOptions {
2346 sort_by: Some("IndexNumber".to_string()),
2347 sort_order: Some("Ascending".to_string()),
2348 limit: Some(500),
2349 include_item_types: Some(vec!["Episode".to_string()]),
2350 ..Default::default()
2351 };
2352 let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
2353 Ok(result) => result.items,
2354 Err(e) => {
2355 log::warn!(
2356 "[PlayerController] Episode lookup failed for season {}: {}",
2357 season.id,
2358 e
2359 );
2360 return None;
2361 }
2362 };
2363 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2364
2365 if let Some(first) = episodes.into_iter().next() {
2367 log::info!(
2368 "[PlayerController] Rolling over to {} of {}: {}",
2369 first.name,
2370 season.name,
2371 first.id
2372 );
2373 return Some(first);
2374 }
2375 }
2376
2377 log::info!("[PlayerController] No further season to roll over into");
2378 None
2379 }
2380
2381 pub fn start_autoplay_countdown(
2383 &self,
2384 _next_item: crate::repository::types::MediaItem,
2385 countdown_seconds: u32,
2386 ) {
2387 let cancel_flag = Arc::new(Mutex::new(false));
2389 *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
2390
2391 let event_emitter = self.event_emitter.clone();
2392
2393 std::thread::spawn(move || {
2394 let mut remaining = countdown_seconds;
2395
2396 while remaining > 0 {
2397 std::thread::sleep(Duration::from_secs(1));
2398
2399 if *cancel_flag.lock_safe() {
2401 log::info!("[PlayerController] Autoplay countdown cancelled");
2402 return;
2403 }
2404
2405 remaining -= 1;
2406
2407 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
2409 emitter.emit(PlayerStatusEvent::CountdownTick {
2410 remaining_seconds: remaining,
2411 });
2412 }
2413 }
2414
2415 log::info!("[PlayerController] Autoplay countdown finished");
2417 });
2418 }
2419}
2420
2421impl Default for PlayerController {
2422 fn default() -> Self {
2423 let playback_reporter = Arc::new(TokioMutex::new(None));
2424 let position_throttler = Arc::new(EventThrottler::new());
2425 Self::new(
2426 Box::new(LegacyPlayer::new(
2427 NullBackend::new(),
2428 crate::player::media_player::Capabilities::mpv(),
2429 )),
2430 playback_reporter,
2431 position_throttler,
2432 )
2433 }
2434}
2435
2436#[cfg(test)]
2437mod tests {
2438
2439 #[test]
2455 fn test_background_episode_advance_clears_the_quality_override() {
2456 let src = include_str!("mod.rs");
2457 let start = src
2458 .find("fn advance_to_next_episode_audio_only")
2459 .expect("advance_to_next_episode_audio_only not found");
2460 let rest = &src[start..];
2461 let end = rest.find("\n pub ").unwrap_or(rest.len());
2462 let body = &rest[..end];
2463
2464 assert!(
2465 body.contains("clear_playback_quality_override"),
2466 "the background episode advance does not clear the per-playback \
2467 quality override, so a ceiling chosen for one episode silently \
2468 caps every episode after it"
2469 );
2470 }
2471
2472 #[test]
2483 fn test_stop_clears_an_active_background_audio_handoff() {
2484 let controller = PlayerController::default();
2485 let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2486 {
2487 let queue_arc = controller.queue();
2488 let mut queue = queue_arc.lock_safe();
2489 queue.set_queue(vec![item], 0);
2490 }
2491
2492 controller.enter_background_audio(557.5);
2493 assert!(
2494 controller.is_background_audio_active(),
2495 "precondition: the handoff is active"
2496 );
2497
2498 controller.stop().expect("stop failed");
2499
2500 assert!(
2501 !controller.is_background_audio_active(),
2502 "a stop must not leave a handoff behind for the next position read"
2503 );
2504 assert_eq!(
2505 *controller.background_audio_base.lock_safe(),
2506 0.0,
2507 "the handoff base must be cleared with it"
2508 );
2509 }
2510
2511 #[test]
2522 fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
2523 let controller = PlayerController::default();
2524 let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2525 item.duration = Some(1800.0);
2526
2527 {
2528 let queue_arc = controller.queue();
2529 let mut queue = queue_arc.lock_safe();
2530 queue.set_queue(vec![item], 0);
2531 }
2532
2533 assert_eq!(
2534 controller.duration(),
2535 Some(1800.0),
2536 "an engine that cannot report a duration should not erase the one the item carries"
2537 );
2538 }
2539 use super::*;
2540
2541 struct CapturingEmitter {
2544 events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
2545 }
2546
2547 impl CapturingEmitter {
2548 fn new() -> Self {
2549 Self {
2550 events: std::sync::Mutex::new(Vec::new()),
2551 }
2552 }
2553 fn events(&self) -> Vec<PlayerStatusEvent> {
2554 self.events.lock_safe().clone()
2555 }
2556 }
2557
2558 impl PlayerEventEmitter for CapturingEmitter {
2559 fn emit(&self, event: PlayerStatusEvent) {
2560 self.events.lock_safe().push(event);
2561 }
2562 }
2563
2564 struct CapturingReports {
2567 operations: std::sync::Mutex<Vec<PlaybackOperation>>,
2568 }
2569
2570 impl CapturingReports {
2571 fn new() -> Self {
2572 Self {
2573 operations: std::sync::Mutex::new(Vec::new()),
2574 }
2575 }
2576
2577 fn stops(&self) -> Vec<(String, f64)> {
2579 self.operations
2580 .lock_safe()
2581 .iter()
2582 .filter_map(|op| match op {
2583 PlaybackOperation::Stopped {
2584 item_id,
2585 position_ticks,
2586 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2587 _ => None,
2588 })
2589 .collect()
2590 }
2591
2592 fn progress(&self) -> Vec<(String, f64)> {
2594 self.operations
2595 .lock_safe()
2596 .iter()
2597 .filter_map(|op| match op {
2598 PlaybackOperation::Progress {
2599 item_id,
2600 position_ticks,
2601 ..
2602 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2603 _ => None,
2604 })
2605 .collect()
2606 }
2607 }
2608
2609 impl PlaybackReportSink for CapturingReports {
2610 fn send(&self, operation: PlaybackOperation) {
2611 self.operations.lock_safe().push(operation);
2612 }
2613 }
2614
2615 #[test]
2616 fn test_report_html5_state_emits_state_changed() {
2617 let controller = PlayerController::default();
2618 let emitter = Arc::new(CapturingEmitter::new());
2619 controller.set_event_emitter(emitter.clone());
2620
2621 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2622
2623 let events = emitter.events();
2624 assert_eq!(events.len(), 1);
2625 match &events[0] {
2626 PlayerStatusEvent::StateChanged { state, media_id } => {
2627 assert_eq!(state, "playing");
2628 assert_eq!(media_id.as_deref(), Some("item-1"));
2629 }
2630 other => panic!("expected StateChanged, got {:?}", other),
2631 }
2632 }
2633
2634 #[test]
2635 fn test_report_html5_position_emits_position_update() {
2636 let controller = PlayerController::default();
2637 let emitter = Arc::new(CapturingEmitter::new());
2638 controller.set_event_emitter(emitter.clone());
2639
2640 controller.report_html5_position(12.5, 300.0);
2641
2642 let events = emitter.events();
2643 assert_eq!(events.len(), 1);
2644 match &events[0] {
2645 PlayerStatusEvent::PositionUpdate { position, duration } => {
2646 assert_eq!(*position, 12.5);
2647 assert_eq!(*duration, 300.0);
2648 }
2649 other => panic!("expected PositionUpdate, got {:?}", other),
2650 }
2651 }
2652
2653 #[test]
2654 fn test_report_html5_media_loaded_emits_media_loaded() {
2655 let controller = PlayerController::default();
2656 let emitter = Arc::new(CapturingEmitter::new());
2657 controller.set_event_emitter(emitter.clone());
2658
2659 controller.report_html5_media_loaded(420.0);
2660
2661 let events = emitter.events();
2662 assert_eq!(events.len(), 1);
2663 match &events[0] {
2664 PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
2665 other => panic!("expected MediaLoaded, got {:?}", other),
2666 }
2667 }
2668
2669 #[test]
2679 fn test_html5_state_is_tracked_from_reports() {
2680 let controller = PlayerController::default();
2681 let emitter = Arc::new(CapturingEmitter::new());
2682 controller.set_event_emitter(emitter.clone());
2683
2684 assert!(!controller.is_html5_active());
2686
2687 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2688 assert!(controller.is_html5_active());
2689 assert!(controller.html5_is_playing());
2690
2691 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2692 assert!(controller.is_html5_active());
2693 assert!(!controller.html5_is_playing());
2694 }
2695
2696 #[test]
2697 fn test_html5_toggle_from_paused_emits_play_control() {
2698 let controller = PlayerController::default();
2699 let emitter = Arc::new(CapturingEmitter::new());
2700 controller.set_event_emitter(emitter.clone());
2701 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2702
2703 controller.toggle_playback().unwrap();
2704
2705 let controls: Vec<_> = emitter
2706 .events()
2707 .into_iter()
2708 .filter_map(|e| match e {
2709 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2710 _ => None,
2711 })
2712 .collect();
2713 assert_eq!(controls, vec!["play".to_string()]);
2714 }
2715
2716 #[test]
2717 fn test_html5_toggle_from_playing_emits_pause_control() {
2718 let controller = PlayerController::default();
2719 let emitter = Arc::new(CapturingEmitter::new());
2720 controller.set_event_emitter(emitter.clone());
2721 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2722
2723 controller.toggle_playback().unwrap();
2724
2725 let controls: Vec<_> = emitter
2726 .events()
2727 .into_iter()
2728 .filter_map(|e| match e {
2729 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2730 _ => None,
2731 })
2732 .collect();
2733 assert_eq!(controls, vec!["pause".to_string()]);
2734 }
2735
2736 #[test]
2737 fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
2738 let controller = PlayerController::default();
2743 let emitter = Arc::new(CapturingEmitter::new());
2744 controller.set_event_emitter(emitter.clone());
2745 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2746
2747 controller.toggle_playback().unwrap();
2748 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2750 controller.toggle_playback().unwrap();
2751
2752 let controls: Vec<_> = emitter
2753 .events()
2754 .into_iter()
2755 .filter_map(|e| match e {
2756 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2757 _ => None,
2758 })
2759 .collect();
2760 assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
2761 }
2762
2763 #[test]
2764 fn test_html5_play_and_pause_emit_control_commands() {
2765 let controller = PlayerController::default();
2766 let emitter = Arc::new(CapturingEmitter::new());
2767 controller.set_event_emitter(emitter.clone());
2768 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2769
2770 controller.play().unwrap();
2771 controller.pause().unwrap();
2772
2773 let controls: Vec<_> = emitter
2774 .events()
2775 .into_iter()
2776 .filter_map(|e| match e {
2777 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2778 _ => None,
2779 })
2780 .collect();
2781 assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
2782 }
2783
2784 #[test]
2785 fn test_background_audio_handoff_moves_transport_to_native_backend() {
2786 let controller = PlayerController::default();
2794 let emitter = Arc::new(CapturingEmitter::new());
2795 controller.set_event_emitter(emitter.clone());
2796
2797 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2799 assert!(controller.is_html5_active());
2800
2801 controller.enter_background_audio(1200.0);
2803 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2804
2805 assert!(
2806 !controller.is_html5_active(),
2807 "native audio owns transport during a background-audio handoff"
2808 );
2809
2810 controller.pause().unwrap();
2811 let controls: Vec<_> = emitter
2812 .events()
2813 .into_iter()
2814 .filter_map(|e| match e {
2815 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2816 _ => None,
2817 })
2818 .collect();
2819 assert!(
2820 controls.is_empty(),
2821 "pause must drive the native backend, not a torn-down element: {:?}",
2822 controls
2823 );
2824 }
2825
2826 #[test]
2827 fn test_background_audio_handoff_suppresses_stale_element_events() {
2828 let controller = PlayerController::default();
2832 let emitter = Arc::new(CapturingEmitter::new());
2833 controller.set_event_emitter(emitter.clone());
2834
2835 controller.enter_background_audio(1200.0);
2836 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2837 controller.report_html5_position(1200.0, 2400.0);
2838
2839 assert!(
2840 emitter.events().is_empty(),
2841 "stale webview reports must not reach the event pipeline: {:?}",
2842 emitter.events()
2843 );
2844 }
2845
2846 #[test]
2847 fn test_exit_background_audio_returns_transport_to_the_webview() {
2848 let controller = PlayerController::default();
2851 let emitter = Arc::new(CapturingEmitter::new());
2852 controller.set_event_emitter(emitter.clone());
2853
2854 controller.enter_background_audio(1200.0);
2855 assert_eq!(controller.exit_background_audio(), 1200.0);
2856
2857 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2858 assert!(controller.is_html5_active());
2859 assert!(controller.html5_is_playing());
2860 }
2861
2862 #[test]
2863 fn test_html5_stopped_report_releases_transport_to_native_backend() {
2864 let controller = PlayerController::default();
2867 let emitter = Arc::new(CapturingEmitter::new());
2868 controller.set_event_emitter(emitter.clone());
2869
2870 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2871 assert!(controller.is_html5_active());
2872
2873 controller.report_html5_state("stopped".to_string(), None);
2874 assert!(!controller.is_html5_active());
2875 }
2876
2877 #[test]
2878 fn test_html5_transport_emits_exactly_one_control_per_intent() {
2879 let controller = PlayerController::default();
2883 let emitter = Arc::new(CapturingEmitter::new());
2884 controller.set_event_emitter(emitter.clone());
2885 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2886
2887 controller.pause().unwrap();
2888
2889 let controls = emitter
2890 .events()
2891 .into_iter()
2892 .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
2893 .count();
2894 assert_eq!(controls, 1, "one intent must produce exactly one control");
2895 }
2896
2897 #[test]
2898 fn test_controller_volume_default() {
2899 let controller = PlayerController::default();
2900 assert_eq!(controller.volume(), 1.0);
2901 }
2902
2903 #[test]
2904 fn test_controller_set_volume() {
2905 let controller = PlayerController::default();
2906 controller.set_volume(0.5).unwrap();
2907 assert_eq!(controller.volume(), 0.5);
2908 }
2909
2910 #[test]
2911 fn test_controller_muted_default() {
2912 let controller = PlayerController::default();
2913 assert!(!controller.muted());
2914 }
2915
2916 #[test]
2917 fn test_controller_volume_delegates_to_backend() {
2918 let controller = PlayerController::default();
2919
2920 controller.set_volume(0.75).unwrap();
2922
2923 assert_eq!(controller.volume(), 0.75);
2925 }
2926
2927 fn create_test_items(count: usize) -> Vec<MediaItem> {
2928 (0..count)
2929 .map(|i| MediaItem {
2930 transport: None,
2932 id: format!("item_{}", i),
2933 title: format!("Track {}", i + 1),
2934 name: Some(format!("Track {}", i + 1)),
2935 artist: Some("Test Artist".to_string()),
2936 album: Some("Test Album".to_string()),
2937 album_name: Some("Test Album".to_string()),
2938 album_id: None,
2939 artist_items: None,
2940 artists: Some(vec!["Test Artist".to_string()]),
2941 primary_image_tag: None,
2942 image_id: None,
2943 item_type: Some("Audio".to_string()),
2944 playlist_id: None,
2945 duration: Some(180.0),
2946 artwork_url: None,
2947 media_type: MediaType::Audio,
2948 source: MediaSource::DirectUrl {
2949 url: format!("http://example.com/track_{}.mp3", i),
2950 },
2951 video_codec: None,
2952 needs_transcoding: false,
2953 video_width: None,
2954 video_height: None,
2955 subtitles: vec![],
2956 series_id: None,
2957 server_id: None,
2958 })
2959 .collect()
2960 }
2961
2962 #[test]
2963 fn test_skip_preserves_queue() {
2964 let controller = PlayerController::default();
2965
2966 let items = create_test_items(5);
2968 let items_clone = items.clone();
2969
2970 controller.play_queue(items, 0).unwrap();
2972
2973 {
2975 let queue = controller.queue();
2976 let queue_lock = queue.lock_safe();
2977 assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
2978 assert_eq!(
2979 queue_lock.current_index(),
2980 Some(0),
2981 "Should start at index 0"
2982 );
2983 assert_eq!(
2984 queue_lock.current().unwrap().id,
2985 "item_0",
2986 "Current item should be item_0"
2987 );
2988 }
2989
2990 controller.next().unwrap();
2992
2993 {
2995 let queue = controller.queue();
2996 let queue_lock = queue.lock_safe();
2997 assert_eq!(
2998 queue_lock.items().len(),
2999 5,
3000 "Queue should still have 5 items after skip"
3001 );
3002 assert_eq!(
3003 queue_lock.current_index(),
3004 Some(1),
3005 "Index should advance to 1"
3006 );
3007 assert_eq!(
3008 queue_lock.current().unwrap().id,
3009 "item_1",
3010 "Current item should be item_1"
3011 );
3012
3013 let current_items = queue_lock.items();
3015 for (i, original) in items_clone.iter().enumerate() {
3016 assert_eq!(
3017 current_items[i].id, original.id,
3018 "Item {} should still be in queue",
3019 i
3020 );
3021 assert_eq!(
3022 current_items[i].title, original.title,
3023 "Item {} title should be unchanged",
3024 i
3025 );
3026 }
3027 }
3028
3029 controller.next().unwrap();
3031
3032 {
3034 let queue = controller.queue();
3035 let queue_lock = queue.lock_safe();
3036 assert_eq!(
3037 queue_lock.items().len(),
3038 5,
3039 "Queue should still have 5 items after second skip"
3040 );
3041 assert_eq!(
3042 queue_lock.current_index(),
3043 Some(2),
3044 "Index should advance to 2"
3045 );
3046 assert_eq!(
3047 queue_lock.current().unwrap().id,
3048 "item_2",
3049 "Current item should be item_2"
3050 );
3051 }
3052
3053 controller.next().unwrap(); controller.next().unwrap(); {
3059 let queue = controller.queue();
3060 let queue_lock = queue.lock_safe();
3061 assert_eq!(
3062 queue_lock.items().len(),
3063 5,
3064 "Queue should still have 5 items at end"
3065 );
3066 assert_eq!(
3067 queue_lock.current_index(),
3068 Some(4),
3069 "Index should be at last item (4)"
3070 );
3071 assert_eq!(
3072 queue_lock.current().unwrap().id,
3073 "item_4",
3074 "Current item should be item_4"
3075 );
3076 }
3077 }
3078
3079 #[test]
3080 fn test_skip_at_end_without_repeat() {
3081 let controller = PlayerController::default();
3082
3083 let items = create_test_items(3);
3085 controller.play_queue(items, 0).unwrap();
3086
3087 controller.next().unwrap(); controller.next().unwrap(); {
3093 let queue = controller.queue();
3094 let queue_lock = queue.lock_safe();
3095 assert_eq!(
3096 queue_lock.current_index(),
3097 Some(2),
3098 "Should be at last item"
3099 );
3100 }
3101
3102 controller.next().unwrap();
3105
3106 {
3108 let queue = controller.queue();
3109 let queue_lock = queue.lock_safe();
3110 assert_eq!(
3111 queue_lock.items().len(),
3112 3,
3113 "Queue should still have 3 items after skip at end"
3114 );
3115 }
3119 }
3120
3121 #[test]
3122 fn test_skip_with_repeat_all() {
3123 let controller = PlayerController::default();
3124
3125 let items = create_test_items(3);
3127 controller.play_queue(items, 0).unwrap();
3128
3129 controller.cycle_repeat();
3131
3132 controller.next().unwrap(); controller.next().unwrap(); controller.next().unwrap();
3138
3139 {
3141 let queue = controller.queue();
3142 let queue_lock = queue.lock_safe();
3143 assert_eq!(
3144 queue_lock.items().len(),
3145 3,
3146 "Queue should still have 3 items"
3147 );
3148 assert_eq!(
3149 queue_lock.current_index(),
3150 Some(0),
3151 "Should wrap to index 0"
3152 );
3153 assert_eq!(
3154 queue_lock.current().unwrap().id,
3155 "item_0",
3156 "Should be back at item_0"
3157 );
3158 }
3159 }
3160
3161 #[test]
3162 fn test_previous_preserves_queue() {
3163 let controller = PlayerController::default();
3164
3165 let items = create_test_items(5);
3167 let items_clone = items.clone();
3168 controller.play_queue(items, 3).unwrap();
3169
3170 {
3172 let queue = controller.queue();
3173 let queue_lock = queue.lock_safe();
3174 assert_eq!(
3175 queue_lock.current_index(),
3176 Some(3),
3177 "Should start at index 3"
3178 );
3179 }
3180
3181 controller.previous().unwrap();
3183
3184 {
3186 let queue = controller.queue();
3187 let queue_lock = queue.lock_safe();
3188 assert_eq!(
3189 queue_lock.items().len(),
3190 5,
3191 "Queue should still have 5 items after previous"
3192 );
3193 assert_eq!(
3194 queue_lock.current_index(),
3195 Some(2),
3196 "Index should move to 2"
3197 );
3198 assert_eq!(
3199 queue_lock.current().unwrap().id,
3200 "item_2",
3201 "Current item should be item_2"
3202 );
3203
3204 let current_items = queue_lock.items();
3206 for (i, original) in items_clone.iter().enumerate() {
3207 assert_eq!(
3208 current_items[i].id, original.id,
3209 "Item {} should still be in queue",
3210 i
3211 );
3212 }
3213 }
3214 }
3215
3216 #[test]
3217 fn test_seek_updates_position() {
3218 let controller = PlayerController::default();
3219
3220 let item = create_test_items(1).into_iter().next().unwrap();
3222 controller.play_item(item).unwrap();
3223
3224 assert_eq!(controller.position(), 0.0, "Initial position should be 0");
3226
3227 controller.seek(30.0).unwrap();
3229 assert_eq!(
3230 controller.position(),
3231 30.0,
3232 "Position should be 30 after seeking"
3233 );
3234
3235 controller.seek(60.0).unwrap();
3237 assert_eq!(
3238 controller.position(),
3239 60.0,
3240 "Position should be 60 after seeking"
3241 );
3242
3243 controller.seek(15.0).unwrap();
3245 assert_eq!(
3246 controller.position(),
3247 15.0,
3248 "Position should be 15 after seeking backward"
3249 );
3250 }
3251
3252 #[test]
3253 fn test_seek_while_paused() {
3254 let controller = PlayerController::default();
3255
3256 let item = create_test_items(1).into_iter().next().unwrap();
3258 controller.play_item(item).unwrap();
3259
3260 controller.pause().unwrap();
3262
3263 assert!(controller.state().is_paused(), "Should be paused");
3265
3266 controller.seek(45.0).unwrap();
3268 assert_eq!(
3269 controller.position(),
3270 45.0,
3271 "Position should update while paused"
3272 );
3273
3274 assert!(
3276 controller.state().is_paused(),
3277 "Should still be paused after seeking"
3278 );
3279 }
3280
3281 #[test]
3282 fn test_seek_while_playing() {
3283 let controller = PlayerController::default();
3284
3285 let item = create_test_items(1).into_iter().next().unwrap();
3287 controller.play_item(item).unwrap();
3288
3289 controller.play().unwrap();
3291
3292 assert!(controller.state().is_playing(), "Should be playing");
3294
3295 controller.seek(20.0).unwrap();
3297 assert_eq!(
3298 controller.position(),
3299 20.0,
3300 "Position should update while playing"
3301 );
3302
3303 assert!(
3305 controller.state().is_playing(),
3306 "Should still be playing after seeking"
3307 );
3308 }
3309
3310 #[test]
3311 fn test_multiple_sequential_seeks() {
3312 let controller = PlayerController::default();
3313
3314 let item = create_test_items(1).into_iter().next().unwrap();
3315 controller.play_item(item).unwrap();
3316
3317 let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
3319
3320 for pos in positions {
3321 controller.seek(pos).unwrap();
3322 assert_eq!(
3323 controller.position(),
3324 pos,
3325 "Position should match after seeking to {}",
3326 pos
3327 );
3328 }
3329 }
3330
3331 #[test]
3335 fn test_play_queue_from_resumes_at_position() {
3336 let controller = PlayerController::default();
3337 let items = create_test_items(3);
3338
3339 controller.play_queue_from(items, 1, Some(42.5)).unwrap();
3340
3341 {
3342 let queue = controller.queue();
3343 let queue_lock = queue.lock_safe();
3344 assert_eq!(
3345 queue_lock.current_index(),
3346 Some(1),
3347 "Should start at index 1"
3348 );
3349 }
3350 assert_eq!(
3351 controller.position(),
3352 42.5,
3353 "Should resume at the requested position"
3354 );
3355 }
3356
3357 #[test]
3359 fn test_play_queue_from_without_position_starts_at_zero() {
3360 let controller = PlayerController::default();
3361
3362 controller
3363 .play_queue_from(create_test_items(2), 0, None)
3364 .unwrap();
3365 assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
3366
3367 controller
3368 .play_queue_from(create_test_items(2), 0, Some(0.2))
3369 .unwrap();
3370 assert_eq!(
3371 controller.position(),
3372 0.0,
3373 "Sub-threshold resume position is ignored (starts at 0)"
3374 );
3375 }
3376
3377 #[test]
3378 fn test_seek_to_zero() {
3379 let controller = PlayerController::default();
3380
3381 let item = create_test_items(1).into_iter().next().unwrap();
3382 controller.play_item(item).unwrap();
3383
3384 controller.seek(60.0).unwrap();
3386 assert_eq!(controller.position(), 60.0);
3387
3388 controller.seek(0.0).unwrap();
3390 assert_eq!(
3391 controller.position(),
3392 0.0,
3393 "Should be able to seek to position 0"
3394 );
3395 }
3396
3397 #[tokio::test]
3399 async fn test_audio_with_next_advances() {
3400 let controller = PlayerController::default();
3401
3402 let items = create_test_items(2);
3404 controller.play_queue(items, 0).unwrap();
3405
3406 controller.take_end_reason();
3408
3409 let decision = controller.on_playback_ended().await.unwrap();
3411
3412 assert!(
3414 matches!(decision, AutoplayDecision::AdvanceToNext),
3415 "Expected AdvanceToNext decision when queue has next item"
3416 );
3417 }
3418
3419 #[tokio::test]
3420 async fn test_audio_at_end_stops() {
3421 let controller = PlayerController::default();
3422
3423 let items = create_test_items(2);
3425 controller.play_queue(items, 1).unwrap();
3426
3427 controller.take_end_reason();
3429
3430 let decision = controller.on_playback_ended().await.unwrap();
3432
3433 assert!(
3435 matches!(decision, AutoplayDecision::Stop),
3436 "Expected Stop decision when at end of queue without repeat"
3437 );
3438 }
3439
3440 #[tokio::test]
3441 async fn test_sleep_timer_end_of_track() {
3442 let controller = PlayerController::default();
3443
3444 let items = create_test_items(3);
3446 controller.play_queue(items, 0).unwrap();
3447
3448 controller.take_end_reason();
3450
3451 {
3453 let mut timer = controller.sleep_timer.lock_safe();
3454 timer.mode = SleepTimerMode::EndOfTrack;
3455 }
3456
3457 let decision = controller.on_playback_ended().await.unwrap();
3459
3460 assert!(
3462 matches!(decision, AutoplayDecision::Stop),
3463 "Expected Stop decision when sleep timer is EndOfTrack"
3464 );
3465
3466 {
3468 let timer = controller.sleep_timer.lock_safe();
3469 assert!(
3470 matches!(timer.mode, SleepTimerMode::Off),
3471 "Sleep timer should be cancelled after EndOfTrack"
3472 );
3473 }
3474 }
3475
3476 #[tokio::test]
3485 async fn test_expired_time_sleep_timer_stops_without_autoplay() {
3486 let controller = PlayerController::default();
3487
3488 let items = create_test_items(3);
3489 controller.play_queue(items, 0).unwrap();
3490 controller.take_end_reason();
3491
3492 let now = chrono::Utc::now().timestamp_millis();
3497 controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
3498
3499 for _ in 0..40 {
3501 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3502 if !controller.sleep_timer.lock_safe().is_active() {
3503 break;
3504 }
3505 }
3506 assert!(
3507 !controller.sleep_timer.lock_safe().is_active(),
3508 "Timer thread should have expired and cancelled the sleep timer"
3509 );
3510
3511 let decision = controller.on_playback_ended().await.unwrap();
3513
3514 assert!(
3515 matches!(decision, AutoplayDecision::Stop),
3516 "Expected Stop after an expired time-based sleep timer, got {:?}",
3517 decision
3518 );
3519 }
3520
3521 #[tokio::test]
3522 async fn test_empty_queue_stops() {
3523 let controller = PlayerController::default();
3524
3525 let decision = controller.on_playback_ended().await.unwrap();
3527
3528 assert!(
3530 matches!(decision, AutoplayDecision::Stop),
3531 "Expected Stop decision when queue is empty"
3532 );
3533 }
3534
3535 #[tokio::test]
3536 async fn test_repeat_all_advances_at_end() {
3537 let controller = PlayerController::default();
3538
3539 let items = create_test_items(2);
3541 controller.play_queue(items, 1).unwrap(); controller.cycle_repeat(); controller.take_end_reason();
3546
3547 let decision = controller.on_playback_ended().await.unwrap();
3549
3550 assert!(
3552 matches!(decision, AutoplayDecision::AdvanceToNext),
3553 "Expected AdvanceToNext decision at end of queue with repeat all"
3554 );
3555 }
3556
3557 #[tokio::test]
3558 async fn test_repeat_one_advances() {
3559 let controller = PlayerController::default();
3560
3561 let items = create_test_items(2);
3563 controller.play_queue(items, 0).unwrap();
3564
3565 controller.cycle_repeat(); controller.cycle_repeat(); controller.take_end_reason();
3571
3572 let decision = controller.on_playback_ended().await.unwrap();
3574
3575 assert!(
3577 matches!(decision, AutoplayDecision::AdvanceToNext),
3578 "Expected AdvanceToNext decision with repeat one (repeats same track)"
3579 );
3580 }
3581
3582 #[test]
3583 fn test_native_load_returns_transport_authority_to_the_backend() {
3584 let controller = PlayerController::default();
3603 let emitter = Arc::new(CapturingEmitter::new());
3604 controller.set_event_emitter(emitter.clone());
3605
3606 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
3608 assert!(controller.is_html5_active());
3609
3610 let item = create_test_items(1).into_iter().next().unwrap();
3612 controller.play_item(item).unwrap();
3613
3614 assert!(
3615 !controller.is_html5_active(),
3616 "loading into the native backend hands transport back to it"
3617 );
3618
3619 controller.toggle_playback().unwrap();
3621 let controls: Vec<_> = emitter
3622 .events()
3623 .into_iter()
3624 .filter_map(|e| match e {
3625 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
3626 _ => None,
3627 })
3628 .collect();
3629 assert!(
3630 controls.is_empty(),
3631 "transport went to a webview element that is not rendering: {controls:?}"
3632 );
3633 }
3634
3635 #[test]
3637 fn test_load_and_play_sets_new_track_loaded() {
3638 let controller = PlayerController::default();
3639 let item = create_test_items(1).into_iter().next().unwrap();
3640
3641 assert!(controller.take_end_reason().is_none());
3643
3644 controller.load_and_play(&item).unwrap();
3646
3647 let reason = controller.take_end_reason();
3649 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3650 }
3651
3652 #[test]
3653 fn test_stop_sets_user_stop() {
3654 let controller = PlayerController::default();
3655 let item = create_test_items(1).into_iter().next().unwrap();
3656
3657 controller.play_item(item).unwrap();
3659
3660 controller.take_end_reason();
3662
3663 controller.stop().unwrap();
3665
3666 let reason = controller.take_end_reason();
3668 assert_eq!(reason, Some(EndReason::UserStop));
3669 }
3670
3671 #[tokio::test]
3672 async fn test_on_playback_ended_with_new_track_loaded_stops() {
3673 let controller = PlayerController::default();
3674
3675 let items = create_test_items(2);
3677 controller.play_queue(items, 0).unwrap();
3678
3679 controller.set_end_reason(EndReason::NewTrackLoaded);
3681
3682 let decision = controller.on_playback_ended().await.unwrap();
3684
3685 assert!(
3687 matches!(decision, AutoplayDecision::Stop),
3688 "Expected Stop decision when EndReason is NewTrackLoaded"
3689 );
3690 }
3691
3692 #[tokio::test]
3693 async fn test_on_playback_ended_with_user_stop_stops() {
3694 let controller = PlayerController::default();
3695
3696 let items = create_test_items(2);
3698 controller.play_queue(items, 0).unwrap();
3699
3700 controller.set_end_reason(EndReason::UserStop);
3702
3703 let decision = controller.on_playback_ended().await.unwrap();
3705
3706 assert!(
3708 matches!(decision, AutoplayDecision::Stop),
3709 "Expected Stop decision when EndReason is UserStop"
3710 );
3711 }
3712
3713 #[tokio::test]
3714 async fn test_on_playback_ended_natural_end_advances() {
3715 let controller = PlayerController::default();
3716
3717 let items = create_test_items(2);
3719 controller.play_queue(items, 0).unwrap();
3720
3721 controller.take_end_reason();
3723
3724 let decision = controller.on_playback_ended().await.unwrap();
3726
3727 assert!(
3729 matches!(decision, AutoplayDecision::AdvanceToNext),
3730 "Expected AdvanceToNext decision when track ends naturally with next track available"
3731 );
3732 }
3733
3734 #[tokio::test]
3735 async fn test_on_playback_ended_with_user_skip_stops() {
3736 let controller = PlayerController::default();
3737
3738 let items = create_test_items(2);
3740 controller.play_queue(items, 0).unwrap();
3741
3742 controller.set_end_reason(EndReason::UserSkip);
3744
3745 let decision = controller.on_playback_ended().await.unwrap();
3747
3748 assert!(
3750 matches!(decision, AutoplayDecision::Stop),
3751 "Expected Stop decision when EndReason is UserSkip"
3752 );
3753 }
3754
3755 #[tokio::test]
3756 async fn test_on_playback_ended_with_error_stops() {
3757 let controller = PlayerController::default();
3758
3759 let items = create_test_items(2);
3761 controller.play_queue(items, 0).unwrap();
3762
3763 controller.set_end_reason(EndReason::Error);
3765
3766 let decision = controller.on_playback_ended().await.unwrap();
3768
3769 assert!(
3771 matches!(decision, AutoplayDecision::Stop),
3772 "Expected Stop decision when EndReason is Error"
3773 );
3774 }
3775
3776 #[tokio::test]
3777 async fn test_take_end_reason_clears_state() {
3778 let controller = PlayerController::default();
3779
3780 controller.set_end_reason(EndReason::NewTrackLoaded);
3782
3783 let reason = controller.take_end_reason();
3785 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3786
3787 let reason = controller.take_end_reason();
3789 assert!(reason.is_none(), "take_end_reason should clear the state");
3790 }
3791
3792 use crate::repository::types as repo_types;
3795
3796 struct MockEpisodeRepo {
3800 seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
3802 }
3803
3804 impl MockEpisodeRepo {
3805 fn season(count: usize) -> Self {
3807 Self::series(&[count])
3808 }
3809
3810 fn series(counts: &[usize]) -> Self {
3814 let seasons = counts
3815 .iter()
3816 .enumerate()
3817 .map(|(s, count)| {
3818 let season_number = s as i32 + 1;
3819 let episodes = (1..=*count)
3820 .map(|i| {
3821 let id = if season_number == 1 {
3822 format!("ep{}", i)
3823 } else {
3824 format!("s{}e{}", season_number, i)
3825 };
3826 make_repo_episode_in(season_number, &id, i as i32)
3827 })
3828 .collect();
3829 (make_repo_season(season_number), episodes)
3830 })
3831 .collect();
3832 Self { seasons }
3833 }
3834
3835 fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
3836 self.seasons.iter().flat_map(|(_, eps)| eps.iter())
3837 }
3838 }
3839
3840 fn make_repo_season(index: i32) -> repo_types::MediaItem {
3841 repo_types::MediaItem {
3842 id: format!("season{}", index),
3843 name: format!("Season {}", index),
3844 item_type: "Season".to_string(),
3845 kind: crate::domain::MediaKind::Season,
3846 is_folder: true,
3847 parent_id: Some("series1".to_string()),
3848 index_number: Some(index),
3849 season_id: None,
3850 season_name: None,
3851 parent_index_number: None,
3852 ..make_repo_episode(&format!("season{}", index), index)
3853 }
3854 }
3855
3856 fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
3857 make_repo_episode_in(1, id, index)
3858 }
3859
3860 fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
3861 repo_types::MediaItem {
3862 id: id.to_string(),
3863 name: format!("Episode {}", index),
3864 item_type: "Episode".to_string(),
3865 kind: crate::domain::MediaKind::Episode,
3866 is_folder: false,
3867 server_id: "server".to_string(),
3868 parent_id: Some(format!("season{}", season_number)),
3869 library_id: None,
3870 overview: None,
3871 genres: None,
3872 runtime_ticks: None,
3873 duration_ms: None,
3874 production_year: None,
3875 premiere_date: None,
3876 community_rating: None,
3877 official_rating: None,
3878 primary_image_tag: None,
3879 image_id: None,
3880 backdrop_image_tags: None,
3881 parent_backdrop_image_tags: None,
3882 album_id: None,
3883 album_name: None,
3884 album_artist: None,
3885 artists: None,
3886 artist_items: None,
3887 index_number: Some(index),
3888 series_id: Some("series1".to_string()),
3889 series_name: Some("Test Series".to_string()),
3890 season_id: Some(format!("season{}", season_number)),
3891 season_name: Some(format!("Season {}", season_number)),
3892 parent_index_number: Some(season_number),
3893 user_data: None,
3894 media_streams: None,
3895 media_sources: None,
3896 people: None,
3897 }
3898 }
3899
3900 #[async_trait::async_trait]
3901 impl crate::repository::MediaRepository for MockEpisodeRepo {
3902 async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
3903 unimplemented!()
3904 }
3905 async fn get_items(
3906 &self,
3907 parent_id: &str,
3908 _options: Option<repo_types::GetItemsOptions>,
3909 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3910 let items = if parent_id == "series1" {
3914 self.seasons.iter().map(|(s, _)| s.clone()).collect()
3915 } else {
3916 self.seasons
3917 .iter()
3918 .find(|(season, _)| season.id == parent_id)
3919 .map(|(_, eps)| eps.clone())
3920 .unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
3921 };
3922 let total_record_count = items.len();
3923 Ok(repo_types::SearchResult {
3924 items,
3925 total_record_count,
3926 })
3927 }
3928 async fn get_item(
3929 &self,
3930 item_id: &str,
3931 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3932 self.all_episodes()
3933 .find(|e| e.id == item_id)
3934 .cloned()
3935 .ok_or(repo_types::RepoError::NotFound {
3936 message: format!("{} not found", item_id),
3937 })
3938 }
3939 async fn get_latest_items(
3940 &self,
3941 _: &str,
3942 _: Option<usize>,
3943 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3944 unimplemented!()
3945 }
3946 async fn get_resume_items(
3947 &self,
3948 _: Option<&str>,
3949 _: Option<usize>,
3950 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3951 unimplemented!()
3952 }
3953 async fn get_next_up_episodes(
3954 &self,
3955 _: Option<&str>,
3956 _: Option<usize>,
3957 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3958 unimplemented!()
3959 }
3960 async fn get_recently_played_audio(
3961 &self,
3962 _: Option<usize>,
3963 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3964 unimplemented!()
3965 }
3966 async fn get_rediscover_albums(
3967 &self,
3968 _: Option<&str>,
3969 _: Option<usize>,
3970 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3971 unimplemented!()
3972 }
3973 async fn get_resume_movies(
3974 &self,
3975 _: Option<usize>,
3976 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3977 unimplemented!()
3978 }
3979 async fn get_genres(
3980 &self,
3981 _: Option<&str>,
3982 ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
3983 unimplemented!()
3984 }
3985 async fn search(
3986 &self,
3987 _: &str,
3988 _: Option<repo_types::SearchOptions>,
3989 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3990 unimplemented!()
3991 }
3992 async fn get_playback_info(
3993 &self,
3994 _: &str,
3995 ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
3996 unimplemented!()
3997 }
3998 async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
3999 unimplemented!()
4000 }
4001 async fn get_audio_only_stream_url_for_video(
4002 &self,
4003 item_id: &str,
4004 _media_source_id: Option<&str>,
4005 _start_time_seconds: Option<f64>,
4006 _audio_stream_index: Option<i32>,
4007 ) -> Result<String, repo_types::RepoError> {
4008 Ok(format!("http://example.com/{}-audio.mp3", item_id))
4009 }
4010 async fn get_live_tv_channels(
4011 &self,
4012 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
4013 unimplemented!()
4014 }
4015 async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4016 unimplemented!()
4017 }
4018 async fn open_live_stream(
4019 &self,
4020 _: &str,
4021 ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
4022 unimplemented!()
4023 }
4024 async fn report_playback_start(
4025 &self,
4026 _: &str,
4027 _: i64,
4028 ) -> Result<(), repo_types::RepoError> {
4029 unimplemented!()
4030 }
4031 async fn report_playback_progress(
4032 &self,
4033 _: &str,
4034 _: i64,
4035 ) -> Result<(), repo_types::RepoError> {
4036 unimplemented!()
4037 }
4038 async fn report_playback_stopped(
4039 &self,
4040 _: &str,
4041 _: i64,
4042 ) -> Result<(), repo_types::RepoError> {
4043 unimplemented!()
4044 }
4045 fn get_image_url(
4046 &self,
4047 _: &str,
4048 _: repo_types::ImageType,
4049 _: Option<repo_types::ImageOptions>,
4050 ) -> String {
4051 unimplemented!()
4052 }
4053 fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
4054 unimplemented!()
4055 }
4056 fn get_video_download_url(
4057 &self,
4058 _: &str,
4059 _: &str,
4060 _: Option<&str>,
4061 _: Option<&str>,
4062 ) -> String {
4063 unimplemented!()
4064 }
4065 async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
4066 unimplemented!()
4067 }
4068 async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
4069 unimplemented!()
4070 }
4071 async fn get_favorites(
4072 &self,
4073 _: repo_types::SearchScope,
4074 _: Option<repo_types::GetItemsOptions>,
4075 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4076 unimplemented!()
4077 }
4078 async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
4079 unimplemented!()
4080 }
4081 async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
4082 unimplemented!()
4083 }
4084 async fn get_person(
4085 &self,
4086 _: &str,
4087 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
4088 unimplemented!()
4089 }
4090 async fn get_items_by_person(
4091 &self,
4092 _: &str,
4093 _: Option<repo_types::GetItemsOptions>,
4094 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4095 unimplemented!()
4096 }
4097 async fn get_similar_items(
4098 &self,
4099 _: &str,
4100 _: Option<usize>,
4101 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4102 unimplemented!()
4103 }
4104 async fn create_playlist(
4105 &self,
4106 _: &str,
4107 _: &[String],
4108 ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
4109 unimplemented!()
4110 }
4111 async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
4112 unimplemented!()
4113 }
4114 async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
4115 unimplemented!()
4116 }
4117 async fn get_playlist_items(
4118 &self,
4119 _: &str,
4120 ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
4121 unimplemented!()
4122 }
4123 async fn add_to_playlist(
4124 &self,
4125 _: &str,
4126 _: &[String],
4127 ) -> Result<(), repo_types::RepoError> {
4128 unimplemented!()
4129 }
4130 async fn remove_from_playlist(
4131 &self,
4132 _: &str,
4133 _: &[String],
4134 ) -> Result<(), repo_types::RepoError> {
4135 unimplemented!()
4136 }
4137 async fn move_playlist_item(
4138 &self,
4139 _: &str,
4140 _: &str,
4141 _: u32,
4142 ) -> Result<(), repo_types::RepoError> {
4143 unimplemented!()
4144 }
4145 }
4146
4147 #[tokio::test]
4150 async fn test_video_playback_ended_offers_next_episode() {
4151 let controller = PlayerController::default();
4152 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
4153
4154 let decision = controller
4155 .on_video_playback_ended("ep2", repo)
4156 .await
4157 .expect("decision should succeed");
4158
4159 match decision {
4160 AutoplayDecision::ShowNextEpisodePopup {
4161 current_episode,
4162 next_episode,
4163 auto_advance,
4164 ..
4165 } => {
4166 assert_eq!(current_episode.id, "ep2");
4167 assert_eq!(next_episode.id, "ep3");
4168 assert!(auto_advance, "default settings should auto-advance");
4169 }
4170 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4171 }
4172 }
4173
4174 #[tokio::test]
4181 async fn test_next_episode_rolls_over_to_the_next_season() {
4182 let controller = PlayerController::default();
4183 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
4184
4185 let decision = controller
4186 .on_video_playback_ended("ep2", repo)
4187 .await
4188 .expect("decision should succeed");
4189
4190 match decision {
4191 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4192 assert_eq!(
4193 next_episode.id, "s2e1",
4194 "the first episode of the next season follows the last of this one"
4195 );
4196 }
4197 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4198 }
4199 }
4200
4201 #[tokio::test]
4205 async fn test_next_episode_skips_an_empty_season() {
4206 let controller = PlayerController::default();
4207 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
4208
4209 let decision = controller
4210 .on_video_playback_ended("ep1", repo)
4211 .await
4212 .expect("decision should succeed");
4213
4214 match decision {
4215 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4216 assert_eq!(next_episode.id, "s3e1");
4217 }
4218 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4219 }
4220 }
4221
4222 #[tokio::test]
4228 async fn test_sleep_timer_still_stops_at_a_season_boundary() {
4229 let controller = PlayerController::default();
4230 controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
4231 controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
4232
4233 let episode = MediaItem {
4234 transport: None,
4235 media_type: MediaType::Audio,
4236 item_type: Some("Episode".to_string()),
4237 series_id: Some("series1".to_string()),
4238 duration: Some(180.0),
4239 source: MediaSource::Remote {
4240 stream_url: "http://example.com/ep1.mp3".to_string(),
4241 jellyfin_item_id: "ep1".to_string(),
4242 },
4243 ..create_test_items(1).remove(0)
4244 };
4245 controller.play_queue(vec![episode], 0).unwrap();
4246 controller.seek(180.0).unwrap();
4248 controller.take_end_reason();
4249
4250 let decision = controller.on_playback_ended().await.unwrap();
4251
4252 assert!(
4253 matches!(decision, AutoplayDecision::Stop),
4254 "the last episode the timer allows must stop, not roll into season 2 (got {:?})",
4255 decision
4256 );
4257 }
4258
4259 #[tokio::test]
4261 async fn test_video_playback_ended_last_episode_stops() {
4262 let controller = PlayerController::default();
4263 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
4264
4265 let decision = controller
4266 .on_video_playback_ended("ep3", repo)
4267 .await
4268 .expect("decision should succeed");
4269
4270 assert!(matches!(decision, AutoplayDecision::Stop));
4271 }
4272
4273 #[tokio::test]
4278 async fn test_playback_ended_uses_controller_repository_for_episodes() {
4279 let controller = PlayerController::default();
4280 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4281
4282 let episode = MediaItem {
4284 transport: None,
4286 media_type: MediaType::Video,
4287 source: MediaSource::Remote {
4288 stream_url: "http://example.com/ep1.mkv".to_string(),
4289 jellyfin_item_id: "ep1".to_string(),
4290 },
4291 ..create_test_items(1).remove(0)
4292 };
4293 controller.play_queue(vec![episode], 0).unwrap();
4294
4295 controller.take_end_reason();
4297
4298 let decision = controller.on_playback_ended().await.unwrap();
4299
4300 match decision {
4301 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4302 assert_eq!(next_episode.id, "ep2");
4303 }
4304 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4305 }
4306 }
4307
4308 #[tokio::test]
4315 async fn test_playback_ended_background_audio_episode_advances() {
4316 let controller = PlayerController::default();
4317 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4318
4319 let episode = MediaItem {
4321 transport: None,
4323 item_type: Some("Episode".to_string()),
4324 media_type: MediaType::Audio, series_id: Some("series1".to_string()),
4326 duration: Some(180.0),
4327 source: MediaSource::Remote {
4328 stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
4329 jellyfin_item_id: "ep2".to_string(),
4330 },
4331 ..create_test_items(1).remove(0)
4332 };
4333 controller.play_queue(vec![episode], 0).unwrap();
4334
4335 controller.seek(180.0).unwrap();
4337 controller.take_end_reason();
4339
4340 let decision = controller.on_playback_ended().await.unwrap();
4341
4342 match decision {
4343 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4344 assert_eq!(next_episode.id, "ep3");
4345 }
4346 other => panic!(
4347 "background-audio episode end must advance to the next episode, got {:?}",
4348 other
4349 ),
4350 }
4351 }
4352
4353 #[tokio::test]
4357 async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
4358 let controller = PlayerController::default();
4359 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4360
4361 controller
4362 .advance_to_next_episode_audio_only("ep2")
4363 .await
4364 .expect("advance should succeed");
4365
4366 let current = controller
4367 .queue
4368 .lock_safe()
4369 .current()
4370 .cloned()
4371 .expect("an item should be loaded");
4372 assert_eq!(current.id, "ep2");
4373 assert_eq!(current.media_type, MediaType::Audio);
4374 assert_eq!(current.item_type.as_deref(), Some("Episode"));
4375 assert_eq!(current.series_id.as_deref(), Some("series1"));
4376 match ¤t.source {
4378 MediaSource::Remote { stream_url, .. } => {
4379 assert!(
4380 stream_url.contains("audio"),
4381 "expected audio-only URL, got {}",
4382 stream_url
4383 );
4384 }
4385 other => panic!("expected Remote source, got {:?}", other),
4386 }
4387
4388 assert!(controller.current_is_audio_episode());
4391 }
4392
4393 #[tokio::test]
4401 async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
4402 let controller = PlayerController::default();
4403 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4404
4405 controller.set_background_audio_base(1200.0);
4407
4408 controller
4409 .advance_to_next_episode_audio_only("ep2")
4410 .await
4411 .expect("advance should succeed");
4412
4413 assert_eq!(
4414 controller.take_background_audio_base(),
4415 0.0,
4416 "the next episode starts at its own zero, so the previous handoff \
4417 base must not survive the advance"
4418 );
4419 }
4420
4421 #[tokio::test]
4431 async fn test_background_audio_resume_names_the_advanced_episode() {
4432 let controller = PlayerController::default();
4433 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4434
4435 let episode = MediaItem {
4436 transport: None,
4437 id: "ep1".to_string(),
4438 item_type: Some("Episode".to_string()),
4439 media_type: MediaType::Audio,
4440 series_id: Some("series1".to_string()),
4441 ..create_test_items(1).remove(0)
4442 };
4443 controller.play_queue(vec![episode], 0).unwrap();
4444 controller.enter_background_audio(1200.0);
4445
4446 let before = controller.background_audio_resume();
4447 assert_eq!(before.item_id.as_deref(), Some("ep1"));
4448 assert_eq!(before.position_seconds, 1200.0);
4449
4450 controller
4451 .advance_to_next_episode_audio_only("ep2")
4452 .await
4453 .expect("advance should succeed");
4454
4455 let after = controller.background_audio_resume();
4456 assert_eq!(
4457 after.item_id.as_deref(),
4458 Some("ep2"),
4459 "the foreground must resume the episode the backend advanced to"
4460 );
4461 assert!(
4462 after.position_seconds < 1200.0,
4463 "the previous episode's handoff base must not leak into the new one"
4464 );
4465 }
4466
4467 #[tokio::test]
4479 async fn test_auto_advance_background_audio_episode_advances_in_backend() {
4480 let controller = PlayerController::default();
4481 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4482
4483 let episode = MediaItem {
4485 transport: None,
4487 id: "ep2".to_string(),
4488 item_type: Some("Episode".to_string()),
4489 media_type: MediaType::Audio,
4490 series_id: Some("series1".to_string()),
4491 source: MediaSource::Remote {
4492 stream_url: "http://example.com/ep2-audio.mp3".to_string(),
4493 jellyfin_item_id: "ep2".to_string(),
4494 },
4495 ..create_test_items(1).remove(0)
4496 };
4497 controller.play_queue(vec![episode], 0).unwrap();
4498
4499 let next = make_repo_episode("ep3", 3);
4500 controller.auto_advance_to_next_episode(next, 10).await;
4501
4502 let current = controller
4503 .queue
4504 .lock_safe()
4505 .current()
4506 .cloned()
4507 .expect("an item should still be loaded");
4508 assert_eq!(
4509 current.id, "ep3",
4510 "background audio-only episode must advance in the backend, not wait \
4511 for a frontend navigation that cannot happen while backgrounded"
4512 );
4513 assert_eq!(current.media_type, MediaType::Audio);
4514 assert!(controller.current_is_audio_episode());
4515 }
4516
4517 fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
4521 MediaItem {
4522 transport: None,
4524 id: "ep2".to_string(),
4525 item_type: Some("Episode".to_string()),
4526 media_type: MediaType::Audio,
4527 series_id: Some("series1".to_string()),
4528 duration: Some(runtime_seconds),
4529 source: MediaSource::Remote {
4530 stream_url:
4531 "http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=0"
4532 .to_string(),
4533 jellyfin_item_id: "ep2".to_string(),
4534 },
4535 ..create_test_items(1).remove(0)
4536 }
4537 }
4538
4539 fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
4543 MediaItem {
4544 transport: None,
4546 source: MediaSource::Local {
4547 file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
4548 jellyfin_item_id: Some("ep2".to_string()),
4549 },
4550 ..audio_only_episode(runtime_seconds)
4551 }
4552 }
4553
4554 #[tokio::test]
4561 async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
4562 let controller = PlayerController::default();
4563 controller
4564 .play_queue(vec![local_audio_only_episode(1500.0)], 0)
4565 .unwrap();
4566 controller.enter_background_audio(0.0);
4568
4569 controller.seek_absolute(900.0).await.unwrap();
4570
4571 assert_eq!(controller.position(), 900.0);
4572 }
4573
4574 #[tokio::test]
4586 async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
4587 let controller = PlayerController::default();
4588 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4589
4590 controller
4591 .play_queue(vec![audio_only_episode(1500.0)], 0)
4592 .unwrap();
4593 controller.seek(600.0).unwrap();
4595 controller.take_end_reason();
4596
4597 let decision = controller.on_playback_ended().await.unwrap();
4598
4599 match decision {
4600 AutoplayDecision::ResumeStream { position } => {
4601 assert_eq!(position, 600.0, "must resume where the stream died");
4602 }
4603 other => panic!(
4604 "a stream that ended 15 minutes short of the runtime must resume, \
4605 not run end-of-episode logic; got {:?}",
4606 other
4607 ),
4608 }
4609 }
4610
4611 #[tokio::test]
4624 async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
4625 let controller = PlayerController::default();
4626 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4627 controller
4628 .play_queue(vec![audio_only_episode(1500.0)], 0)
4629 .unwrap();
4630
4631 controller.enter_background_audio(1200.0);
4633
4634 controller.seek_absolute(1490.0).await.unwrap();
4636
4637 let url = {
4638 let queue = controller.queue();
4639 let queue = queue.lock_safe();
4640 match &queue.current().unwrap().source {
4641 MediaSource::Remote { stream_url, .. } => stream_url.clone(),
4642 other => panic!("expected a remote source, got {:?}", other),
4643 }
4644 };
4645 assert!(
4646 url.contains(&format!(
4647 "StartTimeTicks={}",
4648 (1490.0 * 10_000_000.0) as i64
4649 )),
4650 "the stream must be re-opened at the absolute position; got {}",
4651 url
4652 );
4653
4654 assert_eq!(
4655 *controller.background_audio_base.lock_safe(),
4656 1490.0,
4657 "the re-opened stream's zero is the position it was opened at, or \
4658 every later reading is off by the difference"
4659 );
4660 }
4661
4662 #[tokio::test]
4667 async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
4668 let controller = PlayerController::default();
4669 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4670 controller
4671 .play_queue(vec![audio_only_episode(1500.0)], 0)
4672 .unwrap();
4673
4674 controller.seek_absolute(300.0).await.unwrap();
4675
4676 assert_eq!(controller.position(), 300.0);
4677 assert_eq!(
4678 *controller.background_audio_base.lock_safe(),
4679 0.0,
4680 "an ordinary seek must not invent a handoff base"
4681 );
4682 }
4683
4684 #[test]
4704 fn test_absolute_position_floors_at_the_handoff_base() {
4705 let controller = PlayerController::default();
4706 controller.enter_background_audio(18.4);
4707
4708 assert_eq!(controller.position(), 0.0);
4710 assert_eq!(
4711 controller.absolute_position(),
4712 18.4,
4713 "the audio stream's zero IS the handoff point, so the position can \
4714 never legitimately read below it"
4715 );
4716 }
4717
4718 #[test]
4723 fn test_absolute_position_does_not_double_count_the_handoff_base() {
4724 let controller = PlayerController::default();
4725 controller.enter_background_audio(18.4);
4726
4727 controller.seek(120.0).unwrap();
4729
4730 assert_eq!(controller.absolute_position(), 120.0);
4731 }
4732
4733 #[test]
4740 fn test_webview_position_reports_become_the_controllers_position() {
4741 let controller = PlayerController::default();
4742
4743 controller.report_html5_position(253.4, 2640.0);
4744
4745 assert_eq!(controller.absolute_position(), 253.4);
4746 assert_eq!(controller.observed_duration(), Some(2640.0));
4747 }
4748
4749 #[test]
4755 fn test_webview_teardown_clears_the_observed_position() {
4756 let controller = PlayerController::default();
4757 controller.report_html5_position(253.4, 2640.0);
4758
4759 controller.report_html5_state("stopped".to_string(), None);
4760
4761 assert_eq!(controller.absolute_position(), 0.0);
4762 }
4763
4764 #[test]
4769 fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
4770 let controller = PlayerController::default();
4771 controller.report_html5_position(253.4, 2640.0);
4772
4773 controller.enter_background_audio(18.4);
4774
4775 assert_eq!(
4776 controller.absolute_position(),
4777 18.4,
4778 "the video element is gone; only the handoff base describes the \
4779 stream that is now playing"
4780 );
4781 }
4782
4783 #[tokio::test]
4791 async fn test_a_stop_at_zero_is_never_reported() {
4792 let controller = PlayerController::default();
4793 let reports = Arc::new(CapturingReports::new());
4794 controller.set_report_sink(reports.clone());
4795 controller
4796 .play_queue(vec![audio_only_episode(1500.0)], 0)
4797 .unwrap();
4798
4799 controller.stop().unwrap();
4801
4802 assert!(
4803 reports.stops().is_empty(),
4804 "a zero-position stop must be withheld, not sent; got {:?}",
4805 reports.stops()
4806 );
4807 }
4808
4809 #[tokio::test]
4818 async fn test_a_stop_reports_the_position_actually_reached() {
4819 let webview = PlayerController::default();
4821 let webview_reports = Arc::new(CapturingReports::new());
4822 webview.set_report_sink(webview_reports.clone());
4823 webview
4824 .play_queue(vec![audio_only_episode(1500.0)], 0)
4825 .unwrap();
4826 webview.report_html5_position(253.0, 1500.0);
4827
4828 webview.stop().unwrap();
4829
4830 assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4831
4832 let native = PlayerController::default();
4834 let native_reports = Arc::new(CapturingReports::new());
4835 native.set_report_sink(native_reports.clone());
4836 native
4837 .play_queue(vec![audio_only_episode(1500.0)], 0)
4838 .unwrap();
4839 native.seek(253.0).unwrap();
4840
4841 native.stop().unwrap();
4842
4843 assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4844 }
4845
4846 #[tokio::test]
4856 async fn test_a_finished_audio_only_episode_is_reported_complete() {
4857 let controller = PlayerController::default();
4858 let reports = Arc::new(CapturingReports::new());
4859 controller.set_report_sink(reports.clone());
4860 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4861 controller
4862 .play_queue(vec![audio_only_episode(1500.0)], 0)
4863 .unwrap();
4864 controller.seek(1499.0).unwrap();
4866 controller.take_end_reason();
4867
4868 controller.on_playback_ended().await.unwrap();
4869
4870 assert_eq!(
4871 reports.stops(),
4872 vec![("ep2".to_string(), 1500.0)],
4873 "the finished episode must be reported stopped at its runtime, or \
4874 Jellyfin's ≥90% rule never marks it played"
4875 );
4876 }
4877
4878 #[tokio::test]
4884 async fn test_a_truncated_stream_reports_no_completion() {
4885 let controller = PlayerController::default();
4886 let reports = Arc::new(CapturingReports::new());
4887 controller.set_report_sink(reports.clone());
4888 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4889 controller
4890 .play_queue(vec![audio_only_episode(1500.0)], 0)
4891 .unwrap();
4892 controller.seek(600.0).unwrap();
4893 controller.take_end_reason();
4894
4895 let decision = controller.on_playback_ended().await.unwrap();
4896
4897 assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
4898 assert!(
4899 reports.stops().is_empty(),
4900 "a dropped connection is not a finished episode; got {:?}",
4901 reports.stops()
4902 );
4903 }
4904
4905 #[tokio::test]
4913 async fn test_webview_position_ticks_report_progress_to_the_server() {
4914 let controller = PlayerController::default();
4915 let reports = Arc::new(CapturingReports::new());
4916 controller.set_report_sink(reports.clone());
4917 controller
4918 .play_queue(vec![audio_only_episode(1500.0)], 0)
4919 .unwrap();
4920
4921 controller.report_html5_position(253.4, 1500.0);
4922
4923 assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
4924 }
4925
4926 #[tokio::test]
4931 async fn test_progress_reports_are_throttled_not_sent_per_tick() {
4932 let controller = PlayerController::default();
4933 let reports = Arc::new(CapturingReports::new());
4934 controller.set_report_sink(reports.clone());
4935 controller
4936 .play_queue(vec![audio_only_episode(1500.0)], 0)
4937 .unwrap();
4938
4939 for tick in 0..12 {
4940 controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
4941 }
4942
4943 assert_eq!(
4944 reports.progress().len(),
4945 1,
4946 "twelve ticks inside one throttle window are one report"
4947 );
4948 }
4949
4950 #[tokio::test]
4960 async fn test_truncated_check_uses_the_absolute_position() {
4961 let controller = PlayerController::default();
4962 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4963
4964 controller
4965 .play_queue(vec![audio_only_episode(1500.0)], 0)
4966 .unwrap();
4967 controller.set_background_audio_base(1440.0);
4970 controller.seek(1496.0).unwrap();
4971 controller.take_end_reason();
4972
4973 let decision = controller.on_playback_ended().await.unwrap();
4974
4975 assert!(
4976 matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
4977 "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
4978 decision
4979 );
4980 }
4981
4982 #[tokio::test]
4986 async fn test_repeated_truncation_at_the_same_position_gives_up() {
4987 let controller = PlayerController::default();
4988 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4989
4990 controller
4991 .play_queue(vec![audio_only_episode(1500.0)], 0)
4992 .unwrap();
4993 controller.seek(600.0).unwrap();
4994
4995 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4996 controller.take_end_reason();
4997 let decision = controller.on_playback_ended().await.unwrap();
4998 assert!(
4999 matches!(decision, AutoplayDecision::ResumeStream { .. }),
5000 "attempt {} should still resume, got {:?}",
5001 attempt,
5002 decision
5003 );
5004 }
5005
5006 controller.take_end_reason();
5007 let decision = controller.on_playback_ended().await.unwrap();
5008 assert!(
5009 !matches!(decision, AutoplayDecision::ResumeStream { .. }),
5010 "a stream stuck at the same position must stop retrying, got {:?}",
5011 decision
5012 );
5013 }
5014
5015 #[tokio::test]
5019 async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
5020 let controller = PlayerController::default();
5021
5022 let mut items = create_test_items(2);
5023 items[0].duration = Some(1500.0);
5024 controller.play_queue(items, 0).unwrap();
5025 controller.seek(60.0).unwrap();
5026 controller.take_end_reason();
5027
5028 let decision = controller.on_playback_ended().await.unwrap();
5029 assert!(
5030 matches!(decision, AutoplayDecision::AdvanceToNext),
5031 "plain queue audio must keep advancing, got {:?}",
5032 decision
5033 );
5034 }
5035
5036 #[tokio::test]
5042 async fn test_recoverable_error_resumes_a_music_track() {
5043 let controller = PlayerController::default();
5044
5045 let mut items = create_test_items(3);
5046 for item in &mut items {
5047 item.source = MediaSource::Remote {
5048 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
5049 jellyfin_item_id: item.id.clone(),
5050 };
5051 }
5052 controller.play_queue(items, 1).unwrap();
5053 controller.seek(45.0).unwrap();
5054
5055 let (position, _) = controller
5056 .recoverable_error_resume()
5057 .expect("a streamed music track must be resumable after a network error");
5058 assert_eq!(position, 45.0);
5059 }
5060
5061 #[tokio::test]
5065 async fn test_resume_keeps_the_rest_of_the_queue() {
5066 let controller = PlayerController::default();
5067
5068 let mut items = create_test_items(3);
5069 for item in &mut items {
5070 item.source = MediaSource::Remote {
5071 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
5072 jellyfin_item_id: item.id.clone(),
5073 };
5074 }
5075 controller.play_queue(items, 1).unwrap();
5076
5077 controller
5078 .resume_stream_at(45.0)
5079 .await
5080 .expect("resume should succeed");
5081
5082 let queue = controller.queue.lock_safe();
5083 assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
5084 assert_eq!(queue.current_index(), Some(1), "still on the same track");
5085 assert_eq!(queue.current().unwrap().id, "item_1");
5086 }
5087
5088 #[tokio::test]
5092 async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
5093 let controller = PlayerController::default();
5094
5095 let mut items = create_test_items(1);
5096 items[0].source = MediaSource::Remote {
5097 stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
5098 jellyfin_item_id: "item_0".to_string(),
5099 };
5100 controller.play_queue(items, 0).unwrap();
5101
5102 controller.resume_stream_at(45.0).await.unwrap();
5103
5104 match &controller.queue.lock_safe().current().unwrap().source {
5105 MediaSource::Remote { stream_url, .. } => {
5106 assert_eq!(
5107 stream_url, "http://s/Audio/item_0/stream?Static=true",
5108 "a seekable stream's URL must be left alone"
5109 );
5110 }
5111 other => panic!("expected Remote source, got {:?}", other),
5112 }
5113 assert_eq!(
5114 controller.position(),
5115 45.0,
5116 "and it must land at the position"
5117 );
5118 }
5119
5120 #[tokio::test]
5123 async fn test_recoverable_error_ignores_local_media() {
5124 let controller = PlayerController::default();
5125
5126 let mut items = create_test_items(1);
5127 items[0].source = MediaSource::Local {
5128 file_path: "/music/track.flac".into(),
5129 jellyfin_item_id: Some("item_0".to_string()),
5130 };
5131 controller.play_queue(items, 0).unwrap();
5132 controller.seek(45.0).unwrap();
5133
5134 assert!(controller.recoverable_error_resume().is_none());
5135 }
5136
5137 #[tokio::test]
5142 async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
5143 let controller = PlayerController::default();
5144
5145 controller
5146 .play_queue(vec![audio_only_episode(1500.0)], 0)
5147 .unwrap();
5148 controller.seek(600.0).unwrap();
5149
5150 let mut waits = Vec::new();
5151 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
5152 let (position, delay) = controller
5153 .recoverable_error_resume()
5154 .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
5155 assert_eq!(position, 600.0);
5156 waits.push(delay);
5157 }
5158 assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
5159 assert!(
5160 controller.recoverable_error_resume().is_none(),
5161 "a stream that keeps failing at the same spot must surface the error"
5162 );
5163 }
5164
5165 #[tokio::test]
5168 async fn test_recoverable_error_ignores_direct_url_playback() {
5169 let controller = PlayerController::default();
5170 controller.play_queue(create_test_items(2), 0).unwrap();
5171
5172 assert!(controller.recoverable_error_resume().is_none());
5173 }
5174
5175 #[tokio::test]
5179 async fn test_resume_truncated_stream_reloads_at_position() {
5180 let controller = PlayerController::default();
5181
5182 controller
5183 .play_queue(vec![audio_only_episode(1500.0)], 0)
5184 .unwrap();
5185 controller.set_background_audio_base(0.0);
5186
5187 controller
5188 .resume_stream_at(600.0)
5189 .await
5190 .expect("resume should succeed");
5191
5192 let current = controller
5193 .queue
5194 .lock_safe()
5195 .current()
5196 .cloned()
5197 .expect("the same item should still be loaded");
5198 assert_eq!(current.id, "ep2", "resume must not change the item");
5199 match ¤t.source {
5200 MediaSource::Remote { stream_url, .. } => {
5201 assert!(
5202 stream_url.contains("StartTimeTicks=6000000000"),
5203 "stream must re-open at 600s, got {}",
5204 stream_url
5205 );
5206 assert!(
5207 stream_url.contains("AudioStreamIndex=2"),
5208 "the selected audio track must survive the resume, got {}",
5209 stream_url
5210 );
5211 }
5212 other => panic!("expected Remote source, got {:?}", other),
5213 }
5214 assert_eq!(
5215 controller.take_background_audio_base(),
5216 600.0,
5217 "the re-opened stream's zero is the resume position"
5218 );
5219 }
5220
5221 #[tokio::test]
5225 async fn test_auto_advance_foreground_video_episode_uses_countdown() {
5226 let controller = PlayerController::default();
5227 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
5228
5229 let episode = MediaItem {
5230 transport: None,
5232 id: "ep2".to_string(),
5233 item_type: Some("Episode".to_string()),
5234 media_type: MediaType::Video,
5235 series_id: Some("series1".to_string()),
5236 source: MediaSource::Remote {
5237 stream_url: "http://example.com/ep2.m3u8".to_string(),
5238 jellyfin_item_id: "ep2".to_string(),
5239 },
5240 ..create_test_items(1).remove(0)
5241 };
5242 controller.play_queue(vec![episode], 0).unwrap();
5243
5244 let next = make_repo_episode("ep3", 3);
5245 controller.auto_advance_to_next_episode(next, 10).await;
5246
5247 let current = controller
5248 .queue
5249 .lock_safe()
5250 .current()
5251 .cloned()
5252 .expect("an item should still be loaded");
5253 assert_eq!(
5254 current.id, "ep2",
5255 "foreground video advance is frontend-driven; the backend must not \
5256 swap the queue item out from under it"
5257 );
5258 }
5259
5260 #[tokio::test]
5263 async fn test_playback_ended_without_repository_stops() {
5264 let controller = PlayerController::default();
5265
5266 let episode = MediaItem {
5267 transport: None,
5269 media_type: MediaType::Video,
5270 source: MediaSource::Remote {
5271 stream_url: "http://example.com/ep1.mkv".to_string(),
5272 jellyfin_item_id: "ep1".to_string(),
5273 },
5274 ..create_test_items(1).remove(0)
5275 };
5276 controller.play_queue(vec![episode], 0).unwrap();
5277 controller.take_end_reason();
5278
5279 let decision = controller.on_playback_ended().await.unwrap();
5280 assert!(matches!(decision, AutoplayDecision::Stop));
5281 }
5282}