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
This commit is contained in:
55 files changed
+7007
-87
No files matched your search
@@ -78,6 +78,11 @@ Rectangle {
|
||||
// Mode-aware height (AppShell.slint:396): Small collapses to one
|
||||
// header-tall row; New/Classic/Large keep the full 112px.
|
||||
height: QbzShell.npbMode === 2 ? theme.npbSmallHeight : theme.npbLargeHeight
|
||||
// The shared hover-tooltip overlay (declared further down — id
|
||||
// references resolve at completion). Only the FULL bar consumes it
|
||||
// (the Qobuz Connect button's "Qobuz Connect: On/Off" bubble); the
|
||||
// small bar's button has no tooltip in the reference.
|
||||
tooltip: tooltipOverlay
|
||||
}
|
||||
|
||||
Sidebar {
|
||||
@@ -620,4 +625,15 @@ Rectangle {
|
||||
id: tooltipOverlay
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
// Qobuz Connect diagnostics modal (DeveloperSettings > QOBUZ CONNECT) —
|
||||
// mounted LAST, mirroring QconnectDevModal.slint's topmost mount in
|
||||
// AppShell.slint. Ordering against the tooltip above is a non-issue: this
|
||||
// is a Popup parented to Overlay.overlay (the CastPicker pattern), so it
|
||||
// renders in the overlay layer above EVERY plain Item in this tree —
|
||||
// the tooltip's "LAST child so no neighbour can cover it" invariant is
|
||||
// about Items, and no Item was added after it. Self-gates on
|
||||
// QbzQConnect.diagOpen, so while closed it is an invisible,
|
||||
// non-interactive Item.
|
||||
QconnectDevModal { }
|
||||
}
|
||||
@@ -9,8 +9,30 @@ import com.blitzfc.qbz
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// The shell's shared hover-tooltip overlay (controls/QbzTooltip.qml),
|
||||
// fed in by AppShell (same pattern as Sidebar.tooltip). Forwarded into
|
||||
// the loaded bar below. Only the FULL bar consumes it — the Qobuz
|
||||
// Connect button's "Qobuz Connect: On/Off" bubble (contract §8: the
|
||||
// small bar's button has no tooltip in the reference).
|
||||
property Item tooltip: null
|
||||
|
||||
Loader {
|
||||
id: barLoader
|
||||
anchors.fill: parent
|
||||
source: QbzShell.npbMode === 2 ? "NowPlayingBarSmall.qml" : "PlayerBar.qml"
|
||||
}
|
||||
|
||||
// Same shape as AppShell's viewLoader "kind" Binding: applies the moment
|
||||
// the item exists, re-applies on a mode switch, RestoreNone because the
|
||||
// target is DESTROYED on unload. The `when` source guard keeps it off
|
||||
// NowPlayingBarSmall, which declares no `tooltip` property (a grouped
|
||||
// assignment against a missing property would fail the load).
|
||||
Binding {
|
||||
target: barLoader.item
|
||||
property: "tooltip"
|
||||
value: root.tooltip
|
||||
when: barLoader.item !== null
|
||||
&& barLoader.source.toString().indexOf("PlayerBar.qml") !== -1
|
||||
restoreMode: Binding.RestoreNone
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,10 @@
|
||||
// is AudioStamp.qml — the inline 2-row stamp (quality line over the backend /
|
||||
// mode LEDs) that PlayerBarSmall.slint mounts, shared with the Large bar.
|
||||
//
|
||||
// Connect / Settings / ViewMode are still inert visual replicas where no Qt
|
||||
// invokable exists (TODO comments at the call sites); Volume, Queue, Shuffle,
|
||||
// Repeat, Mute, Lyrics and the add flyout are live. Cast opens
|
||||
// Connect is LIVE (the golden ConnectButton opens shell/QconnectFlyout.qml —
|
||||
// NO tooltip, the reference asymmetry of contract §8); Settings / ViewMode
|
||||
// are live too. Volume, Queue, Shuffle, Repeat, Mute, Lyrics and the add
|
||||
// flyout are live. Cast opens
|
||||
// shell/CastPicker.qml (QbzCast — discovery, connect/disconnect, the
|
||||
// per-device quality cap) and lights while a renderer is connected. Track
|
||||
// Info (the (i) button and the song-card title) opens
|
||||
@@ -47,6 +48,15 @@ Rectangle {
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Volume lock, both halves (contract §11.3 — the same `vol-locked`
|
||||
// formula PlayerBar.slint:152-158 carries and PlayerBar.qml repeats):
|
||||
// the ALSA-Direct hw derivation inside npVolumeLocked is LIFTED while a
|
||||
// peer owns playback; npRemoteVolumeLocked is the sink-pushed "peer
|
||||
// disallows remote volume" half.
|
||||
readonly property bool volLocked:
|
||||
(QbzPlayer.npVolumeLocked && !QbzPlayer.npIsRemote)
|
||||
|| QbzPlayer.npRemoteVolumeLocked
|
||||
|
||||
// Responsive side fraction — IDENTICAL to PlayerBarSmall's mechanism:
|
||||
// LEFT and RIGHT columns share this fraction so the CENTER transport
|
||||
// column lands PLAY on the window centre. The breakpoints (39/30/25 at
|
||||
@@ -383,11 +393,38 @@ Rectangle {
|
||||
gapRight: 8
|
||||
}
|
||||
|
||||
// Qobuz Connect.
|
||||
// TODO(qt-bridge): no qconnect state/toggle exposed
|
||||
// (Slint: NowPlayingState.qconnect-connected +
|
||||
// qconnect-toggle()). Rendered 1:1, inert.
|
||||
QbzIconButton { name: "monitor-speaker" }
|
||||
// Qobuz Connect — the same golden ConnectButton as the
|
||||
// full bar (Slint keeps a file-private copy in each bar,
|
||||
// PlayerBarSmall.slint:52-55), minus the tooltip: the
|
||||
// reference binds NO tooltip on the small bar's button
|
||||
// (contract §8). Opens the shared flyout.
|
||||
Rectangle {
|
||||
id: smallQconnectBtn
|
||||
readonly property bool qcActive: QbzQConnect.qconnectConnected
|
||||
readonly property color gold: "#e0b341"
|
||||
width: 32
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
color: qcActive ? Qt.rgba(gold.r, gold.g, gold.b, 0.16)
|
||||
: (smallQcArea.containsMouse ? theme.surfaceHover : "transparent")
|
||||
border.width: qcActive ? 1 : 0
|
||||
border.color: Qt.rgba(gold.r, gold.g, gold.b, 0.45)
|
||||
QbzIcon {
|
||||
name: "monitor-speaker"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: smallQconnectBtn.qcActive ? "amber"
|
||||
: smallQcArea.containsMouse ? "textPrimary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: smallQcArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: smallQcFlyout.openBelowRight(smallQconnectBtn)
|
||||
}
|
||||
}
|
||||
// Cast (Chromecast / DLNA) — opens the picker modal and,
|
||||
// with it, device discovery (PlayerBarSmall.slint:596-611:
|
||||
// picker-open = true + CastActions.open()); lit while a
|
||||
@@ -430,16 +467,17 @@ Rectangle {
|
||||
Item { width: 4; height: 1 }
|
||||
|
||||
// Volume — mute icon + (WIDE) inline horizontal slider.
|
||||
// Both gate on root.volLocked (§11.3), like the full bar.
|
||||
QbzIconButton {
|
||||
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
|
||||
btnEnabled: !QbzPlayer.npVolumeLocked
|
||||
btnEnabled: !root.volLocked
|
||||
active: QbzPlayer.npMuted
|
||||
onClicked: QbzPlayer.toggleMute()
|
||||
}
|
||||
// WIDE: the shared QbzSlider (PlayerBarSmall mounts the
|
||||
// same primitive at 72px on the 0..1000 scale).
|
||||
QbzSlider {
|
||||
enabled: !QbzPlayer.npVolumeLocked
|
||||
enabled: !root.volLocked
|
||||
visible: root.volumeWide
|
||||
width: 72
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -481,6 +519,11 @@ Rectangle {
|
||||
}
|
||||
AudioSettingsMenu { id: smallAudioMenu; doc: root.settingsDoc }
|
||||
|
||||
// Qobuz Connect device flyout — the ONE shared component both bars mount
|
||||
// (contract §8; the Slint `qconnect-menu` PopupWindow). Opened below-
|
||||
// right of the Connect button.
|
||||
QconnectFlyout { id: smallQcFlyout }
|
||||
|
||||
// "Add to…" flyout behind the transport "+" (TransportControls.slint's
|
||||
// add-menu) — same seven entries, order and icons as the full bar.
|
||||
CardMenu {
|
||||
|
||||
@@ -44,11 +44,12 @@
|
||||
// Info — the (i) button and the song-card title open shell/TrackInfoModal.qml
|
||||
// (needs the QbzAlbum.openTrackInfo glue; see that file's header). Cast opens
|
||||
// shell/CastPicker.qml (QbzCast — discovery, connect/disconnect, the
|
||||
// per-device quality cap) and lights while a renderer is connected.
|
||||
// Inert (TODO comments at the call sites): Connect (the QConnect device
|
||||
// flyout) and add-to-playlist. add-to-mixtape is LIVE since the MyQBZ domain
|
||||
// landed (QbzMyQbzAdd.open). The volume LOCK (ALSA hw / remote) is still not
|
||||
// enforced.
|
||||
// per-device quality cap) and lights while a renderer is connected. Qobuz
|
||||
// Connect is LIVE: the golden ConnectButton opens shell/QconnectFlyout.qml
|
||||
// (QbzQConnect — device list, set-active, connect/disconnect) and carries
|
||||
// the "Qobuz Connect: On/Off" hover bubble via the shell's tooltip overlay.
|
||||
// Inert (TODO comments at the call sites): add-to-playlist. add-to-mixtape
|
||||
// is LIVE since the MyQBZ domain landed (QbzMyQbzAdd.open).
|
||||
//
|
||||
// SIZE (project rule): the inline SongCard / TransportControls / FavToggle
|
||||
// components moved out to shell/SongCard.qml, shell/TransportControls.qml and
|
||||
@@ -68,6 +69,25 @@ Rectangle {
|
||||
readonly property bool largeActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
|
||||
readonly property bool isClassic: QbzShell.npbMode === 1
|
||||
|
||||
// The shell's shared hover-tooltip overlay (controls/QbzTooltip.qml),
|
||||
// fed in through NowPlayingBar.qml's Binding. Consumed by the Qobuz
|
||||
// Connect button's "Qobuz Connect: On/Off" bubble — the ONE tooltip the
|
||||
// reference binds on this bar (PlayerBar.slint:405-407; the small bar's
|
||||
// button has none, contract §8).
|
||||
property Item tooltip: null
|
||||
|
||||
// Volume lock, both halves (contract §11.3 = PlayerBar.slint:152-158's
|
||||
// `vol-locked`): npVolumeLocked carries the ALSA-Direct hw derivation
|
||||
// INSIDE it, and that local bit-perfect lock is LIFTED when a peer owns
|
||||
// playback (the `&& !npIsRemote` term) so the user CAN adjust the remote
|
||||
// renderer; npRemoteVolumeLocked is the sink-pushed "peer disallows
|
||||
// remote volume" half. The natural misread (`!npVolumeLocked &&
|
||||
// !npIsRemote && ...`) would disable the slider for EVERY peer, breaking
|
||||
// §7 volume routing to volume-ALLOWING peers.
|
||||
readonly property bool volLocked:
|
||||
(QbzPlayer.npVolumeLocked && !QbzPlayer.npIsRemote)
|
||||
|| QbzPlayer.npRemoteVolumeLocked
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Responsive zones (PlayerBar.slint). Classic fixes col-side at 0.324;
|
||||
@@ -442,14 +462,60 @@ Rectangle {
|
||||
onClicked: QbzCast.openPicker()
|
||||
}
|
||||
|
||||
// Qobuz Connect — inert (device flyout out of scope).
|
||||
// monitor-speaker is the icon both Slint bars use.
|
||||
QbzIconButton {
|
||||
name: "monitor-speaker"
|
||||
// Qobuz Connect — the Slint ConnectButton (PlayerBar.
|
||||
// slint:32-77): monitor-speaker glyph, GOLDEN over a soft
|
||||
// golden tint whenever a session is on — whether qbz is
|
||||
// the renderer or controlling a peer (not just is-remote).
|
||||
// Opens the shared flyout (QconnectFlyout.qml). This is
|
||||
// NOT a QbzIconButton: the reference's active state is a
|
||||
// gold tint + 1px border + gold glyph, which the shared
|
||||
// button's accent-glyph `active` cannot express. The
|
||||
// "amber" tint IS the reference's gold (#e0b341).
|
||||
Rectangle {
|
||||
id: qconnectBtn
|
||||
readonly property bool qcActive: QbzQConnect.qconnectConnected
|
||||
readonly property color gold: "#e0b341"
|
||||
width: 32
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// TODO(qt-bridge): no qconnect state/toggle exposed
|
||||
// (Slint: NowPlayingState.qconnect-connected +
|
||||
// qconnect-toggle()). Rendered 1:1, inert.
|
||||
color: qcActive ? Qt.rgba(gold.r, gold.g, gold.b, 0.16)
|
||||
: (qcArea.containsMouse ? theme.surfaceHover : "transparent")
|
||||
border.width: qcActive ? 1 : 0
|
||||
border.color: Qt.rgba(gold.r, gold.g, gold.b, 0.45)
|
||||
QbzIcon {
|
||||
name: "monitor-speaker"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.centerIn: parent
|
||||
tintName: qconnectBtn.qcActive ? "amber"
|
||||
: qcArea.containsMouse ? "textPrimary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: qcArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.tooltip)
|
||||
root.tooltip.hide("qconnect")
|
||||
qcFlyout.openBelowRight(qconnectBtn)
|
||||
}
|
||||
// The ONE bar tooltip the reference binds (always
|
||||
// shows on hover, even while connected — Slint
|
||||
// PlayerBar.slint:64-74 says why out loud).
|
||||
onContainsMouseChanged: {
|
||||
if (!root.tooltip)
|
||||
return
|
||||
if (containsMouse)
|
||||
root.tooltip.showAbove(qconnectBtn, "qconnect",
|
||||
QbzQConnect.qconnectConnected
|
||||
? QbzSession.tr("Qobuz Connect: On", QbzSession.trRev)
|
||||
: QbzSession.tr("Qobuz Connect: Off", QbzSession.trRev))
|
||||
else
|
||||
root.tooltip.hide("qconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lyrics.
|
||||
@@ -484,16 +550,18 @@ Rectangle {
|
||||
|
||||
// Volume: mute · slider · −/+ steppers (the steppers are
|
||||
// the Tauri volume-step buttons, gated by the appearance
|
||||
// preference like the Slint bar).
|
||||
// preference like the Slint bar). All four controls gate
|
||||
// on root.volLocked (§11.3 — the local ALSA-hw lock lifts
|
||||
// under a peer; a volume-disallowing peer locks).
|
||||
QbzIconButton {
|
||||
name: QbzPlayer.npMuted ? "volume-x" : "volume-2"
|
||||
btnEnabled: !QbzPlayer.npVolumeLocked
|
||||
btnEnabled: !root.volLocked
|
||||
active: QbzPlayer.npMuted
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzPlayer.toggleMute()
|
||||
}
|
||||
QbzSlider {
|
||||
enabled: !QbzPlayer.npVolumeLocked
|
||||
enabled: !root.volLocked
|
||||
width: 81
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
minimum: 0
|
||||
@@ -510,7 +578,7 @@ Rectangle {
|
||||
name: "minus"
|
||||
iconSize: 15
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
btnEnabled: !QbzPlayer.npVolumeLocked
|
||||
btnEnabled: !root.volLocked
|
||||
onClicked: QbzPlayer.setVolume(Math.max(0.0, QbzPlayer.npVolume - 0.05))
|
||||
}
|
||||
QbzIconButton {
|
||||
@@ -518,7 +586,7 @@ Rectangle {
|
||||
name: "plus"
|
||||
iconSize: 15
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
btnEnabled: !QbzPlayer.npVolumeLocked
|
||||
btnEnabled: !root.volLocked
|
||||
onClicked: QbzPlayer.setVolume(Math.min(1.0, QbzPlayer.npVolume + 0.05))
|
||||
}
|
||||
|
||||
@@ -546,6 +614,11 @@ Rectangle {
|
||||
// not re-parse settingsJson on every open.
|
||||
AudioSettingsMenu { id: audioMenu; doc: root.settingsDoc }
|
||||
|
||||
// Qobuz Connect device flyout — the ONE shared component both bars mount
|
||||
// (contract §8; the Slint `qconnect-menu` PopupWindow, PlayerBar.slint:
|
||||
// 412-642). Opened below-right of the Connect button.
|
||||
QconnectFlyout { id: qcFlyout }
|
||||
|
||||
// "Add to…" flyout behind the transport "+" (TransportControls.slint's
|
||||
// add-menu), on the shared CardMenu surface. Same seven entries, same
|
||||
// order, same icons.
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Qobuz Connect — DEV diagnostics modal, the QML port of
|
||||
// crates/qbz-ui/ui/shell/QconnectDevModal.slint. Opened from Settings >
|
||||
// Developer > QOBUZ CONNECT > "Open diagnostics" (QbzQConnect.diagSetOpen).
|
||||
// Shows the live status block (session topology / renderer roles / queue)
|
||||
// and the rolling 150-line event log, both push-driven off QbzQConnect
|
||||
// (diagStatus / diagLogText); "Clear" empties the log (diagClear).
|
||||
//
|
||||
// The four UI strings here are HARDCODED ENGLISH, 1:1 with the reference
|
||||
// (contract §9 D3 — the Slint hardcodes them too; no msgids exist).
|
||||
//
|
||||
// Mechanism: the CastPicker.qml pattern — a modal Popup parented to
|
||||
// Overlay.overlay with its own #000000bf scrim (the default modal dimmer
|
||||
// would darken it twice). QbzQConnect.diagOpen is the single source of
|
||||
// truth, mirrored onto the Popup both ways. Mounted LAST in AppShell.qml
|
||||
// (topmost), mirroring the Slint mount (AppShell.slint:841,869).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
parent: Overlay.overlay
|
||||
x: 0
|
||||
y: 0
|
||||
width: parent ? parent.width : 0
|
||||
height: parent ? parent.height : 0
|
||||
padding: 0
|
||||
z: 3000
|
||||
modal: true
|
||||
dim: false
|
||||
closePolicy: Popup.CloseOnEscape
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// QbzQConnect.diagOpen is the single source of truth (DeveloperSettings
|
||||
// sets it true; our own close hands the news back, same as CastPicker).
|
||||
Connections {
|
||||
target: QbzQConnect
|
||||
function onDiagOpenChanged() {
|
||||
if (QbzQConnect.diagOpen)
|
||||
root.open()
|
||||
else
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
onClosed: {
|
||||
if (QbzQConnect.diagOpen)
|
||||
QbzQConnect.diagSetOpen(false)
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
QbzQConnect.diagSetOpen(false)
|
||||
}
|
||||
|
||||
background: Rectangle { color: "#bf000000" }
|
||||
|
||||
contentItem: Item {
|
||||
|
||||
// Scrim — click closes.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.dismiss()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
width: Math.min(root.width - 80, 600)
|
||||
height: Math.min(root.height - 80, 640)
|
||||
x: Math.round((parent.width - width) / 2)
|
||||
y: Math.round((parent.height - height) / 2)
|
||||
radius: theme.radiusMd
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
clip: true
|
||||
|
||||
// Swallow clicks so they don't reach the scrim.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 14
|
||||
|
||||
// Header.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 28
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.right: closeBtn.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Qobuz Connect — Diagnostics"
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Rectangle {
|
||||
id: closeBtn
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 28
|
||||
height: 28
|
||||
radius: theme.radiusSm
|
||||
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
name: "x"
|
||||
width: 17
|
||||
height: 17
|
||||
anchors.centerIn: parent
|
||||
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: closeArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live status block (read-only; selectable/copyable like the
|
||||
// Slint TextEdit).
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 140
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
|
||||
Flickable {
|
||||
id: statusFlick
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
contentWidth: width
|
||||
contentHeight: statusText.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
TextEdit {
|
||||
id: statusText
|
||||
width: statusFlick.width
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
font.pixelSize: 12
|
||||
color: theme.textPrimary
|
||||
text: QbzQConnect.diagStatus === ""
|
||||
? "Not connected."
|
||||
: QbzQConnect.diagStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event-log header + clear.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 26
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Event log (newest first)"
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: clearLabel.implicitWidth + 22
|
||||
height: 26
|
||||
radius: theme.radiusSm
|
||||
color: clearArea.containsMouse ? theme.surfaceCard : "transparent"
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
Text {
|
||||
id: clearLabel
|
||||
anchors.centerIn: parent
|
||||
text: "Clear"
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
}
|
||||
MouseArea {
|
||||
id: clearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzQConnect.diagClear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rolling event log (newest first) — read-only so the lines
|
||||
// are selectable + copyable, and it scrolls. Transparent
|
||||
// body, exactly like the Slint's unstyled Rectangle.
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: parent.height - 28 - 140 - 26 - 3 * 14
|
||||
color: "transparent"
|
||||
clip: true
|
||||
|
||||
Flickable {
|
||||
id: logFlick
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: logText.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
TextEdit {
|
||||
id: logText
|
||||
width: logFlick.width
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
font.pixelSize: 11
|
||||
color: theme.textSecondary
|
||||
text: QbzQConnect.diagLogText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user