diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 0b3b7038d..6e36ba3b7 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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", diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index 9e92a2cc0..55e72b380 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.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 diff --git a/crates/qbz-qt/qml/cards/ArtistCard.qml b/crates/qbz-qt/qml/cards/ArtistCard.qml index 13dcac38e..190fbb062 100644 --- a/crates/qbz-qt/qml/cards/ArtistCard.qml +++ b/crates/qbz-qt/qml/cards/ArtistCard.qml @@ -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. } } diff --git a/crates/qbz-qt/qml/cards/MixArtwork.qml b/crates/qbz-qt/qml/cards/MixArtwork.qml new file mode 100644 index 000000000..7f68e4aaa --- /dev/null +++ b/crates/qbz-qt/qml/cards/MixArtwork.qml @@ -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() + } + } +} diff --git a/crates/qbz-qt/qml/cards/RadioCard.qml b/crates/qbz-qt/qml/cards/RadioCard.qml new file mode 100644 index 000000000..a145a12f2 --- /dev/null +++ b/crates/qbz-qt/qml/cards/RadioCard.qml @@ -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 + } + } +} diff --git a/crates/qbz-qt/qml/cards/TrackCard.qml b/crates/qbz-qt/qml/cards/TrackCard.qml index 798fbb828..091d77b8e 100644 --- a/crates/qbz-qt/qml/cards/TrackCard.qml +++ b/crates/qbz-qt/qml/cards/TrackCard.qml @@ -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() } } diff --git a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml index 9434f4c63..3a2a46aef 100644 --- a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml +++ b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml @@ -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 diff --git a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml index 04a4b9333..9a1c01add 100644 --- a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml +++ b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml @@ -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) diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index ffeda8e27..822a9cf4f 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -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 diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index 52f007d2e..fb17f182a 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -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) } } } diff --git a/crates/qbz-qt/qml/views/AlbumCollection.qml b/crates/qbz-qt/qml/views/AlbumCollection.qml index 4dd3634de..c40e46404 100644 --- a/crates/qbz-qt/qml/views/AlbumCollection.qml +++ b/crates/qbz-qt/qml/views/AlbumCollection.qml @@ -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 { diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index c96e0a36a..61925599a 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -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,15 +159,29 @@ 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 || [] - for (var i = 0; i < items.length; i++) { - if ((items[i].artUrl || "") !== "" && (items[i].artPath || "") === "") - return true - } + 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 } @@ -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" } + } + } + + // 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 + } + } } - 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" + } + } + + // 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 } - MixTile { - badge: "qbz"; mixName: "FavQ" - desc: QbzSession.tr("A fresh shuffle from your personal library.", QbzSession.trRev) - c0: "#7f1d1d"; c1: "#ef4444"; c2: "#fb923c" + Text { + text: QbzSession.tr("Shine a light on one of your favourite artists.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal } - MixTile { - badge: "qbz"; mixName: "TopQ" - desc: QbzSession.tr("Discover new music from your most-played playlists.", QbzSession.trRev) - c0: "#1f2937"; c1: "#4b5563"; c2: "#fbbf24" + } + + // 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 + } + } } } } @@ -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 { diff --git a/crates/qbz-qt/qml/views/LocalAlbumView.qml b/crates/qbz-qt/qml/views/LocalAlbumView.qml index 8e78155a2..1ac6f7b0c 100644 --- a/crates/qbz-qt/qml/views/LocalAlbumView.qml +++ b/crates/qbz-qt/qml/views/LocalAlbumView.qml @@ -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 ---- diff --git a/crates/qbz-qt/qml/views/MixView.qml b/crates/qbz-qt/qml/views/MixView.qml new file mode 100644 index 000000000..7a0b2649b --- /dev/null +++ b/crates/qbz-qt/qml/views/MixView.qml @@ -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 + } +} diff --git a/crates/qbz-qt/qml/views/PlaylistBrowseView.qml b/crates/qbz-qt/qml/views/PlaylistBrowseView.qml index 6e3b41859..64f618bc7 100644 --- a/crates/qbz-qt/qml/views/PlaylistBrowseView.qml +++ b/crates/qbz-qt/qml/views/PlaylistBrowseView.qml @@ -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 } } } diff --git a/crates/qbz-qt/qml/views/PlaylistListRow.qml b/crates/qbz-qt/qml/views/PlaylistListRow.qml index 74f0657b9..ca02fd001 100644 --- a/crates/qbz-qt/qml/views/PlaylistListRow.qml +++ b/crates/qbz-qt/qml/views/PlaylistListRow.qml @@ -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 || "") === "" diff --git a/crates/qbz-qt/qml/views/SearchView.qml b/crates/qbz-qt/qml/views/SearchView.qml index c65203ee2..4d83e748e 100644 --- a/crates/qbz-qt/qml/views/SearchView.qml +++ b/crates/qbz-qt/qml/views/SearchView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/SectionRail.qml b/crates/qbz-qt/qml/views/SectionRail.qml index 4ac7d28fa..3e26cd2e9 100644 --- a/crates/qbz-qt/qml/views/SectionRail.qml +++ b/crates/qbz-qt/qml/views/SectionRail.qml @@ -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 { diff --git a/crates/qbz-qt/qml/views/local/LocalTracksTab.qml b/crates/qbz-qt/qml/views/local/LocalTracksTab.qml index 3cbf5945b..ba0da5693 100644 --- a/crates/qbz-qt/qml/views/local/LocalTracksTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalTracksTab.qml @@ -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) { diff --git a/crates/qbz-qt/src/auth_qt.rs b/crates/qbz-qt/src/auth_qt.rs index d5c5addbd..326c17b80 100644 --- a/crates/qbz-qt/src/auth_qt.rs +++ b/crates/qbz-qt/src/auth_qt.rs @@ -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(runtime: &Arc>) -> 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"); diff --git a/crates/qbz-qt/src/discover_config_qt.rs b/crates/qbz-qt/src/discover_config_qt.rs index 809efe768..02345cf49 100644 --- a/crates/qbz-qt/src/discover_config_qt.rs +++ b/crates/qbz-qt/src/discover_config_qt.rs @@ -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); } diff --git a/crates/qbz-qt/src/foryou_qt.rs b/crates/qbz-qt/src/foryou_qt.rs new file mode 100644 index 000000000..b66bbf3ed --- /dev/null +++ b/crates/qbz-qt/src/foryou_qt.rs @@ -0,0 +1,1112 @@ +//! Discover > For You — the three rails that are NOT plain album carousels, +//! plus the Qobuz mix landing page they lead to. +//! +//! Ported from the Slint build, which spreads the same feature over four +//! files: +//! - `discover/QobuzMixesRow.slint` — the four static navigation tiles +//! (already in `HomeView.qml`; this module supplies what they OPEN). +//! - `discover/RadioCarousel.slint` + `RadioCard.slint` — album-seeded +//! radio tiles, built by `foryou.rs::build_radio`. +//! - `discover/Spotlight.slint` — one favourite artist, built by +//! `foryou.rs::load_spotlight`. +//! - `mix/MixView.slint` + `crates/qbz/src/mix.rs` — the DailyQ / WeeklyQ / +//! FavQ / TopQ detail page. +//! +//! ## Transport +//! +//! Radio + Spotlight ride their OWN bridge properties (`radioStationsJson` / +//! `spotlightJson`), NOT the three tab documents — the `pinned` precedent +//! (`home_qt::publish_pinned`). The tab documents carry an EMPTY ordering +//! slot for each (`kind: "radio"` / `"spotlight"`), which is where the +//! discover prefs place the rail; the rows arrive separately so the Spotlight +//! landing (one extra `/artist/page` round trip) never republishes — and so +//! never re-creates the delegates of — every other rail on all three tabs. +//! HomeView self-hides both slots while their document is empty, 1:1 with the +//! Slint `ForYouState.radio-stations.length > 0` / `spotlight-visible` gates. +//! +//! The mix page is ONE document (`mixJson`), the `label_qt` shape: header +//! fields + `tracks[]` in the `qml/rows/TrackRow.qml` item contract, with a +//! generation counter so a stale fetch cannot overwrite a newer page. +//! +//! ## Shared crates reused (no new client code) +//! +//! | need | shared call | +//! |---|---| +//! | DailyQ / WeeklyQ | `core().get_dynamic_suggest_full()` + `get_dynamic_suggest()` fallback | +//! | seed resolution | `core().get_track()` (the `track_to_analysed` payload) | +//! | FavQ | `core().get_favorites("tracks", 200, 0)` | +//! | TopQ | `core().get_user_playlists()` + `get_playlist()` | +//! | Spotlight | `core().get_artist_page()` (+ `artist_qt::map_release` shape) | +//! | album radio | `core().get_radio_album()` + `get_tracks_batch()` enrich | +//! | artist radio | `core().create_smart_artist_radio()` (the `qbz-radio` pool builder) | +//! | artist top tracks| `playback_qt::play_artist()` | +//! +//! ## POC-NOTEs (deliberate deltas vs the Slint build) +//! - `mix_listened_seed_ids` prefers the reco store's scored seeds in Slint +//! (`crate::reco::home_seeds`) and falls back to recents + favourites when +//! the store is cold. This port has no `qbz-reco` dependency, so it is the +//! FALLBACK path only — the same delta `home_qt` already documents for +//! "Rediscover Your Library". +//! - No artist blacklist store in this port (`artist_blacklist` is Slint-side), +//! so the mix rows carry no `isBlacklisted` grey-out. +//! - The mix page has no multi-select bar: `qml/rows/TrackRow.qml` has no +//! multi-select arm and two of the Slint bar's seven actions have no bridge +//! seam at all — the same call `LabelView.qml` already documents. The +//! Slint header's select disc renders DIMMED and inert here (the ArtistView +//! / LabelView precedent), and "Add to playlist" (`list-plus`) is likewise +//! inert, exactly as in the Slint header where it carries no `clicked`. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +use cxx_qt_lib::QString; +use qbz_app::shell::AppRuntime; +use qbz_core::{FrontendAdapter, LoggingAdapter}; +use qbz_models::{Album, Artist, QueueTrack, Track, TrackToAnalyse}; +use serde::Serialize; +use serde_json::Value; + +use crate::album_qt::TrackRow; +use crate::home_qt::{attach_card_art, HomeCard}; + +// =========================================================================== +// Radio Stations rail (RadioCarousel.slint + foryou.rs build_radio) +// =========================================================================== + +/// One album-seeded radio tile (`state.slint` `RadioStationItem`). +#[derive(Clone, Default, Serialize)] +pub struct RadioSeedRow { + #[serde(rename = "albumId")] + pub album_id: String, + pub title: String, + pub artist: String, + #[serde(rename = "artUrl")] + pub art_url: String, + #[serde(rename = "artPath")] + pub art_path: String, +} + +static RADIO: Mutex> = Mutex::new(Vec::new()); + +/// Radio Stations — album-seeded tiles from recent + favourite albums, +/// deduped, capped at 12 (`foryou.rs::build_radio`, verbatim including the +/// Qobuz-source gate: `/radio/album` cannot resolve a local or Plex album id, +/// and an empty source is a legacy entry treated as Qobuz). +pub(crate) fn build_radio( + recent_albums: &[crate::recently_qt::RecentAlbum], + fav_albums: &[Album], +) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut out: Vec = Vec::new(); + for a in recent_albums { + if !(a.source.is_empty() || a.source.eq_ignore_ascii_case("qobuz")) { + continue; + } + if seen.insert(a.id.clone()) { + out.push(RadioSeedRow { + album_id: a.id.clone(), + title: a.title.clone(), + artist: a.artist.clone(), + art_url: a.artwork_url.clone(), + art_path: String::new(), + }); + } + } + for a in fav_albums { + if out.len() >= 12 { + break; + } + if seen.insert(a.id.clone()) { + out.push(RadioSeedRow { + album_id: a.id.clone(), + title: a.title.clone(), + artist: a.artist.name.clone(), + art_url: a.image.best().cloned().unwrap_or_default(), + art_path: String::new(), + }); + } + } + out.truncate(12); + out +} + +/// Store + publish the radio rail, then fetch the covers it is missing and +/// republish (the `label_qt` two-pass artwork shape). +pub(crate) fn publish_radio(rows: Vec) { + let missing = { + let Ok(mut store) = RADIO.lock() else { + return; + }; + *store = rows; + attach_radio_art(&mut store) + }; + push_radio(); + if missing.is_empty() { + return; + } + crate::spawn(async move { + crate::artwork_qt::download_missing(missing).await; + if let Ok(mut store) = RADIO.lock() { + let _ = attach_radio_art(&mut store); + } + push_radio(); + }); +} + +fn attach_radio_art(rows: &mut [RadioSeedRow]) -> Vec { + let mut missing = Vec::new(); + for row in rows.iter_mut() { + if row.art_url.is_empty() { + continue; + } + let hit = crate::artwork_qt::cached_path(&row.art_url); + if hit.is_empty() { + missing.push(row.art_url.clone()); + } else { + row.art_path = hit; + } + } + missing.sort(); + missing.dedup(); + missing +} + +fn push_radio() { + let json = RADIO + .lock() + .ok() + .map(|rows| serde_json::to_string(&*rows).unwrap_or_else(|_| "[]".to_string())) + .unwrap_or_else(|| "[]".to_string()); + crate::home_bridge::ui(move |mut b| { + b.as_mut() + .set_radio_stations_json(QString::from(json.as_str())); + }); +} + +// =========================================================================== +// Spotlight (Spotlight.slint + foryou.rs load_spotlight) +// =========================================================================== + +#[derive(Clone, Default, Serialize)] +pub struct SpotlightDoc { + pub visible: bool, + #[serde(rename = "artistId")] + pub artist_id: String, + pub name: String, + pub category: String, + #[serde(rename = "artUrl")] + pub art_url: String, + #[serde(rename = "artPath")] + pub art_path: String, + #[serde(rename = "hasTopTracks")] + pub has_top_tracks: bool, + pub albums: Vec, +} + +static SPOTLIGHT: Mutex> = Mutex::new(None); + +/// One favourite artist highlighted (`foryou.rs::load_spotlight`): rotate +/// among the top 5 favourites by wall-clock seconds, fetch that artist's +/// page, take up to 6 releases preferring `album` then `live` / `ep-single` / +/// `compilation` in that order. +pub(crate) fn spawn_spotlight(runtime: Arc>, favorites: Vec) +where + A: FrontendAdapter + Send + Sync + 'static, +{ + crate::spawn(async move { + let doc = load_spotlight(&runtime, &favorites).await; + publish_spotlight(doc); + }); +} + +async fn load_spotlight(runtime: &Arc>, favorites: &[Artist]) -> Option +where + A: FrontendAdapter + Send + Sync + 'static, +{ + if favorites.is_empty() { + return None; + } + let pool = favorites.len().min(5); + let idx = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as usize % pool) + .unwrap_or(0); + let seed = &favorites[idx]; + + let page = match runtime.core().get_artist_page(seed.id, None).await { + Ok(page) => page, + Err(e) => { + log::warn!("[qbz-qt] spotlight artist page {} failed: {e}", seed.id); + return None; + } + }; + let art_url = page + .images + .as_ref() + .and_then(|i| i.portrait.as_ref()) + .map(|p| { + format!( + "https://static.qobuz.com/images/artists/covers/medium/{}.{}", + p.hash, p.format + ) + }) + .unwrap_or_default(); + + let mut seen: HashSet = HashSet::new(); + let mut albums: Vec = Vec::new(); + for want in ["album", "live", "ep-single", "compilation"] { + if albums.len() >= 6 { + break; + } + let Some(groups) = page.releases.as_ref() else { + break; + }; + let Some(group) = groups.iter().find(|g| g.release_type == want) else { + continue; + }; + for rel in &group.items { + if !seen.insert(rel.id.clone()) { + continue; + } + // `artist_qt::map_release` is the shared artist-page release + // mapper (rule 5: reuse over a second copy); only the two fields + // the rail transport adds on top are filled in here. + let card = crate::artist_qt::map_release(rel); + albums.push(HomeCard { + is_pinned: card.is_pinned, + is_favorite: card.is_favorite, + id: card.id, + title: card.title, + artist: if card.artist.is_empty() { + page.name.display.clone() + } else { + card.artist + }, + artist_id: card.artist_id, + genre: card.genre, + year: card.year, + quality_tier: card.quality_tier, + quality_detail: card.quality_detail, + art_url: card.art_url, + ..HomeCard::default() + }); + if albums.len() >= 6 { + break; + } + } + } + + Some(SpotlightDoc { + visible: true, + artist_id: seed.id.to_string(), + name: page.name.display.clone(), + category: page.artist_category.clone().unwrap_or_default(), + art_url, + art_path: String::new(), + has_top_tracks: page.top_tracks.as_ref().map(|t| !t.is_empty()).unwrap_or(false), + albums, + }) +} + +fn publish_spotlight(doc: Option) { + let missing = { + let Ok(mut store) = SPOTLIGHT.lock() else { + return; + }; + *store = doc; + store.as_mut().map(attach_spotlight_art).unwrap_or_default() + }; + push_spotlight(); + if missing.is_empty() { + return; + } + crate::spawn(async move { + crate::artwork_qt::download_missing(missing).await; + if let Ok(mut store) = SPOTLIGHT.lock() { + if let Some(doc) = store.as_mut() { + let _ = attach_spotlight_art(doc); + } + } + push_spotlight(); + }); +} + +/// THE RULE (label_qt's `attach_doc_art`): every art-bearing list on the +/// document has to be listed here or its tiles stay blank forever. +fn attach_spotlight_art(doc: &mut SpotlightDoc) -> Vec { + let mut missing = attach_card_art(&mut doc.albums); + if !doc.art_url.is_empty() { + let hit = crate::artwork_qt::cached_path(&doc.art_url); + if hit.is_empty() { + missing.push(doc.art_url.clone()); + } else { + doc.art_path = hit; + } + } + missing.sort(); + missing.dedup(); + missing +} + +fn push_spotlight() { + let json = SPOTLIGHT + .lock() + .ok() + .and_then(|doc| doc.as_ref().and_then(|d| serde_json::to_string(d).ok())) + .unwrap_or_else(|| "{}".to_string()); + crate::home_bridge::ui(move |mut b| { + b.as_mut().set_spotlight_json(QString::from(json.as_str())); + }); +} + +/// Spotlight "play top tracks" (the primary circle button and the TOP TRACKS +/// card) — `media-action("artist", id, "play-top")` in Slint, which resolves +/// to `playback::play_artist_top_tracks`. `playback_qt::play_artist` is the +/// port of that path (top tracks, studio discography as the fallback). +pub(crate) fn spotlight_play_top_tracks(artist_id: String) { + let runtime = crate::app(); + crate::spawn(async move { + if let Err(e) = crate::playback_qt::play_artist(&runtime, &artist_id).await { + log::warn!("[qbz-qt] spotlight top tracks {artist_id} failed: {e}"); + } + }); +} + +/// Spotlight RADIO card — `media-action("artist", id, "radio")`, i.e. the +/// SMART pool builder (`qbz-radio`), not `/radio/artist`. +pub(crate) fn start_artist_radio(artist_id: String) { + let runtime = crate::app(); + crate::spawn(async move { + let Ok(id) = artist_id.parse::() else { + log::warn!("[qbz-qt] smart radio: bad artist id {artist_id}"); + return; + }; + match runtime.core().create_smart_artist_radio(id).await { + Ok(tracks) if !tracks.is_empty() => play_flat(&runtime, tracks, 0, false).await, + Ok(_) => log::warn!("[qbz-qt] smart artist radio {id} returned no tracks"), + Err(e) => log::warn!("[qbz-qt] smart artist radio {id} failed: {e}"), + } + }); +} + +/// Radio Stations tile — `media-action("album", id, "radio")`, the Qobuz +/// `/radio/album` endpoint. Its track objects are MINIMAL (no performer, no +/// album), so they are re-fetched by id exactly like `playback.rs:: +/// enrich_radio_tracks` or the queue rows would show no artist. +pub(crate) fn start_album_radio(album_id: String) { + let runtime = crate::app(); + crate::spawn(async move { + match runtime.core().get_radio_album(&album_id).await { + Ok(resp) => { + let tracks = enrich_radio_tracks(&runtime, resp.tracks.items).await; + if tracks.is_empty() { + log::warn!("[qbz-qt] album radio {album_id} returned no tracks"); + return; + } + play_flat(&runtime, tracks, 0, false).await; + } + Err(e) => log::warn!("[qbz-qt] album radio {album_id} failed: {e}"), + } + }); +} + +async fn enrich_radio_tracks( + runtime: &Arc>, + tracks: Vec, +) -> Vec { + let ids: Vec = tracks.iter().map(|t| t.id).collect(); + if ids.is_empty() { + return tracks; + } + match runtime.core().get_tracks_batch(&ids).await { + Ok(full) if !full.is_empty() => full, + _ => tracks, + } +} + +// =========================================================================== +// Qobuz mix landing page (mix.rs + MixView.slint) +// =========================================================================== + +/// Title + description per mix kind (`mix.rs::mix_meta`). NOTE these are NOT +/// the four tile descriptions in `QobuzMixesRow.slint` — the Slint build uses +/// a different string for `weekly` and `top` on the detail page, and this is a +/// 1:1 port of the detail page's. +pub(crate) fn mix_meta(kind: &str) -> (&'static str, String) { + match kind { + "daily" => ( + "DailyQ", + qbz_i18n::t("Elevate your day with a customized selection of music."), + ), + "weekly" => ("WeeklyQ", qbz_i18n::t("A fresh mix every week.")), + "fav" => ( + "FavQ", + qbz_i18n::t("A fresh shuffle from your personal library."), + ), + "top" => ("TopQ", qbz_i18n::t("From your most-played playlists.")), + _ => ("Mix", String::new()), + } +} + +#[derive(Clone, Default, Serialize)] +pub struct MixDoc { + /// "daily" | "weekly" | "fav" | "top" — the QML picks the header gradient + /// off this, matching the tile it was opened from. + pub kind: String, + pub title: String, + pub subtitle: String, + #[serde(rename = "trackCount")] + pub track_count: i32, + #[serde(rename = "totalDuration")] + pub total_duration: String, + pub loading: bool, + /// `qml/rows/TrackRow.qml` item objects (album_qt::TrackRow + artPath). + pub tracks: Vec, +} + +#[derive(Default)] +struct MixState { + generation: u64, + doc: MixDoc, + /// The resolved tracks, kept so play-all / shuffle / play-from-here build + /// the queue without re-fetching (`mix.rs::CURRENT_MIX`). + queue: Vec, +} + +static MIX: Mutex> = Mutex::new(None); + +fn with_mix(f: impl FnOnce(&mut MixState) -> R) -> Option { + let mut guard = MIX.lock().ok()?; + Some(f(guard.get_or_insert_with(MixState::default))) +} + +fn publish_mix() { + let json = MIX + .lock() + .ok() + .map(|s| match s.as_ref() { + Some(state) => serde_json::to_string(&state.doc).unwrap_or_else(|_| "{}".to_string()), + None => "{}".to_string(), + }) + .unwrap_or_else(|| "{}".to_string()); + crate::home_bridge::ui(move |mut b| { + b.as_mut().set_mix_json(QString::from(json.as_str())); + }); +} + +/// Open a Qobuz mix detail page. Same order as `label_qt::open_label`: +/// offline gate -> `nav_qt::record` (which already publishes `currentView`) +/// -> reset the document + raise `loading` -> fetch. +/// +/// The RESET is the `mix.rs::reset_mix` hop: the title / subtitle / gradient +/// are known from the kind alone, so the page paints its header immediately +/// and only the list is pending — opening WeeklyQ after DailyQ never shows +/// DailyQ's header. +pub(crate) fn open_mix(kind: String) { + if crate::offline_fwd::engine().status().is_offline() { + return; + } + let kind = match kind.as_str() { + "daily" | "weekly" | "fav" | "top" => kind, + other => { + log::warn!("[qbz-qt] open_mix: unknown mix kind {other}"); + return; + } + }; + crate::nav_qt::record("mix"); + let (title, subtitle) = mix_meta(&kind); + let generation = with_mix(|s| { + s.generation = s.generation.wrapping_add(1); + s.doc = MixDoc { + kind: kind.clone(), + title: title.to_string(), + subtitle, + loading: true, + ..MixDoc::default() + }; + s.queue.clear(); + s.generation + }); + let Some(generation) = generation else { + return; + }; + publish_mix(); + fetch_mix(generation, kind); +} + +/// The header's refresh disc — re-load the mix that is on screen. +pub(crate) fn refresh_mix() { + let kind = MIX + .lock() + .ok() + .and_then(|s| s.as_ref().map(|state| state.doc.kind.clone())) + .unwrap_or_default(); + if kind.is_empty() { + return; + } + let generation = with_mix(|s| { + s.generation = s.generation.wrapping_add(1); + s.doc.loading = true; + s.doc.tracks.clear(); + s.doc.track_count = 0; + s.doc.total_duration = String::new(); + s.queue.clear(); + s.generation + }); + let Some(generation) = generation else { + return; + }; + publish_mix(); + fetch_mix(generation, kind); +} + +fn fetch_mix(generation: u64, kind: String) { + let runtime = crate::app(); + crate::spawn(async move { + let tracks = load_mix(&runtime, &kind).await; + let rows: Vec = tracks.iter().map(to_row).collect(); + let queue: Vec = tracks.iter().map(to_queue_track).collect(); + let count = rows.len() as i32; + let duration = total_duration(&tracks); + + let stale = with_mix(|s| { + if s.generation != generation { + return true; + } + s.doc.tracks = rows.iter().map(track_row_value).collect(); + s.doc.track_count = count; + s.doc.total_duration = duration; + s.doc.loading = false; + s.queue = queue; + false + }) + .unwrap_or(true); + if stale { + return; + } + let missing = with_mix(attach_mix_art).unwrap_or_default(); + publish_mix(); + if missing.is_empty() { + return; + } + crate::artwork_qt::download_missing(missing).await; + let stale = with_mix(|s| { + if s.generation != generation { + return true; + } + let _ = attach_mix_art(s); + false + }) + .unwrap_or(true); + if !stale { + publish_mix(); + } + }); +} + +fn attach_mix_art(state: &mut MixState) -> Vec { + let mut missing = Vec::new(); + for row in state.doc.tracks.iter_mut() { + let url = row + .get("artUrl") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + if url.is_empty() { + continue; + } + let hit = crate::artwork_qt::cached_path(&url); + if hit.is_empty() { + missing.push(url); + } else if let Some(obj) = row.as_object_mut() { + obj.insert("artPath".to_string(), Value::String(hit)); + } + } + missing.sort(); + missing.dedup(); + missing +} + +/// `TrackRow` as a `qml/rows/TrackRow.qml` item object, with the (initially +/// empty) `artPath` slot the 36px art cell reads (`label_qt::track_row_value`). +fn track_row_value(row: &TrackRow) -> Value { + let mut value = serde_json::to_value(row).unwrap_or_else(|_| Value::Object(Default::default())); + if let Some(obj) = value.as_object_mut() { + obj.insert("artPath".to_string(), Value::String(String::new())); + } + value +} + +// --- mix track sourcing (mix.rs, verbatim minus the reco seeds) ------------ + +/// Even-spread sample of up to `n` ids (`mix.rs::pick_spread` / Tauri's +/// pickSpread): stride so the analysis seeds are not all clustered. +fn pick_spread(ids: &[u64], n: usize) -> Vec { + if ids.len() <= n { + return ids.to_vec(); + } + (0..n).map(|i| ids[i * ids.len() / n]).collect() +} + +/// The DailyQ / WeeklyQ listened-track seed: recent QOBUZ plays + Qobuz +/// favourites, deduped, capped at 120. Local / Plex / ephemeral recents carry +/// non-Qobuz ids and are excluded; a recents-only seed is frequently empty for +/// local-heavy users, so favourites guarantee a non-empty seed. +async fn mix_listened_seed_ids(runtime: &Arc>) -> Vec +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let mut seeds: Vec = crate::recently_qt::load_tracks() + .into_iter() + .filter(|t| !matches!(t.source.as_str(), "local" | "plex" | "ephemeral")) + .filter_map(|t| t.id.parse::().ok()) + .collect(); + let mut seen: HashSet = seeds.iter().copied().collect(); + for fav in favorite_tracks(runtime).await { + if seen.insert(fav.id) { + seeds.push(fav.id); + } + } + seeds.truncate(120); + seeds +} + +/// Resolve up to 9 spread seeds into the `track_to_analysed` payload (the +/// PRIMARY DailyQ / WeeklyQ path, Tauri `buildSeeds`): `get_track` each, +/// extract `{track_id, artist_id, genre_id, label_id}` (artist = performer, +/// else composer; missing ids default to 0), drop any with `artist_id == 0`. +async fn build_tracks_to_analyse(runtime: &Arc>, seeds: &[u64]) -> Vec +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let mut analysed = Vec::new(); + for id in pick_spread(seeds, 9) { + let Ok(track) = runtime.core().get_track(id).await else { + continue; + }; + let artist_id = track + .performer + .as_ref() + .map(|a| a.id) + .or_else(|| track.composer.as_ref().map(|a| a.id)) + .unwrap_or(0); + if artist_id == 0 { + continue; + } + analysed.push(TrackToAnalyse { + track_id: track.id, + artist_id, + genre_id: track + .album + .as_ref() + .and_then(|a| a.genre.as_ref()) + .map(|g| g.id) + .unwrap_or(0), + label_id: track + .album + .as_ref() + .and_then(|a| a.label.as_ref()) + .map(|l| l.id) + .unwrap_or(0), + }); + } + analysed +} + +async fn load_mix(runtime: &Arc>, kind: &str) -> Vec +where + A: FrontendAdapter + Send + Sync + 'static, +{ + match kind { + "daily" | "weekly" => { + let seeds = mix_listened_seed_ids(runtime).await; + if seeds.is_empty() { + log::warn!( + "[qbz-qt] mix '{kind}': no Qobuz seed tracks (recents + favorites empty) — empty mix" + ); + return Vec::new(); + } + let analysed = build_tracks_to_analyse(runtime, &seeds).await; + let limit = (50usize.saturating_sub(analysed.len())).max(1) as u32; + let tracks = match runtime + .core() + .get_dynamic_suggest_full(&seeds, &analysed, limit) + .await + { + Ok(tracks) if !tracks.is_empty() => tracks, + // FALLBACK (Tauri): retry with empty analysis + limit 50. + Ok(_) => runtime + .core() + .get_dynamic_suggest(&seeds, 50) + .await + .unwrap_or_default(), + Err(e) => { + log::warn!("[qbz-qt] mix '{kind}': dynamic/suggest failed: {e}"); + Vec::new() + } + }; + log::info!( + "[qbz-qt] mix '{kind}': {} seeds, {} analysed -> {} tracks", + seeds.len(), + analysed.len(), + tracks.len() + ); + tracks + } + "fav" => { + let mut tracks = favorite_tracks(runtime).await; + shuffle(&mut tracks); + tracks + } + "top" => playlist_tracks(runtime).await, + _ => Vec::new(), + } +} + +async fn favorite_tracks(runtime: &Arc>) -> Vec +where + A: FrontendAdapter + Send + Sync + 'static, +{ + match runtime.core().get_favorites("tracks", 200, 0).await { + Ok(value) => qbz_models::lenient::parse_items_array(&value, "tracks", "mix favorite track"), + Err(e) => { + log::warn!("[qbz-qt] mix favorite tracks failed: {e}"); + Vec::new() + } + } +} + +async fn playlist_tracks(runtime: &Arc>) -> Vec +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let Ok(playlists) = runtime.core().get_user_playlists().await else { + return Vec::new(); + }; + let mut out: Vec = Vec::new(); + for pl in playlists.into_iter().take(5) { + if out.len() >= 100 { + break; + } + if let Ok(full) = runtime.core().get_playlist(pl.id).await { + if let Some(container) = full.tracks { + out.extend(container.items); + } + } + } + out.truncate(100); + out +} + +/// Lightweight, deterministic-per-call shuffle (no rng dep) — `mix.rs`. +fn shuffle(tracks: &mut [Track]) { + let mut seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(1) + | 1; + let n = tracks.len(); + for i in (1..n).rev() { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let j = (seed % (i as u64 + 1)) as usize; + tracks.swap(i, j); + } +} + +fn mmss(secs: u32) -> String { + format!("{}:{:02}", secs / 60, secs % 60) +} + +/// Human total duration: "1 h 23 min" or "23 min" (`mix.rs::total_duration`). +fn total_duration(tracks: &[Track]) -> String { + let secs: u64 = tracks.iter().map(|t| t.duration as u64).sum(); + let mins = secs / 60; + if mins >= 60 { + let h = (mins / 60).to_string(); + let m = (mins % 60).to_string(); + qbz_i18n::t_args("{} h {} min", &[&h, &m]) + } else { + qbz_i18n::t_args("{} min", &[&mins.to_string()]) + } +} + +/// `Track` -> the shared `album_qt::TrackRow` (rule 5: the row contract +/// `qml/rows/TrackRow.qml` already reads — the SAME struct the album, artist +/// and label pages serialize, which is the one that carries `album` for the +/// 220px Album column `showAlbum: true` draws). +/// +/// `number` stays empty (the .slint mix rows carry none either — the index +/// comes from the delegate) and the work-grouping fields are album-detail +/// only, so they stay empty exactly as they do on the label page. +fn to_row(track: &Track) -> TrackRow { + let mut title = track.title.clone(); + if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) { + title = format!("{title} ({v})"); + } + let bit_depth = track.maximum_bit_depth; + let sample_rate = track.maximum_sampling_rate; + TrackRow { + id: track.id.to_string(), + title, + artist: track + .performer + .as_ref() + .map(|p| p.name.clone()) + .unwrap_or_default(), + artist_id: track + .performer + .as_ref() + .map(|p| p.id.to_string()) + .unwrap_or_default(), + album: track + .album + .as_ref() + .map(|a| a.title.clone()) + .unwrap_or_default(), + album_id: track + .album + .as_ref() + .map(|a| a.id.clone()) + .unwrap_or_default(), + duration: mmss(track.duration), + quality_tier: crate::home_qt::quality_tier_from_depth(bit_depth).to_string(), + quality_detail: crate::home_qt::quality_detail_from_parts(bit_depth, sample_rate), + explicit: track.parental_warning, + artwork_url: track + .album + .as_ref() + .and_then(|a| a.image.best().cloned()) + .unwrap_or_default(), + is_favorite: crate::fav_cache_qt::is_favorite("track", &track.id.to_string()), + ..TrackRow::default() + } +} + +/// `Track` -> `QueueTrack` (`playback_qt::catalog_queue_track`'s shape). The +/// mix is a FLAT list, so no container context is stamped — the now-playing +/// "playing from" falls back to each track's own album, 1:1 with the Slint +/// `play_tracks_ctx(.., None)` radio/mix path. +fn to_queue_track(track: &Track) -> QueueTrack { + let (album_id, album_title, album_artwork) = match track.album.as_ref() { + Some(album) => ( + album.id.clone(), + album.title.clone(), + album.image.best().cloned().unwrap_or_default(), + ), + None => (String::new(), String::new(), String::new()), + }; + let album_key = if album_id.is_empty() { + None + } else { + Some(album_id) + }; + QueueTrack { + id: track.id, + title: track.title.clone(), + version: track.version.clone(), + artist: track + .performer + .as_ref() + .map(|p| p.name.clone()) + .unwrap_or_default(), + album: album_title, + album_version: None, + duration_secs: track.duration as u64, + artwork_url: if album_artwork.is_empty() { + None + } else { + Some(album_artwork) + }, + hires: track.hires, + bit_depth: track.maximum_bit_depth, + sample_rate: track.maximum_sampling_rate, + is_local: false, + album_id: album_key.clone(), + artist_id: track.performer.as_ref().map(|p| p.id), + streamable: track.streamable, + source: Some("qobuz".to_string()), + parental_warning: track.parental_warning, + source_item_id_hint: album_key, + context_kind: None, + context_id: None, + } +} + +// --- mix playback --------------------------------------------------------- + +fn mix_queue() -> Vec { + MIX.lock() + .ok() + .and_then(|s| s.as_ref().map(|state| state.queue.clone())) + .unwrap_or_default() +} + +/// Header Play — the whole mix becomes the queue, from the top. +pub(crate) fn mix_play_all() { + play_queue_from(0, false); +} + +/// Header Shuffle — a fresh random order that does NOT mutate the displayed +/// list (`mix.rs::shuffled_tracks`). +pub(crate) fn mix_shuffle() { + let mut queue = mix_queue(); + if queue.is_empty() { + return; + } + shuffle_queue(&mut queue); + let runtime = crate::app(); + crate::spawn(async move { + if let Err(e) = crate::playback_qt::play_track_list(&runtime, queue, 0, false).await { + log::warn!("[qbz-qt] mix shuffle failed: {e}"); + } + }); +} + +/// A row click / row Play — the mix becomes the queue anchored at that track +/// (`mix.rs::index_of` + play-from-here). +pub(crate) fn mix_play_track(track_id: String) { + let index = mix_queue() + .iter() + .position(|t| t.id.to_string() == track_id) + .unwrap_or(0); + play_queue_from(index, false); +} + +/// A row's ⋯ menu: "next" | "later" | "queue". +pub(crate) fn mix_enqueue_track(track_id: String, mode: String) { + let Some(track) = mix_queue().into_iter().find(|t| t.id.to_string() == track_id) else { + return; + }; + let runtime = crate::app(); + crate::spawn(async move { + if let Err(e) = + crate::playback_qt::enqueue_track_list_mode(&runtime, vec![track], &mode).await + { + log::warn!("[qbz-qt] mix enqueue failed: {e}"); + } + }); +} + +fn play_queue_from(index: usize, shuffle: bool) { + let queue = mix_queue(); + if queue.is_empty() { + return; + } + let runtime = crate::app(); + crate::spawn(async move { + if let Err(e) = + crate::playback_qt::play_track_list(&runtime, queue, index, shuffle).await + { + log::warn!("[qbz-qt] mix play failed: {e}"); + } + }); +} + +/// Shuffle over the already-built queue rows (the mix's own xorshift, applied +/// to `QueueTrack` instead of `Track`). +fn shuffle_queue(tracks: &mut [QueueTrack]) { + let mut seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(1) + | 1; + let n = tracks.len(); + for i in (1..n).rev() { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let j = (seed % (i as u64 + 1)) as usize; + tracks.swap(i, j); + } +} + +/// Start a flat radio result as the queue (Slint `play_radio_response` -> +/// `play_tracks(.., 0)`), reusing the port's shared list-play path. +async fn play_flat( + runtime: &Arc>, + tracks: Vec, + start: usize, + shuffle: bool, +) { + let queue: Vec = tracks.iter().map(to_queue_track).collect(); + if queue.is_empty() { + return; + } + if let Err(e) = crate::playback_qt::play_track_list(runtime, queue, start, shuffle).await { + log::warn!("[qbz-qt] radio play failed: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pick_spread_strides_and_never_overruns() { + let ids: Vec = (0..100).collect(); + let picked = pick_spread(&ids, 9); + assert_eq!(picked.len(), 9); + assert_eq!(picked[0], 0); + // Strictly increasing, in range. + for w in picked.windows(2) { + assert!(w[0] < w[1]); + } + assert!(picked.iter().all(|id| *id < 100)); + // Fewer ids than asked for -> the whole list, unchanged. + assert_eq!(pick_spread(&ids[..4], 9), ids[..4].to_vec()); + } + + #[test] + fn mix_meta_covers_the_four_kinds_and_degrades() { + for kind in ["daily", "weekly", "fav", "top"] { + let (title, subtitle) = mix_meta(kind); + assert!(!title.is_empty(), "{kind} has no title"); + assert!(!subtitle.is_empty(), "{kind} has no subtitle"); + } + assert_eq!(mix_meta("nope").0, "Mix"); + } + + #[test] + fn build_radio_dedupes_caps_and_drops_non_qobuz_sources() { + let recents = vec![ + crate::recently_qt::RecentAlbum { + id: "a1".into(), + title: "One".into(), + artist: "X".into(), + source: "qobuz".into(), + ..Default::default() + }, + // Same album again — deduped. + crate::recently_qt::RecentAlbum { + id: "a1".into(), + title: "One".into(), + artist: "X".into(), + source: String::new(), + ..Default::default() + }, + // Plex ids cannot resolve against /radio/album. + crate::recently_qt::RecentAlbum { + id: "plex-7".into(), + title: "Nope".into(), + artist: "Y".into(), + source: "plex".into(), + ..Default::default() + }, + // Legacy entry with no source is treated as Qobuz. + crate::recently_qt::RecentAlbum { + id: "a2".into(), + title: "Two".into(), + artist: "Z".into(), + source: String::new(), + ..Default::default() + }, + ]; + let rows = build_radio(&recents, &[]); + assert_eq!( + rows.iter().map(|r| r.album_id.as_str()).collect::>(), + vec!["a1", "a2"] + ); + } +} diff --git a/crates/qbz-qt/src/home_bridge.rs b/crates/qbz-qt/src/home_bridge.rs index 48cae56e4..20bf240a5 100644 --- a/crates/qbz-qt/src/home_bridge.rs +++ b/crates/qbz-qt/src/home_bridge.rs @@ -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>) { diff --git a/scripts/qt-run.sh b/scripts/qt-run.sh index 9e094a78f..7748d42c9 100755 --- a/scripts/qt-run.sh +++ b/scripts/qt-run.sh @@ -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}" \