Files
qbz/crates/qbz-qt/qml/shell/QconnectFlyout.qml
T
vicrodh c8559a68b3 feat(qt): port Qobuz Connect — service, flyout, routing and touchpoints
Full-parity port of the Slint QConnect integration per the contract at
qbz-nix-docs/qt-frontend/2026-08-01-qconnect-port/00-CONTRACT.md (three
adversarial review rounds on the spec, §12 trap sign-off PASS on both
halves, RFB-verified incl. a real cloud connect, live session
owner-tested). The shared crates are consumed verbatim — zero backend
changes.

- adapter quartet: qconnect_transport_qt (credentials, identity on the
  shared qconnect_settings.db), qconnect_engine_qt (start_track_stream
  fallback chain + feeder helpers + CMAF cursor sync),
  qconnect_event_sink_qt, qconnect_qt (the facade: connect/disconnect
  recipe, controller routing, echo guards, offline parking B12, startup
  auto-connect)
- QbzQConnect bridge singleton + on_session_entered wiring; one shared
  flyout for both bars (tooltip on the large bar only), Developer >
  Connect diagnostics modal, device-row glyphs, settings rows live
- poll loop: peer-reflection branch before cast, ~2 s renderer reports,
  sync-on-add on every Qobuz-castable add path
- routing: cast → QConnect → local (mute QConnect-first), play arms
  after the set_queue funnel, reorder cloud arm with no local fallback,
  volume/mute/seek remote-first
- touchpoints: lyrics remote anchor, scrobble guarded / Discord
  unguarded, cast suspend/resume, volume-lock lifted when remote

km
2026-08-01 20:23:38 -06:00

361 lines
15 KiB
QML

