feat(qt): For You gains Qobuz Mixes pages, Artist Spotlight and Radios

Ports the three sections the tab was missing, against the Slint
implementation rather than invented: the section rails, the cards, and the
mix landing page with its navigation seam so back/forward behave.

The mixes reuse the shared crates for their requests -- the Slint app
already makes them, so nothing new talks to Qobuz here.

Also carries the qt-run.sh portability work needed for the Mac mini:
`free`, `nproc`, `pgrep` and `timeout` all differ or are simply absent on
BSD/macOS. Two of those bit during the first Mac build. `pgrep` exits 1
when it matches nothing -- the normal case -- and under `pipefail` that
killed the script before a single line of output, which reads exactly like
a build that did nothing. And `timeout` does not exist on macOS at all, so
the smoke produced a one-line log over which the grep cheerfully reported
"0 QML complaints": a test that never ran, rendering as a pass. The smoke
now fails outright on a log that short.

cc
This commit is contained in:
vicrodh committed 2026-07-29 16:26:11 -06:00
1 parent a3b89107fc
commit 0c159c141c
24 files changed
+2656 -202

No files matched your search

+6
View File
@@ -50,8 +50,10 @@ fn main() {
"qml/cards/AlbumCard.qml",
"qml/cards/ArtistCard.qml",
"qml/cards/LabelCard.qml",
"qml/cards/MixArtwork.qml",
"qml/cards/PlaylistCard.qml",
"qml/cards/PlaylistCollage.qml",
"qml/cards/RadioCard.qml",
"qml/cards/SlimCard.qml",
"qml/cards/TrackCard.qml",
"qml/controls/CardMenu.qml",
@@ -70,12 +72,15 @@ fn main() {
"qml/controls/QbzSlider.qml",
"qml/controls/QbzTabBar.qml",
"qml/controls/QbzToggle.qml",
"qml/controls/QbzTooltip.qml",
"qml/controls/QualityBadge.qml",
"qml/controls/QualityMini.qml",
"qml/controls/SettingRow.qml",
"qml/controls/SettingsButton.qml",
"qml/controls/SettingsDivider.qml",
"qml/controls/SettingsSpacer.qml",
"qml/rows/TrackCols.qml",
"qml/rows/TrackListHeader.qml",
"qml/rows/TrackRow.qml",
"qml/settings/AppearanceSettings.qml",
"qml/settings/IntegrationsSettings.qml",
@@ -116,6 +121,7 @@ fn main() {
"qml/views/DiscoverBrowseView.qml",
"qml/views/LabelReleasesView.qml",
"qml/views/LabelView.qml",
"qml/views/MixView.qml",
"qml/views/PlayHistoryView.qml",
"qml/views/PlaylistBrowseView.qml",
"qml/views/PlaylistListRow.qml",
+39 -1
View File
@@ -41,6 +41,14 @@ Rectangle {
property string ribbonKind: ""
// Artwork image source (file://… or "") — the host's cache lookup.
property string artSource: ""
// REMOTE cover url, for the pin payload only (the AlbumCard.slint pin
// TouchArea passes `album.artwork-url`). The pinned store keeps a
// denormalized display snapshot taken at pin time, so a host that
// leaves this empty pins a row the Pinned rail can only draw as a
// placeholder — `artSource` is NOT a substitute, it is a local
// file:// path that means nothing on another machine or after a
// cache wipe.
property string artworkUrl: ""
property bool isFavorite: false
property bool isPinned: false
// Source badge (Library show-local): "local" | "plex" | "" (hidden).
@@ -89,7 +97,37 @@ Rectangle {
}
function togglePin() {
root.isPinned = !root.isPinned
QbzLibrary.togglePin("album", root.albumId, root.title, root.artist, "")
QbzLibrary.togglePin("album", root.albumId, root.title, root.artist,
root.artworkUrl)
}
// Pin fan-out. The pinned store has no change-notify, so `pinChanged`
// (key `{kind}:{id}`, emitted by main.rs::toggle_pin after the write) is
// the ONLY signal that this album's state moved — and it moves from
// anywhere: another rail, another tab, the album page header. This is the
// port's equivalent of the Slint `set_album_row_pinned` walk over every
// live model, and the reason no surface has to republish its document to
// keep a glyph honest. Assigning breaks the host's `isPinned` binding on
// purpose (the optimistic flip above already does).
Connections {
target: QbzLibrary
function onPinChanged(key, value) {
if (root.albumId !== "" && key === "album:" + root.albumId)
root.isPinned = value
}
// Favourite fan-out — the SAME shape, one signal later. `isFavorite`
// was the odd one out: the optimistic flip above breaks the host's
// binding (by design), and nothing settled it afterwards, so a write
// that FAILED stayed visibly wrong until the user navigated away, and
// a heart flipped on another surface (the album page header, a search
// result, the queue) never reached this card. `libraryFavoriteChanged`
// carries the value the write actually produced — the flipped one on
// success, the UNCHANGED one on failure — so this is both the
// cross-surface walk and the rollback.
function onLibraryFavoriteChanged(key, value) {
if (root.albumId !== "" && key === "album:" + root.albumId)
root.isFavorite = value
}
}
// AlbumCard.slint's album-menu, in its order. localMode drops the
+74 -25
View File
@@ -11,21 +11,29 @@
// overlay row at y=113 (follow? / play / more), pin badge top-right of
// the frame, then the meta block (centered name, optional subtitle).
//
// item contract: { id, title, subtitle, following } plus the scalar
// artSource / artworkUrl / isPinned props (the AlbumCard pattern).
// item contract: { id, title, subtitle, following | isFavorite } plus the
// scalar artSource / artworkUrl / isPinned props (the AlbumCard pattern).
// The follow flag arrives under TWO names because the producers spell it
// differently and both mean the same row state: `following` (search_qt
// ArtistRow, derived from fav_cache) and `isFavorite` (home_qt HomeCard /
// library_qt FeedItem — for an artist, "in the library" IS "followed", they
// are the same `favorite/create?artist_ids=` write).
//
// Arms:
// - followMode: "none" (default) | "toggle" — asks for the overlay follow
// button + the menu Follow row. There is NO artist-follow invokable on
// the Qt bridge, so `hasFollowSeam` below force-collapses the arm: the
// button and the row cannot render inert even if a host sets "toggle".
// - followMode: "toggle" (default, = ArtistGridCard.slint's own default) |
// "none" — the overlay follow button + the menu Follow row. The Slint
// reference passes "none" from exactly two places: the label page's
// artist carousel (LabelPageView.slint:524) and the Library artist grids
// (FavoritesView.slint:1831/1865). Everything else shows it.
// - followKind: "artist" (default) | "label" — ArtistGridCard.slint's
// `follow-kind`, the kind the follow write is sent as.
// - subtitle: meta switches to 1-line name + muted subtitle (the Slint
// "Similar to…"/search arm); empty = wrap-2 name.
//
// --- Menu inventory vs discover/ArtistGridCard.slint -------------------
// Open artist · Play · Follow/Following (show-follow) · Not interested
// The first two are live. The last two are gated off (`hasFollowSeam`,
// `hasNotInterestedSeam`) — both used to render and do nothing at all.
// The first three are live. "Not interested" is gated off
// (`hasNotInterestedSeam`) — it used to render and do nothing at all.
// ⋯ and a right press on the portrait or the name open the same menu.
import QtQuick
@@ -44,30 +52,72 @@ Rectangle {
// Pinned state (AlbumCard pattern: scalar prop, optimistic flip on
// click — the model re-publish re-creates the delegate).
property bool isPinned: false
property string followMode: "none"
property string followMode: "toggle"
property string followKind: "artist"
// --- Seams the Qt bridge does NOT have (menu-parity round) -----------
// hasFollowSeam -> artist/label follow (no invokable; the
// .slint routes to media-action(kind, id,
// "follow"))
// hasNotInterestedSeam -> the reco-scoped dismissal store is not
// opened by the Qt port
// Both entries stay out of the menu (and the follow BUTTON stays out of
// the overlay) until their invokable lands.
readonly property bool hasFollowSeam: false
// That entry stays out of the menu until its invokable lands.
//
// `hasFollowSeam` is GONE, and the header comment it carried ("There is
// NO artist-follow invokable on the Qt bridge") was simply wrong:
// `library_bridge.rs` declares `library_toggle_favorite(kind, id)`,
// `library_qt::toggle_favorite` routes "artist" (and "label") straight to
// `add_favorite` / `remove_favorite`, and ArtistView's own header button
// has been calling it all along. The constant made the follow affordance
// disappear from EVERY artist card in the app.
readonly property bool hasNotInterestedSeam: false
color: "transparent"
QbzTheme { id: theme }
readonly property bool showFollow: followMode !== "none" && hasFollowSeam
// ArtistGridCard.slint:89 — `show-follow: follow-mode != "none"`.
readonly property bool showFollow: followMode !== "none"
readonly property bool overlayOn: agArea.containsMouse || pinArea.containsMouse
|| agFollow.hovered || agPlay.hovered || agMore.hovered
implicitWidth: 200
implicitHeight: 246
// --- Follow state (a real QML property, never `item.following`) ------
// Same reason as rows/TrackRow.qml's `favorite`: `item` is a plain JS
// object, so mutating a field on it fires no notifier and re-evaluates no
// binding — and `item: modelData` is a COPY, so the write does not reach
// the model either (both measured under qml6 6.11.1). The seed reads both
// spellings the producers publish (see the header note).
property bool following: root.item.following === true
|| root.item.isFavorite === true
onItemChanged: root.following = Qt.binding(function () {
return root.item.following === true || root.item.isFavorite === true
})
function toggleFollow() {
root.following = !root.following
QbzLibrary.libraryToggleFavorite(root.followKind, root.item.id)
}
Connections {
target: QbzLibrary
// Pin fan-out — the AlbumCard contract, artist key (see AlbumCard.qml:
// the store has no change-notify, so this signal is what keeps every
// OTHER card showing this artist honest without a republish).
function onPinChanged(key, value) {
var aid = (root.item && root.item.id !== undefined) ? root.item.id : ""
if (aid !== "" && key === "artist:" + aid)
root.isPinned = value
}
// Follow fan-out + rollback, the favourite twin of the above. The key
// is `{followKind}:{id}` so a label-follow card settles on its own
// key rather than on an artist one that happens to share the number.
function onLibraryFavoriteChanged(key, value) {
var aid = (root.item && root.item.id !== undefined) ? root.item.id : ""
if (aid !== "" && key === root.followKind + ":" + aid)
root.following = value
}
}
Column {
spacing: 0
Rectangle {
@@ -134,13 +184,11 @@ Rectangle {
shown: root.overlayOn
CardOverlayButton {
id: agFollow
// showFollow already folds in hasFollowSeam, so this
// button never renders while the seam is missing.
visible: root.showFollow
name: root.item.following ? "check" : "user-plus"
active: root.item.following === true
name: root.following ? "check" : "user-plus"
active: root.following
anchors.verticalCenter: parent.verticalCenter
onClicked: root.menuAction("follow")
onClicked: root.toggleFollow()
}
CardOverlayButton {
id: agPlay
@@ -250,8 +298,8 @@ Rectangle {
{ "label": t("Play", r), "icon": "play-fill", "action": "play" },
]
if (root.showFollow)
m.push({ "label": root.item.following ? t("Following", r) : t("Follow", r),
"icon": root.item.following ? "check" : "user-plus", "action": "follow" })
m.push({ "label": root.following ? t("Following", r) : t("Follow", r),
"icon": root.following ? "check" : "user-plus", "action": "follow" })
// Reco-scoped dismissal (NOT the blacklist) — the artist leaves the
// Recommendations rails only.
if (root.hasNotInterestedSeam)
@@ -262,7 +310,8 @@ Rectangle {
function menuAction(a) {
if (a === "open") QbzArtist.openArtist(root.item.id)
else if (a === "play") QbzPlayer.playArtistCard(root.item.id)
// "follow" / "not-interested" are unreachable while their
// has*Seam constant is false — the entries are not built.
else if (a === "follow") root.toggleFollow()
// "not-interested" is unreachable while hasNotInterestedSeam is
// false — the entry is not built.
}
}
+116
View File
@@ -0,0 +1,116 @@
// The Qobuz-mix gradient square — ONE definition of the four mix identities
// (colours, badge, display name), used by BOTH surfaces that draw them:
//
// views/HomeView.qml MixesRail — 220px tile, badge + 22px name
// views/MixView.qml header — 224px art, no badge, 34px name
//
// In the Slint build those two live in different files and each re-declares
// the same four gradients (discover/QobuzMixesRow.slint:79-104 and
// mix/MixView.slint:63-70). Porting that duplication would mean a colour
// changed on the tile and not on the page it opens, so the port keeps one
// source (track rule 5).
//
// GRADIENT DELTA, inherited from the existing MixesRail: the .slint uses
// `@linear-gradient(135deg, a 0%, b 50%, c 100%)` and QML's Gradient has no
// angle — only Vertical / Horizontal. The vertical 3-stop plus a
// top-left-to-bottom-right darkening overlay is the approximation the POC
// already shipped for the tiles; the page header now uses the same one, so
// the two match each other exactly.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
/// "daily" | "weekly" | "fav" | "top".
property string kind: "daily"
property int size: 220
property int titleSize: 22
property int cornerRadius: 8
property bool showBadge: true
/// Whether the square reacts to the pointer at all (the page header does
/// not — it is not a link to anywhere).
property bool interactive: true
signal clicked()
QbzTheme { id: theme }
width: root.size
height: root.size
// QobuzMixesRow.slint:79-104 — the badge is the SERVICE the mix comes
// from ("qobuz" = Qobuz's own dynamic mixes, "qbz" = built locally from
// the user's library / playlists), which is why it is not uniform.
readonly property var _spec: ({
"daily": { "badge": "qobuz", "name": "DailyQ",
"c0": "#1e3a8a", "c1": "#6366f1", "c2": "#c084fc" },
"weekly": { "badge": "qobuz", "name": "WeeklyQ",
"c0": "#065f46", "c1": "#10b981", "c2": "#fbbf24" },
"fav": { "badge": "qbz", "name": "FavQ",
"c0": "#7f1d1d", "c1": "#ef4444", "c2": "#fb923c" },
"top": { "badge": "qbz", "name": "TopQ",
"c0": "#1f2937", "c1": "#4b5563", "c2": "#fbbf24" }
})
readonly property var spec: root._spec[root.kind] !== undefined
? root._spec[root.kind] : root._spec["daily"]
readonly property string mixName: root.spec.name
readonly property string badge: root.spec.badge
/// The four tile descriptions (QobuzMixesRow.slint:82-103). NOTE these
/// are NOT the mix PAGE's subtitles — `mix.rs::mix_meta` uses a different
/// string for `weekly` and `top`, and the page reads its own from Rust.
function tileDescription(k) {
return k === "daily"
? QbzSession.tr("Elevate your day with a customized selection of music.", QbzSession.trRev)
: k === "weekly"
? QbzSession.tr("Take a weekly journey with a fresh mix every Friday.", QbzSession.trRev)
: k === "fav"
? QbzSession.tr("A fresh shuffle from your personal library.", QbzSession.trRev)
: QbzSession.tr("Discover new music from your most-played playlists.", QbzSession.trRev)
}
Rectangle {
anchors.fill: parent
radius: root.cornerRadius
gradient: Gradient {
GradientStop { position: 0.0; color: root.spec.c0 }
GradientStop { position: 0.5; color: root.spec.c1 }
GradientStop { position: 1.0; color: root.spec.c2 }
}
// The 135deg sweep, approximated (see the header note).
Rectangle {
anchors.fill: parent
radius: root.cornerRadius
gradient: Gradient {
GradientStop { position: 0.0; color: "#00000000" }
GradientStop { position: 1.0; color: "#33000000" }
}
}
Text {
visible: root.showBadge
x: 12
y: 12
text: root.badge
color: "#ccffffff"
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1
}
Text {
anchors.centerIn: parent
text: root.mixName
color: "#ffffff"
font.pixelSize: root.titleSize
font.weight: theme.weightBold
}
MouseArea {
anchors.fill: parent
enabled: root.interactive
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}
}
+160
View File
@@ -0,0 +1,160 @@
// 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()
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
clip: true
// 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
clip: true
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
x: Math.round((parent.width - width) / 2)
y: parent.height - height - 16
}
// Hover scrim + play disc.
Rectangle {
anchors.fill: parent
color: "#000000"
opacity: panelArea.containsMouse || playArea.containsMouse ? 0.45 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
Rectangle {
width: 44
height: 44
radius: 22
color: "#ffffff"
anchors.centerIn: parent
opacity: panelArea.containsMouse || playArea.containsMouse ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
QbzIcon {
name: "play-fill"
width: 18
height: 18
anchors.centerIn: parent
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.
enabled: parent.opacity > 0
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
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
}
}
}
+32 -12
View File
@@ -39,6 +39,32 @@ Rectangle {
implicitWidth: 200
implicitHeight: 246
// --- Heart state (a real QML property, never `item.isFavorite`) ------
// Identical reasoning to rows/TrackRow.qml: `item` is a plain JS object
// copied out of `modelData`, so mutating a field on it fires no notifier
// (the glyph never changed) and reaches no model. The binding is
// re-established on every new row object so the document stays
// authoritative.
property bool favorite: root.item.isFavorite === true
onItemChanged: root.favorite = Qt.binding(function () {
return root.item.isFavorite === true
})
function toggleFavorite() {
root.favorite = !root.favorite
QbzLibrary.libraryToggleFavorite("track", root.item.id)
}
// Fan-out + rollback (the shape cards/AlbumCard.qml uses for pin).
Connections {
target: QbzLibrary
function onLibraryFavoriteChanged(key, value) {
var tid = (root.item && root.item.id !== undefined) ? root.item.id : ""
if (tid !== "" && key === "track:" + tid)
root.favorite = value
}
}
Column {
spacing: 0
Rectangle {
@@ -82,13 +108,10 @@ Rectangle {
shown: root.overlayOn
CardOverlayButton {
id: favBtn
name: root.item.isFavorite ? "heart-filled" : "heart"
active: root.item.isFavorite
name: root.favorite ? "heart-filled" : "heart"
active: root.favorite
anchors.verticalCenter: parent.verticalCenter
onClicked: {
root.item.isFavorite = !root.item.isFavorite
QbzLibrary.libraryToggleFavorite("track", root.item.id)
}
onClicked: root.toggleFavorite()
}
CardOverlayButton {
id: playBtn
@@ -201,8 +224,8 @@ Rectangle {
]
if (root.item.artistId) m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" })
if (root.item.albumId) m.push({ "label": t("Go to album", r), "icon": "disc", "action": "go-album" })
m.push({ "label": root.item.isFavorite ? t("Remove from Library", r) : t("Add to Library", r),
"icon": root.item.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r),
"icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" })
return m
}
function trackAction(a) {
@@ -212,9 +235,6 @@ Rectangle {
else if (a === "queue") QbzPlayer.enqueueTrack(root.item.id, "queue")
else if (a === "go-artist") QbzArtist.openArtist(root.item.artistId)
else if (a === "go-album") QbzAlbum.openAlbum(root.item.albumId)
else if (a === "favorite") {
root.item.isFavorite = !root.item.isFavorite
QbzLibrary.libraryToggleFavorite("track", root.item.id)
}
else if (a === "favorite") root.toggleFavorite()
}
}
@@ -57,8 +57,25 @@ Item {
// Scrim — declared FIRST so the panel below it in source order (and thus
// above it in z) keeps its own presses.
//
// `radius` is load-bearing, not decoration. This Item fills the VIEW
// root, which fills AppShell's rounded content frame, and Qt Quick's
// `clip` is a rectangular scissor that does not follow `radius` — so an
// opaque full-bleed child paints straight into the frame's four bezel
// corners and the panel reads square. AppShell mounts four corner nubs
// that cover exactly this, but ONLY while the ambient background is off
// (with ambient on, the gutter must show the ambient field through the
// corners, so an opaque nub would be the regression there). This scrim at
// 75% opacity was the one remaining offender in the ambient-on case: every
// view root already rounds its own fill for precisely this reason
// (HomeView.qml:radius 12 and its comment), the header atmosphere is
// suppressed under ambient, and window-level overlays are supposed to
// cover the corners. Rounding the scrim's own fill fixes it in BOTH
// ambient states and needs no colour guessing — the corners keep showing
// whatever is genuinely behind them.
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
+18 -1
View File
@@ -62,8 +62,15 @@ Rectangle {
// Favorite state of the now-playing track (Slint:
// QueueState.now-playing-favorite) — the queue document carries it on its
// `current` row; re-parsed only when that document changes.
// `current` row; re-parsed only when that document changes. The one-slot
// override is the same seam PlayerBar.qml documents: a heart flipped on
// another surface for THIS track republishes no queue document, so
// without it the bar kept a stale glyph until the track changed.
property string favOverrideId: ""
property bool favOverrideValue: false
readonly property bool npFavorite: {
if (root.favOverrideId !== "" && root.favOverrideId === QbzPlayer.npTrackId)
return root.favOverrideValue
try {
var d = JSON.parse(QbzQueue.queueJson)
return !!(d && d.current && d.current.isFavorite)
@@ -71,6 +78,16 @@ Rectangle {
return false
}
}
Connections {
target: QbzLibrary
function onLibraryFavoriteChanged(key, value) {
var id = QbzPlayer.npTrackId
if (id !== "" && key === "track:" + id) {
root.favOverrideId = id
root.favOverrideValue = value
}
}
}
function fmt(secs) {
var m = Math.floor(secs / 60)
+22
View File
@@ -102,7 +102,19 @@ Rectangle {
// Favorite state of the now-playing track (Slint:
// QueueState.now-playing-favorite). The queue document carries it on its
// `current` row; this re-parses ONLY when that document changes.
//
// The bar's OWN heart settles fine on its own — `queue_qt::toggle_favorite`
// republishes the queue document straight after the write. What it could
// not see was a heart flipped ANYWHERE ELSE for the same track (the album
// page's row, a search result, a card): nothing republishes the queue for
// those, so the bar sat on a stale glyph for the rest of the track. The
// one-slot override below closes it, keyed on the track id so it expires
// by itself the moment playback moves on — no map to keep clean.
property string favOverrideId: ""
property bool favOverrideValue: false
readonly property bool npFavorite: {
if (root.favOverrideId !== "" && root.favOverrideId === QbzPlayer.npTrackId)
return root.favOverrideValue
try {
var d = JSON.parse(QbzQueue.queueJson)
return !!(d && d.current && d.current.isFavorite)
@@ -110,6 +122,16 @@ Rectangle {
return false
}
}
Connections {
target: QbzLibrary
function onLibraryFavoriteChanged(key, value) {
var id = QbzPlayer.npTrackId
if (id !== "" && key === "track:" + id) {
root.favOverrideId = id
root.favOverrideValue = value
}
}
}
// Audio settings / normalization (Tauri's normalization button; Slint's
// "Audio settings" flyout). `normalization` IS settable through
+45 -13
View File
@@ -33,6 +33,46 @@ Rectangle {
readonly property var historyRows: doc.history || []
readonly property bool queueEmpty: currentRow === null && (doc.upcomingTotal || 0) === 0
// --- Favourite state, id-keyed (the ArtistView.localToggles idiom) ---
// The rows here are plain JS objects parsed out of `doc`, so the old
// `row.isFavorite = !row.isFavorite` notified nothing and NO glyph in
// this panel ever moved on click — not the Up Next menu entry, not the
// Now Playing heart. A map at root level is what fits: the queue
// document is re-parsed constantly (every position tick, every queue
// edit), so a per-delegate property alone would be overwritten by the
// next republish carrying the pre-toggle value.
//
// Both writers agree on the same map: the optimistic flip, and
// `libraryFavoriteChanged` — which carries what the write ACTUALLY
// produced, so it is also the rollback for a failed one and the way a
// heart set on another surface reaches this panel.
property var favOverrides: ({})
function favState(row) {
if (!row) return false
return root.favOverrides[row.id] !== undefined
? root.favOverrides[row.id]
: row.isFavorite === true
}
function setFavState(id, value) {
var m = root.favOverrides
m[id] = value
// Rebind requires a NEW object reference (same-ref is not a change).
root.favOverrides = Object.assign({}, m)
}
function toggleFav(row) {
if (!row)
return
root.setFavState(row.id, !root.favState(row))
QbzQueue.queueToggleFavorite("track", row.id)
}
Connections {
target: QbzLibrary
function onLibraryFavoriteChanged(key, value) {
if (key.indexOf("track:") === 0)
root.setFavState(key.substring(6), value)
}
}
// url-keyed cover map (shared artwork pipeline).
property var coverMap: ({})
@@ -310,7 +350,7 @@ Rectangle {
model: [
{ "label": QbzSession.tr("Remove from queue", QbzSession.trRev), "icon": "trash-2", "action": "remove" },
{ "label": QbzSession.tr("Remove all after", QbzSession.trRev), "icon": "list-x", "action": "remove-after" },
{ "label": row.isFavorite ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": row.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
{ "label": root.favState(row) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.favState(row) ? "heart-filled" : "heart", "action": "favorite" },
]
delegate: Rectangle {
required property var modelData
@@ -343,10 +383,7 @@ Rectangle {
var a = modelData.action
if (a === "remove") QbzQueue.queueRemoveUpcoming(rowIndex)
else if (a === "remove-after") QbzQueue.queueRemoveAllAfter(rowIndex)
else if (a === "favorite") {
row.isFavorite = !row.isFavorite
QbzQueue.queueToggleFavorite("track", row.id)
}
else if (a === "favorite") root.toggleFav(row)
}
}
}
@@ -628,22 +665,17 @@ Rectangle {
color: npFavArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
name: root.currentRow && root.currentRow.isFavorite ? "heart-filled" : "heart"
name: root.favState(root.currentRow) ? "heart-filled" : "heart"
width: 17
height: 17
tintName: root.currentRow && root.currentRow.isFavorite ? "favorite" : (npFavArea.containsMouse ? "textPrimary" : "muted")
tintName: root.favState(root.currentRow) ? "favorite" : (npFavArea.containsMouse ? "textPrimary" : "muted")
}
MouseArea {
id: npFavArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.currentRow) {
root.currentRow.isFavorite = !root.currentRow.isFavorite
QbzQueue.queueToggleFavorite("track", root.currentRow.id)
}
}
onClicked: root.toggleFav(root.currentRow)
}
}
}
+8 -2
View File
@@ -122,7 +122,12 @@ Column {
ribbonKind: gcell.modelData.ribbonKind || ""
artSource: gcell.modelData.artPath || ""
isPinned: gcell.modelData.isPinned === true
isFavorite: false
// Snapshot url the pin payload persists (artPath is the
// local cache path — see AlbumCard.artworkUrl).
artworkUrl: gcell.modelData.artUrl || ""
// Stamped on the row (AlbumCardData / HomeCard); false
// made the glyph lie and inverted the first click.
isFavorite: gcell.modelData.isFavorite === true
}
}
}
@@ -235,8 +240,9 @@ Column {
ribbonKind: cell.modelData.ribbonKind || ""
artSource: cell.modelData.artPath || ""
isPinned: cell.modelData.isPinned === true
artworkUrl: cell.modelData.artUrl || ""
plays: root.showPlays ? (cell.modelData.plays || 0) : 0
isFavorite: false
isFavorite: cell.modelData.isFavorite === true
}
}
Loader {
+412 -83
View File
@@ -5,9 +5,16 @@
// Data: QbzHome.homeSectionsJson (one JSON document — see bridge.rs),
// published by src/home_qt.rs; artwork file:// paths resolve through the
// qbz-cache image cache. Section kinds: "album" | "playlist" | "slim" |
// "slimTracks" | "artists" | "pinned" | "mixes" | "recentPlaceholder". Rail
// ORDER + VISIBILITY follow the persisted Discover prefs (phase 11,
// discover_prefs.db).
// "slimTracks" | "artists" | "pinned" | "mixes" | "radio" | "spotlight" |
// "recentPlaceholder". Rail ORDER + VISIBILITY follow the persisted Discover
// prefs (phase 11, discover_prefs.db).
//
// THREE of those kinds are ORDERING SLOTS with no rows in the document —
// "pinned", "radio", "spotlight". Their rows travel on their own bridge
// properties (pinnedJson / radioStationsJson / spotlightJson) so a per-click
// pin, or the Spotlight's late /artist/page landing, does not republish all
// three tab documents and tear down every rail's delegates. See
// src/foryou_qt.rs.
//
// POC-NOTEs:
// - The genre filter (shared GenreFilterPopup, context "discover") and the
@@ -68,6 +75,40 @@ Rectangle {
// Recommendations (the 4th tab). Lazy: the document stays "[]" until the
// tab is first shown and src/recommendations_qt.rs publishes into it.
readonly property var recoSections: JSON.parse(QbzHome.recoSectionsJson)
// The Pinned rail's ROWS, on their own property. The three documents
// above carry the "pinned" section as an EMPTY ordering slot only (see
// home_qt::publish_pinned): a pin is a per-click mutation, and folding
// its rows into them meant every click re-parsed all three documents and
// rebuilt every rail's delegates — with each rail's horizontal scroll
// snapped back to 0. Rebinding THIS re-creates the pinned delegates and
// nothing else. 1:1 with the Slint split (descriptor for the position,
// the PinnedState global for the rows).
readonly property var pinnedItems: {
try {
return JSON.parse(QbzHome.pinnedJson)
} catch (e) {
return []
}
}
// For You's two other out-of-document rails — same split, same reason as
// `pinnedItems` (src/foryou_qt.rs): the tab documents carry an EMPTY
// ordering slot for each and the rows arrive on their own property, so
// the Spotlight's late /artist/page landing does not re-create every
// rail's delegates on all three tabs.
readonly property var radioStations: {
try {
return JSON.parse(QbzHome.radioStationsJson)
} catch (e) {
return []
}
}
readonly property var spotlight: {
try {
return JSON.parse(QbzHome.spotlightJson)
} catch (e) {
return ({})
}
}
property string activeTab: "home"
// Slint gates the 4th tab on the persisted `show_recommendations` pref
@@ -118,16 +159,30 @@ Rectangle {
: root.activeTab === "recommendations" ? root.recoSections
: root.sections
// Cheap "some card in the mounted rails has artUrl but no artPath yet"
// probe — recomputed only when a sections document is republished.
// probe — recomputed only when a sections document is republished. The
// pinned rows are probed too: they no longer travel inside the sections
// documents, and without them a freshly pinned card's placeholder would
// sit frozen (the pulse Timer runs only while something is pending).
readonly property bool artPending: root.anyArtPending(root.activeSections)
|| root.anyItemArtPending(root.pinnedItems)
// The two out-of-document For You rails are probed for the same
// reason the pinned rows are: without them a Spotlight or Radio tile
// waiting for its cover would sit on a FROZEN placeholder, because
// the pulse Timer runs only while something is pending.
|| root.anyItemArtPending(root.radioStations)
|| root.anyItemArtPending(root.spotlight.albums || [])
function anyArtPending(model) {
for (var s = 0; s < model.length; s++) {
var items = model[s].items || []
if (root.anyItemArtPending(model[s].items || []))
return true
}
return false
}
function anyItemArtPending(items) {
for (var i = 0; i < items.length; i++) {
if ((items[i].artUrl || "") !== "" && (items[i].artPath || "") === "")
return true
}
}
return false
}
// The pulse must OUTLIVE `artPending`. That flag drops when the last
@@ -255,9 +310,18 @@ Rectangle {
ribbonKind: modelData.ribbonKind
artSource: modelData.artPath
isPinned: modelData.isPinned
// POC-NOTE: Home hearts are not seeded from fav_cache
// (store not open in the POC); toggles still hit the API.
isFavorite: false
// Snapshot url the pin payload persists (artPath is
// a local cache path — see AlbumCard.artworkUrl).
artworkUrl: modelData.artUrl || ""
// The POC-NOTE that used to sit here ("Home hearts
// are not seeded from fav_cache") is obsolete: the
// store IS open and home_qt stamps `isFavorite` on
// every card row. While it was hardcoded false, a
// Home rail album that IS in the library drew the
// hollow heart, read "Add to Library", and the click
// REMOVED it — the toggle takes its direction from
// the same cache the row is stamped from.
isFavorite: modelData.isFavorite === true
}
// Per-item artwork placeholder: the grey tile shimmers
// until THIS card's cover is ON SCREEN, then dissolves
@@ -454,10 +518,16 @@ Rectangle {
// Pinned rail (PinnedCarousel.slint) — one 200x246 slot per item, the
// card picked by the item's own kind: albums reuse AlbumCard, artists
// render the ArtistGridCard circle, playlists the PlaylistCard square.
// Fed from the shared per-user pinned_items.db (home_qt "pinned"
// section; most-recent first).
// Fed from the shared per-user pinned_items.db (most-recent first).
//
// `sectionData` supplies ONLY the header (title + the rail's position in
// the tab); the rows come from root.pinnedItems — the dedicated
// `pinnedJson` property — so a pin/unpin touches this rail and nothing
// else in the view.
component PinnedRail: Column {
id: pinRail
property var sectionData: ({})
readonly property var items: root.pinnedItems
width: parent ? parent.width : 0
spacing: 12
@@ -482,7 +552,7 @@ Rectangle {
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: sectionData.items
model: pinRail.items
delegate: Item {
required property var modelData
required property int index
@@ -497,23 +567,40 @@ Rectangle {
artist: modelData.artist
artSource: modelData.artPath || ""
isPinned: true
isFavorite: false
// home_qt::pinned_cards stamps the heart per row,
// routed by the row's own item_kind.
isFavorite: modelData.isFavorite === true
// Un-pinning from this rail is fine without it,
// but the glyph is a TOGGLE: re-pinning the row
// the user just dropped must restore the same
// snapshot url, not blank it.
artworkUrl: modelData.artUrl || ""
}
}
Component {
id: pArtist
ArtistCard {
// The pinned row carries the stored snapshot in
// BOTH `artist` and `subtitle` (home_qt
// `pinned_cards`) precisely so this card and the
// album one can each read their own slot. While
// only `artist` was published, this card drew a
// blank second line — and, because it hands
// `item.subtitle` back to togglePin, re-pinning
// from the rail persisted that blank.
item: modelData
artSource: modelData.artPath || ""
isPinned: true
artworkUrl: modelData.artUrl || ""
}
}
Component {
id: pPlaylist
PlaylistCard {
// Pinned model: { id, title, artist, artPath,
// isPinned } — artist maps to the subtitle line.
item: Object.assign({}, modelData, { subtitle: modelData.subtitle || modelData.artist || "" })
// Same story as the artist slot above; the card's
// `artworkUrl` default already resolves the row's
// `artUrl` snapshot.
item: modelData
artSource: modelData.artPath || ""
isPinned: true
}
@@ -543,64 +630,30 @@ Rectangle {
}
}
// Qobuz Mixes rail (QobuzMixesRow.slint) — four static 220px
// navigation tiles (gradient art + badge + name, description below).
// POC-NOTE: the mix DETAIL views are out of scope — tiles are inert.
// (Slint's 135° linear gradients are approximated with corner
// RadialGradients — QML has no angled linear gradient.)
// Qobuz Mixes rail (QobuzMixesRow.slint) — four static 220px navigation
// tiles (gradient art + badge + name, description below). The gradient
// square itself is the shared cards/MixArtwork.qml, because the mix
// LANDING page draws the same four identities at 224px and the colours
// must not be able to drift apart (the .slint re-declares them in both
// files). Tiles are LIVE: `openMix` records the "mix" nav entry and
// fetches — 1:1 with `media-action("mix", which, "open")`.
component MixTile: Column {
property string badge: ""
property string mixName: ""
property string desc: ""
property color c0: "#000000"
property color c1: "#000000"
property color c2: "#000000"
id: mixTile
property string kind: "daily"
spacing: 8
width: 220
Rectangle {
width: 220
height: 220
radius: 8
gradient: Gradient {
GradientStop { position: 0.0; color: c0 }
GradientStop { position: 0.5; color: c1 }
GradientStop { position: 1.0; color: c2 }
}
// Fake the 135° sweep with a corner-centered radial overlay.
Rectangle {
anchors.fill: parent
radius: 8
gradient: Gradient {
GradientStop { position: 0.0; color: "#00000000" }
GradientStop { position: 1.0; color: "#33000000" }
}
}
Text {
x: 12
y: 12
text: badge
color: "#ccffffff"
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1
}
Text {
anchors.centerIn: parent
text: mixName
color: "#ffffff"
font.pixelSize: 22
font.weight: theme.weightBold
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
// Inert (mix detail views out of scope).
}
MixArtwork {
id: art
kind: mixTile.kind
size: 220
titleSize: 22
cornerRadius: 8
onClicked: QbzHome.openMix(mixTile.kind)
}
Text {
width: 220
text: desc
text: art.tileDescription(mixTile.kind)
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
@@ -618,25 +671,265 @@ Rectangle {
}
Row {
spacing: 32
MixTile {
badge: "qobuz"; mixName: "DailyQ"
desc: QbzSession.tr("Elevate your day with a customized selection of music.", QbzSession.trRev)
c0: "#1e3a8a"; c1: "#6366f1"; c2: "#c084fc"
MixTile { kind: "daily" }
MixTile { kind: "weekly" }
MixTile { kind: "fav" }
MixTile { kind: "top" }
}
MixTile {
badge: "qobuz"; mixName: "WeeklyQ"
desc: QbzSession.tr("Take a weekly journey with a fresh mix every Friday.", QbzSession.trRev)
c0: "#065f46"; c1: "#10b981"; c2: "#fbbf24"
}
MixTile {
badge: "qbz"; mixName: "FavQ"
desc: QbzSession.tr("A fresh shuffle from your personal library.", QbzSession.trRev)
c0: "#7f1d1d"; c1: "#ef4444"; c2: "#fb923c"
// Radio Stations rail (RadioCarousel.slint) — album-seeded radio tiles in
// a paged horizontal list with the same chevrons every other rail has.
// 200px card + 32px spacing = the 232px pitch the album rails use.
//
// The rows come from `QbzHome.radioStationsJson`, NOT from `sectionData`:
// the section in the tab document is an empty ordering slot (the `pinned`
// split — see src/foryou_qt.rs). `sectionData` supplies the title only.
component RadioRail: Column {
id: radioRail
property var sectionData: ({})
readonly property var items: root.radioStations
width: parent ? parent.width : 0
spacing: 12
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
readonly property int step: perPage * 232
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
QbzSectionHeader {
title: radioRail.sectionData.title
leftEnabled: rail.contentX > 1
rightEnabled: rail.contentX < radioRail.maxScroll - 1
onPageLeft: rail.contentX = Math.max(0, rail.contentX - radioRail.step)
onPageRight: rail.contentX = Math.min(radioRail.maxScroll, rail.contentX + radioRail.step)
}
Item {
width: parent.width
// RadioCarousel.slint:94 — 234, not the album rails' 246 (the
// radio card's meta block is one line shorter in the worst case).
height: 234
ListView {
id: rail
anchors.fill: parent
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: radioRail.items
delegate: Item {
required property var modelData
required property int index
width: 200
height: 234
RadioCard {
seedTitle: modelData.title
seedSubtitle: modelData.artist
label: QbzSession.tr("RADIO", QbzSession.trRev)
artSource: modelData.artPath || ""
// Both the card body and the hover disc start the
// album radio (the .slint wires `clicked` and `play`
// to the same callback).
onActivated: QbzHome.startAlbumRadio(modelData.albumId)
onPlayRequested: QbzHome.startAlbumRadio(modelData.albumId)
}
// The card's artwork is an INSET 126px display, so the
// placeholder is that square, not the whole tile.
QbzSkeleton {
variant: "art"
width: 126
height: 126
x: 37
y: 29
blockRadius: 4
pending: (modelData.artUrl || "") !== ""
coverSource: modelData.artPath || ""
phase: root.skelPhase
cellIndex: index
settleMs: 6000
}
}
}
}
}
// Spotlight (Spotlight.slint) — one favourite artist: header, a hero
// (140px round portrait + ARTIST + name + play / open-artist discs), and
// a draggable row mixing a TOP TRACKS card, a RADIO card and the
// artist's albums. Rows come from `QbzHome.spotlightJson`; `sectionData`
// is the ordering slot only.
component SpotlightRail: Column {
id: spot
property var sectionData: ({})
readonly property var doc: root.spotlight
readonly property var albums: root.spotlight.albums || []
readonly property bool hasTopTracks: root.spotlight.hasTopTracks === true
width: parent ? parent.width : 0
spacing: 12
// Header — the .slint hardcodes the pair, not sectionData.title.
Column {
spacing: 2
Text {
text: QbzSession.tr("Spotlight", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Text {
text: QbzSession.tr("Shine a light on one of your favourite artists.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// Hero.
Row {
spacing: 16
Rectangle {
width: 140
height: 140
radius: 70
color: theme.surfaceElevated
clip: true
QbzIcon {
visible: (spot.doc.artPath || "") === ""
name: "user"
width: 40
height: 40
anchors.centerIn: parent
tintName: "muted"
}
RoundedImage {
anchors.fill: parent
source: spot.doc.artPath || ""
radius: 70
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: QbzArtist.openArtist(spot.doc.artistId || "")
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Text {
visible: (spot.doc.category || "") !== ""
text: QbzSession.tr("ARTIST", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Text {
text: spot.doc.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontTitle
font.weight: theme.weightBold
}
// Spotlight.slint's local CircleButton is 44px for BOTH
// discs (not the shared primitive's 44/32) — a 32px disc next
// to the 140px portrait reads wrong, which is presumably why.
// QbzCircleAction's size is a plain binding, so a caller can
// override it without a new knob on the shared control.
Row {
spacing: 10
QbzCircleAction {
visible: spot.hasTopTracks
name: "play-fill"
primary: true
onClicked: QbzHome.playArtistTopTracks(spot.doc.artistId || "")
}
QbzCircleAction {
name: "user"
width: 44
height: 44
onClicked: QbzArtist.openArtist(spot.doc.artistId || "")
}
}
}
}
Item { width: 1; height: 4 }
// Content row — one draggable strip, like the other carousels.
Item {
width: parent.width
height: 270
ListView {
id: spotRow
anchors.fill: parent
orientation: ListView.Horizontal
spacing: 24
clip: true
boundsBehavior: Flickable.StopAtBounds
// The two radio cards are prepended to the album rows, so the
// strip is ONE model and the drag/scroll covers all of it (the
// .slint puts them in the same HorizontalLayout).
model: {
var out = []
if (spot.hasTopTracks)
out.push({ "slot": "top" })
out.push({ "slot": "radio" })
for (var i = 0; i < spot.albums.length; i++)
out.push({ "slot": "album", "album": spot.albums[i] })
return out
}
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
// The PinnedRail dispatch pattern: Components declared in
// the delegate scope, so `modelData` resolves.
Component {
id: topCard
RadioCard {
seedTitle: QbzSession.tr("Top tracks", QbzSession.trRev)
seedSubtitle: QbzSession.tr("By {}", QbzSession.trRev)
.replace("{}", spot.doc.name || "")
label: QbzSession.tr("TOP TRACKS", QbzSession.trRev)
artSource: spot.doc.artPath || ""
onActivated: QbzHome.playArtistTopTracks(spot.doc.artistId || "")
onPlayRequested: QbzHome.playArtistTopTracks(spot.doc.artistId || "")
}
}
Component {
id: radioSeedCard
RadioCard {
seedTitle: spot.doc.name || ""
seedSubtitle: QbzSession.tr("Qobuz Radio Station", QbzSession.trRev)
label: QbzSession.tr("RADIO", QbzSession.trRev)
artSource: spot.doc.artPath || ""
onActivated: QbzHome.startArtistRadio(spot.doc.artistId || "")
onPlayRequested: QbzHome.startArtistRadio(spot.doc.artistId || "")
}
}
Component {
id: albumCardComp
AlbumCard {
albumId: modelData.album.id
title: modelData.album.title
artist: modelData.album.artist
artistId: modelData.album.artistId
genre: modelData.album.genre
year: modelData.album.year
qualityTier: modelData.album.qualityTier
artSource: modelData.album.artPath || ""
artworkUrl: modelData.album.artUrl || ""
isPinned: modelData.album.isPinned === true
isFavorite: modelData.album.isFavorite === true
}
}
Loader {
anchors.fill: parent
sourceComponent: modelData.slot === "top" ? topCard
: modelData.slot === "radio" ? radioSeedCard
: albumCardComp
}
}
MixTile {
badge: "qbz"; mixName: "TopQ"
desc: QbzSession.tr("Discover new music from your most-played playlists.", QbzSession.trRev)
c0: "#1f2937"; c1: "#4b5563"; c2: "#fbbf24"
}
}
}
@@ -658,6 +951,22 @@ Rectangle {
required property var modelData
property string railTab: rails.tabId
width: parent ? parent.width : 0
// The pinned slot is always in the document (it is where the
// discover prefs put the rail); it renders only once the
// store has rows — the Slint `PinnedState.items.length > 0`
// gate. A Column skips invisible children entirely, spacing
// included, so an empty pinned rail leaves no gap.
// The out-of-document rails are always IN the document (that
// is where the discover prefs put them) and render only once
// their own store has rows — the Slint
// `PinnedState.items.length > 0` /
// `ForYouState.radio-stations.length > 0` /
// `spotlight-visible` gates. A Column skips invisible children
// entirely, spacing included, so an empty one leaves no gap.
visible: modelData.kind === "pinned" ? root.pinnedItems.length > 0
: modelData.kind === "radio" ? root.radioStations.length > 0
: modelData.kind === "spotlight" ? root.spotlight.visible === true
: true
sourceComponent: modelData.kind === "album" ? albumRailComp
: modelData.kind === "playlist" ? playlistRailComp
: modelData.kind === "slim" ? slimGridComp
@@ -665,6 +974,8 @@ Rectangle {
: modelData.kind === "artists" ? artistRailComp
: modelData.kind === "pinned" ? pinnedRailComp
: modelData.kind === "mixes" ? mixesRailComp
: modelData.kind === "radio" ? radioRailComp
: modelData.kind === "spotlight" ? spotlightRailComp
: recentComp
property var sectionData: modelData
@@ -719,6 +1030,11 @@ Rectangle {
delegate: PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
// The card's own default is `false`; the
// pin state travels on the row (home_qt
// `map_playlist`), so it has to be handed
// over or the glyph lies on every rail.
isPinned: modelData.isPinned === true
}
}
}
@@ -766,6 +1082,11 @@ Rectangle {
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
// Same hand-over as the playlist rail —
// home_qt `map_fav_artist` publishes the
// pin state and the card defaults to false.
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
}
@@ -774,6 +1095,14 @@ Rectangle {
id: mixesRailComp
MixesRail { sectionData: parent.sectionData }
}
Component {
id: radioRailComp
RadioRail { sectionData: parent.sectionData }
}
Component {
id: spotlightRailComp
SpotlightRail { sectionData: parent.sectionData }
}
Component {
id: recentComp
QbzEmptyState {
+18 -46
View File
@@ -16,6 +16,7 @@ import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../rows"
import "../theme"
import "local"
@@ -278,53 +279,24 @@ Rectangle {
}
// ---- Column header ----
Row {
// rows/TrackListHeader.qml, on rows/TrackCols.qml geometry — the
// same object the LocalTrackRows below lay their cells out from.
// (LocalAlbumView.slint:501-540 hardcodes a second set here —
// 16px gaps, Duration 80, Quality 80 — that its own rows do not
// use; the port does not copy that hazard.)
//
// `trailingReserve: 72` is the slice LocalTrackRow spends OUTSIDE
// the shared row: it narrows the shared TrackRow by 26 (source
// glyph gutter) + 46 (its own re-drawn ⋯: 32 + one 14px gap), so
// the labelled columns end 72px short of the row's right edge.
// Same constant, one place: LocalTrackRow.qml:82.
TrackListHeader {
width: parent.width
height: 40
leftPadding: 12
rightPadding: 12
spacing: 16
Text {
width: 32
height: parent.height
text: "#"
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
}
Text {
width: parent.width - 24 - 32 - 80 - 80 - 28 - 32 - 5 * 16
height: parent.height
text: QbzSession.tr("Title", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
}
Text {
width: 80
height: parent.height
text: QbzSession.tr("Duration", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.letterSpacing: 0.5
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
Text {
width: 80
height: parent.height
text: QbzSession.tr("Quality", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.letterSpacing: 0.5
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
// Source column + the per-row more-button slot.
Item { width: 28; height: parent.height }
Item { width: 32; height: parent.height }
bandHeight: 40
labelSpacing: 0.5
showFavorite: false
showMenu: false
trailingReserve: 72
}
// ---- Track list ----
+297
View File
@@ -0,0 +1,297 @@
// Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ) — QML port of
// mix/MixView.slint. Route "mix", opened from the Qobuz Mixes tiles on
// Discover > For You.
//
// ONE JSON document (QbzHome.mixJson, src/foryou_qt.rs MixDoc): kind, title,
// subtitle, trackCount, totalDuration, loading, tracks[]. The gradient is
// keyed off `kind` through the shared cards/MixArtwork.qml, so the page's
// artwork is literally the tile the user clicked, enlarged.
//
// SCROLLING-PAGE CONVENTION (AlbumView / LabelView / PlaylistView): one
// Flickable whose Column pads 32 / 32 / top 11 / bottom 100. The .slint's
// NavButtons row is dropped (back/forward live in the shell header,
// HeaderBar.qml:230) but its 22px spacer is KEPT, so the header sits where
// every other detail page's does.
//
// Numbers from MixView.slint: art 224 radius Radius.md · header gap 32 ·
// eyebrow 11px semibold letter-spacing 1.5 · title Typography.section bold ·
// subtitle Typography.body wrapped at `root.width - 320` · info line
// Typography.legal · spacers 6/8/10/20/28 · then the column header, a 3px
// gap, a 1px border-subtle rule, 6px, then the rows.
//
// The column header is rows/TrackListHeader.qml — NOT the .slint's numbers.
// MixView.slint:246-284 lays its header out with 16px gaps, a 40px "#", a
// 64px Duration and NO artwork reserve, while its rows are the shared
// TrackRow (14px gaps, 32px number, 36px artwork, 70px Duration): the labels
// there sit right of the columns they name, which is exactly the defect
// being fixed here. The port takes the ROW's geometry from
// rows/TrackCols.qml for both.
//
// POC-NOTEs, each deliberate and each with a precedent in this port:
// - NO multi-select bar. qml/rows/TrackRow.qml has no multi-select arm and
// two of the .slint bar's seven actions (add-to-playlist, add-to-mixtape)
// have no bridge seam at all — LabelView.qml made the same call. The
// .slint header's select disc renders DIMMED and inert here (the
// ArtistView / LabelView precedent).
// - The "Add to playlist" disc (list-plus) is inert in the .slint too — it
// carries no `clicked` there (MixView.slint:182-188) — so it is inert
// here, dimmed the same way, rather than silently absent.
// - No scroll restore: this port has no NavState.restore-scope counterpart
// (the same note LabelView.qml carries).
// - CIRCLE ACTION SIZE: the .slint MixView declares a LOCAL CircleAction
// fixed at 38px, predating the shared primitives/CircleAction.slint
// (44 primary / 32 secondary). This port mounts the shared
// controls/QbzCircleAction.qml — the port of the SHARED primitive — so the
// mix page's discs match every other detail page in this build.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../rows"
import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
radius: 12
QbzTheme { id: theme }
readonly property var doc: {
try {
return JSON.parse(QbzHome.mixJson)
} catch (e) {
return ({})
}
}
readonly property var tracks: root.doc.tracks || []
readonly property bool loading: root.doc.loading === true
readonly property string kind: root.doc.kind || "daily"
// --- skeleton pulse (the HomeView gating rule: freeze on NOT VISIBLE,
// never on lost focus) --------------------------------------------------
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
Timer {
interval: 900
repeat: true
running: root.visible && root.windowShowing && root.loading
onTriggered: root.skelPhase = !root.skelPhase
}
// ============================ offline gate ============================
QbzOfflinePlaceholder {
visible: QbzSession.offline
anchors.centerIn: parent
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
// ============================ the page ================================
Flickable {
id: flick
anchors.fill: parent
visible: !QbzSession.offline
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: page
width: parent.width
leftPadding: 32
rightPadding: 32
topPadding: 11
bottomPadding: 100
spacing: 0
// The .slint's NavButtons row is not drawn (shell header owns
// back/forward) but its 22px spacer is kept.
Item { width: 1; height: 22 }
// --- Header -----------------------------------------------------
Row {
width: parent.width - 64
spacing: 32
// The mix gradient — the SAME square the For You tile drew,
// at 224px with no badge and a 34px name.
MixArtwork {
kind: root.kind
size: 224
titleSize: 34
cornerRadius: theme.radiusMd
showBadge: false
interactive: false
}
Column {
width: parent.width - 224 - 32
anchors.top: parent.top
anchors.topMargin: 4
spacing: 0
Text {
text: QbzSession.tr("Qobuz Mixes", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Item { width: 1; height: 6 }
Text {
width: parent.width
text: root.doc.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Item { width: 1; height: 8 }
Text {
// .slint: `width: root.width - 320px`.
width: Math.max(0, root.width - 320)
text: root.doc.subtitle || ""
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
Item { width: 1; height: 10 }
Text {
visible: (root.doc.trackCount || 0) > 0
text: (root.doc.trackCount || 0) + " "
+ QbzSession.tr("tracks", QbzSession.trRev)
+ " • " + (root.doc.totalDuration || "")
color: theme.textSecondary
font.pixelSize: theme.fontLegal
}
Item { width: 1; height: 20 }
// Action discs (MixView.slint:161-210).
Row {
spacing: 12
QbzCircleAction {
name: "play-fill"
primary: true
btnEnabled: root.tracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.mixPlayAll()
}
QbzCircleAction {
name: "shuffle"
btnEnabled: root.tracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.mixShuffle()
}
// Add-to-playlist: inert in the .slint too (no
// `clicked` there) — see the header note.
QbzCircleAction {
name: "list-plus"
btnEnabled: false
anchors.verticalCenter: parent.verticalCenter
}
// Multi-select: DIMMED and inert (see the header note).
QbzCircleAction {
name: "square-check-big"
btnEnabled: false
anchors.verticalCenter: parent.verticalCenter
}
QbzCircleAction {
name: "home-gear"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.mixRefresh()
}
}
}
}
Item { width: 1; height: 28 }
// --- Loading / empty --------------------------------------------
// The .slint mounts a bare centred spinner. This port shows the
// SHAPE of the list that is coming (the PlaylistView / LabelView
// convention): a column of track-row placeholders.
QbzSkeleton {
visible: root.loading && root.tracks.length === 0
width: parent.width - 64
height: visible ? 8 * 56 : 0
variant: "rowList"
rowH: 50
rowGap: 6
rowArt: true
rowArtSize: 36
phase: root.skelPhase
}
Text {
visible: !root.loading && root.tracks.length === 0
text: QbzSession.tr("No tracks for this mix.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
// --- Track list column header -----------------------------------
// `parent.width - 64` is the width the rows get (see the delegate
// below) — the header component's contract is that it is exactly
// as wide as the rows it labels.
TrackListHeader {
visible: root.tracks.length > 0
width: parent.width - 64
showArtwork: true
showAlbum: true
}
Item { visible: root.tracks.length > 0; width: 1; height: visible ? 3 : 0 }
Rectangle {
visible: root.tracks.length > 0
width: parent.width - 64
height: visible ? 1 : 0
color: theme.borderSubtle
}
Item { width: 1; height: 6 }
// --- Rows --------------------------------------------------------
// A Repeater, not a ListView: the mix is capped at 50 (dynamic
// suggest) / 100 (TopQ) / 200 (FavQ) rows and the page is ONE
// Flickable, exactly like LabelView's Popular Tracks. The 2000-row
// case that forces windowing does not arise here.
Repeater {
model: root.tracks
delegate: TrackRow {
required property var modelData
required property int index
// `parent` is the padded page Column (the LabelView
// guard: it is momentarily null while the delegate is
// being reparented).
width: parent ? parent.width - 64 : 0
item: modelData
number: index + 1
showArtwork: true
showAlbum: true
showFavorite: true
// The mix list is not reorderable and is not a drag
// source for playlists (flat catalog rows, no container).
onPlayRequested: QbzHome.mixPlayTrack(modelData.id)
onEnqueueRequested: function (mode) {
QbzHome.mixEnqueueTrack(modelData.id, mode)
}
}
}
}
}
// Thin auto-hiding scrollbar in the right gutter (ListScrollbar).
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}
@@ -299,6 +299,15 @@ Rectangle {
// the overlay button plays it.
item: plCell.modelData
artSource: plCell.modelData.artPath || ""
// The row carries the pin state
// (home_qt `map_playlist`, which this page
// reuses); the card defaults to false, so
// without the hand-over the glyph read
// "unpinned" on an already-pinned playlist
// and the first click un-pinned it.
// `artworkUrl` needs no hand-over: the
// card defaults it to `item.artUrl`.
isPinned: plCell.modelData.isPinned === true
}
}
}
@@ -82,10 +82,15 @@ Rectangle {
radius: 6
color: theme.surfaceElevated
clip: true
// These rows come from `home_qt::map_playlist` (browse_qt.rs:612),
// whose art is the playlist's own `image.rectangle` — 800x380.
// `auto` pads that instead of cropping to the middle 47% of it,
// and still crops the square `image.covers[0]` fallback.
RoundedImage {
anchors.fill: parent
source: root.item.artPath || ""
radius: 6
fit: "auto"
}
QbzIcon {
visible: (root.item.artPath || "") === ""
+38 -6
View File
@@ -469,6 +469,13 @@ Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
item: root.mp.artist || ({})
artSource: (root.mp.artist || ({})).artPath || ""
// Same two fields the album hero below
// carries: the pin state (or the glyph
// lies and the first click UN-pins) and
// the REMOTE url the pin payload stores
// (artPath is a local cache path).
isPinned: (root.mp.artist || ({})).isPinned === true
artworkUrl: (root.mp.artist || ({})).artUrl || ""
}
AlbumCard {
visible: root.mp.kind === "album"
@@ -481,8 +488,17 @@ Rectangle {
year: (root.mp.album || ({})).year || ""
qualityTier: (root.mp.album || ({})).qualityTier || ""
artSource: (root.mp.album || ({})).artPath || ""
isFavorite: false
isPinned: false
// search_qt::map_album stamps the heart
// from fav_cache; false inverted the
// first click on an album already in the
// library.
isFavorite: (root.mp.album || ({})).isFavorite === true
// The row carries the pin state
// (search_qt `map_album`); hardcoding
// false made the glyph lie and turned
// the first click into an UN-pin.
isPinned: (root.mp.album || ({})).isPinned === true
artworkUrl: (root.mp.album || ({})).artUrl || ""
}
SearchTrackHero {
visible: root.mp.kind === "track"
@@ -525,6 +541,8 @@ Rectangle {
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
}
@@ -552,6 +570,8 @@ Rectangle {
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
}
@@ -590,8 +610,12 @@ Rectangle {
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
isFavorite: false
isPinned: false
// search_qt::map_album stamps the heart
// from fav_cache; false made the glyph lie
// and inverted the first click.
isFavorite: modelData.isFavorite === true
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
CardArtSkeleton {
card: modelData
@@ -626,8 +650,9 @@ Rectangle {
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
isFavorite: false
isPinned: false
isFavorite: modelData.isFavorite === true
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
CardArtSkeleton {
card: modelData
@@ -712,6 +737,8 @@ Rectangle {
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
// Artists: ROUND cells (ArtistGridCard's portrait), and
@@ -761,8 +788,12 @@ Rectangle {
width: 200
height: 246
PlaylistCard {
// artworkUrl is not passed: the card
// defaults it to `item.artUrl`, which is
// this row's remote cover.
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
}
CardArtSkeleton {
card: modelData
@@ -790,6 +821,7 @@ Rectangle {
PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
}
CardArtSkeleton {
card: modelData
+10 -1
View File
@@ -53,7 +53,16 @@ Column {
year: modelData.year
qualityTier: modelData.qualityTier
artSource: root.coverMap[modelData.artUrl] || ""
isFavorite: false
// The row carries the heart (home_qt / recommendations_qt
// stamp it from fav_cache at build time); hardcoding false
// made a library album draw hollow and read "Add to
// Library", so the first click REMOVED it.
isFavorite: modelData.isFavorite === true
// Pin glyph: the row carries the state, and the pin payload
// needs the REMOTE url (coverMap holds local file:// paths,
// which are worthless as a stored display snapshot).
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
Rectangle {
@@ -139,7 +139,33 @@ Item {
onContentYChanged: {
root.report()
// Infinite scroll: 600px of runway (Slint :1442).
if (contentY + height >= contentHeight - 600
//
// `contentHeight > height` states the precondition the
// predicate always had: a list shorter than its viewport
// has no bottom to run out of, so it cannot be "600px from
// the end". Nothing is lost by it — a list that short
// cannot scroll (StopAtBounds), so contentY never moves
// and this handler never runs for it.
//
// It is ALSO the guard for this handler's one re-entrant
// entry: it runs from INSIDE QQuickItemView::setModel()
// (the same window that makes a `.model` readback segfault
// — see views/LibraryView.qml), where contentY has been
// reset but the layout has not been redone.
//
// MEASURED, so nobody re-hunts it: that entry does NOT in
// fact misfire under qml6 6.11.1. contentHeight inside the
// swap is the PREVIOUS model's value, not 0 — checked over
// empty->long, short->long, long->short, long->empty,
// same-length republishes, both with forceLayout and on
// the real async polish path. Every one of them entered
// the handler with (contentY 0, contentHeight stale-LARGE)
// and evaluated FALSE. So this line changes no observed
// behaviour today; it is kept because it is true and free,
// and because the only shape that would misfire is the one
// it covers.
if (contentHeight > height
&& contentY + height >= contentHeight - 600
&& QbzLocal.localTracksHasMore
&& !QbzLocal.localTracksLoadingMore
&& !QbzLocal.localTracksLoading) {
+17 -4
View File
@@ -153,6 +153,11 @@ where
crate::local_plex::init_for_user(&dir);
// Phase 5: local-favorites store (Library show-local + hearts).
crate::library_qt::init_local_favorites(&dir);
// Qobuz favourite-id cache (favorites_cache.db, shared with the Slint
// app): the disk seed is what makes album / artist / track / label
// hearts correct from first paint — before it, every heart read the
// Library feed, which is empty until the Library view is opened.
crate::fav_cache_qt::init_for_user(&dir);
// Phase 7: pinned-items store (AlbumCard pin badges).
crate::sidebar_qt::init_pinned(&dir);
// Phase 15: intelligent-search service (cortinilla cache+ranking).
@@ -259,6 +264,8 @@ where
// this session so the first Local Library read does not have to.
crate::local_plex::init_for_user(&dir);
crate::library_qt::init_local_favorites(&dir);
// Qobuz favourite-id cache — see the fresh-login path.
crate::fav_cache_qt::init_for_user(&dir);
crate::sidebar_qt::init_pinned(&dir);
// Phase 15: intelligent-search service (cortinilla cache+ranking).
crate::search_qt::init(&dir, crate::search_qt::intelligent_search_pref());
@@ -329,6 +336,10 @@ where
crate::local_plex::init_for_user(&dir);
// Phase 5: local-favorites store (Library show-local + hearts).
crate::library_qt::init_local_favorites(&dir);
// Qobuz favourite-id cache. The disk seed is the ONLY source offline
// (no warm can run), which is exactly the gap the reference closes
// here too — see `fav_cache::init_for_user` on the offline entry.
crate::fav_cache_qt::init_for_user(&dir);
// Phase 7: pinned-items store (AlbumCard pin badges).
crate::sidebar_qt::init_pinned(&dir);
// Phase 15: intelligent-search service (cortinilla cache+ranking).
@@ -344,16 +355,18 @@ where
/// the Qobuz client session, and tear down the offline-mode per-user state
/// (which reopens the Qobuz gate so a logged-out user can sign back in).
///
/// POC-NOTE: the Slint logout also tears down the skipped per-user stores
/// (offline cache, fav_cache, reco, artist vectors, discover prefs,
/// blacklist, pinned, local favorites, search, lyrics) — nothing to tear
/// down here since this phase never opens them.
/// POC-NOTE: the Slint logout also tears down the per-user stores this phase
/// never opens (offline cache, reco, artist vectors, discover prefs,
/// blacklist, pinned, local favorites, search, lyrics). The favourite-id
/// cache IS open now, and it holds the previous user's hearts — dropping it
/// is not optional, or the next account inherits them.
pub async fn logout<A>(runtime: &Arc<AppRuntime<A>>) -> Result<(), String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let _ = qbz_credentials::clear_oauth_token();
let _ = runtime.core().logout().await;
crate::fav_cache_qt::teardown();
crate::offline_fwd::teardown();
runtime.deactivate().await?;
log::info!("[qbz-qt] logged out");
+1 -1
View File
@@ -158,7 +158,7 @@ fn mutate(tab_key: &str, f: impl FnOnce(&mut DiscoverPrefs, DiscoveryTab)) {
log::warn!("[qbz-qt] discover config: save failed: {e}");
return;
}
crate::home_qt::republish_from_prefs();
crate::home_qt::republish_cached();
publish(tab_key);
}
File diff suppressed because it is too large. Load diff
+99
View File
@@ -45,6 +45,29 @@ pub mod qbz_home {
// HomeSection shape, ordered by each tab's discover prefs).
#[qproperty(QString, editor_sections_json)]
#[qproperty(QString, for_you_sections_json)]
// The "Pinned" rail's ROWS, on their own property (a bare HomeCard
// array). The three documents above carry only an EMPTY `pinned`
// section, as the ordering slot the discover prefs place — 1:1 with
// the Slint arm, where the descriptor supplies the position and the
// `PinnedState.items` global supplies the rows
// (discover/HomeView.slint:561). Pinning is a per-click mutation, and
// folding the rows into the tab documents made every click republish
// all three: every rail's delegate model torn down and rebuilt, and
// every horizontal scroll reset to 0.
#[qproperty(QString, pinned_json)]
// Discover > For You, the two rails whose rows do NOT travel inside
// the tab documents (src/foryou_qt.rs — same split, same reason as
// `pinned_json` above): the documents carry an EMPTY ordering slot
// (`kind: "radio"` / `"spotlight"`) where the prefs place the rail,
// and these carry the rows. The Spotlight costs one extra
// /artist/page round trip that lands well after the index, and
// folding it into the tab documents would republish — and re-create
// the delegates of — every rail on all three tabs when it did.
#[qproperty(QString, radio_stations_json)]
#[qproperty(QString, spotlight_json)]
// The Qobuz mix landing page (DailyQ / WeeklyQ / FavQ / TopQ), route
// "mix" — opened from the Qobuz Mixes tiles on For You.
#[qproperty(QString, mix_json)]
// Recommendations tab (the 4th) — same HomeSection shape, published
// by src/recommendations_qt.rs. LAZY: nothing is fetched until the
// view calls `loadRecommendations()`, and a row whose service is not
@@ -142,6 +165,34 @@ pub mod qbz_home {
#[qinvokable]
fn label_open_releases(self: Pin<&mut QbzHome>);
// --- Discover > For You: Qobuz Mixes / Radio / Spotlight ----------
/// A Qobuz Mixes tile: open the mix landing page ("daily" | "weekly"
/// | "fav" | "top"). Slint: `media-action("mix", which, "open")`.
#[qinvokable]
fn open_mix(self: Pin<&mut QbzHome>, kind: QString);
#[qinvokable]
fn mix_play_all(self: Pin<&mut QbzHome>);
#[qinvokable]
fn mix_shuffle(self: Pin<&mut QbzHome>);
/// The header's refresh disc — re-fetch the mix on screen.
#[qinvokable]
fn mix_refresh(self: Pin<&mut QbzHome>);
/// A row click / row Play: the mix becomes the queue, anchored there.
#[qinvokable]
fn mix_play_track(self: Pin<&mut QbzHome>, track_id: QString);
/// A row's context menu: "next" | "later" | "queue".
#[qinvokable]
fn mix_enqueue_track(self: Pin<&mut QbzHome>, track_id: QString, mode: QString);
/// Radio Stations tile — Qobuz `/radio/album` off the seed album.
#[qinvokable]
fn start_album_radio(self: Pin<&mut QbzHome>, album_id: QString);
/// Spotlight RADIO card — the smart `qbz-radio` artist pool.
#[qinvokable]
fn start_artist_radio(self: Pin<&mut QbzHome>, artist_id: QString);
/// Spotlight play / TOP TRACKS card — the artist's popular tracks.
#[qinvokable]
fn play_artist_top_tracks(self: Pin<&mut QbzHome>, artist_id: QString);
// --- Label releases sub-view --------------------------------------
#[qinvokable]
fn label_releases_load_more(self: Pin<&mut QbzHome>);
@@ -167,6 +218,10 @@ pub struct QbzHomeRust {
home_sections_json: QString,
editor_sections_json: QString,
for_you_sections_json: QString,
pinned_json: QString,
radio_stations_json: QString,
spotlight_json: QString,
mix_json: QString,
reco_sections_json: QString,
reco_loading: bool,
discover_browse_json: QString,
@@ -191,6 +246,12 @@ impl Default for QbzHomeRust {
home_sections_json: QString::from("[]"),
editor_sections_json: QString::from("[]"),
for_you_sections_json: QString::from("[]"),
pinned_json: QString::from("[]"),
radio_stations_json: QString::from("[]"),
// "{}" so `JSON.parse(...).visible` is a plain `undefined` on the
// first frame instead of throwing (the browse-document rule).
spotlight_json: QString::from("{}"),
mix_json: QString::from("{}"),
reco_sections_json: QString::from("[]"),
reco_loading: false,
// "{}" so the views' JSON.parse never throws on the first frame.
@@ -335,6 +396,44 @@ impl qbz_home::QbzHome {
crate::label_qt::open_releases();
}
// --- Discover > For You: mixes / radio / spotlight ---------------------
pub fn open_mix(self: Pin<&mut Self>, kind: QString) {
crate::foryou_qt::open_mix(kind.to_string());
}
pub fn mix_play_all(self: Pin<&mut Self>) {
crate::foryou_qt::mix_play_all();
}
pub fn mix_shuffle(self: Pin<&mut Self>) {
crate::foryou_qt::mix_shuffle();
}
pub fn mix_refresh(self: Pin<&mut Self>) {
crate::foryou_qt::refresh_mix();
}
pub fn mix_play_track(self: Pin<&mut Self>, track_id: QString) {
crate::foryou_qt::mix_play_track(track_id.to_string());
}
pub fn mix_enqueue_track(self: Pin<&mut Self>, track_id: QString, mode: QString) {
crate::foryou_qt::mix_enqueue_track(track_id.to_string(), mode.to_string());
}
pub fn start_album_radio(self: Pin<&mut Self>, album_id: QString) {
crate::foryou_qt::start_album_radio(album_id.to_string());
}
pub fn start_artist_radio(self: Pin<&mut Self>, artist_id: QString) {
crate::foryou_qt::start_artist_radio(artist_id.to_string());
}
pub fn play_artist_top_tracks(self: Pin<&mut Self>, artist_id: QString) {
crate::foryou_qt::spotlight_play_top_tracks(artist_id.to_string());
}
// --- Label releases ----------------------------------------------------
pub fn label_releases_load_more(self: Pin<&mut Self>) {
+74 -6
View File
@@ -49,6 +49,66 @@ cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
QT_CRATE="crates/qbz-qt"
AUDIT_DIR="$HOME/Personal/qbz/qbz-nix-docs/qt-frontend/tools"
# --- Portability shims (this runs on the Mac mini too) -----------------------
# Everything below that differs between GNU/Linux and BSD/macOS lives here, so
# the body reads the same on both. On macOS the audits also live elsewhere (the
# docs repo is not synced to the build box), so a missing AUDIT_DIR degrades to
# a warning rather than an error — see the audit block.
UNAME_S="$(uname -s)"
# Available RAM in MB. macOS has no `free`; vm_stat's "free + inactive" pages
# are the honest analogue of MemAvailable (inactive is reclaimable).
mem_avail_mb() {
if command -v free >/dev/null 2>&1; then
free -m | awk '/^Mem:/ {print $7}'
elif [[ "${UNAME_S}" == "Darwin" ]]; then
vm_stat | awk '/page size of/ {ps=$8} /Pages free/ {f=$3} /Pages inactive/ {i=$3}
END { gsub(/\./,"",f); gsub(/\./,"",i); if (ps=="") ps=16384;
printf "%d", (f+i)*ps/1048576 }'
else
echo 0
fi
}
cpu_count() {
if command -v nproc >/dev/null 2>&1; then nproc
elif [[ "${UNAME_S}" == "Darwin" ]]; then sysctl -n hw.ncpu
else echo 4; fi
}
# BSD pgrep has no -c and uses -l where GNU uses -a, so count lines instead.
# The `|| true` is load-bearing: pgrep exits 1 when it matches nothing, which is
# the NORMAL case here, and under `set -euo pipefail` that non-zero status
# propagates out of the command substitution and kills the script — silently,
# before a single line of output, looking exactly like a build that did nothing.
# (It did exactly that once. The tell was an empty log with rc=1.)
other_builds() { { pgrep -x rustc; pgrep -x cargo; } 2>/dev/null || true; }
# `timeout` is GNU coreutils and is simply ABSENT on macOS, where the shell
# answers `command not found` — which, inside the smoke block, produced a
# one-line log that then reported "0 QML complaints" and looked like a pass.
# A smoke that never started must never read as green, so this shims it.
run_timeout() {
local secs=$1; shift
if command -v timeout >/dev/null 2>&1; then timeout "${secs}" "$@"; return $?; fi
if command -v gtimeout >/dev/null 2>&1; then gtimeout "${secs}" "$@"; return $?; fi
"$@" & local pid=$!
( sleep "${secs}"; kill "${pid}" 2>/dev/null ) & local watcher=$!
wait "${pid}" 2>/dev/null; local rc=$?
kill "${watcher}" 2>/dev/null || true
return "${rc}"
}
# cxx-qt-build finds Qt through qmake / CMAKE_PREFIX_PATH. Homebrew keeps it
# off PATH by design (qt is keg-only-ish in practice), so a plain shell has no
# qmake and the build dies in build.rs rather than in the compiler. Add it when
# it is there; say nothing when it is not, so Linux is untouched.
if [[ "${UNAME_S}" == "Darwin" ]]; then
for qtdir in /opt/homebrew/opt/qt /usr/local/opt/qt; do
if [[ -x "${qtdir}/bin/qmake" ]]; then
export PATH="${qtdir}/bin:${PATH}"
export CMAKE_PREFIX_PATH="${qtdir}${CMAKE_PREFIX_PATH:+:${CMAKE_PREFIX_PATH}}"
break
fi
done
fi
# --- Pretty helpers ----------------------------------------------------------
if [[ -t 2 ]]; then C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_GRN=$'\033[32m'
C_RED=$'\033[31m'; C_YEL=$'\033[33m'; C_RST=$'\033[0m'
@@ -70,10 +130,9 @@ BIN="crates/target/${PROFILE}/qbz-qt"
# 30 GB box with no hibernation, and the failure mode is a swap-thrash livelock
# that needs a power-cycle — earlyoom does not reliably fire. Cheap to check.
if [[ "${FORCE:-0}" != 1 ]]; then
others=$(pgrep -c -x 'rustc|cargo' 2>/dev/null || true)
others=$(other_builds | wc -l | tr -d ' ')
[[ "${others}" =~ ^[0-9]+$ ]] || others=0
if (( others > 0 )); then
pgrep -a -x 'rustc|cargo' 2>/dev/null | head -5 >&2 || true
die "another cargo/rustc is running (${others}). Builds are serialized box-wide — wait, or FORCE=1 if you know it is harmless."
fi
fi
@@ -103,7 +162,7 @@ if [[ -n "${JOBS:-}" ]]; then
export CARGO_BUILD_JOBS="${JOBS}"
else
# Leave a couple of cores for the desktop; cargo's default is all of them.
ncpu=$(nproc 2>/dev/null || echo 4)
ncpu=$(cpu_count)
export CARGO_BUILD_JOBS="$(( ncpu > 3 ? ncpu - 2 : 1 ))"
fi
@@ -120,7 +179,7 @@ eta_secs=0
[[ -r "${eta_file}" ]] && eta_secs=$(cat "${eta_file}" 2>/dev/null || echo 0)
[[ "${eta_secs}" =~ ^[0-9]+$ ]] || eta_secs=0
avail_mb=$(free -m | awk '/^Mem:/ {print $7}')
avail_mb=$(mem_avail_mb)
say "profile=${PROFILE} jobs=${CARGO_BUILD_JOBS} avail=${avail_mb}MB rustflags='${RUSTFLAGS:-}'"
# --- Start banner ------------------------------------------------------------
@@ -175,9 +234,18 @@ fi
# and prove the QML tree actually resolves. A lazily-resolved type error only
# ever shows up here or in front of the owner.
if [[ "${SMOKE:-0}" == 1 ]]; then
log="$(mktemp -t qbz-qt-smoke-XXXXXX.log)"
# BSD mktemp -t takes a bare prefix, GNU takes a template; give both a full
# template path so the two agree.
log="$(mktemp "${TMPDIR:-/tmp}/qbz-qt-smoke-XXXXXX")"
say "offscreen smoke (75s max) → ${log}"
QT_QPA_PLATFORM=offscreen RUST_LOG=info timeout 75 "./${BIN}" > "${log}" 2>&1 || true
QT_QPA_PLATFORM=offscreen RUST_LOG=info run_timeout 75 "./${BIN}" > "${log}" 2>&1 || true
# A log this short means the process never really started (the macOS
# `timeout`-not-found case did exactly that). Zero complaints in an empty log
# is not a pass.
if (( $(wc -l < "${log}") < 10 )); then
cat "${log}" >&2
die "smoke produced almost no output — the app did not start; see ${log}"
fi
# `propertyCache` from SettingsButton is known-benign noise — filter it out
# rather than let it mask a real count.
errs=$(grep -av 'propertyCache' "${log}" \