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:
5 files changed
+188
-12
No files matched your search
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
||||
import { Toast } from "primitives/Toast.slint";
|
||||
|
||||
// Re-export the state globals so the Rust layer can populate them.
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState } from "state.slint";
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice } from "state.slint";
|
||||
|
||||
// Which top-level screen is shown. The app starts on `splash` while it
|
||||
// restores a saved session, then resolves to `shell` or `login`.
|
||||
|
||||
@@ -19,7 +19,7 @@ import { IconButton, PlayButton } from "../primitives/BarControls.slint";
|
||||
import { SeekBar } from "SeekBar.slint";
|
||||
import { SongCard } from "SongCard.slint";
|
||||
import { TransportControls } from "TransportControls.slint";
|
||||
import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState } from "../state.slint";
|
||||
import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState, QconnectDevState, QconnectDevice } from "../state.slint";
|
||||
|
||||
// IconButton + PlayButton now live in primitives/BarControls.slint (shared
|
||||
// with TransportControls). ConnectButton + ToggleRow stay local (bar-only).
|
||||
@@ -365,12 +365,22 @@ export component PlayerBar inherits Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
// "Playing on" header — shown only when a peer
|
||||
// renderer owns playback (controller mode).
|
||||
if NowPlayingState.is-remote && NowPlayingState.cast-target != "": Text {
|
||||
text: @tr("Playing on: {}", NowPlayingState.cast-target);
|
||||
color: Theme.accent;
|
||||
font-size: 11px;
|
||||
overflow: elide;
|
||||
}
|
||||
// Device list / empty state.
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
background: Theme.surface-elevated;
|
||||
border-radius: Radius.sm;
|
||||
VerticalLayout {
|
||||
clip: true;
|
||||
// Empty state (no session renderers yet).
|
||||
if QconnectDevState.devices.length == 0: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 4px;
|
||||
Text {
|
||||
@@ -387,6 +397,61 @@ export component PlayerBar inherits Rectangle {
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
// Device picker — plain rows (ADR-008: no
|
||||
// pills). Tap a row to make it the active
|
||||
// renderer; the local device row is
|
||||
// "Play here" (pulls playback back to QBZ).
|
||||
if QconnectDevState.devices.length > 0: dev-flick := Flickable {
|
||||
viewport-height: dev-rows.preferred-height;
|
||||
dev-rows := VerticalLayout {
|
||||
padding: 4px;
|
||||
spacing: 2px;
|
||||
for d in QconnectDevState.devices: Rectangle {
|
||||
height: 34px;
|
||||
border-radius: Radius.sm;
|
||||
background: row-ta.has-hover ? Theme.surface-hover : transparent;
|
||||
row-ta := TouchArea {
|
||||
clicked => {
|
||||
QconnectDevState.set-active(d.renderer-id);
|
||||
qconnect-menu.close();
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/monitor-speaker.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
tint: d.is-active ? Theme.accent : Theme.text-secondary;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: d.is-local ? @tr("Play here") : d.name;
|
||||
color: d.is-active ? Theme.text-primary : Theme.text-secondary;
|
||||
font-size: 12px;
|
||||
font-weight: d.is-active ? 600 : 400;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
// Active indicator dot (no pill).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: d.is-active ? Theme.accent : transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Connect / Disconnect toggle.
|
||||
Rectangle {
|
||||
|
||||
@@ -1977,10 +1977,23 @@ export global NowPlayingState {
|
||||
callback qconnect-toggle();
|
||||
}
|
||||
|
||||
// One row in the QConnect device picker (a session renderer). Populated by the
|
||||
// event sink from `session.renderers`.
|
||||
export struct QconnectDevice {
|
||||
renderer-id: int,
|
||||
name: string,
|
||||
is-local: bool, // this is QBZ itself ("Play here")
|
||||
is-active: bool, // currently the active renderer
|
||||
}
|
||||
|
||||
// Qobuz Connect DEV diagnostics — live session topology + a rolling event log,
|
||||
// rendered by QconnectDevModal (opened from the connect flyout). Lets us inspect
|
||||
// QC behavior at runtime WITHOUT a rebuild (Slint builds are slow). `open` is
|
||||
// in-out so the flyout button + the modal backdrop toggle it directly.
|
||||
//
|
||||
// Also hosts the device-picker model (CONTROLLER mode): the available session
|
||||
// renderers + the active renderer id, plus the set-active callback the flyout
|
||||
// rows invoke to switch playback to another renderer (or back to "Play here").
|
||||
export global QconnectDevState {
|
||||
in-out property <bool> open: false;
|
||||
in property <string> status;
|
||||
@@ -1988,6 +2001,11 @@ export global QconnectDevState {
|
||||
// modal can render it in a read-only TextEdit (selectable + copyable).
|
||||
in property <string> log-text;
|
||||
callback clear();
|
||||
|
||||
// Device picker.
|
||||
in property <[QconnectDevice]> devices: [];
|
||||
in property <int> active-renderer-id: -1;
|
||||
callback set-active(int); // renderer-id; the local id = "Play here"
|
||||
}
|
||||
|
||||
// Single global hover-tooltip channel. Each hoverable control writes its text
|
||||
|
||||
Reference in new issue
Block a user