From 6afbc941714fc628c92fb4e9319a4471a13d6008 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 6 May 2026 14:21:27 -0600 Subject: [PATCH] fix(qconnect): honor peer-controller seeks when qbz is the active renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #387: seeks issued from the official Qobuz mobile app while qbz acts as the active renderer were silently dropped — audio kept playing from the original position while the controller's progress bar locked. Two independent gates were swallowing the command between the WebSocket and the audio thread; either alone is enough to break the flow, so both move together. 1. Upstream filter (crates/qconnect-app/src/app.rs) The is_echo guard treated every SET_STATE with playing_state=None AND current_track=None as a pure server echo and skipped emitting RendererCommandApplied. iOS sends a real seek as exactly that shape (only current_position_ms set, the rest unchanged), so the command never reached the bridge handler. Add current_position_ms.is_none() to the guard. Pure echoes still match (server echoes carry only next_track); position-only seeks pass through. 2. Downstream gate (src-tauri/src/qconnect/corebridge.rs) The seek branch was conditioned on peer_renderer_active — i.e. it only acted when qbz was the controller observing a peer renderer, never when qbz was the renderer itself. Removed; the existing is_echo_reset and abs_diff > 2 checks already filter the redundant- seek case the gate was originally added to defend against (147bcbd7). Drop the now-unused is_peer_renderer_active import. Verified manually: seeks from iPhone (renderer_id=3) targeting qbz (renderer_id=2) at ~1/4 and ~5/6 of a 150s track now translate to local audio jumps to the correct positions. cc --- crates/qconnect-app/src/app.rs | 22 +++++++++++++++++----- src-tauri/src/qconnect/corebridge.rs | 22 +++++++++++++--------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/crates/qconnect-app/src/app.rs b/crates/qconnect-app/src/app.rs index bae34c187..bdeb8d3db 100644 --- a/crates/qconnect-app/src/app.rs +++ b/crates/qconnect-app/src/app.rs @@ -373,17 +373,29 @@ where }; // Detect echo SET_STATE commands: the server echoes every state report - // as a SET_STATE with only next_track (playing_state=None, current_track=None). - // These echoes must NOT trigger CoreBridge actions (align cursor, load track, - // resume/pause) or state reports, otherwise they destroy the local queue - // and cause feedback loops. + // as a SET_STATE with only next_track (playing_state=None, + // current_track=None, current_position_ms=None). These echoes must + // NOT trigger CoreBridge actions (align cursor, load track, + // resume/pause) or state reports, otherwise they destroy the local + // queue and cause feedback loops. + // + // Issue #387: a peer controller (e.g. official Qobuz mobile app) + // sends a SEEK as SET_STATE with only current_position_ms set + // (playing_state=None, current_track=None). The previous filter + // matched that shape and silently swallowed the command, so qbz + // never moved its audio thread when an external controller seeked. + // Treat current_position_ms.is_some() as the signal that the + // command carries real intent and must be propagated. let is_echo = matches!( &renderer_command, RendererCommand::SetState { playing_state, current_track, + current_position_ms, .. - } if playing_state.is_none() && current_track.is_none() + } if playing_state.is_none() + && current_track.is_none() + && current_position_ms.is_none() ); let (snapshot, queue_version) = { diff --git a/src-tauri/src/qconnect/corebridge.rs b/src-tauri/src/qconnect/corebridge.rs index 3f7f03361..61625f8c6 100644 --- a/src-tauri/src/qconnect/corebridge.rs +++ b/src-tauri/src/qconnect/corebridge.rs @@ -15,7 +15,6 @@ use crate::core_bridge::CoreBridge; use super::queue_resolution::{ dedupe_track_ids, resolve_core_shuffle_order, resolve_remote_start_index, }; -use super::session::is_peer_renderer_active; use super::track_loading::ensure_remote_track_loaded; use super::{ model_track_to_core_queue_track, normalize_volume_to_fraction, @@ -84,10 +83,6 @@ pub(super) async fn apply_renderer_command_to_corebridge( projection_renderer_state.next_track = next_track.clone(); } let resolved_current_track = projection_renderer_state.current_track.as_ref(); - let peer_renderer_active = { - let state = sync_state.lock().await; - is_peer_renderer_active(&state.session) - }; if let Some(projected_track) = resolved_current_track { let queue_state = { let state = sync_state.lock().await; @@ -197,10 +192,19 @@ pub(super) async fn apply_renderer_command_to_corebridge( .unwrap_or(false) && target_secs <= 1 && current_pos_secs > 2; - if peer_renderer_active - && !is_echo_reset - && current_pos_secs.abs_diff(target_secs) > 2 - { + // Issue #387: honor seeks regardless of which device is the + // active renderer. The previous gate (`peer_renderer_active`) + // skipped seeks entirely when local was the active renderer, + // breaking the case where a peer controller (e.g. official + // Qobuz mobile app) sends a real seek to qbz acting as the + // renderer — the audio thread never moved while the cloud + // state advanced, so the controller's progress bar locked. + // The is_echo_reset + abs_diff > 2 gates already filter the + // cloud-echo case the peer_renderer_active check was added + // to defend against in commit 147bcbd7. If hiccups return, + // revert this change and reintroduce a more targeted echo + // detector (UUID-based) instead of the all-or-nothing gate. + if !is_echo_reset && current_pos_secs.abs_diff(target_secs) > 2 { log::info!( "[QConnect] SetState seek: current={}s target={}s", current_pos_secs,