fix(Remote playback): kludge to scrub after stream move
This commit is contained in:
parent
2811e1b7ca
commit
6836ce79c8
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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");
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user