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(specta::Type, Debug, Clone, PartialEq, serde::Serialize)]
185#[serde(rename_all = "camelCase")]
186pub struct BackgroundAudioResume {
187 pub item_id: Option<String>,
190 pub position_seconds: f64,
192}
193
194#[derive(Debug, Clone)]
203#[cfg_attr(not(target_os = "android"), allow(dead_code))]
206pub struct LockscreenMetadata {
207 pub title: String,
208 pub artist: String,
209 pub album: Option<String>,
210 pub duration_ms: i64,
212 pub position_ms: i64,
214 pub is_playing: bool,
215}
216
217pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
225 #[cfg(target_os = "android")]
226 {
227 return android::update_lockscreen_metadata(_meta);
228 }
229 #[cfg(not(target_os = "android"))]
230 {
231 Ok(())
232 }
233}
234
235pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
241 #[cfg(target_os = "android")]
242 {
243 return android::set_position_offset(_offset_seconds);
244 }
245 #[cfg(not(target_os = "android"))]
246 {
247 Ok(())
248 }
249}
250
251use crate::utils::lock::MutexSafe;
252use log::{debug, error, info, warn};
253use std::sync::{Arc, Mutex};
254use std::time::Duration;
255use tokio::sync::Mutex as TokioMutex;
256
257use crate::jellyfin::JellyfinClient;
258use crate::playback_reporting::{
259 EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
260};
261use crate::repository::MediaRepository;
262use crate::settings::AudioSettings;
263use crate::utils::conversions::seconds_to_ticks;
264
265pub struct PlayerController {
267 backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
270 queue: Arc<Mutex<QueueManager>>,
271 jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
272 muted: bool,
273
274 sleep_timer: Arc<Mutex<SleepTimerState>>,
276
277 autoplay_settings: Arc<Mutex<AutoplaySettings>>,
279
280 repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
282
283 event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
285
286 countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
288
289 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
291
292 reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
295
296 position_throttler: Arc<EventThrottler>,
299
300 end_reason: Arc<Mutex<Option<EndReason>>>,
302
303 autoplay_episode_count: Arc<Mutex<u32>>,
305
306 background_audio_base: Arc<Mutex<f64>>,
320
321 background_audio_active: Arc<Mutex<bool>>,
331
332 stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
340
341 html5_playing: Arc<Mutex<Option<bool>>>,
351
352 reported_time: Arc<Mutex<stream_end::ObservedTime>>,
364}
365
366impl PlayerController {
367 pub fn new(
368 backend: Box<dyn MediaPlayer>,
369 playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
370 position_throttler: Arc<EventThrottler>,
371 ) -> Self {
372 let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
373 reporter: playback_reporter.clone(),
374 });
375 let controller = Self {
376 backend: Arc::new(Mutex::new(backend)),
377 queue: Arc::new(Mutex::new(QueueManager::new())),
378 jellyfin_client: Arc::new(Mutex::new(None)),
379 muted: false,
380 sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
381 autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
382 repository: Arc::new(Mutex::new(None)),
383 event_emitter: Arc::new(Mutex::new(None)),
384 countdown_cancel: Arc::new(Mutex::new(None)),
385 playback_reporter,
386 reports: Arc::new(Mutex::new(reports)),
387 position_throttler,
388 end_reason: Arc::new(Mutex::new(None)),
389 autoplay_episode_count: Arc::new(Mutex::new(0)),
390 background_audio_base: Arc::new(Mutex::new(0.0)),
391 background_audio_active: Arc::new(Mutex::new(false)),
392 stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
393 html5_playing: Arc::new(Mutex::new(None)),
394 reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
395 };
396
397 controller.start_timer_thread();
399
400 controller
401 }
402
403 pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
405 let mut jellyfin = self.jellyfin_client.lock_safe();
406 *jellyfin = client;
407 log::info!(
408 "[PlayerController] Jellyfin client configured: {}",
409 jellyfin.is_some()
410 );
411 }
412
413 pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
415 self.jellyfin_client.clone()
416 }
417
418 pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
425 *self.repository.lock_safe() = Some(repo);
426 }
427
428 pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
431 let mut reporter_guard = self.playback_reporter.lock().await;
432 *reporter_guard = reporter;
433 log::info!(
434 "[PlayerController] Playback reporter configured: {}",
435 reporter_guard.is_some()
436 );
437 }
438
439 #[allow(dead_code)]
442 pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
443 self.playback_reporter.clone()
444 }
445
446 #[allow(dead_code)]
449 pub fn position_throttler(&self) -> Arc<EventThrottler> {
450 self.position_throttler.clone()
451 }
452
453 fn set_end_reason(&self, reason: EndReason) {
455 log::debug!("[PlayerController] Setting end reason: {:?}", reason);
456 *self.end_reason.lock_safe() = Some(reason);
457 }
458
459 fn take_end_reason(&self) -> Option<EndReason> {
461 self.end_reason.lock_safe().take()
462 }
463
464 fn peek_end_reason(&self) -> Option<EndReason> {
471 *self.end_reason.lock_safe()
472 }
473
474 fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
487 log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
488 *end_reason.lock_safe() = Some(EndReason::UserStop);
489 }
490
491 fn increment_autoplay_count(&self) -> bool {
493 let max = self.autoplay_settings.lock_safe().max_episodes;
494
495 if max == 0 {
496 return false;
498 }
499
500 let mut count = self.autoplay_episode_count.lock_safe();
501 *count += 1;
502 debug!(
503 "[PlayerController] Autoplay episode count: {}/{}",
504 *count, max
505 );
506
507 *count >= max
508 }
509
510 fn reset_autoplay_count(&self) {
512 let mut count = self.autoplay_episode_count.lock_safe();
513 if *count > 0 {
514 debug!(
515 "[PlayerController] Resetting autoplay episode counter (was {})",
516 *count
517 );
518 }
519 *count = 0;
520 }
521
522 pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
524 debug!("[PlayerController] play_item: {}", item.title);
525
526 self.reset_autoplay_count();
528
529 {
531 let mut queue = self.queue.lock_safe();
532 queue.set_queue(vec![item.clone()], 0);
533 }
534
535 self.load_and_play(&item)?;
537
538 Ok(())
539 }
540
541 pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
553 debug!(
554 "[PlayerController] set_current_item (no backend load): {}",
555 item.title
556 );
557
558 self.reset_autoplay_count();
559 self.clear_reported_time();
565
566 let mut queue = self.queue.lock_safe();
567 queue.set_queue(vec![item], 0);
568
569 Ok(())
570 }
571
572 pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
575 debug!("[PlayerController] load_and_play: {}", item.title);
576
577 self.set_end_reason(EndReason::NewTrackLoaded);
579
580 *self.html5_playing.lock_safe() = None;
602
603 let mut backend = self.backend.lock_safe();
604 backend.open(OpenRequest::new(
607 item.clone(),
608 StreamSelection::for_queued_item(
609 item.playback_url(),
610 item.transport,
611 item.needs_transcoding,
612 ),
613 ))?;
614 drop(backend);
615
616 self.clear_reported_time();
619
620 if let Some(jellyfin_id) = item.jellyfin_id() {
622 let context = if item.album_id.is_some() {
624 Some(PlaybackContext {
625 context_type: "container".to_string(),
626 context_id: item.album_id.clone(),
627 })
628 } else {
629 None
630 };
631
632 let position = self.absolute_position();
638
639 log::info!(
640 "[PlayerController] Reporting playback start: {} @ {:.1}s",
641 jellyfin_id,
642 position
643 );
644 self.report(PlaybackOperation::Start {
645 item_id: jellyfin_id.to_string(),
646 position_ticks: seconds_to_ticks(position),
647 context,
648 });
649 }
650
651 Ok(())
652 }
653
654 pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
656 self.play_queue_from(items, start_index, None)
657 }
658
659 pub fn play_queue_from(
666 &self,
667 items: Vec<MediaItem>,
668 start_index: usize,
669 start_position: Option<f64>,
670 ) -> Result<(), PlayerError> {
671 debug!(
672 "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
673 items.len(),
674 start_index,
675 start_position
676 );
677
678 self.reset_autoplay_count();
680
681 {
682 let mut queue = self.queue.lock_safe();
683 queue.set_queue(items, start_index);
684 }
685
686 if let Some(item) = self.queue.lock_safe().current().cloned() {
688 self.load_and_play(&item)?;
689
690 if let Some(position) = start_position {
694 if position > 0.5 {
695 self.seek(position)?;
696 }
697 }
698 }
699
700 Ok(())
701 }
702
703 pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
709 debug!(
710 "[PlayerController] set_queue (no local playback): {} items, index {}",
711 items.len(),
712 start_index
713 );
714 self.reset_autoplay_count();
715 let mut queue = self.queue.lock_safe();
716 queue.set_queue(items, start_index);
717 Ok(())
718 }
719
720 pub fn is_html5_active(&self) -> bool {
725 self.html5_playing.lock_safe().is_some()
726 }
727
728 pub fn html5_is_playing(&self) -> bool {
733 self.html5_playing.lock_safe().unwrap_or(false)
734 }
735
736 fn emit_html5_control(&self, action: &str) {
738 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
739 emitter.emit(PlayerStatusEvent::ControlCommand {
740 action: action.to_string(),
741 position: None,
742 });
743 }
744 }
745
746 pub fn play(&self) -> Result<(), PlayerError> {
748 debug!("[PlayerController] play");
749 if self.is_html5_active() {
752 self.emit_html5_control("play");
753 return Ok(());
754 }
755 let mut backend = self.backend.lock_safe();
756 backend.play()
757 }
758
759 pub fn pause(&self) -> Result<(), PlayerError> {
761 if self.is_html5_active() {
762 self.emit_html5_control("pause");
763 return Ok(());
764 }
765 let mut backend = self.backend.lock_safe();
766 backend.pause()
767 }
768
769 pub fn toggle_playback(&self) -> Result<(), PlayerError> {
777 if self.is_html5_active() {
778 let action = if self.html5_is_playing() {
779 "pause"
780 } else {
781 "play"
782 };
783 self.emit_html5_control(action);
784 return Ok(());
785 }
786 let mut backend = self.backend.lock_safe();
787 if backend.snapshot().phase.is_active() {
788 backend.pause()
789 } else {
790 backend.play()
791 }
792 }
793
794 pub fn stop(&self) -> Result<(), PlayerError> {
796 self.set_end_reason(EndReason::UserStop);
798
799 let jellyfin_id = {
801 let queue = self.queue.lock_safe();
802 queue
803 .current()
804 .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
805 };
806
807 let position = self.absolute_position();
810
811 let mut backend = self.backend.lock_safe();
812 backend.close()?;
813 drop(backend);
814 self.clear_reported_time();
815
816 if self.is_background_audio_active() {
832 debug!("[PlayerController] stop: clearing an active background-audio handoff");
833 }
834 *self.background_audio_active.lock_safe() = false;
835 self.set_background_audio_base(0.0);
836 *self.html5_playing.lock_safe() = None;
837
838 if let Some(jellyfin_id) = jellyfin_id {
839 self.report_stopped_at(jellyfin_id, position);
840 }
841
842 Ok(())
843 }
844
845 fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
859 if position <= 0.0 {
860 debug!(
861 "[PlayerController] Withholding zero-position stop report for {} \
862 (nothing played; reporting it would clear the resume point)",
863 jellyfin_id
864 );
865 return;
866 }
867
868 log::info!(
869 "[PlayerController] Reporting playback stopped: {} @ {:.1}s",
870 jellyfin_id,
871 position
872 );
873 self.report(PlaybackOperation::Stopped {
874 item_id: jellyfin_id,
875 position_ticks: seconds_to_ticks(position),
876 });
877 }
878
879 pub fn next(&self) -> Result<(), PlayerError> {
884 self.reset_autoplay_count();
886
887 let next_item = {
888 let mut queue = self.queue.lock_safe();
889 queue.next().cloned()
890 };
891
892 debug!(
893 "[PlayerController] next: {:?}",
894 next_item.as_ref().map(|i| &i.title)
895 );
896
897 if let Some(item) = next_item {
898 self.load_and_play(&item)
899 } else {
900 debug!("[PlayerController] No next item, stopping");
901 self.stop()
902 }
903 }
904
905 pub fn previous(&self) -> Result<(), PlayerError> {
910 self.reset_autoplay_count();
912 {
914 let backend = self.backend.lock_safe();
915 if backend.snapshot().position.as_secs_f64() > 3.0 {
916 debug!("[PlayerController] previous: restarting current track (position > 3s)");
917 drop(backend);
918 return self.seek(0.0);
919 }
920 }
921
922 let prev_item = {
923 let mut queue = self.queue.lock_safe();
924 queue.previous().cloned()
925 };
926
927 debug!(
928 "[PlayerController] previous: {:?}",
929 prev_item.as_ref().map(|i| &i.title)
930 );
931
932 if let Some(item) = prev_item {
933 self.load_and_play(&item)
934 } else {
935 self.seek(0.0)
936 }
937 }
938
939 pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
946 let mut backend = self.backend.lock_safe();
947 backend.seek(Duration::from_secs_f64(position.max(0.0)))
948 }
949
950 pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
968 let rebuild = self.is_background_audio_active() && {
973 let queue = self.queue.lock_safe();
974 queue
975 .current()
976 .map(|item| {
977 Self::is_audio_only_video(item)
978 && matches!(item.source, MediaSource::Remote { .. })
979 })
980 .unwrap_or(false)
981 };
982
983 if rebuild {
984 return self.resume_stream_at(position.max(0.0)).await;
985 }
986
987 self.seek(position).map_err(|e| e.to_string())
988 }
989
990 pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
992 self.backend.lock_safe().set_volume(volume)
993 }
994
995 pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
997 let mut backend = self.backend.lock_safe();
998 backend.select_audio_track(Some(stream_index))
999 }
1000
1001 pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
1003 let mut backend = self.backend.lock_safe();
1004 backend.select_subtitle_track(stream_index)
1005 }
1006
1007 pub fn state(&self) -> PlayerState {
1009 let phase = self.backend.lock_safe().snapshot().phase;
1010 let media = self.queue.lock_safe().current().cloned();
1011 match (phase, media) {
1012 (Phase::Playing, Some(media)) => PlayerState::Playing {
1013 media,
1014 position: self.position(),
1015 duration: self.duration().unwrap_or(0.0),
1016 },
1017 (Phase::Paused, Some(media)) => PlayerState::Paused {
1018 media,
1019 position: self.position(),
1020 duration: self.duration().unwrap_or(0.0),
1021 },
1022 (Phase::Opening, Some(media)) => PlayerState::Loading { media },
1023 (Phase::Failed(error), media) => PlayerState::Error { media, error },
1024 _ => PlayerState::Idle,
1027 }
1028 }
1029
1030 pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
1034 self.backend.lock_safe().capabilities()
1035 }
1036
1037 pub fn position(&self) -> f64 {
1039 self.backend.lock_safe().snapshot().position.as_secs_f64()
1040 }
1041
1042 pub fn absolute_position(&self) -> f64 {
1065 let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
1066 let reported = self.reported_time.lock_safe().last_position();
1067 let base = if self.is_background_audio_active() {
1068 *self.background_audio_base.lock_safe()
1069 } else {
1070 0.0
1071 };
1072
1073 native.max(reported).max(base)
1074 }
1075
1076 pub fn observed_duration(&self) -> Option<f64> {
1080 self.reported_time.lock_safe().last_duration()
1081 }
1082
1083 fn clear_reported_time(&self) {
1091 self.reported_time.lock_safe().reset();
1092 }
1093
1094 #[cfg_attr(not(test), allow(dead_code))]
1099 pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
1100 *self.reports.lock_safe() = sink;
1101 }
1102
1103 fn report(&self, operation: PlaybackOperation) {
1108 let sink = self.reports.lock_safe().clone();
1109 sink.send(operation);
1110 }
1111
1112 fn current_jellyfin_id(&self) -> Option<String> {
1114 let queue = self.queue.lock_safe();
1115 queue
1116 .current()
1117 .and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
1118 }
1119
1120 pub fn duration(&self) -> Option<f64> {
1129 let usable = |d: f64| (d > 0.0).then_some(d);
1142
1143 self.backend
1144 .lock_safe()
1145 .snapshot()
1146 .duration
1147 .map(|d| d.as_secs_f64())
1148 .and_then(usable)
1149 .or_else(|| self.observed_duration().and_then(usable))
1150 .or_else(|| {
1151 self.queue
1152 .lock_safe()
1153 .current()
1154 .and_then(|item| item.duration)
1155 .and_then(usable)
1156 })
1157 }
1158
1159 pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
1161 self.queue.clone()
1162 }
1163
1164 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
1172 pub fn current_is_audio_episode(&self) -> bool {
1173 self.queue
1174 .lock_safe()
1175 .current()
1176 .map(|item| {
1177 item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
1178 })
1179 .unwrap_or(false)
1180 }
1181
1182 pub fn clear_queue(&self) {
1186 self.queue.lock_safe().clear();
1187 }
1188
1189 pub fn toggle_shuffle(&self) {
1191 self.queue.lock_safe().toggle_shuffle();
1192 }
1193
1194 pub fn cycle_repeat(&self) {
1196 self.queue.lock_safe().cycle_repeat();
1197 }
1198
1199 pub fn is_shuffle(&self) -> bool {
1201 self.queue.lock_safe().is_shuffle()
1202 }
1203
1204 pub fn repeat_mode(&self) -> RepeatMode {
1206 self.queue.lock_safe().repeat_mode()
1207 }
1208
1209 pub fn volume(&self) -> f32 {
1211 self.backend.lock_safe().snapshot().volume
1212 }
1213
1214 pub fn muted(&self) -> bool {
1216 self.muted
1217 }
1218
1219 pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
1221 self.backend.lock_safe().set_audio_settings(settings)
1222 }
1223
1224 pub fn audio_settings(&self) -> AudioSettings {
1226 self.backend.lock_safe().audio_settings()
1227 }
1228
1229 pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
1233 let mut event_emitter = self.event_emitter.lock_safe();
1234 *event_emitter = Some(emitter);
1235 }
1236
1237 pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
1239 self.event_emitter.lock_safe().clone()
1240 }
1241
1242 pub fn sleep_timer_state(&self) -> SleepTimerState {
1244 self.sleep_timer.lock_safe().clone()
1245 }
1246
1247 pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
1249 let mut timer = self.sleep_timer.lock_safe();
1250 timer.mode = mode.clone();
1251 if let SleepTimerMode::Time { end_time } = mode {
1252 let now = chrono::Utc::now().timestamp_millis();
1253 timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
1254 } else {
1255 timer.remaining_seconds = 0;
1256 }
1257 drop(timer);
1258
1259 self.emit_sleep_timer_changed();
1261 }
1262
1263 pub fn cancel_sleep_timer(&self) {
1265 self.set_sleep_timer(SleepTimerMode::Off);
1266 }
1267
1268 fn start_timer_thread(&self) {
1270 let sleep_timer = self.sleep_timer.clone();
1271 let event_emitter = self.event_emitter.clone();
1272 let backend = self.backend.clone();
1273 let end_reason = self.end_reason.clone();
1274
1275 std::thread::spawn(move || {
1276 loop {
1277 std::thread::sleep(Duration::from_secs(1));
1278
1279 let mut timer = sleep_timer.lock_safe();
1280 if timer.is_active() {
1281 timer.update_remaining_seconds();
1282
1283 if matches!(timer.mode, SleepTimerMode::Time { .. })
1285 && timer.remaining_seconds == 0
1286 {
1287 debug!("[SleepTimer] Time-based timer expired, stopping playback");
1288 timer.cancel();
1289
1290 Self::note_sleep_timer_stop(&end_reason);
1297
1298 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1300 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1301 mode: SleepTimerMode::Off,
1302 remaining_seconds: 0,
1303 });
1304 emitter.emit(PlayerStatusEvent::SleepTimerExpired);
1308 }
1309 drop(timer);
1310
1311 if let Err(e) = backend.lock_safe().close() {
1313 error!("[SleepTimer] Failed to stop playback: {}", e);
1314 }
1315 continue;
1316 }
1317
1318 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
1320 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1321 mode: timer.mode.clone(),
1322 remaining_seconds: timer.remaining_seconds,
1323 });
1324 }
1325 }
1326 drop(timer);
1327 }
1328 });
1329 }
1330
1331 fn emit_sleep_timer_changed(&self) {
1333 let timer = self.sleep_timer.lock_safe().clone();
1334
1335 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1336 emitter.emit(PlayerStatusEvent::SleepTimerChanged {
1337 mode: timer.mode,
1338 remaining_seconds: timer.remaining_seconds,
1339 });
1340 }
1341 }
1342
1343 pub fn emit_queue_changed(&self) {
1345 let queue = self.queue.lock_safe();
1346
1347 debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
1348 queue.items().len(), queue.current_index());
1349
1350 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1351 emitter.emit(PlayerStatusEvent::QueueChanged {
1352 items: queue.items().to_vec(),
1353 current_index: queue.current_index(),
1354 shuffle: queue.is_shuffle(),
1355 repeat: queue.repeat_mode(),
1356 has_next: queue.has_next(),
1357 has_previous: queue.has_previous(),
1358 });
1359 } else {
1360 warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
1361 }
1362 }
1363
1364 pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
1379 if self.is_background_audio_active() {
1384 debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
1385 return;
1386 }
1387 let element_gone = {
1393 let mut tracked = self.html5_playing.lock_safe();
1394 *tracked = match state.as_str() {
1395 "playing" => Some(true),
1396 "paused" | "loading" => Some(false),
1400 _ => None,
1402 };
1403 tracked.is_none()
1404 };
1405 if element_gone {
1408 self.clear_reported_time();
1409 }
1410 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1411 emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
1412 }
1413 }
1414
1415 pub fn report_html5_position(&self, position: f64, duration: f64) {
1420 if self.is_background_audio_active() {
1423 return;
1424 }
1425 self.reported_time.lock_safe().record(position, duration);
1428
1429 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1430 emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
1431 }
1432
1433 self.report_progress_throttled(position);
1434 }
1435
1436 fn report_progress_throttled(&self, position: f64) {
1451 if position <= 0.0 {
1452 return;
1453 }
1454 let Some(item_id) = self.current_jellyfin_id() else {
1455 return;
1456 };
1457 if !self.position_throttler.should_report(&item_id) {
1458 return;
1459 }
1460
1461 self.report(PlaybackOperation::Progress {
1462 item_id: item_id.clone(),
1463 position_ticks: seconds_to_ticks(position),
1464 is_paused: false,
1467 });
1468 self.position_throttler.mark_reported(&item_id);
1469 }
1470
1471 pub fn report_html5_media_loaded(&self, duration: f64) {
1474 if self.is_background_audio_active() {
1476 return;
1477 }
1478 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
1479 emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
1480 }
1481 }
1482
1483 pub fn autoplay_settings(&self) -> AutoplaySettings {
1487 self.autoplay_settings.lock_safe().clone()
1488 }
1489
1490 pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
1492 let validated = settings.with_validated_countdown();
1493 *self.autoplay_settings.lock_safe() = validated;
1494 }
1495
1496 pub fn cancel_autoplay_countdown(&self) {
1498 if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
1499 *cancel_flag.lock_safe() = true;
1500 }
1501 }
1502
1503 pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
1508 if let Some(position) = self.truncated_stream_resume_position() {
1514 return Ok(AutoplayDecision::ResumeStream { position });
1515 }
1516
1517 let end_reason = self.take_end_reason();
1519
1520 debug!(
1521 "[PlayerController] on_playback_ended: end_reason={:?}",
1522 end_reason
1523 );
1524
1525 match end_reason {
1527 None | Some(EndReason::Finished) => {
1528 debug!("[PlayerController] Track finished naturally, checking autoplay");
1530 }
1531 Some(EndReason::NewTrackLoaded) => {
1532 debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
1534 return Ok(AutoplayDecision::Stop);
1535 }
1536 Some(EndReason::UserStop) => {
1537 debug!("[PlayerController] UserStop - stopping without autoplay");
1539 return Ok(AutoplayDecision::Stop);
1540 }
1541 Some(EndReason::UserSkip) => {
1542 debug!("[PlayerController] UserSkip - stopping without autoplay");
1544 return Ok(AutoplayDecision::Stop);
1545 }
1546 Some(EndReason::Error) => {
1547 debug!("[PlayerController] Error - stopping without autoplay");
1549 return Ok(AutoplayDecision::Stop);
1550 }
1551 }
1552
1553 let current_item = {
1554 let queue = self.queue.lock_safe();
1555 queue.current().cloned()
1556 };
1557
1558 let Some(current) = current_item else {
1559 return Ok(AutoplayDecision::Stop);
1560 };
1561
1562 self.report_completion(¤t);
1566
1567 let timer_mode = {
1569 let timer = self.sleep_timer.lock_safe();
1570 timer.mode.clone()
1571 };
1572
1573 match &timer_mode {
1574 SleepTimerMode::Time { end_time } => {
1575 let now = chrono::Utc::now().timestamp_millis();
1577 if now >= *end_time {
1578 debug!("[PlayerController] Time-based sleep timer expired at track boundary");
1579 self.sleep_timer.lock_safe().cancel();
1580 self.emit_sleep_timer_changed();
1581 return Ok(AutoplayDecision::Stop);
1582 }
1583 }
1584 SleepTimerMode::EndOfTrack => {
1585 self.sleep_timer.lock_safe().cancel();
1587 self.emit_sleep_timer_changed();
1588 return Ok(AutoplayDecision::Stop);
1589 }
1590 SleepTimerMode::Episodes { .. } => {
1591 let is_episode = self.is_episode_item(¤t).await;
1596
1597 if is_episode {
1598 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
1599 self.emit_sleep_timer_changed();
1600
1601 if should_stop {
1602 return Ok(AutoplayDecision::Stop);
1603 }
1604 }
1605 }
1606 _ => {
1607 }
1609 }
1610
1611 if self.is_episode_item(¤t).await {
1617 let repo = self.repository.lock_safe().clone();
1618 let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
1619 let next_ep_result = if let Some(repo) = &repo {
1620 match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
1623 Ok(next) => next,
1624 Err(e) => {
1625 warn!(
1626 "[PlayerController] Next-episode lookup failed for {}: {}",
1627 jellyfin_id, e
1628 );
1629 None
1630 }
1631 }
1632 } else {
1633 warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
1634 None
1635 };
1636 if let Some(next_ep) = next_ep_result {
1637 let settings = self.autoplay_settings.lock_safe().clone();
1638
1639 let limit_reached = self.increment_autoplay_count();
1641 if limit_reached {
1642 debug!(
1643 "[PlayerController] Auto-play episode limit reached ({} episodes)",
1644 settings.max_episodes
1645 );
1646 }
1647
1648 return Ok(AutoplayDecision::ShowNextEpisodePopup {
1649 current_episode: next_ep.0, next_episode: next_ep.1,
1651 countdown_seconds: settings.countdown_seconds,
1652 auto_advance: settings.enabled && !limit_reached,
1653 });
1654 }
1655 return Ok(AutoplayDecision::Stop);
1657 }
1658
1659 let has_next = {
1661 let queue = self.queue.lock_safe();
1662 queue.has_next()
1663 };
1664
1665 if has_next {
1666 Ok(AutoplayDecision::AdvanceToNext)
1668 } else {
1669 Ok(AutoplayDecision::Stop)
1671 }
1672 }
1673
1674 fn report_completion(&self, item: &MediaItem) {
1695 if !Self::is_audio_only_video(item) {
1696 return;
1697 }
1698 let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
1699 return;
1700 };
1701
1702 let position = completion_report_position(item.duration, self.absolute_position());
1703 log::info!(
1704 "[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
1705 jellyfin_id,
1706 position
1707 );
1708 self.report_stopped_at(jellyfin_id, position);
1709 }
1710
1711 pub fn set_background_audio_base(&self, seconds: f64) {
1716 *self.background_audio_base.lock_safe() = seconds.max(0.0);
1717 }
1718
1719 pub fn enter_background_audio(&self, position: f64) {
1730 self.set_background_audio_base(position);
1731 *self.background_audio_active.lock_safe() = true;
1732 *self.html5_playing.lock_safe() = None;
1733 self.clear_reported_time();
1736 }
1737
1738 pub fn background_audio_resume(&self) -> BackgroundAudioResume {
1756 BackgroundAudioResume {
1757 item_id: self.queue.lock_safe().current().map(|item| item.id.clone()),
1758 position_seconds: self.absolute_position(),
1759 }
1760 }
1761
1762 pub fn exit_background_audio(&self) -> f64 {
1763 *self.background_audio_active.lock_safe() = false;
1764 self.take_background_audio_base()
1765 }
1766
1767 pub fn is_background_audio_active(&self) -> bool {
1772 *self.background_audio_active.lock_safe()
1773 }
1774
1775 pub fn take_background_audio_base(&self) -> f64 {
1779 let mut base = self.background_audio_base.lock_safe();
1780 std::mem::replace(&mut *base, 0.0)
1781 }
1782
1783 pub async fn auto_advance_to_next_episode(
1794 &self,
1795 next_episode: crate::repository::types::MediaItem,
1796 countdown_seconds: u32,
1797 ) {
1798 if self.current_is_audio_episode() {
1803 info!(
1804 "[PlayerController] Background audio episode — advancing to {} in backend",
1805 next_episode.id
1806 );
1807 match self
1808 .advance_to_next_episode_audio_only(&next_episode.id)
1809 .await
1810 {
1811 Ok(()) => self.emit_queue_changed(),
1812 Err(e) => {
1813 error!(
1814 "[PlayerController] Background audio advance failed: {} — stopping",
1815 e
1816 );
1817 if let Some(emitter) = self.event_emitter() {
1818 emitter.emit(PlayerStatusEvent::PlaybackEnded);
1819 }
1820 }
1821 }
1822 return;
1823 }
1824
1825 self.start_autoplay_countdown(next_episode, countdown_seconds);
1827 }
1828
1829 fn is_audio_only_video(item: &MediaItem) -> bool {
1833 stream_end::is_audio_only_video(item)
1834 }
1835
1836 fn claim_stream_resume(&self) -> Option<(f64, u32)> {
1851 let current = {
1852 let queue = self.queue.lock_safe();
1853 queue.current().cloned()
1854 }?;
1855 if !matches!(current.source, MediaSource::Remote { .. }) {
1856 return None;
1857 }
1858
1859 let absolute = self.absolute_position();
1864
1865 match self.stream_resume.lock_safe().allow_attempt(absolute) {
1866 Some(attempt) => Some((absolute, attempt)),
1867 None => {
1868 warn!(
1869 "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
1870 current.id, absolute
1871 );
1872 None
1873 }
1874 }
1875 }
1876
1877 fn truncated_stream_resume_position(&self) -> Option<f64> {
1884 if matches!(
1886 self.peek_end_reason(),
1887 Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
1888 ) {
1889 return None;
1890 }
1891
1892 let item_duration = {
1896 let queue = self.queue.lock_safe();
1897 let current = queue.current()?;
1898 if !Self::is_audio_only_video(current) {
1899 return None;
1900 }
1901 current.duration
1902 };
1903 let absolute = self.position().max(0.0);
1905
1906 if !stream_end::is_truncated_end(
1909 absolute,
1910 item_duration,
1911 stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
1912 ) {
1913 return None;
1914 }
1915
1916 self.claim_stream_resume().map(|(position, _)| position)
1917 }
1918
1919 pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
1941 self.claim_stream_resume()
1942 .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
1943 }
1944
1945 pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
1972 let current = {
1973 let queue = self.queue.lock_safe();
1974 queue.current().cloned()
1975 }
1976 .ok_or_else(|| "No current item to resume".to_string())?;
1977
1978 let MediaSource::Remote { stream_url, .. } = ¤t.source else {
1979 return Err(format!(
1980 "Cannot resume a non-remote source for {}",
1981 current.id
1982 ));
1983 };
1984
1985 info!(
1986 "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
1987 current.id, position
1988 );
1989
1990 if !Self::is_audio_only_video(¤t) {
1991 self.load_and_play(¤t).map_err(|e| e.to_string())?;
1992 if position > 0.5 {
1993 self.seek(position).map_err(|e| e.to_string())?;
1994 }
1995 return Ok(());
1996 }
1997
1998 let restarted_url = stream_end::with_start_time(stream_url, position);
1999 {
2000 let queue_arc = self.queue.clone();
2001 let mut queue = queue_arc.lock_safe();
2002 if !queue.update_current_stream_url(restarted_url) {
2003 return Err(format!("Failed to update stream URL for {}", current.id));
2004 }
2005 }
2006 let resumed = {
2007 let queue = self.queue.lock_safe();
2008 queue.current().cloned()
2009 }
2010 .ok_or_else(|| "Current item vanished mid-resume".to_string())?;
2011
2012 self.set_background_audio_base(position);
2016 let _ = set_lockscreen_position_offset(position.max(0.0));
2017
2018 self.load_and_play(&resumed).map_err(|e| e.to_string())
2019 }
2020
2021 pub async fn advance_to_next_episode_audio_only(
2036 &self,
2037 next_episode_id: &str,
2038 ) -> Result<(), String> {
2039 crate::repository::online::clear_playback_quality_override();
2047
2048 let repo = self
2049 .repository
2050 .lock_safe()
2051 .clone()
2052 .ok_or_else(|| "No repository for background episode advance".to_string())?;
2053
2054 let next = repo
2056 .get_item(next_episode_id)
2057 .await
2058 .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
2059
2060 let stream_url = repo
2063 .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
2064 .await
2065 .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
2066
2067 let media_item = MediaItem {
2068 transport: None,
2070 id: next.id.clone(),
2071 title: next.name.clone(),
2072 name: Some(next.name.clone()),
2073 artist: next.series_name.clone(),
2074 album: None,
2075 album_name: None,
2076 album_id: None,
2077 artist_items: None,
2078 artists: None,
2079 primary_image_tag: next.primary_image_tag.clone(),
2080 image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
2081 item_type: Some("Episode".to_string()),
2083 playlist_id: None,
2084 duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
2085 artwork_url: None,
2086 media_type: MediaType::Audio,
2087 source: MediaSource::Remote {
2088 stream_url,
2089 jellyfin_item_id: next.id.clone(),
2090 },
2091 video_codec: None,
2092 needs_transcoding: false,
2093 video_width: None,
2094 video_height: None,
2095 subtitles: vec![],
2096 series_id: next.series_id.clone(),
2097 server_id: Some(next.server_id.clone()),
2098 };
2099
2100 self.set_background_audio_base(0.0);
2105 let _ = set_lockscreen_position_offset(0.0);
2106 self.stream_resume.lock_safe().reset();
2109
2110 self.play_item(media_item).map_err(|e| e.to_string())
2111 }
2112
2113 pub async fn on_video_playback_ended(
2120 &self,
2121 item_id: &str,
2122 repo: Arc<dyn crate::repository::MediaRepository>,
2123 ) -> Result<AutoplayDecision, String> {
2124 let stale_reason = self.take_end_reason();
2126 if stale_reason.is_some() {
2127 debug!(
2128 "[PlayerController] Cleared stale end_reason for video: {:?}",
2129 stale_reason
2130 );
2131 }
2132
2133 log::info!(
2134 "[PlayerController] on_video_playback_ended: item_id={}",
2135 item_id
2136 );
2137
2138 let timer_mode = {
2140 let timer = self.sleep_timer.lock_safe();
2141 timer.mode.clone()
2142 };
2143
2144 match &timer_mode {
2145 SleepTimerMode::Time { end_time } => {
2146 let now = chrono::Utc::now().timestamp_millis();
2147 if now >= *end_time {
2148 debug!("[PlayerController] Time-based sleep timer expired at video end");
2149 self.sleep_timer.lock_safe().cancel();
2150 self.emit_sleep_timer_changed();
2151 return Ok(AutoplayDecision::Stop);
2152 }
2153 }
2154 SleepTimerMode::EndOfTrack => {
2155 self.sleep_timer.lock_safe().cancel();
2156 self.emit_sleep_timer_changed();
2157 return Ok(AutoplayDecision::Stop);
2158 }
2159 SleepTimerMode::Episodes { .. } => {
2160 let should_stop = self.sleep_timer.lock_safe().decrement_episode();
2161 self.emit_sleep_timer_changed();
2162 if should_stop {
2163 return Ok(AutoplayDecision::Stop);
2164 }
2165 }
2166 _ => {}
2167 }
2168
2169 let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
2173 Ok(next) => next,
2174 Err(e) => {
2175 warn!(
2176 "[PlayerController] Next-episode lookup failed for {}: {}",
2177 item_id, e
2178 );
2179 None
2180 }
2181 };
2182 if let Some(next_ep) = next_ep_result {
2183 let settings = self.autoplay_settings.lock_safe().clone();
2184
2185 let limit_reached = self.increment_autoplay_count();
2186 if limit_reached {
2187 debug!(
2188 "[PlayerController] Auto-play episode limit reached ({} episodes)",
2189 settings.max_episodes
2190 );
2191 }
2192
2193 return Ok(AutoplayDecision::ShowNextEpisodePopup {
2194 current_episode: next_ep.0,
2195 next_episode: next_ep.1,
2196 countdown_seconds: settings.countdown_seconds,
2197 auto_advance: settings.enabled && !limit_reached,
2198 });
2199 }
2200
2201 debug!("[PlayerController] No next episode found for {}", item_id);
2203 Ok(AutoplayDecision::Stop)
2204 }
2205
2206 async fn is_episode_item(&self, item: &MediaItem) -> bool {
2214 match item.item_type.as_deref() {
2215 Some("Episode") => true,
2216 Some(_) => item.media_type == MediaType::Video,
2217 None => item.media_type == MediaType::Video,
2218 }
2219 }
2220
2221 async fn fetch_next_episode_for_item(
2228 &self,
2229 item_id: &str,
2230 repo: &Arc<dyn crate::repository::MediaRepository>,
2231 ) -> Result<
2232 Option<(
2233 crate::repository::types::MediaItem,
2234 crate::repository::types::MediaItem,
2235 )>,
2236 String,
2237 > {
2238 use crate::repository::types::GetItemsOptions;
2239
2240 let current_repo_item = repo
2242 .get_item(item_id)
2243 .await
2244 .map_err(|e| format!("Failed to get current item: {}", e))?;
2245
2246 let season_id = match ¤t_repo_item.season_id {
2248 Some(sid) => sid.clone(),
2249 None => {
2250 log::info!(
2251 "[PlayerController] Current item has no season_id, cannot find next episode"
2252 );
2253 return Ok(None);
2254 }
2255 };
2256
2257 let options = GetItemsOptions {
2259 sort_by: Some("IndexNumber".to_string()),
2260 sort_order: Some("Ascending".to_string()),
2261 limit: Some(500),
2262 include_item_types: Some(vec!["Episode".to_string()]),
2263 ..Default::default()
2264 };
2265
2266 let result = repo
2267 .get_items(&season_id, Some(options))
2268 .await
2269 .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
2270
2271 let mut episodes = result.items;
2274 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2275 log::info!(
2276 "[PlayerController] Season has {} episodes, looking for next after {}",
2277 episodes.len(),
2278 current_repo_item.id
2279 );
2280
2281 if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
2283 if current_idx + 1 < episodes.len() {
2284 let next = &episodes[current_idx + 1];
2285 log::info!(
2286 "[PlayerController] Found next episode: {} (index {})",
2287 next.name,
2288 current_idx + 1
2289 );
2290 return Ok(Some((current_repo_item, next.clone())));
2291 } else {
2292 log::info!("[PlayerController] Current episode is the last in the season");
2293 if let Some(next) = self
2294 .first_episode_of_next_season(¤t_repo_item, repo)
2295 .await
2296 {
2297 return Ok(Some((current_repo_item, next)));
2298 }
2299 }
2300 } else {
2301 log::info!(
2302 "[PlayerController] Current episode not found in season episodes (ids: {:?})",
2303 episodes
2304 .iter()
2305 .map(|e| e.id.as_str())
2306 .take(20)
2307 .collect::<Vec<_>>()
2308 );
2309 }
2310
2311 Ok(None)
2312 }
2313
2314 async fn first_episode_of_next_season(
2327 &self,
2328 current: &crate::repository::types::MediaItem,
2329 repo: &Arc<dyn crate::repository::MediaRepository>,
2330 ) -> Option<crate::repository::types::MediaItem> {
2331 use crate::repository::types::GetItemsOptions;
2332
2333 let series_id = current.series_id.as_deref()?;
2334 let season_id = current.season_id.as_deref()?;
2335
2336 let season_options = GetItemsOptions {
2337 sort_by: Some("IndexNumber".to_string()),
2338 sort_order: Some("Ascending".to_string()),
2339 limit: Some(500),
2340 include_item_types: Some(vec!["Season".to_string()]),
2341 ..Default::default()
2342 };
2343 let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
2344 Ok(result) => result.items,
2345 Err(e) => {
2346 log::warn!(
2347 "[PlayerController] Season lookup failed for series {}: {}",
2348 series_id,
2349 e
2350 );
2351 return None;
2352 }
2353 };
2354 seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
2357
2358 let current_idx = seasons.iter().position(|s| s.id == season_id)?;
2359
2360 for season in &seasons[current_idx + 1..] {
2361 if season.index_number == Some(0) {
2366 continue;
2367 }
2368
2369 let episode_options = GetItemsOptions {
2370 sort_by: Some("IndexNumber".to_string()),
2371 sort_order: Some("Ascending".to_string()),
2372 limit: Some(500),
2373 include_item_types: Some(vec!["Episode".to_string()]),
2374 ..Default::default()
2375 };
2376 let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
2377 Ok(result) => result.items,
2378 Err(e) => {
2379 log::warn!(
2380 "[PlayerController] Episode lookup failed for season {}: {}",
2381 season.id,
2382 e
2383 );
2384 return None;
2385 }
2386 };
2387 episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2388
2389 if let Some(first) = episodes.into_iter().next() {
2391 log::info!(
2392 "[PlayerController] Rolling over to {} of {}: {}",
2393 first.name,
2394 season.name,
2395 first.id
2396 );
2397 return Some(first);
2398 }
2399 }
2400
2401 log::info!("[PlayerController] No further season to roll over into");
2402 None
2403 }
2404
2405 pub fn start_autoplay_countdown(
2407 &self,
2408 _next_item: crate::repository::types::MediaItem,
2409 countdown_seconds: u32,
2410 ) {
2411 let cancel_flag = Arc::new(Mutex::new(false));
2413 *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
2414
2415 let event_emitter = self.event_emitter.clone();
2416
2417 std::thread::spawn(move || {
2418 let mut remaining = countdown_seconds;
2419
2420 while remaining > 0 {
2421 std::thread::sleep(Duration::from_secs(1));
2422
2423 if *cancel_flag.lock_safe() {
2425 log::info!("[PlayerController] Autoplay countdown cancelled");
2426 return;
2427 }
2428
2429 remaining -= 1;
2430
2431 if let Some(emitter) = event_emitter.lock_safe().as_ref() {
2433 emitter.emit(PlayerStatusEvent::CountdownTick {
2434 remaining_seconds: remaining,
2435 });
2436 }
2437 }
2438
2439 log::info!("[PlayerController] Autoplay countdown finished");
2441 });
2442 }
2443}
2444
2445impl Default for PlayerController {
2446 fn default() -> Self {
2447 let playback_reporter = Arc::new(TokioMutex::new(None));
2448 let position_throttler = Arc::new(EventThrottler::new());
2449 Self::new(
2450 Box::new(LegacyPlayer::new(
2451 NullBackend::new(),
2452 crate::player::media_player::Capabilities::mpv(),
2453 )),
2454 playback_reporter,
2455 position_throttler,
2456 )
2457 }
2458}
2459
2460#[cfg(test)]
2461mod tests {
2462
2463 #[test]
2479 fn test_background_episode_advance_clears_the_quality_override() {
2480 let src = include_str!("mod.rs");
2481 let start = src
2482 .find("fn advance_to_next_episode_audio_only")
2483 .expect("advance_to_next_episode_audio_only not found");
2484 let rest = &src[start..];
2485 let end = rest.find("\n pub ").unwrap_or(rest.len());
2486 let body = &rest[..end];
2487
2488 assert!(
2489 body.contains("clear_playback_quality_override"),
2490 "the background episode advance does not clear the per-playback \
2491 quality override, so a ceiling chosen for one episode silently \
2492 caps every episode after it"
2493 );
2494 }
2495
2496 #[test]
2507 fn test_stop_clears_an_active_background_audio_handoff() {
2508 let controller = PlayerController::default();
2509 let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2510 {
2511 let queue_arc = controller.queue();
2512 let mut queue = queue_arc.lock_safe();
2513 queue.set_queue(vec![item], 0);
2514 }
2515
2516 controller.enter_background_audio(557.5);
2517 assert!(
2518 controller.is_background_audio_active(),
2519 "precondition: the handoff is active"
2520 );
2521
2522 controller.stop().expect("stop failed");
2523
2524 assert!(
2525 !controller.is_background_audio_active(),
2526 "a stop must not leave a handoff behind for the next position read"
2527 );
2528 assert_eq!(
2529 *controller.background_audio_base.lock_safe(),
2530 0.0,
2531 "the handoff base must be cleared with it"
2532 );
2533 }
2534
2535 #[test]
2546 fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
2547 let controller = PlayerController::default();
2548 let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2549 item.duration = Some(1800.0);
2550
2551 {
2552 let queue_arc = controller.queue();
2553 let mut queue = queue_arc.lock_safe();
2554 queue.set_queue(vec![item], 0);
2555 }
2556
2557 assert_eq!(
2558 controller.duration(),
2559 Some(1800.0),
2560 "an engine that cannot report a duration should not erase the one the item carries"
2561 );
2562 }
2563 use super::*;
2564
2565 struct CapturingEmitter {
2568 events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
2569 }
2570
2571 impl CapturingEmitter {
2572 fn new() -> Self {
2573 Self {
2574 events: std::sync::Mutex::new(Vec::new()),
2575 }
2576 }
2577 fn events(&self) -> Vec<PlayerStatusEvent> {
2578 self.events.lock_safe().clone()
2579 }
2580 }
2581
2582 impl PlayerEventEmitter for CapturingEmitter {
2583 fn emit(&self, event: PlayerStatusEvent) {
2584 self.events.lock_safe().push(event);
2585 }
2586 }
2587
2588 struct CapturingReports {
2591 operations: std::sync::Mutex<Vec<PlaybackOperation>>,
2592 }
2593
2594 impl CapturingReports {
2595 fn new() -> Self {
2596 Self {
2597 operations: std::sync::Mutex::new(Vec::new()),
2598 }
2599 }
2600
2601 fn stops(&self) -> Vec<(String, f64)> {
2603 self.operations
2604 .lock_safe()
2605 .iter()
2606 .filter_map(|op| match op {
2607 PlaybackOperation::Stopped {
2608 item_id,
2609 position_ticks,
2610 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2611 _ => None,
2612 })
2613 .collect()
2614 }
2615
2616 fn progress(&self) -> Vec<(String, f64)> {
2618 self.operations
2619 .lock_safe()
2620 .iter()
2621 .filter_map(|op| match op {
2622 PlaybackOperation::Progress {
2623 item_id,
2624 position_ticks,
2625 ..
2626 } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2627 _ => None,
2628 })
2629 .collect()
2630 }
2631 }
2632
2633 impl PlaybackReportSink for CapturingReports {
2634 fn send(&self, operation: PlaybackOperation) {
2635 self.operations.lock_safe().push(operation);
2636 }
2637 }
2638
2639 #[test]
2640 fn test_report_html5_state_emits_state_changed() {
2641 let controller = PlayerController::default();
2642 let emitter = Arc::new(CapturingEmitter::new());
2643 controller.set_event_emitter(emitter.clone());
2644
2645 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2646
2647 let events = emitter.events();
2648 assert_eq!(events.len(), 1);
2649 match &events[0] {
2650 PlayerStatusEvent::StateChanged { state, media_id } => {
2651 assert_eq!(state, "playing");
2652 assert_eq!(media_id.as_deref(), Some("item-1"));
2653 }
2654 other => panic!("expected StateChanged, got {:?}", other),
2655 }
2656 }
2657
2658 #[test]
2659 fn test_report_html5_position_emits_position_update() {
2660 let controller = PlayerController::default();
2661 let emitter = Arc::new(CapturingEmitter::new());
2662 controller.set_event_emitter(emitter.clone());
2663
2664 controller.report_html5_position(12.5, 300.0);
2665
2666 let events = emitter.events();
2667 assert_eq!(events.len(), 1);
2668 match &events[0] {
2669 PlayerStatusEvent::PositionUpdate { position, duration } => {
2670 assert_eq!(*position, 12.5);
2671 assert_eq!(*duration, 300.0);
2672 }
2673 other => panic!("expected PositionUpdate, got {:?}", other),
2674 }
2675 }
2676
2677 #[test]
2678 fn test_report_html5_media_loaded_emits_media_loaded() {
2679 let controller = PlayerController::default();
2680 let emitter = Arc::new(CapturingEmitter::new());
2681 controller.set_event_emitter(emitter.clone());
2682
2683 controller.report_html5_media_loaded(420.0);
2684
2685 let events = emitter.events();
2686 assert_eq!(events.len(), 1);
2687 match &events[0] {
2688 PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
2689 other => panic!("expected MediaLoaded, got {:?}", other),
2690 }
2691 }
2692
2693 #[test]
2703 fn test_html5_state_is_tracked_from_reports() {
2704 let controller = PlayerController::default();
2705 let emitter = Arc::new(CapturingEmitter::new());
2706 controller.set_event_emitter(emitter.clone());
2707
2708 assert!(!controller.is_html5_active());
2710
2711 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2712 assert!(controller.is_html5_active());
2713 assert!(controller.html5_is_playing());
2714
2715 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2716 assert!(controller.is_html5_active());
2717 assert!(!controller.html5_is_playing());
2718 }
2719
2720 #[test]
2721 fn test_html5_toggle_from_paused_emits_play_control() {
2722 let controller = PlayerController::default();
2723 let emitter = Arc::new(CapturingEmitter::new());
2724 controller.set_event_emitter(emitter.clone());
2725 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2726
2727 controller.toggle_playback().unwrap();
2728
2729 let controls: Vec<_> = emitter
2730 .events()
2731 .into_iter()
2732 .filter_map(|e| match e {
2733 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2734 _ => None,
2735 })
2736 .collect();
2737 assert_eq!(controls, vec!["play".to_string()]);
2738 }
2739
2740 #[test]
2741 fn test_html5_toggle_from_playing_emits_pause_control() {
2742 let controller = PlayerController::default();
2743 let emitter = Arc::new(CapturingEmitter::new());
2744 controller.set_event_emitter(emitter.clone());
2745 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2746
2747 controller.toggle_playback().unwrap();
2748
2749 let controls: Vec<_> = emitter
2750 .events()
2751 .into_iter()
2752 .filter_map(|e| match e {
2753 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2754 _ => None,
2755 })
2756 .collect();
2757 assert_eq!(controls, vec!["pause".to_string()]);
2758 }
2759
2760 #[test]
2761 fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
2762 let controller = PlayerController::default();
2767 let emitter = Arc::new(CapturingEmitter::new());
2768 controller.set_event_emitter(emitter.clone());
2769 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2770
2771 controller.toggle_playback().unwrap();
2772 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2774 controller.toggle_playback().unwrap();
2775
2776 let controls: Vec<_> = emitter
2777 .events()
2778 .into_iter()
2779 .filter_map(|e| match e {
2780 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2781 _ => None,
2782 })
2783 .collect();
2784 assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
2785 }
2786
2787 #[test]
2788 fn test_html5_play_and_pause_emit_control_commands() {
2789 let controller = PlayerController::default();
2790 let emitter = Arc::new(CapturingEmitter::new());
2791 controller.set_event_emitter(emitter.clone());
2792 controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2793
2794 controller.play().unwrap();
2795 controller.pause().unwrap();
2796
2797 let controls: Vec<_> = emitter
2798 .events()
2799 .into_iter()
2800 .filter_map(|e| match e {
2801 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2802 _ => None,
2803 })
2804 .collect();
2805 assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
2806 }
2807
2808 #[test]
2809 fn test_background_audio_handoff_moves_transport_to_native_backend() {
2810 let controller = PlayerController::default();
2818 let emitter = Arc::new(CapturingEmitter::new());
2819 controller.set_event_emitter(emitter.clone());
2820
2821 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2823 assert!(controller.is_html5_active());
2824
2825 controller.enter_background_audio(1200.0);
2827 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2828
2829 assert!(
2830 !controller.is_html5_active(),
2831 "native audio owns transport during a background-audio handoff"
2832 );
2833
2834 controller.pause().unwrap();
2835 let controls: Vec<_> = emitter
2836 .events()
2837 .into_iter()
2838 .filter_map(|e| match e {
2839 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2840 _ => None,
2841 })
2842 .collect();
2843 assert!(
2844 controls.is_empty(),
2845 "pause must drive the native backend, not a torn-down element: {:?}",
2846 controls
2847 );
2848 }
2849
2850 #[test]
2851 fn test_background_audio_handoff_suppresses_stale_element_events() {
2852 let controller = PlayerController::default();
2856 let emitter = Arc::new(CapturingEmitter::new());
2857 controller.set_event_emitter(emitter.clone());
2858
2859 controller.enter_background_audio(1200.0);
2860 controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2861 controller.report_html5_position(1200.0, 2400.0);
2862
2863 assert!(
2864 emitter.events().is_empty(),
2865 "stale webview reports must not reach the event pipeline: {:?}",
2866 emitter.events()
2867 );
2868 }
2869
2870 #[test]
2871 fn test_exit_background_audio_returns_transport_to_the_webview() {
2872 let controller = PlayerController::default();
2875 let emitter = Arc::new(CapturingEmitter::new());
2876 controller.set_event_emitter(emitter.clone());
2877
2878 controller.enter_background_audio(1200.0);
2879 assert_eq!(controller.exit_background_audio(), 1200.0);
2880
2881 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2882 assert!(controller.is_html5_active());
2883 assert!(controller.html5_is_playing());
2884 }
2885
2886 #[test]
2887 fn test_html5_stopped_report_releases_transport_to_native_backend() {
2888 let controller = PlayerController::default();
2891 let emitter = Arc::new(CapturingEmitter::new());
2892 controller.set_event_emitter(emitter.clone());
2893
2894 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2895 assert!(controller.is_html5_active());
2896
2897 controller.report_html5_state("stopped".to_string(), None);
2898 assert!(!controller.is_html5_active());
2899 }
2900
2901 #[test]
2902 fn test_html5_transport_emits_exactly_one_control_per_intent() {
2903 let controller = PlayerController::default();
2907 let emitter = Arc::new(CapturingEmitter::new());
2908 controller.set_event_emitter(emitter.clone());
2909 controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2910
2911 controller.pause().unwrap();
2912
2913 let controls = emitter
2914 .events()
2915 .into_iter()
2916 .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
2917 .count();
2918 assert_eq!(controls, 1, "one intent must produce exactly one control");
2919 }
2920
2921 #[test]
2922 fn test_controller_volume_default() {
2923 let controller = PlayerController::default();
2924 assert_eq!(controller.volume(), 1.0);
2925 }
2926
2927 #[test]
2928 fn test_controller_set_volume() {
2929 let controller = PlayerController::default();
2930 controller.set_volume(0.5).unwrap();
2931 assert_eq!(controller.volume(), 0.5);
2932 }
2933
2934 #[test]
2935 fn test_controller_muted_default() {
2936 let controller = PlayerController::default();
2937 assert!(!controller.muted());
2938 }
2939
2940 #[test]
2941 fn test_controller_volume_delegates_to_backend() {
2942 let controller = PlayerController::default();
2943
2944 controller.set_volume(0.75).unwrap();
2946
2947 assert_eq!(controller.volume(), 0.75);
2949 }
2950
2951 fn create_test_items(count: usize) -> Vec<MediaItem> {
2952 (0..count)
2953 .map(|i| MediaItem {
2954 transport: None,
2956 id: format!("item_{}", i),
2957 title: format!("Track {}", i + 1),
2958 name: Some(format!("Track {}", i + 1)),
2959 artist: Some("Test Artist".to_string()),
2960 album: Some("Test Album".to_string()),
2961 album_name: Some("Test Album".to_string()),
2962 album_id: None,
2963 artist_items: None,
2964 artists: Some(vec!["Test Artist".to_string()]),
2965 primary_image_tag: None,
2966 image_id: None,
2967 item_type: Some("Audio".to_string()),
2968 playlist_id: None,
2969 duration: Some(180.0),
2970 artwork_url: None,
2971 media_type: MediaType::Audio,
2972 source: MediaSource::DirectUrl {
2973 url: format!("http://example.com/track_{}.mp3", i),
2974 },
2975 video_codec: None,
2976 needs_transcoding: false,
2977 video_width: None,
2978 video_height: None,
2979 subtitles: vec![],
2980 series_id: None,
2981 server_id: None,
2982 })
2983 .collect()
2984 }
2985
2986 #[test]
2987 fn test_skip_preserves_queue() {
2988 let controller = PlayerController::default();
2989
2990 let items = create_test_items(5);
2992 let items_clone = items.clone();
2993
2994 controller.play_queue(items, 0).unwrap();
2996
2997 {
2999 let queue = controller.queue();
3000 let queue_lock = queue.lock_safe();
3001 assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
3002 assert_eq!(
3003 queue_lock.current_index(),
3004 Some(0),
3005 "Should start at index 0"
3006 );
3007 assert_eq!(
3008 queue_lock.current().unwrap().id,
3009 "item_0",
3010 "Current item should be item_0"
3011 );
3012 }
3013
3014 controller.next().unwrap();
3016
3017 {
3019 let queue = controller.queue();
3020 let queue_lock = queue.lock_safe();
3021 assert_eq!(
3022 queue_lock.items().len(),
3023 5,
3024 "Queue should still have 5 items after skip"
3025 );
3026 assert_eq!(
3027 queue_lock.current_index(),
3028 Some(1),
3029 "Index should advance to 1"
3030 );
3031 assert_eq!(
3032 queue_lock.current().unwrap().id,
3033 "item_1",
3034 "Current item should be item_1"
3035 );
3036
3037 let current_items = queue_lock.items();
3039 for (i, original) in items_clone.iter().enumerate() {
3040 assert_eq!(
3041 current_items[i].id, original.id,
3042 "Item {} should still be in queue",
3043 i
3044 );
3045 assert_eq!(
3046 current_items[i].title, original.title,
3047 "Item {} title should be unchanged",
3048 i
3049 );
3050 }
3051 }
3052
3053 controller.next().unwrap();
3055
3056 {
3058 let queue = controller.queue();
3059 let queue_lock = queue.lock_safe();
3060 assert_eq!(
3061 queue_lock.items().len(),
3062 5,
3063 "Queue should still have 5 items after second skip"
3064 );
3065 assert_eq!(
3066 queue_lock.current_index(),
3067 Some(2),
3068 "Index should advance to 2"
3069 );
3070 assert_eq!(
3071 queue_lock.current().unwrap().id,
3072 "item_2",
3073 "Current item should be item_2"
3074 );
3075 }
3076
3077 controller.next().unwrap(); controller.next().unwrap(); {
3083 let queue = controller.queue();
3084 let queue_lock = queue.lock_safe();
3085 assert_eq!(
3086 queue_lock.items().len(),
3087 5,
3088 "Queue should still have 5 items at end"
3089 );
3090 assert_eq!(
3091 queue_lock.current_index(),
3092 Some(4),
3093 "Index should be at last item (4)"
3094 );
3095 assert_eq!(
3096 queue_lock.current().unwrap().id,
3097 "item_4",
3098 "Current item should be item_4"
3099 );
3100 }
3101 }
3102
3103 #[test]
3104 fn test_skip_at_end_without_repeat() {
3105 let controller = PlayerController::default();
3106
3107 let items = create_test_items(3);
3109 controller.play_queue(items, 0).unwrap();
3110
3111 controller.next().unwrap(); controller.next().unwrap(); {
3117 let queue = controller.queue();
3118 let queue_lock = queue.lock_safe();
3119 assert_eq!(
3120 queue_lock.current_index(),
3121 Some(2),
3122 "Should be at last item"
3123 );
3124 }
3125
3126 controller.next().unwrap();
3129
3130 {
3132 let queue = controller.queue();
3133 let queue_lock = queue.lock_safe();
3134 assert_eq!(
3135 queue_lock.items().len(),
3136 3,
3137 "Queue should still have 3 items after skip at end"
3138 );
3139 }
3143 }
3144
3145 #[test]
3146 fn test_skip_with_repeat_all() {
3147 let controller = PlayerController::default();
3148
3149 let items = create_test_items(3);
3151 controller.play_queue(items, 0).unwrap();
3152
3153 controller.cycle_repeat();
3155
3156 controller.next().unwrap(); controller.next().unwrap(); controller.next().unwrap();
3162
3163 {
3165 let queue = controller.queue();
3166 let queue_lock = queue.lock_safe();
3167 assert_eq!(
3168 queue_lock.items().len(),
3169 3,
3170 "Queue should still have 3 items"
3171 );
3172 assert_eq!(
3173 queue_lock.current_index(),
3174 Some(0),
3175 "Should wrap to index 0"
3176 );
3177 assert_eq!(
3178 queue_lock.current().unwrap().id,
3179 "item_0",
3180 "Should be back at item_0"
3181 );
3182 }
3183 }
3184
3185 #[test]
3186 fn test_previous_preserves_queue() {
3187 let controller = PlayerController::default();
3188
3189 let items = create_test_items(5);
3191 let items_clone = items.clone();
3192 controller.play_queue(items, 3).unwrap();
3193
3194 {
3196 let queue = controller.queue();
3197 let queue_lock = queue.lock_safe();
3198 assert_eq!(
3199 queue_lock.current_index(),
3200 Some(3),
3201 "Should start at index 3"
3202 );
3203 }
3204
3205 controller.previous().unwrap();
3207
3208 {
3210 let queue = controller.queue();
3211 let queue_lock = queue.lock_safe();
3212 assert_eq!(
3213 queue_lock.items().len(),
3214 5,
3215 "Queue should still have 5 items after previous"
3216 );
3217 assert_eq!(
3218 queue_lock.current_index(),
3219 Some(2),
3220 "Index should move to 2"
3221 );
3222 assert_eq!(
3223 queue_lock.current().unwrap().id,
3224 "item_2",
3225 "Current item should be item_2"
3226 );
3227
3228 let current_items = queue_lock.items();
3230 for (i, original) in items_clone.iter().enumerate() {
3231 assert_eq!(
3232 current_items[i].id, original.id,
3233 "Item {} should still be in queue",
3234 i
3235 );
3236 }
3237 }
3238 }
3239
3240 #[test]
3241 fn test_seek_updates_position() {
3242 let controller = PlayerController::default();
3243
3244 let item = create_test_items(1).into_iter().next().unwrap();
3246 controller.play_item(item).unwrap();
3247
3248 assert_eq!(controller.position(), 0.0, "Initial position should be 0");
3250
3251 controller.seek(30.0).unwrap();
3253 assert_eq!(
3254 controller.position(),
3255 30.0,
3256 "Position should be 30 after seeking"
3257 );
3258
3259 controller.seek(60.0).unwrap();
3261 assert_eq!(
3262 controller.position(),
3263 60.0,
3264 "Position should be 60 after seeking"
3265 );
3266
3267 controller.seek(15.0).unwrap();
3269 assert_eq!(
3270 controller.position(),
3271 15.0,
3272 "Position should be 15 after seeking backward"
3273 );
3274 }
3275
3276 #[test]
3277 fn test_seek_while_paused() {
3278 let controller = PlayerController::default();
3279
3280 let item = create_test_items(1).into_iter().next().unwrap();
3282 controller.play_item(item).unwrap();
3283
3284 controller.pause().unwrap();
3286
3287 assert!(controller.state().is_paused(), "Should be paused");
3289
3290 controller.seek(45.0).unwrap();
3292 assert_eq!(
3293 controller.position(),
3294 45.0,
3295 "Position should update while paused"
3296 );
3297
3298 assert!(
3300 controller.state().is_paused(),
3301 "Should still be paused after seeking"
3302 );
3303 }
3304
3305 #[test]
3306 fn test_seek_while_playing() {
3307 let controller = PlayerController::default();
3308
3309 let item = create_test_items(1).into_iter().next().unwrap();
3311 controller.play_item(item).unwrap();
3312
3313 controller.play().unwrap();
3315
3316 assert!(controller.state().is_playing(), "Should be playing");
3318
3319 controller.seek(20.0).unwrap();
3321 assert_eq!(
3322 controller.position(),
3323 20.0,
3324 "Position should update while playing"
3325 );
3326
3327 assert!(
3329 controller.state().is_playing(),
3330 "Should still be playing after seeking"
3331 );
3332 }
3333
3334 #[test]
3335 fn test_multiple_sequential_seeks() {
3336 let controller = PlayerController::default();
3337
3338 let item = create_test_items(1).into_iter().next().unwrap();
3339 controller.play_item(item).unwrap();
3340
3341 let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
3343
3344 for pos in positions {
3345 controller.seek(pos).unwrap();
3346 assert_eq!(
3347 controller.position(),
3348 pos,
3349 "Position should match after seeking to {}",
3350 pos
3351 );
3352 }
3353 }
3354
3355 #[test]
3359 fn test_play_queue_from_resumes_at_position() {
3360 let controller = PlayerController::default();
3361 let items = create_test_items(3);
3362
3363 controller.play_queue_from(items, 1, Some(42.5)).unwrap();
3364
3365 {
3366 let queue = controller.queue();
3367 let queue_lock = queue.lock_safe();
3368 assert_eq!(
3369 queue_lock.current_index(),
3370 Some(1),
3371 "Should start at index 1"
3372 );
3373 }
3374 assert_eq!(
3375 controller.position(),
3376 42.5,
3377 "Should resume at the requested position"
3378 );
3379 }
3380
3381 #[test]
3383 fn test_play_queue_from_without_position_starts_at_zero() {
3384 let controller = PlayerController::default();
3385
3386 controller
3387 .play_queue_from(create_test_items(2), 0, None)
3388 .unwrap();
3389 assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
3390
3391 controller
3392 .play_queue_from(create_test_items(2), 0, Some(0.2))
3393 .unwrap();
3394 assert_eq!(
3395 controller.position(),
3396 0.0,
3397 "Sub-threshold resume position is ignored (starts at 0)"
3398 );
3399 }
3400
3401 #[test]
3402 fn test_seek_to_zero() {
3403 let controller = PlayerController::default();
3404
3405 let item = create_test_items(1).into_iter().next().unwrap();
3406 controller.play_item(item).unwrap();
3407
3408 controller.seek(60.0).unwrap();
3410 assert_eq!(controller.position(), 60.0);
3411
3412 controller.seek(0.0).unwrap();
3414 assert_eq!(
3415 controller.position(),
3416 0.0,
3417 "Should be able to seek to position 0"
3418 );
3419 }
3420
3421 #[tokio::test]
3423 async fn test_audio_with_next_advances() {
3424 let controller = PlayerController::default();
3425
3426 let items = create_test_items(2);
3428 controller.play_queue(items, 0).unwrap();
3429
3430 controller.take_end_reason();
3432
3433 let decision = controller.on_playback_ended().await.unwrap();
3435
3436 assert!(
3438 matches!(decision, AutoplayDecision::AdvanceToNext),
3439 "Expected AdvanceToNext decision when queue has next item"
3440 );
3441 }
3442
3443 #[tokio::test]
3444 async fn test_audio_at_end_stops() {
3445 let controller = PlayerController::default();
3446
3447 let items = create_test_items(2);
3449 controller.play_queue(items, 1).unwrap();
3450
3451 controller.take_end_reason();
3453
3454 let decision = controller.on_playback_ended().await.unwrap();
3456
3457 assert!(
3459 matches!(decision, AutoplayDecision::Stop),
3460 "Expected Stop decision when at end of queue without repeat"
3461 );
3462 }
3463
3464 #[tokio::test]
3465 async fn test_sleep_timer_end_of_track() {
3466 let controller = PlayerController::default();
3467
3468 let items = create_test_items(3);
3470 controller.play_queue(items, 0).unwrap();
3471
3472 controller.take_end_reason();
3474
3475 {
3477 let mut timer = controller.sleep_timer.lock_safe();
3478 timer.mode = SleepTimerMode::EndOfTrack;
3479 }
3480
3481 let decision = controller.on_playback_ended().await.unwrap();
3483
3484 assert!(
3486 matches!(decision, AutoplayDecision::Stop),
3487 "Expected Stop decision when sleep timer is EndOfTrack"
3488 );
3489
3490 {
3492 let timer = controller.sleep_timer.lock_safe();
3493 assert!(
3494 matches!(timer.mode, SleepTimerMode::Off),
3495 "Sleep timer should be cancelled after EndOfTrack"
3496 );
3497 }
3498 }
3499
3500 #[tokio::test]
3509 async fn test_expired_time_sleep_timer_stops_without_autoplay() {
3510 let controller = PlayerController::default();
3511
3512 let items = create_test_items(3);
3513 controller.play_queue(items, 0).unwrap();
3514 controller.take_end_reason();
3515
3516 let now = chrono::Utc::now().timestamp_millis();
3521 controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
3522
3523 for _ in 0..40 {
3525 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3526 if !controller.sleep_timer.lock_safe().is_active() {
3527 break;
3528 }
3529 }
3530 assert!(
3531 !controller.sleep_timer.lock_safe().is_active(),
3532 "Timer thread should have expired and cancelled the sleep timer"
3533 );
3534
3535 let decision = controller.on_playback_ended().await.unwrap();
3537
3538 assert!(
3539 matches!(decision, AutoplayDecision::Stop),
3540 "Expected Stop after an expired time-based sleep timer, got {:?}",
3541 decision
3542 );
3543 }
3544
3545 #[tokio::test]
3546 async fn test_empty_queue_stops() {
3547 let controller = PlayerController::default();
3548
3549 let decision = controller.on_playback_ended().await.unwrap();
3551
3552 assert!(
3554 matches!(decision, AutoplayDecision::Stop),
3555 "Expected Stop decision when queue is empty"
3556 );
3557 }
3558
3559 #[tokio::test]
3560 async fn test_repeat_all_advances_at_end() {
3561 let controller = PlayerController::default();
3562
3563 let items = create_test_items(2);
3565 controller.play_queue(items, 1).unwrap(); controller.cycle_repeat(); controller.take_end_reason();
3570
3571 let decision = controller.on_playback_ended().await.unwrap();
3573
3574 assert!(
3576 matches!(decision, AutoplayDecision::AdvanceToNext),
3577 "Expected AdvanceToNext decision at end of queue with repeat all"
3578 );
3579 }
3580
3581 #[tokio::test]
3582 async fn test_repeat_one_advances() {
3583 let controller = PlayerController::default();
3584
3585 let items = create_test_items(2);
3587 controller.play_queue(items, 0).unwrap();
3588
3589 controller.cycle_repeat(); controller.cycle_repeat(); controller.take_end_reason();
3595
3596 let decision = controller.on_playback_ended().await.unwrap();
3598
3599 assert!(
3601 matches!(decision, AutoplayDecision::AdvanceToNext),
3602 "Expected AdvanceToNext decision with repeat one (repeats same track)"
3603 );
3604 }
3605
3606 #[test]
3607 fn test_native_load_returns_transport_authority_to_the_backend() {
3608 let controller = PlayerController::default();
3627 let emitter = Arc::new(CapturingEmitter::new());
3628 controller.set_event_emitter(emitter.clone());
3629
3630 controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
3632 assert!(controller.is_html5_active());
3633
3634 let item = create_test_items(1).into_iter().next().unwrap();
3636 controller.play_item(item).unwrap();
3637
3638 assert!(
3639 !controller.is_html5_active(),
3640 "loading into the native backend hands transport back to it"
3641 );
3642
3643 controller.toggle_playback().unwrap();
3645 let controls: Vec<_> = emitter
3646 .events()
3647 .into_iter()
3648 .filter_map(|e| match e {
3649 PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
3650 _ => None,
3651 })
3652 .collect();
3653 assert!(
3654 controls.is_empty(),
3655 "transport went to a webview element that is not rendering: {controls:?}"
3656 );
3657 }
3658
3659 #[test]
3661 fn test_load_and_play_sets_new_track_loaded() {
3662 let controller = PlayerController::default();
3663 let item = create_test_items(1).into_iter().next().unwrap();
3664
3665 assert!(controller.take_end_reason().is_none());
3667
3668 controller.load_and_play(&item).unwrap();
3670
3671 let reason = controller.take_end_reason();
3673 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3674 }
3675
3676 #[test]
3677 fn test_stop_sets_user_stop() {
3678 let controller = PlayerController::default();
3679 let item = create_test_items(1).into_iter().next().unwrap();
3680
3681 controller.play_item(item).unwrap();
3683
3684 controller.take_end_reason();
3686
3687 controller.stop().unwrap();
3689
3690 let reason = controller.take_end_reason();
3692 assert_eq!(reason, Some(EndReason::UserStop));
3693 }
3694
3695 #[tokio::test]
3696 async fn test_on_playback_ended_with_new_track_loaded_stops() {
3697 let controller = PlayerController::default();
3698
3699 let items = create_test_items(2);
3701 controller.play_queue(items, 0).unwrap();
3702
3703 controller.set_end_reason(EndReason::NewTrackLoaded);
3705
3706 let decision = controller.on_playback_ended().await.unwrap();
3708
3709 assert!(
3711 matches!(decision, AutoplayDecision::Stop),
3712 "Expected Stop decision when EndReason is NewTrackLoaded"
3713 );
3714 }
3715
3716 #[tokio::test]
3717 async fn test_on_playback_ended_with_user_stop_stops() {
3718 let controller = PlayerController::default();
3719
3720 let items = create_test_items(2);
3722 controller.play_queue(items, 0).unwrap();
3723
3724 controller.set_end_reason(EndReason::UserStop);
3726
3727 let decision = controller.on_playback_ended().await.unwrap();
3729
3730 assert!(
3732 matches!(decision, AutoplayDecision::Stop),
3733 "Expected Stop decision when EndReason is UserStop"
3734 );
3735 }
3736
3737 #[tokio::test]
3738 async fn test_on_playback_ended_natural_end_advances() {
3739 let controller = PlayerController::default();
3740
3741 let items = create_test_items(2);
3743 controller.play_queue(items, 0).unwrap();
3744
3745 controller.take_end_reason();
3747
3748 let decision = controller.on_playback_ended().await.unwrap();
3750
3751 assert!(
3753 matches!(decision, AutoplayDecision::AdvanceToNext),
3754 "Expected AdvanceToNext decision when track ends naturally with next track available"
3755 );
3756 }
3757
3758 #[tokio::test]
3759 async fn test_on_playback_ended_with_user_skip_stops() {
3760 let controller = PlayerController::default();
3761
3762 let items = create_test_items(2);
3764 controller.play_queue(items, 0).unwrap();
3765
3766 controller.set_end_reason(EndReason::UserSkip);
3768
3769 let decision = controller.on_playback_ended().await.unwrap();
3771
3772 assert!(
3774 matches!(decision, AutoplayDecision::Stop),
3775 "Expected Stop decision when EndReason is UserSkip"
3776 );
3777 }
3778
3779 #[tokio::test]
3780 async fn test_on_playback_ended_with_error_stops() {
3781 let controller = PlayerController::default();
3782
3783 let items = create_test_items(2);
3785 controller.play_queue(items, 0).unwrap();
3786
3787 controller.set_end_reason(EndReason::Error);
3789
3790 let decision = controller.on_playback_ended().await.unwrap();
3792
3793 assert!(
3795 matches!(decision, AutoplayDecision::Stop),
3796 "Expected Stop decision when EndReason is Error"
3797 );
3798 }
3799
3800 #[tokio::test]
3801 async fn test_take_end_reason_clears_state() {
3802 let controller = PlayerController::default();
3803
3804 controller.set_end_reason(EndReason::NewTrackLoaded);
3806
3807 let reason = controller.take_end_reason();
3809 assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3810
3811 let reason = controller.take_end_reason();
3813 assert!(reason.is_none(), "take_end_reason should clear the state");
3814 }
3815
3816 use crate::repository::types as repo_types;
3819
3820 struct MockEpisodeRepo {
3824 seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
3826 }
3827
3828 impl MockEpisodeRepo {
3829 fn season(count: usize) -> Self {
3831 Self::series(&[count])
3832 }
3833
3834 fn series(counts: &[usize]) -> Self {
3838 let seasons = counts
3839 .iter()
3840 .enumerate()
3841 .map(|(s, count)| {
3842 let season_number = s as i32 + 1;
3843 let episodes = (1..=*count)
3844 .map(|i| {
3845 let id = if season_number == 1 {
3846 format!("ep{}", i)
3847 } else {
3848 format!("s{}e{}", season_number, i)
3849 };
3850 make_repo_episode_in(season_number, &id, i as i32)
3851 })
3852 .collect();
3853 (make_repo_season(season_number), episodes)
3854 })
3855 .collect();
3856 Self { seasons }
3857 }
3858
3859 fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
3860 self.seasons.iter().flat_map(|(_, eps)| eps.iter())
3861 }
3862 }
3863
3864 fn make_repo_season(index: i32) -> repo_types::MediaItem {
3865 repo_types::MediaItem {
3866 id: format!("season{}", index),
3867 name: format!("Season {}", index),
3868 item_type: "Season".to_string(),
3869 kind: crate::domain::MediaKind::Season,
3870 is_folder: true,
3871 parent_id: Some("series1".to_string()),
3872 index_number: Some(index),
3873 season_id: None,
3874 season_name: None,
3875 parent_index_number: None,
3876 ..make_repo_episode(&format!("season{}", index), index)
3877 }
3878 }
3879
3880 fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
3881 make_repo_episode_in(1, id, index)
3882 }
3883
3884 fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
3885 repo_types::MediaItem {
3886 id: id.to_string(),
3887 name: format!("Episode {}", index),
3888 item_type: "Episode".to_string(),
3889 kind: crate::domain::MediaKind::Episode,
3890 is_folder: false,
3891 server_id: "server".to_string(),
3892 parent_id: Some(format!("season{}", season_number)),
3893 library_id: None,
3894 overview: None,
3895 genres: None,
3896 runtime_ticks: None,
3897 duration_ms: None,
3898 production_year: None,
3899 premiere_date: None,
3900 community_rating: None,
3901 official_rating: None,
3902 primary_image_tag: None,
3903 image_id: None,
3904 backdrop_image_tags: None,
3905 parent_backdrop_image_tags: None,
3906 album_id: None,
3907 album_name: None,
3908 album_artist: None,
3909 artists: None,
3910 artist_items: None,
3911 index_number: Some(index),
3912 series_id: Some("series1".to_string()),
3913 series_name: Some("Test Series".to_string()),
3914 season_id: Some(format!("season{}", season_number)),
3915 season_name: Some(format!("Season {}", season_number)),
3916 parent_index_number: Some(season_number),
3917 user_data: None,
3918 media_streams: None,
3919 media_sources: None,
3920 people: None,
3921 }
3922 }
3923
3924 #[async_trait::async_trait]
3925 impl crate::repository::MediaRepository for MockEpisodeRepo {
3926 async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
3927 unimplemented!()
3928 }
3929 async fn get_items(
3930 &self,
3931 parent_id: &str,
3932 _options: Option<repo_types::GetItemsOptions>,
3933 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3934 let items = if parent_id == "series1" {
3938 self.seasons.iter().map(|(s, _)| s.clone()).collect()
3939 } else {
3940 self.seasons
3941 .iter()
3942 .find(|(season, _)| season.id == parent_id)
3943 .map(|(_, eps)| eps.clone())
3944 .unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
3945 };
3946 let total_record_count = items.len();
3947 Ok(repo_types::SearchResult {
3948 items,
3949 total_record_count,
3950 })
3951 }
3952 async fn get_item(
3953 &self,
3954 item_id: &str,
3955 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3956 self.all_episodes()
3957 .find(|e| e.id == item_id)
3958 .cloned()
3959 .ok_or(repo_types::RepoError::NotFound {
3960 message: format!("{} not found", item_id),
3961 })
3962 }
3963 async fn get_latest_items(
3964 &self,
3965 _: &str,
3966 _: Option<usize>,
3967 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3968 unimplemented!()
3969 }
3970 async fn get_resume_items(
3971 &self,
3972 _: Option<&str>,
3973 _: Option<usize>,
3974 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3975 unimplemented!()
3976 }
3977 async fn get_next_up_episodes(
3978 &self,
3979 _: Option<&str>,
3980 _: Option<usize>,
3981 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3982 unimplemented!()
3983 }
3984 async fn get_recently_played_audio(
3985 &self,
3986 _: Option<usize>,
3987 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3988 unimplemented!()
3989 }
3990 async fn get_rediscover_albums(
3991 &self,
3992 _: Option<&str>,
3993 _: Option<usize>,
3994 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3995 unimplemented!()
3996 }
3997 async fn get_resume_movies(
3998 &self,
3999 _: Option<usize>,
4000 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
4001 unimplemented!()
4002 }
4003 async fn get_genres(
4004 &self,
4005 _: Option<&str>,
4006 ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
4007 unimplemented!()
4008 }
4009 async fn search(
4010 &self,
4011 _: &str,
4012 _: Option<repo_types::SearchOptions>,
4013 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4014 unimplemented!()
4015 }
4016 async fn get_playback_info(
4017 &self,
4018 _: &str,
4019 ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
4020 unimplemented!()
4021 }
4022 async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
4023 unimplemented!()
4024 }
4025 async fn get_audio_only_stream_url_for_video(
4026 &self,
4027 item_id: &str,
4028 _media_source_id: Option<&str>,
4029 _start_time_seconds: Option<f64>,
4030 _audio_stream_index: Option<i32>,
4031 ) -> Result<String, repo_types::RepoError> {
4032 Ok(format!("http://example.com/{}-audio.mp3", item_id))
4033 }
4034 async fn get_live_tv_channels(
4035 &self,
4036 ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
4037 unimplemented!()
4038 }
4039 async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4040 unimplemented!()
4041 }
4042 async fn open_live_stream(
4043 &self,
4044 _: &str,
4045 ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
4046 unimplemented!()
4047 }
4048 async fn report_playback_start(
4049 &self,
4050 _: &str,
4051 _: i64,
4052 ) -> Result<(), repo_types::RepoError> {
4053 unimplemented!()
4054 }
4055 async fn report_playback_progress(
4056 &self,
4057 _: &str,
4058 _: i64,
4059 ) -> Result<(), repo_types::RepoError> {
4060 unimplemented!()
4061 }
4062 async fn report_playback_stopped(
4063 &self,
4064 _: &str,
4065 _: i64,
4066 ) -> Result<(), repo_types::RepoError> {
4067 unimplemented!()
4068 }
4069 fn get_image_url(
4070 &self,
4071 _: &str,
4072 _: repo_types::ImageType,
4073 _: Option<repo_types::ImageOptions>,
4074 ) -> String {
4075 unimplemented!()
4076 }
4077 fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
4078 unimplemented!()
4079 }
4080 fn get_video_download_url(
4081 &self,
4082 _: &str,
4083 _: &str,
4084 _: Option<&str>,
4085 _: Option<&str>,
4086 ) -> String {
4087 unimplemented!()
4088 }
4089 async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
4090 unimplemented!()
4091 }
4092 async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
4093 unimplemented!()
4094 }
4095 async fn get_favorites(
4096 &self,
4097 _: repo_types::SearchScope,
4098 _: Option<repo_types::GetItemsOptions>,
4099 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4100 unimplemented!()
4101 }
4102 async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
4103 unimplemented!()
4104 }
4105 async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
4106 unimplemented!()
4107 }
4108 async fn get_person(
4109 &self,
4110 _: &str,
4111 ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
4112 unimplemented!()
4113 }
4114 async fn get_items_by_person(
4115 &self,
4116 _: &str,
4117 _: Option<repo_types::GetItemsOptions>,
4118 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4119 unimplemented!()
4120 }
4121 async fn get_similar_items(
4122 &self,
4123 _: &str,
4124 _: Option<usize>,
4125 ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
4126 unimplemented!()
4127 }
4128 async fn create_playlist(
4129 &self,
4130 _: &str,
4131 _: &[String],
4132 ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
4133 unimplemented!()
4134 }
4135 async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
4136 unimplemented!()
4137 }
4138 async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
4139 unimplemented!()
4140 }
4141 async fn get_playlist_items(
4142 &self,
4143 _: &str,
4144 ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
4145 unimplemented!()
4146 }
4147 async fn add_to_playlist(
4148 &self,
4149 _: &str,
4150 _: &[String],
4151 ) -> Result<(), repo_types::RepoError> {
4152 unimplemented!()
4153 }
4154 async fn remove_from_playlist(
4155 &self,
4156 _: &str,
4157 _: &[String],
4158 ) -> Result<(), repo_types::RepoError> {
4159 unimplemented!()
4160 }
4161 async fn move_playlist_item(
4162 &self,
4163 _: &str,
4164 _: &str,
4165 _: u32,
4166 ) -> Result<(), repo_types::RepoError> {
4167 unimplemented!()
4168 }
4169 }
4170
4171 #[tokio::test]
4174 async fn test_video_playback_ended_offers_next_episode() {
4175 let controller = PlayerController::default();
4176 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
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 {
4185 current_episode,
4186 next_episode,
4187 auto_advance,
4188 ..
4189 } => {
4190 assert_eq!(current_episode.id, "ep2");
4191 assert_eq!(next_episode.id, "ep3");
4192 assert!(auto_advance, "default settings should auto-advance");
4193 }
4194 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4195 }
4196 }
4197
4198 #[tokio::test]
4205 async fn test_next_episode_rolls_over_to_the_next_season() {
4206 let controller = PlayerController::default();
4207 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
4208
4209 let decision = controller
4210 .on_video_playback_ended("ep2", repo)
4211 .await
4212 .expect("decision should succeed");
4213
4214 match decision {
4215 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4216 assert_eq!(
4217 next_episode.id, "s2e1",
4218 "the first episode of the next season follows the last of this one"
4219 );
4220 }
4221 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4222 }
4223 }
4224
4225 #[tokio::test]
4229 async fn test_next_episode_skips_an_empty_season() {
4230 let controller = PlayerController::default();
4231 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
4232
4233 let decision = controller
4234 .on_video_playback_ended("ep1", repo)
4235 .await
4236 .expect("decision should succeed");
4237
4238 match decision {
4239 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4240 assert_eq!(next_episode.id, "s3e1");
4241 }
4242 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4243 }
4244 }
4245
4246 #[tokio::test]
4252 async fn test_sleep_timer_still_stops_at_a_season_boundary() {
4253 let controller = PlayerController::default();
4254 controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
4255 controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
4256
4257 let episode = MediaItem {
4258 transport: None,
4259 media_type: MediaType::Audio,
4260 item_type: Some("Episode".to_string()),
4261 series_id: Some("series1".to_string()),
4262 duration: Some(180.0),
4263 source: MediaSource::Remote {
4264 stream_url: "http://example.com/ep1.mp3".to_string(),
4265 jellyfin_item_id: "ep1".to_string(),
4266 },
4267 ..create_test_items(1).remove(0)
4268 };
4269 controller.play_queue(vec![episode], 0).unwrap();
4270 controller.seek(180.0).unwrap();
4272 controller.take_end_reason();
4273
4274 let decision = controller.on_playback_ended().await.unwrap();
4275
4276 assert!(
4277 matches!(decision, AutoplayDecision::Stop),
4278 "the last episode the timer allows must stop, not roll into season 2 (got {:?})",
4279 decision
4280 );
4281 }
4282
4283 #[tokio::test]
4285 async fn test_video_playback_ended_last_episode_stops() {
4286 let controller = PlayerController::default();
4287 let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
4288
4289 let decision = controller
4290 .on_video_playback_ended("ep3", repo)
4291 .await
4292 .expect("decision should succeed");
4293
4294 assert!(matches!(decision, AutoplayDecision::Stop));
4295 }
4296
4297 #[tokio::test]
4302 async fn test_playback_ended_uses_controller_repository_for_episodes() {
4303 let controller = PlayerController::default();
4304 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4305
4306 let episode = MediaItem {
4308 transport: None,
4310 media_type: MediaType::Video,
4311 source: MediaSource::Remote {
4312 stream_url: "http://example.com/ep1.mkv".to_string(),
4313 jellyfin_item_id: "ep1".to_string(),
4314 },
4315 ..create_test_items(1).remove(0)
4316 };
4317 controller.play_queue(vec![episode], 0).unwrap();
4318
4319 controller.take_end_reason();
4321
4322 let decision = controller.on_playback_ended().await.unwrap();
4323
4324 match decision {
4325 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4326 assert_eq!(next_episode.id, "ep2");
4327 }
4328 other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4329 }
4330 }
4331
4332 #[tokio::test]
4339 async fn test_playback_ended_background_audio_episode_advances() {
4340 let controller = PlayerController::default();
4341 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4342
4343 let episode = MediaItem {
4345 transport: None,
4347 item_type: Some("Episode".to_string()),
4348 media_type: MediaType::Audio, series_id: Some("series1".to_string()),
4350 duration: Some(180.0),
4351 source: MediaSource::Remote {
4352 stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
4353 jellyfin_item_id: "ep2".to_string(),
4354 },
4355 ..create_test_items(1).remove(0)
4356 };
4357 controller.play_queue(vec![episode], 0).unwrap();
4358
4359 controller.seek(180.0).unwrap();
4361 controller.take_end_reason();
4363
4364 let decision = controller.on_playback_ended().await.unwrap();
4365
4366 match decision {
4367 AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4368 assert_eq!(next_episode.id, "ep3");
4369 }
4370 other => panic!(
4371 "background-audio episode end must advance to the next episode, got {:?}",
4372 other
4373 ),
4374 }
4375 }
4376
4377 #[tokio::test]
4381 async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
4382 let controller = PlayerController::default();
4383 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4384
4385 controller
4386 .advance_to_next_episode_audio_only("ep2")
4387 .await
4388 .expect("advance should succeed");
4389
4390 let current = controller
4391 .queue
4392 .lock_safe()
4393 .current()
4394 .cloned()
4395 .expect("an item should be loaded");
4396 assert_eq!(current.id, "ep2");
4397 assert_eq!(current.media_type, MediaType::Audio);
4398 assert_eq!(current.item_type.as_deref(), Some("Episode"));
4399 assert_eq!(current.series_id.as_deref(), Some("series1"));
4400 match ¤t.source {
4402 MediaSource::Remote { stream_url, .. } => {
4403 assert!(
4404 stream_url.contains("audio"),
4405 "expected audio-only URL, got {}",
4406 stream_url
4407 );
4408 }
4409 other => panic!("expected Remote source, got {:?}", other),
4410 }
4411
4412 assert!(controller.current_is_audio_episode());
4415 }
4416
4417 #[tokio::test]
4425 async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
4426 let controller = PlayerController::default();
4427 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4428
4429 controller.set_background_audio_base(1200.0);
4431
4432 controller
4433 .advance_to_next_episode_audio_only("ep2")
4434 .await
4435 .expect("advance should succeed");
4436
4437 assert_eq!(
4438 controller.take_background_audio_base(),
4439 0.0,
4440 "the next episode starts at its own zero, so the previous handoff \
4441 base must not survive the advance"
4442 );
4443 }
4444
4445 #[tokio::test]
4455 async fn test_background_audio_resume_names_the_advanced_episode() {
4456 let controller = PlayerController::default();
4457 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4458
4459 let episode = MediaItem {
4460 transport: None,
4461 id: "ep1".to_string(),
4462 item_type: Some("Episode".to_string()),
4463 media_type: MediaType::Audio,
4464 series_id: Some("series1".to_string()),
4465 ..create_test_items(1).remove(0)
4466 };
4467 controller.play_queue(vec![episode], 0).unwrap();
4468 controller.enter_background_audio(1200.0);
4469
4470 let before = controller.background_audio_resume();
4471 assert_eq!(before.item_id.as_deref(), Some("ep1"));
4472 assert_eq!(before.position_seconds, 1200.0);
4473
4474 controller
4475 .advance_to_next_episode_audio_only("ep2")
4476 .await
4477 .expect("advance should succeed");
4478
4479 let after = controller.background_audio_resume();
4480 assert_eq!(
4481 after.item_id.as_deref(),
4482 Some("ep2"),
4483 "the foreground must resume the episode the backend advanced to"
4484 );
4485 assert!(
4486 after.position_seconds < 1200.0,
4487 "the previous episode's handoff base must not leak into the new one"
4488 );
4489 }
4490
4491 #[tokio::test]
4503 async fn test_auto_advance_background_audio_episode_advances_in_backend() {
4504 let controller = PlayerController::default();
4505 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4506
4507 let episode = MediaItem {
4509 transport: None,
4511 id: "ep2".to_string(),
4512 item_type: Some("Episode".to_string()),
4513 media_type: MediaType::Audio,
4514 series_id: Some("series1".to_string()),
4515 source: MediaSource::Remote {
4516 stream_url: "http://example.com/ep2-audio.mp3".to_string(),
4517 jellyfin_item_id: "ep2".to_string(),
4518 },
4519 ..create_test_items(1).remove(0)
4520 };
4521 controller.play_queue(vec![episode], 0).unwrap();
4522
4523 let next = make_repo_episode("ep3", 3);
4524 controller.auto_advance_to_next_episode(next, 10).await;
4525
4526 let current = controller
4527 .queue
4528 .lock_safe()
4529 .current()
4530 .cloned()
4531 .expect("an item should still be loaded");
4532 assert_eq!(
4533 current.id, "ep3",
4534 "background audio-only episode must advance in the backend, not wait \
4535 for a frontend navigation that cannot happen while backgrounded"
4536 );
4537 assert_eq!(current.media_type, MediaType::Audio);
4538 assert!(controller.current_is_audio_episode());
4539 }
4540
4541 fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
4545 MediaItem {
4546 transport: None,
4548 id: "ep2".to_string(),
4549 item_type: Some("Episode".to_string()),
4550 media_type: MediaType::Audio,
4551 series_id: Some("series1".to_string()),
4552 duration: Some(runtime_seconds),
4553 source: MediaSource::Remote {
4554 stream_url:
4555 "http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=0"
4556 .to_string(),
4557 jellyfin_item_id: "ep2".to_string(),
4558 },
4559 ..create_test_items(1).remove(0)
4560 }
4561 }
4562
4563 fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
4567 MediaItem {
4568 transport: None,
4570 source: MediaSource::Local {
4571 file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
4572 jellyfin_item_id: Some("ep2".to_string()),
4573 },
4574 ..audio_only_episode(runtime_seconds)
4575 }
4576 }
4577
4578 #[tokio::test]
4585 async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
4586 let controller = PlayerController::default();
4587 controller
4588 .play_queue(vec![local_audio_only_episode(1500.0)], 0)
4589 .unwrap();
4590 controller.enter_background_audio(0.0);
4592
4593 controller.seek_absolute(900.0).await.unwrap();
4594
4595 assert_eq!(controller.position(), 900.0);
4596 }
4597
4598 #[tokio::test]
4610 async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
4611 let controller = PlayerController::default();
4612 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4613
4614 controller
4615 .play_queue(vec![audio_only_episode(1500.0)], 0)
4616 .unwrap();
4617 controller.seek(600.0).unwrap();
4619 controller.take_end_reason();
4620
4621 let decision = controller.on_playback_ended().await.unwrap();
4622
4623 match decision {
4624 AutoplayDecision::ResumeStream { position } => {
4625 assert_eq!(position, 600.0, "must resume where the stream died");
4626 }
4627 other => panic!(
4628 "a stream that ended 15 minutes short of the runtime must resume, \
4629 not run end-of-episode logic; got {:?}",
4630 other
4631 ),
4632 }
4633 }
4634
4635 #[tokio::test]
4648 async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
4649 let controller = PlayerController::default();
4650 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4651 controller
4652 .play_queue(vec![audio_only_episode(1500.0)], 0)
4653 .unwrap();
4654
4655 controller.enter_background_audio(1200.0);
4657
4658 controller.seek_absolute(1490.0).await.unwrap();
4660
4661 let url = {
4662 let queue = controller.queue();
4663 let queue = queue.lock_safe();
4664 match &queue.current().unwrap().source {
4665 MediaSource::Remote { stream_url, .. } => stream_url.clone(),
4666 other => panic!("expected a remote source, got {:?}", other),
4667 }
4668 };
4669 assert!(
4670 url.contains(&format!(
4671 "StartTimeTicks={}",
4672 (1490.0 * 10_000_000.0) as i64
4673 )),
4674 "the stream must be re-opened at the absolute position; got {}",
4675 url
4676 );
4677
4678 assert_eq!(
4679 *controller.background_audio_base.lock_safe(),
4680 1490.0,
4681 "the re-opened stream's zero is the position it was opened at, or \
4682 every later reading is off by the difference"
4683 );
4684 }
4685
4686 #[tokio::test]
4691 async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
4692 let controller = PlayerController::default();
4693 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4694 controller
4695 .play_queue(vec![audio_only_episode(1500.0)], 0)
4696 .unwrap();
4697
4698 controller.seek_absolute(300.0).await.unwrap();
4699
4700 assert_eq!(controller.position(), 300.0);
4701 assert_eq!(
4702 *controller.background_audio_base.lock_safe(),
4703 0.0,
4704 "an ordinary seek must not invent a handoff base"
4705 );
4706 }
4707
4708 #[test]
4728 fn test_absolute_position_floors_at_the_handoff_base() {
4729 let controller = PlayerController::default();
4730 controller.enter_background_audio(18.4);
4731
4732 assert_eq!(controller.position(), 0.0);
4734 assert_eq!(
4735 controller.absolute_position(),
4736 18.4,
4737 "the audio stream's zero IS the handoff point, so the position can \
4738 never legitimately read below it"
4739 );
4740 }
4741
4742 #[test]
4747 fn test_absolute_position_does_not_double_count_the_handoff_base() {
4748 let controller = PlayerController::default();
4749 controller.enter_background_audio(18.4);
4750
4751 controller.seek(120.0).unwrap();
4753
4754 assert_eq!(controller.absolute_position(), 120.0);
4755 }
4756
4757 #[test]
4764 fn test_webview_position_reports_become_the_controllers_position() {
4765 let controller = PlayerController::default();
4766
4767 controller.report_html5_position(253.4, 2640.0);
4768
4769 assert_eq!(controller.absolute_position(), 253.4);
4770 assert_eq!(controller.observed_duration(), Some(2640.0));
4771 }
4772
4773 #[test]
4779 fn test_webview_teardown_clears_the_observed_position() {
4780 let controller = PlayerController::default();
4781 controller.report_html5_position(253.4, 2640.0);
4782
4783 controller.report_html5_state("stopped".to_string(), None);
4784
4785 assert_eq!(controller.absolute_position(), 0.0);
4786 }
4787
4788 #[test]
4793 fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
4794 let controller = PlayerController::default();
4795 controller.report_html5_position(253.4, 2640.0);
4796
4797 controller.enter_background_audio(18.4);
4798
4799 assert_eq!(
4800 controller.absolute_position(),
4801 18.4,
4802 "the video element is gone; only the handoff base describes the \
4803 stream that is now playing"
4804 );
4805 }
4806
4807 #[tokio::test]
4815 async fn test_a_stop_at_zero_is_never_reported() {
4816 let controller = PlayerController::default();
4817 let reports = Arc::new(CapturingReports::new());
4818 controller.set_report_sink(reports.clone());
4819 controller
4820 .play_queue(vec![audio_only_episode(1500.0)], 0)
4821 .unwrap();
4822
4823 controller.stop().unwrap();
4825
4826 assert!(
4827 reports.stops().is_empty(),
4828 "a zero-position stop must be withheld, not sent; got {:?}",
4829 reports.stops()
4830 );
4831 }
4832
4833 #[tokio::test]
4842 async fn test_a_stop_reports_the_position_actually_reached() {
4843 let webview = PlayerController::default();
4845 let webview_reports = Arc::new(CapturingReports::new());
4846 webview.set_report_sink(webview_reports.clone());
4847 webview
4848 .play_queue(vec![audio_only_episode(1500.0)], 0)
4849 .unwrap();
4850 webview.report_html5_position(253.0, 1500.0);
4851
4852 webview.stop().unwrap();
4853
4854 assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4855
4856 let native = PlayerController::default();
4858 let native_reports = Arc::new(CapturingReports::new());
4859 native.set_report_sink(native_reports.clone());
4860 native
4861 .play_queue(vec![audio_only_episode(1500.0)], 0)
4862 .unwrap();
4863 native.seek(253.0).unwrap();
4864
4865 native.stop().unwrap();
4866
4867 assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4868 }
4869
4870 #[tokio::test]
4880 async fn test_a_finished_audio_only_episode_is_reported_complete() {
4881 let controller = PlayerController::default();
4882 let reports = Arc::new(CapturingReports::new());
4883 controller.set_report_sink(reports.clone());
4884 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4885 controller
4886 .play_queue(vec![audio_only_episode(1500.0)], 0)
4887 .unwrap();
4888 controller.seek(1499.0).unwrap();
4890 controller.take_end_reason();
4891
4892 controller.on_playback_ended().await.unwrap();
4893
4894 assert_eq!(
4895 reports.stops(),
4896 vec![("ep2".to_string(), 1500.0)],
4897 "the finished episode must be reported stopped at its runtime, or \
4898 Jellyfin's ≥90% rule never marks it played"
4899 );
4900 }
4901
4902 #[tokio::test]
4908 async fn test_a_truncated_stream_reports_no_completion() {
4909 let controller = PlayerController::default();
4910 let reports = Arc::new(CapturingReports::new());
4911 controller.set_report_sink(reports.clone());
4912 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4913 controller
4914 .play_queue(vec![audio_only_episode(1500.0)], 0)
4915 .unwrap();
4916 controller.seek(600.0).unwrap();
4917 controller.take_end_reason();
4918
4919 let decision = controller.on_playback_ended().await.unwrap();
4920
4921 assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
4922 assert!(
4923 reports.stops().is_empty(),
4924 "a dropped connection is not a finished episode; got {:?}",
4925 reports.stops()
4926 );
4927 }
4928
4929 #[tokio::test]
4937 async fn test_webview_position_ticks_report_progress_to_the_server() {
4938 let controller = PlayerController::default();
4939 let reports = Arc::new(CapturingReports::new());
4940 controller.set_report_sink(reports.clone());
4941 controller
4942 .play_queue(vec![audio_only_episode(1500.0)], 0)
4943 .unwrap();
4944
4945 controller.report_html5_position(253.4, 1500.0);
4946
4947 assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
4948 }
4949
4950 #[tokio::test]
4955 async fn test_progress_reports_are_throttled_not_sent_per_tick() {
4956 let controller = PlayerController::default();
4957 let reports = Arc::new(CapturingReports::new());
4958 controller.set_report_sink(reports.clone());
4959 controller
4960 .play_queue(vec![audio_only_episode(1500.0)], 0)
4961 .unwrap();
4962
4963 for tick in 0..12 {
4964 controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
4965 }
4966
4967 assert_eq!(
4968 reports.progress().len(),
4969 1,
4970 "twelve ticks inside one throttle window are one report"
4971 );
4972 }
4973
4974 #[tokio::test]
4984 async fn test_truncated_check_uses_the_absolute_position() {
4985 let controller = PlayerController::default();
4986 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4987
4988 controller
4989 .play_queue(vec![audio_only_episode(1500.0)], 0)
4990 .unwrap();
4991 controller.set_background_audio_base(1440.0);
4994 controller.seek(1496.0).unwrap();
4995 controller.take_end_reason();
4996
4997 let decision = controller.on_playback_ended().await.unwrap();
4998
4999 assert!(
5000 matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
5001 "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
5002 decision
5003 );
5004 }
5005
5006 #[tokio::test]
5010 async fn test_repeated_truncation_at_the_same_position_gives_up() {
5011 let controller = PlayerController::default();
5012 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
5013
5014 controller
5015 .play_queue(vec![audio_only_episode(1500.0)], 0)
5016 .unwrap();
5017 controller.seek(600.0).unwrap();
5018
5019 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
5020 controller.take_end_reason();
5021 let decision = controller.on_playback_ended().await.unwrap();
5022 assert!(
5023 matches!(decision, AutoplayDecision::ResumeStream { .. }),
5024 "attempt {} should still resume, got {:?}",
5025 attempt,
5026 decision
5027 );
5028 }
5029
5030 controller.take_end_reason();
5031 let decision = controller.on_playback_ended().await.unwrap();
5032 assert!(
5033 !matches!(decision, AutoplayDecision::ResumeStream { .. }),
5034 "a stream stuck at the same position must stop retrying, got {:?}",
5035 decision
5036 );
5037 }
5038
5039 #[tokio::test]
5043 async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
5044 let controller = PlayerController::default();
5045
5046 let mut items = create_test_items(2);
5047 items[0].duration = Some(1500.0);
5048 controller.play_queue(items, 0).unwrap();
5049 controller.seek(60.0).unwrap();
5050 controller.take_end_reason();
5051
5052 let decision = controller.on_playback_ended().await.unwrap();
5053 assert!(
5054 matches!(decision, AutoplayDecision::AdvanceToNext),
5055 "plain queue audio must keep advancing, got {:?}",
5056 decision
5057 );
5058 }
5059
5060 #[tokio::test]
5066 async fn test_recoverable_error_resumes_a_music_track() {
5067 let controller = PlayerController::default();
5068
5069 let mut items = create_test_items(3);
5070 for item in &mut items {
5071 item.source = MediaSource::Remote {
5072 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
5073 jellyfin_item_id: item.id.clone(),
5074 };
5075 }
5076 controller.play_queue(items, 1).unwrap();
5077 controller.seek(45.0).unwrap();
5078
5079 let (position, _) = controller
5080 .recoverable_error_resume()
5081 .expect("a streamed music track must be resumable after a network error");
5082 assert_eq!(position, 45.0);
5083 }
5084
5085 #[tokio::test]
5089 async fn test_resume_keeps_the_rest_of_the_queue() {
5090 let controller = PlayerController::default();
5091
5092 let mut items = create_test_items(3);
5093 for item in &mut items {
5094 item.source = MediaSource::Remote {
5095 stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
5096 jellyfin_item_id: item.id.clone(),
5097 };
5098 }
5099 controller.play_queue(items, 1).unwrap();
5100
5101 controller
5102 .resume_stream_at(45.0)
5103 .await
5104 .expect("resume should succeed");
5105
5106 let queue = controller.queue.lock_safe();
5107 assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
5108 assert_eq!(queue.current_index(), Some(1), "still on the same track");
5109 assert_eq!(queue.current().unwrap().id, "item_1");
5110 }
5111
5112 #[tokio::test]
5116 async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
5117 let controller = PlayerController::default();
5118
5119 let mut items = create_test_items(1);
5120 items[0].source = MediaSource::Remote {
5121 stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
5122 jellyfin_item_id: "item_0".to_string(),
5123 };
5124 controller.play_queue(items, 0).unwrap();
5125
5126 controller.resume_stream_at(45.0).await.unwrap();
5127
5128 match &controller.queue.lock_safe().current().unwrap().source {
5129 MediaSource::Remote { stream_url, .. } => {
5130 assert_eq!(
5131 stream_url, "http://s/Audio/item_0/stream?Static=true",
5132 "a seekable stream's URL must be left alone"
5133 );
5134 }
5135 other => panic!("expected Remote source, got {:?}", other),
5136 }
5137 assert_eq!(
5138 controller.position(),
5139 45.0,
5140 "and it must land at the position"
5141 );
5142 }
5143
5144 #[tokio::test]
5147 async fn test_recoverable_error_ignores_local_media() {
5148 let controller = PlayerController::default();
5149
5150 let mut items = create_test_items(1);
5151 items[0].source = MediaSource::Local {
5152 file_path: "/music/track.flac".into(),
5153 jellyfin_item_id: Some("item_0".to_string()),
5154 };
5155 controller.play_queue(items, 0).unwrap();
5156 controller.seek(45.0).unwrap();
5157
5158 assert!(controller.recoverable_error_resume().is_none());
5159 }
5160
5161 #[tokio::test]
5166 async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
5167 let controller = PlayerController::default();
5168
5169 controller
5170 .play_queue(vec![audio_only_episode(1500.0)], 0)
5171 .unwrap();
5172 controller.seek(600.0).unwrap();
5173
5174 let mut waits = Vec::new();
5175 for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
5176 let (position, delay) = controller
5177 .recoverable_error_resume()
5178 .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
5179 assert_eq!(position, 600.0);
5180 waits.push(delay);
5181 }
5182 assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
5183 assert!(
5184 controller.recoverable_error_resume().is_none(),
5185 "a stream that keeps failing at the same spot must surface the error"
5186 );
5187 }
5188
5189 #[tokio::test]
5192 async fn test_recoverable_error_ignores_direct_url_playback() {
5193 let controller = PlayerController::default();
5194 controller.play_queue(create_test_items(2), 0).unwrap();
5195
5196 assert!(controller.recoverable_error_resume().is_none());
5197 }
5198
5199 #[tokio::test]
5203 async fn test_resume_truncated_stream_reloads_at_position() {
5204 let controller = PlayerController::default();
5205
5206 controller
5207 .play_queue(vec![audio_only_episode(1500.0)], 0)
5208 .unwrap();
5209 controller.set_background_audio_base(0.0);
5210
5211 controller
5212 .resume_stream_at(600.0)
5213 .await
5214 .expect("resume should succeed");
5215
5216 let current = controller
5217 .queue
5218 .lock_safe()
5219 .current()
5220 .cloned()
5221 .expect("the same item should still be loaded");
5222 assert_eq!(current.id, "ep2", "resume must not change the item");
5223 match ¤t.source {
5224 MediaSource::Remote { stream_url, .. } => {
5225 assert!(
5226 stream_url.contains("StartTimeTicks=6000000000"),
5227 "stream must re-open at 600s, got {}",
5228 stream_url
5229 );
5230 assert!(
5231 stream_url.contains("AudioStreamIndex=2"),
5232 "the selected audio track must survive the resume, got {}",
5233 stream_url
5234 );
5235 }
5236 other => panic!("expected Remote source, got {:?}", other),
5237 }
5238 assert_eq!(
5239 controller.take_background_audio_base(),
5240 600.0,
5241 "the re-opened stream's zero is the resume position"
5242 );
5243 }
5244
5245 #[tokio::test]
5249 async fn test_auto_advance_foreground_video_episode_uses_countdown() {
5250 let controller = PlayerController::default();
5251 controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
5252
5253 let episode = MediaItem {
5254 transport: None,
5256 id: "ep2".to_string(),
5257 item_type: Some("Episode".to_string()),
5258 media_type: MediaType::Video,
5259 series_id: Some("series1".to_string()),
5260 source: MediaSource::Remote {
5261 stream_url: "http://example.com/ep2.m3u8".to_string(),
5262 jellyfin_item_id: "ep2".to_string(),
5263 },
5264 ..create_test_items(1).remove(0)
5265 };
5266 controller.play_queue(vec![episode], 0).unwrap();
5267
5268 let next = make_repo_episode("ep3", 3);
5269 controller.auto_advance_to_next_episode(next, 10).await;
5270
5271 let current = controller
5272 .queue
5273 .lock_safe()
5274 .current()
5275 .cloned()
5276 .expect("an item should still be loaded");
5277 assert_eq!(
5278 current.id, "ep2",
5279 "foreground video advance is frontend-driven; the backend must not \
5280 swap the queue item out from under it"
5281 );
5282 }
5283
5284 #[tokio::test]
5287 async fn test_playback_ended_without_repository_stops() {
5288 let controller = PlayerController::default();
5289
5290 let episode = MediaItem {
5291 transport: None,
5293 media_type: MediaType::Video,
5294 source: MediaSource::Remote {
5295 stream_url: "http://example.com/ep1.mkv".to_string(),
5296 jellyfin_item_id: "ep1".to_string(),
5297 },
5298 ..create_test_items(1).remove(0)
5299 };
5300 controller.play_queue(vec![episode], 0).unwrap();
5301 controller.take_end_reason();
5302
5303 let decision = controller.on_playback_ended().await.unwrap();
5304 assert!(matches!(decision, AutoplayDecision::Stop));
5305 }
5306}