1pub mod autoplay;
6pub mod backend;
7pub mod background_policy;
8#[cfg(any(test, feature = "conformance"))]
9pub mod conformance;
10pub mod events;
11#[cfg(any(test, feature = "conformance"))]
12pub mod fake_player;
13#[cfg(test)]
14mod fake_player_conformance;
15pub mod legacy_player;
16pub mod media;
17pub mod media_player;
18#[cfg(target_os = "linux")]
19pub mod mpv_player;
20pub mod queue;
21pub mod seek;
22pub mod session;
23pub mod sleep_timer;
24pub mod state;
25pub mod stream_end;
26
27#[cfg(test)]
28mod mpv_backend_test;
29
30#[cfg(target_os = "android")]
32pub mod android;
33
34#[cfg(target_os = "linux")]
35pub mod mpv_backend;
36
37pub mod native_video;
40
41#[cfg(target_os = "linux")]
47pub mod mpv_render;
48
49#[cfg(target_os = "linux")]
54pub mod video_surface;
55
56#[cfg(not(any(target_os = "linux", target_os = "android")))]
59pub mod webview_audio_backend;
60
61use crate::repository::stream_selection::StreamSelection;
63pub use autoplay::{AutoplayDecision, AutoplaySettings};
64pub use backend::{NullBackend, PlayerBackend, PlayerError};
65pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
66pub use legacy_player::LegacyPlayer;
67pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
68pub use media_player::{MediaPlayer, OpenRequest, Phase};
69pub use queue::{QueueManager, RepeatMode};
70pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
71pub use session::{MediaSessionManager, MediaSessionType};
72pub use sleep_timer::{SleepTimerMode, SleepTimerState};
73pub use state::{EndReason, PlayerState};
74
75#[cfg(target_os = "android")]
77pub use android::ExoPlayerBackend;
78
79#[cfg(target_os = "linux")]
80pub use mpv_backend::MpvBackend;
81
82#[cfg(not(any(target_os = "linux", target_os = "android")))]
83pub use webview_audio_backend::WebviewAudioBackend;
84
85#[cfg(target_os = "android")]
86pub use android::{
87 disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
88 set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
89};
90
91pub trait PlaybackReportSink: Send + Sync {
102 fn send(&self, operation: PlaybackOperation);
104}
105
106struct ReporterSink {
112 reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
113}
114
115impl PlaybackReportSink for ReporterSink {
116 fn send(&self, operation: PlaybackOperation) {
117 let reporter = self.reporter.clone();
118 let task = async move {
119 let guard = reporter.lock().await;
120 let Some(reporter) = guard.as_ref() else {
121 warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
122 return;
123 };
124 if let Err(e) = reporter.report(operation, true).await {
126 log::error!("[PlayerController] Failed to report playback: {}", e);
127 }
128 };
129
130 if let Ok(handle) = tokio::runtime::Handle::try_current() {
131 handle.spawn(task);
132 } else {
133 std::thread::spawn(move || match tokio::runtime::Runtime::new() {
134 Ok(rt) => rt.block_on(task),
135 Err(e) => log::error!(
136 "[PlayerController] No runtime available to report playback: {}",
137 e
138 ),
139 });
140 }
141 }
142}
143
144fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
154 match runtime {
155 Some(runtime) if runtime > 0.0 => runtime,
156 _ => last_position.max(0.0),
157 }
158}
159
160#[cfg_attr(not(target_os = "android"), allow(dead_code))]
168const RESUME_BACKOFF_STEP_SECS: u64 = 2;
169
170#[derive(Debug, Clone)]
179#[cfg_attr(not(target_os = "android"), allow(dead_code))]
182pub struct LockscreenMetadata {
183 pub title: String,
184 pub artist: String,
185 pub album: Option<String>,
186 pub duration_ms: i64,
188 pub position_ms: i64,
190 pub is_playing: bool,
191}
192
193pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
201 #[cfg(target_os = "android")]
202 {
203 return android::update_lockscreen_metadata(_meta);
204 }
205 #[cfg(not(target_os = "android"))]
206 {
207 Ok(())
208 }
209}
210
211pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
217 #[cfg(target_os = "android")]
218 {
219 return android::set_position_offset(_offset_seconds);
220 }
221 #[cfg(not(target_os = "android"))]
222 {
223 Ok(())
224 }
225}
226
227use crate::utils::lock::MutexSafe;
228use log::{debug, error, info, warn};
229use std::sync::{Arc, Mutex};
230use std::time::Duration;
231use tokio::sync::Mutex as TokioMutex;
232
233use crate::jellyfin::JellyfinClient;
234use crate::playback_reporting::{
235 EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
236};
237use crate::repository::MediaRepository;
238use crate::settings::AudioSettings;
239use crate::utils::conversions::seconds_to_ticks;
240
241pub struct PlayerController {
243 backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
246 queue: Arc<Mutex<QueueManager>>,
247 jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
248 muted: bool,
249
250 sleep_timer: Arc<Mutex<SleepTimerState>>,
252
253 autoplay_settings: Arc<Mutex<AutoplaySettings>>,
255
256 repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
258
259 event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
261
262 countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
264
265 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
267
268 reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
271
272 position_throttler: Arc<EventThrottler>,
275
276 end_reason: Arc<Mutex<Option<EndReason>>>,
278
279 autoplay_episode_count: Arc<Mutex<u32>>,
281
282 background_audio_base: Arc<Mutex<f64>>,
296
297 background_audio_active: Arc<Mutex<bool>>,
307
308 stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
316
317 html5_playing: Arc<Mutex<Option<bool>>>,
327
328 reported_time: Arc<Mutex<stream_end::ObservedTime>>,
340}
341
342impl PlayerController {
343 pub fn new(
344 backend: Box<dyn MediaPlayer>,
345 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
346 position_throttler: Arc<EventThrottler>,
347 ) -> Self {
348 let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
349 reporter: playback_reporter.clone(),
350 });
351 let controller = Self {
352 backend: Arc::new(Mutex::new(backend)),
353 queue: Arc::new(Mutex::new(QueueManager::new())),
354 jellyfin_client: Arc::new(Mutex::new(None)),
355 muted: false,
356 sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
357 autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
358 repository: Arc::new(Mutex::new(None)),
359 event_emitter: Arc::new(Mutex::new(None)),
360 countdown_cancel: Arc::new(Mutex::new(None)),
361 playback_reporter,
362 reports: Arc::new(Mutex::new(reports)),
363 position_throttler,
364 end_reason: Arc::new(Mutex::new(None)),
365 autoplay_episode_count: Arc::new(Mutex::new(0)),
366 background_audio_base: Arc::new(Mutex::new(0.0)),
367 background_audio_active: Arc::new(Mutex::new(false)),
368 stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
369 html5_playing: Arc::new(Mutex::new(None)),
370 reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
371 };
372
373 controller.start_timer_thread();
375
376 controller
377 }
378
379 pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
381 let mut jellyfin = self.jellyfin_client.lock_safe();
382 *jellyfin = client;
383 log::info!(
384 "[PlayerController] Jellyfin client configured: {}",
385 jellyfin.is_some()
386 );
387 }
388
389 pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
391 self.jellyfin_client.clone()
392 }
393
394 pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
401 *self.repository.lock_safe() = Some(repo);
402 }
403
404 pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
407 let mut reporter_guard = self.playback_reporter.lock().await;
408 *reporter_guard = reporter;
409 log::info!(
410 "[PlayerController] Playback reporter configured: {}",
411 reporter_guard.is_some()
412 );
413 }
414
415 #[allow(dead_code)]
418 pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
419 self.playback_reporter.clone()
420 }
421
422 #[allow(dead_code)]
425 pub fn position_throttler(&self) -> Arc<EventThrottler> {
426 self.position_throttler.clone()
427 }
428
429 fn set_end_reason(&self, reason: EndReason) {
431 log::debug!("[PlayerController] Setting end reason: {:?}", reason);
432 *self.end_reason.lock_safe() = Some(reason);
433 }
434
435 fn take_end_reason(&self) -> Option<EndReason> {
437 self.end_reason.lock_safe().take()
438 }
439
440 fn peek_end_reason(&self) -> Option<EndReason> {
447 *self.end_reason.lock_safe()
448 }
449
450 fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
463 log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
464 *end_reason.lock_safe() = Some(EndReason::UserStop);
465 }
466
467 fn increment_autoplay_count(&self) -> bool {
469 let max = self.autoplay_settings.lock_safe().max_episodes;
470
471 if max == 0 {
472 return false;
474 }
475
476 let mut count = self.autoplay_episode_count.lock_safe();
477 *count += 1;
478 debug!(
479 "[PlayerController] Autoplay episode count: {}/{}",
480 *count, max
481 );
482
483 *count >= max
484 }
485
486 fn reset_autoplay_count(&self) {
488 let mut count = self.autoplay_episode_count.lock_safe();
489 if *count > 0 {
490 debug!(
491 "[PlayerController] Resetting autoplay episode counter (was {})",
492 *count
493 );
494 }
495 *count = 0;
496 }
497
498 pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
500 debug!("[PlayerController] play_item: {}", item.title);
501
502 self.reset_autoplay_count();
504
505 {
507 let mut queue = self.queue.lock_safe();
508 queue.set_queue(vec![item.clone()], 0);
509 }
510
511 self.load_and_play(&item)?;
513
514 Ok(())
515 }
516
517 pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
529 debug!(
530 "[PlayerController] set_current_item (no backend load): {}",
531 item.title
532 );
533
534 self.reset_autoplay_count();
535 self.clear_reported_time();
541
542 let mut queue = self.queue.lock_safe();
543 queue.set_queue(vec![item], 0);
544
545 Ok(())
546 }
547
548 pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
551 debug!("[PlayerController] load_and_play: {}", item.title);
552
553 self.set_end_reason(EndReason::NewTrackLoaded);
555
556 *self.html5_playing.lock_safe() = None;
578
579 let mut backend = self.backend.lock_safe();
580 backend.open(OpenRequest::new(
583 item.clone(),
584 StreamSelection::for_queued_item(
585 item.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 exit_background_audio(&self) -> f64 {
1722 *self.background_audio_active.lock_safe() = false;
1723 self.take_background_audio_base()
1724 }
1725
1726 pub fn is_background_audio_active(&self) -> bool {
1731 *self.background_audio_active.lock_safe()
1732 }
1733
1734 pub fn take_background_audio_base(&self) -> f64 {
1738 let mut base = self.background_audio_base.lock_safe();
1739 std::mem::replace(&mut *base, 0.0)
1740 }
1741
1742 pub async fn auto_advance_to_next_episode(
1753 &self,
1754 next_episode: crate::repository::types::MediaItem,
1755 countdown_seconds: u32,
1756 ) {
1757 if self.current_is_audio_episode() {
1762 info!(
1763 "[PlayerController] Background audio episode — advancing to {} in backend",
1764 next_episode.id
1765 );
1766 match self
1767 .advance_to_next_episode_audio_only(&next_episode.id)
1768 .await
1769 {
1770 Ok(()) => self.emit_queue_changed(),
1771 Err(e) => {
1772 error!(
1773 "[PlayerController] Background audio advance failed: {} — stopping",
1774 e
1775 );
1776 if let Some(emitter) = self.event_emitter() {
1777 emitter.emit(PlayerStatusEvent::PlaybackEnded);
1778 }
1779 }
1780 }
1781 return;
1782 }
1783
1784 self.start_autoplay_countdown(next_episode, countdown_seconds);
1786 }
1787
1788 fn is_audio_only_video(item: &MediaItem) -> bool {
1792 stream_end::is_audio_only_video(item)
1793 }
1794
1795 fn claim_stream_resume(&self) -> Option<(f64, u32)> {
1810 let current = {
1811 let queue = self.queue.lock_safe();
1812 queue.current().cloned()
1813 }?;
1814 if !matches!(current.source, MediaSource::Remote { .. }) {
1815 return None;
1816 }
1817
1818 let absolute = self.absolute_position();
1823
1824 match self.stream_resume.lock_safe().allow_attempt(absolute) {
1825 Some(attempt) => Some((absolute, attempt)),
1826 None => {
1827 warn!(
1828 "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
1829 current.id, absolute
1830 );
1831 None
1832 }
1833 }
1834 }
1835
1836 fn truncated_stream_resume_position(&self) -> Option<f64> {
1843 if matches!(
1845 self.peek_end_reason(),
1846 Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
1847 ) {
1848 return None;
1849 }
1850
1851 let item_duration = {
1855 let queue = self.queue.lock_safe();
1856 let current = queue.current()?;
1857 if !Self::is_audio_only_video(current) {
1858 return None;
1859 }
1860 current.duration
1861 };
1862 let absolute = self.position().max(0.0);
1864
1865 if !stream_end::is_truncated_end(
1868 absolute,
1869 item_duration,
1870 stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
1871 ) {
1872 return None;
1873 }
1874
1875 self.claim_stream_resume().map(|(position, _)| position)
1876 }
1877
1878 pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
1900 self.claim_stream_resume()
1901 .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
1902 }
1903
1904 pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
1931 let current = {
1932 let queue = self.queue.lock_safe();
1933 queue.current().cloned()
1934 }
1935 .ok_or_else(|| "No current item to resume".to_string())?;
1936
1937 let MediaSource::Remote { stream_url, .. } = ¤t.source else {
1938 return Err(format!(
1939 "Cannot resume a non-remote source for {}",
1940 current.id
1941 ));
1942 };
1943
1944 info!(
1945 "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
1946 current.id, position
1947 );
1948
1949 if !Self::is_audio_only_video(¤t) {
1950 self.load_and_play(¤t).map_err(|e| e.to_string())?;
1951 if position > 0.5 {
1952 self.seek(position).map_err(|e| e.to_string())?;
1953 }
1954 return Ok(());
1955 }
1956
1957 let restarted_url = stream_end::with_start_time(stream_url, position);
1958 {
1959 let queue_arc = self.queue.clone();
1960 let mut queue = queue_arc.lock_safe();
1961 if !queue.update_current_stream_url(restarted_url) {
1962 return Err(format!("Failed to update stream URL for {}", current.id));
1963 }
1964 }
1965 let resumed = {
1966 let queue = self.queue.lock_safe();
1967 queue.current().cloned()
1968 }
1969 .ok_or_else(|| "Current item vanished mid-resume".to_string())?;
1970
1971 self.set_background_audio_base(position);
1975 let _ = set_lockscreen_position_offset(position.max(0.0));
1976
1977 self.load_and_play(&resumed).map_err(|e| e.to_string())
1978 }
1979
1980 pub async fn advance_to_next_episode_audio_only(
1995 &self,
1996 next_episode_id: &str,
1997 ) -> Result<(), String> {
1998 crate::repository::online::clear_playback_quality_override();
2006
2007 let repo = self
2008 .repository
2009 .lock_safe()
2010 .clone()
2011 .ok_or_else(|| "No repository for background episode advance".to_string())?;
2012
2013 let next = repo
2015 .get_item(next_episode_id)
2016 .await
2017 .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
2018
2019 let stream_url = repo
2022 .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
2023 .await
2024 .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
2025
2026 let media_item = MediaItem {
2027 transport: None,
2029 id: next.id.clone(),
2030 title: next.name.clone(),
2031 name: Some(next.name.clone()),
2032 artist: next.series_name.clone(),
2033 album: None,
2034 album_name: None,
2035 album_id: None,
2036 artist_items: None,
2037 artists: None,
2038 primary_image_tag: next.primary_image_tag.clone(),
2039 image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
2040 item_type: Some("Episode".to_string()),
2042 playlist_id: None,
2043 duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
2044 artwork_url: None,
2045 media_type: MediaType::Audio,
2046 source: MediaSource::Remote {
2047 stream_url,
2048 jellyfin_item_id: next.id.clone(),
2049 },
2050 video_codec: None,
2051 needs_transcoding: false,
2052 video_width: None,
2053 video_height: None,
2054 subtitles: vec![],
2055 series_id: next.series_id.clone(),
2056 server_id: Some(next.server_id.clone()),
2057 };
2058
2059 self.set_background_audio_base(0.0);
2064 let _ = set_lockscreen_position_offset(0.0);
2065 self.stream_resume.lock_safe().reset();
2068
2069 self.play_item(media_item).map_err(|e| e.to_string())
2070 }
2071
2072 pub async fn on_video_playback_ended(
2079 &self,
2080 item_id: &str,
2081 repo: Arc<dyn crate::repository::MediaRepository>,
2082 ) -> Result<AutoplayDecision, String> {
2083 let stale_reason = self.take_end_reason();
2085 if stale_reason.is_some() {
2086 debug!(
2087 "[PlayerController] Cleared stale end_reason for video: {:?}",
2088 stale_reason
2089 );
2090 }
2091
2092 log::info!(
2093 "[PlayerController] on_video_playback_ended: item_id={}",
2094 item_id
2095 );
2096
2097 let timer_mode = {
2099 let timer = self.sleep_timer.lock_safe();
2100 timer.mode.clone()
2101 };
2102
2103 match &timer_mode {
2104 SleepTimerMode::Time { end_time } => {
2105 let now = chrono::Utc::now().timestamp_millis();
2106 if now >= *end_time {
2107 debug!("[PlayerController] Time-based sleep timer expired at video end");
2108 self.sleep_timer.lock_safe().cancel();
2109 self.emit_sleep_timer_changed();
2110 return Ok(AutoplayDecision::Stop);
2111 }
2112 }
2113 SleepTimerMode::EndOfTrack => {
2114 self.sleep_timer.lock_safe().cancel();
2115 self.emit_sleep_timer_changed();
2116 return Ok(AutoplayDecision::Stop);
2117 }
2118 SleepTimerMode::Episodes { .. } => {
2119 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
2120 self.emit_sleep_timer_changed();
2121 if should_stop {
2122 return Ok(AutoplayDecision::Stop);
2123 }
2124 }
2125 _ => {}
2126 }
2127
2128 let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
2132 Ok(next) => next,
2133 Err(e) => {
2134 warn!(
2135 "[PlayerController] Next-episode lookup failed for {}: {}",
2136 item_id, e
2137 );
2138 None
2139 }
2140 };
2141 if let Some(next_ep) = next_ep_result {
2142 let settings = self.autoplay_settings.lock_safe().clone();
2143
2144 let limit_reached = self.increment_autoplay_count();
2145 if limit_reached {
2146 debug!(
2147 "[PlayerController] Auto-play episode limit reached ({} episodes)",
2148 settings.max_episodes
2149 );
2150 }
2151
2152 return Ok(AutoplayDecision::ShowNextEpisodePopup {
2153 current_episode: next_ep.0,
2154 next_episode: next_ep.1,
2155 countdown_seconds: settings.countdown_seconds,
2156 auto_advance: settings.enabled && !limit_reached,
2157 });
2158 }
2159
2160 debug!("[PlayerController] No next episode found for {}", item_id);
2162 Ok(AutoplayDecision::Stop)
2163 }
2164
2165 async fn is_episode_item(&self, item: &MediaItem) -> bool {
2173 match item.item_type.as_deref() {
2174 Some("Episode") => true,
2175 Some(_) => item.media_type == MediaType::Video,
2176 None => item.media_type == MediaType::Video,
2177 }
2178 }
2179
2180 async fn fetch_next_episode_for_item(
2187 &self,
2188 item_id: &str,
2189 repo: &Arc<dyn crate::repository::MediaRepository>,
2190 ) -> Result<
2191 Option<(
2192 crate::repository::types::MediaItem,
2193 crate::repository::types::MediaItem,
2194 )>,
2195 String,
2196 > {
2197 use crate::repository::types::GetItemsOptions;
2198
2199 let current_repo_item = repo
2201 .get_item(item_id)
2202 .await
2203 .map_err(|e| format!("Failed to get current item: {}", e))?;
2204
2205 let season_id = match ¤t_repo_item.season_id {
2207 Some(sid) => sid.clone(),
2208 None => {
2209 log::info!(
2210 "[PlayerController] Current item has no season_id, cannot find next episode"
2211 );
2212 return Ok(None);
2213 }
2214 };
2215
2216 let options = GetItemsOptions {
2218 sort_by: Some("IndexNumber".to_string()),
2219 sort_order: Some("Ascending".to_string()),
2220 limit: Some(500),
2221 include_item_types: Some(vec!["Episode".to_string()]),
2222 ..Default::default()
2223 };
2224
2225 let result = repo
2226 .get_items(&season_id, Some(options))
2227 .await
2228 .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
2229
2230 let mut episodes = result.items;
2233 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2234 log::info!(
2235 "[PlayerController] Season has {} episodes, looking for next after {}",
2236 episodes.len(),
2237 current_repo_item.id
2238 );
2239
2240 if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
2242 if current_idx + 1 < episodes.len() {
2243 let next = &episodes[current_idx + 1];
2244 log::info!(
2245 "[PlayerController] Found next episode: {} (index {})",
2246 next.name,
2247 current_idx + 1
2248 );
2249 return Ok(Some((current_repo_item, next.clone())));
2250 } else {
2251 log::info!("[PlayerController] Current episode is the last in the season");
2252 }
2253 } else {
2254 log::info!(
2255 "[PlayerController] Current episode not found in season episodes (ids: {:?})",
2256 episodes
2257 .iter()
2258 .map(|e| e.id.as_str())
2259 .take(20)
2260 .collect::<Vec<_>>()
2261 );
2262 }
2263
2264 Ok(None)
2265 }
2266
2267 pub fn start_autoplay_countdown(
2269 &self,
2270 _next_item: crate::repository::types::MediaItem,
2271 countdown_seconds: u32,
2272 ) {
2273 let cancel_flag = Arc::new(Mutex::new(false));
2275 *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
2276
2277 let event_emitter = self.event_emitter.clone();
2278
2279 std::thread::spawn(move || {
2280 let mut remaining = countdown_seconds;
2281
2282 while remaining > 0 {
2283 std::thread::sleep(Duration::from_secs(1));
2284
2285 if *cancel_flag.lock_safe() {
2287 log::info!("[PlayerController] Autoplay countdown cancelled");
2288 return;
2289 }
2290
2291 remaining -= 1;
2292
2293 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
2295 emitter.emit(PlayerStatusEvent::CountdownTick {
2296 remaining_seconds: remaining,
2297 });
2298 }
2299 }
2300
2301 log::info!("[PlayerController] Autoplay countdown finished");
2303 });
2304 }
2305}
2306
2307impl Default for PlayerController {
2308 fn default() -> Self {
2309 let playback_reporter = Arc::new(TokioMutex::new(None));
2310 let position_throttler = Arc::new(EventThrottler::new());
2311 Self::new(
2312 Box::new(LegacyPlayer::new(
2313 NullBackend::new(),
2314 crate::player::media_player::Capabilities::mpv(),
2315 )),
2316 playback_reporter,
2317 position_throttler,
2318 )
2319 }
2320}
2321
2322#[cfg(test)]
2323mod tests {
2324
2325 #[test]
2341 fn test_background_episode_advance_clears_the_quality_override() {
2342 let src = include_str!("mod.rs");
2343 let start = src
2344 .find("fn advance_to_next_episode_audio_only")
2345 .expect("advance_to_next_episode_audio_only not found");
2346 let rest = &src[start..];
2347 let end = rest.find("\n pub ").unwrap_or(rest.len());
2348 let body = &rest[..end];
2349
2350 assert!(
2351 body.contains("clear_playback_quality_override"),
2352 "the background episode advance does not clear the per-playback \
2353 quality override, so a ceiling chosen for one episode silently \
2354 caps every episode after it"
2355 );
2356 }
2357
2358 #[test]
2369 fn test_stop_clears_an_active_background_audio_handoff() {
2370 let controller = PlayerController::default();
2371 let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2372 {
2373 let queue_arc = controller.queue();
2374 let mut queue = queue_arc.lock_safe();
2375 queue.set_queue(vec![item], 0);
2376 }
2377
2378 controller.enter_background_audio(557.5);
2379 assert!(
2380 controller.is_background_audio_active(),
2381 "precondition: the handoff is active"
2382 );
2383
2384 controller.stop().expect("stop failed");
2385
2386 assert!(
2387 !controller.is_background_audio_active(),
2388 "a stop must not leave a handoff behind for the next position read"
2389 );
2390 assert_eq!(
2391 *controller.background_audio_base.lock_safe(),
2392 0.0,
2393 "the handoff base must be cleared with it"
2394 );
2395 }
2396
2397 #[test]
2408 fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
2409 let controller = PlayerController::default();
2410 let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2411 item.duration = Some(1800.0);
2412
2413 {
2414 let queue_arc = controller.queue();
2415 let mut queue = queue_arc.lock_safe();
2416 queue.set_queue(vec![item], 0);
2417 }
2418
2419 assert_eq!(
2420 controller.duration(),
2421 Some(1800.0),
2422 "an engine that cannot report a duration should not erase the one the item carries"
2423 );
2424 }
2425 use super::*;
2426
2427 struct CapturingEmitter {
2430 events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
2431 }
2432
2433 impl CapturingEmitter {
2434 fn new() -> Self {
2435 Self {
2436 events: std::sync::Mutex::new(Vec::new()),
2437 }
2438 }
2439 fn events(&self) -> Vec<PlayerStatusEvent> {
2440 self.events.lock_safe().clone()
2441 }
2442 }
2443
2444 impl PlayerEventEmitter for CapturingEmitter {
2445 fn emit(&self, event: PlayerStatusEvent) {
2446 self.events.lock_safe().push(event);
2447 }
2448 }
2449
2450 struct CapturingReports {
2453 operations: std::sync::Mutex<Vec<PlaybackOperation>>,
2454 }
2455
2456 impl CapturingReports {
2457 fn new() -> Self {
2458 Self {
2459 operations: std::sync::Mutex::new(Vec::new()),
2460 }
2461 }
2462
2463 fn stops(&self) -> Vec<(String, f64)> {
2465 self.operations
2466 .lock_safe()
2467 .iter()
2468 .filter_map(|op| match op {
2469 PlaybackOperation::Stopped {
2470 item_id,
2471 position_ticks,
2472 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2473 _ => None,
2474 })
2475 .collect()
2476 }
2477
2478 fn progress(&self) -> Vec<(String, f64)> {
2480 self.operations
2481 .lock_safe()
2482 .iter()
2483 .filter_map(|op| match op {
2484 PlaybackOperation::Progress {
2485 item_id,
2486 position_ticks,
2487 ..
2488 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2489 _ => None,
2490 })
2491 .collect()
2492 }
2493 }
2494
2495 impl PlaybackReportSink for CapturingReports {
2496 fn send(&self, operation: PlaybackOperation) {
2497 self.operations.lock_safe().push(operation);
2498 }
2499 }
2500
2501 #[test]
2502 fn test_report_html5_state_emits_state_changed() {
2503 let controller = PlayerController::default();
2504 let emitter = Arc::new(CapturingEmitter::new());
2505 controller.set_event_emitter(emitter.clone());
2506
2507 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2508
2509 let events = emitter.events();
2510 assert_eq!(events.len(), 1);
2511 match &events[0] {
2512 PlayerStatusEvent::StateChanged { state, media_id } => {
2513 assert_eq!(state, "playing");
2514 assert_eq!(media_id.as_deref(), Some("item-1"));
2515 }
2516 other => panic!("expected StateChanged, got {:?}", other),
2517 }
2518 }
2519
2520 #[test]
2521 fn test_report_html5_position_emits_position_update() {
2522 let controller = PlayerController::default();
2523 let emitter = Arc::new(CapturingEmitter::new());
2524 controller.set_event_emitter(emitter.clone());
2525
2526 controller.report_html5_position(12.5, 300.0);
2527
2528 let events = emitter.events();
2529 assert_eq!(events.len(), 1);
2530 match &events[0] {
2531 PlayerStatusEvent::PositionUpdate { position, duration } => {
2532 assert_eq!(*position, 12.5);
2533 assert_eq!(*duration, 300.0);
2534 }
2535 other => panic!("expected PositionUpdate, got {:?}", other),
2536 }
2537 }
2538
2539 #[test]
2540 fn test_report_html5_media_loaded_emits_media_loaded() {
2541 let controller = PlayerController::default();
2542 let emitter = Arc::new(CapturingEmitter::new());
2543 controller.set_event_emitter(emitter.clone());
2544
2545 controller.report_html5_media_loaded(420.0);
2546
2547 let events = emitter.events();
2548 assert_eq!(events.len(), 1);
2549 match &events[0] {
2550 PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
2551 other => panic!("expected MediaLoaded, got {:?}", other),
2552 }
2553 }
2554
2555 #[test]
2565 fn test_html5_state_is_tracked_from_reports() {
2566 let controller = PlayerController::default();
2567 let emitter = Arc::new(CapturingEmitter::new());
2568 controller.set_event_emitter(emitter.clone());
2569
2570 assert!(!controller.is_html5_active());
2572
2573 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2574 assert!(controller.is_html5_active());
2575 assert!(controller.html5_is_playing());
2576
2577 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2578 assert!(controller.is_html5_active());
2579 assert!(!controller.html5_is_playing());
2580 }
2581
2582 #[test]
2583 fn test_html5_toggle_from_paused_emits_play_control() {
2584 let controller = PlayerController::default();
2585 let emitter = Arc::new(CapturingEmitter::new());
2586 controller.set_event_emitter(emitter.clone());
2587 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2588
2589 controller.toggle_playback().unwrap();
2590
2591 let controls: Vec<_> = emitter
2592 .events()
2593 .into_iter()
2594 .filter_map(|e| match e {
2595 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2596 _ => None,
2597 })
2598 .collect();
2599 assert_eq!(controls, vec!["play".to_string()]);
2600 }
2601
2602 #[test]
2603 fn test_html5_toggle_from_playing_emits_pause_control() {
2604 let controller = PlayerController::default();
2605 let emitter = Arc::new(CapturingEmitter::new());
2606 controller.set_event_emitter(emitter.clone());
2607 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2608
2609 controller.toggle_playback().unwrap();
2610
2611 let controls: Vec<_> = emitter
2612 .events()
2613 .into_iter()
2614 .filter_map(|e| match e {
2615 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2616 _ => None,
2617 })
2618 .collect();
2619 assert_eq!(controls, vec!["pause".to_string()]);
2620 }
2621
2622 #[test]
2623 fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
2624 let controller = PlayerController::default();
2629 let emitter = Arc::new(CapturingEmitter::new());
2630 controller.set_event_emitter(emitter.clone());
2631 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2632
2633 controller.toggle_playback().unwrap();
2634 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2636 controller.toggle_playback().unwrap();
2637
2638 let controls: Vec<_> = emitter
2639 .events()
2640 .into_iter()
2641 .filter_map(|e| match e {
2642 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2643 _ => None,
2644 })
2645 .collect();
2646 assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
2647 }
2648
2649 #[test]
2650 fn test_html5_play_and_pause_emit_control_commands() {
2651 let controller = PlayerController::default();
2652 let emitter = Arc::new(CapturingEmitter::new());
2653 controller.set_event_emitter(emitter.clone());
2654 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2655
2656 controller.play().unwrap();
2657 controller.pause().unwrap();
2658
2659 let controls: Vec<_> = emitter
2660 .events()
2661 .into_iter()
2662 .filter_map(|e| match e {
2663 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2664 _ => None,
2665 })
2666 .collect();
2667 assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
2668 }
2669
2670 #[test]
2671 fn test_background_audio_handoff_moves_transport_to_native_backend() {
2672 let controller = PlayerController::default();
2680 let emitter = Arc::new(CapturingEmitter::new());
2681 controller.set_event_emitter(emitter.clone());
2682
2683 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2685 assert!(controller.is_html5_active());
2686
2687 controller.enter_background_audio(1200.0);
2689 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2690
2691 assert!(
2692 !controller.is_html5_active(),
2693 "native audio owns transport during a background-audio handoff"
2694 );
2695
2696 controller.pause().unwrap();
2697 let controls: Vec<_> = emitter
2698 .events()
2699 .into_iter()
2700 .filter_map(|e| match e {
2701 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2702 _ => None,
2703 })
2704 .collect();
2705 assert!(
2706 controls.is_empty(),
2707 "pause must drive the native backend, not a torn-down element: {:?}",
2708 controls
2709 );
2710 }
2711
2712 #[test]
2713 fn test_background_audio_handoff_suppresses_stale_element_events() {
2714 let controller = PlayerController::default();
2718 let emitter = Arc::new(CapturingEmitter::new());
2719 controller.set_event_emitter(emitter.clone());
2720
2721 controller.enter_background_audio(1200.0);
2722 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2723 controller.report_html5_position(1200.0, 2400.0);
2724
2725 assert!(
2726 emitter.events().is_empty(),
2727 "stale webview reports must not reach the event pipeline: {:?}",
2728 emitter.events()
2729 );
2730 }
2731
2732 #[test]
2733 fn test_exit_background_audio_returns_transport_to_the_webview() {
2734 let controller = PlayerController::default();
2737 let emitter = Arc::new(CapturingEmitter::new());
2738 controller.set_event_emitter(emitter.clone());
2739
2740 controller.enter_background_audio(1200.0);
2741 assert_eq!(controller.exit_background_audio(), 1200.0);
2742
2743 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2744 assert!(controller.is_html5_active());
2745 assert!(controller.html5_is_playing());
2746 }
2747
2748 #[test]
2749 fn test_html5_stopped_report_releases_transport_to_native_backend() {
2750 let controller = PlayerController::default();
2753 let emitter = Arc::new(CapturingEmitter::new());
2754 controller.set_event_emitter(emitter.clone());
2755
2756 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2757 assert!(controller.is_html5_active());
2758
2759 controller.report_html5_state("stopped".to_string(), None);
2760 assert!(!controller.is_html5_active());
2761 }
2762
2763 #[test]
2764 fn test_html5_transport_emits_exactly_one_control_per_intent() {
2765 let controller = PlayerController::default();
2769 let emitter = Arc::new(CapturingEmitter::new());
2770 controller.set_event_emitter(emitter.clone());
2771 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2772
2773 controller.pause().unwrap();
2774
2775 let controls = emitter
2776 .events()
2777 .into_iter()
2778 .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
2779 .count();
2780 assert_eq!(controls, 1, "one intent must produce exactly one control");
2781 }
2782
2783 #[test]
2784 fn test_controller_volume_default() {
2785 let controller = PlayerController::default();
2786 assert_eq!(controller.volume(), 1.0);
2787 }
2788
2789 #[test]
2790 fn test_controller_set_volume() {
2791 let controller = PlayerController::default();
2792 controller.set_volume(0.5).unwrap();
2793 assert_eq!(controller.volume(), 0.5);
2794 }
2795
2796 #[test]
2797 fn test_controller_muted_default() {
2798 let controller = PlayerController::default();
2799 assert!(!controller.muted());
2800 }
2801
2802 #[test]
2803 fn test_controller_volume_delegates_to_backend() {
2804 let controller = PlayerController::default();
2805
2806 controller.set_volume(0.75).unwrap();
2808
2809 assert_eq!(controller.volume(), 0.75);
2811 }
2812
2813 fn create_test_items(count: usize) -> Vec<MediaItem> {
2814 (0..count)
2815 .map(|i| MediaItem {
2816 transport: None,
2818 id: format!("item_{}", i),
2819 title: format!("Track {}", i + 1),
2820 name: Some(format!("Track {}", i + 1)),
2821 artist: Some("Test Artist".to_string()),
2822 album: Some("Test Album".to_string()),
2823 album_name: Some("Test Album".to_string()),
2824 album_id: None,
2825 artist_items: None,
2826 artists: Some(vec!["Test Artist".to_string()]),
2827 primary_image_tag: None,
2828 image_id: None,
2829 item_type: Some("Audio".to_string()),
2830 playlist_id: None,
2831 duration: Some(180.0),
2832 artwork_url: None,
2833 media_type: MediaType::Audio,
2834 source: MediaSource::DirectUrl {
2835 url: format!("http://example.com/track_{}.mp3", i),
2836 },
2837 video_codec: None,
2838 needs_transcoding: false,
2839 video_width: None,
2840 video_height: None,
2841 subtitles: vec![],
2842 series_id: None,
2843 server_id: None,
2844 })
2845 .collect()
2846 }
2847
2848 #[test]
2849 fn test_skip_preserves_queue() {
2850 let controller = PlayerController::default();
2851
2852 let items = create_test_items(5);
2854 let items_clone = items.clone();
2855
2856 controller.play_queue(items, 0).unwrap();
2858
2859 {
2861 let queue = controller.queue();
2862 let queue_lock = queue.lock_safe();
2863 assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
2864 assert_eq!(
2865 queue_lock.current_index(),
2866 Some(0),
2867 "Should start at index 0"
2868 );
2869 assert_eq!(
2870 queue_lock.current().unwrap().id,
2871 "item_0",
2872 "Current item should be item_0"
2873 );
2874 }
2875
2876 controller.next().unwrap();
2878
2879 {
2881 let queue = controller.queue();
2882 let queue_lock = queue.lock_safe();
2883 assert_eq!(
2884 queue_lock.items().len(),
2885 5,
2886 "Queue should still have 5 items after skip"
2887 );
2888 assert_eq!(
2889 queue_lock.current_index(),
2890 Some(1),
2891 "Index should advance to 1"
2892 );
2893 assert_eq!(
2894 queue_lock.current().unwrap().id,
2895 "item_1",
2896 "Current item should be item_1"
2897 );
2898
2899 let current_items = queue_lock.items();
2901 for (i, original) in items_clone.iter().enumerate() {
2902 assert_eq!(
2903 current_items[i].id, original.id,
2904 "Item {} should still be in queue",
2905 i
2906 );
2907 assert_eq!(
2908 current_items[i].title, original.title,
2909 "Item {} title should be unchanged",
2910 i
2911 );
2912 }
2913 }
2914
2915 controller.next().unwrap();
2917
2918 {
2920 let queue = controller.queue();
2921 let queue_lock = queue.lock_safe();
2922 assert_eq!(
2923 queue_lock.items().len(),
2924 5,
2925 "Queue should still have 5 items after second skip"
2926 );
2927 assert_eq!(
2928 queue_lock.current_index(),
2929 Some(2),
2930 "Index should advance to 2"
2931 );
2932 assert_eq!(
2933 queue_lock.current().unwrap().id,
2934 "item_2",
2935 "Current item should be item_2"
2936 );
2937 }
2938
2939 controller.next().unwrap(); controller.next().unwrap(); {
2945 let queue = controller.queue();
2946 let queue_lock = queue.lock_safe();
2947 assert_eq!(
2948 queue_lock.items().len(),
2949 5,
2950 "Queue should still have 5 items at end"
2951 );
2952 assert_eq!(
2953 queue_lock.current_index(),
2954 Some(4),
2955 "Index should be at last item (4)"
2956 );
2957 assert_eq!(
2958 queue_lock.current().unwrap().id,
2959 "item_4",
2960 "Current item should be item_4"
2961 );
2962 }
2963 }
2964
2965 #[test]
2966 fn test_skip_at_end_without_repeat() {
2967 let controller = PlayerController::default();
2968
2969 let items = create_test_items(3);
2971 controller.play_queue(items, 0).unwrap();
2972
2973 controller.next().unwrap(); controller.next().unwrap(); {
2979 let queue = controller.queue();
2980 let queue_lock = queue.lock_safe();
2981 assert_eq!(
2982 queue_lock.current_index(),
2983 Some(2),
2984 "Should be at last item"
2985 );
2986 }
2987
2988 controller.next().unwrap();
2991
2992 {
2994 let queue = controller.queue();
2995 let queue_lock = queue.lock_safe();
2996 assert_eq!(
2997 queue_lock.items().len(),
2998 3,
2999 "Queue should still have 3 items after skip at end"
3000 );
3001 }
3005 }
3006
3007 #[test]
3008 fn test_skip_with_repeat_all() {
3009 let controller = PlayerController::default();
3010
3011 let items = create_test_items(3);
3013 controller.play_queue(items, 0).unwrap();
3014
3015 controller.cycle_repeat();
3017
3018 controller.next().unwrap(); controller.next().unwrap(); controller.next().unwrap();
3024
3025 {
3027 let queue = controller.queue();
3028 let queue_lock = queue.lock_safe();
3029 assert_eq!(
3030 queue_lock.items().len(),
3031 3,
3032 "Queue should still have 3 items"
3033 );
3034 assert_eq!(
3035 queue_lock.current_index(),
3036 Some(0),
3037 "Should wrap to index 0"
3038 );
3039 assert_eq!(
3040 queue_lock.current().unwrap().id,
3041 "item_0",
3042 "Should be back at item_0"
3043 );
3044 }
3045 }
3046
3047 #[test]
3048 fn test_previous_preserves_queue() {
3049 let controller = PlayerController::default();
3050
3051 let items = create_test_items(5);
3053 let items_clone = items.clone();
3054 controller.play_queue(items, 3).unwrap();
3055
3056 {
3058 let queue = controller.queue();
3059 let queue_lock = queue.lock_safe();
3060 assert_eq!(
3061 queue_lock.current_index(),
3062 Some(3),
3063 "Should start at index 3"
3064 );
3065 }
3066
3067 controller.previous().unwrap();
3069
3070 {
3072 let queue = controller.queue();
3073 let queue_lock = queue.lock_safe();
3074 assert_eq!(
3075 queue_lock.items().len(),
3076 5,
3077 "Queue should still have 5 items after previous"
3078 );
3079 assert_eq!(
3080 queue_lock.current_index(),
3081 Some(2),
3082 "Index should move to 2"
3083 );
3084 assert_eq!(
3085 queue_lock.current().unwrap().id,
3086 "item_2",
3087 "Current item should be item_2"
3088 );
3089
3090 let current_items = queue_lock.items();
3092 for (i, original) in items_clone.iter().enumerate() {
3093 assert_eq!(
3094 current_items[i].id, original.id,
3095 "Item {} should still be in queue",
3096 i
3097 );
3098 }
3099 }
3100 }
3101
3102 #[test]
3103 fn test_seek_updates_position() {
3104 let controller = PlayerController::default();
3105
3106 let item = create_test_items(1).into_iter().next().unwrap();
3108 controller.play_item(item).unwrap();
3109
3110 assert_eq!(controller.position(), 0.0, "Initial position should be 0");
3112
3113 controller.seek(30.0).unwrap();
3115 assert_eq!(
3116 controller.position(),
3117 30.0,
3118 "Position should be 30 after seeking"
3119 );
3120
3121 controller.seek(60.0).unwrap();
3123 assert_eq!(
3124 controller.position(),
3125 60.0,
3126 "Position should be 60 after seeking"
3127 );
3128
3129 controller.seek(15.0).unwrap();
3131 assert_eq!(
3132 controller.position(),
3133 15.0,
3134 "Position should be 15 after seeking backward"
3135 );
3136 }
3137
3138 #[test]
3139 fn test_seek_while_paused() {
3140 let controller = PlayerController::default();
3141
3142 let item = create_test_items(1).into_iter().next().unwrap();
3144 controller.play_item(item).unwrap();
3145
3146 controller.pause().unwrap();
3148
3149 assert!(controller.state().is_paused(), "Should be paused");
3151
3152 controller.seek(45.0).unwrap();
3154 assert_eq!(
3155 controller.position(),
3156 45.0,
3157 "Position should update while paused"
3158 );
3159
3160 assert!(
3162 controller.state().is_paused(),
3163 "Should still be paused after seeking"
3164 );
3165 }
3166
3167 #[test]
3168 fn test_seek_while_playing() {
3169 let controller = PlayerController::default();
3170
3171 let item = create_test_items(1).into_iter().next().unwrap();
3173 controller.play_item(item).unwrap();
3174
3175 controller.play().unwrap();
3177
3178 assert!(controller.state().is_playing(), "Should be playing");
3180
3181 controller.seek(20.0).unwrap();
3183 assert_eq!(
3184 controller.position(),
3185 20.0,
3186 "Position should update while playing"
3187 );
3188
3189 assert!(
3191 controller.state().is_playing(),
3192 "Should still be playing after seeking"
3193 );
3194 }
3195
3196 #[test]
3197 fn test_multiple_sequential_seeks() {
3198 let controller = PlayerController::default();
3199
3200 let item = create_test_items(1).into_iter().next().unwrap();
3201 controller.play_item(item).unwrap();
3202
3203 let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
3205
3206 for pos in positions {
3207 controller.seek(pos).unwrap();
3208 assert_eq!(
3209 controller.position(),
3210 pos,
3211 "Position should match after seeking to {}",
3212 pos
3213 );
3214 }
3215 }
3216
3217 #[test]
3221 fn test_play_queue_from_resumes_at_position() {
3222 let controller = PlayerController::default();
3223 let items = create_test_items(3);
3224
3225 controller.play_queue_from(items, 1, Some(42.5)).unwrap();
3226
3227 {
3228 let queue = controller.queue();
3229 let queue_lock = queue.lock_safe();
3230 assert_eq!(
3231 queue_lock.current_index(),
3232 Some(1),
3233 "Should start at index 1"
3234 );
3235 }
3236 assert_eq!(
3237 controller.position(),
3238 42.5,
3239 "Should resume at the requested position"
3240 );
3241 }
3242
3243 #[test]
3245 fn test_play_queue_from_without_position_starts_at_zero() {
3246 let controller = PlayerController::default();
3247
3248 controller
3249 .play_queue_from(create_test_items(2), 0, None)
3250 .unwrap();
3251 assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
3252
3253 controller
3254 .play_queue_from(create_test_items(2), 0, Some(0.2))
3255 .unwrap();
3256 assert_eq!(
3257 controller.position(),
3258 0.0,
3259 "Sub-threshold resume position is ignored (starts at 0)"
3260 );
3261 }
3262
3263 #[test]
3264 fn test_seek_to_zero() {
3265 let controller = PlayerController::default();
3266
3267 let item = create_test_items(1).into_iter().next().unwrap();
3268 controller.play_item(item).unwrap();
3269
3270 controller.seek(60.0).unwrap();
3272 assert_eq!(controller.position(), 60.0);
3273
3274 controller.seek(0.0).unwrap();
3276 assert_eq!(
3277 controller.position(),
3278 0.0,
3279 "Should be able to seek to position 0"
3280 );
3281 }
3282
3283 #[tokio::test]
3285 async fn test_audio_with_next_advances() {
3286 let controller = PlayerController::default();
3287
3288 let items = create_test_items(2);
3290 controller.play_queue(items, 0).unwrap();
3291
3292 controller.take_end_reason();
3294
3295 let decision = controller.on_playback_ended().await.unwrap();
3297
3298 assert!(
3300 matches!(decision, AutoplayDecision::AdvanceToNext),
3301 "Expected AdvanceToNext decision when queue has next item"
3302 );
3303 }
3304
3305 #[tokio::test]
3306 async fn test_audio_at_end_stops() {
3307 let controller = PlayerController::default();
3308
3309 let items = create_test_items(2);
3311 controller.play_queue(items, 1).unwrap();
3312
3313 controller.take_end_reason();
3315
3316 let decision = controller.on_playback_ended().await.unwrap();
3318
3319 assert!(
3321 matches!(decision, AutoplayDecision::Stop),
3322 "Expected Stop decision when at end of queue without repeat"
3323 );
3324 }
3325
3326 #[tokio::test]
3327 async fn test_sleep_timer_end_of_track() {
3328 let controller = PlayerController::default();
3329
3330 let items = create_test_items(3);
3332 controller.play_queue(items, 0).unwrap();
3333
3334 controller.take_end_reason();
3336
3337 {
3339 let mut timer = controller.sleep_timer.lock_safe();
3340 timer.mode = SleepTimerMode::EndOfTrack;
3341 }
3342
3343 let decision = controller.on_playback_ended().await.unwrap();
3345
3346 assert!(
3348 matches!(decision, AutoplayDecision::Stop),
3349 "Expected Stop decision when sleep timer is EndOfTrack"
3350 );
3351
3352 {
3354 let timer = controller.sleep_timer.lock_safe();
3355 assert!(
3356 matches!(timer.mode, SleepTimerMode::Off),
3357 "Sleep timer should be cancelled after EndOfTrack"
3358 );
3359 }
3360 }
3361
3362 #[tokio::test]
3371 async fn test_expired_time_sleep_timer_stops_without_autoplay() {
3372 let controller = PlayerController::default();
3373
3374 let items = create_test_items(3);
3375 controller.play_queue(items, 0).unwrap();
3376 controller.take_end_reason();
3377
3378 let now = chrono::Utc::now().timestamp_millis();
3383 controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
3384
3385 for _ in 0..40 {
3387 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3388 if !controller.sleep_timer.lock_safe().is_active() {
3389 break;
3390 }
3391 }
3392 assert!(
3393 !controller.sleep_timer.lock_safe().is_active(),
3394 "Timer thread should have expired and cancelled the sleep timer"
3395 );
3396
3397 let decision = controller.on_playback_ended().await.unwrap();
3399
3400 assert!(
3401 matches!(decision, AutoplayDecision::Stop),
3402 "Expected Stop after an expired time-based sleep timer, got {:?}",
3403 decision
3404 );
3405 }
3406
3407 #[tokio::test]
3408 async fn test_empty_queue_stops() {
3409 let controller = PlayerController::default();
3410
3411 let decision = controller.on_playback_ended().await.unwrap();
3413
3414 assert!(
3416 matches!(decision, AutoplayDecision::Stop),
3417 "Expected Stop decision when queue is empty"
3418 );
3419 }
3420
3421 #[tokio::test]
3422 async fn test_repeat_all_advances_at_end() {
3423 let controller = PlayerController::default();
3424
3425 let items = create_test_items(2);
3427 controller.play_queue(items, 1).unwrap(); controller.cycle_repeat(); controller.take_end_reason();
3432
3433 let decision = controller.on_playback_ended().await.unwrap();
3435
3436 assert!(
3438 matches!(decision, AutoplayDecision::AdvanceToNext),
3439 "Expected AdvanceToNext decision at end of queue with repeat all"
3440 );
3441 }
3442
3443 #[tokio::test]
3444 async fn test_repeat_one_advances() {
3445 let controller = PlayerController::default();
3446
3447 let items = create_test_items(2);
3449 controller.play_queue(items, 0).unwrap();
3450
3451 controller.cycle_repeat(); controller.cycle_repeat(); controller.take_end_reason();
3457
3458 let decision = controller.on_playback_ended().await.unwrap();
3460
3461 assert!(
3463 matches!(decision, AutoplayDecision::AdvanceToNext),
3464 "Expected AdvanceToNext decision with repeat one (repeats same track)"
3465 );
3466 }
3467
3468 #[test]
3469 fn test_native_load_returns_transport_authority_to_the_backend() {
3470 let controller = PlayerController::default();
3489 let emitter = Arc::new(CapturingEmitter::new());
3490 controller.set_event_emitter(emitter.clone());
3491
3492 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
3494 assert!(controller.is_html5_active());
3495
3496 let item = create_test_items(1).into_iter().next().unwrap();
3498 controller.play_item(item).unwrap();
3499
3500 assert!(
3501 !controller.is_html5_active(),
3502 "loading into the native backend hands transport back to it"
3503 );
3504
3505 controller.toggle_playback().unwrap();
3507 let controls: Vec<_> = emitter
3508 .events()
3509 .into_iter()
3510 .filter_map(|e| match e {
3511 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
3512 _ => None,
3513 })
3514 .collect();
3515 assert!(
3516 controls.is_empty(),
3517 "transport went to a webview element that is not rendering: {controls:?}"
3518 );
3519 }
3520
3521 #[test]
3523 fn test_load_and_play_sets_new_track_loaded() {
3524 let controller = PlayerController::default();
3525 let item = create_test_items(1).into_iter().next().unwrap();
3526
3527 assert!(controller.take_end_reason().is_none());
3529
3530 controller.load_and_play(&item).unwrap();
3532
3533 let reason = controller.take_end_reason();
3535 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3536 }
3537
3538 #[test]
3539 fn test_stop_sets_user_stop() {
3540 let controller = PlayerController::default();
3541 let item = create_test_items(1).into_iter().next().unwrap();
3542
3543 controller.play_item(item).unwrap();
3545
3546 controller.take_end_reason();
3548
3549 controller.stop().unwrap();
3551
3552 let reason = controller.take_end_reason();
3554 assert_eq!(reason, Some(EndReason::UserStop));
3555 }
3556
3557 #[tokio::test]
3558 async fn test_on_playback_ended_with_new_track_loaded_stops() {
3559 let controller = PlayerController::default();
3560
3561 let items = create_test_items(2);
3563 controller.play_queue(items, 0).unwrap();
3564
3565 controller.set_end_reason(EndReason::NewTrackLoaded);
3567
3568 let decision = controller.on_playback_ended().await.unwrap();
3570
3571 assert!(
3573 matches!(decision, AutoplayDecision::Stop),
3574 "Expected Stop decision when EndReason is NewTrackLoaded"
3575 );
3576 }
3577
3578 #[tokio::test]
3579 async fn test_on_playback_ended_with_user_stop_stops() {
3580 let controller = PlayerController::default();
3581
3582 let items = create_test_items(2);
3584 controller.play_queue(items, 0).unwrap();
3585
3586 controller.set_end_reason(EndReason::UserStop);
3588
3589 let decision = controller.on_playback_ended().await.unwrap();
3591
3592 assert!(
3594 matches!(decision, AutoplayDecision::Stop),
3595 "Expected Stop decision when EndReason is UserStop"
3596 );
3597 }
3598
3599 #[tokio::test]
3600 async fn test_on_playback_ended_natural_end_advances() {
3601 let controller = PlayerController::default();
3602
3603 let items = create_test_items(2);
3605 controller.play_queue(items, 0).unwrap();
3606
3607 controller.take_end_reason();
3609
3610 let decision = controller.on_playback_ended().await.unwrap();
3612
3613 assert!(
3615 matches!(decision, AutoplayDecision::AdvanceToNext),
3616 "Expected AdvanceToNext decision when track ends naturally with next track available"
3617 );
3618 }
3619
3620 #[tokio::test]
3621 async fn test_on_playback_ended_with_user_skip_stops() {
3622 let controller = PlayerController::default();
3623
3624 let items = create_test_items(2);
3626 controller.play_queue(items, 0).unwrap();
3627
3628 controller.set_end_reason(EndReason::UserSkip);
3630
3631 let decision = controller.on_playback_ended().await.unwrap();
3633
3634 assert!(
3636 matches!(decision, AutoplayDecision::Stop),
3637 "Expected Stop decision when EndReason is UserSkip"
3638 );
3639 }
3640
3641 #[tokio::test]
3642 async fn test_on_playback_ended_with_error_stops() {
3643 let controller = PlayerController::default();
3644
3645 let items = create_test_items(2);
3647 controller.play_queue(items, 0).unwrap();
3648
3649 controller.set_end_reason(EndReason::Error);
3651
3652 let decision = controller.on_playback_ended().await.unwrap();
3654
3655 assert!(
3657 matches!(decision, AutoplayDecision::Stop),
3658 "Expected Stop decision when EndReason is Error"
3659 );
3660 }
3661
3662 #[tokio::test]
3663 async fn test_take_end_reason_clears_state() {
3664 let controller = PlayerController::default();
3665
3666 controller.set_end_reason(EndReason::NewTrackLoaded);
3668
3669 let reason = controller.take_end_reason();
3671 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3672
3673 let reason = controller.take_end_reason();
3675 assert!(reason.is_none(), "take_end_reason should clear the state");
3676 }
3677
3678 use crate::repository::types as repo_types;
3681
3682 struct MockEpisodeRepo {
3686 episodes: Vec<repo_types::MediaItem>,
3687 }
3688
3689 impl MockEpisodeRepo {
3690 fn season(count: usize) -> Self {
3691 let episodes = (1..=count)
3692 .map(|i| {
3693 let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
3694 item.name = format!("Episode {}", i);
3695 item
3696 })
3697 .collect();
3698 Self { episodes }
3699 }
3700 }
3701
3702 fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
3703 repo_types::MediaItem {
3704 id: id.to_string(),
3705 name: format!("Episode {}", index),
3706 item_type: "Episode".to_string(),
3707 kind: crate::domain::MediaKind::Episode,
3708 is_folder: false,
3709 server_id: "server".to_string(),
3710 parent_id: Some("season1".to_string()),
3711 library_id: None,
3712 overview: None,
3713 genres: None,
3714 runtime_ticks: None,
3715 duration_ms: None,
3716 production_year: None,
3717 premiere_date: None,
3718 community_rating: None,
3719 official_rating: None,
3720 primary_image_tag: None,
3721 image_id: None,
3722 backdrop_image_tags: None,
3723 parent_backdrop_image_tags: None,
3724 album_id: None,
3725 album_name: None,
3726 album_artist: None,
3727 artists: None,
3728 artist_items: None,
3729 index_number: Some(index),
3730 series_id: Some("series1".to_string()),
3731 series_name: Some("Test Series".to_string()),
3732 season_id: Some("season1".to_string()),
3733 season_name: Some("Season 1".to_string()),
3734 parent_index_number: Some(1),
3735 user_data: None,
3736 media_streams: None,
3737 media_sources: None,
3738 people: None,
3739 }
3740 }
3741
3742 #[async_trait::async_trait]
3743 impl crate::repository::MediaRepository for MockEpisodeRepo {
3744 async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
3745 unimplemented!()
3746 }
3747 async fn get_items(
3748 &self,
3749 parent_id: &str,
3750 _options: Option<repo_types::GetItemsOptions>,
3751 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3752 assert_eq!(parent_id, "season1", "episode lookup must query the season");
3753 Ok(repo_types::SearchResult {
3754 items: self.episodes.clone(),
3755 total_record_count: self.episodes.len(),
3756 })
3757 }
3758 async fn get_item(
3759 &self,
3760 item_id: &str,
3761 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3762 self.episodes
3763 .iter()
3764 .find(|e| e.id == item_id)
3765 .cloned()
3766 .ok_or(repo_types::RepoError::NotFound {
3767 message: format!("{} not found", item_id),
3768 })
3769 }
3770 async fn get_latest_items(
3771 &self,
3772 _: &str,
3773 _: Option<usize>,
3774 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3775 unimplemented!()
3776 }
3777 async fn get_resume_items(
3778 &self,
3779 _: Option<&str>,
3780 _: Option<usize>,
3781 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3782 unimplemented!()
3783 }
3784 async fn get_next_up_episodes(
3785 &self,
3786 _: Option<&str>,
3787 _: Option<usize>,
3788 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3789 unimplemented!()
3790 }
3791 async fn get_recently_played_audio(
3792 &self,
3793 _: Option<usize>,
3794 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3795 unimplemented!()
3796 }
3797 async fn get_rediscover_albums(
3798 &self,
3799 _: Option<&str>,
3800 _: Option<usize>,
3801 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3802 unimplemented!()
3803 }
3804 async fn get_resume_movies(
3805 &self,
3806 _: Option<usize>,
3807 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3808 unimplemented!()
3809 }
3810 async fn get_genres(
3811 &self,
3812 _: Option<&str>,
3813 ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
3814 unimplemented!()
3815 }
3816 async fn search(
3817 &self,
3818 _: &str,
3819 _: Option<repo_types::SearchOptions>,
3820 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3821 unimplemented!()
3822 }
3823 async fn get_playback_info(
3824 &self,
3825 _: &str,
3826 ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
3827 unimplemented!()
3828 }
3829 async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
3830 unimplemented!()
3831 }
3832 async fn get_audio_only_stream_url_for_video(
3833 &self,
3834 item_id: &str,
3835 _media_source_id: Option<&str>,
3836 _start_time_seconds: Option<f64>,
3837 _audio_stream_index: Option<i32>,
3838 ) -> Result<String, repo_types::RepoError> {
3839 Ok(format!("http://example.com/{}-audio.mp3", item_id))
3840 }
3841 async fn get_live_tv_channels(
3842 &self,
3843 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3844 unimplemented!()
3845 }
3846 async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3847 unimplemented!()
3848 }
3849 async fn open_live_stream(
3850 &self,
3851 _: &str,
3852 ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
3853 unimplemented!()
3854 }
3855 async fn report_playback_start(
3856 &self,
3857 _: &str,
3858 _: i64,
3859 ) -> Result<(), repo_types::RepoError> {
3860 unimplemented!()
3861 }
3862 async fn report_playback_progress(
3863 &self,
3864 _: &str,
3865 _: i64,
3866 ) -> Result<(), repo_types::RepoError> {
3867 unimplemented!()
3868 }
3869 async fn report_playback_stopped(
3870 &self,
3871 _: &str,
3872 _: i64,
3873 ) -> Result<(), repo_types::RepoError> {
3874 unimplemented!()
3875 }
3876 fn get_image_url(
3877 &self,
3878 _: &str,
3879 _: repo_types::ImageType,
3880 _: Option<repo_types::ImageOptions>,
3881 ) -> String {
3882 unimplemented!()
3883 }
3884 fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
3885 unimplemented!()
3886 }
3887 fn get_video_download_url(
3888 &self,
3889 _: &str,
3890 _: &str,
3891 _: Option<&str>,
3892 _: Option<&str>,
3893 ) -> String {
3894 unimplemented!()
3895 }
3896 async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
3897 unimplemented!()
3898 }
3899 async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
3900 unimplemented!()
3901 }
3902 async fn get_favorites(
3903 &self,
3904 _: repo_types::SearchScope,
3905 _: Option<repo_types::GetItemsOptions>,
3906 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3907 unimplemented!()
3908 }
3909 async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
3910 unimplemented!()
3911 }
3912 async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
3913 unimplemented!()
3914 }
3915 async fn get_person(
3916 &self,
3917 _: &str,
3918 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3919 unimplemented!()
3920 }
3921 async fn get_items_by_person(
3922 &self,
3923 _: &str,
3924 _: Option<repo_types::GetItemsOptions>,
3925 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3926 unimplemented!()
3927 }
3928 async fn get_similar_items(
3929 &self,
3930 _: &str,
3931 _: Option<usize>,
3932 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3933 unimplemented!()
3934 }
3935 async fn create_playlist(
3936 &self,
3937 _: &str,
3938 _: &[String],
3939 ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
3940 unimplemented!()
3941 }
3942 async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
3943 unimplemented!()
3944 }
3945 async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
3946 unimplemented!()
3947 }
3948 async fn get_playlist_items(
3949 &self,
3950 _: &str,
3951 ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
3952 unimplemented!()
3953 }
3954 async fn add_to_playlist(
3955 &self,
3956 _: &str,
3957 _: &[String],
3958 ) -> Result<(), repo_types::RepoError> {
3959 unimplemented!()
3960 }
3961 async fn remove_from_playlist(
3962 &self,
3963 _: &str,
3964 _: &[String],
3965 ) -> Result<(), repo_types::RepoError> {
3966 unimplemented!()
3967 }
3968 async fn move_playlist_item(
3969 &self,
3970 _: &str,
3971 _: &str,
3972 _: u32,
3973 ) -> Result<(), repo_types::RepoError> {
3974 unimplemented!()
3975 }
3976 }
3977
3978 #[tokio::test]
3981 async fn test_video_playback_ended_offers_next_episode() {
3982 let controller = PlayerController::default();
3983 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
3984
3985 let decision = controller
3986 .on_video_playback_ended("ep2", repo)
3987 .await
3988 .expect("decision should succeed");
3989
3990 match decision {
3991 AutoplayDecision::ShowNextEpisodePopup {
3992 current_episode,
3993 next_episode,
3994 auto_advance,
3995 ..
3996 } => {
3997 assert_eq!(current_episode.id, "ep2");
3998 assert_eq!(next_episode.id, "ep3");
3999 assert!(auto_advance, "default settings should auto-advance");
4000 }
4001 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4002 }
4003 }
4004
4005 #[tokio::test]
4007 async fn test_video_playback_ended_last_episode_stops() {
4008 let controller = PlayerController::default();
4009 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
4010
4011 let decision = controller
4012 .on_video_playback_ended("ep3", repo)
4013 .await
4014 .expect("decision should succeed");
4015
4016 assert!(matches!(decision, AutoplayDecision::Stop));
4017 }
4018
4019 #[tokio::test]
4024 async fn test_playback_ended_uses_controller_repository_for_episodes() {
4025 let controller = PlayerController::default();
4026 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4027
4028 let episode = MediaItem {
4030 transport: None,
4032 media_type: MediaType::Video,
4033 source: MediaSource::Remote {
4034 stream_url: "http://example.com/ep1.mkv".to_string(),
4035 jellyfin_item_id: "ep1".to_string(),
4036 },
4037 ..create_test_items(1).remove(0)
4038 };
4039 controller.play_queue(vec![episode], 0).unwrap();
4040
4041 controller.take_end_reason();
4043
4044 let decision = controller.on_playback_ended().await.unwrap();
4045
4046 match decision {
4047 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4048 assert_eq!(next_episode.id, "ep2");
4049 }
4050 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4051 }
4052 }
4053
4054 #[tokio::test]
4061 async fn test_playback_ended_background_audio_episode_advances() {
4062 let controller = PlayerController::default();
4063 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4064
4065 let episode = MediaItem {
4067 transport: None,
4069 item_type: Some("Episode".to_string()),
4070 media_type: MediaType::Audio, series_id: Some("series1".to_string()),
4072 duration: Some(180.0),
4073 source: MediaSource::Remote {
4074 stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
4075 jellyfin_item_id: "ep2".to_string(),
4076 },
4077 ..create_test_items(1).remove(0)
4078 };
4079 controller.play_queue(vec![episode], 0).unwrap();
4080
4081 controller.seek(180.0).unwrap();
4083 controller.take_end_reason();
4085
4086 let decision = controller.on_playback_ended().await.unwrap();
4087
4088 match decision {
4089 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4090 assert_eq!(next_episode.id, "ep3");
4091 }
4092 other => panic!(
4093 "background-audio episode end must advance to the next episode, got {:?}",
4094 other
4095 ),
4096 }
4097 }
4098
4099 #[tokio::test]
4103 async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
4104 let controller = PlayerController::default();
4105 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4106
4107 controller
4108 .advance_to_next_episode_audio_only("ep2")
4109 .await
4110 .expect("advance should succeed");
4111
4112 let current = controller
4113 .queue
4114 .lock_safe()
4115 .current()
4116 .cloned()
4117 .expect("an item should be loaded");
4118 assert_eq!(current.id, "ep2");
4119 assert_eq!(current.media_type, MediaType::Audio);
4120 assert_eq!(current.item_type.as_deref(), Some("Episode"));
4121 assert_eq!(current.series_id.as_deref(), Some("series1"));
4122 match ¤t.source {
4124 MediaSource::Remote { stream_url, .. } => {
4125 assert!(
4126 stream_url.contains("audio"),
4127 "expected audio-only URL, got {}",
4128 stream_url
4129 );
4130 }
4131 other => panic!("expected Remote source, got {:?}", other),
4132 }
4133
4134 assert!(controller.current_is_audio_episode());
4137 }
4138
4139 #[tokio::test]
4147 async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
4148 let controller = PlayerController::default();
4149 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4150
4151 controller.set_background_audio_base(1200.0);
4153
4154 controller
4155 .advance_to_next_episode_audio_only("ep2")
4156 .await
4157 .expect("advance should succeed");
4158
4159 assert_eq!(
4160 controller.take_background_audio_base(),
4161 0.0,
4162 "the next episode starts at its own zero, so the previous handoff \
4163 base must not survive the advance"
4164 );
4165 }
4166
4167 #[tokio::test]
4179 async fn test_auto_advance_background_audio_episode_advances_in_backend() {
4180 let controller = PlayerController::default();
4181 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4182
4183 let episode = MediaItem {
4185 transport: None,
4187 id: "ep2".to_string(),
4188 item_type: Some("Episode".to_string()),
4189 media_type: MediaType::Audio,
4190 series_id: Some("series1".to_string()),
4191 source: MediaSource::Remote {
4192 stream_url: "http://example.com/ep2-audio.mp3".to_string(),
4193 jellyfin_item_id: "ep2".to_string(),
4194 },
4195 ..create_test_items(1).remove(0)
4196 };
4197 controller.play_queue(vec![episode], 0).unwrap();
4198
4199 let next = make_repo_episode("ep3", 3);
4200 controller.auto_advance_to_next_episode(next, 10).await;
4201
4202 let current = controller
4203 .queue
4204 .lock_safe()
4205 .current()
4206 .cloned()
4207 .expect("an item should still be loaded");
4208 assert_eq!(
4209 current.id, "ep3",
4210 "background audio-only episode must advance in the backend, not wait \
4211 for a frontend navigation that cannot happen while backgrounded"
4212 );
4213 assert_eq!(current.media_type, MediaType::Audio);
4214 assert!(controller.current_is_audio_episode());
4215 }
4216
4217 fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
4221 MediaItem {
4222 transport: None,
4224 id: "ep2".to_string(),
4225 item_type: Some("Episode".to_string()),
4226 media_type: MediaType::Audio,
4227 series_id: Some("series1".to_string()),
4228 duration: Some(runtime_seconds),
4229 source: MediaSource::Remote {
4230 stream_url:
4231 "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
4232 .to_string(),
4233 jellyfin_item_id: "ep2".to_string(),
4234 },
4235 ..create_test_items(1).remove(0)
4236 }
4237 }
4238
4239 fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
4243 MediaItem {
4244 transport: None,
4246 source: MediaSource::Local {
4247 file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
4248 jellyfin_item_id: Some("ep2".to_string()),
4249 },
4250 ..audio_only_episode(runtime_seconds)
4251 }
4252 }
4253
4254 #[tokio::test]
4261 async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
4262 let controller = PlayerController::default();
4263 controller
4264 .play_queue(vec![local_audio_only_episode(1500.0)], 0)
4265 .unwrap();
4266 controller.enter_background_audio(0.0);
4268
4269 controller.seek_absolute(900.0).await.unwrap();
4270
4271 assert_eq!(controller.position(), 900.0);
4272 }
4273
4274 #[tokio::test]
4286 async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
4287 let controller = PlayerController::default();
4288 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4289
4290 controller
4291 .play_queue(vec![audio_only_episode(1500.0)], 0)
4292 .unwrap();
4293 controller.seek(600.0).unwrap();
4295 controller.take_end_reason();
4296
4297 let decision = controller.on_playback_ended().await.unwrap();
4298
4299 match decision {
4300 AutoplayDecision::ResumeStream { position } => {
4301 assert_eq!(position, 600.0, "must resume where the stream died");
4302 }
4303 other => panic!(
4304 "a stream that ended 15 minutes short of the runtime must resume, \
4305 not run end-of-episode logic; got {:?}",
4306 other
4307 ),
4308 }
4309 }
4310
4311 #[tokio::test]
4324 async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
4325 let controller = PlayerController::default();
4326 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4327 controller
4328 .play_queue(vec![audio_only_episode(1500.0)], 0)
4329 .unwrap();
4330
4331 controller.enter_background_audio(1200.0);
4333
4334 controller.seek_absolute(1490.0).await.unwrap();
4336
4337 let url = {
4338 let queue = controller.queue();
4339 let queue = queue.lock_safe();
4340 match &queue.current().unwrap().source {
4341 MediaSource::Remote { stream_url, .. } => stream_url.clone(),
4342 other => panic!("expected a remote source, got {:?}", other),
4343 }
4344 };
4345 assert!(
4346 url.contains(&format!(
4347 "StartTimeTicks={}",
4348 (1490.0 * 10_000_000.0) as i64
4349 )),
4350 "the stream must be re-opened at the absolute position; got {}",
4351 url
4352 );
4353
4354 assert_eq!(
4355 *controller.background_audio_base.lock_safe(),
4356 1490.0,
4357 "the re-opened stream's zero is the position it was opened at, or \
4358 every later reading is off by the difference"
4359 );
4360 }
4361
4362 #[tokio::test]
4367 async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
4368 let controller = PlayerController::default();
4369 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4370 controller
4371 .play_queue(vec![audio_only_episode(1500.0)], 0)
4372 .unwrap();
4373
4374 controller.seek_absolute(300.0).await.unwrap();
4375
4376 assert_eq!(controller.position(), 300.0);
4377 assert_eq!(
4378 *controller.background_audio_base.lock_safe(),
4379 0.0,
4380 "an ordinary seek must not invent a handoff base"
4381 );
4382 }
4383
4384 #[test]
4404 fn test_absolute_position_floors_at_the_handoff_base() {
4405 let controller = PlayerController::default();
4406 controller.enter_background_audio(18.4);
4407
4408 assert_eq!(controller.position(), 0.0);
4410 assert_eq!(
4411 controller.absolute_position(),
4412 18.4,
4413 "the audio stream's zero IS the handoff point, so the position can \
4414 never legitimately read below it"
4415 );
4416 }
4417
4418 #[test]
4423 fn test_absolute_position_does_not_double_count_the_handoff_base() {
4424 let controller = PlayerController::default();
4425 controller.enter_background_audio(18.4);
4426
4427 controller.seek(120.0).unwrap();
4429
4430 assert_eq!(controller.absolute_position(), 120.0);
4431 }
4432
4433 #[test]
4440 fn test_webview_position_reports_become_the_controllers_position() {
4441 let controller = PlayerController::default();
4442
4443 controller.report_html5_position(253.4, 2640.0);
4444
4445 assert_eq!(controller.absolute_position(), 253.4);
4446 assert_eq!(controller.observed_duration(), Some(2640.0));
4447 }
4448
4449 #[test]
4455 fn test_webview_teardown_clears_the_observed_position() {
4456 let controller = PlayerController::default();
4457 controller.report_html5_position(253.4, 2640.0);
4458
4459 controller.report_html5_state("stopped".to_string(), None);
4460
4461 assert_eq!(controller.absolute_position(), 0.0);
4462 }
4463
4464 #[test]
4469 fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
4470 let controller = PlayerController::default();
4471 controller.report_html5_position(253.4, 2640.0);
4472
4473 controller.enter_background_audio(18.4);
4474
4475 assert_eq!(
4476 controller.absolute_position(),
4477 18.4,
4478 "the video element is gone; only the handoff base describes the \
4479 stream that is now playing"
4480 );
4481 }
4482
4483 #[tokio::test]
4491 async fn test_a_stop_at_zero_is_never_reported() {
4492 let controller = PlayerController::default();
4493 let reports = Arc::new(CapturingReports::new());
4494 controller.set_report_sink(reports.clone());
4495 controller
4496 .play_queue(vec![audio_only_episode(1500.0)], 0)
4497 .unwrap();
4498
4499 controller.stop().unwrap();
4501
4502 assert!(
4503 reports.stops().is_empty(),
4504 "a zero-position stop must be withheld, not sent; got {:?}",
4505 reports.stops()
4506 );
4507 }
4508
4509 #[tokio::test]
4518 async fn test_a_stop_reports_the_position_actually_reached() {
4519 let webview = PlayerController::default();
4521 let webview_reports = Arc::new(CapturingReports::new());
4522 webview.set_report_sink(webview_reports.clone());
4523 webview
4524 .play_queue(vec![audio_only_episode(1500.0)], 0)
4525 .unwrap();
4526 webview.report_html5_position(253.0, 1500.0);
4527
4528 webview.stop().unwrap();
4529
4530 assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4531
4532 let native = PlayerController::default();
4534 let native_reports = Arc::new(CapturingReports::new());
4535 native.set_report_sink(native_reports.clone());
4536 native
4537 .play_queue(vec![audio_only_episode(1500.0)], 0)
4538 .unwrap();
4539 native.seek(253.0).unwrap();
4540
4541 native.stop().unwrap();
4542
4543 assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4544 }
4545
4546 #[tokio::test]
4556 async fn test_a_finished_audio_only_episode_is_reported_complete() {
4557 let controller = PlayerController::default();
4558 let reports = Arc::new(CapturingReports::new());
4559 controller.set_report_sink(reports.clone());
4560 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4561 controller
4562 .play_queue(vec![audio_only_episode(1500.0)], 0)
4563 .unwrap();
4564 controller.seek(1499.0).unwrap();
4566 controller.take_end_reason();
4567
4568 controller.on_playback_ended().await.unwrap();
4569
4570 assert_eq!(
4571 reports.stops(),
4572 vec![("ep2".to_string(), 1500.0)],
4573 "the finished episode must be reported stopped at its runtime, or \
4574 Jellyfin's ≥90% rule never marks it played"
4575 );
4576 }
4577
4578 #[tokio::test]
4584 async fn test_a_truncated_stream_reports_no_completion() {
4585 let controller = PlayerController::default();
4586 let reports = Arc::new(CapturingReports::new());
4587 controller.set_report_sink(reports.clone());
4588 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4589 controller
4590 .play_queue(vec![audio_only_episode(1500.0)], 0)
4591 .unwrap();
4592 controller.seek(600.0).unwrap();
4593 controller.take_end_reason();
4594
4595 let decision = controller.on_playback_ended().await.unwrap();
4596
4597 assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
4598 assert!(
4599 reports.stops().is_empty(),
4600 "a dropped connection is not a finished episode; got {:?}",
4601 reports.stops()
4602 );
4603 }
4604
4605 #[tokio::test]
4613 async fn test_webview_position_ticks_report_progress_to_the_server() {
4614 let controller = PlayerController::default();
4615 let reports = Arc::new(CapturingReports::new());
4616 controller.set_report_sink(reports.clone());
4617 controller
4618 .play_queue(vec![audio_only_episode(1500.0)], 0)
4619 .unwrap();
4620
4621 controller.report_html5_position(253.4, 1500.0);
4622
4623 assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
4624 }
4625
4626 #[tokio::test]
4631 async fn test_progress_reports_are_throttled_not_sent_per_tick() {
4632 let controller = PlayerController::default();
4633 let reports = Arc::new(CapturingReports::new());
4634 controller.set_report_sink(reports.clone());
4635 controller
4636 .play_queue(vec![audio_only_episode(1500.0)], 0)
4637 .unwrap();
4638
4639 for tick in 0..12 {
4640 controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
4641 }
4642
4643 assert_eq!(
4644 reports.progress().len(),
4645 1,
4646 "twelve ticks inside one throttle window are one report"
4647 );
4648 }
4649
4650 #[tokio::test]
4660 async fn test_truncated_check_uses_the_absolute_position() {
4661 let controller = PlayerController::default();
4662 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4663
4664 controller
4665 .play_queue(vec![audio_only_episode(1500.0)], 0)
4666 .unwrap();
4667 controller.set_background_audio_base(1440.0);
4670 controller.seek(1496.0).unwrap();
4671 controller.take_end_reason();
4672
4673 let decision = controller.on_playback_ended().await.unwrap();
4674
4675 assert!(
4676 matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
4677 "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
4678 decision
4679 );
4680 }
4681
4682 #[tokio::test]
4686 async fn test_repeated_truncation_at_the_same_position_gives_up() {
4687 let controller = PlayerController::default();
4688 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4689
4690 controller
4691 .play_queue(vec![audio_only_episode(1500.0)], 0)
4692 .unwrap();
4693 controller.seek(600.0).unwrap();
4694
4695 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4696 controller.take_end_reason();
4697 let decision = controller.on_playback_ended().await.unwrap();
4698 assert!(
4699 matches!(decision, AutoplayDecision::ResumeStream { .. }),
4700 "attempt {} should still resume, got {:?}",
4701 attempt,
4702 decision
4703 );
4704 }
4705
4706 controller.take_end_reason();
4707 let decision = controller.on_playback_ended().await.unwrap();
4708 assert!(
4709 !matches!(decision, AutoplayDecision::ResumeStream { .. }),
4710 "a stream stuck at the same position must stop retrying, got {:?}",
4711 decision
4712 );
4713 }
4714
4715 #[tokio::test]
4719 async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
4720 let controller = PlayerController::default();
4721
4722 let mut items = create_test_items(2);
4723 items[0].duration = Some(1500.0);
4724 controller.play_queue(items, 0).unwrap();
4725 controller.seek(60.0).unwrap();
4726 controller.take_end_reason();
4727
4728 let decision = controller.on_playback_ended().await.unwrap();
4729 assert!(
4730 matches!(decision, AutoplayDecision::AdvanceToNext),
4731 "plain queue audio must keep advancing, got {:?}",
4732 decision
4733 );
4734 }
4735
4736 #[tokio::test]
4742 async fn test_recoverable_error_resumes_a_music_track() {
4743 let controller = PlayerController::default();
4744
4745 let mut items = create_test_items(3);
4746 for item in &mut items {
4747 item.source = MediaSource::Remote {
4748 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
4749 jellyfin_item_id: item.id.clone(),
4750 };
4751 }
4752 controller.play_queue(items, 1).unwrap();
4753 controller.seek(45.0).unwrap();
4754
4755 let (position, _) = controller
4756 .recoverable_error_resume()
4757 .expect("a streamed music track must be resumable after a network error");
4758 assert_eq!(position, 45.0);
4759 }
4760
4761 #[tokio::test]
4765 async fn test_resume_keeps_the_rest_of_the_queue() {
4766 let controller = PlayerController::default();
4767
4768 let mut items = create_test_items(3);
4769 for item in &mut items {
4770 item.source = MediaSource::Remote {
4771 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
4772 jellyfin_item_id: item.id.clone(),
4773 };
4774 }
4775 controller.play_queue(items, 1).unwrap();
4776
4777 controller
4778 .resume_stream_at(45.0)
4779 .await
4780 .expect("resume should succeed");
4781
4782 let queue = controller.queue.lock_safe();
4783 assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
4784 assert_eq!(queue.current_index(), Some(1), "still on the same track");
4785 assert_eq!(queue.current().unwrap().id, "item_1");
4786 }
4787
4788 #[tokio::test]
4792 async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
4793 let controller = PlayerController::default();
4794
4795 let mut items = create_test_items(1);
4796 items[0].source = MediaSource::Remote {
4797 stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
4798 jellyfin_item_id: "item_0".to_string(),
4799 };
4800 controller.play_queue(items, 0).unwrap();
4801
4802 controller.resume_stream_at(45.0).await.unwrap();
4803
4804 match &controller.queue.lock_safe().current().unwrap().source {
4805 MediaSource::Remote { stream_url, .. } => {
4806 assert_eq!(
4807 stream_url, "http://s/Audio/item_0/stream?Static=true",
4808 "a seekable stream's URL must be left alone"
4809 );
4810 }
4811 other => panic!("expected Remote source, got {:?}", other),
4812 }
4813 assert_eq!(
4814 controller.position(),
4815 45.0,
4816 "and it must land at the position"
4817 );
4818 }
4819
4820 #[tokio::test]
4823 async fn test_recoverable_error_ignores_local_media() {
4824 let controller = PlayerController::default();
4825
4826 let mut items = create_test_items(1);
4827 items[0].source = MediaSource::Local {
4828 file_path: "/music/track.flac".into(),
4829 jellyfin_item_id: Some("item_0".to_string()),
4830 };
4831 controller.play_queue(items, 0).unwrap();
4832 controller.seek(45.0).unwrap();
4833
4834 assert!(controller.recoverable_error_resume().is_none());
4835 }
4836
4837 #[tokio::test]
4842 async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
4843 let controller = PlayerController::default();
4844
4845 controller
4846 .play_queue(vec![audio_only_episode(1500.0)], 0)
4847 .unwrap();
4848 controller.seek(600.0).unwrap();
4849
4850 let mut waits = Vec::new();
4851 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4852 let (position, delay) = controller
4853 .recoverable_error_resume()
4854 .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
4855 assert_eq!(position, 600.0);
4856 waits.push(delay);
4857 }
4858 assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
4859 assert!(
4860 controller.recoverable_error_resume().is_none(),
4861 "a stream that keeps failing at the same spot must surface the error"
4862 );
4863 }
4864
4865 #[tokio::test]
4868 async fn test_recoverable_error_ignores_direct_url_playback() {
4869 let controller = PlayerController::default();
4870 controller.play_queue(create_test_items(2), 0).unwrap();
4871
4872 assert!(controller.recoverable_error_resume().is_none());
4873 }
4874
4875 #[tokio::test]
4879 async fn test_resume_truncated_stream_reloads_at_position() {
4880 let controller = PlayerController::default();
4881
4882 controller
4883 .play_queue(vec![audio_only_episode(1500.0)], 0)
4884 .unwrap();
4885 controller.set_background_audio_base(0.0);
4886
4887 controller
4888 .resume_stream_at(600.0)
4889 .await
4890 .expect("resume should succeed");
4891
4892 let current = controller
4893 .queue
4894 .lock_safe()
4895 .current()
4896 .cloned()
4897 .expect("the same item should still be loaded");
4898 assert_eq!(current.id, "ep2", "resume must not change the item");
4899 match ¤t.source {
4900 MediaSource::Remote { stream_url, .. } => {
4901 assert!(
4902 stream_url.contains("StartTimeTicks=6000000000"),
4903 "stream must re-open at 600s, got {}",
4904 stream_url
4905 );
4906 assert!(
4907 stream_url.contains("AudioStreamIndex=2"),
4908 "the selected audio track must survive the resume, got {}",
4909 stream_url
4910 );
4911 }
4912 other => panic!("expected Remote source, got {:?}", other),
4913 }
4914 assert_eq!(
4915 controller.take_background_audio_base(),
4916 600.0,
4917 "the re-opened stream's zero is the resume position"
4918 );
4919 }
4920
4921 #[tokio::test]
4925 async fn test_auto_advance_foreground_video_episode_uses_countdown() {
4926 let controller = PlayerController::default();
4927 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4928
4929 let episode = MediaItem {
4930 transport: None,
4932 id: "ep2".to_string(),
4933 item_type: Some("Episode".to_string()),
4934 media_type: MediaType::Video,
4935 series_id: Some("series1".to_string()),
4936 source: MediaSource::Remote {
4937 stream_url: "http://example.com/ep2.m3u8".to_string(),
4938 jellyfin_item_id: "ep2".to_string(),
4939 },
4940 ..create_test_items(1).remove(0)
4941 };
4942 controller.play_queue(vec![episode], 0).unwrap();
4943
4944 let next = make_repo_episode("ep3", 3);
4945 controller.auto_advance_to_next_episode(next, 10).await;
4946
4947 let current = controller
4948 .queue
4949 .lock_safe()
4950 .current()
4951 .cloned()
4952 .expect("an item should still be loaded");
4953 assert_eq!(
4954 current.id, "ep2",
4955 "foreground video advance is frontend-driven; the backend must not \
4956 swap the queue item out from under it"
4957 );
4958 }
4959
4960 #[tokio::test]
4963 async fn test_playback_ended_without_repository_stops() {
4964 let controller = PlayerController::default();
4965
4966 let episode = MediaItem {
4967 transport: None,
4969 media_type: MediaType::Video,
4970 source: MediaSource::Remote {
4971 stream_url: "http://example.com/ep1.mkv".to_string(),
4972 jellyfin_item_id: "ep1".to_string(),
4973 },
4974 ..create_test_items(1).remove(0)
4975 };
4976 controller.play_queue(vec![episode], 0).unwrap();
4977 controller.take_end_reason();
4978
4979 let decision = controller.on_playback_ended().await.unwrap();
4980 assert!(matches!(decision, AutoplayDecision::Stop));
4981 }
4982}