// 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() } } } } } } } } }