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
31pub mod lock_order;
34
35pub mod jni_guard;
38
39#[cfg(target_os = "android")]
41pub mod android;
42
43#[cfg(target_os = "linux")]
44pub mod mpv_backend;
45
46pub mod native_video;
49
50#[cfg(target_os = "linux")]
56pub mod mpv_render;
57
58#[cfg(target_os = "linux")]
63pub mod video_surface;
64
65#[cfg(not(any(target_os = "linux", target_os = "android")))]
68pub mod webview_audio_backend;
69
70use crate::repository::stream_selection::StreamSelection;
72pub use autoplay::{AutoplayDecision, AutoplaySettings};
73pub use backend::{NullBackend, PlayerBackend, PlayerError};
74pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
75pub use legacy_player::LegacyPlayer;
76pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
77pub use media_player::{MediaPlayer, OpenRequest, Phase};
78pub use queue::{QueueManager, RepeatMode};
79pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
80pub use session::{MediaSessionManager, MediaSessionType};
81pub use sleep_timer::{SleepTimerMode, SleepTimerState};
82pub use state::{EndReason, PlayerState};
83pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchStrategy};
84
85#[cfg(target_os = "android")]
87pub use android::ExoPlayerBackend;
88
89#[cfg(target_os = "linux")]
90pub use mpv_backend::MpvBackend;
91
92#[cfg(not(any(target_os = "linux", target_os = "android")))]
93pub use webview_audio_backend::WebviewAudioBackend;
94
95#[cfg(target_os = "android")]
96pub use android::{
97 disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
98 set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
99};
100
101pub trait PlaybackReportSink: Send + Sync {
112 fn send(&self, operation: PlaybackOperation);
114}
115
116struct ReporterSink {
122 reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
123}
124
125impl PlaybackReportSink for ReporterSink {
126 fn send(&self, operation: PlaybackOperation) {
127 let reporter = self.reporter.clone();
128 let task = async move {
129 let guard = reporter.lock().await;
130 let Some(reporter) = guard.as_ref() else {
131 warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
132 return;
133 };
134 if let Err(e) = reporter.report(operation, true).await {
136 log::error!("[PlayerController] Failed to report playback: {}", e);
137 }
138 };
139
140 if let Ok(handle) = tokio::runtime::Handle::try_current() {
141 handle.spawn(task);
142 } else {
143 std::thread::spawn(move || match tokio::runtime::Runtime::new() {
144 Ok(rt) => rt.block_on(task),
145 Err(e) => log::error!(
146 "[PlayerController] No runtime available to report playback: {}",
147 e
148 ),
149 });
150 }
151 }
152}
153
154fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
164 match runtime {
165 Some(runtime) if runtime > 0.0 => runtime,
166 _ => last_position.max(0.0),
167 }
168}
169
170#[cfg_attr(not(target_os = "android"), allow(dead_code))]
178const RESUME_BACKOFF_STEP_SECS: u64 = 2;
179
180#[derive(Debug, Clone)]
189#[cfg_attr(not(target_os = "android"), allow(dead_code))]
192pub struct LockscreenMetadata {
193 pub title: String,
194 pub artist: String,
195 pub album: Option<String>,
196 pub duration_ms: i64,
198 pub position_ms: i64,
200 pub is_playing: bool,
201}
202
203pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
211 #[cfg(target_os = "android")]
212 {
213 return android::update_lockscreen_metadata(_meta);
214 }
215 #[cfg(not(target_os = "android"))]
216 {
217 Ok(())
218 }
219}
220
221pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
227 #[cfg(target_os = "android")]
228 {
229 return android::set_position_offset(_offset_seconds);
230 }
231 #[cfg(not(target_os = "android"))]
232 {
233 Ok(())
234 }
235}
236
237use crate::utils::lock::MutexSafe;
238use log::{debug, error, info, warn};
239use std::sync::{Arc, Mutex};
240use std::time::Duration;
241use tokio::sync::Mutex as TokioMutex;
242
243use crate::jellyfin::JellyfinClient;
244use crate::playback_reporting::{
245 EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
246};
247use crate::repository::MediaRepository;
248use crate::settings::AudioSettings;
249use crate::utils::conversions::seconds_to_ticks;
250
251pub struct PlayerController {
253 backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
256 queue: Arc<Mutex<QueueManager>>,
257 jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
258 muted: bool,
259
260 sleep_timer: Arc<Mutex<SleepTimerState>>,
262
263 autoplay_settings: Arc<Mutex<AutoplaySettings>>,
265
266 repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
268
269 event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
271
272 countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
274
275 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
277
278 reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
281
282 position_throttler: Arc<EventThrottler>,
285
286 end_reason: Arc<Mutex<Option<EndReason>>>,
288
289 autoplay_episode_count: Arc<Mutex<u32>>,
291
292 background_audio_base: Arc<Mutex<f64>>,
306
307 background_audio_active: Arc<Mutex<bool>>,
317
318 stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
326
327 html5_playing: Arc<Mutex<Option<bool>>>,
337
338 reported_time: Arc<Mutex<stream_end::ObservedTime>>,
350}
351
352impl PlayerController {
353 pub fn new(
354 backend: Box<dyn MediaPlayer>,
355 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
356 position_throttler: Arc<EventThrottler>,
357 ) -> Self {
358 let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
359 reporter: playback_reporter.clone(),
360 });
361 let controller = Self {
362 backend: Arc::new(Mutex::new(backend)),
363 queue: Arc::new(Mutex::new(QueueManager::new())),
364 jellyfin_client: Arc::new(Mutex::new(None)),
365 muted: false,
366 sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
367 autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
368 repository: Arc::new(Mutex::new(None)),
369 event_emitter: Arc::new(Mutex::new(None)),
370 countdown_cancel: Arc::new(Mutex::new(None)),
371 playback_reporter,
372 reports: Arc::new(Mutex::new(reports)),
373 position_throttler,
374 end_reason: Arc::new(Mutex::new(None)),
375 autoplay_episode_count: Arc::new(Mutex::new(0)),
376 background_audio_base: Arc::new(Mutex::new(0.0)),
377 background_audio_active: Arc::new(Mutex::new(false)),
378 stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
379 html5_playing: Arc::new(Mutex::new(None)),
380 reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
381 };
382
383 controller.start_timer_thread();
385
386 controller
387 }
388
389 pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
391 let mut jellyfin = self.jellyfin_client.lock_safe();
392 *jellyfin = client;
393 log::info!(
394 "[PlayerController] Jellyfin client configured: {}",
395 jellyfin.is_some()
396 );
397 }
398
399 pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
401 self.jellyfin_client.clone()
402 }
403
404 pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
411 *self.repository.lock_safe() = Some(repo);
412 }
413
414 pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
417 let mut reporter_guard = self.playback_reporter.lock().await;
418 *reporter_guard = reporter;
419 log::info!(
420 "[PlayerController] Playback reporter configured: {}",
421 reporter_guard.is_some()
422 );
423 }
424
425 #[allow(dead_code)]
428 pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
429 self.playback_reporter.clone()
430 }
431
432 #[allow(dead_code)]
435 pub fn position_throttler(&self) -> Arc<EventThrottler> {
436 self.position_throttler.clone()
437 }
438
439 fn set_end_reason(&self, reason: EndReason) {
441 log::debug!("[PlayerController] Setting end reason: {:?}", reason);
442 *self.end_reason.lock_safe() = Some(reason);
443 }
444
445 fn take_end_reason(&self) -> Option<EndReason> {
447 self.end_reason.lock_safe().take()
448 }
449
450 fn peek_end_reason(&self) -> Option<EndReason> {
457 *self.end_reason.lock_safe()
458 }
459
460 fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
473 log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
474 *end_reason.lock_safe() = Some(EndReason::UserStop);
475 }
476
477 fn increment_autoplay_count(&self) -> bool {
479 let max = self.autoplay_settings.lock_safe().max_episodes;
480
481 if max == 0 {
482 return false;
484 }
485
486 let mut count = self.autoplay_episode_count.lock_safe();
487 *count += 1;
488 debug!(
489 "[PlayerController] Autoplay episode count: {}/{}",
490 *count, max
491 );
492
493 *count >= max
494 }
495
496 fn reset_autoplay_count(&self) {
498 let mut count = self.autoplay_episode_count.lock_safe();
499 if *count > 0 {
500 debug!(
501 "[PlayerController] Resetting autoplay episode counter (was {})",
502 *count
503 );
504 }
505 *count = 0;
506 }
507
508 pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
510 debug!("[PlayerController] play_item: {}", item.title);
511
512 self.reset_autoplay_count();
514
515 {
517 let mut queue = self.queue.lock_safe();
518 queue.set_queue(vec![item.clone()], 0);
519 }
520
521 self.load_and_play(&item)?;
523
524 Ok(())
525 }
526
527 pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
539 debug!(
540 "[PlayerController] set_current_item (no backend load): {}",
541 item.title
542 );
543
544 self.reset_autoplay_count();
545 self.clear_reported_time();
551
552 let mut queue = self.queue.lock_safe();
553 queue.set_queue(vec![item], 0);
554
555 Ok(())
556 }
557
558 pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
561 debug!("[PlayerController] load_and_play: {}", item.title);
562
563 self.set_end_reason(EndReason::NewTrackLoaded);
565
566 *self.html5_playing.lock_safe() = None;
588
589 let mut backend = self.backend.lock_safe();
590 backend.open(OpenRequest::new(
593 item.clone(),
594 StreamSelection::for_queued_item(
595 item.playback_url(),
596 item.transport,
597 item.needs_transcoding,
598 ),
599 ))?;
600 drop(backend);
601
602 self.clear_reported_time();
605
606 if let Some(jellyfin_id) = item.jellyfin_id() {
608 let context = if item.album_id.is_some() {
610 Some(PlaybackContext {
611 context_type: "container".to_string(),
612 context_id: item.album_id.clone(),
613 })
614 } else {
615 None
616 };
617
618 let position = self.absolute_position();
624
625 log::info!(
626 "[PlayerController] Reporting playback start: {} @ {:.1}s",
627 jellyfin_id,
628 position
629 );
630 self.report(PlaybackOperation::Start {
631 item_id: jellyfin_id.to_string(),
632 position_ticks: seconds_to_ticks(position),
633 context,
634 });
635 }
636
637 Ok(())
638 }
639
640 pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
642 self.play_queue_from(items, start_index, None)
643 }
644
645 pub fn play_queue_from(
652 &self,
653 items: Vec<MediaItem>,
654 start_index: usize,
655 start_position: Option<f64>,
656 ) -> Result<(), PlayerError> {
657 debug!(
658 "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
659 items.len(),
660 start_index,
661 start_position
662 );
663
664 self.reset_autoplay_count();
666
667 {
668 let mut queue = self.queue.lock_safe();
669 queue.set_queue(items, start_index);
670 }
671
672 if let Some(item) = self.queue.lock_safe().current().cloned() {
674 self.load_and_play(&item)?;
675
676 if let Some(position) = start_position {
680 if position > 0.5 {
681 self.seek(position)?;
682 }
683 }
684 }
685
686 Ok(())
687 }
688
689 pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
695 debug!(
696 "[PlayerController] set_queue (no local playback): {} items, index {}",
697 items.len(),
698 start_index
699 );
700 self.reset_autoplay_count();
701 let mut queue = self.queue.lock_safe();
702 queue.set_queue(items, start_index);
703 Ok(())
704 }
705
706 pub fn is_html5_active(&self) -> bool {
711 self.html5_playing.lock_safe().is_some()
712 }
713
714 pub fn html5_is_playing(&self) -> bool {
719 self.html5_playing.lock_safe().unwrap_or(false)
720 }
721
722 fn emit_html5_control(&self, action: &str) {
724 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
725 emitter.emit(PlayerStatusEvent::ControlCommand {
726 action: action.to_string(),
727 position: None,
728 });
729 }
730 }
731
732 pub fn play(&self) -> Result<(), PlayerError> {
734 debug!("[PlayerController] play");
735 if self.is_html5_active() {
738 self.emit_html5_control("play");
739 return Ok(());
740 }
741 let mut backend = self.backend.lock_safe();
742 backend.play()
743 }
744
745 pub fn pause(&self) -> Result<(), PlayerError> {
747 if self.is_html5_active() {
748 self.emit_html5_control("pause");
749 return Ok(());
750 }
751 let mut backend = self.backend.lock_safe();
752 backend.pause()
753 }
754
755 pub fn toggle_playback(&self) -> Result<(), PlayerError> {
763 if self.is_html5_active() {
764 let action = if self.html5_is_playing() {
765 "pause"
766 } else {
767 "play"
768 };
769 self.emit_html5_control(action);
770 return Ok(());
771 }
772 let mut backend = self.backend.lock_safe();
773 if backend.snapshot().phase.is_active() {
774 backend.pause()
775 } else {
776 backend.play()
777 }
778 }
779
780 pub fn stop(&self) -> Result<(), PlayerError> {
782 self.set_end_reason(EndReason::UserStop);
784
785 let jellyfin_id = {
787 let queue = self.queue.lock_safe();
788 queue
789 .current()
790 .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
791 };
792
793 let position = self.absolute_position();
796
797 let mut backend = self.backend.lock_safe();
798 backend.close()?;
799 drop(backend);
800 self.clear_reported_time();
801
802 if self.is_background_audio_active() {
818 debug!("[PlayerController] stop: clearing an active background-audio handoff");
819 }
820 *self.background_audio_active.lock_safe() = false;
821 self.set_background_audio_base(0.0);
822 *self.html5_playing.lock_safe() = None;
823
824 if let Some(jellyfin_id) = jellyfin_id {
825 self.report_stopped_at(jellyfin_id, position);
826 }
827
828 Ok(())
829 }
830
831 fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
845 if position <= 0.0 {
846 debug!(
847 "[PlayerController] Withholding zero-position stop report for {} \
848 (nothing played; reporting it would clear the resume point)",
849 jellyfin_id
850 );
851 return;
852 }
853
854 log::info!(
855 "[PlayerController] Reporting playback stopped: {} @ {:.1}s",
856 jellyfin_id,
857 position
858 );
859 self.report(PlaybackOperation::Stopped {
860 item_id: jellyfin_id,
861 position_ticks: seconds_to_ticks(position),
862 });
863 }
864
865 pub fn next(&self) -> Result<(), PlayerError> {
870 self.reset_autoplay_count();
872
873 let next_item = {
874 let mut queue = self.queue.lock_safe();
875 queue.next().cloned()
876 };
877
878 debug!(
879 "[PlayerController] next: {:?}",
880 next_item.as_ref().map(|i| &i.title)
881 );
882
883 if let Some(item) = next_item {
884 self.load_and_play(&item)
885 } else {
886 debug!("[PlayerController] No next item, stopping");
887 self.stop()
888 }
889 }
890
891 pub fn previous(&self) -> Result<(), PlayerError> {
896 self.reset_autoplay_count();
898 {
900 let backend = self.backend.lock_safe();
901 if backend.snapshot().position.as_secs_f64() > 3.0 {
902 debug!("[PlayerController] previous: restarting current track (position > 3s)");
903 drop(backend);
904 return self.seek(0.0);
905 }
906 }
907
908 let prev_item = {
909 let mut queue = self.queue.lock_safe();
910 queue.previous().cloned()
911 };
912
913 debug!(
914 "[PlayerController] previous: {:?}",
915 prev_item.as_ref().map(|i| &i.title)
916 );
917
918 if let Some(item) = prev_item {
919 self.load_and_play(&item)
920 } else {
921 self.seek(0.0)
922 }
923 }
924
925 pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
932 let mut backend = self.backend.lock_safe();
933 backend.seek(Duration::from_secs_f64(position.max(0.0)))
934 }
935
936 pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
954 let rebuild = self.is_background_audio_active() && {
959 let queue = self.queue.lock_safe();
960 queue
961 .current()
962 .map(|item| {
963 Self::is_audio_only_video(item)
964 && matches!(item.source, MediaSource::Remote { .. })
965 })
966 .unwrap_or(false)
967 };
968
969 if rebuild {
970 return self.resume_stream_at(position.max(0.0)).await;
971 }
972
973 self.seek(position).map_err(|e| e.to_string())
974 }
975
976 pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
978 self.backend.lock_safe().set_volume(volume)
979 }
980
981 pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
983 let mut backend = self.backend.lock_safe();
984 backend.select_audio_track(Some(stream_index))
985 }
986
987 pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
989 let mut backend = self.backend.lock_safe();
990 backend.select_subtitle_track(stream_index)
991 }
992
993 pub fn state(&self) -> PlayerState {
995 let phase = self.backend.lock_safe().snapshot().phase;
996 let media = self.queue.lock_safe().current().cloned();
997 match (phase, media) {
998 (Phase::Playing, Some(media)) => PlayerState::Playing {
999 media,
1000 position: self.position(),
1001 duration: self.duration().unwrap_or(0.0),
1002 },
1003 (Phase::Paused, Some(media)) => PlayerState::Paused {
1004 media,
1005 position: self.position(),
1006 duration: self.duration().unwrap_or(0.0),
1007 },
1008 (Phase::Opening, Some(media)) => PlayerState::Loading { media },
1009 (Phase::Failed(error), media) => PlayerState::Error { media, error },
1010 _ => PlayerState::Idle,
1013 }
1014 }
1015
1016 pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
1020 self.backend.lock_safe().capabilities()
1021 }
1022
1023 pub fn position(&self) -> f64 {
1025 self.backend.lock_safe().snapshot().position.as_secs_f64()
1026 }
1027
1028 pub fn absolute_position(&self) -> f64 {
1051 let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
1052 let reported = self.reported_time.lock_safe().last_position();
1053 let base = if self.is_background_audio_active() {
1054 *self.background_audio_base.lock_safe()
1055 } else {
1056 0.0
1057 };
1058
1059 native.max(reported).max(base)
1060 }
1061
1062 pub fn observed_duration(&self) -> Option<f64> {
1066 self.reported_time.lock_safe().last_duration()
1067 }
1068
1069 fn clear_reported_time(&self) {
1077 self.reported_time.lock_safe().reset();
1078 }
1079
1080 #[cfg_attr(not(test), allow(dead_code))]
1085 pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
1086 *self.reports.lock_safe() = sink;
1087 }
1088
1089 fn report(&self, operation: PlaybackOperation) {
1094 let sink = self.reports.lock_safe().clone();
1095 sink.send(operation);
1096 }
1097
1098 fn current_jellyfin_id(&self) -> Option<String> {
1100 let queue = self.queue.lock_safe();
1101 queue
1102 .current()
1103 .and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
1104 }
1105
1106 pub fn duration(&self) -> Option<f64> {
1115 let usable = |d: f64| (d > 0.0).then_some(d);
1128
1129 self.backend
1130 .lock_safe()
1131 .snapshot()
1132 .duration
1133 .map(|d| d.as_secs_f64())
1134 .and_then(usable)
1135 .or_else(|| self.observed_duration().and_then(usable))
1136 .or_else(|| {
1137 self.queue
1138 .lock_safe()
1139 .current()
1140 .and_then(|item| item.duration)
1141 .and_then(usable)
1142 })
1143 }
1144
1145 pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
1147 self.queue.clone()
1148 }
1149
1150 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
1158 pub fn current_is_audio_episode(&self) -> bool {
1159 self.queue
1160 .lock_safe()
1161 .current()
1162 .map(|item| {
1163 item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
1164 })
1165 .unwrap_or(false)
1166 }
1167
1168 pub fn clear_queue(&self) {
1172 self.queue.lock_safe().clear();
1173 }
1174
1175 pub fn toggle_shuffle(&self) {
1177 self.queue.lock_safe().toggle_shuffle();
1178 }
1179
1180 pub fn cycle_repeat(&self) {
1182 self.queue.lock_safe().cycle_repeat();
1183 }
1184
1185 pub fn is_shuffle(&self) -> bool {
1187 self.queue.lock_safe().is_shuffle()
1188 }
1189
1190 pub fn repeat_mode(&self) -> RepeatMode {
1192 self.queue.lock_safe().repeat_mode()
1193 }
1194
1195 pub fn volume(&self) -> f32 {
1197 self.backend.lock_safe().snapshot().volume
1198 }
1199
1200 pub fn muted(&self) -> bool {
1202 self.muted
1203 }
1204
1205 pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
1207 self.backend.lock_safe().set_audio_settings(settings)
1208 }
1209
1210 pub fn audio_settings(&self) -> AudioSettings {
1212 self.backend.lock_safe().audio_settings()
1213 }
1214
1215 pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
1219 let mut event_emitter = self.event_emitter.lock_safe();
1220 *event_emitter = Some(emitter);
1221 }
1222
1223 pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
1225 self.event_emitter.lock_safe().clone()
1226 }
1227
1228 pub fn sleep_timer_state(&self) -> SleepTimerState {
1230 self.sleep_timer.lock_safe().clone()
1231 }
1232
1233 pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
1235 let mut timer = self.sleep_timer.lock_safe();
1236 timer.mode = mode.clone();
1237 if let SleepTimerMode::Time { end_time } = mode {
1238 let now = chrono::Utc::now().timestamp_millis();
1239 timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
1240 } else {
1241 timer.remaining_seconds = 0;
1242 }
1243 drop(timer);
1244
1245 self.emit_sleep_timer_changed();
1247 }
1248
1249 pub fn cancel_sleep_timer(&self) {
1251 self.set_sleep_timer(SleepTimerMode::Off);
1252 }
1253
1254 fn start_timer_thread(&self) {
1256 let sleep_timer = self.sleep_timer.clone();
1257 let event_emitter = self.event_emitter.clone();
1258 let backend = self.backend.clone();
1259 let end_reason = self.end_reason.clone();
1260
1261 std::thread::spawn(move || {
1262 loop {
1263 std::thread::sleep(Duration::from_secs(1));
1264
1265 let mut timer = sleep_timer.lock_safe();
1266 if timer.is_active() {
1267 timer.update_remaining_seconds();
1268
1269 if matches!(timer.mode, SleepTimerMode::Time { .. })
1271 && timer.remaining_seconds == 0
1272 {
1273 debug!("[SleepTimer] Time-based timer expired, stopping playback");
1274 timer.cancel();
1275
1276 Self::note_sleep_timer_stop(&end_reason);
1283
1284 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1286 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1287 mode: SleepTimerMode::Off,
1288 remaining_seconds: 0,
1289 });
1290 emitter.emit(PlayerStatusEvent::SleepTimerExpired);
1294 }
1295 drop(timer);
1296
1297 if let Err(e) = backend.lock_safe().close() {
1299 error!("[SleepTimer] Failed to stop playback: {}", e);
1300 }
1301 continue;
1302 }
1303
1304 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1306 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1307 mode: timer.mode.clone(),
1308 remaining_seconds: timer.remaining_seconds,
1309 });
1310 }
1311 }
1312 drop(timer);
1313 }
1314 });
1315 }
1316
1317 fn emit_sleep_timer_changed(&self) {
1319 let timer = self.sleep_timer.lock_safe().clone();
1320
1321 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1322 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1323 mode: timer.mode,
1324 remaining_seconds: timer.remaining_seconds,
1325 });
1326 }
1327 }
1328
1329 pub fn emit_queue_changed(&self) {
1331 let queue = self.queue.lock_safe();
1332
1333 debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
1334 queue.items().len(), queue.current_index());
1335
1336 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1337 emitter.emit(PlayerStatusEvent::QueueChanged {
1338 items: queue.items().to_vec(),
1339 current_index: queue.current_index(),
1340 shuffle: queue.is_shuffle(),
1341 repeat: queue.repeat_mode(),
1342 has_next: queue.has_next(),
1343 has_previous: queue.has_previous(),
1344 });
1345 } else {
1346 warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
1347 }
1348 }
1349
1350 pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
1365 if self.is_background_audio_active() {
1370 debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
1371 return;
1372 }
1373 let element_gone = {
1379 let mut tracked = self.html5_playing.lock_safe();
1380 *tracked = match state.as_str() {
1381 "playing" => Some(true),
1382 "paused" | "loading" => Some(false),
1386 _ => None,
1388 };
1389 tracked.is_none()
1390 };
1391 if element_gone {
1394 self.clear_reported_time();
1395 }
1396 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1397 emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
1398 }
1399 }
1400
1401 pub fn report_html5_position(&self, position: f64, duration: f64) {
1406 if self.is_background_audio_active() {
1409 return;
1410 }
1411 self.reported_time.lock_safe().record(position, duration);
1414
1415 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1416 emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
1417 }
1418
1419 self.report_progress_throttled(position);
1420 }
1421
1422 fn report_progress_throttled(&self, position: f64) {
1437 if position <= 0.0 {
1438 return;
1439 }
1440 let Some(item_id) = self.current_jellyfin_id() else {
1441 return;
1442 };
1443 if !self.position_throttler.should_report(&item_id) {
1444 return;
1445 }
1446
1447 self.report(PlaybackOperation::Progress {
1448 item_id: item_id.clone(),
1449 position_ticks: seconds_to_ticks(position),
1450 is_paused: false,
1453 });
1454 self.position_throttler.mark_reported(&item_id);
1455 }
1456
1457 pub fn report_html5_media_loaded(&self, duration: f64) {
1460 if self.is_background_audio_active() {
1462 return;
1463 }
1464 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1465 emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
1466 }
1467 }
1468
1469 pub fn autoplay_settings(&self) -> AutoplaySettings {
1473 self.autoplay_settings.lock_safe().clone()
1474 }
1475
1476 pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
1478 let validated = settings.with_validated_countdown();
1479 *self.autoplay_settings.lock_safe() = validated;
1480 }
1481
1482 pub fn cancel_autoplay_countdown(&self) {
1484 if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
1485 *cancel_flag.lock_safe() = true;
1486 }
1487 }
1488
1489 pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
1494 if let Some(position) = self.truncated_stream_resume_position() {
1500 return Ok(AutoplayDecision::ResumeStream { position });
1501 }
1502
1503 let end_reason = self.take_end_reason();
1505
1506 debug!(
1507 "[PlayerController] on_playback_ended: end_reason={:?}",
1508 end_reason
1509 );
1510
1511 match end_reason {
1513 None | Some(EndReason::Finished) => {
1514 debug!("[PlayerController] Track finished naturally, checking autoplay");
1516 }
1517 Some(EndReason::NewTrackLoaded) => {
1518 debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
1520 return Ok(AutoplayDecision::Stop);
1521 }
1522 Some(EndReason::UserStop) => {
1523 debug!("[PlayerController] UserStop - stopping without autoplay");
1525 return Ok(AutoplayDecision::Stop);
1526 }
1527 Some(EndReason::UserSkip) => {
1528 debug!("[PlayerController] UserSkip - stopping without autoplay");
1530 return Ok(AutoplayDecision::Stop);
1531 }
1532 Some(EndReason::Error) => {
1533 debug!("[PlayerController] Error - stopping without autoplay");
1535 return Ok(AutoplayDecision::Stop);
1536 }
1537 }
1538
1539 let current_item = {
1540 let queue = self.queue.lock_safe();
1541 queue.current().cloned()
1542 };
1543
1544 let Some(current) = current_item else {
1545 return Ok(AutoplayDecision::Stop);
1546 };
1547
1548 self.report_completion(¤t);
1552
1553 let timer_mode = {
1555 let timer = self.sleep_timer.lock_safe();
1556 timer.mode.clone()
1557 };
1558
1559 match &timer_mode {
1560 SleepTimerMode::Time { end_time } => {
1561 let now = chrono::Utc::now().timestamp_millis();
1563 if now >= *end_time {
1564 debug!("[PlayerController] Time-based sleep timer expired at track boundary");
1565 self.sleep_timer.lock_safe().cancel();
1566 self.emit_sleep_timer_changed();
1567 return Ok(AutoplayDecision::Stop);
1568 }
1569 }
1570 SleepTimerMode::EndOfTrack => {
1571 self.sleep_timer.lock_safe().cancel();
1573 self.emit_sleep_timer_changed();
1574 return Ok(AutoplayDecision::Stop);
1575 }
1576 SleepTimerMode::Episodes { .. } => {
1577 let is_episode = self.is_episode_item(¤t).await;
1582
1583 if is_episode {
1584 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
1585 self.emit_sleep_timer_changed();
1586
1587 if should_stop {
1588 return Ok(AutoplayDecision::Stop);
1589 }
1590 }
1591 }
1592 _ => {
1593 }
1595 }
1596
1597 if self.is_episode_item(¤t).await {
1603 let repo = self.repository.lock_safe().clone();
1604 let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
1605 let next_ep_result = if let Some(repo) = &repo {
1606 match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
1609 Ok(next) => next,
1610 Err(e) => {
1611 warn!(
1612 "[PlayerController] Next-episode lookup failed for {}: {}",
1613 jellyfin_id, e
1614 );
1615 None
1616 }
1617 }
1618 } else {
1619 warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
1620 None
1621 };
1622 if let Some(next_ep) = next_ep_result {
1623 let settings = self.autoplay_settings.lock_safe().clone();
1624
1625 let limit_reached = self.increment_autoplay_count();
1627 if limit_reached {
1628 debug!(
1629 "[PlayerController] Auto-play episode limit reached ({} episodes)",
1630 settings.max_episodes
1631 );
1632 }
1633
1634 return Ok(AutoplayDecision::ShowNextEpisodePopup {
1635 current_episode: next_ep.0, next_episode: next_ep.1,
1637 countdown_seconds: settings.countdown_seconds,
1638 auto_advance: settings.enabled && !limit_reached,
1639 });
1640 }
1641 return Ok(AutoplayDecision::Stop);
1643 }
1644
1645 let has_next = {
1647 let queue = self.queue.lock_safe();
1648 queue.has_next()
1649 };
1650
1651 if has_next {
1652 Ok(AutoplayDecision::AdvanceToNext)
1654 } else {
1655 Ok(AutoplayDecision::Stop)
1657 }
1658 }
1659
1660 fn report_completion(&self, item: &MediaItem) {
1681 if !Self::is_audio_only_video(item) {
1682 return;
1683 }
1684 let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
1685 return;
1686 };
1687
1688 let position = completion_report_position(item.duration, self.absolute_position());
1689 log::info!(
1690 "[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
1691 jellyfin_id,
1692 position
1693 );
1694 self.report_stopped_at(jellyfin_id, position);
1695 }
1696
1697 pub fn set_background_audio_base(&self, seconds: f64) {
1702 *self.background_audio_base.lock_safe() = seconds.max(0.0);
1703 }
1704
1705 pub fn enter_background_audio(&self, position: f64) {
1716 self.set_background_audio_base(position);
1717 *self.background_audio_active.lock_safe() = true;
1718 *self.html5_playing.lock_safe() = None;
1719 self.clear_reported_time();
1722 }
1723
1724 pub fn exit_background_audio(&self) -> f64 {
1732 *self.background_audio_active.lock_safe() = false;
1733 self.take_background_audio_base()
1734 }
1735
1736 pub fn is_background_audio_active(&self) -> bool {
1741 *self.background_audio_active.lock_safe()
1742 }
1743
1744 pub fn take_background_audio_base(&self) -> f64 {
1748 let mut base = self.background_audio_base.lock_safe();
1749 std::mem::replace(&mut *base, 0.0)
1750 }
1751
1752 pub async fn auto_advance_to_next_episode(
1763 &self,
1764 next_episode: crate::repository::types::MediaItem,
1765 countdown_seconds: u32,
1766 ) {
1767 if self.current_is_audio_episode() {
1772 info!(
1773 "[PlayerController] Background audio episode — advancing to {} in backend",
1774 next_episode.id
1775 );
1776 match self
1777 .advance_to_next_episode_audio_only(&next_episode.id)
1778 .await
1779 {
1780 Ok(()) => self.emit_queue_changed(),
1781 Err(e) => {
1782 error!(
1783 "[PlayerController] Background audio advance failed: {} — stopping",
1784 e
1785 );
1786 if let Some(emitter) = self.event_emitter() {
1787 emitter.emit(PlayerStatusEvent::PlaybackEnded);
1788 }
1789 }
1790 }
1791 return;
1792 }
1793
1794 self.start_autoplay_countdown(next_episode, countdown_seconds);
1796 }
1797
1798 fn is_audio_only_video(item: &MediaItem) -> bool {
1802 stream_end::is_audio_only_video(item)
1803 }
1804
1805 fn claim_stream_resume(&self) -> Option<(f64, u32)> {
1820 let current = {
1821 let queue = self.queue.lock_safe();
1822 queue.current().cloned()
1823 }?;
1824 if !matches!(current.source, MediaSource::Remote { .. }) {
1825 return None;
1826 }
1827
1828 let absolute = self.absolute_position();
1833
1834 match self.stream_resume.lock_safe().allow_attempt(absolute) {
1835 Some(attempt) => Some((absolute, attempt)),
1836 None => {
1837 warn!(
1838 "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
1839 current.id, absolute
1840 );
1841 None
1842 }
1843 }
1844 }
1845
1846 fn truncated_stream_resume_position(&self) -> Option<f64> {
1853 if matches!(
1855 self.peek_end_reason(),
1856 Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
1857 ) {
1858 return None;
1859 }
1860
1861 let item_duration = {
1865 let queue = self.queue.lock_safe();
1866 let current = queue.current()?;
1867 if !Self::is_audio_only_video(current) {
1868 return None;
1869 }
1870 current.duration
1871 };
1872 let absolute = self.position().max(0.0);
1874
1875 if !stream_end::is_truncated_end(
1878 absolute,
1879 item_duration,
1880 stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
1881 ) {
1882 return None;
1883 }
1884
1885 self.claim_stream_resume().map(|(position, _)| position)
1886 }
1887
1888 pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
1910 self.claim_stream_resume()
1911 .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
1912 }
1913
1914 pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
1941 let current = {
1942 let queue = self.queue.lock_safe();
1943 queue.current().cloned()
1944 }
1945 .ok_or_else(|| "No current item to resume".to_string())?;
1946
1947 let MediaSource::Remote { stream_url, .. } = ¤t.source else {
1948 return Err(format!(
1949 "Cannot resume a non-remote source for {}",
1950 current.id
1951 ));
1952 };
1953
1954 info!(
1955 "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
1956 current.id, position
1957 );
1958
1959 if !Self::is_audio_only_video(¤t) {
1960 self.load_and_play(¤t).map_err(|e| e.to_string())?;
1961 if position > 0.5 {
1962 self.seek(position).map_err(|e| e.to_string())?;
1963 }
1964 return Ok(());
1965 }
1966
1967 let restarted_url = stream_end::with_start_time(stream_url, position);
1968 {
1969 let queue_arc = self.queue.clone();
1970 let mut queue = queue_arc.lock_safe();
1971 if !queue.update_current_stream_url(restarted_url) {
1972 return Err(format!("Failed to update stream URL for {}", current.id));
1973 }
1974 }
1975 let resumed = {
1976 let queue = self.queue.lock_safe();
1977 queue.current().cloned()
1978 }
1979 .ok_or_else(|| "Current item vanished mid-resume".to_string())?;
1980
1981 self.set_background_audio_base(position);
1985 let _ = set_lockscreen_position_offset(position.max(0.0));
1986
1987 self.load_and_play(&resumed).map_err(|e| e.to_string())
1988 }
1989
1990 pub async fn advance_to_next_episode_audio_only(
2005 &self,
2006 next_episode_id: &str,
2007 ) -> Result<(), String> {
2008 crate::repository::online::clear_playback_quality_override();
2016
2017 let repo = self
2018 .repository
2019 .lock_safe()
2020 .clone()
2021 .ok_or_else(|| "No repository for background episode advance".to_string())?;
2022
2023 let next = repo
2025 .get_item(next_episode_id)
2026 .await
2027 .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
2028
2029 let stream_url = repo
2032 .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
2033 .await
2034 .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
2035
2036 let media_item = MediaItem {
2037 transport: None,
2039 id: next.id.clone(),
2040 title: next.name.clone(),
2041 name: Some(next.name.clone()),
2042 artist: next.series_name.clone(),
2043 album: None,
2044 album_name: None,
2045 album_id: None,
2046 artist_items: None,
2047 artists: None,
2048 primary_image_tag: next.primary_image_tag.clone(),
2049 image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
2050 item_type: Some("Episode".to_string()),
2052 playlist_id: None,
2053 duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
2054 artwork_url: None,
2055 media_type: MediaType::Audio,
2056 source: MediaSource::Remote {
2057 stream_url,
2058 jellyfin_item_id: next.id.clone(),
2059 },
2060 video_codec: None,
2061 needs_transcoding: false,
2062 video_width: None,
2063 video_height: None,
2064 subtitles: vec![],
2065 series_id: next.series_id.clone(),
2066 server_id: Some(next.server_id.clone()),
2067 };
2068
2069 self.set_background_audio_base(0.0);
2074 let _ = set_lockscreen_position_offset(0.0);
2075 self.stream_resume.lock_safe().reset();
2078
2079 self.play_item(media_item).map_err(|e| e.to_string())
2080 }
2081
2082 pub async fn on_video_playback_ended(
2089 &self,
2090 item_id: &str,
2091 repo: Arc<dyn crate::repository::MediaRepository>,
2092 ) -> Result<AutoplayDecision, String> {
2093 let stale_reason = self.take_end_reason();
2095 if stale_reason.is_some() {
2096 debug!(
2097 "[PlayerController] Cleared stale end_reason for video: {:?}",
2098 stale_reason
2099 );
2100 }
2101
2102 log::info!(
2103 "[PlayerController] on_video_playback_ended: item_id={}",
2104 item_id
2105 );
2106
2107 let timer_mode = {
2109 let timer = self.sleep_timer.lock_safe();
2110 timer.mode.clone()
2111 };
2112
2113 match &timer_mode {
2114 SleepTimerMode::Time { end_time } => {
2115 let now = chrono::Utc::now().timestamp_millis();
2116 if now >= *end_time {
2117 debug!("[PlayerController] Time-based sleep timer expired at video end");
2118 self.sleep_timer.lock_safe().cancel();
2119 self.emit_sleep_timer_changed();
2120 return Ok(AutoplayDecision::Stop);
2121 }
2122 }
2123 SleepTimerMode::EndOfTrack => {
2124 self.sleep_timer.lock_safe().cancel();
2125 self.emit_sleep_timer_changed();
2126 return Ok(AutoplayDecision::Stop);
2127 }
2128 SleepTimerMode::Episodes { .. } => {
2129 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
2130 self.emit_sleep_timer_changed();
2131 if should_stop {
2132 return Ok(AutoplayDecision::Stop);
2133 }
2134 }
2135 _ => {}
2136 }
2137
2138 let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
2142 Ok(next) => next,
2143 Err(e) => {
2144 warn!(
2145 "[PlayerController] Next-episode lookup failed for {}: {}",
2146 item_id, e
2147 );
2148 None
2149 }
2150 };
2151 if let Some(next_ep) = next_ep_result {
2152 let settings = self.autoplay_settings.lock_safe().clone();
2153
2154 let limit_reached = self.increment_autoplay_count();
2155 if limit_reached {
2156 debug!(
2157 "[PlayerController] Auto-play episode limit reached ({} episodes)",
2158 settings.max_episodes
2159 );
2160 }
2161
2162 return Ok(AutoplayDecision::ShowNextEpisodePopup {
2163 current_episode: next_ep.0,
2164 next_episode: next_ep.1,
2165 countdown_seconds: settings.countdown_seconds,
2166 auto_advance: settings.enabled && !limit_reached,
2167 });
2168 }
2169
2170 debug!("[PlayerController] No next episode found for {}", item_id);
2172 Ok(AutoplayDecision::Stop)
2173 }
2174
2175 async fn is_episode_item(&self, item: &MediaItem) -> bool {
2183 match item.item_type.as_deref() {
2184 Some("Episode") => true,
2185 Some(_) => item.media_type == MediaType::Video,
2186 None => item.media_type == MediaType::Video,
2187 }
2188 }
2189
2190 async fn fetch_next_episode_for_item(
2197 &self,
2198 item_id: &str,
2199 repo: &Arc<dyn crate::repository::MediaRepository>,
2200 ) -> Result<
2201 Option<(
2202 crate::repository::types::MediaItem,
2203 crate::repository::types::MediaItem,
2204 )>,
2205 String,
2206 > {
2207 use crate::repository::types::GetItemsOptions;
2208
2209 let current_repo_item = repo
2211 .get_item(item_id)
2212 .await
2213 .map_err(|e| format!("Failed to get current item: {}", e))?;
2214
2215 let season_id = match ¤t_repo_item.season_id {
2217 Some(sid) => sid.clone(),
2218 None => {
2219 log::info!(
2220 "[PlayerController] Current item has no season_id, cannot find next episode"
2221 );
2222 return Ok(None);
2223 }
2224 };
2225
2226 let options = GetItemsOptions {
2228 sort_by: Some("IndexNumber".to_string()),
2229 sort_order: Some("Ascending".to_string()),
2230 limit: Some(500),
2231 include_item_types: Some(vec!["Episode".to_string()]),
2232 ..Default::default()
2233 };
2234
2235 let result = repo
2236 .get_items(&season_id, Some(options))
2237 .await
2238 .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
2239
2240 let mut episodes = result.items;
2243 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2244 log::info!(
2245 "[PlayerController] Season has {} episodes, looking for next after {}",
2246 episodes.len(),
2247 current_repo_item.id
2248 );
2249
2250 if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
2252 if current_idx + 1 < episodes.len() {
2253 let next = &episodes[current_idx + 1];
2254 log::info!(
2255 "[PlayerController] Found next episode: {} (index {})",
2256 next.name,
2257 current_idx + 1
2258 );
2259 return Ok(Some((current_repo_item, next.clone())));
2260 } else {
2261 log::info!("[PlayerController] Current episode is the last in the season");
2262 if let Some(next) = self
2263 .first_episode_of_next_season(¤t_repo_item, repo)
2264 .await
2265 {
2266 return Ok(Some((current_repo_item, next)));
2267 }
2268 }
2269 } else {
2270 log::info!(
2271 "[PlayerController] Current episode not found in season episodes (ids: {:?})",
2272 episodes
2273 .iter()
2274 .map(|e| e.id.as_str())
2275 .take(20)
2276 .collect::<Vec<_>>()
2277 );
2278 }
2279
2280 Ok(None)
2281 }
2282
2283 async fn first_episode_of_next_season(
2296 &self,
2297 current: &crate::repository::types::MediaItem,
2298 repo: &Arc<dyn crate::repository::MediaRepository>,
2299 ) -> Option<crate::repository::types::MediaItem> {
2300 use crate::repository::types::GetItemsOptions;
2301
2302 let series_id = current.series_id.as_deref()?;
2303 let season_id = current.season_id.as_deref()?;
2304
2305 let season_options = GetItemsOptions {
2306 sort_by: Some("IndexNumber".to_string()),
2307 sort_order: Some("Ascending".to_string()),
2308 limit: Some(500),
2309 include_item_types: Some(vec!["Season".to_string()]),
2310 ..Default::default()
2311 };
2312 let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
2313 Ok(result) => result.items,
2314 Err(e) => {
2315 log::warn!(
2316 "[PlayerController] Season lookup failed for series {}: {}",
2317 series_id,
2318 e
2319 );
2320 return None;
2321 }
2322 };
2323 seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
2326
2327 let current_idx = seasons.iter().position(|s| s.id == season_id)?;
2328
2329 for season in &seasons[current_idx + 1..] {
2330 if season.index_number == Some(0) {
2335 continue;
2336 }
2337
2338 let episode_options = GetItemsOptions {
2339 sort_by: Some("IndexNumber".to_string()),
2340 sort_order: Some("Ascending".to_string()),
2341 limit: Some(500),
2342 include_item_types: Some(vec!["Episode".to_string()]),
2343 ..Default::default()
2344 };
2345 let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
2346 Ok(result) => result.items,
2347 Err(e) => {
2348 log::warn!(
2349 "[PlayerController] Episode lookup failed for season {}: {}",
2350 season.id,
2351 e
2352 );
2353 return None;
2354 }
2355 };
2356 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2357
2358 if let Some(first) = episodes.into_iter().next() {
2360 log::info!(
2361 "[PlayerController] Rolling over to {} of {}: {}",
2362 first.name,
2363 season.name,
2364 first.id
2365 );
2366 return Some(first);
2367 }
2368 }
2369
2370 log::info!("[PlayerController] No further season to roll over into");
2371 None
2372 }
2373
2374 pub fn start_autoplay_countdown(
2376 &self,
2377 _next_item: crate::repository::types::MediaItem,
2378 countdown_seconds: u32,
2379 ) {
2380 let cancel_flag = Arc::new(Mutex::new(false));
2382 *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
2383
2384 let event_emitter = self.event_emitter.clone();
2385
2386 std::thread::spawn(move || {
2387 let mut remaining = countdown_seconds;
2388
2389 while remaining > 0 {
2390 std::thread::sleep(Duration::from_secs(1));
2391
2392 if *cancel_flag.lock_safe() {
2394 log::info!("[PlayerController] Autoplay countdown cancelled");
2395 return;
2396 }
2397
2398 remaining -= 1;
2399
2400 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
2402 emitter.emit(PlayerStatusEvent::CountdownTick {
2403 remaining_seconds: remaining,
2404 });
2405 }
2406 }
2407
2408 log::info!("[PlayerController] Autoplay countdown finished");
2410 });
2411 }
2412}
2413
2414impl Default for PlayerController {
2415 fn default() -> Self {
2416 let playback_reporter = Arc::new(TokioMutex::new(None));
2417 let position_throttler = Arc::new(EventThrottler::new());
2418 Self::new(
2419 Box::new(LegacyPlayer::new(
2420 NullBackend::new(),
2421 crate::player::media_player::Capabilities::mpv(),
2422 )),
2423 playback_reporter,
2424 position_throttler,
2425 )
2426 }
2427}
2428
2429#[cfg(test)]
2430mod tests {
2431
2432 #[test]
2448 fn test_background_episode_advance_clears_the_quality_override() {
2449 let src = include_str!("mod.rs");
2450 let start = src
2451 .find("fn advance_to_next_episode_audio_only")
2452 .expect("advance_to_next_episode_audio_only not found");
2453 let rest = &src[start..];
2454 let end = rest.find("\n pub ").unwrap_or(rest.len());
2455 let body = &rest[..end];
2456
2457 assert!(
2458 body.contains("clear_playback_quality_override"),
2459 "the background episode advance does not clear the per-playback \
2460 quality override, so a ceiling chosen for one episode silently \
2461 caps every episode after it"
2462 );
2463 }
2464
2465 #[test]
2476 fn test_stop_clears_an_active_background_audio_handoff() {
2477 let controller = PlayerController::default();
2478 let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2479 {
2480 let queue_arc = controller.queue();
2481 let mut queue = queue_arc.lock_safe();
2482 queue.set_queue(vec![item], 0);
2483 }
2484
2485 controller.enter_background_audio(557.5);
2486 assert!(
2487 controller.is_background_audio_active(),
2488 "precondition: the handoff is active"
2489 );
2490
2491 controller.stop().expect("stop failed");
2492
2493 assert!(
2494 !controller.is_background_audio_active(),
2495 "a stop must not leave a handoff behind for the next position read"
2496 );
2497 assert_eq!(
2498 *controller.background_audio_base.lock_safe(),
2499 0.0,
2500 "the handoff base must be cleared with it"
2501 );
2502 }
2503
2504 #[test]
2515 fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
2516 let controller = PlayerController::default();
2517 let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2518 item.duration = Some(1800.0);
2519
2520 {
2521 let queue_arc = controller.queue();
2522 let mut queue = queue_arc.lock_safe();
2523 queue.set_queue(vec![item], 0);
2524 }
2525
2526 assert_eq!(
2527 controller.duration(),
2528 Some(1800.0),
2529 "an engine that cannot report a duration should not erase the one the item carries"
2530 );
2531 }
2532 use super::*;
2533
2534 struct CapturingEmitter {
2537 events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
2538 }
2539
2540 impl CapturingEmitter {
2541 fn new() -> Self {
2542 Self {
2543 events: std::sync::Mutex::new(Vec::new()),
2544 }
2545 }
2546 fn events(&self) -> Vec<PlayerStatusEvent> {
2547 self.events.lock_safe().clone()
2548 }
2549 }
2550
2551 impl PlayerEventEmitter for CapturingEmitter {
2552 fn emit(&self, event: PlayerStatusEvent) {
2553 self.events.lock_safe().push(event);
2554 }
2555 }
2556
2557 struct CapturingReports {
2560 operations: std::sync::Mutex<Vec<PlaybackOperation>>,
2561 }
2562
2563 impl CapturingReports {
2564 fn new() -> Self {
2565 Self {
2566 operations: std::sync::Mutex::new(Vec::new()),
2567 }
2568 }
2569
2570 fn stops(&self) -> Vec<(String, f64)> {
2572 self.operations
2573 .lock_safe()
2574 .iter()
2575 .filter_map(|op| match op {
2576 PlaybackOperation::Stopped {
2577 item_id,
2578 position_ticks,
2579 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2580 _ => None,
2581 })
2582 .collect()
2583 }
2584
2585 fn progress(&self) -> Vec<(String, f64)> {
2587 self.operations
2588 .lock_safe()
2589 .iter()
2590 .filter_map(|op| match op {
2591 PlaybackOperation::Progress {
2592 item_id,
2593 position_ticks,
2594 ..
2595 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2596 _ => None,
2597 })
2598 .collect()
2599 }
2600 }
2601
2602 impl PlaybackReportSink for CapturingReports {
2603 fn send(&self, operation: PlaybackOperation) {
2604 self.operations.lock_safe().push(operation);
2605 }
2606 }
2607
2608 #[test]
2609 fn test_report_html5_state_emits_state_changed() {
2610 let controller = PlayerController::default();
2611 let emitter = Arc::new(CapturingEmitter::new());
2612 controller.set_event_emitter(emitter.clone());
2613
2614 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2615
2616 let events = emitter.events();
2617 assert_eq!(events.len(), 1);
2618 match &events[0] {
2619 PlayerStatusEvent::StateChanged { state, media_id } => {
2620 assert_eq!(state, "playing");
2621 assert_eq!(media_id.as_deref(), Some("item-1"));
2622 }
2623 other => panic!("expected StateChanged, got {:?}", other),
2624 }
2625 }
2626
2627 #[test]
2628 fn test_report_html5_position_emits_position_update() {
2629 let controller = PlayerController::default();
2630 let emitter = Arc::new(CapturingEmitter::new());
2631 controller.set_event_emitter(emitter.clone());
2632
2633 controller.report_html5_position(12.5, 300.0);
2634
2635 let events = emitter.events();
2636 assert_eq!(events.len(), 1);
2637 match &events[0] {
2638 PlayerStatusEvent::PositionUpdate { position, duration } => {
2639 assert_eq!(*position, 12.5);
2640 assert_eq!(*duration, 300.0);
2641 }
2642 other => panic!("expected PositionUpdate, got {:?}", other),
2643 }
2644 }
2645
2646 #[test]
2647 fn test_report_html5_media_loaded_emits_media_loaded() {
2648 let controller = PlayerController::default();
2649 let emitter = Arc::new(CapturingEmitter::new());
2650 controller.set_event_emitter(emitter.clone());
2651
2652 controller.report_html5_media_loaded(420.0);
2653
2654 let events = emitter.events();
2655 assert_eq!(events.len(), 1);
2656 match &events[0] {
2657 PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
2658 other => panic!("expected MediaLoaded, got {:?}", other),
2659 }
2660 }
2661
2662 #[test]
2672 fn test_html5_state_is_tracked_from_reports() {
2673 let controller = PlayerController::default();
2674 let emitter = Arc::new(CapturingEmitter::new());
2675 controller.set_event_emitter(emitter.clone());
2676
2677 assert!(!controller.is_html5_active());
2679
2680 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2681 assert!(controller.is_html5_active());
2682 assert!(controller.html5_is_playing());
2683
2684 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2685 assert!(controller.is_html5_active());
2686 assert!(!controller.html5_is_playing());
2687 }
2688
2689 #[test]
2690 fn test_html5_toggle_from_paused_emits_play_control() {
2691 let controller = PlayerController::default();
2692 let emitter = Arc::new(CapturingEmitter::new());
2693 controller.set_event_emitter(emitter.clone());
2694 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2695
2696 controller.toggle_playback().unwrap();
2697
2698 let controls: Vec<_> = emitter
2699 .events()
2700 .into_iter()
2701 .filter_map(|e| match e {
2702 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2703 _ => None,
2704 })
2705 .collect();
2706 assert_eq!(controls, vec!["play".to_string()]);
2707 }
2708
2709 #[test]
2710 fn test_html5_toggle_from_playing_emits_pause_control() {
2711 let controller = PlayerController::default();
2712 let emitter = Arc::new(CapturingEmitter::new());
2713 controller.set_event_emitter(emitter.clone());
2714 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2715
2716 controller.toggle_playback().unwrap();
2717
2718 let controls: Vec<_> = emitter
2719 .events()
2720 .into_iter()
2721 .filter_map(|e| match e {
2722 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2723 _ => None,
2724 })
2725 .collect();
2726 assert_eq!(controls, vec!["pause".to_string()]);
2727 }
2728
2729 #[test]
2730 fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
2731 let controller = PlayerController::default();
2736 let emitter = Arc::new(CapturingEmitter::new());
2737 controller.set_event_emitter(emitter.clone());
2738 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2739
2740 controller.toggle_playback().unwrap();
2741 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2743 controller.toggle_playback().unwrap();
2744
2745 let controls: Vec<_> = emitter
2746 .events()
2747 .into_iter()
2748 .filter_map(|e| match e {
2749 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2750 _ => None,
2751 })
2752 .collect();
2753 assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
2754 }
2755
2756 #[test]
2757 fn test_html5_play_and_pause_emit_control_commands() {
2758 let controller = PlayerController::default();
2759 let emitter = Arc::new(CapturingEmitter::new());
2760 controller.set_event_emitter(emitter.clone());
2761 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2762
2763 controller.play().unwrap();
2764 controller.pause().unwrap();
2765
2766 let controls: Vec<_> = emitter
2767 .events()
2768 .into_iter()
2769 .filter_map(|e| match e {
2770 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2771 _ => None,
2772 })
2773 .collect();
2774 assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
2775 }
2776
2777 #[test]
2778 fn test_background_audio_handoff_moves_transport_to_native_backend() {
2779 let controller = PlayerController::default();
2787 let emitter = Arc::new(CapturingEmitter::new());
2788 controller.set_event_emitter(emitter.clone());
2789
2790 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2792 assert!(controller.is_html5_active());
2793
2794 controller.enter_background_audio(1200.0);
2796 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2797
2798 assert!(
2799 !controller.is_html5_active(),
2800 "native audio owns transport during a background-audio handoff"
2801 );
2802
2803 controller.pause().unwrap();
2804 let controls: Vec<_> = emitter
2805 .events()
2806 .into_iter()
2807 .filter_map(|e| match e {
2808 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2809 _ => None,
2810 })
2811 .collect();
2812 assert!(
2813 controls.is_empty(),
2814 "pause must drive the native backend, not a torn-down element: {:?}",
2815 controls
2816 );
2817 }
2818
2819 #[test]
2820 fn test_background_audio_handoff_suppresses_stale_element_events() {
2821 let controller = PlayerController::default();
2825 let emitter = Arc::new(CapturingEmitter::new());
2826 controller.set_event_emitter(emitter.clone());
2827
2828 controller.enter_background_audio(1200.0);
2829 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2830 controller.report_html5_position(1200.0, 2400.0);
2831
2832 assert!(
2833 emitter.events().is_empty(),
2834 "stale webview reports must not reach the event pipeline: {:?}",
2835 emitter.events()
2836 );
2837 }
2838
2839 #[test]
2840 fn test_exit_background_audio_returns_transport_to_the_webview() {
2841 let controller = PlayerController::default();
2844 let emitter = Arc::new(CapturingEmitter::new());
2845 controller.set_event_emitter(emitter.clone());
2846
2847 controller.enter_background_audio(1200.0);
2848 assert_eq!(controller.exit_background_audio(), 1200.0);
2849
2850 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2851 assert!(controller.is_html5_active());
2852 assert!(controller.html5_is_playing());
2853 }
2854
2855 #[test]
2856 fn test_html5_stopped_report_releases_transport_to_native_backend() {
2857 let controller = PlayerController::default();
2860 let emitter = Arc::new(CapturingEmitter::new());
2861 controller.set_event_emitter(emitter.clone());
2862
2863 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2864 assert!(controller.is_html5_active());
2865
2866 controller.report_html5_state("stopped".to_string(), None);
2867 assert!(!controller.is_html5_active());
2868 }
2869
2870 #[test]
2871 fn test_html5_transport_emits_exactly_one_control_per_intent() {
2872 let controller = PlayerController::default();
2876 let emitter = Arc::new(CapturingEmitter::new());
2877 controller.set_event_emitter(emitter.clone());
2878 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2879
2880 controller.pause().unwrap();
2881
2882 let controls = emitter
2883 .events()
2884 .into_iter()
2885 .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
2886 .count();
2887 assert_eq!(controls, 1, "one intent must produce exactly one control");
2888 }
2889
2890 #[test]
2891 fn test_controller_volume_default() {
2892 let controller = PlayerController::default();
2893 assert_eq!(controller.volume(), 1.0);
2894 }
2895
2896 #[test]
2897 fn test_controller_set_volume() {
2898 let controller = PlayerController::default();
2899 controller.set_volume(0.5).unwrap();
2900 assert_eq!(controller.volume(), 0.5);
2901 }
2902
2903 #[test]
2904 fn test_controller_muted_default() {
2905 let controller = PlayerController::default();
2906 assert!(!controller.muted());
2907 }
2908
2909 #[test]
2910 fn test_controller_volume_delegates_to_backend() {
2911 let controller = PlayerController::default();
2912
2913 controller.set_volume(0.75).unwrap();
2915
2916 assert_eq!(controller.volume(), 0.75);
2918 }
2919
2920 fn create_test_items(count: usize) -> Vec<MediaItem> {
2921 (0..count)
2922 .map(|i| MediaItem {
2923 transport: None,
2925 id: format!("item_{}", i),
2926 title: format!("Track {}", i + 1),
2927 name: Some(format!("Track {}", i + 1)),
2928 artist: Some("Test Artist".to_string()),
2929 album: Some("Test Album".to_string()),
2930 album_name: Some("Test Album".to_string()),
2931 album_id: None,
2932 artist_items: None,
2933 artists: Some(vec!["Test Artist".to_string()]),
2934 primary_image_tag: None,
2935 image_id: None,
2936 item_type: Some("Audio".to_string()),
2937 playlist_id: None,
2938 duration: Some(180.0),
2939 artwork_url: None,
2940 media_type: MediaType::Audio,
2941 source: MediaSource::DirectUrl {
2942 url: format!("http://example.com/track_{}.mp3", i),
2943 },
2944 video_codec: None,
2945 needs_transcoding: false,
2946 video_width: None,
2947 video_height: None,
2948 subtitles: vec![],
2949 series_id: None,
2950 server_id: None,
2951 })
2952 .collect()
2953 }
2954
2955 #[test]
2956 fn test_skip_preserves_queue() {
2957 let controller = PlayerController::default();
2958
2959 let items = create_test_items(5);
2961 let items_clone = items.clone();
2962
2963 controller.play_queue(items, 0).unwrap();
2965
2966 {
2968 let queue = controller.queue();
2969 let queue_lock = queue.lock_safe();
2970 assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
2971 assert_eq!(
2972 queue_lock.current_index(),
2973 Some(0),
2974 "Should start at index 0"
2975 );
2976 assert_eq!(
2977 queue_lock.current().unwrap().id,
2978 "item_0",
2979 "Current item should be item_0"
2980 );
2981 }
2982
2983 controller.next().unwrap();
2985
2986 {
2988 let queue = controller.queue();
2989 let queue_lock = queue.lock_safe();
2990 assert_eq!(
2991 queue_lock.items().len(),
2992 5,
2993 "Queue should still have 5 items after skip"
2994 );
2995 assert_eq!(
2996 queue_lock.current_index(),
2997 Some(1),
2998 "Index should advance to 1"
2999 );
3000 assert_eq!(
3001 queue_lock.current().unwrap().id,
3002 "item_1",
3003 "Current item should be item_1"
3004 );
3005
3006 let current_items = queue_lock.items();
3008 for (i, original) in items_clone.iter().enumerate() {
3009 assert_eq!(
3010 current_items[i].id, original.id,
3011 "Item {} should still be in queue",
3012 i
3013 );
3014 assert_eq!(
3015 current_items[i].title, original.title,
3016 "Item {} title should be unchanged",
3017 i
3018 );
3019 }
3020 }
3021
3022 controller.next().unwrap();
3024
3025 {
3027 let queue = controller.queue();
3028 let queue_lock = queue.lock_safe();
3029 assert_eq!(
3030 queue_lock.items().len(),
3031 5,
3032 "Queue should still have 5 items after second skip"
3033 );
3034 assert_eq!(
3035 queue_lock.current_index(),
3036 Some(2),
3037 "Index should advance to 2"
3038 );
3039 assert_eq!(
3040 queue_lock.current().unwrap().id,
3041 "item_2",
3042 "Current item should be item_2"
3043 );
3044 }
3045
3046 controller.next().unwrap(); controller.next().unwrap(); {
3052 let queue = controller.queue();
3053 let queue_lock = queue.lock_safe();
3054 assert_eq!(
3055 queue_lock.items().len(),
3056 5,
3057 "Queue should still have 5 items at end"
3058 );
3059 assert_eq!(
3060 queue_lock.current_index(),
3061 Some(4),
3062 "Index should be at last item (4)"
3063 );
3064 assert_eq!(
3065 queue_lock.current().unwrap().id,
3066 "item_4",
3067 "Current item should be item_4"
3068 );
3069 }
3070 }
3071
3072 #[test]
3073 fn test_skip_at_end_without_repeat() {
3074 let controller = PlayerController::default();
3075
3076 let items = create_test_items(3);
3078 controller.play_queue(items, 0).unwrap();
3079
3080 controller.next().unwrap(); controller.next().unwrap(); {
3086 let queue = controller.queue();
3087 let queue_lock = queue.lock_safe();
3088 assert_eq!(
3089 queue_lock.current_index(),
3090 Some(2),
3091 "Should be at last item"
3092 );
3093 }
3094
3095 controller.next().unwrap();
3098
3099 {
3101 let queue = controller.queue();
3102 let queue_lock = queue.lock_safe();
3103 assert_eq!(
3104 queue_lock.items().len(),
3105 3,
3106 "Queue should still have 3 items after skip at end"
3107 );
3108 }
3112 }
3113
3114 #[test]
3115 fn test_skip_with_repeat_all() {
3116 let controller = PlayerController::default();
3117
3118 let items = create_test_items(3);
3120 controller.play_queue(items, 0).unwrap();
3121
3122 controller.cycle_repeat();
3124
3125 controller.next().unwrap(); controller.next().unwrap(); controller.next().unwrap();
3131
3132 {
3134 let queue = controller.queue();
3135 let queue_lock = queue.lock_safe();
3136 assert_eq!(
3137 queue_lock.items().len(),
3138 3,
3139 "Queue should still have 3 items"
3140 );
3141 assert_eq!(
3142 queue_lock.current_index(),
3143 Some(0),
3144 "Should wrap to index 0"
3145 );
3146 assert_eq!(
3147 queue_lock.current().unwrap().id,
3148 "item_0",
3149 "Should be back at item_0"
3150 );
3151 }
3152 }
3153
3154 #[test]
3155 fn test_previous_preserves_queue() {
3156 let controller = PlayerController::default();
3157
3158 let items = create_test_items(5);
3160 let items_clone = items.clone();
3161 controller.play_queue(items, 3).unwrap();
3162
3163 {
3165 let queue = controller.queue();
3166 let queue_lock = queue.lock_safe();
3167 assert_eq!(
3168 queue_lock.current_index(),
3169 Some(3),
3170 "Should start at index 3"
3171 );
3172 }
3173
3174 controller.previous().unwrap();
3176
3177 {
3179 let queue = controller.queue();
3180 let queue_lock = queue.lock_safe();
3181 assert_eq!(
3182 queue_lock.items().len(),
3183 5,
3184 "Queue should still have 5 items after previous"
3185 );
3186 assert_eq!(
3187 queue_lock.current_index(),
3188 Some(2),
3189 "Index should move to 2"
3190 );
3191 assert_eq!(
3192 queue_lock.current().unwrap().id,
3193 "item_2",
3194 "Current item should be item_2"
3195 );
3196
3197 let current_items = queue_lock.items();
3199 for (i, original) in items_clone.iter().enumerate() {
3200 assert_eq!(
3201 current_items[i].id, original.id,
3202 "Item {} should still be in queue",
3203 i
3204 );
3205 }
3206 }
3207 }
3208
3209 #[test]
3210 fn test_seek_updates_position() {
3211 let controller = PlayerController::default();
3212
3213 let item = create_test_items(1).into_iter().next().unwrap();
3215 controller.play_item(item).unwrap();
3216
3217 assert_eq!(controller.position(), 0.0, "Initial position should be 0");
3219
3220 controller.seek(30.0).unwrap();
3222 assert_eq!(
3223 controller.position(),
3224 30.0,
3225 "Position should be 30 after seeking"
3226 );
3227
3228 controller.seek(60.0).unwrap();
3230 assert_eq!(
3231 controller.position(),
3232 60.0,
3233 "Position should be 60 after seeking"
3234 );
3235
3236 controller.seek(15.0).unwrap();
3238 assert_eq!(
3239 controller.position(),
3240 15.0,
3241 "Position should be 15 after seeking backward"
3242 );
3243 }
3244
3245 #[test]
3246 fn test_seek_while_paused() {
3247 let controller = PlayerController::default();
3248
3249 let item = create_test_items(1).into_iter().next().unwrap();
3251 controller.play_item(item).unwrap();
3252
3253 controller.pause().unwrap();
3255
3256 assert!(controller.state().is_paused(), "Should be paused");
3258
3259 controller.seek(45.0).unwrap();
3261 assert_eq!(
3262 controller.position(),
3263 45.0,
3264 "Position should update while paused"
3265 );
3266
3267 assert!(
3269 controller.state().is_paused(),
3270 "Should still be paused after seeking"
3271 );
3272 }
3273
3274 #[test]
3275 fn test_seek_while_playing() {
3276 let controller = PlayerController::default();
3277
3278 let item = create_test_items(1).into_iter().next().unwrap();
3280 controller.play_item(item).unwrap();
3281
3282 controller.play().unwrap();
3284
3285 assert!(controller.state().is_playing(), "Should be playing");
3287
3288 controller.seek(20.0).unwrap();
3290 assert_eq!(
3291 controller.position(),
3292 20.0,
3293 "Position should update while playing"
3294 );
3295
3296 assert!(
3298 controller.state().is_playing(),
3299 "Should still be playing after seeking"
3300 );
3301 }
3302
3303 #[test]
3304 fn test_multiple_sequential_seeks() {
3305 let controller = PlayerController::default();
3306
3307 let item = create_test_items(1).into_iter().next().unwrap();
3308 controller.play_item(item).unwrap();
3309
3310 let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
3312
3313 for pos in positions {
3314 controller.seek(pos).unwrap();
3315 assert_eq!(
3316 controller.position(),
3317 pos,
3318 "Position should match after seeking to {}",
3319 pos
3320 );
3321 }
3322 }
3323
3324 #[test]
3328 fn test_play_queue_from_resumes_at_position() {
3329 let controller = PlayerController::default();
3330 let items = create_test_items(3);
3331
3332 controller.play_queue_from(items, 1, Some(42.5)).unwrap();
3333
3334 {
3335 let queue = controller.queue();
3336 let queue_lock = queue.lock_safe();
3337 assert_eq!(
3338 queue_lock.current_index(),
3339 Some(1),
3340 "Should start at index 1"
3341 );
3342 }
3343 assert_eq!(
3344 controller.position(),
3345 42.5,
3346 "Should resume at the requested position"
3347 );
3348 }
3349
3350 #[test]
3352 fn test_play_queue_from_without_position_starts_at_zero() {
3353 let controller = PlayerController::default();
3354
3355 controller
3356 .play_queue_from(create_test_items(2), 0, None)
3357 .unwrap();
3358 assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
3359
3360 controller
3361 .play_queue_from(create_test_items(2), 0, Some(0.2))
3362 .unwrap();
3363 assert_eq!(
3364 controller.position(),
3365 0.0,
3366 "Sub-threshold resume position is ignored (starts at 0)"
3367 );
3368 }
3369
3370 #[test]
3371 fn test_seek_to_zero() {
3372 let controller = PlayerController::default();
3373
3374 let item = create_test_items(1).into_iter().next().unwrap();
3375 controller.play_item(item).unwrap();
3376
3377 controller.seek(60.0).unwrap();
3379 assert_eq!(controller.position(), 60.0);
3380
3381 controller.seek(0.0).unwrap();
3383 assert_eq!(
3384 controller.position(),
3385 0.0,
3386 "Should be able to seek to position 0"
3387 );
3388 }
3389
3390 #[tokio::test]
3392 async fn test_audio_with_next_advances() {
3393 let controller = PlayerController::default();
3394
3395 let items = create_test_items(2);
3397 controller.play_queue(items, 0).unwrap();
3398
3399 controller.take_end_reason();
3401
3402 let decision = controller.on_playback_ended().await.unwrap();
3404
3405 assert!(
3407 matches!(decision, AutoplayDecision::AdvanceToNext),
3408 "Expected AdvanceToNext decision when queue has next item"
3409 );
3410 }
3411
3412 #[tokio::test]
3413 async fn test_audio_at_end_stops() {
3414 let controller = PlayerController::default();
3415
3416 let items = create_test_items(2);
3418 controller.play_queue(items, 1).unwrap();
3419
3420 controller.take_end_reason();
3422
3423 let decision = controller.on_playback_ended().await.unwrap();
3425
3426 assert!(
3428 matches!(decision, AutoplayDecision::Stop),
3429 "Expected Stop decision when at end of queue without repeat"
3430 );
3431 }
3432
3433 #[tokio::test]
3434 async fn test_sleep_timer_end_of_track() {
3435 let controller = PlayerController::default();
3436
3437 let items = create_test_items(3);
3439 controller.play_queue(items, 0).unwrap();
3440
3441 controller.take_end_reason();
3443
3444 {
3446 let mut timer = controller.sleep_timer.lock_safe();
3447 timer.mode = SleepTimerMode::EndOfTrack;
3448 }
3449
3450 let decision = controller.on_playback_ended().await.unwrap();
3452
3453 assert!(
3455 matches!(decision, AutoplayDecision::Stop),
3456 "Expected Stop decision when sleep timer is EndOfTrack"
3457 );
3458
3459 {
3461 let timer = controller.sleep_timer.lock_safe();
3462 assert!(
3463 matches!(timer.mode, SleepTimerMode::Off),
3464 "Sleep timer should be cancelled after EndOfTrack"
3465 );
3466 }
3467 }
3468
3469 #[tokio::test]
3478 async fn test_expired_time_sleep_timer_stops_without_autoplay() {
3479 let controller = PlayerController::default();
3480
3481 let items = create_test_items(3);
3482 controller.play_queue(items, 0).unwrap();
3483 controller.take_end_reason();
3484
3485 let now = chrono::Utc::now().timestamp_millis();
3490 controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
3491
3492 for _ in 0..40 {
3494 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3495 if !controller.sleep_timer.lock_safe().is_active() {
3496 break;
3497 }
3498 }
3499 assert!(
3500 !controller.sleep_timer.lock_safe().is_active(),
3501 "Timer thread should have expired and cancelled the sleep timer"
3502 );
3503
3504 let decision = controller.on_playback_ended().await.unwrap();
3506
3507 assert!(
3508 matches!(decision, AutoplayDecision::Stop),
3509 "Expected Stop after an expired time-based sleep timer, got {:?}",
3510 decision
3511 );
3512 }
3513
3514 #[tokio::test]
3515 async fn test_empty_queue_stops() {
3516 let controller = PlayerController::default();
3517
3518 let decision = controller.on_playback_ended().await.unwrap();
3520
3521 assert!(
3523 matches!(decision, AutoplayDecision::Stop),
3524 "Expected Stop decision when queue is empty"
3525 );
3526 }
3527
3528 #[tokio::test]
3529 async fn test_repeat_all_advances_at_end() {
3530 let controller = PlayerController::default();
3531
3532 let items = create_test_items(2);
3534 controller.play_queue(items, 1).unwrap(); controller.cycle_repeat(); controller.take_end_reason();
3539
3540 let decision = controller.on_playback_ended().await.unwrap();
3542
3543 assert!(
3545 matches!(decision, AutoplayDecision::AdvanceToNext),
3546 "Expected AdvanceToNext decision at end of queue with repeat all"
3547 );
3548 }
3549
3550 #[tokio::test]
3551 async fn test_repeat_one_advances() {
3552 let controller = PlayerController::default();
3553
3554 let items = create_test_items(2);
3556 controller.play_queue(items, 0).unwrap();
3557
3558 controller.cycle_repeat(); controller.cycle_repeat(); controller.take_end_reason();
3564
3565 let decision = controller.on_playback_ended().await.unwrap();
3567
3568 assert!(
3570 matches!(decision, AutoplayDecision::AdvanceToNext),
3571 "Expected AdvanceToNext decision with repeat one (repeats same track)"
3572 );
3573 }
3574
3575 #[test]
3576 fn test_native_load_returns_transport_authority_to_the_backend() {
3577 let controller = PlayerController::default();
3596 let emitter = Arc::new(CapturingEmitter::new());
3597 controller.set_event_emitter(emitter.clone());
3598
3599 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
3601 assert!(controller.is_html5_active());
3602
3603 let item = create_test_items(1).into_iter().next().unwrap();
3605 controller.play_item(item).unwrap();
3606
3607 assert!(
3608 !controller.is_html5_active(),
3609 "loading into the native backend hands transport back to it"
3610 );
3611
3612 controller.toggle_playback().unwrap();
3614 let controls: Vec<_> = emitter
3615 .events()
3616 .into_iter()
3617 .filter_map(|e| match e {
3618 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
3619 _ => None,
3620 })
3621 .collect();
3622 assert!(
3623 controls.is_empty(),
3624 "transport went to a webview element that is not rendering: {controls:?}"
3625 );
3626 }
3627
3628 #[test]
3630 fn test_load_and_play_sets_new_track_loaded() {
3631 let controller = PlayerController::default();
3632 let item = create_test_items(1).into_iter().next().unwrap();
3633
3634 assert!(controller.take_end_reason().is_none());
3636
3637 controller.load_and_play(&item).unwrap();
3639
3640 let reason = controller.take_end_reason();
3642 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3643 }
3644
3645 #[test]
3646 fn test_stop_sets_user_stop() {
3647 let controller = PlayerController::default();
3648 let item = create_test_items(1).into_iter().next().unwrap();
3649
3650 controller.play_item(item).unwrap();
3652
3653 controller.take_end_reason();
3655
3656 controller.stop().unwrap();
3658
3659 let reason = controller.take_end_reason();
3661 assert_eq!(reason, Some(EndReason::UserStop));
3662 }
3663
3664 #[tokio::test]
3665 async fn test_on_playback_ended_with_new_track_loaded_stops() {
3666 let controller = PlayerController::default();
3667
3668 let items = create_test_items(2);
3670 controller.play_queue(items, 0).unwrap();
3671
3672 controller.set_end_reason(EndReason::NewTrackLoaded);
3674
3675 let decision = controller.on_playback_ended().await.unwrap();
3677
3678 assert!(
3680 matches!(decision, AutoplayDecision::Stop),
3681 "Expected Stop decision when EndReason is NewTrackLoaded"
3682 );
3683 }
3684
3685 #[tokio::test]
3686 async fn test_on_playback_ended_with_user_stop_stops() {
3687 let controller = PlayerController::default();
3688
3689 let items = create_test_items(2);
3691 controller.play_queue(items, 0).unwrap();
3692
3693 controller.set_end_reason(EndReason::UserStop);
3695
3696 let decision = controller.on_playback_ended().await.unwrap();
3698
3699 assert!(
3701 matches!(decision, AutoplayDecision::Stop),
3702 "Expected Stop decision when EndReason is UserStop"
3703 );
3704 }
3705
3706 #[tokio::test]
3707 async fn test_on_playback_ended_natural_end_advances() {
3708 let controller = PlayerController::default();
3709
3710 let items = create_test_items(2);
3712 controller.play_queue(items, 0).unwrap();
3713
3714 controller.take_end_reason();
3716
3717 let decision = controller.on_playback_ended().await.unwrap();
3719
3720 assert!(
3722 matches!(decision, AutoplayDecision::AdvanceToNext),
3723 "Expected AdvanceToNext decision when track ends naturally with next track available"
3724 );
3725 }
3726
3727 #[tokio::test]
3728 async fn test_on_playback_ended_with_user_skip_stops() {
3729 let controller = PlayerController::default();
3730
3731 let items = create_test_items(2);
3733 controller.play_queue(items, 0).unwrap();
3734
3735 controller.set_end_reason(EndReason::UserSkip);
3737
3738 let decision = controller.on_playback_ended().await.unwrap();
3740
3741 assert!(
3743 matches!(decision, AutoplayDecision::Stop),
3744 "Expected Stop decision when EndReason is UserSkip"
3745 );
3746 }
3747
3748 #[tokio::test]
3749 async fn test_on_playback_ended_with_error_stops() {
3750 let controller = PlayerController::default();
3751
3752 let items = create_test_items(2);
3754 controller.play_queue(items, 0).unwrap();
3755
3756 controller.set_end_reason(EndReason::Error);
3758
3759 let decision = controller.on_playback_ended().await.unwrap();
3761
3762 assert!(
3764 matches!(decision, AutoplayDecision::Stop),
3765 "Expected Stop decision when EndReason is Error"
3766 );
3767 }
3768
3769 #[tokio::test]
3770 async fn test_take_end_reason_clears_state() {
3771 let controller = PlayerController::default();
3772
3773 controller.set_end_reason(EndReason::NewTrackLoaded);
3775
3776 let reason = controller.take_end_reason();
3778 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3779
3780 let reason = controller.take_end_reason();
3782 assert!(reason.is_none(), "take_end_reason should clear the state");
3783 }
3784
3785 use crate::repository::types as repo_types;
3788
3789 struct MockEpisodeRepo {
3793 seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
3795 }
3796
3797 impl MockEpisodeRepo {
3798 fn season(count: usize) -> Self {
3800 Self::series(&[count])
3801 }
3802
3803 fn series(counts: &[usize]) -> Self {
3807 let seasons = counts
3808 .iter()
3809 .enumerate()
3810 .map(|(s, count)| {
3811 let season_number = s as i32 + 1;
3812 let episodes = (1..=*count)
3813 .map(|i| {
3814 let id = if season_number == 1 {
3815 format!("ep{}", i)
3816 } else {
3817 format!("s{}e{}", season_number, i)
3818 };
3819 make_repo_episode_in(season_number, &id, i as i32)
3820 })
3821 .collect();
3822 (make_repo_season(season_number), episodes)
3823 })
3824 .collect();
3825 Self { seasons }
3826 }
3827
3828 fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
3829 self.seasons.iter().flat_map(|(_, eps)| eps.iter())
3830 }
3831 }
3832
3833 fn make_repo_season(index: i32) -> repo_types::MediaItem {
3834 repo_types::MediaItem {
3835 id: format!("season{}", index),
3836 name: format!("Season {}", index),
3837 item_type: "Season".to_string(),
3838 kind: crate::domain::MediaKind::Season,
3839 is_folder: true,
3840 parent_id: Some("series1".to_string()),
3841 index_number: Some(index),
3842 season_id: None,
3843 season_name: None,
3844 parent_index_number: None,
3845 ..make_repo_episode(&format!("season{}", index), index)
3846 }
3847 }
3848
3849 fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
3850 make_repo_episode_in(1, id, index)
3851 }
3852
3853 fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
3854 repo_types::MediaItem {
3855 id: id.to_string(),
3856 name: format!("Episode {}", index),
3857 item_type: "Episode".to_string(),
3858 kind: crate::domain::MediaKind::Episode,
3859 is_folder: false,
3860 server_id: "server".to_string(),
3861 parent_id: Some(format!("season{}", season_number)),
3862 library_id: None,
3863 overview: None,
3864 genres: None,
3865 runtime_ticks: None,
3866 duration_ms: None,
3867 production_year: None,
3868 premiere_date: None,
3869 community_rating: None,
3870 official_rating: None,
3871 primary_image_tag: None,
3872 image_id: None,
3873 backdrop_image_tags: None,
3874 parent_backdrop_image_tags: None,
3875 album_id: None,
3876 album_name: None,
3877 album_artist: None,
3878 artists: None,
3879 artist_items: None,
3880 index_number: Some(index),
3881 series_id: Some("series1".to_string()),
3882 series_name: Some("Test Series".to_string()),
3883 season_id: Some(format!("season{}", season_number)),
3884 season_name: Some(format!("Season {}", season_number)),
3885 parent_index_number: Some(season_number),
3886 user_data: None,
3887 media_streams: None,
3888 media_sources: None,
3889 people: None,
3890 }
3891 }
3892
3893 #[async_trait::async_trait]
3894 impl crate::repository::MediaRepository for MockEpisodeRepo {
3895 async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
3896 unimplemented!()
3897 }
3898 async fn get_items(
3899 &self,
3900 parent_id: &str,
3901 _options: Option<repo_types::GetItemsOptions>,
3902 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3903 let items = if parent_id == "series1" {
3907 self.seasons.iter().map(|(s, _)| s.clone()).collect()
3908 } else {
3909 self.seasons
3910 .iter()
3911 .find(|(season, _)| season.id == parent_id)
3912 .map(|(_, eps)| eps.clone())
3913 .unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
3914 };
3915 let total_record_count = items.len();
3916 Ok(repo_types::SearchResult {
3917 items,
3918 total_record_count,
3919 })
3920 }
3921 async fn get_item(
3922 &self,
3923 item_id: &str,
3924 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3925 self.all_episodes()
3926 .find(|e| e.id == item_id)
3927 .cloned()
3928 .ok_or(repo_types::RepoError::NotFound {
3929 message: format!("{} not found", item_id),
3930 })
3931 }
3932 async fn get_latest_items(
3933 &self,
3934 _: &str,
3935 _: Option<usize>,
3936 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3937 unimplemented!()
3938 }
3939 async fn get_resume_items(
3940 &self,
3941 _: Option<&str>,
3942 _: Option<usize>,
3943 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3944 unimplemented!()
3945 }
3946 async fn get_next_up_episodes(
3947 &self,
3948 _: Option<&str>,
3949 _: Option<usize>,
3950 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3951 unimplemented!()
3952 }
3953 async fn get_recently_played_audio(
3954 &self,
3955 _: Option<usize>,
3956 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3957 unimplemented!()
3958 }
3959 async fn get_rediscover_albums(
3960 &self,
3961 _: Option<&str>,
3962 _: Option<usize>,
3963 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3964 unimplemented!()
3965 }
3966 async fn get_resume_movies(
3967 &self,
3968 _: Option<usize>,
3969 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3970 unimplemented!()
3971 }
3972 async fn get_genres(
3973 &self,
3974 _: Option<&str>,
3975 ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
3976 unimplemented!()
3977 }
3978 async fn search(
3979 &self,
3980 _: &str,
3981 _: Option<repo_types::SearchOptions>,
3982 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3983 unimplemented!()
3984 }
3985 async fn get_playback_info(
3986 &self,
3987 _: &str,
3988 ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
3989 unimplemented!()
3990 }
3991 async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
3992 unimplemented!()
3993 }
3994 async fn get_audio_only_stream_url_for_video(
3995 &self,
3996 item_id: &str,
3997 _media_source_id: Option<&str>,
3998 _start_time_seconds: Option<f64>,
3999 _audio_stream_index: Option<i32>,
4000 ) -> Result<String, repo_types::RepoError> {
4001 Ok(format!("http://example.com/{}-audio.mp3", item_id))
4002 }
4003 async fn get_live_tv_channels(
4004 &self,
4005 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
4006 unimplemented!()
4007 }
4008 async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4009 unimplemented!()
4010 }
4011 async fn open_live_stream(
4012 &self,
4013 _: &str,
4014 ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
4015 unimplemented!()
4016 }
4017 async fn report_playback_start(
4018 &self,
4019 _: &str,
4020 _: i64,
4021 ) -> Result<(), repo_types::RepoError> {
4022 unimplemented!()
4023 }
4024 async fn report_playback_progress(
4025 &self,
4026 _: &str,
4027 _: i64,
4028 ) -> Result<(), repo_types::RepoError> {
4029 unimplemented!()
4030 }
4031 async fn report_playback_stopped(
4032 &self,
4033 _: &str,
4034 _: i64,
4035 ) -> Result<(), repo_types::RepoError> {
4036 unimplemented!()
4037 }
4038 fn get_image_url(
4039 &self,
4040 _: &str,
4041 _: repo_types::ImageType,
4042 _: Option<repo_types::ImageOptions>,
4043 ) -> String {
4044 unimplemented!()
4045 }
4046 fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
4047 unimplemented!()
4048 }
4049 fn get_video_download_url(
4050 &self,
4051 _: &str,
4052 _: &str,
4053 _: Option<&str>,
4054 _: Option<&str>,
4055 ) -> String {
4056 unimplemented!()
4057 }
4058 async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
4059 unimplemented!()
4060 }
4061 async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
4062 unimplemented!()
4063 }
4064 async fn get_favorites(
4065 &self,
4066 _: repo_types::SearchScope,
4067 _: Option<repo_types::GetItemsOptions>,
4068 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4069 unimplemented!()
4070 }
4071 async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
4072 unimplemented!()
4073 }
4074 async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
4075 unimplemented!()
4076 }
4077 async fn get_person(
4078 &self,
4079 _: &str,
4080 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
4081 unimplemented!()
4082 }
4083 async fn get_items_by_person(
4084 &self,
4085 _: &str,
4086 _: Option<repo_types::GetItemsOptions>,
4087 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4088 unimplemented!()
4089 }
4090 async fn get_similar_items(
4091 &self,
4092 _: &str,
4093 _: Option<usize>,
4094 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4095 unimplemented!()
4096 }
4097 async fn create_playlist(
4098 &self,
4099 _: &str,
4100 _: &[String],
4101 ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
4102 unimplemented!()
4103 }
4104 async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
4105 unimplemented!()
4106 }
4107 async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
4108 unimplemented!()
4109 }
4110 async fn get_playlist_items(
4111 &self,
4112 _: &str,
4113 ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
4114 unimplemented!()
4115 }
4116 async fn add_to_playlist(
4117 &self,
4118 _: &str,
4119 _: &[String],
4120 ) -> Result<(), repo_types::RepoError> {
4121 unimplemented!()
4122 }
4123 async fn remove_from_playlist(
4124 &self,
4125 _: &str,
4126 _: &[String],
4127 ) -> Result<(), repo_types::RepoError> {
4128 unimplemented!()
4129 }
4130 async fn move_playlist_item(
4131 &self,
4132 _: &str,
4133 _: &str,
4134 _: u32,
4135 ) -> Result<(), repo_types::RepoError> {
4136 unimplemented!()
4137 }
4138 }
4139
4140 #[tokio::test]
4143 async fn test_video_playback_ended_offers_next_episode() {
4144 let controller = PlayerController::default();
4145 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
4146
4147 let decision = controller
4148 .on_video_playback_ended("ep2", repo)
4149 .await
4150 .expect("decision should succeed");
4151
4152 match decision {
4153 AutoplayDecision::ShowNextEpisodePopup {
4154 current_episode,
4155 next_episode,
4156 auto_advance,
4157 ..
4158 } => {
4159 assert_eq!(current_episode.id, "ep2");
4160 assert_eq!(next_episode.id, "ep3");
4161 assert!(auto_advance, "default settings should auto-advance");
4162 }
4163 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4164 }
4165 }
4166
4167 #[tokio::test]
4174 async fn test_next_episode_rolls_over_to_the_next_season() {
4175 let controller = PlayerController::default();
4176 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
4177
4178 let decision = controller
4179 .on_video_playback_ended("ep2", repo)
4180 .await
4181 .expect("decision should succeed");
4182
4183 match decision {
4184 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4185 assert_eq!(
4186 next_episode.id, "s2e1",
4187 "the first episode of the next season follows the last of this one"
4188 );
4189 }
4190 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4191 }
4192 }
4193
4194 #[tokio::test]
4198 async fn test_next_episode_skips_an_empty_season() {
4199 let controller = PlayerController::default();
4200 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
4201
4202 let decision = controller
4203 .on_video_playback_ended("ep1", repo)
4204 .await
4205 .expect("decision should succeed");
4206
4207 match decision {
4208 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4209 assert_eq!(next_episode.id, "s3e1");
4210 }
4211 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4212 }
4213 }
4214
4215 #[tokio::test]
4221 async fn test_sleep_timer_still_stops_at_a_season_boundary() {
4222 let controller = PlayerController::default();
4223 controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
4224 controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
4225
4226 let episode = MediaItem {
4227 transport: None,
4228 media_type: MediaType::Audio,
4229 item_type: Some("Episode".to_string()),
4230 series_id: Some("series1".to_string()),
4231 duration: Some(180.0),
4232 source: MediaSource::Remote {
4233 stream_url: "http://example.com/ep1.mp3".to_string(),
4234 jellyfin_item_id: "ep1".to_string(),
4235 },
4236 ..create_test_items(1).remove(0)
4237 };
4238 controller.play_queue(vec![episode], 0).unwrap();
4239 controller.seek(180.0).unwrap();
4241 controller.take_end_reason();
4242
4243 let decision = controller.on_playback_ended().await.unwrap();
4244
4245 assert!(
4246 matches!(decision, AutoplayDecision::Stop),
4247 "the last episode the timer allows must stop, not roll into season 2 (got {:?})",
4248 decision
4249 );
4250 }
4251
4252 #[tokio::test]
4254 async fn test_video_playback_ended_last_episode_stops() {
4255 let controller = PlayerController::default();
4256 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
4257
4258 let decision = controller
4259 .on_video_playback_ended("ep3", repo)
4260 .await
4261 .expect("decision should succeed");
4262
4263 assert!(matches!(decision, AutoplayDecision::Stop));
4264 }
4265
4266 #[tokio::test]
4271 async fn test_playback_ended_uses_controller_repository_for_episodes() {
4272 let controller = PlayerController::default();
4273 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4274
4275 let episode = MediaItem {
4277 transport: None,
4279 media_type: MediaType::Video,
4280 source: MediaSource::Remote {
4281 stream_url: "http://example.com/ep1.mkv".to_string(),
4282 jellyfin_item_id: "ep1".to_string(),
4283 },
4284 ..create_test_items(1).remove(0)
4285 };
4286 controller.play_queue(vec![episode], 0).unwrap();
4287
4288 controller.take_end_reason();
4290
4291 let decision = controller.on_playback_ended().await.unwrap();
4292
4293 match decision {
4294 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4295 assert_eq!(next_episode.id, "ep2");
4296 }
4297 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4298 }
4299 }
4300
4301 #[tokio::test]
4308 async fn test_playback_ended_background_audio_episode_advances() {
4309 let controller = PlayerController::default();
4310 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4311
4312 let episode = MediaItem {
4314 transport: None,
4316 item_type: Some("Episode".to_string()),
4317 media_type: MediaType::Audio, series_id: Some("series1".to_string()),
4319 duration: Some(180.0),
4320 source: MediaSource::Remote {
4321 stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
4322 jellyfin_item_id: "ep2".to_string(),
4323 },
4324 ..create_test_items(1).remove(0)
4325 };
4326 controller.play_queue(vec![episode], 0).unwrap();
4327
4328 controller.seek(180.0).unwrap();
4330 controller.take_end_reason();
4332
4333 let decision = controller.on_playback_ended().await.unwrap();
4334
4335 match decision {
4336 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4337 assert_eq!(next_episode.id, "ep3");
4338 }
4339 other => panic!(
4340 "background-audio episode end must advance to the next episode, got {:?}",
4341 other
4342 ),
4343 }
4344 }
4345
4346 #[tokio::test]
4350 async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
4351 let controller = PlayerController::default();
4352 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4353
4354 controller
4355 .advance_to_next_episode_audio_only("ep2")
4356 .await
4357 .expect("advance should succeed");
4358
4359 let current = controller
4360 .queue
4361 .lock_safe()
4362 .current()
4363 .cloned()
4364 .expect("an item should be loaded");
4365 assert_eq!(current.id, "ep2");
4366 assert_eq!(current.media_type, MediaType::Audio);
4367 assert_eq!(current.item_type.as_deref(), Some("Episode"));
4368 assert_eq!(current.series_id.as_deref(), Some("series1"));
4369 match ¤t.source {
4371 MediaSource::Remote { stream_url, .. } => {
4372 assert!(
4373 stream_url.contains("audio"),
4374 "expected audio-only URL, got {}",
4375 stream_url
4376 );
4377 }
4378 other => panic!("expected Remote source, got {:?}", other),
4379 }
4380
4381 assert!(controller.current_is_audio_episode());
4384 }
4385
4386 #[tokio::test]
4394 async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
4395 let controller = PlayerController::default();
4396 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4397
4398 controller.set_background_audio_base(1200.0);
4400
4401 controller
4402 .advance_to_next_episode_audio_only("ep2")
4403 .await
4404 .expect("advance should succeed");
4405
4406 assert_eq!(
4407 controller.take_background_audio_base(),
4408 0.0,
4409 "the next episode starts at its own zero, so the previous handoff \
4410 base must not survive the advance"
4411 );
4412 }
4413
4414 #[tokio::test]
4426 async fn test_auto_advance_background_audio_episode_advances_in_backend() {
4427 let controller = PlayerController::default();
4428 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4429
4430 let episode = MediaItem {
4432 transport: None,
4434 id: "ep2".to_string(),
4435 item_type: Some("Episode".to_string()),
4436 media_type: MediaType::Audio,
4437 series_id: Some("series1".to_string()),
4438 source: MediaSource::Remote {
4439 stream_url: "http://example.com/ep2-audio.mp3".to_string(),
4440 jellyfin_item_id: "ep2".to_string(),
4441 },
4442 ..create_test_items(1).remove(0)
4443 };
4444 controller.play_queue(vec![episode], 0).unwrap();
4445
4446 let next = make_repo_episode("ep3", 3);
4447 controller.auto_advance_to_next_episode(next, 10).await;
4448
4449 let current = controller
4450 .queue
4451 .lock_safe()
4452 .current()
4453 .cloned()
4454 .expect("an item should still be loaded");
4455 assert_eq!(
4456 current.id, "ep3",
4457 "background audio-only episode must advance in the backend, not wait \
4458 for a frontend navigation that cannot happen while backgrounded"
4459 );
4460 assert_eq!(current.media_type, MediaType::Audio);
4461 assert!(controller.current_is_audio_episode());
4462 }
4463
4464 fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
4468 MediaItem {
4469 transport: None,
4471 id: "ep2".to_string(),
4472 item_type: Some("Episode".to_string()),
4473 media_type: MediaType::Audio,
4474 series_id: Some("series1".to_string()),
4475 duration: Some(runtime_seconds),
4476 source: MediaSource::Remote {
4477 stream_url:
4478 "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
4479 .to_string(),
4480 jellyfin_item_id: "ep2".to_string(),
4481 },
4482 ..create_test_items(1).remove(0)
4483 }
4484 }
4485
4486 fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
4490 MediaItem {
4491 transport: None,
4493 source: MediaSource::Local {
4494 file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
4495 jellyfin_item_id: Some("ep2".to_string()),
4496 },
4497 ..audio_only_episode(runtime_seconds)
4498 }
4499 }
4500
4501 #[tokio::test]
4508 async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
4509 let controller = PlayerController::default();
4510 controller
4511 .play_queue(vec![local_audio_only_episode(1500.0)], 0)
4512 .unwrap();
4513 controller.enter_background_audio(0.0);
4515
4516 controller.seek_absolute(900.0).await.unwrap();
4517
4518 assert_eq!(controller.position(), 900.0);
4519 }
4520
4521 #[tokio::test]
4533 async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
4534 let controller = PlayerController::default();
4535 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4536
4537 controller
4538 .play_queue(vec![audio_only_episode(1500.0)], 0)
4539 .unwrap();
4540 controller.seek(600.0).unwrap();
4542 controller.take_end_reason();
4543
4544 let decision = controller.on_playback_ended().await.unwrap();
4545
4546 match decision {
4547 AutoplayDecision::ResumeStream { position } => {
4548 assert_eq!(position, 600.0, "must resume where the stream died");
4549 }
4550 other => panic!(
4551 "a stream that ended 15 minutes short of the runtime must resume, \
4552 not run end-of-episode logic; got {:?}",
4553 other
4554 ),
4555 }
4556 }
4557
4558 #[tokio::test]
4571 async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
4572 let controller = PlayerController::default();
4573 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4574 controller
4575 .play_queue(vec![audio_only_episode(1500.0)], 0)
4576 .unwrap();
4577
4578 controller.enter_background_audio(1200.0);
4580
4581 controller.seek_absolute(1490.0).await.unwrap();
4583
4584 let url = {
4585 let queue = controller.queue();
4586 let queue = queue.lock_safe();
4587 match &queue.current().unwrap().source {
4588 MediaSource::Remote { stream_url, .. } => stream_url.clone(),
4589 other => panic!("expected a remote source, got {:?}", other),
4590 }
4591 };
4592 assert!(
4593 url.contains(&format!(
4594 "StartTimeTicks={}",
4595 (1490.0 * 10_000_000.0) as i64
4596 )),
4597 "the stream must be re-opened at the absolute position; got {}",
4598 url
4599 );
4600
4601 assert_eq!(
4602 *controller.background_audio_base.lock_safe(),
4603 1490.0,
4604 "the re-opened stream's zero is the position it was opened at, or \
4605 every later reading is off by the difference"
4606 );
4607 }
4608
4609 #[tokio::test]
4614 async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
4615 let controller = PlayerController::default();
4616 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4617 controller
4618 .play_queue(vec![audio_only_episode(1500.0)], 0)
4619 .unwrap();
4620
4621 controller.seek_absolute(300.0).await.unwrap();
4622
4623 assert_eq!(controller.position(), 300.0);
4624 assert_eq!(
4625 *controller.background_audio_base.lock_safe(),
4626 0.0,
4627 "an ordinary seek must not invent a handoff base"
4628 );
4629 }
4630
4631 #[test]
4651 fn test_absolute_position_floors_at_the_handoff_base() {
4652 let controller = PlayerController::default();
4653 controller.enter_background_audio(18.4);
4654
4655 assert_eq!(controller.position(), 0.0);
4657 assert_eq!(
4658 controller.absolute_position(),
4659 18.4,
4660 "the audio stream's zero IS the handoff point, so the position can \
4661 never legitimately read below it"
4662 );
4663 }
4664
4665 #[test]
4670 fn test_absolute_position_does_not_double_count_the_handoff_base() {
4671 let controller = PlayerController::default();
4672 controller.enter_background_audio(18.4);
4673
4674 controller.seek(120.0).unwrap();
4676
4677 assert_eq!(controller.absolute_position(), 120.0);
4678 }
4679
4680 #[test]
4687 fn test_webview_position_reports_become_the_controllers_position() {
4688 let controller = PlayerController::default();
4689
4690 controller.report_html5_position(253.4, 2640.0);
4691
4692 assert_eq!(controller.absolute_position(), 253.4);
4693 assert_eq!(controller.observed_duration(), Some(2640.0));
4694 }
4695
4696 #[test]
4702 fn test_webview_teardown_clears_the_observed_position() {
4703 let controller = PlayerController::default();
4704 controller.report_html5_position(253.4, 2640.0);
4705
4706 controller.report_html5_state("stopped".to_string(), None);
4707
4708 assert_eq!(controller.absolute_position(), 0.0);
4709 }
4710
4711 #[test]
4716 fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
4717 let controller = PlayerController::default();
4718 controller.report_html5_position(253.4, 2640.0);
4719
4720 controller.enter_background_audio(18.4);
4721
4722 assert_eq!(
4723 controller.absolute_position(),
4724 18.4,
4725 "the video element is gone; only the handoff base describes the \
4726 stream that is now playing"
4727 );
4728 }
4729
4730 #[tokio::test]
4738 async fn test_a_stop_at_zero_is_never_reported() {
4739 let controller = PlayerController::default();
4740 let reports = Arc::new(CapturingReports::new());
4741 controller.set_report_sink(reports.clone());
4742 controller
4743 .play_queue(vec![audio_only_episode(1500.0)], 0)
4744 .unwrap();
4745
4746 controller.stop().unwrap();
4748
4749 assert!(
4750 reports.stops().is_empty(),
4751 "a zero-position stop must be withheld, not sent; got {:?}",
4752 reports.stops()
4753 );
4754 }
4755
4756 #[tokio::test]
4765 async fn test_a_stop_reports_the_position_actually_reached() {
4766 let webview = PlayerController::default();
4768 let webview_reports = Arc::new(CapturingReports::new());
4769 webview.set_report_sink(webview_reports.clone());
4770 webview
4771 .play_queue(vec![audio_only_episode(1500.0)], 0)
4772 .unwrap();
4773 webview.report_html5_position(253.0, 1500.0);
4774
4775 webview.stop().unwrap();
4776
4777 assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4778
4779 let native = PlayerController::default();
4781 let native_reports = Arc::new(CapturingReports::new());
4782 native.set_report_sink(native_reports.clone());
4783 native
4784 .play_queue(vec![audio_only_episode(1500.0)], 0)
4785 .unwrap();
4786 native.seek(253.0).unwrap();
4787
4788 native.stop().unwrap();
4789
4790 assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4791 }
4792
4793 #[tokio::test]
4803 async fn test_a_finished_audio_only_episode_is_reported_complete() {
4804 let controller = PlayerController::default();
4805 let reports = Arc::new(CapturingReports::new());
4806 controller.set_report_sink(reports.clone());
4807 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4808 controller
4809 .play_queue(vec![audio_only_episode(1500.0)], 0)
4810 .unwrap();
4811 controller.seek(1499.0).unwrap();
4813 controller.take_end_reason();
4814
4815 controller.on_playback_ended().await.unwrap();
4816
4817 assert_eq!(
4818 reports.stops(),
4819 vec![("ep2".to_string(), 1500.0)],
4820 "the finished episode must be reported stopped at its runtime, or \
4821 Jellyfin's ≥90% rule never marks it played"
4822 );
4823 }
4824
4825 #[tokio::test]
4831 async fn test_a_truncated_stream_reports_no_completion() {
4832 let controller = PlayerController::default();
4833 let reports = Arc::new(CapturingReports::new());
4834 controller.set_report_sink(reports.clone());
4835 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4836 controller
4837 .play_queue(vec![audio_only_episode(1500.0)], 0)
4838 .unwrap();
4839 controller.seek(600.0).unwrap();
4840 controller.take_end_reason();
4841
4842 let decision = controller.on_playback_ended().await.unwrap();
4843
4844 assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
4845 assert!(
4846 reports.stops().is_empty(),
4847 "a dropped connection is not a finished episode; got {:?}",
4848 reports.stops()
4849 );
4850 }
4851
4852 #[tokio::test]
4860 async fn test_webview_position_ticks_report_progress_to_the_server() {
4861 let controller = PlayerController::default();
4862 let reports = Arc::new(CapturingReports::new());
4863 controller.set_report_sink(reports.clone());
4864 controller
4865 .play_queue(vec![audio_only_episode(1500.0)], 0)
4866 .unwrap();
4867
4868 controller.report_html5_position(253.4, 1500.0);
4869
4870 assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
4871 }
4872
4873 #[tokio::test]
4878 async fn test_progress_reports_are_throttled_not_sent_per_tick() {
4879 let controller = PlayerController::default();
4880 let reports = Arc::new(CapturingReports::new());
4881 controller.set_report_sink(reports.clone());
4882 controller
4883 .play_queue(vec![audio_only_episode(1500.0)], 0)
4884 .unwrap();
4885
4886 for tick in 0..12 {
4887 controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
4888 }
4889
4890 assert_eq!(
4891 reports.progress().len(),
4892 1,
4893 "twelve ticks inside one throttle window are one report"
4894 );
4895 }
4896
4897 #[tokio::test]
4907 async fn test_truncated_check_uses_the_absolute_position() {
4908 let controller = PlayerController::default();
4909 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4910
4911 controller
4912 .play_queue(vec![audio_only_episode(1500.0)], 0)
4913 .unwrap();
4914 controller.set_background_audio_base(1440.0);
4917 controller.seek(1496.0).unwrap();
4918 controller.take_end_reason();
4919
4920 let decision = controller.on_playback_ended().await.unwrap();
4921
4922 assert!(
4923 matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
4924 "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
4925 decision
4926 );
4927 }
4928
4929 #[tokio::test]
4933 async fn test_repeated_truncation_at_the_same_position_gives_up() {
4934 let controller = PlayerController::default();
4935 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4936
4937 controller
4938 .play_queue(vec![audio_only_episode(1500.0)], 0)
4939 .unwrap();
4940 controller.seek(600.0).unwrap();
4941
4942 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4943 controller.take_end_reason();
4944 let decision = controller.on_playback_ended().await.unwrap();
4945 assert!(
4946 matches!(decision, AutoplayDecision::ResumeStream { .. }),
4947 "attempt {} should still resume, got {:?}",
4948 attempt,
4949 decision
4950 );
4951 }
4952
4953 controller.take_end_reason();
4954 let decision = controller.on_playback_ended().await.unwrap();
4955 assert!(
4956 !matches!(decision, AutoplayDecision::ResumeStream { .. }),
4957 "a stream stuck at the same position must stop retrying, got {:?}",
4958 decision
4959 );
4960 }
4961
4962 #[tokio::test]
4966 async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
4967 let controller = PlayerController::default();
4968
4969 let mut items = create_test_items(2);
4970 items[0].duration = Some(1500.0);
4971 controller.play_queue(items, 0).unwrap();
4972 controller.seek(60.0).unwrap();
4973 controller.take_end_reason();
4974
4975 let decision = controller.on_playback_ended().await.unwrap();
4976 assert!(
4977 matches!(decision, AutoplayDecision::AdvanceToNext),
4978 "plain queue audio must keep advancing, got {:?}",
4979 decision
4980 );
4981 }
4982
4983 #[tokio::test]
4989 async fn test_recoverable_error_resumes_a_music_track() {
4990 let controller = PlayerController::default();
4991
4992 let mut items = create_test_items(3);
4993 for item in &mut items {
4994 item.source = MediaSource::Remote {
4995 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
4996 jellyfin_item_id: item.id.clone(),
4997 };
4998 }
4999 controller.play_queue(items, 1).unwrap();
5000 controller.seek(45.0).unwrap();
5001
5002 let (position, _) = controller
5003 .recoverable_error_resume()
5004 .expect("a streamed music track must be resumable after a network error");
5005 assert_eq!(position, 45.0);
5006 }
5007
5008 #[tokio::test]
5012 async fn test_resume_keeps_the_rest_of_the_queue() {
5013 let controller = PlayerController::default();
5014
5015 let mut items = create_test_items(3);
5016 for item in &mut items {
5017 item.source = MediaSource::Remote {
5018 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
5019 jellyfin_item_id: item.id.clone(),
5020 };
5021 }
5022 controller.play_queue(items, 1).unwrap();
5023
5024 controller
5025 .resume_stream_at(45.0)
5026 .await
5027 .expect("resume should succeed");
5028
5029 let queue = controller.queue.lock_safe();
5030 assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
5031 assert_eq!(queue.current_index(), Some(1), "still on the same track");
5032 assert_eq!(queue.current().unwrap().id, "item_1");
5033 }
5034
5035 #[tokio::test]
5039 async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
5040 let controller = PlayerController::default();
5041
5042 let mut items = create_test_items(1);
5043 items[0].source = MediaSource::Remote {
5044 stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
5045 jellyfin_item_id: "item_0".to_string(),
5046 };
5047 controller.play_queue(items, 0).unwrap();
5048
5049 controller.resume_stream_at(45.0).await.unwrap();
5050
5051 match &controller.queue.lock_safe().current().unwrap().source {
5052 MediaSource::Remote { stream_url, .. } => {
5053 assert_eq!(
5054 stream_url, "http://s/Audio/item_0/stream?Static=true",
5055 "a seekable stream's URL must be left alone"
5056 );
5057 }
5058 other => panic!("expected Remote source, got {:?}", other),
5059 }
5060 assert_eq!(
5061 controller.position(),
5062 45.0,
5063 "and it must land at the position"
5064 );
5065 }
5066
5067 #[tokio::test]
5070 async fn test_recoverable_error_ignores_local_media() {
5071 let controller = PlayerController::default();
5072
5073 let mut items = create_test_items(1);
5074 items[0].source = MediaSource::Local {
5075 file_path: "/music/track.flac".into(),
5076 jellyfin_item_id: Some("item_0".to_string()),
5077 };
5078 controller.play_queue(items, 0).unwrap();
5079 controller.seek(45.0).unwrap();
5080
5081 assert!(controller.recoverable_error_resume().is_none());
5082 }
5083
5084 #[tokio::test]
5089 async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
5090 let controller = PlayerController::default();
5091
5092 controller
5093 .play_queue(vec![audio_only_episode(1500.0)], 0)
5094 .unwrap();
5095 controller.seek(600.0).unwrap();
5096
5097 let mut waits = Vec::new();
5098 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
5099 let (position, delay) = controller
5100 .recoverable_error_resume()
5101 .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
5102 assert_eq!(position, 600.0);
5103 waits.push(delay);
5104 }
5105 assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
5106 assert!(
5107 controller.recoverable_error_resume().is_none(),
5108 "a stream that keeps failing at the same spot must surface the error"
5109 );
5110 }
5111
5112 #[tokio::test]
5115 async fn test_recoverable_error_ignores_direct_url_playback() {
5116 let controller = PlayerController::default();
5117 controller.play_queue(create_test_items(2), 0).unwrap();
5118
5119 assert!(controller.recoverable_error_resume().is_none());
5120 }
5121
5122 #[tokio::test]
5126 async fn test_resume_truncated_stream_reloads_at_position() {
5127 let controller = PlayerController::default();
5128
5129 controller
5130 .play_queue(vec![audio_only_episode(1500.0)], 0)
5131 .unwrap();
5132 controller.set_background_audio_base(0.0);
5133
5134 controller
5135 .resume_stream_at(600.0)
5136 .await
5137 .expect("resume should succeed");
5138
5139 let current = controller
5140 .queue
5141 .lock_safe()
5142 .current()
5143 .cloned()
5144 .expect("the same item should still be loaded");
5145 assert_eq!(current.id, "ep2", "resume must not change the item");
5146 match ¤t.source {
5147 MediaSource::Remote { stream_url, .. } => {
5148 assert!(
5149 stream_url.contains("StartTimeTicks=6000000000"),
5150 "stream must re-open at 600s, got {}",
5151 stream_url
5152 );
5153 assert!(
5154 stream_url.contains("AudioStreamIndex=2"),
5155 "the selected audio track must survive the resume, got {}",
5156 stream_url
5157 );
5158 }
5159 other => panic!("expected Remote source, got {:?}", other),
5160 }
5161 assert_eq!(
5162 controller.take_background_audio_base(),
5163 600.0,
5164 "the re-opened stream's zero is the resume position"
5165 );
5166 }
5167
5168 #[tokio::test]
5172 async fn test_auto_advance_foreground_video_episode_uses_countdown() {
5173 let controller = PlayerController::default();
5174 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
5175
5176 let episode = MediaItem {
5177 transport: None,
5179 id: "ep2".to_string(),
5180 item_type: Some("Episode".to_string()),
5181 media_type: MediaType::Video,
5182 series_id: Some("series1".to_string()),
5183 source: MediaSource::Remote {
5184 stream_url: "http://example.com/ep2.m3u8".to_string(),
5185 jellyfin_item_id: "ep2".to_string(),
5186 },
5187 ..create_test_items(1).remove(0)
5188 };
5189 controller.play_queue(vec![episode], 0).unwrap();
5190
5191 let next = make_repo_episode("ep3", 3);
5192 controller.auto_advance_to_next_episode(next, 10).await;
5193
5194 let current = controller
5195 .queue
5196 .lock_safe()
5197 .current()
5198 .cloned()
5199 .expect("an item should still be loaded");
5200 assert_eq!(
5201 current.id, "ep2",
5202 "foreground video advance is frontend-driven; the backend must not \
5203 swap the queue item out from under it"
5204 );
5205 }
5206
5207 #[tokio::test]
5210 async fn test_playback_ended_without_repository_stops() {
5211 let controller = PlayerController::default();
5212
5213 let episode = MediaItem {
5214 transport: None,
5216 media_type: MediaType::Video,
5217 source: MediaSource::Remote {
5218 stream_url: "http://example.com/ep1.mkv".to_string(),
5219 jellyfin_item_id: "ep1".to_string(),
5220 },
5221 ..create_test_items(1).remove(0)
5222 };
5223 controller.play_queue(vec![episode], 0).unwrap();
5224 controller.take_end_reason();
5225
5226 let decision = controller.on_playback_ended().await.unwrap();
5227 assert!(matches!(decision, AutoplayDecision::Stop));
5228 }
5229}