Stopping a remote session left Android stuck on the remote volume slider with no way back to the device speaker. Two causes: 1. `player_stop`'s remote branch sent "Stop" to the session and returned without touching the playback mode, so the manager stayed in Remote. It now drops to Idle, mirroring what the local branch already does. 2. Volume routing was torn down at a single call site (`transfer_to_local_inner`), so every *other* exit from remote mode leaked the Android VolumeProviderCompat. Routing is now derived from the transition inside `set_mode`: entering remote attaches control, any exit from remote hands it back to the local media stream. This also covers the frontend `disconnect()` path (Remote -> Idle) and the local-playback-start paths (Remote -> Local). Adds a `RemoteVolumeControl` trait so the routing rule is unit-testable off-device — the real implementation is Android JNI. Tests cover remote->idle, remote->local, remote->remote (re-arms, never releases), and that local/idle transitions leave routing untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1306 lines
50 KiB
Rust
1306 lines
50 KiB
Rust
use crate::utils::lock::{MutexSafe, RwLockSafe};
|
|
use log::{debug, error, info};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Arc, Mutex, RwLock,
|
|
};
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
use tokio::time::{sleep, Duration};
|
|
|
|
use crate::jellyfin::JellyfinClient;
|
|
use crate::player::{PlayerController, PlayerEventEmitter, PlayerStatusEvent, QueueContext};
|
|
|
|
/// Playback mode - local device, remote session, or idle
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(tag = "type", rename_all = "lowercase")]
|
|
pub enum PlaybackMode {
|
|
Local,
|
|
Remote { session_id: String },
|
|
Idle,
|
|
}
|
|
|
|
/// Number of Jellyfin ticks per second (100ns units).
|
|
const TICKS_PER_SECOND: f64 = 10_000_000.0;
|
|
|
|
/// Below this many seconds we treat the position as "at the start" and don't
|
|
/// send a resume position, so a fresh track casts from 0 rather than ~0.
|
|
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
|
|
|
|
/// Volume level (0-100) the remote volume slider starts at. The real level is
|
|
/// corrected by the session poller once the remote session reports its volume.
|
|
const DEFAULT_REMOTE_VOLUME: i32 = 50;
|
|
|
|
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
|
|
/// hand to a remote session, or `None` if we're effectively at the start.
|
|
///
|
|
/// Pure helper so the resume-position math is unit-testable without a remote
|
|
/// session or HTTP. The *source* of `position_seconds` matters too: callers
|
|
/// must pass the live backend position (`PlayerController::position()`), not the
|
|
/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
|
|
fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
|
|
if position_seconds > RESUME_THRESHOLD_SECONDS {
|
|
Some((position_seconds * TICKS_PER_SECOND) as i64)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Platform hook for attaching/detaching the OS remote-volume control.
|
|
///
|
|
/// On Android, entering remote mode hands the `MediaSession` a
|
|
/// `VolumeProviderCompat` so hardware volume buttons and the system slider drive
|
|
/// the *remote* session; leaving remote mode must hand it back to the local
|
|
/// media stream. Behind a trait so the routing rule (see
|
|
/// [`PlaybackModeManager::set_mode`]) is unit-testable off-device — the real
|
|
/// implementation is JNI and only exists on Android.
|
|
pub trait RemoteVolumeControl: Send + Sync {
|
|
/// Attach remote-volume control (and, on Android, start the playback service).
|
|
fn enable(&self, initial_volume: i32);
|
|
/// Return volume control to the local device speaker.
|
|
fn disable(&self);
|
|
}
|
|
|
|
/// Production hook: forwards to the Android JNI bridge; no-op elsewhere.
|
|
struct PlatformRemoteVolumeControl;
|
|
|
|
impl RemoteVolumeControl for PlatformRemoteVolumeControl {
|
|
#[allow(unused_variables)]
|
|
fn enable(&self, initial_volume: i32) {
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
if let Err(e) = crate::player::enable_remote_volume(initial_volume) {
|
|
log::warn!(
|
|
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
|
e
|
|
);
|
|
// Non-fatal - continue; the next poll tick will retry metadata.
|
|
}
|
|
}
|
|
}
|
|
|
|
fn disable(&self) {
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
if let Err(e) = crate::player::disable_remote_volume() {
|
|
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
|
|
// Non-fatal - the mode change itself has already happened.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Manages playback mode transfers between local and remote sessions
|
|
pub struct PlaybackModeManager {
|
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
|
player_controller: Arc<TokioMutex<PlayerController>>,
|
|
current_mode: Arc<RwLock<PlaybackMode>>,
|
|
is_transferring: Arc<AtomicBool>,
|
|
/// Optional emitter used to notify the frontend when the mode changes, so its
|
|
/// mirror store stays in sync with this authoritative one. `None` in tests.
|
|
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
|
/// Platform hook for OS-level remote volume routing (swapped in tests).
|
|
remote_volume: Arc<dyn RemoteVolumeControl>,
|
|
}
|
|
|
|
impl PlaybackModeManager {
|
|
/// Create a new playback mode manager
|
|
pub fn new(
|
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
|
player_controller: Arc<TokioMutex<PlayerController>>,
|
|
) -> Self {
|
|
Self {
|
|
jellyfin_client,
|
|
player_controller,
|
|
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
|
is_transferring: Arc::new(AtomicBool::new(false)),
|
|
event_emitter: Arc::new(Mutex::new(None)),
|
|
remote_volume: Arc::new(PlatformRemoteVolumeControl),
|
|
}
|
|
}
|
|
|
|
/// Construct with a custom remote-volume hook (tests).
|
|
#[cfg(test)]
|
|
fn with_remote_volume(
|
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
|
player_controller: Arc<TokioMutex<PlayerController>>,
|
|
remote_volume: Arc<dyn RemoteVolumeControl>,
|
|
) -> Self {
|
|
Self {
|
|
jellyfin_client,
|
|
player_controller,
|
|
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
|
is_transferring: Arc::new(AtomicBool::new(false)),
|
|
event_emitter: Arc::new(Mutex::new(None)),
|
|
remote_volume,
|
|
}
|
|
}
|
|
|
|
/// Wire the event emitter so `set_mode` notifies the frontend. Called once
|
|
/// during setup; safe to leave unset (tests do), in which case mode changes
|
|
/// simply aren't broadcast.
|
|
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
|
|
*self.event_emitter.lock_safe() = Some(emitter);
|
|
}
|
|
|
|
/// Get current playback mode
|
|
pub fn get_mode(&self) -> PlaybackMode {
|
|
self.current_mode.read_safe().clone()
|
|
}
|
|
|
|
/// Set playback mode (internal use).
|
|
///
|
|
/// Broadcasts a `PlaybackModeChanged` event when the mode actually changes so
|
|
/// the frontend's mirror store reconciles to this authoritative value. The
|
|
/// write lock is released before emitting to avoid holding it across the
|
|
/// emitter call.
|
|
///
|
|
/// Also owns **OS volume routing**, which is derived from the transition
|
|
/// rather than from each call site: entering remote mode attaches the remote
|
|
/// volume control, and *any* exit from remote mode hands it back to the local
|
|
/// speaker. Doing this per-call-site is what caused the bug where stopping a
|
|
/// remote session (`player_stop` → Idle) left Android stuck on the remote
|
|
/// volume slider — only the transfer-to-local path tore it down.
|
|
///
|
|
/// TRACES: UR-010 | DR-059, IR-021
|
|
pub fn set_mode(&self, mode: PlaybackMode) {
|
|
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
|
let (changed, was_remote) = {
|
|
let mut current = self.current_mode.write_safe();
|
|
let changed = *current != mode;
|
|
let was_remote = matches!(*current, PlaybackMode::Remote { .. });
|
|
*current = mode.clone();
|
|
(changed, was_remote)
|
|
};
|
|
|
|
if !changed {
|
|
return;
|
|
}
|
|
|
|
// Volume routing follows the transition. Note remote->remote (switching
|
|
// target session) re-arms rather than releasing control.
|
|
let is_remote = matches!(mode, PlaybackMode::Remote { .. });
|
|
if is_remote {
|
|
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
|
} else if was_remote {
|
|
log::info!("[PlaybackMode] Leaving remote mode - restoring local volume control");
|
|
self.remote_volume.disable();
|
|
}
|
|
|
|
let (mode_str, session_id) = match &mode {
|
|
PlaybackMode::Local => ("local".to_string(), None),
|
|
PlaybackMode::Idle => ("idle".to_string(), None),
|
|
PlaybackMode::Remote { session_id } => ("remote".to_string(), Some(session_id.clone())),
|
|
};
|
|
|
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::PlaybackModeChanged {
|
|
mode: mode_str,
|
|
session_id,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Start the Android playback service and hand it remote-volume control.
|
|
///
|
|
/// Must run on EVERY transition into remote mode, because it is what starts
|
|
/// the foreground service. Without a running service there is no media
|
|
/// notification (the lockscreen card is missing) AND system volume buttons
|
|
/// aren't intercepted for the remote session (remote volume control dead).
|
|
/// Both symptoms share this one cause, so this must not be skipped on any
|
|
/// remote-entry path (notably the empty-queue early return in
|
|
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
|
|
///
|
|
/// [`set_mode`](Self::set_mode) already arms this on entry into remote mode;
|
|
/// calling it again is harmless (the service start is idempotent) and keeps
|
|
/// the guarantee when the mode was already remote, which `set_mode` skips as
|
|
/// a no-op transition.
|
|
fn enable_remote_control(&self) {
|
|
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
|
}
|
|
|
|
/// Check if currently transferring
|
|
pub fn is_transferring(&self) -> bool {
|
|
self.is_transferring.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Set the transferring flag directly.
|
|
///
|
|
/// The remote->local transfer is driven from the frontend in two steps
|
|
/// (`player_play_tracks` to start local playback, then
|
|
/// `playback_mode_transfer_to_local` to stop the remote). The first step's
|
|
/// routing depends on this flag: while it's set, `player_play_tracks` plays
|
|
/// locally instead of casting back to the remote session. The frontend must
|
|
/// raise the flag *before* that first call and lower it when the sequence is
|
|
/// done (or aborts), so it can't be left stuck on.
|
|
pub fn set_transferring(&self, transferring: bool) {
|
|
self.is_transferring.store(transferring, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Send volume command to remote session
|
|
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
|
#[allow(dead_code)] // Called from Android JNI callback
|
|
pub async fn send_remote_volume_command(
|
|
&self,
|
|
command: &str,
|
|
volume: i32,
|
|
) -> Result<(), String> {
|
|
log::info!(
|
|
"[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}",
|
|
command,
|
|
volume
|
|
);
|
|
|
|
// Get the current session ID
|
|
let session_id = match self.get_mode() {
|
|
PlaybackMode::Remote { session_id } => session_id,
|
|
_ => {
|
|
log::warn!("[PlaybackMode] Ignoring remote volume command - not in remote mode");
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Current mode is Remote, session_id={}",
|
|
session_id
|
|
);
|
|
|
|
// Get Jellyfin client
|
|
let client = {
|
|
log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
|
|
let client_opt = self.jellyfin_client.lock().map_err(|e| {
|
|
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
|
format!("Failed to lock Jellyfin client: {}", e)
|
|
})?;
|
|
|
|
log::info!("[PlaybackMode] Jellyfin client lock acquired");
|
|
|
|
match client_opt.as_ref() {
|
|
Some(c) => {
|
|
log::info!("[PlaybackMode] Jellyfin client is configured, cloning...");
|
|
c.clone()
|
|
}
|
|
None => {
|
|
log::error!("[PlaybackMode] Jellyfin client is NOT configured!");
|
|
return Err("Jellyfin client not configured".to_string());
|
|
}
|
|
}
|
|
};
|
|
|
|
log::info!("[PlaybackMode] About to call client.session_set_volume...");
|
|
|
|
// Send the volume command
|
|
log::info!(
|
|
"[PlaybackMode] Sending {} command to session {} (volume: {})",
|
|
command,
|
|
session_id,
|
|
volume
|
|
);
|
|
let result = client.session_set_volume(session_id, volume).await;
|
|
|
|
match &result {
|
|
Ok(_) => log::info!("[PlaybackMode] session_set_volume returned Ok"),
|
|
Err(e) => log::error!("[PlaybackMode] session_set_volume returned Err: {}", e),
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// Extract Jellyfin item IDs from queue items
|
|
/// Returns (item_ids, adjusted_current_index)
|
|
fn extract_jellyfin_ids(
|
|
&self,
|
|
items: &[crate::player::MediaItem],
|
|
original_index: usize,
|
|
) -> Result<(Vec<String>, usize), String> {
|
|
let mut jellyfin_ids: Vec<String> = Vec::new();
|
|
let mut adjusted_index: Option<usize> = None;
|
|
let mut jellyfin_item_count = 0;
|
|
|
|
for (i, item) in items.iter().enumerate() {
|
|
if let Some(id) = item.jellyfin_id() {
|
|
jellyfin_ids.push(id.to_string());
|
|
|
|
// If this is the currently playing item, record its new index
|
|
if i == original_index {
|
|
adjusted_index = Some(jellyfin_item_count);
|
|
}
|
|
|
|
jellyfin_item_count += 1;
|
|
}
|
|
}
|
|
|
|
// Ensure the currently playing item has a Jellyfin ID
|
|
let final_index = match adjusted_index {
|
|
Some(idx) => idx,
|
|
None => {
|
|
log::warn!(
|
|
"[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
|
|
original_index
|
|
);
|
|
return Err(
|
|
"Cannot transfer: currently playing item is not from Jellyfin".to_string(),
|
|
);
|
|
}
|
|
};
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Extracted {} Jellyfin IDs from queue (original index: {} -> adjusted: {})",
|
|
jellyfin_ids.len(),
|
|
original_index,
|
|
final_index
|
|
);
|
|
|
|
Ok((jellyfin_ids, final_index))
|
|
}
|
|
|
|
/// Transfer playback from local device to remote Jellyfin session
|
|
pub async fn transfer_to_remote(
|
|
&self,
|
|
session_id: String,
|
|
position_override: Option<f64>,
|
|
) -> Result<(), String> {
|
|
debug!("[PlaybackMode] transfer_to_remote ENTERED");
|
|
debug!("[PlaybackMode] session_id: {}", session_id);
|
|
log::info!(
|
|
"[PlaybackMode] Transferring to remote session: {}",
|
|
session_id
|
|
);
|
|
|
|
// Set transferring flag
|
|
debug!("[PlaybackMode] Setting is_transferring flag");
|
|
self.is_transferring.store(true, Ordering::Relaxed);
|
|
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
|
|
|
// Perform the transfer
|
|
let result = self
|
|
.transfer_to_remote_inner(&session_id, position_override)
|
|
.await;
|
|
|
|
// Clear transferring flag
|
|
self.is_transferring.store(false, Ordering::Relaxed);
|
|
|
|
result
|
|
}
|
|
|
|
async fn transfer_to_remote_inner(
|
|
&self,
|
|
session_id: &str,
|
|
position_override: Option<f64>,
|
|
) -> Result<(), String> {
|
|
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
|
|
debug!(
|
|
"[PlaybackMode] transfer_to_remote_inner: session_id={}",
|
|
session_id
|
|
);
|
|
|
|
// If we're already controlling a remote session, that *old* session — not
|
|
// the idle local player — is the source of truth for the current track and
|
|
// position. Capture it so we can resume there and stop it afterwards.
|
|
let previous_remote_session = match self.get_mode() {
|
|
PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev),
|
|
_ => None,
|
|
};
|
|
|
|
// Get current player state and queue context
|
|
let (queue_ids, mut current_index, mut position_seconds, queue_context) = {
|
|
log::info!("[PlaybackMode] Acquiring player controller lock...");
|
|
debug!("[PlaybackMode] Acquiring player controller lock...");
|
|
let player = self.player_controller.lock().await;
|
|
log::info!("[PlaybackMode] Player controller lock acquired");
|
|
debug!("[PlaybackMode] Player controller lock acquired");
|
|
|
|
let queue_arc = player.queue();
|
|
let queue = queue_arc.lock_safe();
|
|
|
|
let original_index = queue.current_index().unwrap_or(0);
|
|
let items = queue.items();
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Queue has {} items, original_index={}",
|
|
items.len(),
|
|
original_index
|
|
);
|
|
debug!(
|
|
"[PlaybackMode] Queue has {} items, original_index={}",
|
|
items.len(),
|
|
original_index
|
|
);
|
|
|
|
// Log each item's jellyfin_id for debugging
|
|
for (i, item) in items.iter().enumerate() {
|
|
let jf_id = item.jellyfin_id().unwrap_or("NONE");
|
|
log::debug!(
|
|
"[PlaybackMode] Item {}: id={}, jellyfin_id={}",
|
|
i,
|
|
item.id,
|
|
jf_id
|
|
);
|
|
}
|
|
|
|
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
|
|
// Prefer the frontend-supplied position when available. The backend
|
|
// position is unreliable as a transfer source: on Linux, *video* plays
|
|
// in the HTML5 <video> element and the MPV backend is never loaded, so
|
|
// PlayerController::position() is always 0; only the frontend knows the
|
|
// true position. We fall back to the live backend position (correct for
|
|
// Linux audio via MPV) when the frontend doesn't pass one.
|
|
let position = match position_override {
|
|
Some(p) => {
|
|
log::info!("[PlaybackMode] Using frontend position override: {:.2}s", p);
|
|
p
|
|
}
|
|
None => player.position(),
|
|
};
|
|
let context = queue.context().clone();
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Queue context: {:?}, {} items, current index: {}",
|
|
context,
|
|
ids.len(),
|
|
adjusted_index
|
|
);
|
|
debug!(
|
|
"[PlaybackMode] Extracted {} jellyfin IDs, adjusted_index={}, position={:.2}s",
|
|
ids.len(),
|
|
adjusted_index,
|
|
position
|
|
);
|
|
|
|
(ids, adjusted_index, position, context)
|
|
};
|
|
|
|
// If queue is empty, just switch mode
|
|
if queue_ids.is_empty() {
|
|
log::info!("[PlaybackMode] Queue is empty, just switching mode");
|
|
self.set_mode(PlaybackMode::Remote {
|
|
session_id: session_id.to_string(),
|
|
});
|
|
// Start the service + remote-volume control here too — otherwise this
|
|
// early return leaves remote mode with no media notification and no
|
|
// volume interception (lockscreen card missing + remote volume dead).
|
|
self.enable_remote_control();
|
|
return Ok(());
|
|
}
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Queue has {} items, current index: {}, position: {:.2}s",
|
|
queue_ids.len(),
|
|
current_index,
|
|
position_seconds
|
|
);
|
|
|
|
// Get Jellyfin client for remote transfer
|
|
log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
|
|
debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
|
|
let client = {
|
|
let client_opt = self.jellyfin_client.lock().map_err(|e| {
|
|
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
|
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
|
format!("Failed to lock Jellyfin client: {}", e)
|
|
})?;
|
|
|
|
match client_opt.as_ref() {
|
|
Some(c) => {
|
|
log::info!("[PlaybackMode] Jellyfin client is configured");
|
|
debug!("[PlaybackMode] Jellyfin client is configured");
|
|
c.clone()
|
|
}
|
|
None => {
|
|
log::error!("[PlaybackMode] Jellyfin client NOT configured!");
|
|
error!("[PlaybackMode] Jellyfin client NOT configured!");
|
|
return Err("Jellyfin client not configured".to_string());
|
|
}
|
|
}
|
|
};
|
|
|
|
// Remote -> remote switch: take the current track and position from the
|
|
// session we're leaving, since the local player is idle and reports 0.
|
|
if let Some(ref prev_session_id) = previous_remote_session {
|
|
log::info!(
|
|
"[PlaybackMode] Remote->remote switch; reading state from previous session {}",
|
|
prev_session_id
|
|
);
|
|
match client.get_session(prev_session_id).await {
|
|
Ok(Some(session)) => {
|
|
// Resume at the previous session's position.
|
|
if let Some(ticks) =
|
|
session.play_state.as_ref().and_then(|ps| ps.position_ticks)
|
|
{
|
|
position_seconds = ticks as f64 / TICKS_PER_SECOND;
|
|
log::info!(
|
|
"[PlaybackMode] Using previous remote position: {:.2}s",
|
|
position_seconds
|
|
);
|
|
}
|
|
// Resume on whichever track the previous session reached.
|
|
if let Some(now_id) = session
|
|
.now_playing_item
|
|
.as_ref()
|
|
.and_then(|i| i.id.as_deref())
|
|
{
|
|
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
|
|
log::info!(
|
|
"[PlaybackMode] Previous session is on track {} (queue index {})",
|
|
now_id,
|
|
idx
|
|
);
|
|
current_index = idx;
|
|
} else {
|
|
log::warn!(
|
|
"[PlaybackMode] Previous session's track {} not found in queue; keeping index {}",
|
|
now_id,
|
|
current_index
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => log::warn!(
|
|
"[PlaybackMode] Previous remote session not found while reading state"
|
|
),
|
|
Err(e) => log::warn!(
|
|
"[PlaybackMode] Failed to read previous remote session: {}",
|
|
e
|
|
),
|
|
}
|
|
}
|
|
|
|
// Calculate position in ticks (from the live position read above)
|
|
let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
|
|
|
|
// Log queue context for debugging (context is tracked but we always send track IDs)
|
|
match &queue_context {
|
|
QueueContext::Album {
|
|
album_id,
|
|
album_name,
|
|
} => {
|
|
log::info!(
|
|
"[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
|
|
album_name,
|
|
album_id,
|
|
queue_ids.len()
|
|
);
|
|
}
|
|
QueueContext::Playlist {
|
|
playlist_id,
|
|
playlist_name,
|
|
} => {
|
|
log::info!(
|
|
"[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
|
|
playlist_name,
|
|
playlist_id,
|
|
queue_ids.len()
|
|
);
|
|
}
|
|
QueueContext::Custom => {
|
|
log::info!(
|
|
"[PlaybackMode] Transferring custom queue with {} tracks to remote",
|
|
queue_ids.len()
|
|
);
|
|
}
|
|
}
|
|
|
|
// Always send individual track IDs - Jellyfin's play_on_session expects track IDs,
|
|
// not album/playlist container IDs
|
|
let expected_item_id = queue_ids
|
|
.get(current_index)
|
|
.cloned()
|
|
.ok_or("Invalid start index")?;
|
|
|
|
// Send play command to remote session with all track IDs
|
|
log::info!(
|
|
"[PlaybackMode] Sending play command to remote session: {} ({} tracks, starting at index {}, position: {:.2}s)",
|
|
session_id,
|
|
queue_ids.len(),
|
|
current_index,
|
|
position_seconds
|
|
);
|
|
debug!(
|
|
"[PlaybackMode] Calling play_on_session: session={}, tracks={}, index={}, position_ticks={:?}",
|
|
session_id,
|
|
queue_ids.len(),
|
|
current_index,
|
|
start_position_ticks
|
|
);
|
|
|
|
// Log first few track IDs for debugging
|
|
if queue_ids.len() > 0 {
|
|
let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect();
|
|
debug!("[PlaybackMode] First track IDs: {:?}...", preview);
|
|
}
|
|
|
|
client
|
|
.play_on_session(
|
|
session_id.to_string(),
|
|
queue_ids.clone(),
|
|
current_index,
|
|
start_position_ticks,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
log::error!("[PlaybackMode] Failed to send play command: {}", e);
|
|
error!("[PlaybackMode] Failed to send play command: {}", e);
|
|
format!("Failed to start playback on remote session: {}", e)
|
|
})?;
|
|
|
|
log::info!("[PlaybackMode] Play command sent successfully");
|
|
info!("[PlaybackMode] Play command sent successfully to remote session");
|
|
|
|
// Wait for remote session to load the track (poll with timeout)
|
|
log::info!("[PlaybackMode] Waiting for remote session to load track...");
|
|
|
|
let mut attempts = 0;
|
|
let max_attempts = 50; // 5 seconds max (50 * 100ms)
|
|
let mut track_loaded = false;
|
|
|
|
while attempts < max_attempts {
|
|
sleep(Duration::from_millis(100)).await;
|
|
attempts += 1;
|
|
|
|
match client.get_session(session_id).await {
|
|
Ok(Some(session)) => {
|
|
if let Some(now_playing) = &session.now_playing_item {
|
|
if now_playing.id.as_deref() == Some(&expected_item_id) {
|
|
log::info!(
|
|
"[PlaybackMode] Remote session loaded track '{}' after {}ms",
|
|
now_playing.name.as_deref().unwrap_or("Unknown"),
|
|
attempts * 100
|
|
);
|
|
track_loaded = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
log::warn!("[PlaybackMode] Remote session not found while polling");
|
|
return Err("Remote session not found".to_string());
|
|
}
|
|
Err(e) => {
|
|
log::warn!(
|
|
"[PlaybackMode] Error polling session (attempt {}): {}",
|
|
attempts,
|
|
e
|
|
);
|
|
// Continue polling - transient errors are OK
|
|
}
|
|
}
|
|
}
|
|
|
|
if !track_loaded {
|
|
log::error!("[PlaybackMode] Timeout waiting for remote session to load track");
|
|
return Err("Remote session did not load track in time".to_string());
|
|
}
|
|
|
|
// Resume at the right position. We send StartPositionTicks in the play
|
|
// command above, but some Jellyfin client/server combinations ignore it
|
|
// and start from 0. Now that the track is confirmed loaded, issue an
|
|
// explicit seek as well (mirrors how the local resume path works). This
|
|
// is the reliable mechanism; StartPositionTicks is best-effort.
|
|
if let Some(ticks) = start_position_ticks {
|
|
log::info!(
|
|
"[PlaybackMode] Seeking remote session to resume position: {} ticks",
|
|
ticks
|
|
);
|
|
if let Err(e) = client.session_seek(session_id.to_string(), ticks).await {
|
|
// Non-fatal: the track is already playing, just not at the
|
|
// resume point. Log and continue rather than failing the transfer.
|
|
log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
|
|
}
|
|
}
|
|
|
|
// Remote -> remote switch: stop the session we just left so we don't end
|
|
// up with two devices playing at once. Do this only after the new session
|
|
// is confirmed playing, so a failure here doesn't leave us with silence.
|
|
if let Some(prev_session_id) = previous_remote_session {
|
|
log::info!(
|
|
"[PlaybackMode] Stopping previous remote session {}",
|
|
prev_session_id
|
|
);
|
|
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
|
|
log::warn!(
|
|
"[PlaybackMode] Failed to stop previous remote session: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
// Stop local playback (queue should remain intact for remote session)
|
|
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
|
|
{
|
|
let player = self.player_controller.lock().await;
|
|
|
|
// Log queue state BEFORE stop
|
|
{
|
|
let queue_arc = player.queue();
|
|
let queue = queue_arc.lock_safe();
|
|
info!(
|
|
"[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
|
|
queue.items().len(),
|
|
queue.current_index()
|
|
);
|
|
}
|
|
|
|
player
|
|
.stop()
|
|
.map_err(|e| format!("Failed to stop playback: {}", e))?;
|
|
|
|
// Log queue state AFTER stop (should be unchanged)
|
|
{
|
|
let queue_arc = player.queue();
|
|
let queue = queue_arc.lock_safe();
|
|
info!(
|
|
"[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
|
|
queue.items().len(),
|
|
queue.current_index()
|
|
);
|
|
}
|
|
}
|
|
|
|
// Update mode to remote
|
|
self.set_mode(PlaybackMode::Remote {
|
|
session_id: session_id.to_string(),
|
|
});
|
|
|
|
// Start the service + remote-volume control (intercepts volume buttons,
|
|
// and starts the foreground service that renders the lockscreen card).
|
|
self.enable_remote_control();
|
|
|
|
log::info!("[PlaybackMode] Successfully transferred to remote");
|
|
Ok(())
|
|
}
|
|
|
|
/// Transfer playback from remote session back to local device
|
|
pub async fn transfer_to_local(
|
|
&self,
|
|
current_item_id: String,
|
|
position_ticks: i64,
|
|
) -> Result<(), String> {
|
|
log::info!("[PlaybackMode] Transferring to local playback");
|
|
|
|
// Set transferring flag
|
|
self.is_transferring.store(true, Ordering::Relaxed);
|
|
|
|
// Perform the transfer
|
|
let result = self
|
|
.transfer_to_local_inner(¤t_item_id, position_ticks)
|
|
.await;
|
|
|
|
// Clear transferring flag
|
|
self.is_transferring.store(false, Ordering::Relaxed);
|
|
|
|
result
|
|
}
|
|
|
|
async fn transfer_to_local_inner(
|
|
&self,
|
|
current_item_id: &str,
|
|
position_ticks: i64,
|
|
) -> Result<(), String> {
|
|
// Get current remote session info
|
|
let session_id = match self.get_mode() {
|
|
PlaybackMode::Remote { session_id } => session_id,
|
|
_ => return Err("Not in remote playback mode".to_string()),
|
|
};
|
|
|
|
let position_seconds = position_ticks as f64 / 10_000_000.0;
|
|
|
|
log::info!(
|
|
"[PlaybackMode] Transfer to local: session={}, item_id={}, position={:.2}s",
|
|
session_id,
|
|
current_item_id,
|
|
position_seconds
|
|
);
|
|
|
|
// Get Jellyfin client for stopping remote playback
|
|
let client = {
|
|
let client_opt = self
|
|
.jellyfin_client
|
|
.lock()
|
|
.map_err(|e| format!("Failed to lock Jellyfin client: {}", e))?;
|
|
|
|
client_opt
|
|
.as_ref()
|
|
.ok_or("Jellyfin client not configured")?
|
|
.clone()
|
|
};
|
|
|
|
// Stop remote playback
|
|
log::info!(
|
|
"[PlaybackMode] Stopping remote playback on session: {}",
|
|
session_id
|
|
);
|
|
match client
|
|
.send_session_command(session_id.clone(), "Stop")
|
|
.await
|
|
{
|
|
Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
|
|
Err(e) => {
|
|
log::warn!(
|
|
"[PlaybackMode] Failed to stop remote session (non-fatal): {}",
|
|
e
|
|
);
|
|
// Don't fail the transfer if we can't stop the remote session
|
|
// The user is already playing locally, so this is not critical
|
|
}
|
|
}
|
|
|
|
// For now, we'll return an error indicating that the TypeScript side needs to handle
|
|
// loading the media item, since we don't have access to the repository here yet.
|
|
// This will be improved in Phase 3 when repository is migrated to Rust.
|
|
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
|
|
|
|
// Update mode to local. This also returns volume control to the local
|
|
// device speaker — set_mode owns that for every exit from remote mode.
|
|
self.set_mode(PlaybackMode::Local);
|
|
|
|
log::info!("[PlaybackMode] Successfully transferred to local");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Test PlaybackMode enum serialization to JSON
|
|
///
|
|
/// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
#[test]
|
|
fn test_playback_mode_serialization() {
|
|
let mode_idle = PlaybackMode::Idle;
|
|
let json = serde_json::to_string(&mode_idle).unwrap();
|
|
assert_eq!(json, r#"{"type":"idle"}"#);
|
|
|
|
let mode_local = PlaybackMode::Local;
|
|
let json = serde_json::to_string(&mode_local).unwrap();
|
|
assert_eq!(json, r#"{"type":"local"}"#);
|
|
|
|
let mode_remote = PlaybackMode::Remote {
|
|
session_id: "abc123".to_string(),
|
|
};
|
|
let json = serde_json::to_string(&mode_remote).unwrap();
|
|
assert!(json.contains(r#""type":"remote""#));
|
|
assert!(json.contains(r#""session_id":"abc123""#));
|
|
}
|
|
|
|
/// Test PlaybackMode enum deserialization from JSON
|
|
///
|
|
/// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
|
|
#[test]
|
|
fn test_playback_mode_deserialization() {
|
|
let json = r#"{"type":"idle"}"#;
|
|
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
|
|
assert_eq!(mode, PlaybackMode::Idle);
|
|
|
|
let json = r#"{"type":"local"}"#;
|
|
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
|
|
assert_eq!(mode, PlaybackMode::Local);
|
|
|
|
let json = r#"{"type":"remote","session_id":"test_session"}"#;
|
|
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
|
|
assert_eq!(
|
|
mode,
|
|
PlaybackMode::Remote {
|
|
session_id: "test_session".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
/// Capturing emitter so we can assert what `set_mode` broadcasts.
|
|
struct CapturingEmitter {
|
|
events: Mutex<Vec<PlayerStatusEvent>>,
|
|
}
|
|
|
|
impl PlayerEventEmitter for CapturingEmitter {
|
|
fn emit(&self, event: PlayerStatusEvent) {
|
|
self.events.lock().unwrap().push(event);
|
|
}
|
|
}
|
|
|
|
fn manager_with_emitter() -> (PlaybackModeManager, Arc<CapturingEmitter>) {
|
|
let emitter = Arc::new(CapturingEmitter {
|
|
events: Mutex::new(Vec::new()),
|
|
});
|
|
let manager = PlaybackModeManager::new(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
);
|
|
manager.set_event_emitter(emitter.clone());
|
|
(manager, emitter)
|
|
}
|
|
|
|
/// set_mode broadcasts a PlaybackModeChanged event with the right payload so
|
|
/// the frontend can reconcile its mirror store to this authoritative one.
|
|
#[test]
|
|
fn test_set_mode_emits_change_event() {
|
|
let (manager, emitter) = manager_with_emitter();
|
|
|
|
manager.set_mode(PlaybackMode::Remote {
|
|
session_id: "sess-1".to_string(),
|
|
});
|
|
manager.set_mode(PlaybackMode::Local);
|
|
manager.set_mode(PlaybackMode::Idle);
|
|
|
|
let events = emitter.events.lock().unwrap();
|
|
assert_eq!(events.len(), 3, "one event per real mode change");
|
|
|
|
match &events[0] {
|
|
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
|
|
assert_eq!(mode, "remote");
|
|
assert_eq!(session_id.as_deref(), Some("sess-1"));
|
|
}
|
|
other => panic!("expected PlaybackModeChanged, got {:?}", other),
|
|
}
|
|
match &events[1] {
|
|
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
|
|
assert_eq!(mode, "local");
|
|
assert_eq!(session_id.as_deref(), None);
|
|
}
|
|
other => panic!("expected PlaybackModeChanged, got {:?}", other),
|
|
}
|
|
match &events[2] {
|
|
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
|
|
assert_eq!(mode, "idle");
|
|
assert_eq!(session_id.as_deref(), None);
|
|
}
|
|
other => panic!("expected PlaybackModeChanged, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
/// Records enable/disable calls so tests can assert volume routing.
|
|
struct RecordingVolumeControl {
|
|
calls: Mutex<Vec<&'static str>>,
|
|
}
|
|
|
|
impl RemoteVolumeControl for RecordingVolumeControl {
|
|
fn enable(&self, _initial_volume: i32) {
|
|
self.calls.lock().unwrap().push("enable");
|
|
}
|
|
fn disable(&self) {
|
|
self.calls.lock().unwrap().push("disable");
|
|
}
|
|
}
|
|
|
|
fn manager_with_volume_control() -> (PlaybackModeManager, Arc<RecordingVolumeControl>) {
|
|
let volume = Arc::new(RecordingVolumeControl {
|
|
calls: Mutex::new(Vec::new()),
|
|
});
|
|
let manager = PlaybackModeManager::with_remote_volume(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
volume.clone(),
|
|
);
|
|
(manager, volume)
|
|
}
|
|
|
|
/// Leaving remote mode must hand volume control back to the local device.
|
|
///
|
|
/// Stopping a remote session (`player_stop`) drives the manager
|
|
/// Remote -> Idle without going through `transfer_to_local`. Before this was
|
|
/// centralised in `set_mode`, only the transfer path tore the Android
|
|
/// `VolumeProviderCompat` down, so a plain stop left the system stuck on the
|
|
/// remote volume slider with no way back to the phone speaker.
|
|
///
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
#[test]
|
|
fn test_leaving_remote_mode_restores_local_volume() {
|
|
let (manager, volume) = manager_with_volume_control();
|
|
|
|
manager.set_mode(PlaybackMode::Remote {
|
|
session_id: "sess-1".to_string(),
|
|
});
|
|
// The stop path: remote -> idle, no transfer involved.
|
|
manager.set_mode(PlaybackMode::Idle);
|
|
|
|
assert_eq!(
|
|
*volume.calls.lock().unwrap(),
|
|
vec!["enable", "disable"],
|
|
"remote->idle must return volume control to the local speaker"
|
|
);
|
|
}
|
|
|
|
/// The same must hold for remote -> local (transfer back to this device).
|
|
///
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
#[test]
|
|
fn test_remote_to_local_restores_local_volume() {
|
|
let (manager, volume) = manager_with_volume_control();
|
|
|
|
manager.set_mode(PlaybackMode::Remote {
|
|
session_id: "sess-1".to_string(),
|
|
});
|
|
manager.set_mode(PlaybackMode::Local);
|
|
|
|
assert_eq!(
|
|
*volume.calls.lock().unwrap(),
|
|
vec!["enable", "disable"],
|
|
"remote->local must return volume control to the local speaker"
|
|
);
|
|
}
|
|
|
|
/// Volume routing must not be touched by transitions that never involve
|
|
/// remote mode — an idle->local start would otherwise issue a pointless
|
|
/// `setPlaybackToLocal` on every playback start.
|
|
///
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
#[test]
|
|
fn test_non_remote_transitions_leave_volume_routing_alone() {
|
|
let (manager, volume) = manager_with_volume_control();
|
|
|
|
manager.set_mode(PlaybackMode::Local);
|
|
manager.set_mode(PlaybackMode::Idle);
|
|
manager.set_mode(PlaybackMode::Local);
|
|
|
|
assert!(
|
|
volume.calls.lock().unwrap().is_empty(),
|
|
"local/idle transitions must not touch remote volume routing"
|
|
);
|
|
}
|
|
|
|
/// Switching directly between two remote sessions stays remote: control must
|
|
/// remain attached (re-armed for the new session), never handed back local.
|
|
///
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
#[test]
|
|
fn test_remote_to_remote_keeps_remote_volume() {
|
|
let (manager, volume) = manager_with_volume_control();
|
|
|
|
manager.set_mode(PlaybackMode::Remote {
|
|
session_id: "sess-1".to_string(),
|
|
});
|
|
manager.set_mode(PlaybackMode::Remote {
|
|
session_id: "sess-2".to_string(),
|
|
});
|
|
|
|
assert_eq!(
|
|
*volume.calls.lock().unwrap(),
|
|
vec!["enable", "enable"],
|
|
"remote->remote re-arms control without releasing it to local"
|
|
);
|
|
}
|
|
|
|
/// Setting the same mode twice must not re-emit — the frontend reconciler
|
|
/// (and the event channel) shouldn't be spammed on no-op transitions.
|
|
#[test]
|
|
fn test_set_mode_deduplicates_no_op() {
|
|
let (manager, emitter) = manager_with_emitter();
|
|
|
|
manager.set_mode(PlaybackMode::Local);
|
|
manager.set_mode(PlaybackMode::Local);
|
|
manager.set_mode(PlaybackMode::Local);
|
|
|
|
assert_eq!(
|
|
emitter.events.lock().unwrap().len(),
|
|
1,
|
|
"repeated identical mode set emits only once"
|
|
);
|
|
}
|
|
|
|
/// The resume position handed to a remote session is derived from a live
|
|
/// playback position. Guards the seconds->ticks conversion and the
|
|
/// at-the-start threshold (Bug: casting restarted the track from 0).
|
|
#[test]
|
|
fn test_start_position_ticks_from_seconds() {
|
|
// Mid-track positions convert to ticks (10M ticks per second).
|
|
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
|
|
assert_eq!(
|
|
start_position_ticks_from_seconds(123.45),
|
|
Some(1_234_500_000)
|
|
);
|
|
|
|
// At/near the start, send no resume position so the track casts from 0.
|
|
assert_eq!(start_position_ticks_from_seconds(0.0), None);
|
|
assert_eq!(start_position_ticks_from_seconds(0.5), None);
|
|
|
|
// Just past the threshold resumes rather than restarting.
|
|
assert!(start_position_ticks_from_seconds(0.6).is_some());
|
|
}
|
|
|
|
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
|
|
mod extract_jellyfin_ids_tests {
|
|
use crate::player::{MediaItem, MediaSource, MediaType};
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
|
|
fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem {
|
|
MediaItem {
|
|
id: id.to_string(),
|
|
title: format!("Track {}", id),
|
|
name: Some(format!("Track {}", id)),
|
|
artist: Some("Test Artist".to_string()),
|
|
album: Some("Test Album".to_string()),
|
|
album_name: Some("Test Album".to_string()),
|
|
album_id: Some("album_123".to_string()),
|
|
artist_items: None,
|
|
artists: Some(vec!["Test Artist".to_string()]),
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: Some("Audio".to_string()),
|
|
playlist_id: None,
|
|
duration: Some(180.0),
|
|
artwork_url: None,
|
|
media_type: MediaType::Audio,
|
|
source: MediaSource::Remote {
|
|
stream_url: format!("http://example.com/{}.mp3", id),
|
|
jellyfin_item_id: jellyfin_id.to_string(),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
}
|
|
}
|
|
|
|
fn create_test_item_local(id: &str) -> MediaItem {
|
|
MediaItem {
|
|
id: id.to_string(),
|
|
title: format!("Local Track {}", id),
|
|
name: Some(format!("Local Track {}", id)),
|
|
artist: Some("Test Artist".to_string()),
|
|
album: None,
|
|
album_name: None,
|
|
album_id: None,
|
|
artist_items: None,
|
|
artists: Some(vec!["Test Artist".to_string()]),
|
|
primary_image_tag: None,
|
|
image_id: None,
|
|
item_type: Some("Audio".to_string()),
|
|
playlist_id: None,
|
|
duration: Some(180.0),
|
|
artwork_url: None,
|
|
media_type: MediaType::Audio,
|
|
source: MediaSource::DirectUrl {
|
|
url: format!("http://example.com/{}.mp3", id),
|
|
},
|
|
video_codec: None,
|
|
needs_transcoding: false,
|
|
video_width: None,
|
|
video_height: None,
|
|
subtitles: vec![],
|
|
series_id: None,
|
|
server_id: None,
|
|
}
|
|
}
|
|
|
|
/// Test extracting all Jellyfin track IDs from album
|
|
///
|
|
/// Verifies that all individual track IDs are extracted, not just the album ID.
|
|
///
|
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
|
/// @req-test: DR-003 - Playback mode manager (Jellyfin ID extraction)
|
|
/// @req-test: IR-012 - Jellyfin Sessions API for remote playback control
|
|
#[test]
|
|
fn test_extract_all_jellyfin_ids_from_album() {
|
|
// Simulate an album with 5 tracks - all should be extracted
|
|
let items: Vec<MediaItem> = (1..=5)
|
|
.map(|i| {
|
|
create_test_item_with_jellyfin_id(
|
|
&format!("track_{}", i),
|
|
&format!("jf_track_{}", i),
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let manager = super::PlaybackModeManager::new(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
);
|
|
|
|
let result = manager.extract_jellyfin_ids(&items, 2);
|
|
assert!(result.is_ok());
|
|
|
|
let (ids, index) = result.unwrap();
|
|
|
|
// All 5 track IDs should be extracted (not just the album ID)
|
|
assert_eq!(ids.len(), 5, "All 5 track IDs should be extracted");
|
|
assert_eq!(ids[0], "jf_track_1");
|
|
assert_eq!(ids[1], "jf_track_2");
|
|
assert_eq!(ids[2], "jf_track_3");
|
|
assert_eq!(ids[3], "jf_track_4");
|
|
assert_eq!(ids[4], "jf_track_5");
|
|
|
|
// Index should point to track 3 (original index 2)
|
|
assert_eq!(index, 2, "Current index should be preserved");
|
|
}
|
|
|
|
/// Test extracting Jellyfin IDs filters out local items
|
|
///
|
|
/// @req-test: UR-010 - Control playback of remote sessions (local filtering)
|
|
/// @req-test: DR-003 - Playback mode manager (local item filtering)
|
|
#[test]
|
|
fn test_extract_filters_local_items() {
|
|
// Mix of Jellyfin and local items - only Jellyfin items should be extracted
|
|
let items = vec![
|
|
create_test_item_with_jellyfin_id("1", "jf_1"),
|
|
create_test_item_local("2"), // Local, no Jellyfin ID
|
|
create_test_item_with_jellyfin_id("3", "jf_3"),
|
|
create_test_item_with_jellyfin_id("4", "jf_4"),
|
|
];
|
|
|
|
let manager = super::PlaybackModeManager::new(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
);
|
|
|
|
// Playing track 3 (index 2 in original, should become index 1 after filtering)
|
|
let result = manager.extract_jellyfin_ids(&items, 2);
|
|
assert!(result.is_ok());
|
|
|
|
let (ids, index) = result.unwrap();
|
|
|
|
// Only 3 Jellyfin tracks should be extracted
|
|
assert_eq!(ids.len(), 3);
|
|
assert_eq!(ids[0], "jf_1");
|
|
assert_eq!(ids[1], "jf_3");
|
|
assert_eq!(ids[2], "jf_4");
|
|
|
|
// Index should be adjusted (track 3 is now at position 1)
|
|
assert_eq!(index, 1);
|
|
}
|
|
|
|
/// Test extraction fails when current item is local
|
|
///
|
|
/// @req-test: DR-003 - Playback mode manager (error handling)
|
|
/// @req-test: UR-010 - Control playback of remote sessions (validation)
|
|
#[test]
|
|
fn test_extract_fails_when_current_item_is_local() {
|
|
// Current item has no Jellyfin ID - should fail
|
|
let items = vec![
|
|
create_test_item_with_jellyfin_id("1", "jf_1"),
|
|
create_test_item_local("2"), // Local, no Jellyfin ID
|
|
create_test_item_with_jellyfin_id("3", "jf_3"),
|
|
];
|
|
|
|
let manager = super::PlaybackModeManager::new(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
);
|
|
|
|
// Playing the local track (index 1) should fail
|
|
let result = manager.extract_jellyfin_ids(&items, 1);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("not from Jellyfin"));
|
|
}
|
|
|
|
/// Test extraction fails on empty queue
|
|
///
|
|
/// @req-test: DR-003 - Playback mode manager (edge case: empty queue)
|
|
#[test]
|
|
fn test_extract_empty_queue() {
|
|
let items: Vec<MediaItem> = vec![];
|
|
|
|
let manager = super::PlaybackModeManager::new(
|
|
Arc::new(Mutex::new(None)),
|
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
|
);
|
|
|
|
let result = manager.extract_jellyfin_ids(&items, 0);
|
|
assert!(result.is_err());
|
|
}
|
|
}
|
|
}
|