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}