Buttons that rest on surfaceElevated used surfaceHover for their hover fill: a 6% white wash meant for rows that rest transparent. Over an elevated button it lands DARKER than the rest fill — on a modal's dark ground the Cancel/Save/footer buttons went near-black under the pointer. QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived so every palette has it), its ambient half-alpha twin, and the elevatedFill / elevatedHoverFill pair an ambient-aware control paints. Every site with the old pairing (65 across modals, toolbars, selects, circle actions, chips and cards) now hovers to the lift. cc
586 lines
25 KiB
QML
586 lines
25 KiB
QML
// 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
|
|
// No `height:` binding: a Text without one takes its
|
|
// implicit height and a Column skips invisible items;
|
|
// the explicit binding raised a "binding loop for
|
|
// property height" warning on every connect.
|
|
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.surfaceElevatedHover : theme.surfaceElevated)
|
|
|
|
Row {
|
|
anchors.centerIn: parent
|
|
spacing: 6
|
|
Text {
|
|
text: tabRoot.label
|
|
// Selected -> this text sits ON `theme.accent`, so it takes
|
|
// the on-accent selector like every other accent-filled
|
|
// control (theme/QbzTheme.qml, "ON AN ACCENT FILL"). This was
|
|
// the LAST accent fill in the tree still reading raw
|
|
// accent-text, i.e. the one place the app contradicted its
|
|
// own rule. shell/CastPicker.slint:86 is a hardcoded #ffffff
|
|
// — 1.70:1 on high-contrast — so this diverges from upstream
|
|
// by measurement, same as the rest.
|
|
color: tabRoot.selected ? theme.accentGlyphColor : 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
|
|
// Same selector as the label beside it — the two halves of a
|
|
// tab cannot disagree. This was the worst on-accent number
|
|
// left anywhere: raw accent-text at 80% composites to
|
|
// #715c7a on rose-pine-dawn's #d7827e, i.e. 2.10:1; through
|
|
// the selector it composites to #2b1a19 and 5.84:1.
|
|
color: tabRoot.selected ? theme.accentGlyphColor : theme.textMuted
|
|
// The 80% is NOT ours to drop: CastPicker.slint:93 spells the
|
|
// count `#ffffffcc`, and that de-emphasis against the
|
|
// full-strength label is the tab's whole visual hierarchy.
|
|
// It does cost contrast, and the residue is stated rather
|
|
// than hidden: on breeze-light the selector returns #ffffff
|
|
// (its own accent-text), which at 80% over accent #1d99f3
|
|
// composites to #d2ebfd = 2.47:1, under the 3:1 floor. That
|
|
// is the ALPHA's doing, not the colour's — the same label at
|
|
// full strength is 3.04:1 and clears. Fixing it means either
|
|
// dropping upstream's alpha or giving de-emphasised
|
|
// on-accent text its own selector; both are owner calls, and
|
|
// neither is this round's approved deviation.
|
|
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()
|
|
}
|
|
}
|
|
}
|