fix(remote playback): Move audio between remote players
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m37s

This commit is contained in:
Duncan Tourolle 2026-06-25 23:39:01 +02:00
parent 6836ce79c8
commit 4634ed595c
4 changed files with 73 additions and 10 deletions

View File

@ -215,8 +215,16 @@ impl PlaybackModeManager {
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED"); log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id); 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 // 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..."); log::info!("[PlaybackMode] Acquiring player controller lock...");
debug!("[PlaybackMode] Acquiring player controller lock..."); debug!("[PlaybackMode] Acquiring player controller lock...");
let player = self.player_controller.lock().await; 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) // Calculate position in ticks (from the live position read above)
let start_position_ticks = start_position_ticks_from_seconds(position_seconds); 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) // Stop local playback (queue should remain intact for remote session)
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared"); log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
{ {

View File

@ -28,6 +28,7 @@ vi.mock("$lib/stores/queue", () => ({
setQueue: vi.fn(), setQueue: vi.fn(),
addToQueue: vi.fn(), addToQueue: vi.fn(),
}, },
currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
})); }));
vi.mock("./DownloadButton.svelte", () => ({ vi.mock("./DownloadButton.svelte", () => ({

View File

@ -13,6 +13,7 @@ import type { MediaItem, ItemType } from "$lib/api/types";
import type { NowPlayingItem } from "$lib/api/bindings"; import type { NowPlayingItem } from "$lib/api/bindings";
import { isRemoteMode } from "./playbackMode"; import { isRemoteMode } from "./playbackMode";
import { selectedSession } from "./sessions"; import { selectedSession } from "./sessions";
import { currentQueueItem } from "./queue";
import { ticksToSeconds } from "$lib/utils/playbackUnits"; import { ticksToSeconds } from "$lib/utils/playbackUnits";
// Merged media item from backend (matches Rust MergedMediaItem) // 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) * AND current media is audio (not video: Movie or Episode)
*/ */
export const shouldShowAudioMiniPlayer = derived( export const shouldShowAudioMiniPlayer = derived(
[player, currentMedia, isRemoteMode, selectedSession], [player, currentMedia, currentQueueItem, isRemoteMode, selectedSession],
([$player, $media, $isRemote, $session]) => { ([$player, $media, $queueItem, $isRemote, $session]) => {
// In remote mode, show if the remote session has a now-playing item // In remote mode, show if the remote session has a now-playing item
if ($isRemote && $session?.nowPlayingItem) { if ($isRemote && $session?.nowPlayingItem) {
return true; 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; const state = $player.state;
if (state.kind !== "playing" && state.kind !== "paused") { if (state.kind === "idle" || state.kind === "error") {
return false; return false;
} }
// Don't show for video content // Determine media type from the player state, falling back to the queue
const mediaType = $media?.type; // item (the player store can momentarily lack media during transitions).
const mediaType = $media?.type ?? $queueItem?.type;
if (mediaType === "Movie" || mediaType === "Episode") { if (mediaType === "Movie" || mediaType === "Episode") {
return false; return false;
} }

View File

@ -136,10 +136,10 @@
<BottomNav /> <BottomNav />
{/if} {/if}
<!-- Mini Player - show everywhere except on full player page and login --> <!-- Mini Player - show everywhere except on full player page, login and settings -->
<!-- Android: Show on all routes (except player/login) --> <!-- Android: Show on all routes (except player/login/settings) -->
<!-- Desktop: Show on non-library routes (library layout has its own MiniPlayer) --> <!-- Desktop: Show on non-library routes (library layout has its own MiniPlayer) -->
{#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')} {#if $isAndroid || !$page.url.pathname.startsWith('/library')}
<MiniPlayer <MiniPlayer
media={$currentMedia} media={$currentMedia}