// Qobuz Connect device flyout — the QML port of the PopupWindow both Slint
// bars mount (`shell/PlayerBar.slint:412-642` + `shell/PlayerBarSmall.slint:
// 374-596`). ONE shared component is the contract §8 sanctioned collapse of
// the two Slint flyouts (their only delta was 1px of popup y); the bar-side
// asymmetry that survives is the BUTTON's tooltip, which lives in the bars,
// not here.
//
// STRUCTURE + BEHAVIOR are 1:1 with the Slint; geometry follows this port's
// own popup conventions (controls/QbzContextMenu.qml): a Popup parented to
// the Overlay, opened below-right of the trigger and clamped into the window
// with an 8px margin — the same placement ViewModeMenu / AudioSettingsMenu
// use from the same bars. Slint's fixed 280x244 panel, 14px padding and 12px
// block spacing are kept.
//
// STATE: all session data is push-driven off the QbzQConnect singleton
// (src/qconnect_bridge.rs) — `devicesJson` is parsed here exactly like
// CastPicker.qml parses QbzCast.devicesJson, guarded so a malformed/absent
// document degrades to the empty state instead of throwing. The 8s
// "Looking for devices…" discovery window is a QML-side flag + Timer
// (`qcConnecting`), 1:1 with the Slint `qc-connecting` UI timer — there is
// deliberately NO Rust busy flag (contract §8 state-mapping table).
//
// List states, mutually exclusive (Slint :504-601):
// discovering qcConnecting && devices empty -> spinner + caption
// empty !qcConnecting && devices empty -> "No devices found" + hint
// devices devices non-empty -> 34px rows, tap to set active
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../theme"
Popup {
id: root
parent: Overlay.overlay
width: 280
height: 244
padding: 14
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
QbzTheme { id: theme }
// True while a Connect click is discovering renderers (PlayerBar.slint:
// 164). The spinner is gated on this + the 8s timer below; UI-only.
property bool qcConnecting: false
// The parsed device document (QbzQConnect.devicesJson). Rows:
// { renderer_id, name, is_local, is_active, icon } — the exact shape the
// bridge header documents.
readonly property var devices: {
try {
var d = JSON.parse(QbzQConnect.devicesJson || "[]")
return Array.isArray(d) ? d : []
} catch (e) {
return []
}
}
// Discovery timer — bounds the spinner so the "no devices" empty state
// shows if none appear within ~8s (Slint :486-493). Started on a Connect
// click; stopped on disconnect. Single-shot, so triggering self-stops.
Timer {
id: discoverTimer
interval: 8000
onTriggered: root.qcConnecting = false
}
// Placement — byte-for-byte the QbzContextMenu convention: below the
// trigger, right edges aligned, clamped 8px into the window. From the
// bottom bar the y clamp is what lifts the panel into view.
function _place(gx, gy, win) {
if (win) {
x = Math.max(8, Math.min(gx, win.width - width - 8))
y = Math.max(8, Math.min(gy, win.height - height - 8))
} else {
x = gx
y = gy
}
open()
}
function openBelowRight(sourceItem) {
var g = sourceItem.mapToItem(null, sourceItem.width - width, sourceItem.height + 4)
_place(g.x, g.y, sourceItem.Window.window)
}
// The Slint chrome: surface-main, Radius.sm, 1px border-muted.
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Item {
Column {
id: topCol
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
spacing: 12
// Header.
Row {
width: parent.width
height: 16
spacing: 8
QbzIcon {
name: "monitor-speaker"
width: 16
height: 16
tintName: "textPrimary"
}
Text {
text: QbzSession.tr("Qobuz Connect", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightSemibold
anchors.verticalCenter: parent.verticalCenter
}
}
// Status divider.
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// Status row.
Row {
width: parent.width
height: 12
spacing: 8
Rectangle {
width: 8
height: 8
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: QbzQConnect.qconnectConnected ? "#22c55e" : theme.textMuted
}
Text {
text: QbzQConnect.qconnectConnected
? QbzSession.tr("Connected", QbzSession.trRev)
: QbzSession.tr("Disconnected", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
}
// "Playing on" line — only while a peer renderer owns playback
// (controller mode). Invisible children are skipped by the Column.
Text {
visible: QbzPlayer.npIsRemote && QbzPlayer.npCastTarget !== ""
width: parent.width
text: QbzSession.tr("Playing on: {}", QbzSession.trRev)
.replace("{}", QbzPlayer.npCastTarget)
color: theme.accent
font.pixelSize: 11
elide: Text.ElideRight
}
}
// Connect / Disconnect toggle (declared before the list box so the
// box can anchor against it; the list takes the stretch between).
Rectangle {
id: connectBtn
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 34
radius: theme.radiusSm
color: connectArea.pressed ? theme.accentPressed
: connectArea.containsMouse ? theme.accentHover : theme.accent
Text {
anchors.centerIn: parent
text: QbzQConnect.qconnectConnected
? QbzSession.tr("Disconnect", QbzSession.trRev)
: QbzSession.tr("Connect", QbzSession.trRev)
// On an accent fill: the theme's on-accent selector, the same
// correction CastPicker's tabs document (Slint's raw
// accent-text fails contrast on light themes).
color: theme.accentGlyphColor
font.pixelSize: 13
font.weight: theme.weightSemibold
}
MouseArea {
id: connectArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (QbzQConnect.qconnectConnected) {
// Connected -> this click DISCONNECTS -> close the
// flyout (Slint :612-618).
QbzQConnect.connectToggle()
root.qcConnecting = false
discoverTimer.stop()
root.close()
} else {
// Disconnected -> CONNECT -> keep the flyout open and
// spin while renderers are discovered (Slint :619-626).
QbzQConnect.connectToggle()
root.qcConnecting = true
discoverTimer.restart()
}
}
}
}
// Device list / empty state — the Slint's vertical-stretch block
// (:495-602): surface-elevated, Radius.sm, clipped.
Rectangle {
id: listBox
anchors.top: topCol.bottom
anchors.topMargin: 12
anchors.bottom: connectBtn.top
anchors.bottomMargin: 12
anchors.left: parent.left
anchors.right: parent.right
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
// Discovering — Connect was clicked and no renderers have arrived
// yet. Hides once devices appear or the timer expires.
Column {
anchors.centerIn: parent
spacing: 6
visible: root.qcConnecting && root.devices.length === 0
QbzSpinner {
size: 22
anchors.horizontalCenter: parent.horizontalCenter
}
Text {
text: QbzSession.tr("Looking for devices…", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
}
}
// Empty state (no session renderers yet).
Column {
anchors.centerIn: parent
width: parent.width - 24
spacing: 4
visible: !root.qcConnecting && root.devices.length === 0
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: QbzSession.tr("No devices found", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: QbzSession.tr("Make sure your renderer is on the same network", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 10
wrapMode: Text.WordWrap
}
}
// Device picker — plain rows (ADR-008: no pills). Tap a row to
// make it the active renderer; the local row is "Play here".
Flickable {
id: listFlick
anchors.fill: parent
anchors.margins: 4
visible: root.devices.length > 0
contentWidth: width
contentHeight: devRows.height
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: devRows
width: listFlick.width
spacing: 2
Repeater {
model: root.devices
delegate: Rectangle {
id: deviceRow
required property var modelData
// The Slint lights `d.is-active` from the row
// (PlayerBar.slint:576,581,583,595); the bridge's
// activeRendererId is the fallback when a row
// lacks the flag.
readonly property bool rowActive: modelData.is_active === true
|| (modelData.is_active === undefined
&& modelData.renderer_id === QbzQConnect.activeRendererId)
width: devRows.width
height: 34
radius: theme.radiusSm
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
QbzIcon {
// Device-type glyph, mirroring Qobuz
// Connect. The sink's keys are mobile /
// web / computer / speaker (its own
// default arm is "speaker"), so the
// fallback here is speaker too.
name: deviceRow.modelData.icon === "mobile" ? "smartphone"
: deviceRow.modelData.icon === "web" ? "globe"
: deviceRow.modelData.icon === "computer" ? "monitor"
: "speaker"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: deviceRow.rowActive ? "accent" : "secondary"
}
Text {
width: parent.width - 14 - 8 - 16
text: deviceRow.modelData.is_local === true
? QbzSession.tr("Play here", QbzSession.trRev)
: (deviceRow.modelData.name || "")
color: deviceRow.rowActive ? theme.textPrimary : theme.textSecondary
font.pixelSize: 12
font.weight: deviceRow.rowActive ? theme.weightSemibold : Font.Normal
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
// Active indicator dot (no pill).
Rectangle {
width: 8
height: 8
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: deviceRow.rowActive ? theme.accent : "transparent"
}
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
QbzQConnect.setActive(deviceRow.modelData.renderer_id)
root.close()
}
}
}
}
}
}
}
}
}