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:
10 files changed
+2530
-18
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user