feat(qbz-slint): QConnect device picker + cast-aware now-playing state

Add the CONTROLLER-mode device picker. New QconnectDevice struct + devices /
active-renderer-id / set-active on the QconnectDevState global (re-exported via
app.slint so include_modules! generates the type). The connect flyout replaces
the "No devices found" placeholder with a plain-row list (ADR-008: no pills)
when session renderers exist: each row taps to set-active and closes the menu;
the local device renders as "Play here" to pull playback back to QBZ. A
"Playing on: {name}" header shows while a peer is active.

The event sink rebuilds the device list and pushes the cast-aware now-playing
state (is-remote / cast-target / volume-locked) from session topology after
every event, replacing the prior TODO. main.rs wires on_set_active to the new
SlintQconnectService::set_active_renderer (over send_set_active_renderer).

Wired but unverified (blind build, no phone+speaker); device round-trips need a
smoke test. Slice 4 (peer-state reflection in the poll loop) deferred.

cc
This commit is contained in:
vicrodh committed 2026-06-03 07:08:46 -06:00
1 parent 126e9da1db
commit 0d7ac7930d
5 files changed
+188 -12

No files matched your search

+21
View File
@@ -2973,6 +2973,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
qconnect_service::dev_clear(&weak);
});
}
// Device picker — switch the active renderer (or pull playback back to QBZ
// via the local id = "Play here"). The topology refresh arrives on the next
// session event and re-renders the picker + is-remote state.
{
let handle = tokio_rt.handle().clone();
let weak = window.as_weak();
window
.global::<QconnectDevState>()
.on_set_active(move |renderer_id| {
let Some(svc) = qconnect_service::service() else {
return;
};
let weak = weak.clone();
handle.spawn(async move {
if let Err(e) = svc.set_active_renderer(renderer_id).await {
log::warn!("[QConnect] set_active_renderer({renderer_id}): {e}");
crate::toast::error_weak(&weak, "Failed to switch renderer");
}
});
});
}
// Transport — wired through the NowPlayingState global callbacks.
//
+81 -9
View File
@@ -23,9 +23,10 @@ use std::sync::{Arc, OnceLock, Weak};
use async_trait::async_trait;
use qbz_app::shell::AppRuntime;
use qconnect_app::{
build_session_renderer_snapshot, cache_renderer_snapshot, is_peer_renderer_active, QconnectApp,
QconnectAppEvent, QconnectEventSink, QconnectRemoteSyncState, QconnectRendererEngine,
RendererCommand, RendererReport, RendererReportType,
build_session_renderer_snapshot, cache_renderer_snapshot, is_peer_renderer_active,
renderer_allows_remote_volume, QconnectApp, QconnectAppEvent, QconnectEventSink,
QconnectRemoteSyncState, QconnectRendererEngine, RendererCommand, RendererReport,
RendererReportType,
};
use qconnect_transport_ws::NativeWsTransport;
use serde_json::Value;
@@ -54,9 +55,8 @@ pub struct SlintQconnectEventSink {
/// is_active=true after SetActive(true)) and to drive the session-apply +
/// freeze/watchdog without an ownership cycle.
app: Arc<OnceLock<Weak<SlintQconnectApp>>>,
/// Window handle for future UI surfacing (toasts, badge state). Unused until
/// the QConnect UI-polish step.
#[allow(dead_code)]
/// Window handle for UI surfacing: the DEV modal, the device picker, and the
/// cast-aware now-playing state (is-remote / cast-target / volume-locked).
window: slint::Weak<AppWindow>,
}
@@ -138,6 +138,76 @@ impl SlintQconnectEventSink {
crate::qconnect_service::dev_set_status(&self.window, status);
}
/// Rebuild the QConnect device-picker model from the live session topology
/// and push it to `QconnectDevState` (devices + active-renderer-id). Mirrors
/// the Tauri renderer-list source. Maps `session.renderers` -> rows, marking
/// the local device (`is-local`, rendered as "Play here") and the active one.
async fn refresh_device_list(&self) {
let (devices, active_id) = {
let st = self.sync_state.lock().await;
let session = &st.session;
let devices: Vec<crate::QconnectDevice> = session
.renderers
.iter()
.map(|r| crate::QconnectDevice {
renderer_id: r.renderer_id,
name: r
.friendly_name
.clone()
.unwrap_or_else(|| "Unknown device".to_string())
.into(),
is_local: Some(r.renderer_id) == session.local_renderer_id,
is_active: Some(r.renderer_id) == session.active_renderer_id,
})
.collect();
(devices, session.active_renderer_id.unwrap_or(-1))
};
let _ = self.window.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
let dev = w.global::<crate::QconnectDevState>();
dev.set_devices(slint::ModelRc::new(slint::VecModel::from(devices)));
dev.set_active_renderer_id(active_id);
});
}
/// Push the cast-aware now-playing state (is-remote / cast-target /
/// volume-locked) to `NowPlayingState` from the live session topology.
/// Replaces the old `TODO(slint-qconnect-ui)`. `is-remote` is true when a
/// PEER renderer owns playback; `cast-target` is its friendly name;
/// `volume-locked` is true when that renderer disallows remote volume.
async fn refresh_now_playing_remote_state(&self) {
let (is_remote, cast_target, volume_locked) = {
let st = self.sync_state.lock().await;
let session = &st.session;
let is_remote = is_peer_renderer_active(session);
let active = session.active_renderer_id;
let active_info = active.and_then(|active_id| {
session.renderers.iter().find(|r| r.renderer_id == active_id)
});
let cast_target = if is_remote {
active_info
.and_then(|r| r.friendly_name.clone())
.unwrap_or_default()
} else {
String::new()
};
let volume_locked = is_remote
&& active_info
.map(|r| !renderer_allows_remote_volume(r))
.unwrap_or(false);
(is_remote, cast_target, volume_locked)
};
let _ = self.window.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
let nps = w.global::<crate::NowPlayingState>();
nps.set_is_remote(is_remote);
nps.set_cast_target(cast_target.into());
nps.set_volume_locked(volume_locked);
});
}
/// Refresh the Slint now-playing card + queue sidebar from the current core
/// state. The inbound renderer orchestration (materialize / apply) mutates
/// the core player+queue but does NOT touch the UI; without this the card +
@@ -417,9 +487,11 @@ impl QconnectEventSink for SlintQconnectEventSink {
crate::qconnect_service::dev_push_event(&self.window, dev_event_line(&event));
self.refresh_dev_status().await;
// TODO(slint-qconnect-ui): route the event into NowPlayingState (is-remote,
// cast-target, volume-locked) + the toast surface, mirroring the Tauri
// blanket `qconnect:event` emit the Svelte UI consumes.
// Controller-mode UI: rebuild the device picker + push the cast-aware
// now-playing state (is-remote / cast-target / volume-locked) from the
// live session topology after every event.
self.refresh_device_list().await;
self.refresh_now_playing_remote_state().await;
}
}