feat(qt): wire local-network casting end to end

The two cast buttons in the now-playing bars rendered and did nothing: no
service, no bridge, no picker. This ports crates/qbz/src/cast_service.rs
onto the shared qbz-cast crate (discovery, both device families and the
media server are the crate's, not reimplemented) and hangs a QbzCast
singleton off it.

Discovery is armed only by openPicker() and stopped by closePicker(), so a
user who never opens the picker sends no mDNS/SSDP traffic. Connecting
stops the local backend through its public seam and re-casts the current
track at the streaming preference clamped by the per-renderer cap.

Playback is routed at four seams in playback_qt (new track, toggle, seek,
volume) and the local poll yields while casting — without that the two
polls fight over the bar every second. Logout and exit tear the connection
down, or the renderer keeps playing after the app is gone.

Deliberate gaps, each named in the module header: no offline-cache tier,
no QConnect suspend/restore, no Plex casting (the Slint carries the same
TODO), and lyrics do not follow a remote position yet.

cc
This commit is contained in:
vicrodh committed 2026-07-29 00:56:07 -06:00
1 parent c56fab3c4e
commit 87b18fa7f7
10 files changed
+2530 -18

No files matched your search

+557
View File
@@ -0,0 +1,557 @@
// Cast (Chromecast / DLNA) device picker — 1:1 QML port of
// crates/qbz-ui/ui/shell/CastPicker.slint.
//
// HOW IT OPENS (taken from the .slint, not chosen): a MODAL — a full-window
// #000000bf scrim with a centered card. Slint mounts it last in AppShell so it
// sits on top (ADR-009: modal z >= 3000) and gates it on CastState.picker-open.
// Here it is a Popup parented to `Overlay.overlay`, the same mechanism
// shell/TrackInfoModal.qml uses, so the bars can own it without AppShell.qml
// having to mount anything — the NowPlayingBar Loader guarantees exactly ONE
// bar, hence exactly one instance, is alive.
//
// Geometry, verbatim from the .slint:
// card width = min(window - 80, 400)
// card height = 420
// card padding 18, block spacing 14, rows 52, tabs 34, banner 64
//
// STATE + ACTIONS: QbzCast (src/cast_bridge.rs), the Qt surface of the Slint
// CastState / CastActions globals. `QbzCast.devicesJson` is the ONE document
// carrying both device lists (see the bridge header for why it is not a
// QVariantList); every read is guarded, so a malformed/absent document
// degrades to the empty state instead of throwing.
//
// DISCOVERY IS PICKER-OWNED: opening calls QbzCast.openPicker() (which arms
// mDNS + SSDP) and closing calls QbzCast.closePicker() (which stops them).
// Nothing scans while this modal is shut.
//
// No pills (ADR-008): the tabs are rectangular and the counts are plain
// trailing text, exactly like the .slint.
//
// ONE DELIBERATE DEVIATION from the .slint: the connected banner and the
// Disconnect button use the theme's semantic success/danger tokens instead of
// the .slint's hardcoded #1f3d2e / #3fae6a / #b23b3b. Slint recolours against
// one palette; this port serves 36 themes including light ones, where a
// hardcoded dark green card reads as a rendering bug (the same class of
// problem QbzIconButton's tint note documents).
//
// LAYOUT NOTE: the .slint uses a VerticalLayout whose device list carries
// `vertical-stretch: 1`. QML has no stretch on a Column, so the top block is a
// Column, the error line is pinned to the card's bottom, and the list simply
// anchors between them — same result, no height arithmetic to drift.
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
// Our own scrim (the .slint's #000000bf) — the default modal dimmer would
// darken it twice.
dim: false
closePolicy: Popup.CloseOnEscape
QbzTheme { id: theme }
// --- Open/close: QbzCast.pickerOpen is the single source of truth ------
// The bar button calls QbzCast.openPicker(), which arms discovery AND
// raises the flag; this mirrors the flag onto the Popup. Escape closes the
// Popup directly, so `onClosed` hands the news back to Rust — otherwise
// discovery would keep scanning behind a shut modal.
Connections {
target: QbzCast
function onPickerOpenChanged() {
if (QbzCast.pickerOpen)
root.open()
else
root.close()
}
}
onClosed: {
if (QbzCast.pickerOpen)
QbzCast.closePicker()
}
function dismiss() {
QbzCast.closePicker()
}
// --- Data --------------------------------------------------------------
readonly property var devices: {
try {
var d = JSON.parse(QbzCast.devicesJson || "[]")
return Array.isArray(d) ? d : []
} catch (e) {
return []
}
}
// Only the selected tab's renderers are listed (the .slint collapses the
// others row by row).
readonly property var tabDevices: devices.filter(function (d) {
return d && d.protocol === QbzCast.selectedTab
})
readonly property var capOptions: {
try {
var o = JSON.parse(QbzCast.deviceCapOptions || "[]")
return Array.isArray(o) ? o : []
} catch (e) {
return []
}
}
readonly property bool tabEmpty:
(QbzCast.selectedTab === "chromecast" && QbzCast.chromecastCount === 0)
|| (QbzCast.selectedTab === "dlna" && QbzCast.dlnaCount === 0)
// Quality-line suffix (#638). Over-cap wins: a locally-existing copy
// (cache / offline store) served above the requested tier — named plainly,
// never as an error (caps govern requests only; the bytes go out as-is).
// Otherwise, when the per-renderer cap shaped the request (cause 3), say
// so — the user's own choice, never an implied device limitation.
readonly property string qualitySuffix: {
if (QbzCast.qualityOverCap) {
if (QbzCast.qualityOrigin === "offline")
return " · " + QbzSession.tr("offline copy — cap not applied", QbzSession.trRev)
if (QbzCast.qualityOrigin === "cache")
return " · " + QbzSession.tr("cached copy — cap not applied", QbzSession.trRev)
return ""
}
if (QbzCast.qualityLimitCause === 3)
return " · " + QbzSession.tr("capped for this device", QbzSession.trRev)
return ""
}
background: Rectangle { color: "#bf000000" }
contentItem: Item {
// Scrim — click closes + stops discovery.
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
Rectangle {
id: card
width: Math.min(root.width - 80, 400)
height: 420
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 }
// ---- Top block: header · banner · quality · cap · tabs -------
Column {
id: topBlock
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 18
spacing: 14
// Header.
Item {
width: parent.width
height: 28
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 8
QbzIcon {
name: "cast"
width: 18
height: 18
tintName: "textPrimary"
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: QbzSession.tr("Cast", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
anchors.verticalCenter: parent.verticalCenter
}
}
Rectangle {
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()
}
}
}
// Connected banner (replaces the tabs + list).
Rectangle {
visible: QbzCast.connected
width: parent.width
height: visible ? 64 : 0
radius: theme.radiusSm
color: theme.successBg
border.width: 1
border.color: theme.successBorder
Rectangle {
id: liveDot
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: 10
height: 10
radius: 5
color: theme.success
}
Column {
anchors.left: liveDot.right
anchors.leftMargin: 10
anchors.right: disconnectBtn.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: QbzSession.tr("Connected to", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
Text {
width: parent.width
text: QbzCast.deviceName
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
}
Rectangle {
id: disconnectBtn
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: disconnectLabel.implicitWidth + 24
height: 32
radius: theme.radiusSm
color: disconnectArea.containsMouse ? theme.dangerHover : theme.danger
Text {
id: disconnectLabel
anchors.centerIn: parent
text: QbzSession.tr("Disconnect", QbzSession.trRev)
color: "#ffffff"
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
MouseArea {
id: disconnectArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzCast.disconnectDevice()
}
}
}
// Delivered quality under the banner, when known — MEASURED
// from the served bytes (#638 fix 1), plus the disclosure
// suffix (over-cap local copy, or the cap at work).
Text {
visible: QbzCast.connected && QbzCast.qualityDetail !== ""
width: parent.width
height: visible ? implicitHeight : 0
text: QbzSession.tr("Streaming {} · {}", QbzSession.trRev)
.replace("{}", QbzCast.protocol === "dlna" ? "DLNA" : "Chromecast")
.replace("{}", QbzCast.qualityDetail) + root.qualitySuffix
color: theme.textMuted
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
// Per-renderer quality cap (#638 fix 4) — a manual per-device
// choice of the tier QBZ REQUESTS when casting here, persisted
// per renderer id (hidden when the device has no stable
// identity). Label-left / control-trailing, per the
// ui-control-alignment standard.
Item {
visible: QbzCast.connected && QbzCast.deviceCapAvailable
width: parent.width
height: visible ? 34 : 0
Text {
anchors.left: parent.left
anchors.right: capSelect.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Quality for this device", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
QbzSelect {
id: capSelect
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
menuWidth: 200
popupWidth: 260
options: root.capOptions
currentIndex: QbzCast.deviceCapIndex
onSelected: function (index) {
QbzCast.setDeviceQualityCap(QbzCast.deviceCapKey, index)
}
}
}
// Tabs (only while nothing is connected).
Item {
visible: !QbzCast.connected
width: parent.width
height: visible ? 34 : 0
Row {
anchors.fill: parent
spacing: 8
CastTab {
width: (parent.width - 8) / 2
label: "Chromecast"
count: QbzCast.chromecastCount
selected: QbzCast.selectedTab === "chromecast"
onClicked: QbzCast.setTab("chromecast")
}
CastTab {
width: (parent.width - 8) / 2
label: "DLNA"
count: QbzCast.dlnaCount
selected: QbzCast.selectedTab === "dlna"
onClicked: QbzCast.setTab("dlna")
}
}
}
}
// ---- Error line (pinned to the card's bottom) ----------------
// The raw last error (connect refused, unsupported source, a
// renderer that stopped answering) — the same raw string the
// .slint renders.
Text {
id: errorLine
visible: QbzCast.error !== ""
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 18
text: QbzCast.error
color: theme.danger
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
// ---- Device list (the .slint's vertical-stretch: 1 block) ----
Rectangle {
id: listBox
visible: !QbzCast.connected
anchors.top: topBlock.bottom
anchors.topMargin: 14
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: errorLine.visible ? errorLine.top : parent.bottom
anchors.bottomMargin: errorLine.visible ? 14 : 18
anchors.leftMargin: 18
anchors.rightMargin: 18
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
Flickable {
id: listFlick
anchors.fill: parent
anchors.margins: 6
contentWidth: width
contentHeight: rows.height
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: rows
width: listFlick.width
spacing: 2
Repeater {
model: root.tabDevices
// One discovered-device row (the .slint's
// CastDeviceRow). A DLNA renderer without
// AVTransport is listed but NOT clickable — it
// cannot play, and pretending otherwise would be
// a control that does nothing.
delegate: Rectangle {
id: deviceRow
required property var modelData
readonly property bool canPlay: modelData.canPlay !== false
width: rows.width
height: 52
radius: theme.radiusSm
color: (rowArea.containsMouse && canPlay)
? theme.surfaceHover : "transparent"
QbzIcon {
id: rowIcon
name: "cast"
width: 18
height: 18
tintName: "secondary"
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.left: rowIcon.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: deviceRow.modelData.name || ""
color: deviceRow.canPlay ? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
Text {
width: parent.width
text: deviceRow.canPlay
? (deviceRow.modelData.ip || "")
: QbzSession.tr("{} (not playable)", QbzSession.trRev)
.replace("{}", deviceRow.modelData.ip || "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
enabled: deviceRow.canPlay
cursorShape: deviceRow.canPlay
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzCast.connectDevice(
deviceRow.modelData.id || "",
deviceRow.modelData.protocol || "")
}
}
}
}
}
QbzScrollBar {
target: listFlick
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
// Empty / scanning overlay (the selected tab has no devices).
// No MouseArea: the list underneath is empty anyway, and
// swallowing clicks here would be invisible dead weight.
Column {
anchors.centerIn: parent
width: parent.width - 24
spacing: 4
visible: root.tabEmpty
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: QbzCast.scanning
? QbzSession.tr("Searching…", QbzSession.trRev)
: QbzSession.tr("No devices found", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: QbzSession.tr("Looking for Chromecast & DLNA on your network", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
}
}
}
}
// A rectangular tab button with a trailing count (no pills, ADR-008).
component CastTab: Rectangle {
id: tabRoot
property string label: ""
property int count: 0
property bool selected: false
signal clicked()
height: 34
radius: theme.radiusSm
color: selected ? theme.accent
: (tabArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated)
Row {
anchors.centerIn: parent
spacing: 6
Text {
text: tabRoot.label
color: tabRoot.selected ? theme.accentText : theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: tabRoot.selected ? theme.weightSemibold : Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
Text {
visible: tabRoot.count > 0
text: tabRoot.count
color: tabRoot.selected ? theme.accentText : theme.textMuted
opacity: tabRoot.selected ? 0.8 : 1.0
font.pixelSize: theme.fontLegal
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: tabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: tabRoot.clicked()
}
}
}
+25 -9
View File
@@ -21,11 +21,14 @@
// 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 / Cast / 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. Track Info (the
// (i) button and the song-card title) opens shell/TrackInfoModal.qml — it
// needs the QbzAlbum.openTrackInfo glue, see that file's header.
// 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
// 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
// shell/TrackInfoModal.qml — it needs the QbzAlbum.openTrackInfo glue, see
// that file's header.
import QtQuick
import com.blitzfc.qbz
@@ -88,6 +91,14 @@ Rectangle {
TrackInfoModal { id: trackInfo }
// --- Cast picker (shell/CastPicker.slint) ----------------------------
// Same mount reasoning as TrackInfoModal above: a Popup parented to
// Overlay.overlay, so AppShell.qml needs no mount, and the NowPlayingBar
// Loader guarantees exactly ONE bar (hence one instance) is alive.
// Visibility follows QbzCast.pickerOpen; the right-cluster cast button
// raises it through QbzCast.openPicker(), which also arms discovery.
CastPicker { }
// Square compact icon button (BarControls.IconButton): disabled = dim
// 0.3 + non-clickable; active = accent tint.
@@ -355,10 +366,15 @@ Rectangle {
// (Slint: NowPlayingState.qconnect-connected +
// qconnect-toggle()). Rendered 1:1, inert.
QbzIconButton { name: "monitor-speaker" }
// Cast (Chromecast / DLNA).
// TODO(qt-bridge): no cast picker in the Qt port
// (Slint: CastState.picker-open + CastActions.open()).
QbzIconButton { name: "cast" }
// Cast (Chromecast / DLNA) — opens the picker modal and,
// with it, device discovery (PlayerBarSmall.slint:596-611:
// picker-open = true + CastActions.open()); lit while a
// renderer is connected.
QbzIconButton {
name: "cast"
active: QbzPlayer.npCastActive
onClicked: QbzCast.openPicker()
}
// Audio settings — Tauri's normalization toggle (2.0
// groups normalization + gapless behind this button).
// TODO(glue): `normalization` is settable through
+20 -8
View File
@@ -42,10 +42,12 @@
// steppers, lyrics, queue, the Now-Playing-view flyout (npbSetMode),
// normalization, open album/artist via the SongCard meta links, and Track
// Info — the (i) button and the song-card title open shell/TrackInfoModal.qml
// (needs the QbzAlbum.openTrackInfo glue; see that file's header).
// Inert (TODO comments at the call sites): Cast, Connect (device flyouts),
// add-to-playlist, add-to-mixtape. The volume LOCK (ALSA hw / remote) is
// still not enforced.
// (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), add-to-playlist, add-to-mixtape. The volume LOCK (ALSA hw /
// remote) is still not enforced.
//
// SIZE (project rule): the inline SongCard / TransportControls / FavToggle
// components moved out to shell/SongCard.qml, shell/TransportControls.qml and
@@ -146,6 +148,15 @@ Rectangle {
TrackInfoModal { id: trackInfo }
// --- Cast picker (shell/CastPicker.slint) ------------------------------
// Mounted here for the same reason TrackInfoModal is: it is a Popup
// parented to Overlay.overlay, so AppShell.qml needs no mount, and the
// NowPlayingBar Loader guarantees exactly ONE bar (hence one instance) is
// alive. Visibility follows QbzCast.pickerOpen — the cast button in the
// right cluster raises it through QbzCast.openPicker(), which is also
// what arms device discovery.
CastPicker { }
// --- Shared bits -------------------------------------------------------
// SongCard / TransportControls / FavToggle were inline components here
// until phase 25; they now live in their own files (shell/SongCard.qml,
@@ -326,13 +337,14 @@ Rectangle {
Item { visible: root.largeActive; width: 12; height: 1 }
// Cast (Chromecast / DLNA) — Tauri's first right-cluster
// button.
// button. Opens the picker modal and, with it, discovery
// (PlayerBar.slint:646-664: picker-open = true +
// CastActions.open()); lit while a renderer is connected.
QbzIconButton {
name: "cast"
active: QbzPlayer.npCastActive
anchors.verticalCenter: parent.verticalCenter
// TODO(qt-bridge): no cast picker in the Qt port
// (Slint: CastState.picker-open + CastActions.open()).
// Rendered 1:1, inert.
onClicked: QbzCast.openPicker()
}
// Qobuz Connect — inert (device flyout out of scope).