diff --git a/crates/qbz-qt/src/local_playback.rs b/crates/qbz-qt/src/local_playback.rs index 0a5a8074a..b692cde90 100644 --- a/crates/qbz-qt/src/local_playback.rs +++ b/crates/qbz-qt/src/local_playback.rs @@ -389,6 +389,12 @@ fn belongs_to_the_offline_tier(track: &QueueTrack) -> bool { /// view went silent. This is now the only copy of the test, and both local /// play paths (here and `local_album_actions`) go through it. pub(crate) async fn play_audible(runtime: &Runtime, track: &QueueTrack) -> bool { + // A connected renderer owns playback — the same gate every Qobuz entry + // passes (`route_play_remote`). Cheap when nothing is connected; without + // it a Local Library row played beside a DLNA renderer. + if crate::playback_qt::route_play_remote(runtime, track.id, "play_audible").await { + return true; + } if belongs_to_the_offline_tier(track) { return match crate::playback_qt::play_resolved_offline_aware(runtime, track.id, 0).await { Ok(()) => true, diff --git a/crates/qbz-qt/src/myqbz_play_qt.rs b/crates/qbz-qt/src/myqbz_play_qt.rs index 4426704ba..204a11935 100644 --- a/crates/qbz-qt/src/myqbz_play_qt.rs +++ b/crates/qbz-qt/src/myqbz_play_qt.rs @@ -349,7 +349,7 @@ pub(crate) async fn play_all_tracks( // `touch_play` below is frontend-local recency bookkeeping that runs // regardless of where the audio plays (the Slint collection-play flow // records it on the routed path too). - if !crate::playback_qt::route_play_to_peer(runtime, first_id).await { + if !crate::playback_qt::route_play_remote(runtime, first_id, "play_all_tracks").await { if let Err(e) = crate::playback_qt::play_resolved_offline_aware(runtime, first_id, 0).await { log::error!("[qbz-qt] myqbz_play: play_track {first_id} failed: {e}"); @@ -523,7 +523,7 @@ pub(crate) fn item_action(source_item_id: String, action: String) { let first_id = anchor.track_id; // QConnect CONTROLLER mode (§7): route the play to the peer // (after the funnel, before the local audible step). - if crate::playback_qt::route_play_to_peer(&runtime, first_id).await { + if crate::playback_qt::route_play_remote(&runtime, first_id, "item_action").await { return; } if let Err(e) = @@ -753,7 +753,7 @@ pub(crate) fn play_inline_track(item_source_item_id: String, track_id: String, a let first_id = anchor.track_id; // QConnect CONTROLLER mode (§7): route the play to the peer // (after the funnel, before the local audible step). - if crate::playback_qt::route_play_to_peer(&runtime, first_id).await { + if crate::playback_qt::route_play_remote(&runtime, first_id, "play_inline_track").await { return; } if let Err(e) = diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index cf1387436..7916d6d84 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -946,6 +946,13 @@ pub(crate) async fn play_resolved_offline_aware( track_id: u64, start_position_secs: u64, ) -> Result<(), String> { + // TRIPWIRE for the funnel: this is the LOCAL Qobuz/offline step and must + // never run while a renderer owns playback. A hit here means an entry + // point skipped `route_play_remote` — it is a bug, and this line is how + // the next one gets caught in a log instead of in a smoke. + if crate::cast_qt::is_casting().await { + log::warn!("[play] LOCAL PLAY WHILE CASTING (bug): track {track_id} reached play_resolved_offline_aware"); + } // SOURCE FIRST (design 02 §9 stage 3). Everything below this block is the // QOBUZ entry: the tier walk, the offline cache, the quality request. A row // a different source owns has no business in it, and until now it went @@ -1028,7 +1035,39 @@ pub(crate) async fn play_resolved_offline_aware( /// clears the spinner via its position push (`has_audio = true`) on the next /// tick, but a REFUSED routed play (mixed queue) never primes the peer — a /// paused+idle peer would leave the spinner latched (sign-off F1). -pub(crate) async fn route_play_to_peer( +/// THE remote-renderer gate — the one place every audible start passes +/// before any local backend is touched: CAST first (a connected renderer +/// owns playback), then the QConnect PEER (controller mode), else `false` +/// = play locally. Same order as the queue funnel always had. +/// +/// Why one seam: the cast gate lived ONLY in `play_queue_track`, while the +/// peer gate had been added to every other entry (`play_single_track`, +/// `play_album*`, `play_artist`, `play_track_list_in`, playlist, MyQBZ, the +/// Local Library funnel) as `route_play_to_peer` — so a track CLICK while +/// casting started the local backend beside the renderer (2026-08-29 smoke, +/// twice). Nothing may call `route_play_to_peer` directly any more; `entry` +/// names the caller in the log so the next missing gate is visible. +pub(crate) async fn route_play_remote( + runtime: &Arc>, + track_id: u64, + entry: &'static str, +) -> bool { + if crate::cast_qt::play_current_if_cast(runtime).await { + log::info!("[play] entry={entry} track={track_id} -> cast renderer"); + crate::now_playing::clear_loading(); + refresh_now_playing(runtime).await; + publish_queue(runtime).await; + return true; + } + if route_play_to_peer(runtime, track_id).await { + log::info!("[play] entry={entry} track={track_id} -> qconnect peer"); + return true; + } + log::debug!("[play] entry={entry} track={track_id} -> local"); + false +} + +async fn route_play_to_peer( runtime: &Arc>, track_id: u64, ) -> bool { @@ -1370,7 +1409,7 @@ pub async fn play_artist( publish_queue(runtime).await; // QConnect CONTROLLER mode (§7): a peer owns playback — route the play // to it AFTER the funnel, never run the local audible step. - if route_play_to_peer(runtime, first_id).await { + if route_play_remote(runtime, first_id, "play_artist").await { return Ok(()); } play_resolved_offline_aware(runtime, first_id, 0) @@ -1433,7 +1472,7 @@ pub async fn play_artist( publish_queue(runtime).await; // QConnect CONTROLLER mode (§7): route the play to the peer (after the // funnel, before the local audible step). - if route_play_to_peer(runtime, first_id).await { + if route_play_remote(runtime, first_id, "play_artist").await { return Ok(()); } play_resolved_offline_aware(runtime, first_id, 0) @@ -1557,7 +1596,7 @@ pub async fn play_album_from( log::info!("[qbz-qt] play_album: queue set ({count} tracks), playing track {first_id}"); // QConnect CONTROLLER mode (§7): route the play to the peer (after the // funnel, before the local audible step). - if route_play_to_peer(runtime, first_id).await { + if route_play_remote(runtime, first_id, "play_album_from").await { return Ok(()); } play_resolved_offline_aware(runtime, first_id, 0) @@ -1602,7 +1641,7 @@ pub async fn play_album_from_track( publish_queue(runtime).await; // QConnect CONTROLLER mode (§7): route the play to the peer (after the // funnel, before the local audible step). - if route_play_to_peer(runtime, first_id).await { + if route_play_remote(runtime, first_id, "play_album_from_track").await { return Ok(()); } play_resolved_offline_aware(runtime, first_id, 0) @@ -1763,7 +1802,7 @@ pub async fn play_track_list_in( publish_queue(runtime).await; // QConnect CONTROLLER mode (§7): route the play to the peer (after the // funnel, before the local audible step). - if route_play_to_peer(runtime, first_id).await { + if route_play_remote(runtime, first_id, "play_track_list_in").await { return Ok(()); } play_resolved_offline_aware(runtime, first_id, 0) @@ -2220,7 +2259,7 @@ pub async fn play_single_track( log::info!("[qbz-qt] play_single_track: playing {first_id}"); // QConnect CONTROLLER mode (§7): route the play to the peer (after the // funnel, before the local audible step). - if route_play_to_peer(runtime, first_id).await { + if route_play_remote(runtime, first_id, "play_single_track").await { return Ok(()); } play_resolved_offline_aware(runtime, first_id, 0) @@ -2500,18 +2539,10 @@ async fn play_queue_track( track_id: u64, start_position_secs: u64, ) { - // CAST owns playback while a renderer is connected: route the new track to - // it instead of starting the local backend, or both would play. - if crate::cast_qt::play_current_if_cast(runtime).await { - refresh_now_playing(runtime).await; - publish_queue(runtime).await; - return; - } - // QConnect CONTROLLER mode (Slint playback.rs:638-651, AFTER the cast - // gate, BEFORE the local arms): a peer renderer owns playback — route the - // play to it (it pushes the current core queue, which the caller's cursor - // move already updated) and never start the local backend. - if route_play_to_peer(runtime, track_id).await { + // A connected renderer (cast, then QConnect peer) owns playback: route + // the new track to it and never start the local backend, or both play. + // Same seam as every other entry point — see `route_play_remote`. + if route_play_remote(runtime, track_id, "play_queue_track").await { return; } // Source-aware audible step: a LOCAL file plays from disk through the diff --git a/crates/qbz-qt/src/playlist_qt.rs b/crates/qbz-qt/src/playlist_qt.rs index 48276a717..fe5c5dd4a 100644 --- a/crates/qbz-qt/src/playlist_qt.rs +++ b/crates/qbz-qt/src/playlist_qt.rs @@ -2148,7 +2148,7 @@ async fn play_queue_at( crate::queue_qt::publish(runtime).await; // QConnect CONTROLLER mode (§7): route the play to the peer (after the // funnel, before the local audible step). - if crate::playback_qt::route_play_to_peer(runtime, first_id).await { + if crate::playback_qt::route_play_remote(runtime, first_id, "play_queue_at").await { return Ok(()); } crate::playback_qt::play_resolved_offline_aware(runtime, first_id, 0)