fix(Remote playback): kludge to scrub after stream move
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m51s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m18s

This commit is contained in:
Duncan Tourolle 2026-06-25 21:31:39 +02:00
parent 2811e1b7ca
commit 6836ce79c8
11 changed files with 227 additions and 40 deletions

View File

@ -41,12 +41,14 @@ pub fn playback_mode_is_transferring(
pub async fn playback_mode_transfer_to_remote( pub async fn playback_mode_transfer_to_remote(
manager: State<'_, PlaybackModeManagerWrapper>, manager: State<'_, PlaybackModeManagerWrapper>,
session_id: String, session_id: String,
position: Option<f64>,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!( log::info!(
"[PlaybackModeCommands] Transferring to remote session: {}", "[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
session_id session_id,
position
); );
manager.0.transfer_to_remote(session_id).await manager.0.transfer_to_remote(session_id, position).await
} }
/// Transfer playback from remote session back to local device /// Transfer playback from remote session back to local device

View File

@ -1249,6 +1249,59 @@ pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
} }
} }
/// Start a freshly-built queue on the active remote session.
///
/// Used by the "play tracks"/"play album track" commands when we're in remote
/// mode: instead of starting local MPV playback, we cast the selected tracks to
/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
/// play_on_session call, but for a brand-new selection (so there's no resume
/// position - playback starts from the chosen track's beginning).
///
/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
/// the start index is adjusted to the remaining Jellyfin items. Returns an error
/// if the selected track itself has no Jellyfin ID.
async fn play_selection_on_remote(
controller: &PlayerController,
session_id: &str,
media_items: &[MediaItem],
start_index: usize,
) -> Result<(), String> {
// Collect Jellyfin IDs, tracking where the selected track lands after any
// local-only items are dropped.
let mut jellyfin_ids: Vec<String> = Vec::new();
let mut adjusted_index: Option<usize> = None;
for (i, item) in media_items.iter().enumerate() {
if let Some(id) = item.jellyfin_id() {
if i == start_index {
adjusted_index = Some(jellyfin_ids.len());
}
jellyfin_ids.push(id.to_string());
}
}
let start_index = adjusted_index
.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
if jellyfin_ids.is_empty() {
return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
}
let client = {
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Fresh selection: start from the beginning of the chosen track.
client
.play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
.await
.map_err(|e| format!("Failed to start playback on remote session: {}", e))
}
/// Play a track from an album - backend fetches all album tracks and builds queue /// Play a track from an album - backend fetches all album tracks and builds queue
#[tauri::command] #[tauri::command]
@ -1258,6 +1311,7 @@ pub async fn player_play_album_track(
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String, repository_handle: String,
request: PlayAlbumTrackRequest, request: PlayAlbumTrackRequest,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
@ -1380,11 +1434,24 @@ pub async fn player_play_album_track(
session_mgr.start_audio_session(first_item.clone()); session_mgr.start_audio_session(first_item.clone());
} }
// Play the queue
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller
.play_queue(media_items, start_index) // When controlling a remote session, cast the selection there instead of
.map_err(|e| e.to_string())?; // starting local MPV playback. We still load the queue locally (below) so
// the queue/context stay in sync for the UI and for transferring back.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
controller.set_queue(media_items, start_index).map_err(|e| e.to_string())?;
} else {
controller
.play_queue(media_items, start_index)
.map_err(|e| e.to_string())?;
}
// Set the queue context for remote transfer // Set the queue context for remote transfer
{ {
@ -1426,6 +1493,7 @@ pub async fn player_play_tracks(
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String, repository_handle: String,
request: PlayTracksRequest, request: PlayTracksRequest,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
@ -1533,10 +1601,33 @@ pub async fn player_play_tracks(
session_mgr.start_audio_session(first_item.clone()); session_mgr.start_audio_session(first_item.clone());
} }
// Play queue
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.play_queue_from(media_items, request.start_index, request.start_position)
.map_err(|e| e.to_string())?; // When controlling a remote session, cast the selection there instead of
// starting local MPV playback. Skip this while a transfer is in flight: the
// transfer-to-local path calls this command to load the queue locally and
// the mode is still Remote until the transfer completes - routing it back to
// the remote would undo the transfer.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id }
if !playback_mode.0.is_transferring() =>
{
Some(session_id)
}
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
.await?;
controller
.set_queue(media_items, request.start_index)
.map_err(|e| e.to_string())?;
} else {
controller
.play_queue_from(media_items, request.start_index, request.start_position)
.map_err(|e| e.to_string())?;
}
// Set queue context // Set queue context
{ {

View File

@ -284,22 +284,35 @@ impl JellyfinClient {
} }
/// Seek on a remote session /// Seek on a remote session
///
/// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the
/// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the
/// body (as we used to) is silently ignored and the remote never seeks.
pub async fn session_seek( pub async fn session_seek(
&self, &self,
session_id: String, session_id: String,
position_ticks: i64, position_ticks: i64,
) -> Result<(), String> { ) -> Result<(), String> {
#[derive(serde::Serialize)] let url = format!(
#[serde(rename_all = "PascalCase")] "{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
struct SeekRequest { self.config.server_url, session_id, position_ticks
seek_position_ticks: i64, );
let response = self.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
} }
let request = SeekRequest { log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
seek_position_ticks: position_ticks, Ok(())
};
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
} }
/// Send a full GeneralCommand to a remote session. /// Send a full GeneralCommand to a remote session.

