The disc was btnEnabled: false with a comment saying neither engine had a seam on a Qt bridge. Both do now. ArtistPageView.slint:426-461 opens a two-row dropdown — QBZ Radio (the local qbz-radio pool builder) and Qobuz Radio (/radio/artist) — and that is what this restores. The smart arm already shipped behind Discover's spotlight RADIO card and simply was not wired here; the Qobuz arm is new (get_radio_artist + the same enrich_radio_tracks the album radio uses, because its track objects are just as bare). Both labels already exist in all eight catalogues, so no new msgid. AND THE WAIT IS VISIBLE NOW. A radio is a fetch, an enrich and a queue write; until the first track resolves nothing on screen moves, so the click read as dead. QbzCircleAction gains the `loading` arm the reference already has (CircleAction.slint:18/:77) and QbzHome.radioPending carries the KEY of the radio being built — "album:<id>" or "artist:<id>" — so the Discover rail spins only the station that was clicked instead of all six. The spinner rides QbzShell.pulseMs, NOT a RotationAnimation. Qt Quick has no partial redraws, so a continuous animator presents the whole window at display rate (~1.2% GPU per present on the owner's hybrid laptop); a 60 Hz spin for the seconds a radio takes is exactly the bill the pulse law refuses. 12 degrees per ~30 Hz tick, and the Connections is disabled unless the button is spinning and on screen, so at rest it writes nothing. theme/QbzSpinner.qml still uses a RotationAnimation and is deliberately not reused. On the cards, `loading` also pins the hover disc up: it only exists on hover, so it vanished the moment the pointer left after a click, which is what made the click look swallowed. A RadioGuard disarms the key in Drop, so a bad id, a network error and an empty result all clear it. A disc spinning forever is worse than no indicator. cc
204 lines
8.4 KiB
QML
204 lines
8.4 KiB
QML
// Radio / Top-Tracks card — QML port of discover/RadioCard.slint.
|
|
//
|
|
// A 200x246 tile matching AlbumCard / PlaylistCard: a 200px "display" panel
|
|
// with the seed artwork centered (126px square, nudged 8px up) and a wordmark
|
|
// ("RADIO" / "TOP TRACKS") over it, then a title + subtitle below. Hover dims
|
|
// the panel (#000000 @ 0.45, 150ms) and reveals a 44px white play disc.
|
|
//
|
|
// Numbers come from RadioCard.slint, not from taste:
|
|
// panel 200x200 radius Radius.sm · display 126x126 radius 4, centered,
|
|
// y offset -8 · wordmark 15px bold letter-spacing 2 at bottom+16 ·
|
|
// play disc 44px, glyph 18px black · meta: body/medium then legal/muted,
|
|
// both elided at 200px.
|
|
//
|
|
// WHY THIS IS A CARD AND NOT A PARAMETER ON AlbumCard (track rule 5): the
|
|
// two share the 200x246 footprint and nothing else. The album card is
|
|
// artwork-first with a hover action ROW (play / favourite / pin / more), a
|
|
// quality badge, an award ribbon and a context menu keyed on an album id;
|
|
// this one has no id, no menu, no badges, and its artwork is a small inset
|
|
// "display" on a panel rather than the tile itself. Bending AlbumCard into
|
|
// it would mean disabling five features and re-laying out the art.
|
|
//
|
|
// DELTA vs the .slint, inherited from it: the Tauri original derives the
|
|
// panel colour from the artwork's dominant palette. The Slint port stands a
|
|
// neutral elevated panel in its place and says so; this follows the Slint.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../theme"
|
|
|
|
Item {
|
|
id: root
|
|
|
|
/// The line under the panel (the seed's name).
|
|
property string seedTitle: ""
|
|
/// The muted second line ("" hides it, like the .slint `if`).
|
|
property string seedSubtitle: ""
|
|
/// The wordmark over the display.
|
|
property string label: QbzSession.tr("RADIO", QbzSession.trRev)
|
|
/// file:// path to the seed artwork (already resolved by Rust).
|
|
property string artSource: ""
|
|
|
|
/// Whole-card activation (the .slint `clicked`).
|
|
signal activated()
|
|
/// The hover play disc (the .slint `play`) — the same action in both
|
|
/// call sites today, kept separate so a future caller can split them.
|
|
signal playRequested()
|
|
|
|
/// True while THIS card's radio is being built (QbzHome.radioPending).
|
|
/// The disc stops hiding on pointer-out and its glyph becomes a spinner:
|
|
/// a station takes a fetch + an enrich + a queue write to start, and the
|
|
/// tile's own hover affordance vanishing the moment you move the mouse is
|
|
/// what made the click read as lost.
|
|
property bool loading: false
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
width: 200
|
|
height: 246
|
|
|
|
Column {
|
|
anchors.fill: parent
|
|
spacing: 8
|
|
|
|
// --- display panel ------------------------------------------------
|
|
Rectangle {
|
|
id: panel
|
|
width: 200
|
|
height: 200
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
// No clip: with the wordmark bounded below, nothing in this tile
|
|
// can cross its edge.
|
|
|
|
// Centered artwork "display", nudged up 8px to leave room for
|
|
// the wordmark.
|
|
Rectangle {
|
|
width: 126
|
|
height: 126
|
|
x: Math.round((parent.width - width) / 2)
|
|
y: Math.round((parent.height - height) / 2) - 8
|
|
radius: 4
|
|
color: theme.surfaceCard
|
|
// No clip: RoundedImage confines itself on both arms; a clip is an
|
|
// unconditional batch root, one per visible card.
|
|
RoundedImage {
|
|
anchors.fill: parent
|
|
source: root.artSource
|
|
radius: 4
|
|
}
|
|
}
|
|
|
|
// Wordmark over the display, bottom.
|
|
Text {
|
|
text: root.label
|
|
color: "#e6ffffff"
|
|
font.pixelSize: 15
|
|
font.weight: theme.weightBold
|
|
font.letterSpacing: 2
|
|
// Bounded + centred-in-box instead of centred-by-width: this
|
|
// wordmark is the ONLY thing that could leave the tile (the PT
|
|
// string "PRINCIPAIS FAIXAS" at 15px bold with 2px letter
|
|
// spacing reaches the 200px edge), and bounding it is what
|
|
// lets both of this card's clips go. Worst case is now a right
|
|
// ellipsis instead of today's chop at both edges.
|
|
width: parent.width - 24
|
|
x: 12
|
|
horizontalAlignment: Text.AlignHCenter
|
|
elide: Text.ElideRight
|
|
y: parent.height - height - 16
|
|
}
|
|
|
|
// Hover scrim + play disc.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: "#000000"
|
|
opacity: (root.loading || panelArea.containsMouse || playArea.containsMouse)
|
|
? 0.45 : 0.0
|
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
|
}
|
|
Rectangle {
|
|
id: spinDisc
|
|
width: 44
|
|
height: 44
|
|
radius: 22
|
|
color: "#ffffff"
|
|
anchors.centerIn: parent
|
|
// `root.loading` pins it up: the hover-only rule is right for
|
|
// an idle tile and wrong for one that is working, because the
|
|
// pointer leaves the moment the click lands.
|
|
opacity: (root.loading || panelArea.containsMouse || playArea.containsMouse)
|
|
? 1.0 : 0.0
|
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
|
// Spinner on the SHARED SHELL PULSE — 12 degrees per ~30 Hz
|
|
// tick, one turn a second. Never a RotationAnimation: a
|
|
// continuous animator presents the whole window at display
|
|
// rate (THE PULSE LAW, qt-frontend/2026-08-11-scenegraph-
|
|
// batches §9), and a Discover rail can hold several of these.
|
|
// The Connections is disabled unless this card is the one
|
|
// loading, so an idle rail writes nothing.
|
|
property real spinPhase: 0
|
|
Connections {
|
|
target: QbzShell
|
|
enabled: root.loading && root.visible
|
|
function onPulseMsChanged() {
|
|
spinDisc.spinPhase = (spinDisc.spinPhase + 12) % 360
|
|
}
|
|
}
|
|
QbzIcon {
|
|
name: root.loading ? "loader-circle" : "play-fill"
|
|
width: 18
|
|
height: 18
|
|
anchors.centerIn: parent
|
|
rotation: root.loading ? spinDisc.spinPhase : 0
|
|
tintName: "black"
|
|
}
|
|
MouseArea {
|
|
id: playArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
// The disc is invisible until the panel is hovered, so it
|
|
// must not swallow the first press before it appears — and
|
|
// while the station is building there is nothing a second
|
|
// press could do but start it again.
|
|
enabled: parent.opacity > 0 && !root.loading
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.playRequested()
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: panelArea
|
|
anchors.fill: parent
|
|
// BELOW the play disc in declaration order would eat its
|
|
// clicks; the disc is declared first, so this one is last and
|
|
// therefore ON TOP. z pushes it back under.
|
|
z: -1
|
|
hoverEnabled: true
|
|
enabled: !root.loading
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.activated()
|
|
}
|
|
}
|
|
|
|
// --- meta ------------------------------------------------------
|
|
Text {
|
|
width: 200
|
|
text: root.seedTitle
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
visible: root.seedSubtitle !== ""
|
|
width: 200
|
|
text: root.seedSubtitle
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|