1use crate::utils::lock::{MutexSafe, RwLockSafe};
2use log::{debug, error, info};
3use serde::{Deserialize, Serialize};
4use std::sync::{
5 atomic::{AtomicBool, Ordering},
6 Arc, Mutex, RwLock,
7};
8use tokio::sync::Mutex as TokioMutex;
9use tokio::time::{sleep, Duration};
10
11use crate::jellyfin::JellyfinClient;
12use crate::player::{PlayerController, PlayerEventEmitter, PlayerStatusEvent, QueueContext};
13
14#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(tag = "type", rename_all = "lowercase")]
17pub enum PlaybackMode {
18 Local,
19 Remote { session_id: String },
20 Idle,
21}
22
23const TICKS_PER_SECOND: f64 = 10_000_000.0;
25
26const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
29
30const DEFAULT_REMOTE_VOLUME: i32 = 50;
33
34fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
42 if position_seconds > RESUME_THRESHOLD_SECONDS {
43 Some((position_seconds * TICKS_PER_SECOND) as i64)
44 } else {
45 None
46 }
47}
48
49pub trait RemoteVolumeControl: Send + Sync {
58 fn enable(&self, initial_volume: i32);
60 fn disable(&self);
62}
63
64struct PlatformRemoteVolumeControl;
66
67impl RemoteVolumeControl for PlatformRemoteVolumeControl {
68 #[allow(unused_variables)]
69 fn enable(&self, initial_volume: i32) {
70 #[cfg(target_os = "android")]
71 {
72 if let Err(e) = crate::player::enable_remote_volume(initial_volume) {
73 log::warn!(
74 "[PlaybackMode] Failed to enable remote volume/service: {}",
75 e
76 );
77 }
79 }
80 }
81
82 fn disable(&self) {
83 #[cfg(target_os = "android")]
84 {
85 if let Err(e) = crate::player::disable_remote_volume() {
86 log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
87 }
89 }
90 }
91}
92
93pub struct PlaybackModeManager {
95 jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
96 player_controller: Arc<TokioMutex<PlayerController>>,
97 current_mode: Arc<RwLock<PlaybackMode>>,
98 is_transferring: Arc<AtomicBool>,
99 event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
102 remote_volume: Arc<dyn RemoteVolumeControl>,
104}
105
106impl PlaybackModeManager {
107 pub fn new(
109 jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
110 player_controller: Arc<TokioMutex<PlayerController>>,
111 ) -> Self {
112 Self {
113 jellyfin_client,
114 player_controller,
115 current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
116 is_transferring: Arc::new(AtomicBool::new(false)),
117 event_emitter: Arc::new(Mutex::new(None)),
118 remote_volume: Arc::new(PlatformRemoteVolumeControl),
119 }
120 }
121
122 #[cfg(test)]
124 fn with_remote_volume(
125 jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
126 player_controller: Arc<TokioMutex<PlayerController>>,
127 remote_volume: Arc<dyn RemoteVolumeControl>,
128 ) -> Self {
129 Self {
130 jellyfin_client,
131 player_controller,
132 current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
133 is_transferring: Arc::new(AtomicBool::new(false)),
134 event_emitter: Arc::new(Mutex::new(None)),
135 remote_volume,
136 }
137 }
138
139 pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
143 *self.event_emitter.lock_safe() = Some(emitter);
144 }
145
146 pub fn get_mode(&self) -> PlaybackMode {
148 self.current_mode.read_safe().clone()
149 }
150
151 pub fn set_mode(&self, mode: PlaybackMode) {
167 log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
168 let (changed, was_remote) = {
169 let mut current = self.current_mode.write_safe();
170 let changed = *current != mode;
171 let was_remote = matches!(*current, PlaybackMode::Remote { .. });
172 *current = mode.clone();
173 (changed, was_remote)
174 };
175
176 if !changed {
177 return;
178 }
179
180 let is_remote = matches!(mode, PlaybackMode::Remote { .. });
183 if is_remote {
184 self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
185 } else if was_remote {
186 log::info!("[PlaybackMode] Leaving remote mode - restoring local volume control");
187 self.remote_volume.disable();
188 }
189
190 let (mode_str, session_id) = match &mode {
191 PlaybackMode::Local => ("local".to_string(), None),
192 PlaybackMode::Idle => ("idle".to_string(), None),
193 PlaybackMode::Remote { session_id } => ("remote".to_string(), Some(session_id.clone())),
194 };
195
196 if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
197 emitter.emit(PlayerStatusEvent::PlaybackModeChanged {
198 mode: mode_str,
199 session_id,
200 });
201 }
202 }
203
204 fn enable_remote_control(&self) {
219 self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
220 }
221
222 pub fn is_transferring(&self) -> bool {
224 self.is_transferring.load(Ordering::Relaxed)
225 }
226
227 pub fn set_transferring(&self, transferring: bool) {
237 self.is_transferring.store(transferring, Ordering::Relaxed);
238 }
239
240 #[allow(dead_code)] pub async fn send_remote_volume_command(
244 &self,
245 command: &str,
246 volume: i32,
247 ) -> Result<(), String> {
248 log::info!(
249 "[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}",
250 command,
251 volume
252 );
253
254 let session_id = match self.get_mode() {
256 PlaybackMode::Remote { session_id } => session_id,
257 _ => {
258 log::warn!("[PlaybackMode] Ignoring remote volume command - not in remote mode");
259 return Ok(());
260 }
261 };
262
263 log::info!(
264 "[PlaybackMode] Current mode is Remote, session_id={}",
265 session_id
266 );
267
268 let client = {
270 log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
271 let client_opt = self.jellyfin_client.lock().map_err(|e| {
272 log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
273 format!("Failed to lock Jellyfin client: {}", e)
274 })?;
275
276 log::info!("[PlaybackMode] Jellyfin client lock acquired");
277
278 match client_opt.as_ref() {
279 Some(c) => {
280 log::info!("[PlaybackMode] Jellyfin client is configured, cloning...");
281 c.clone()
282 }
283 None => {
284 log::error!("[PlaybackMode] Jellyfin client is NOT configured!");
285 return Err("Jellyfin client not configured".to_string());
286 }
287 }
288 };
289
290 log::info!("[PlaybackMode] About to call client.session_set_volume...");
291
292 log::info!(
294 "[PlaybackMode] Sending {} command to session {} (volume: {})",
295 command,
296 session_id,
297 volume
298 );
299 let result = client.session_set_volume(session_id, volume).await;
300
301 match &result {
302 Ok(_) => log::info!("[PlaybackMode] session_set_volume returned Ok"),
303 Err(e) => log::error!("[PlaybackMode] session_set_volume returned Err: {}", e),
304 }
305
306 result
307 }
308
309 fn extract_jellyfin_ids(
312 &self,
313 items: &[crate::player::MediaItem],
314 original_index: usize,
315 ) -> Result<(Vec<String>, usize), String> {
316 let mut jellyfin_ids: Vec<String> = Vec::new();
317 let mut adjusted_index: Option<usize> = None;
318 let mut jellyfin_item_count = 0;
319
320 for (i, item) in items.iter().enumerate() {
321 if let Some(id) = item.jellyfin_id() {
322 jellyfin_ids.push(id.to_string());
323
324 if i == original_index {
326 adjusted_index = Some(jellyfin_item_count);
327 }
328
329 jellyfin_item_count += 1;
330 }
331 }
332
333 let final_index = match adjusted_index {
335 Some(idx) => idx,
336 None => {
337 log::warn!(
338 "[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
339 original_index
340 );
341 return Err(
342 "Cannot transfer: currently playing item is not from Jellyfin".to_string(),
343 );
344 }
345 };
346
347 log::info!(
348 "[PlaybackMode] Extracted {} Jellyfin IDs from queue (original index: {} -> adjusted: {})",
349 jellyfin_ids.len(),
350 original_index,
351 final_index
352 );
353
354 Ok((jellyfin_ids, final_index))
355 }
356
357 pub async fn transfer_to_remote(
359 &self,
360 session_id: String,
361 position_override: Option<f64>,
362 ) -> Result<(), String> {
363 debug!("[PlaybackMode] transfer_to_remote ENTERED");
364 debug!("[PlaybackMode] session_id: {}", session_id);
365 log::info!(
366 "[PlaybackMode] Transferring to remote session: {}",
367 session_id
368 );
369
370 debug!("[PlaybackMode] Setting is_transferring flag");
372 self.is_transferring.store(true, Ordering::Relaxed);
373 debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
374
375 let result = self
377 .transfer_to_remote_inner(&session_id, position_override)
378 .await;
379
380 self.is_transferring.store(false, Ordering::Relaxed);
382
383 result
384 }
385
386 async fn transfer_to_remote_inner(
387 &self,
388 session_id: &str,
389 position_override: Option<f64>,
390 ) -> Result<(), String> {
391 log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
392 debug!(
393 "[PlaybackMode] transfer_to_remote_inner: session_id={}",
394 session_id
395 );
396
397 let previous_remote_session = match self.get_mode() {
401 PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev),
402 _ => None,
403 };
404
405 let (queue_ids, mut current_index, mut position_seconds, queue_context) = {
407 log::info!("[PlaybackMode] Acquiring player controller lock...");
408 debug!("[PlaybackMode] Acquiring player controller lock...");
409 let player = self.player_controller.lock().await;
410 log::info!("[PlaybackMode] Player controller lock acquired");
411 debug!("[PlaybackMode] Player controller lock acquired");
412
413 let queue_arc = player.queue();
414 let queue = queue_arc.lock_safe();
415
416 let original_index = queue.current_index().unwrap_or(0);
417 let items = queue.items();
418
419 log::info!(
420 "[PlaybackMode] Queue has {} items, original_index={}",
421 items.len(),
422 original_index
423 );
424 debug!(
425 "[PlaybackMode] Queue has {} items, original_index={}",
426 items.len(),
427 original_index
428 );
429
430 for (i, item) in items.iter().enumerate() {
432 let jf_id = item.jellyfin_id().unwrap_or("NONE");
433 log::debug!(
434 "[PlaybackMode] Item {}: id={}, jellyfin_id={}",
435 i,
436 item.id,
437 jf_id
438 );
439 }
440
441 let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
442 let position = match position_override {
449 Some(p) => {
450 log::info!("[PlaybackMode] Using frontend position override: {:.2}s", p);
451 p
452 }
453 None => player.position(),
454 };
455 let context = queue.context().clone();
456
457 log::info!(
458 "[PlaybackMode] Queue context: {:?}, {} items, current index: {}",
459 context,
460 ids.len(),
461 adjusted_index
462 );
463 debug!(
464 "[PlaybackMode] Extracted {} jellyfin IDs, adjusted_index={}, position={:.2}s",
465 ids.len(),
466 adjusted_index,
467 position
468 );
469
470 (ids, adjusted_index, position, context)
471 };
472
473 if queue_ids.is_empty() {
475 log::info!("[PlaybackMode] Queue is empty, just switching mode");
476 self.set_mode(PlaybackMode::Remote {
477 session_id: session_id.to_string(),
478 });
479 self.enable_remote_control();
483 return Ok(());
484 }
485
486 log::info!(
487 "[PlaybackMode] Queue has {} items, current index: {}, position: {:.2}s",
488 queue_ids.len(),
489 current_index,
490 position_seconds
491 );
492
493 log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
495 debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
496 let client = {
497 let client_opt = self.jellyfin_client.lock().map_err(|e| {
498 log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
499 error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
500 format!("Failed to lock Jellyfin client: {}", e)
501 })?;
502
503 match client_opt.as_ref() {
504 Some(c) => {
505 log::info!("[PlaybackMode] Jellyfin client is configured");
506 debug!("[PlaybackMode] Jellyfin client is configured");
507 c.clone()
508 }
509 None => {
510 log::error!("[PlaybackMode] Jellyfin client NOT configured!");
511 error!("[PlaybackMode] Jellyfin client NOT configured!");
512 return Err("Jellyfin client not configured".to_string());
513 }
514 }
515 };
516
517 if let Some(ref prev_session_id) = previous_remote_session {
520 log::info!(
521 "[PlaybackMode] Remote->remote switch; reading state from previous session {}",
522 prev_session_id
523 );
524 match client.get_session(prev_session_id).await {
525 Ok(Some(session)) => {
526 if let Some(ticks) =
528 session.play_state.as_ref().and_then(|ps| ps.position_ticks)
529 {
530 position_seconds = ticks as f64 / TICKS_PER_SECOND;
531 log::info!(
532 "[PlaybackMode] Using previous remote position: {:.2}s",
533 position_seconds
534 );
535 }
536 if let Some(now_id) = session
538 .now_playing_item
539 .as_ref()
540 .and_then(|i| i.id.as_deref())
541 {
542 if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
543 log::info!(
544 "[PlaybackMode] Previous session is on track {} (queue index {})",
545 now_id,
546 idx
547 );
548 current_index = idx;
549 } else {
550 log::warn!(
551 "[PlaybackMode] Previous session's track {} not found in queue; keeping index {}",
552 now_id,
553 current_index
554 );
555 }
556 }
557 }
558 Ok(None) => log::warn!(
559 "[PlaybackMode] Previous remote session not found while reading state"
560 ),
561 Err(e) => log::warn!(
562 "[PlaybackMode] Failed to read previous remote session: {}",
563 e
564 ),
565 }
566 }
567
568 let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
570
571 match &queue_context {
573 QueueContext::Album {
574 album_id,
575 album_name,
576 } => {
577 log::info!(
578 "[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
579 album_name,
580 album_id,
581 queue_ids.len()
582 );
583 }
584 QueueContext::Playlist {
585 playlist_id,
586 playlist_name,
587 } => {
588 log::info!(
589 "[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
590 playlist_name,
591 playlist_id,
592 queue_ids.len()
593 );
594 }
595 QueueContext::Custom => {
596 log::info!(
597 "[PlaybackMode] Transferring custom queue with {} tracks to remote",
598 queue_ids.len()
599 );
600 }
601 }
602
603 let expected_item_id = queue_ids
606 .get(current_index)
607 .cloned()
608 .ok_or("Invalid start index")?;
609
610 log::info!(
612 "[PlaybackMode] Sending play command to remote session: {} ({} tracks, starting at index {}, position: {:.2}s)",
613 session_id,
614 queue_ids.len(),
615 current_index,
616 position_seconds
617 );
618 debug!(
619 "[PlaybackMode] Calling play_on_session: session={}, tracks={}, index={}, position_ticks={:?}",
620 session_id,
621 queue_ids.len(),
622 current_index,
623 start_position_ticks
624 );
625
626 if !queue_ids.is_empty() {
628 let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect();
629 debug!("[PlaybackMode] First track IDs: {:?}...", preview);
630 }
631
632 client
633 .play_on_session(
634 session_id.to_string(),
635 queue_ids.clone(),
636 current_index,
637 start_position_ticks,
638 )
639 .await
640 .map_err(|e| {
641 log::error!("[PlaybackMode] Failed to send play command: {}", e);
642 error!("[PlaybackMode] Failed to send play command: {}", e);
643 format!("Failed to start playback on remote session: {}", e)
644 })?;
645
646 log::info!("[PlaybackMode] Play command sent successfully");
647 info!("[PlaybackMode] Play command sent successfully to remote session");
648
649 log::info!("[PlaybackMode] Waiting for remote session to load track...");
651
652 let mut attempts = 0;
653 let max_attempts = 50; let mut track_loaded = false;
655
656 while attempts < max_attempts {
657 sleep(Duration::from_millis(100)).await;
658 attempts += 1;
659
660 match client.get_session(session_id).await {
661 Ok(Some(session)) => {
662 if let Some(now_playing) = &session.now_playing_item {
663 if now_playing.id.as_deref() == Some(&expected_item_id) {
664 log::info!(
665 "[PlaybackMode] Remote session loaded track '{}' after {}ms",
666 now_playing.name.as_deref().unwrap_or("Unknown"),
667 attempts * 100
668 );
669 track_loaded = true;
670 break;
671 }
672 }
673 }
674 Ok(None) => {
675 log::warn!("[PlaybackMode] Remote session not found while polling");
676 return Err("Remote session not found".to_string());
677 }
678 Err(e) => {
679 log::warn!(
680 "[PlaybackMode] Error polling session (attempt {}): {}",
681 attempts,
682 e
683 );
684 }
686 }
687 }
688
689 if !track_loaded {
690 log::error!("[PlaybackMode] Timeout waiting for remote session to load track");
691 return Err("Remote session did not load track in time".to_string());
692 }
693
694 if let Some(ticks) = start_position_ticks {
700 log::info!(
701 "[PlaybackMode] Seeking remote session to resume position: {} ticks",
702 ticks
703 );
704 if let Err(e) = client.session_seek(session_id.to_string(), ticks).await {
705 log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
708 }
709 }
710
711 if let Some(prev_session_id) = previous_remote_session {
715 log::info!(
716 "[PlaybackMode] Stopping previous remote session {}",
717 prev_session_id
718 );
719 if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
720 log::warn!(
721 "[PlaybackMode] Failed to stop previous remote session: {}",
722 e
723 );
724 }
725 }
726
727 log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
729 {
730 let player = self.player_controller.lock().await;
731
732 {
734 let queue_arc = player.queue();
735 let queue = queue_arc.lock_safe();
736 info!(
737 "[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
738 queue.items().len(),
739 queue.current_index()
740 );
741 }
742
743 player
744 .stop()
745 .map_err(|e| format!("Failed to stop playback: {}", e))?;
746
747 {
749 let queue_arc = player.queue();
750 let queue = queue_arc.lock_safe();
751 info!(
752 "[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
753 queue.items().len(),
754 queue.current_index()
755 );
756 }
757 }
758
759 self.set_mode(PlaybackMode::Remote {
761 session_id: session_id.to_string(),
762 });
763
764 self.enable_remote_control();
767
768 log::info!("[PlaybackMode] Successfully transferred to remote");
769 Ok(())
770 }
771
772 pub async fn transfer_to_local(
774 &self,
775 current_item_id: String,
776 position_ticks: i64,
777 ) -> Result<(), String> {
778 log::info!("[PlaybackMode] Transferring to local playback");
779
780 self.is_transferring.store(true, Ordering::Relaxed);
782
783 let result = self
785 .transfer_to_local_inner(¤t_item_id, position_ticks)
786 .await;
787
788 self.is_transferring.store(false, Ordering::Relaxed);
790
791 result
792 }
793
794 async fn transfer_to_local_inner(
795 &self,
796 current_item_id: &str,
797 position_ticks: i64,
798 ) -> Result<(), String> {
799 let session_id = match self.get_mode() {
801 PlaybackMode::Remote { session_id } => session_id,
802 _ => return Err("Not in remote playback mode".to_string()),
803 };
804
805 let position_seconds = position_ticks as f64 / 10_000_000.0;
806
807 log::info!(
808 "[PlaybackMode] Transfer to local: session={}, item_id={}, position={:.2}s",
809 session_id,
810 current_item_id,
811 position_seconds
812 );
813
814 let client = {
816 let client_opt = self
817 .jellyfin_client
818 .lock()
819 .map_err(|e| format!("Failed to lock Jellyfin client: {}", e))?;
820
821 client_opt
822 .as_ref()
823 .ok_or("Jellyfin client not configured")?
824 .clone()
825 };
826
827 log::info!(
829 "[PlaybackMode] Stopping remote playback on session: {}",
830 session_id
831 );
832 match client
833 .send_session_command(session_id.clone(), "Stop")
834 .await
835 {
836 Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
837 Err(e) => {
838 log::warn!(
839 "[PlaybackMode] Failed to stop remote session (non-fatal): {}",
840 e
841 );
842 }
845 }
846
847 log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
851
852 self.set_mode(PlaybackMode::Local);
855
856 log::info!("[PlaybackMode] Successfully transferred to local");
857 Ok(())
858 }
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864
865 #[test]
870 fn test_playback_mode_serialization() {
871 let mode_idle = PlaybackMode::Idle;
872 let json = serde_json::to_string(&mode_idle).unwrap();
873 assert_eq!(json, r#"{"type":"idle"}"#);
874
875 let mode_local = PlaybackMode::Local;
876 let json = serde_json::to_string(&mode_local).unwrap();
877 assert_eq!(json, r#"{"type":"local"}"#);
878
879 let mode_remote = PlaybackMode::Remote {
880 session_id: "abc123".to_string(),
881 };
882 let json = serde_json::to_string(&mode_remote).unwrap();
883 assert!(json.contains(r#""type":"remote""#));
884 assert!(json.contains(r#""session_id":"abc123""#));
885 }
886
887 #[test]
891 fn test_playback_mode_deserialization() {
892 let json = r#"{"type":"idle"}"#;
893 let mode: PlaybackMode = serde_json::from_str(json).unwrap();
894 assert_eq!(mode, PlaybackMode::Idle);
895
896 let json = r#"{"type":"local"}"#;
897 let mode: PlaybackMode = serde_json::from_str(json).unwrap();
898 assert_eq!(mode, PlaybackMode::Local);
899
900 let json = r#"{"type":"remote","session_id":"test_session"}"#;
901 let mode: PlaybackMode = serde_json::from_str(json).unwrap();
902 assert_eq!(
903 mode,
904 PlaybackMode::Remote {
905 session_id: "test_session".to_string()
906 }
907 );
908 }
909
910 struct CapturingEmitter {
912 events: Mutex<Vec<PlayerStatusEvent>>,
913 }
914
915 impl PlayerEventEmitter for CapturingEmitter {
916 fn emit(&self, event: PlayerStatusEvent) {
917 self.events.lock_safe().push(event);
918 }
919 }
920
921 fn manager_with_emitter() -> (PlaybackModeManager, Arc<CapturingEmitter>) {
922 let emitter = Arc::new(CapturingEmitter {
923 events: Mutex::new(Vec::new()),
924 });
925 let manager = PlaybackModeManager::new(
926 Arc::new(Mutex::new(None)),
927 Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
928 );
929 manager.set_event_emitter(emitter.clone());
930 (manager, emitter)
931 }
932
933 #[test]
936 fn test_set_mode_emits_change_event() {
937 let (manager, emitter) = manager_with_emitter();
938
939 manager.set_mode(PlaybackMode::Remote {
940 session_id: "sess-1".to_string(),
941 });
942 manager.set_mode(PlaybackMode::Local);
943 manager.set_mode(PlaybackMode::Idle);
944
945 let events = emitter.events.lock_safe();
946 assert_eq!(events.len(), 3, "one event per real mode change");
947
948 match &events[0] {
949 PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
950 assert_eq!(mode, "remote");
951 assert_eq!(session_id.as_deref(), Some("sess-1"));
952 }
953 other => panic!("expected PlaybackModeChanged, got {:?}", other),
954 }
955 match &events[1] {
956 PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
957 assert_eq!(mode, "local");
958 assert_eq!(session_id.as_deref(), None);
959 }
960 other => panic!("expected PlaybackModeChanged, got {:?}", other),
961 }
962 match &events[2] {
963 PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
964 assert_eq!(mode, "idle");
965 assert_eq!(session_id.as_deref(), None);
966 }
967 other => panic!("expected PlaybackModeChanged, got {:?}", other),
968 }
969 }
970
971 struct RecordingVolumeControl {
973 calls: Mutex<Vec<&'static str>>,
974 }
975
976 impl RemoteVolumeControl for RecordingVolumeControl {
977 fn enable(&self, _initial_volume: i32) {
978 self.calls.lock_safe().push("enable");
979 }
980 fn disable(&self) {
981 self.calls.lock_safe().push("disable");
982 }
983 }
984
985 fn manager_with_volume_control() -> (PlaybackModeManager, Arc<RecordingVolumeControl>) {
986 let volume = Arc::new(RecordingVolumeControl {
987 calls: Mutex::new(Vec::new()),
988 });
989 let manager = PlaybackModeManager::with_remote_volume(
990 Arc::new(Mutex::new(None)),
991 Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
992 volume.clone(),
993 );
994 (manager, volume)
995 }
996
997 #[test]
1007 fn test_leaving_remote_mode_restores_local_volume() {
1008 let (manager, volume) = manager_with_volume_control();
1009
1010 manager.set_mode(PlaybackMode::Remote {
1011 session_id: "sess-1".to_string(),
1012 });
1013 manager.set_mode(PlaybackMode::Idle);
1015
1016 assert_eq!(
1017 *volume.calls.lock_safe(),
1018 vec!["enable", "disable"],
1019 "remote->idle must return volume control to the local speaker"
1020 );
1021 }
1022
1023 #[test]
1027 fn test_remote_to_local_restores_local_volume() {
1028 let (manager, volume) = manager_with_volume_control();
1029
1030 manager.set_mode(PlaybackMode::Remote {
1031 session_id: "sess-1".to_string(),
1032 });
1033 manager.set_mode(PlaybackMode::Local);
1034
1035 assert_eq!(
1036 *volume.calls.lock_safe(),
1037 vec!["enable", "disable"],
1038 "remote->local must return volume control to the local speaker"
1039 );
1040 }
1041
1042 #[test]
1048 fn test_non_remote_transitions_leave_volume_routing_alone() {
1049 let (manager, volume) = manager_with_volume_control();
1050
1051 manager.set_mode(PlaybackMode::Local);
1052 manager.set_mode(PlaybackMode::Idle);
1053 manager.set_mode(PlaybackMode::Local);
1054
1055 assert!(
1056 volume.calls.lock_safe().is_empty(),
1057 "local/idle transitions must not touch remote volume routing"
1058 );
1059 }
1060
1061 #[test]
1066 fn test_remote_to_remote_keeps_remote_volume() {
1067 let (manager, volume) = manager_with_volume_control();
1068
1069 manager.set_mode(PlaybackMode::Remote {
1070 session_id: "sess-1".to_string(),
1071 });
1072 manager.set_mode(PlaybackMode::Remote {
1073 session_id: "sess-2".to_string(),
1074 });
1075
1076 assert_eq!(
1077 *volume.calls.lock_safe(),
1078 vec!["enable", "enable"],
1079 "remote->remote re-arms control without releasing it to local"
1080 );
1081 }
1082
1083 #[test]
1086 fn test_set_mode_deduplicates_no_op() {
1087 let (manager, emitter) = manager_with_emitter();
1088
1089 manager.set_mode(PlaybackMode::Local);
1090 manager.set_mode(PlaybackMode::Local);
1091 manager.set_mode(PlaybackMode::Local);
1092
1093 assert_eq!(
1094 emitter.events.lock_safe().len(),
1095 1,
1096 "repeated identical mode set emits only once"
1097 );
1098 }
1099
1100 #[test]
1104 fn test_start_position_ticks_from_seconds() {
1105 assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
1107 assert_eq!(
1108 start_position_ticks_from_seconds(123.45),
1109 Some(1_234_500_000)
1110 );
1111
1112 assert_eq!(start_position_ticks_from_seconds(0.0), None);
1114 assert_eq!(start_position_ticks_from_seconds(0.5), None);
1115
1116 assert!(start_position_ticks_from_seconds(0.6).is_some());
1118 }
1119
1120 mod extract_jellyfin_ids_tests {
1122 use crate::player::{MediaItem, MediaSource, MediaType};
1123 use std::sync::{Arc, Mutex};
1124 use tokio::sync::Mutex as TokioMutex;
1125
1126 fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem {
1127 MediaItem {
1128 id: id.to_string(),
1129 title: format!("Track {}", id),
1130 name: Some(format!("Track {}", id)),
1131 artist: Some("Test Artist".to_string()),
1132 album: Some("Test Album".to_string()),
1133 album_name: Some("Test Album".to_string()),
1134 album_id: Some("album_123".to_string()),
1135 artist_items: None,
1136 artists: Some(vec!["Test Artist".to_string()]),
1137 primary_image_tag: None,
1138 image_id: None,
1139 item_type: Some("Audio".to_string()),
1140 playlist_id: None,
1141 duration: Some(180.0),
1142 artwork_url: None,
1143 media_type: MediaType::Audio,
1144 source: MediaSource::Remote {
1145 stream_url: format!("http://example.com/{}.mp3", id),
1146 jellyfin_item_id: jellyfin_id.to_string(),
1147 },
1148 video_codec: None,
1149 needs_transcoding: false,
1150 video_width: None,
1151 video_height: None,
1152 subtitles: vec![],
1153 series_id: None,
1154 server_id: None,
1155 }
1156 }
1157
1158 fn create_test_item_local(id: &str) -> MediaItem {
1159 MediaItem {
1160 id: id.to_string(),
1161 title: format!("Local Track {}", id),
1162 name: Some(format!("Local Track {}", id)),
1163 artist: Some("Test Artist".to_string()),
1164 album: None,
1165 album_name: None,
1166 album_id: None,
1167 artist_items: None,
1168 artists: Some(vec!["Test Artist".to_string()]),
1169 primary_image_tag: None,
1170 image_id: None,
1171 item_type: Some("Audio".to_string()),
1172 playlist_id: None,
1173 duration: Some(180.0),
1174 artwork_url: None,
1175 media_type: MediaType::Audio,
1176 source: MediaSource::DirectUrl {
1177 url: format!("http://example.com/{}.mp3", id),
1178 },
1179 video_codec: None,
1180 needs_transcoding: false,
1181 video_width: None,
1182 video_height: None,
1183 subtitles: vec![],
1184 series_id: None,
1185 server_id: None,
1186 }
1187 }
1188
1189 #[test]
1197 fn test_extract_all_jellyfin_ids_from_album() {
1198 let items: Vec<MediaItem> = (1..=5)
1200 .map(|i| {
1201 create_test_item_with_jellyfin_id(
1202 &format!("track_{}", i),
1203 &format!("jf_track_{}", i),
1204 )
1205 })
1206 .collect();
1207
1208 let manager = super::PlaybackModeManager::new(
1209 Arc::new(Mutex::new(None)),
1210 Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
1211 );
1212
1213 let result = manager.extract_jellyfin_ids(&items, 2);
1214 assert!(result.is_ok());
1215
1216 let (ids, index) = result.unwrap();
1217
1218 assert_eq!(ids.len(), 5, "All 5 track IDs should be extracted");
1220 assert_eq!(ids[0], "jf_track_1");
1221 assert_eq!(ids[1], "jf_track_2");
1222 assert_eq!(ids[2], "jf_track_3");
1223 assert_eq!(ids[3], "jf_track_4");
1224 assert_eq!(ids[4], "jf_track_5");
1225
1226 assert_eq!(index, 2, "Current index should be preserved");
1228 }
1229
1230 #[test]
1235 fn test_extract_filters_local_items() {
1236 let items = vec![
1238 create_test_item_with_jellyfin_id("1", "jf_1"),
1239 create_test_item_local("2"), create_test_item_with_jellyfin_id("3", "jf_3"),
1241 create_test_item_with_jellyfin_id("4", "jf_4"),
1242 ];
1243
1244 let manager = super::PlaybackModeManager::new(
1245 Arc::new(Mutex::new(None)),
1246 Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
1247 );
1248
1249 let result = manager.extract_jellyfin_ids(&items, 2);
1251 assert!(result.is_ok());
1252
1253 let (ids, index) = result.unwrap();
1254
1255 assert_eq!(ids.len(), 3);
1257 assert_eq!(ids[0], "jf_1");
1258 assert_eq!(ids[1], "jf_3");
1259 assert_eq!(ids[2], "jf_4");
1260
1261 assert_eq!(index, 1);
1263 }
1264
1265 #[test]
1270 fn test_extract_fails_when_current_item_is_local() {
1271 let items = vec![
1273 create_test_item_with_jellyfin_id("1", "jf_1"),
1274 create_test_item_local("2"), create_test_item_with_jellyfin_id("3", "jf_3"),
1276 ];
1277
1278 let manager = super::PlaybackModeManager::new(
1279 Arc::new(Mutex::new(None)),
1280 Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
1281 );
1282
1283 let result = manager.extract_jellyfin_ids(&items, 1);
1285 assert!(result.is_err());
1286 assert!(result.unwrap_err().contains("not from Jellyfin"));
1287 }
1288
1289 #[test]
1293 fn test_extract_empty_queue() {
1294 let items: Vec<MediaItem> = vec![];
1295
1296 let manager = super::PlaybackModeManager::new(
1297 Arc::new(Mutex::new(None)),
1298 Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
1299 );
1300
1301 let result = manager.extract_jellyfin_ids(&items, 0);
1302 assert!(result.is_err());
1303 }
1304 }
1305}