View File

@ -181,7 +181,11 @@ impl PlaybackModeManager {
} }
/// Transfer playback from local device to remote Jellyfin session /// Transfer playback from local device to remote Jellyfin session
pub async fn transfer_to_remote(&self, session_id: String) -> Result<(), String> { pub async fn transfer_to_remote(
&self,
session_id: String,
position_override: Option<f64>,
) -> Result<(), String> {
debug!("[PlaybackMode] transfer_to_remote ENTERED"); debug!("[PlaybackMode] transfer_to_remote ENTERED");
debug!("[PlaybackMode] session_id: {}", session_id); debug!("[PlaybackMode] session_id: {}", session_id);
log::info!( log::info!(
@ -195,7 +199,7 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner"); debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer // Perform the transfer
let result = self.transfer_to_remote_inner(&session_id).await; let result = self.transfer_to_remote_inner(&session_id, position_override).await;
// Clear transferring flag // Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed); self.is_transferring.store(false, Ordering::Relaxed);
@ -203,7 +207,11 @@ impl PlaybackModeManager {
result result
} }
async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> { async fn transfer_to_remote_inner(
&self,
session_id: &str,
position_override: Option<f64>,
) -> Result<(), String> {
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);
@ -231,11 +239,19 @@ impl PlaybackModeManager {
} }
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?; let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
// Read the LIVE backend position, not the snapshot embedded in PlayerState. // Prefer the frontend-supplied position when available. The backend
// On Android the embedded position is only refreshed on play/pause // position is unreliable as a transfer source: on Linux, *video* plays
// transitions, so PlayerState::position() is stale (often 0) mid-track; // in the HTML5 <video> element and the MPV backend is never loaded, so
// PlayerController::position() always reflects the backend's current time. // PlayerController::position() is always 0; only the frontend knows the
let position = player.position(); // 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(); let context = queue.context().clone();
log::info!( log::info!(
@ -413,6 +429,23 @@ impl PlaybackModeManager {
return Err("Remote session did not load track in time".to_string()); 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);
}
}
// 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

@ -358,6 +358,23 @@ impl PlayerController {
Ok(()) Ok(())
} }
/// Replace the queue without starting local playback.
///
/// Used when we're controlling a remote session: the tracks play on the
/// remote device, but we keep the local queue in sync so the UI reflects
/// what's playing and a later transfer-to-local has the queue to resume.
pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_queue (no local playback): {} items, index {}",
items.len(),
start_index
);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
Ok(())
}
/// Play/resume playback /// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> { pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play"); debug!("[PlayerController] play");

View File

