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