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

+1 -1
View File
@@ -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`.
+67 -2
View File
@@ -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 {
+18
View File
@@ -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