Skip to main content

jellytau_lib/playback_mode/
mod.rs

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/// Playback mode - local device, remote session, or idle
15#[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
23/// Number of Jellyfin ticks per second (100ns units).
24const TICKS_PER_SECOND: f64 = 10_000_000.0;
25
26/// Below this many seconds we treat the position as "at the start" and don't
27/// send a resume position, so a fresh track casts from 0 rather than ~0.
28const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
29
30/// Volume level (0-100) the remote volume slider starts at. The real level is
31/// corrected by the session poller once the remote session reports its volume.
32const DEFAULT_REMOTE_VOLUME: i32 = 50;
33
34/// Convert a live playback position (seconds) into the `StartPositionTicks` to
35/// hand to a remote session, or `None` if we're effectively at the start.
36///
37/// Pure helper so the resume-position math is unit-testable without a remote
38/// session or HTTP. The *source* of `position_seconds` matters too: callers
39/// must pass the live backend position (`PlayerController::position()`), not the
40/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
41fn 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
49/// Platform hook for attaching/detaching the OS remote-volume control.
50///
51/// On Android, entering remote mode hands the `MediaSession` a
52/// `VolumeProviderCompat` so hardware volume buttons and the system slider drive
53/// the *remote* session; leaving remote mode must hand it back to the local
54/// media stream. Behind a trait so the routing rule (see
55/// [`PlaybackModeManager::set_mode`]) is unit-testable off-device — the real
56/// implementation is JNI and only exists on Android.
57pub trait RemoteVolumeControl: Send + Sync {
58    /// Attach remote-volume control (and, on Android, start the playback service).
59    fn enable(&self, initial_volume: i32);
60    /// Return volume control to the local device speaker.
61    fn disable(&self);
62}
63
64/// Production hook: forwards to the Android JNI bridge; no-op elsewhere.
65struct 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                // Non-fatal - continue; the next poll tick will retry metadata.
78            }
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                // Non-fatal - the mode change itself has already happened.
88            }
89        }
90    }
91}
92
93/// Manages playback mode transfers between local and remote sessions
94pub 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    /// Optional emitter used to notify the frontend when the mode changes, so its
100    /// mirror store stays in sync with this authoritative one. `None` in tests.
101    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
102    /// Platform hook for OS-level remote volume routing (swapped in tests).
103    remote_volume: Arc<dyn RemoteVolumeControl>,
104}
105
106impl PlaybackModeManager {
107    /// Create a new playback mode manager
108    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    /// Construct with a custom remote-volume hook (tests).
123    #[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    /// Wire the event emitter so `set_mode` notifies the frontend. Called once
140    /// during setup; safe to leave unset (tests do), in which case mode changes
141    /// simply aren't broadcast.
142    pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
143        *self.event_emitter.lock_safe() = Some(emitter);
144    }
145
146    /// Get current playback mode
147    pub fn get_mode(&self) -> PlaybackMode {
148        self.current_mode.read_safe().clone()
149    }
150
151    /// Set playback mode (internal use).
152    ///
153    /// Broadcasts a `PlaybackModeChanged` event when the mode actually changes so
154    /// the frontend's mirror store reconciles to this authoritative value. The
155    /// write lock is released before emitting to avoid holding it across the
156    /// emitter call.
157    ///
158    /// Also owns **OS volume routing**, which is derived from the transition
159    /// rather than from each call site: entering remote mode attaches the remote
160    /// volume control, and *any* exit from remote mode hands it back to the local
161    /// speaker. Doing this per-call-site is what caused the bug where stopping a
162    /// remote session (`player_stop` → Idle) left Android stuck on the remote
163    /// volume slider — only the transfer-to-local path tore it down.
164    ///
165    /// TRACES: UR-010 | DR-059, IR-021
166    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        // Volume routing follows the transition. Note remote->remote (switching
181        // target session) re-arms rather than releasing control.
182        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    /// Start the Android playback service and hand it remote-volume control.
205    ///
206    /// Must run on EVERY transition into remote mode, because it is what starts
207    /// the foreground service. Without a running service there is no media
208    /// notification (the lockscreen card is missing) AND system volume buttons
209    /// aren't intercepted for the remote session (remote volume control dead).
210    /// Both symptoms share this one cause, so this must not be skipped on any
211    /// remote-entry path (notably the empty-queue early return in
212    /// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
213    ///
214    /// [`set_mode`](Self::set_mode) already arms this on entry into remote mode;
215    /// calling it again is harmless (the service start is idempotent) and keeps
216    /// the guarantee when the mode was already remote, which `set_mode` skips as
217    /// a no-op transition.
218    fn enable_remote_control(&self) {
219        self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
220    }
221
222    /// Check if currently transferring
223    pub fn is_transferring(&self) -> bool {
224        self.is_transferring.load(Ordering::Relaxed)
225    }
226
227    /// Set the transferring flag directly.
228    ///
229    /// The remote->local transfer is driven from the frontend in two steps
230    /// (`player_play_tracks` to start local playback, then
231    /// `playback_mode_transfer_to_local` to stop the remote). The first step's
232    /// routing depends on this flag: while it's set, `player_play_tracks` plays
233    /// locally instead of casting back to the remote session. The frontend must
234    /// raise the flag *before* that first call and lower it when the sequence is
235    /// done (or aborts), so it can't be left stuck on.
236    pub fn set_transferring(&self, transferring: bool) {
237        self.is_transferring.store(transferring, Ordering::Relaxed);
238    }
239
240    /// Send volume command to remote session
241    /// Commands: "SetVolume", "VolumeUp", "VolumeDown"
242    #[allow(dead_code)] // Called from Android JNI callback
243    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        // Get the current session ID
255        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        // Get Jellyfin client
269        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        // Send the volume command
293        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    /// Extract Jellyfin item IDs from queue items
310    /// Returns (item_ids, adjusted_current_index)
311    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 this is the currently playing item, record its new index
325                if i == original_index {
326                    adjusted_index = Some(jellyfin_item_count);
327                }
328
329                jellyfin_item_count += 1;
330            }
331        }
332
333        // Ensure the currently playing item has a Jellyfin ID
334        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    /// Transfer playback from local device to remote Jellyfin session
358    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        // Set transferring flag
371        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        // Perform the transfer
376        let result = self
377            .transfer_to_remote_inner(&session_id, position_override)
378            .await;
379
380        // Clear transferring flag
381        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        // If we're already controlling a remote session, that *old* session — not
398        // the idle local player — is the source of truth for the current track and
399        // position. Capture it so we can resume there and stop it afterwards.
400        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        // Get current player state and queue context
406        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            // Log each item's jellyfin_id for debugging
431            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            // Prefer the frontend-supplied position when available. The backend
443            // position is unreliable as a transfer source: on Linux, *video* plays
444            // in the HTML5 <video> element and the MPV backend is never loaded, so
445            // PlayerController::position() is always 0; only the frontend knows the
446            // true position. We fall back to the live backend position (correct for
447            // Linux audio via MPV) when the frontend doesn't pass one.
448            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 is empty, just switch mode
474        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            // Start the service + remote-volume control here too — otherwise this
480            // early return leaves remote mode with no media notification and no
481            // volume interception (lockscreen card missing + remote volume dead).
482            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        // Get Jellyfin client for remote transfer
494        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        // Remote -> remote switch: take the current track and position from the
518        // session we're leaving, since the local player is idle and reports 0.
519        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                    // Resume at the previous session's position.
527                    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                    // Resume on whichever track the previous session reached.
537                    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        // Calculate position in ticks (from the live position read above)
569        let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
570
571        // Log queue context for debugging (context is tracked but we always send track IDs)
572        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        // Always send individual track IDs - Jellyfin's play_on_session expects track IDs,
604        // not album/playlist container IDs
605        let expected_item_id = queue_ids
606            .get(current_index)
607            .cloned()
608            .ok_or("Invalid start index")?;
609
610        // Send play command to remote session with all track IDs
611        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        // Log first few track IDs for debugging
627        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        // Wait for remote session to load the track (poll with timeout)
650        log::info!("[PlaybackMode] Waiting for remote session to load track...");
651
652        let mut attempts = 0;
653        let max_attempts = 50; // 5 seconds max (50 * 100ms)
654        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                    // Continue polling - transient errors are OK
685                }
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        // Resume at the right position. We send StartPositionTicks in the play
695        // command above, but some Jellyfin client/server combinations ignore it
696        // and start from 0. Now that the track is confirmed loaded, issue an
697        // explicit seek as well (mirrors how the local resume path works). This
698        // is the reliable mechanism; StartPositionTicks is best-effort.
699        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                // Non-fatal: the track is already playing, just not at the
706                // resume point. Log and continue rather than failing the transfer.
707                log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
708            }
709        }
710
711        // Remote -> remote switch: stop the session we just left so we don't end
712        // up with two devices playing at once. Do this only after the new session
713        // is confirmed playing, so a failure here doesn't leave us with silence.
714        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        // Stop local playback (queue should remain intact for remote session)
728        log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
729        {
730            let player = self.player_controller.lock().await;
731
732            // Log queue state BEFORE stop
733            {
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            // Log queue state AFTER stop (should be unchanged)
748            {
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        // Update mode to remote
760        self.set_mode(PlaybackMode::Remote {
761            session_id: session_id.to_string(),
762        });
763
764        // Start the service + remote-volume control (intercepts volume buttons,
765        // and starts the foreground service that renders the lockscreen card).
766        self.enable_remote_control();
767
768        log::info!("[PlaybackMode] Successfully transferred to remote");
769        Ok(())
770    }
771
772    /// Transfer playback from remote session back to local device
773    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        // Set transferring flag
781        self.is_transferring.store(true, Ordering::Relaxed);
782
783        // Perform the transfer
784        let result = self
785            .transfer_to_local_inner(&current_item_id, position_ticks)
786            .await;
787
788        // Clear transferring flag
789        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        // Get current remote session info
800        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        // Get Jellyfin client for stopping remote playback
815        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        // Stop remote playback
828        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                // Don't fail the transfer if we can't stop the remote session
843                // The user is already playing locally, so this is not critical
844            }
845        }
846
847        // For now, we'll return an error indicating that the TypeScript side needs to handle
848        // loading the media item, since we don't have access to the repository here yet.
849        // This will be improved in Phase 3 when repository is migrated to Rust.
850        log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
851
852        // Update mode to local. This also returns volume control to the local
853        // device speaker — set_mode owns that for every exit from remote mode.
854        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 PlaybackMode enum serialization to JSON
866    ///
867    /// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
868    /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
869    #[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 PlaybackMode enum deserialization from JSON
888    ///
889    /// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
890    #[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    /// Capturing emitter so we can assert what `set_mode` broadcasts.
911    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    /// set_mode broadcasts a PlaybackModeChanged event with the right payload so
934    /// the frontend can reconcile its mirror store to this authoritative one.
935    #[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    /// Records enable/disable calls so tests can assert volume routing.
972    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    /// Leaving remote mode must hand volume control back to the local device.
998    ///
999    /// Stopping a remote session (`player_stop`) drives the manager
1000    /// Remote -> Idle without going through `transfer_to_local`. Before this was
1001    /// centralised in `set_mode`, only the transfer path tore the Android
1002    /// `VolumeProviderCompat` down, so a plain stop left the system stuck on the
1003    /// remote volume slider with no way back to the phone speaker.
1004    ///
1005    /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
1006    #[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        // The stop path: remote -> idle, no transfer involved.
1014        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    /// The same must hold for remote -> local (transfer back to this device).
1024    ///
1025    /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
1026    #[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    /// Volume routing must not be touched by transitions that never involve
1043    /// remote mode — an idle->local start would otherwise issue a pointless
1044    /// `setPlaybackToLocal` on every playback start.
1045    ///
1046    /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
1047    #[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    /// Switching directly between two remote sessions stays remote: control must
1062    /// remain attached (re-armed for the new session), never handed back local.
1063    ///
1064    /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
1065    #[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    /// Setting the same mode twice must not re-emit — the frontend reconciler
1084    /// (and the event channel) shouldn't be spammed on no-op transitions.
1085    #[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    /// The resume position handed to a remote session is derived from a live
1101    /// playback position. Guards the seconds->ticks conversion and the
1102    /// at-the-start threshold (Bug: casting restarted the track from 0).
1103    #[test]
1104    fn test_start_position_ticks_from_seconds() {
1105        // Mid-track positions convert to ticks (10M ticks per second).
1106        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        // At/near the start, send no resume position so the track casts from 0.
1113        assert_eq!(start_position_ticks_from_seconds(0.0), None);
1114        assert_eq!(start_position_ticks_from_seconds(0.5), None);
1115
1116        // Just past the threshold resumes rather than restarting.
1117        assert!(start_position_ticks_from_seconds(0.6).is_some());
1118    }
1119
1120    // Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
1121    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                // Audio and direct-URL items never negotiate a transport.
1129                transport: None,
1130                id: id.to_string(),
1131                title: format!("Track {}", id),
1132                name: Some(format!("Track {}", id)),
1133                artist: Some("Test Artist".to_string()),
1134                album: Some("Test Album".to_string()),
1135                album_name: Some("Test Album".to_string()),
1136                album_id: Some("album_123".to_string()),
1137                artist_items: None,
1138                artists: Some(vec!["Test Artist".to_string()]),
1139                primary_image_tag: None,
1140                image_id: None,
1141                item_type: Some("Audio".to_string()),
1142                playlist_id: None,
1143                duration: Some(180.0),
1144                artwork_url: None,
1145                media_type: MediaType::Audio,
1146                source: MediaSource::Remote {
1147                    stream_url: format!("http://example.com/{}.mp3", id),
1148                    jellyfin_item_id: jellyfin_id.to_string(),
1149                },
1150                video_codec: None,
1151                needs_transcoding: false,
1152                video_width: None,
1153                video_height: None,
1154                subtitles: vec![],
1155                series_id: None,
1156                server_id: None,
1157            }
1158        }
1159
1160        fn create_test_item_local(id: &str) -> MediaItem {
1161            MediaItem {
1162                // Audio and direct-URL items never negotiate a transport.
1163                transport: None,
1164                id: id.to_string(),
1165                title: format!("Local Track {}", id),
1166                name: Some(format!("Local Track {}", id)),
1167                artist: Some("Test Artist".to_string()),
1168                album: None,
1169                album_name: None,
1170                album_id: None,
1171                artist_items: None,
1172                artists: Some(vec!["Test Artist".to_string()]),
1173                primary_image_tag: None,
1174                image_id: None,
1175                item_type: Some("Audio".to_string()),
1176                playlist_id: None,
1177                duration: Some(180.0),
1178                artwork_url: None,
1179                media_type: MediaType::Audio,
1180                source: MediaSource::DirectUrl {
1181                    url: format!("http://example.com/{}.mp3", id),
1182                },
1183                video_codec: None,
1184                needs_transcoding: false,
1185                video_width: None,
1186                video_height: None,
1187                subtitles: vec![],
1188                series_id: None,
1189                server_id: None,
1190            }
1191        }
1192
1193        /// Test extracting all Jellyfin track IDs from album
1194        ///
1195        /// Verifies that all individual track IDs are extracted, not just the album ID.
1196        ///
1197        /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
1198        /// @req-test: DR-003 - Playback mode manager (Jellyfin ID extraction)
1199        /// @req-test: IR-012 - Jellyfin Sessions API for remote playback control
1200        #[test]
1201        fn test_extract_all_jellyfin_ids_from_album() {
1202            // Simulate an album with 5 tracks - all should be extracted
1203            let items: Vec<MediaItem> = (1..=5)
1204                .map(|i| {
1205                    create_test_item_with_jellyfin_id(
1206                        &format!("track_{}", i),
1207                        &format!("jf_track_{}", i),
1208                    )
1209                })
1210                .collect();
1211
1212            let manager = super::PlaybackModeManager::new(
1213                Arc::new(Mutex::new(None)),
1214                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
1215            );
1216
1217            let result = manager.extract_jellyfin_ids(&items, 2);
1218            assert!(result.is_ok());
1219
1220            let (ids, index) = result.unwrap();
1221
1222            // All 5 track IDs should be extracted (not just the album ID)
1223            assert_eq!(ids.len(), 5, "All 5 track IDs should be extracted");
1224            assert_eq!(ids[0], "jf_track_1");
1225            assert_eq!(ids[1], "jf_track_2");
1226            assert_eq!(ids[2], "jf_track_3");
1227            assert_eq!(ids[3], "jf_track_4");
1228            assert_eq!(ids[4], "jf_track_5");
1229
1230            // Index should point to track 3 (original index 2)
1231            assert_eq!(index, 2, "Current index should be preserved");
1232        }
1233
1234        /// Test extracting Jellyfin IDs filters out local items
1235        ///
1236        /// @req-test: UR-010 - Control playback of remote sessions (local filtering)
1237        /// @req-test: DR-003 - Playback mode manager (local item filtering)
1238        #[test]
1239        fn test_extract_filters_local_items() {
1240            // Mix of Jellyfin and local items - only Jellyfin items should be extracted
1241            let items = vec![
1242                create_test_item_with_jellyfin_id("1", "jf_1"),
1243                create_test_item_local("2"), // Local, no Jellyfin ID
1244                create_test_item_with_jellyfin_id("3", "jf_3"),
1245                create_test_item_with_jellyfin_id("4", "jf_4"),
1246            ];
1247
1248            let manager = super::PlaybackModeManager::new(
1249                Arc::new(Mutex::new(None)),
1250                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
1251            );
1252
1253            // Playing track 3 (index 2 in original, should become index 1 after filtering)
1254            let result = manager.extract_jellyfin_ids(&items, 2);
1255            assert!(result.is_ok());
1256
1257            let (ids, index) = result.unwrap();
1258
1259            // Only 3 Jellyfin tracks should be extracted
1260            assert_eq!(ids.len(), 3);
1261            assert_eq!(ids[0], "jf_1");
1262            assert_eq!(ids[1], "jf_3");
1263            assert_eq!(ids[2], "jf_4");
1264
1265            // Index should be adjusted (track 3 is now at position 1)
1266            assert_eq!(index, 1);
1267        }
1268
1269        /// Test extraction fails when current item is local
1270        ///
1271        /// @req-test: DR-003 - Playback mode manager (error handling)
1272        /// @req-test: UR-010 - Control playback of remote sessions (validation)
1273        #[test]
1274        fn test_extract_fails_when_current_item_is_local() {
1275            // Current item has no Jellyfin ID - should fail
1276            let items = vec![
1277                create_test_item_with_jellyfin_id("1", "jf_1"),
1278                create_test_item_local("2"), // Local, no Jellyfin ID
1279                create_test_item_with_jellyfin_id("3", "jf_3"),
1280            ];
1281
1282            let manager = super::PlaybackModeManager::new(
1283                Arc::new(Mutex::new(None)),
1284                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
1285            );
1286
1287            // Playing the local track (index 1) should fail
1288            let result = manager.extract_jellyfin_ids(&items, 1);
1289            assert!(result.is_err());
1290            assert!(result.unwrap_err().contains("not from Jellyfin"));
1291        }
1292
1293        /// Test extraction fails on empty queue
1294        ///
1295        /// @req-test: DR-003 - Playback mode manager (edge case: empty queue)
1296        #[test]
1297        fn test_extract_empty_queue() {
1298            let items: Vec<MediaItem> = vec![];
1299
1300            let manager = super::PlaybackModeManager::new(
1301                Arc::new(Mutex::new(None)),
1302                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
1303            );
1304
1305            let result = manager.extract_jellyfin_ids(&items, 0);
1306            assert!(result.is_err());
1307        }
1308    }
1309}