@ -303,8 +303,8 @@ async playbackModeIsTransferring() : Promise<boolean> {
/** /**
* Transfer playback from local device to a remote Jellyfin session * Transfer playback from local device to a remote Jellyfin session
*/ */
async playbackModeTransferToRemote(sessionId: string) : Promise<null> { async playbackModeTransferToRemote(sessionId: string, position: number | null) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId }); return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId, position });
}, },
/** /**
* Get remote session status (for polling position/duration) * Get remote session status (for polling position/duration)

View File

@ -1,7 +1,9 @@
<!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 --> <!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 -->
<script lang="ts"> <script lang="ts">
import { get } from "svelte/store";
import { sessions, controllableSessions, selectedSession } from "$lib/stores"; import { sessions, controllableSessions, selectedSession } from "$lib/stores";
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode"; import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
import { playbackPosition } from "$lib/stores/player";
import type { Session } from "$lib/api/types"; import type { Session } from "$lib/api/types";
interface Props { interface Props {
@ -14,8 +16,8 @@
async function handleSessionSelect(session: Session) { async function handleSessionSelect(session: Session) {
try { try {
// Transfer playback to remote session // Transfer playback to remote session, resuming at our current position
await playbackMode.transferToRemote(session.id); await playbackMode.transferToRemote(session.id, get(playbackPosition));
if (onSelectSession) { if (onSelectSession) {
onSelectSession(session); onSelectSession(session);

View File

@ -10,7 +10,7 @@
import { type UnlistenFn } from "@tauri-apps/api/event"; import { type UnlistenFn } from "@tauri-apps/api/event";
import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings"; import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
import { player, playbackPosition } from "$lib/stores/player"; import { player, playbackPosition, currentMedia } from "$lib/stores/player";
import { queue, currentQueueItem } from "$lib/stores/queue"; import { queue, currentQueueItem } from "$lib/stores/queue";
import { playbackMode } from "$lib/stores/playbackMode"; import { playbackMode } from "$lib/stores/playbackMode";
import { sleepTimer } from "$lib/stores/sleepTimer"; import { sleepTimer } from "$lib/stores/sleepTimer";
@ -163,9 +163,16 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
} }
if (state === "playing" && currentItem) { if (state === "playing" && currentItem) {
// Use 0 for position/duration - will be updated by position_update events // Preserve the current position when the same track is already loaded
// (e.g. resuming from pause, or a spurious PlaybackRestart). Only reset
// to 0 when switching to a different track. position_update events keep
// it fresh either way, but resetting unconditionally caused the time to
// flash to 0:00 on pause/resume.
const previous = get(currentMedia);
const isSameTrack = previous?.id === currentItem.id;
const startPosition = isSameTrack ? get(playbackPosition) : 0;
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0; const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
player.setPlaying(currentItem, 0, initialDuration); player.setPlaying(currentItem, startPosition, initialDuration);
// Trigger preloading of upcoming tracks in the background // Trigger preloading of upcoming tracks in the background
preloadUpcomingTracks().catch((e) => { preloadUpcomingTracks().catch((e) => {

View File

@ -175,10 +175,20 @@ function createMusicStore() {
const genres = await repo.getGenres(libraryId); const genres = await repo.getGenres(libraryId);
if (genres.length === 0) return; if (genres.length === 0) return;
const hasCounts = genres.some(g => g.albumCount != null); // Counts are only *useful* if they actually differentiate genres. Some
// servers return Fields=ItemCounts but populate every genre with the same
// value (or 0), which leaves the list in its original alphabetical order
// after "ranking" — so diversity selection seeds on the first genre and we
// get a wall of A-genres ("avangard", "avan-gard", ...) with no Rock.
// Treat that as "no usable counts" and fall through to the probe path,
// which ranks by genres' real album counts instead.
const positiveCounts = genres
.map(g => g.albumCount)
.filter((c): c is number => c != null && c > 0);
const hasUsefulCounts = new Set(positiveCounts).size > 1;
let genreRows: GenreRow[]; let genreRows: GenreRow[];
if (hasCounts) { if (hasUsefulCounts) {
// Rank by reported count, pick a diverse subset, then fetch only those. // Rank by reported count, pick a diverse subset, then fetch only those.
const ranked = [...genres].sort( const ranked = [...genres].sort(
(a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0) (a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0)
@ -188,7 +198,8 @@ function createMusicStore() {
row => row.items.length > 0 row => row.items.length > 0
); );
} else { } else {
// No counts (offline, or a server that ignores Fields=ItemCounts). // No usable counts (offline, a server that ignores Fields=ItemCounts,
// or one that returns uniform/zero counts — see hasUsefulCounts above).
// The genre list is alphabetical, so probing the first N would only // The genre list is alphabetical, so probing the first N would only
// ever surface A-genres. Sample at an even stride across the whole // ever surface A-genres. Sample at an even stride across the whole
// list instead, so the probe pool spans A→Z; then drop empties, rank // list instead, so the probe pool spans A→Z; then drop empties, rank

View File

@ -167,6 +167,7 @@ describe("playbackMode store", () => {
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_remote", { expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_remote", {
sessionId: "session-456", sessionId: "session-456",
position: null,
}); });
}); });

View File

@ -73,7 +73,10 @@ function createPlaybackModeStore() {
* - Polls remote session until track loads * - Polls remote session until track loads
* - Stops local playback * - Stops local playback
*/ */
async function transferToRemote(sessionId: string | null | undefined): Promise<void> { async function transferToRemote(
sessionId: string | null | undefined,
currentPosition?: number,
): Promise<void> {
console.log("[PlaybackMode] Transferring to remote session:", sessionId); console.log("[PlaybackMode] Transferring to remote session:", sessionId);
update((s) => ({ ...s, isTransferring: true, transferError: null })); update((s) => ({ ...s, isTransferring: true, transferError: null }));
@ -88,10 +91,17 @@ function createPlaybackModeStore() {
}; };
try { try {
// Pass the caller's current local position so the remote resumes where we
// are. The backend can't reliably read this itself: on Linux video plays in
// the HTML5 <video> element and the MPV backend reports 0. A null override
// falls back to the backend position (correct for Linux audio via MPV).
const positionOverride =
currentPosition !== undefined && currentPosition > 0 ? currentPosition : null;
// Rust handles everything - just wait for it to complete // Rust handles everything - just wait for it to complete
// It includes its own 5-second timeout for track loading // It includes its own 5-second timeout for track loading
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId); console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId, "position:", positionOverride);
await commands.playbackModeTransferToRemote(sessionId ?? ""); await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride);
console.log("[PlaybackMode] Invoke completed successfully"); console.log("[PlaybackMode] Invoke completed successfully");
if (aborted) { if (aborted) {