From 4634ed595c3824f090c1464ab43a35b10111fc49 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 25 Jun 2026 23:39:01 +0200 Subject: [PATCH] fix(remote playback): Move audio between remote players --- src-tauri/src/playback_mode/mod.rs | 60 +++++++++++++++++++- src/lib/components/library/TrackList.test.ts | 1 + src/lib/stores/player.ts | 16 ++++-- src/routes/+layout.svelte | 6 +- 4 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/playback_mode/mod.rs b/src-tauri/src/playback_mode/mod.rs index 5602f1b..056b250 100644 --- a/src-tauri/src/playback_mode/mod.rs +++ b/src-tauri/src/playback_mode/mod.rs @@ -215,8 +215,16 @@ impl PlaybackModeManager { 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, current_index, position_seconds, 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; @@ -313,6 +321,46 @@ impl PlaybackModeManager { } }; + // 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); @@ -446,6 +494,16 @@ impl PlaybackModeManager { } } + // 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"); { diff --git a/src/lib/components/library/TrackList.test.ts b/src/lib/components/library/TrackList.test.ts index 55e1f4c..4e41399 100644 --- a/src/lib/components/library/TrackList.test.ts +++ b/src/lib/components/library/TrackList.test.ts @@ -28,6 +28,7 @@ vi.mock("$lib/stores/queue", () => ({ setQueue: vi.fn(), addToQueue: vi.fn(), }, + currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) }, })); vi.mock("./DownloadButton.svelte", () => ({ diff --git a/src/lib/stores/player.ts b/src/lib/stores/player.ts index 4bd21a7..d7db36b 100644 --- a/src/lib/stores/player.ts +++ b/src/lib/stores/player.ts @@ -13,6 +13,7 @@ import type { MediaItem, ItemType } from "$lib/api/types"; import type { NowPlayingItem } from "$lib/api/bindings"; import { isRemoteMode } from "./playbackMode"; import { selectedSession } from "./sessions"; +import { currentQueueItem } from "./queue"; import { ticksToSeconds } from "$lib/utils/playbackUnits"; // Merged media item from backend (matches Rust MergedMediaItem) @@ -257,21 +258,24 @@ export const mergedVolume = derived( * AND current media is audio (not video: Movie or Episode) */ export const shouldShowAudioMiniPlayer = derived( - [player, currentMedia, isRemoteMode, selectedSession], - ([$player, $media, $isRemote, $session]) => { + [player, currentMedia, currentQueueItem, isRemoteMode, selectedSession], + ([$player, $media, $queueItem, $isRemote, $session]) => { // In remote mode, show if the remote session has a now-playing item if ($isRemote && $session?.nowPlayingItem) { return true; } - // Local mode: only show when actively playing or paused + // Local mode: hide only when there is genuinely nothing loaded + // (idle/stopped/error). Keep showing through loading/seeking transitions + // so the mini player doesn't blink out when advancing between tracks. const state = $player.state; - if (state.kind !== "playing" && state.kind !== "paused") { + if (state.kind === "idle" || state.kind === "error") { return false; } - // Don't show for video content - const mediaType = $media?.type; + // Determine media type from the player state, falling back to the queue + // item (the player store can momentarily lack media during transitions). + const mediaType = $media?.type ?? $queueItem?.type; if (mediaType === "Movie" || mediaType === "Episode") { return false; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7e545ce..bcd5842 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -136,10 +136,10 @@ {/if} - - + + - {#if !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login')} + {#if !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/settings')} {#if $isAndroid || !$page.url.pathname.startsWith('/library')}