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:
24 files changed
+2656
-202
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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 || "") === ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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}" \
|
||||
|
||||
Reference in new issue
Block a user