feat(qbz-slint): route local play to the peer renderer when controlling

Playing a new album/track on QBZ while controlling a peer renderer played
it LOCALLY instead of casting it to the peer. Add
SlintQconnectService::play_on_peer_if_active(track_id): gated on
is_peer_renderer_active, it pushes the current core queue to the peer
unconditionally (same all-or-nothing Qobuz-castable admission as
sync_local_queue_if_changed; a mixed/local/Plex queue is refused with a
toast and treated as handled so it is not double-played locally), then
calls play_remote_renderer_track_if_active to SetPlayerState the peer to
the requested track. Send errors are logged but still return handled — a
peer owns playback, so never fall back to local audio (double-play).

play_audible (the single local-play chokepoint for play-all / track play)
now tries play_on_peer_if_active first and returns early when handled. When
no peer is active it returns false and the existing local play runs
byte-unchanged, so renderer / local playback do not regress.

cc
This commit is contained in:
vicrodh committed 2026-06-03 09:27:00 -06:00
1 parent 6405b10204
commit 4f0e7a5664
2 files changed
+112

No files matched your search

+10
View File
@@ -134,6 +134,16 @@ type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// the player's self-contained `play_track`. Errors are logged, not
/// surfaced — the poll loop keeps the UI consistent regardless.
async fn play_audible(runtime: &Runtime, weak: &slint::Weak<AppWindow>, track_id: u64) {
// QConnect CONTROLLER mode: when a PEER renderer owns playback, route the
// new play to the peer instead of playing locally. `play_on_peer_if_active`
// returns false in every non-controller situation (disconnected, renderer
// mode where active == local, no peer), so the existing local path below
// runs byte-unchanged and renderer / local playback do not regress.
if let Some(svc) = crate::qconnect_service::service() {
if svc.play_on_peer_if_active(track_id).await {
return;
}
}
// Source-aware: a LOCAL user file plays from disk via the play_data seam.
// Offline-cached + Qobuz keep the existing tier-walk below (unchanged), so
// streaming playback can't regress. The current queue track tells us which
+102
View File
@@ -540,6 +540,108 @@ impl SlintQconnectService {
}
}
/// Controller play-routing: when QBZ is CONTROLLING a peer renderer and the
/// user plays a new album/track on QBZ, route it to the peer instead of
/// playing it locally. Returns `true` when handled remotely (caller MUST
/// NOT play locally) and `false` when no peer is active (caller plays
/// locally — the existing behavior runs byte-unchanged).
///
/// Unlike `sync_local_queue_if_changed`, the queue push here is
/// UNCONDITIONAL (no echo-gate, no is_local_renderer_active gate) — the user
/// just issued a fresh play, so the current core queue IS what should run on
/// the peer. Admission is the SAME all-or-nothing rule: a queue containing
/// any local / Plex track is refused whole (a renderer can only play Qobuz
/// catalog ids; offline qobuz_download IS eligible). On refusal it toasts and
/// returns `true` (handled — do NOT fall back to playing a mixed queue
/// locally that we just declined to cast). On any send error it logs and
/// still returns `true` (a peer owns playback; falling back to local audio
/// would double-play).
pub async fn play_on_peer_if_active(&self, track_id: u64) -> bool {
let (app, peer_active) = {
let guard = self.inner.lock().await;
let Some(runtime) = guard.runtime.as_ref() else {
return false;
};
let peer_active = {
let state = runtime.sync_state.lock().await;
is_peer_renderer_active(&state.session)
};
(Arc::clone(&runtime.app), peer_active)
};
if !peer_active {
return false;
}
// (a) Push the CURRENT core queue to the peer (unconditional).
let (tracks, current_index) = self.runtime.core().get_all_queue_tracks().await;
if tracks.is_empty() {
return false;
}
let ordered_ids: Vec<u64> = tracks.iter().map(|track| track.id).collect();
// Admission: refuse the whole push if any track isn't Qobuz-castable.
let all_eligible = tracks.iter().all(|track| {
let source = track
.source
.as_deref()
.unwrap_or("qobuz")
.to_ascii_lowercase();
source != "local" && source != "plex" && track.id > 0
});
if !all_eligible {
log::info!("[QConnect] Local queue has non-Qobuz tracks; not casting to Connect");
crate::toast::error_weak(&self.window, "Mixed queue — not cast to Qobuz Connect");
dev_push_event(&self.window, "-> queue push REFUSED (mixed/non-Qobuz)".to_string());
// Remember it so the poll loop's sync doesn't re-toast this queue.
*self.last_pushed_queue_ids.lock().await = Some(ordered_ids);
// Handled: do NOT play a refused queue locally.
return true;
}
let count = ordered_ids.len();
let track_ids: Vec<i64> = ordered_ids.iter().map(|id| *id as i64).collect();
let start_index = current_index.unwrap_or(0);
let payload = json!({
"track_ids": track_ids,
"queue_position": start_index,
"shuffle_mode": false,
"shuffle_pivot_index": start_index,
"context_uuid": Uuid::new_v4().to_string(),
"autoplay_reset": true,
"autoplay_loading": false,
});
let command = app
.build_queue_command(QueueCommandType::CtrlSrvrQueueLoadTracks, payload)
.await;
match app.send_queue_command(command).await {
Ok(_) => {
log::info!(
"[QConnect] play_on_peer: pushed queue ({count} tracks, start={start_index})"
);
dev_push_event(
&self.window,
format!("-> play_on_peer QueueLoadTracks {count} start={start_index}"),
);
*self.last_pushed_queue_ids.lock().await = Some(ordered_ids);
}
Err(err) => {
log::warn!("[QConnect] play_on_peer: queue push failed: {err}");
// Still handled: a peer owns playback; never fall back to local.
return true;
}
}
// (b) SetPlayerState the peer to the requested track (polls the peer
// queue until the track appears). Errors are logged, never fall back.
match self.play_remote_renderer_track_if_active(track_id).await {
Ok(_) => {}
Err(err) => {
log::warn!("[QConnect] play_on_peer: play_remote_track failed: {err}");
}
}
true
}
// -----------------------------------------------------------------------
// CONTROLLER-mode transport routing (`*_if_remote`). Mirror of the Tauri
// `src-tauri/src/qconnect/service.rs` adapter. Return contract: