fix(Remote playback): kludge to scrub after stream move
This commit is contained in:
@@ -41,12 +41,14 @@ pub fn playback_mode_is_transferring(
|
||||
pub async fn playback_mode_transfer_to_remote(
|
||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||
session_id: String,
|
||||
position: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
"[PlaybackModeCommands] Transferring to remote session: {}",
|
||||
session_id
|
||||
"[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
|
||||
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
|
||||
|
||||
@@ -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
|
||||
#[tauri::command]
|
||||
@@ -1258,6 +1311,7 @@ pub async fn player_play_album_track(
|
||||
session: State<'_, MediaSessionManagerWrapper>,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||
repository_handle: String,
|
||||
request: PlayAlbumTrackRequest,
|
||||
) -> Result<PlayerStatus, String> {
|
||||
@@ -1380,11 +1434,24 @@ pub async fn player_play_album_track(
|
||||
session_mgr.start_audio_session(first_item.clone());
|
||||
}
|
||||
|
||||
// Play the queue
|
||||
let controller = player.0.lock().await;
|
||||
controller
|
||||
.play_queue(media_items, start_index)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// When controlling a remote session, cast the selection there instead of
|
||||
// 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
|
||||
{
|
||||
@@ -1426,6 +1493,7 @@ pub async fn player_play_tracks(
|
||||
session: State<'_, MediaSessionManagerWrapper>,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||
repository_handle: String,
|
||||
request: PlayTracksRequest,
|
||||
) -> Result<PlayerStatus, String> {
|
||||
@@ -1533,10 +1601,33 @@ pub async fn player_play_tracks(
|
||||
session_mgr.start_audio_session(first_item.clone());
|
||||
}
|
||||
|
||||
// Play queue
|
||||
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
|
||||
{
|
||||
|
||||
@@ -284,22 +284,35 @@ impl JellyfinClient {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
session_id: String,
|
||||
position_ticks: i64,
|
||||
) -> Result<(), String> {
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct SeekRequest {
|
||||
seek_position_ticks: i64,
|
||||
let url = format!(
|
||||
"{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
|
||||
self.config.server_url, session_id, position_ticks
|
||||
);
|
||||
|
||||
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 {
|
||||
seek_position_ticks: position_ticks,
|
||||
};
|
||||
|
||||
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
|
||||
log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a full GeneralCommand to a remote session.
|
||||
|
||||
@@ -181,7 +181,11 @@ impl PlaybackModeManager {
|
||||
}
|
||||
|
||||
/// 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] session_id: {}", session_id);
|
||||
log::info!(
|
||||
@@ -195,7 +199,7 @@ impl PlaybackModeManager {
|
||||
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
||||
|
||||
// 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
|
||||
self.is_transferring.store(false, Ordering::Relaxed);
|
||||
@@ -203,7 +207,11 @@ impl PlaybackModeManager {
|
||||
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");
|
||||
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)?;
|
||||
// Read the LIVE backend position, not the snapshot embedded in PlayerState.
|
||||
// On Android the embedded position is only refreshed on play/pause
|
||||
// transitions, so PlayerState::position() is stale (often 0) mid-track;
|
||||
// PlayerController::position() always reflects the backend's current time.
|
||||
let position = player.position();
|
||||
// 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!(
|
||||
@@ -413,6 +429,23 @@ impl PlaybackModeManager {
|
||||
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)
|
||||
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
|
||||
{
|
||||
|
||||
@@ -358,6 +358,23 @@ impl PlayerController {
|
||||
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
|
||||
pub fn play(&self) -> Result<(), PlayerError> {
|
||||
debug!("[PlayerController] play");
|
||||
|
||||
Reference in New Issue
Block a user