fix(session): resume Qobuz streams at saved position without losing seek (#315)

Three bugs were stacking up to make session resume drop the saved offset
for streamed Qobuz tracks:

1. Position save was debounced, not throttled. Each playback tick reset
   the 5s timer, so the save never fired while a track played
   continuously — the persisted position froze at whatever value got
   written during the first quiet window after track start (typically
   ~2s). Switch to throttle: fire the first call immediately, then once
   per 5s window, with a tail call to flush the latest value when the
   window ends.

2. togglePlay tried to apply the restore offset via a separate cmdSeek
   right after cmdPlayTrack. For Qobuz streaming the audio thread's
   Seek handler bails when current_audio_data is unset — streaming
   uses current_streaming_source instead — so the seek was silently
   dropped and the track started at 0:00. Pass start_position_secs
   directly to v2_play_track. The streaming backend (legacy + V2
   player) waits for enough buffer to cover the offset plus 8s
   headroom, then eagerly decode-and-discards decoder output up to
   the offset before engaging the audio engine — no race, no
   first-pull underrun. Plex and local tracks keep the old play+seek
   path; they load full audio_data so Seek works as-is.

3. The seekbar showed 0:00 from app launch until the user hit play,
   even when a saved position was queued. setPendingSessionRestore
   now pre-positions currentTime when positionSecs > 0 so the UI
   reflects "paused at X" the moment the app opens.

Other plumbing:
- AudioCommand::PlayStreaming carries start_position_secs and
  content_length (used to estimate bytes-per-second for the resume
  buffer target).
- Player::play_streaming{,_dynamic} grow a start_position_secs
  parameter; QConnect and api_server callers pass 0 (remote-driven
  playback always starts fresh).
- cmdPlayTrack forwards startPositionSecs as a 5th optional arg.

cc
This commit is contained in:
vicrodh committed 2026-05-14 02:18:59 -06:00
1 parent b2960e8029
commit 921f8e1a49
8 files changed
+331 -66

No files matched your search

+3
View File
@@ -652,6 +652,7 @@ async fn play_queue_track(ctx: &ApiContext, track: QueueTrack) -> Result<QueueTr
None,
None,
Some(track.duration_secs),
None, // start_position_secs: remote-driven play always starts at 0
bridge,
offline_cache,
audio_settings,
@@ -884,6 +885,7 @@ async fn play_queue_index(
None,
None,
Some(t.duration_secs),
None, // start_position_secs: remote-driven play always starts at 0
bridge,
offline_cache,
audio_settings,
@@ -1059,6 +1061,7 @@ async fn play_album(
None,
None,
Some(first_track.duration_secs),
None, // start_position_secs: remote-driven play always starts at 0
bridge,
offline_cache,
audio_settings,
+9
View File
@@ -887,12 +887,18 @@ pub struct V2PlayTrackResult {
/// 3. Downloads audio
/// 4. Plays via CoreBridge.player() (qbz-player crate)
/// 5. Caches for future playback
// `start_position_secs` (#315): when > 0, the streaming path waits for
// enough buffer to cover this offset and pre-skips decoder output up to
// that point, then engages audio. Avoids the play-then-seek race that
// silently dropped the saved position on first-track session restore.
// 0 / None = start from beginning.
#[tauri::command]
pub async fn v2_play_track(
track_id: u64,
quality: Option<String>,
force_lowest_quality: Option<bool>,
duration_secs: Option<u64>,
start_position_secs: Option<u64>,
bridge: State<'_, CoreBridgeState>,
offline_cache: State<'_, OfflineCacheState>,
audio_settings: State<'_, AudioSettingsState>,
@@ -901,6 +907,7 @@ pub async fn v2_play_track(
app_handle: tauri::AppHandle,
runtime: State<'_, RuntimeManagerState>,
) -> Result<V2PlayTrackResult, RuntimeError> {
let start_position_secs = start_position_secs.unwrap_or(0);
// Runtime contract: require CoreBridge auth for V2 playback
runtime
.manager()
@@ -1294,6 +1301,7 @@ pub async fn v2_play_track(
total_flac_size,
speed_mbps,
duration_secs.unwrap_or(0),
start_position_secs,
)
.map_err(RuntimeError::Internal)?;
@@ -1528,6 +1536,7 @@ pub async fn v2_play_track(
stream_info.content_length,
stream_info.speed_mbps,
duration_secs.unwrap_or(0),
start_position_secs,
)
.map_err(RuntimeError::Internal)?;
+122 -18
View File
@@ -86,6 +86,17 @@ enum AudioCommand {
sample_rate: u32,
channels: u16,
duration_secs: u64,
/// Resume offset in seconds. 0 = start from beginning. >0 = the
/// audio thread waits until enough bytes are buffered to cover
/// (position + headroom) seconds, then decodes-and-discards
/// samples up to `start_position_secs` before feeding the engine.
/// Avoids the play-then-seek race where Seek would silently
/// bail because `current_audio_data` is unset for streaming.
start_position_secs: u64,
/// Total content length in bytes. Used together with
/// `duration_secs` to estimate bytes-per-second when computing
/// how much buffer is needed for `start_position_secs > 0`.
content_length: u64,
},
/// Pause playback
Pause,
@@ -1619,13 +1630,16 @@ impl Player {
sample_rate,
channels,
duration_secs,
start_position_secs,
content_length,
} => {
log::info!(
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s)",
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s, start={}s)",
track_id,
sample_rate,
channels,
duration_secs
duration_secs,
start_position_secs
);
*pause_suspend_deadline = None;
@@ -1839,12 +1853,45 @@ impl Player {
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
apply_engine_volume(&stream_opt, &engine, volume);
// Wait for minimum buffer before starting playback
// Wait for minimum buffer before starting playback.
// When start_position_secs > 0 (session resume),
// also wait for enough buffer to cover the resume
// offset plus an 8s headroom — the eager pre-skip
// below will decode-and-discard up to the offset,
// and we need bytes in the buffer to feed it
// without blocking the audio device.
log::info!("Streaming: waiting for initial buffer...");
let start_wait = Instant::now();
let max_wait = Duration::from_secs(30);
let max_wait = Duration::from_secs(60);
while !source.has_min_buffer() && start_wait.elapsed() < max_wait {
let bytes_per_sec_estimate: u64 = if duration_secs > 0
&& content_length > 0
{
content_length / duration_secs
} else {
// Conservative fallback: ~200 KB/s ≈ 16/44 FLAC.
200_000
};
let resume_buffer_target: u64 = if start_position_secs > 0 {
bytes_per_sec_estimate
.saturating_mul(start_position_secs.saturating_add(8))
} else {
0
};
let buffer_sufficient = |src: &Arc<BufferedMediaSource>| -> bool {
if !src.has_min_buffer() {
return false;
}
if resume_buffer_target == 0 {
return true;
}
(src.buffer_size() as u64) >= resume_buffer_target
};
while !buffer_sufficient(&source)
&& start_wait.elapsed() < max_wait
{
std::thread::sleep(Duration::from_millis(50));
}
@@ -1852,11 +1899,20 @@ impl Player {
log::error!("Streaming: timeout waiting for initial buffer");
return;
}
if resume_buffer_target > 0
&& (source.buffer_size() as u64) < resume_buffer_target
{
log::warn!(
"Streaming: timed out waiting for resume buffer (got {} bytes, wanted {}); pre-skip may underrun briefly",
source.buffer_size(),
resume_buffer_target
);
}
let buffer_wait_ms = start_wait.elapsed().as_millis();
log::info!(
"Streaming: initial buffer ready in {}ms, creating incremental decoder...",
buffer_wait_ms
"Streaming: buffer ready in {}ms ({} bytes, target {}), creating incremental decoder...",
buffer_wait_ms, source.buffer_size(), resume_buffer_target
);
// Create incremental streaming source - this starts playback IMMEDIATELY
@@ -1934,8 +1990,41 @@ impl Player {
thread_state.set_normalization_gain(normalization);
// Box the incremental source to match the expected type
let source_to_play: Box<dyn Source<Item = f32> + Send> =
let mut source_to_play: Box<dyn Source<Item = f32> + Send> =
Box::new(incremental_source);
// Eager pre-skip for session resume. We decode and
// discard samples here so that the engine's first
// pull doesn't have to do the work synchronously,
// which would underrun the audio device for
// multi-second offsets. The buffer wait above
// guarantees there's enough downloaded data to
// feed this loop without blocking on bytes.
if start_position_secs > 0 {
let target_samples: u64 = (start_position_secs)
.saturating_mul(actual_sr as u64)
.saturating_mul(actual_ch as u64);
let skip_start = Instant::now();
let mut skipped: u64 = 0;
while skipped < target_samples {
if source_to_play.next().is_none() {
log::warn!(
"Resume: source ended before reaching {}s (pre-skipped {} samples)",
start_position_secs,
skipped
);
break;
}
skipped += 1;
}
log::info!(
"Resume: pre-skipped {} samples ({}s) in {}ms",
skipped,
start_position_secs,
skip_start.elapsed().as_millis()
);
}
// Wrap source with diagnostic, normalization, and visualizer
let source_to_play = wrap_source(
source_to_play,
@@ -1950,16 +2039,19 @@ impl Player {
}
thread_state.is_playing.store(true, Ordering::SeqCst);
thread_state.position.store(0, Ordering::SeqCst);
thread_state
.position
.store(start_position_secs, Ordering::SeqCst);
thread_state
.current_track_id
.store(track_id, Ordering::SeqCst);
thread_state.start_playback_timer(0);
thread_state.start_playback_timer(start_position_secs);
*current_engine = Some(engine);
log::info!(
"Audio thread: streaming playback STARTED in {}ms (incremental decode active)",
start_wait.elapsed().as_millis()
"Audio thread: streaming playback STARTED in {}ms at {}s (incremental decode active)",
start_wait.elapsed().as_millis(),
start_position_secs
);
}
AudioCommand::Pause => {
@@ -2689,7 +2781,10 @@ impl Player {
}
/// Play from streaming source (starts playback before full download)
/// Returns the BufferWriter so caller can push data as it downloads
/// Returns the BufferWriter so caller can push data as it downloads.
/// `start_position_secs` > 0 turns this into a session-resume play:
/// the audio thread waits for enough buffer to cover the offset and
/// pre-skips decode output up to that point before engaging audio.
pub fn play_streaming(
&self,
track_id: u64,
@@ -2698,14 +2793,16 @@ impl Player {
content_length: u64,
buffer_seconds: u8,
duration_secs: u64,
start_position_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s)",
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s, start={}s)",
track_id,
sample_rate,
channels,
content_length,
duration_secs
duration_secs,
start_position_secs
);
// Use StreamingConfig::from_seconds for proper buffer sizing
@@ -2721,6 +2818,8 @@ impl Player {
sample_rate,
channels,
duration_secs,
start_position_secs,
content_length,
})
.map_err(|e| {
log::error!("Player: Failed to send streaming command: {}", e);
@@ -2732,7 +2831,8 @@ impl Player {
}
/// Play from streaming source with dynamic buffer based on measured speed
/// Returns the BufferWriter so caller can push data as it downloads
/// Returns the BufferWriter so caller can push data as it downloads.
/// `start_position_secs` > 0 signals session resume (see `play_streaming`).
pub fn play_streaming_dynamic(
&self,
track_id: u64,
@@ -2742,16 +2842,18 @@ impl Player {
content_length: u64,
speed_mbps: f64,
duration_secs: u64,
start_position_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s)",
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s, start={}s)",
track_id,
sample_rate,
channels,
bit_depth,
content_length as f64 / (1024.0 * 1024.0),
speed_mbps,
duration_secs
duration_secs,
start_position_secs
);
// Update shared state with actual stream quality
@@ -2770,6 +2872,8 @@ impl Player {
sample_rate,
channels,
duration_secs,
start_position_secs,
content_length,
})
.map_err(|e| {
log::error!("Player: Failed to send streaming command: {}", e);
+1
View File
@@ -131,6 +131,7 @@ async fn stream_remote_track_into_player(
stream_info.content_length,
stream_info.speed_mbps,
duration_secs,
0, // QConnect tracks always start from 0; resume is local-only
)
.map_err(|err| format!("start streaming remote track {track_id}: {err}"))?;