feat(qbz-qt): theme-following icons, the real header atmosphere, sharp covers, playback context

**Icons follow the theme.** The bakes are frozen dark-palette hexes, so on light
themes secondary, muted and accent were simply wrong, and accent was wrong on
any theme whose accent is not #4285f4. Slint recolours at render time, which
this path cannot do — so the tint is generated once per colour into the cache
and loaded through the same Image+svg path the bakes already use. No effect, no
shader, so it cannot hit the "renders nothing on software" failure.

The sweep found the part the diagnosis had missed: "primary" was two meanings
welded together — max-contrast-on-theme-surface, and literal white for glyphs on
hosts that are dark under every theme. Remapping it globally would have painted
dark glyphs on dark scrims in fourteen places. The vocabulary is split instead,
with "primary" left as an alias so nothing changes until it is migrated
deliberately.

**The album header gradient was Slint's fallback, not its design.** Slint paints
a blurred artwork field and only falls back to the flat two-band gradient when
that image is unavailable; the port shipped the fallback. The field needs no
shader — it is a 128x128 image, so the pipeline ports verbatim: the cover down
to 8x8, back up to 128, blur, colour adjust, vignette. The 8x8 step is the look,
not an optimization; everything after it works from eight pixels of colour,
which is why it reads as atmosphere rather than as a blurred cover. It comes out
of the same decode that already produced the flat tint.

**Covers were point-sampled.** Canvas drawImage does not filter when it scales,
measured at RMSE 0.000 against a nearest-neighbour reference — a 600px cover in
a 200px cell throws away eight of every nine pixels, and the error is aliasing,
not softness. Rust now produces a derivative at the drawn device size so the
draw is 1:1 and there is no resample at all.

**The now-playing context stack.** It had no Qt counterpart: the model carried
context_kind and context_id, some builders stamped them, and nothing ever read
them back. The album path only appeared to work by coincidence, because the
glyph was hardcoded to open the album.

Plus: Discover's missing rails with real play history, Recommendations on the
integrations, queue drag reorder, and the playlist detail collage.

cc
This commit is contained in:
vicrodh committed 2026-07-28 21:12:31 -06:00
1 parent a1c771c39e
commit 2a5b5ac16e
31 files changed
+3548 -329

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ fn main() {
.qt_module("QuickControls2")
.qml_module(QmlModule {
uri: "com.blitzfc.qbz",
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs"],
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs"],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" fill="#ffffff" width="128" height="128" viewBox="0 0 32 32" id="icon"><defs><style>.cls-1{fill:none;}</style></defs><title>MP3</title><path d="M28,9H22v2h6v4H24v2h4v4H22v2h6a2,2,0,0,0,2-2V11A2,2,0,0,0,28,9Z"/><path d="M14,23H12V9h6a2,2,0,0,1,2,2v5a2,2,0,0,1-2,2H14Zm0-7h4V11H14Z"/><polygon points="8 9 6.49 14 6 15.98 5.54 14 4 9 2 9 2 23 4 23 4 15 3.84 13 4.42 15 6 19.63 7.58 15 8.16 13 8 15 8 23 10 23 10 9 8 9"/><rect id="_Transparent_Rectangle_" data-name="&lt;Transparent Rectangle&gt;" class="cls-1" width="32" height="32"/></svg>

After

Width:  |  Height:  |  Size: 658 B

+28 -3
View File
@@ -75,14 +75,39 @@ Item {
// logs a warning on every re-evaluation.
readonly property color tintColor: root.tint === "" ? "transparent" : root.tint
visible: root.active && root.tint !== ""
visible: root.active && (root.tint !== "" || root.hasAtmosphere)
// Never eat clicks meant for the header controls painted on top.
enabled: false
// Band 1 — the artwork tint. Solid down to the 82% mark (route A's image
// field is opaque over exactly that span); the last 18% is band 4's job.
/// ROUTE A — the blurred artwork field Slint actually renders
/// (`ImmersiveAtmosphere`, AlbumPageView.slint:222). It needs no shader:
/// the atmosphere is a 128x128 image produced in Rust
/// (atmosphere_qt.rs, the immersive.rs pipeline ported stage for stage),
/// so it works on the software path like any other picture. Empty means
/// the cover has not resolved yet and route B stands in.
property string atmosphere: ""
readonly property bool hasAtmosphere: root.atmosphere !== ""
Image {
anchors.fill: parent
source: root.atmosphere
visible: root.hasAtmosphere
// 128px stretched across a whole header IS the design — the source is
// eight pixels of colour blurred out, so smooth scaling is the point.
smooth: true
fillMode: Image.Stretch
// ImmersiveAtmosphere.slint's base-opacity for a static header field.
opacity: 0.92
asynchronous: true
}
// Band 1 — ROUTE B, the flat tint. This is Slint's FALLBACK, painted only
// while no atmosphere exists; shipping it as the design is what made the
// header read as a plain ugly gradient. Solid to the 82% mark, the last
// 18% is band 4's job.
Rectangle {
anchors.fill: parent
visible: !root.hasAtmosphere
gradient: Gradient {
GradientStop { position: 0.0; color: root.tintColor }
GradientStop { position: 0.16; color: root.tintColor }
+5 -1
View File
@@ -108,7 +108,11 @@ Item {
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
tintName: parent.parent.active ? "primary" : "secondary"
// Theme tokens on both arms — the row sits on
// surface-elevated / surface-hover, so the active
// glyph is "textPrimary" and NOT the fixed-white
// "primary" bake (white-on-white on light themes).
tintName: parent.parent.active ? "textPrimary" : "secondary"
}
Text {
height: parent.height
+19 -7
View File
@@ -138,7 +138,11 @@ Rectangle {
width: navTab.showIcon ? 14 : 0
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: navTab.isActive ? "primary" : "secondary"
// Both arms are THEME tokens (the tab sits on surface-elevated
// / surface-hover): "textPrimary", never the fixed-white
// "primary" bake, or the active tab is white-on-white on every
// light theme. See the vocabulary in QbzIcon.qml.
tintName: navTab.isActive ? "textPrimary" : "secondary"
}
Text {
height: parent.height
@@ -193,7 +197,7 @@ Rectangle {
width: 16
height: 16
anchors.centerIn: parent
tintName: cnb.isActive ? "primary" : "secondary"
tintName: cnb.isActive ? "textPrimary" : "secondary"
}
MouseArea {
id: cnbArea
@@ -308,7 +312,7 @@ Rectangle {
width: 16
height: 16
anchors.centerIn: parent
tintName: plPopup.opened ? "primary" : "secondary"
tintName: plPopup.opened ? "textPrimary" : "secondary"
}
MouseArea {
id: plBtnArea
@@ -579,11 +583,15 @@ Rectangle {
}
}
}
// Placeholder (centered when empty + unfocused, left once typing).
// Placeholder — centered on the WHOLE box, exactly like
// HeaderBar.slint:792 (`width: parent.width; horizontal-alignment:
// center`). The magnifier is absolutely positioned at x:10 and takes
// no layout space, so it must not shift the placeholder either: the
// 30px left inset this used to carry pushed "Search" 15px right of
// the field's centre, which is what reads as "not centred".
Text {
visible: searchInput.text === "" && !searchInput.activeFocus
anchors.fill: parent
anchors.leftMargin: 30
text: QbzSession.tr("Search", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
@@ -617,7 +625,7 @@ Rectangle {
width: 12
height: 12
anchors.centerIn: parent
tintName: clearArea.containsMouse ? "primary" : "muted"
tintName: clearArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: clearArea
@@ -835,7 +843,11 @@ Rectangle {
width: 14
height: 14
anchors.centerIn: parent
tintName: wcCloseArea.containsMouse ? "primary" : "secondary"
// The hover fill is a FIXED #e81123 under every theme, so the
// hovered glyph is literal white ("white"), not a theme
// token — the one place in this file where the fixed bake is
// the right answer.
tintName: wcCloseArea.containsMouse ? "white" : "secondary"
}
MouseArea {
id: wcCloseArea
+202 -23
View File
@@ -4,15 +4,14 @@
// Tabs (Queue / History), NOW PLAYING card (live heart), UP NEXT with the
// #442 "Next in queue" / "Next up" section markers, 40-row pagination,
// live search filter, row actions (play / remove / remove-all-after /
// heart), drag reorder (basic press-drag), footer (count line + Clear /
// stubs + search field), History tab (thumbnail rows, click replays as a
// fresh single-track queue), exact empty states. Data: QbzQueue.queueJson
// (queue_qt.rs QueueDoc).
// heart), drag reorder (6px press-drag, drop indicator + floating ghost),
// footer (count line + Clear / stubs + search field), History tab
// (thumbnail rows, click replays as a fresh single-track queue), exact
// empty states. Data: QbzQueue.queueJson (queue_qt.rs QueueDoc).
//
// POC-NOTEs: save-as-playlist (opens a picker modal upstream — inert),
// infinite-play + sleep timer engines, stop-after marker, row menu's
// "Add to playlist" / "Track info" entries, ephemeral rows, the ghost
// drag pill (rows move with a plain displaced animation instead).
// "Add to playlist" / "Track info" entries, ephemeral rows.
import QtQuick
import QtQuick.Controls
@@ -37,11 +36,52 @@ Rectangle {
// url-keyed cover map (shared artwork pipeline).
property var coverMap: ({})
// Drag-reorder state (basic press-drag; ghost is the row's own y).
property int dragFrom: -1
// --- UP NEXT drag-reorder state (QueueSidebar.slint:371-410) ---------
// Panel-local on purpose: this is NOT the QbzShell drag global (that one
// is the track -> sidebar-playlist ghost, and its drag_end() posts an
// "add tracks to playlist" — a reorder must never reach it).
// ALL indices here are PAGE-LOCAL, which is exactly what queue_qt.rs's
// move_track takes (it resolves page-local -> queue-wide itself, filter
// and pagination included) — same contract as play/remove/remove-after.
property bool reorderActive: false
property int reorderFrom: -1 // page-local index of the dragged row
property int reorderOver: -1 // insertion slot, 0..N
property real reorderPointerY: 0 // live pointer WINDOW y (drives the ghost)
QbzTheme { id: theme }
// Pointer window-Y -> insertion slot [0..N]. The Slint does this with a
// fixed 44px stride plus a 22px correction per #442 section header; here
// the rows' real geometry answers it directly (the delegate exposes its
// row center in window space), so section headers, the list padding and
// the 8px inter-row spacing are all accounted for without a second
// arithmetic model to keep in sync. Top half of row k -> slot k, bottom
// half -> slot k+1, past the last row -> N (append). <=40 rows per page.
function slotFromPointer(winY) {
var n = root.upcoming.length
for (var i = 0; i < n; i++) {
var d = upcomingRepeater.itemAt(i)
if (d && winY < d.rowCenterY())
return i
}
return n
}
// Commit — QueueSidebar.slint commit-reorder(). `to == from` and
// `to == from + 1` both drop back onto the SAME gap, so neither reaches
// the bridge (move_track's from == to early return would swallow them
// anyway, at the cost of a queue round-trip and a republish).
function commitReorder() {
var from = root.reorderFrom
var to = root.reorderOver
root.reorderActive = false
root.reorderFrom = -1
root.reorderOver = -1
if (from < 0 || to < 0 || to === from || to === from + 1)
return
QbzQueue.queueMoveTrack(from, to)
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
@@ -122,21 +162,35 @@ Rectangle {
// One UP NEXT / History row (QueueRow.slint).
component QueueRow: Rectangle {
id: qrRoot
property var row: ({})
property int rowIndex: 0
property bool showNumber: true
property bool inQueue: true
/// Drag-reorder source? UP NEXT rows only — History has no order to
/// commit, and it keeps its drag-scroll because of this (see qrArea's
/// preventStealing).
property bool reorderable: false
readonly property bool hovered: qrArea.containsMouse || menuArea.containsMouse
readonly property bool isActive: inQueue && QbzPlayer.npTrackId !== "" && QbzPlayer.npTrackId === row.id
readonly property bool dragSource: reorderable && root.reorderActive
&& root.reorderFrom === rowIndex
// Row center in WINDOW space — what slotFromPointer() measures the
// pointer against. A function, not a binding: mapToItem() is not
// reactive, and it is only ever read during a live drag.
function rowCenterY() {
return qrRoot.mapToItem(null, 0, qrRoot.height / 2).y
}
height: 44
radius: theme.radiusSm
color: hovered ? theme.surfaceHover
: (rowIndex % 2 === 1 ? "#592a2a2a" : "transparent")
border.width: root.dragFrom === rowIndex ? 1 : 0
border.width: dragSource ? 1 : 0
border.color: theme.accent
opacity: root.dragFrom === rowIndex ? 0.4 : 1.0
opacity: dragSource ? 0.4 : 1.0
Row {
anchors.fill: parent
@@ -301,16 +355,115 @@ Rectangle {
}
}
// Row body: click plays, >6px press-drag reorders (QueueSidebar
// .slint's `ta` does both from one TouchArea — one area per row, not
// two stacked).
MouseArea {
id: qrArea
anchors.fill: parent
// BEHIND the row's own controls. Declaration order IS z-order in
// QML and this fills the row from the last declaration, so at the
// default z it sat on top of the ⋯ button and swallowed its press
// — the menu never opened, the row just played. Same fix (and the
// same reason the .slint declares its TouchArea BEFORE the
// content) as rows/TrackRow.qml's trArea.
z: -1
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// THE reason drag-reorder scrolled instead of reordering: the
// enclosing Flickable filters its children's mouse events and
// grabs the press once the pointer moves past
// QStyleHints.startDragDistance (~10px), after which this area
// gets `canceled`, never `released`. Keeping the grab here is the
// Qt equivalent of QueueSidebar.slint's `interactive: false` on
// the queue Flickable — but scoped to the row and without that
// side effect: Qt's Flickable also drops WHEEL events when it is
// non-interactive, so wheel + scrollbar keep working, and only a
// press that starts ON an UP NEXT row is denied to the flick.
// (Consequence, same as the Slint: no drag-scroll while
// reordering — pagination covers long lists.)
preventStealing: qrRoot.reorderable
property real downY: 0
property bool dragging: false
// A release that ended a drag must NOT also play the track
// (`clicked` fires after `released`).
property bool suppressClick: false
onPressed: function (mouse) {
qrArea.downY = qrArea.mapToItem(null, mouse.x, mouse.y).y
qrArea.dragging = false
}
onPositionChanged: function (mouse) {
if (!qrRoot.reorderable || !qrArea.pressed)
return
// WINDOW space for both the threshold and the slot, so a
// wheel-scroll mid-gesture cannot corrupt either.
var winY = qrArea.mapToItem(null, mouse.x, mouse.y).y
if (!qrArea.dragging && Math.abs(winY - qrArea.downY) > 6) {
qrArea.dragging = true
root.reorderActive = true
root.reorderFrom = qrRoot.rowIndex
root.reorderOver = qrRoot.rowIndex
}
if (qrArea.dragging) {
root.reorderPointerY = winY
root.reorderOver = root.slotFromPointer(winY)
}
}
onReleased: {
if (!qrArea.dragging)
return
qrArea.dragging = false
qrArea.suppressClick = true
root.commitReorder()
}
// Safety net (the .slint keeps one too): if the grab is lost
// mid-drag the release never arrives here, and without this the
// panel would stay stuck in reorderActive with a ghost pinned to
// the last pointer position.
onCanceled: {
if (!qrArea.dragging)
return
qrArea.dragging = false
qrArea.suppressClick = true
root.commitReorder()
}
onClicked: {
if (qrArea.suppressClick) {
qrArea.suppressClick = false
return
}
if (inQueue) QbzQueue.queuePlayUpcoming(rowIndex)
else QbzQueue.queuePlayHistory(rowIndex)
}
}
// Drop indicator — the .slint draws one 2px accent line at the
// insertion slot, hidden on the two no-op slots (from, from+1). Here
// it rides the row that OWNS the slot (its top edge, and the last
// row's bottom edge for the append slot), which needs no overlay
// geometry and stays correct across the section headers.
Rectangle {
visible: qrRoot.reorderable && root.reorderActive
&& root.reorderOver === qrRoot.rowIndex
&& root.reorderOver !== root.reorderFrom
&& root.reorderOver !== root.reorderFrom + 1
y: -1
width: parent.width
height: 2
radius: 1
color: theme.accent
}
Rectangle {
visible: qrRoot.reorderable && root.reorderActive
&& root.reorderOver === root.upcoming.length
&& qrRoot.rowIndex === root.upcoming.length - 1
&& root.reorderFrom !== root.upcoming.length - 1
y: parent.height - 1
width: parent.width
height: 2
radius: 1
color: theme.accent
}
}
Column {
@@ -511,11 +664,16 @@ Rectangle {
}
Repeater {
id: upcomingRepeater
model: root.upcoming
delegate: Column {
id: upDelegate
required property var modelData
required property int index
width: parent ? parent.width : 0
// Row center in window space — slotFromPointer()
// reads it off the Repeater's items.
function rowCenterY() { return qrow.rowCenterY() }
// #442 section header above the row.
Row {
visible: modelData.section !== ""
@@ -540,24 +698,13 @@ Rectangle {
}
}
QueueRow {
id: qrow
width: parent.width
row: modelData
rowIndex: index
showNumber: true
inQueue: true
// Basic press-drag reorder (POC: no ghost pill).
MouseArea {
anchors.fill: parent
onPressAndHold: root.dragFrom = index
onReleased: {
if (root.dragFrom >= 0) {
var target = Math.max(0, Math.min(root.upcoming.length - 1,
Math.floor((parent.mapToItem(null, mouseX, mouseY).y - queueBody.mapToItem(null, 0, 0).y - 40) / 44)))
QbzQueue.queueMoveTrack((doc.page || 0) * 40 + root.dragFrom, (doc.page || 0) * 40 + target)
root.dragFrom = -1
}
}
}
reorderable: true
}
}
}
@@ -736,4 +883,36 @@ Rectangle {
}
}
}
// Floating drag ghost (QueueSidebar.slint:1098-1130) — a pill following
// the pointer with the dragged track's title, so it is clear something is
// being moved. A free child of the panel ROOT (not of the Column, which
// would stack it), declared LAST so it renders above the list, and with
// no mouse target of its own. The live pointer window-Y is converted into
// panel-local space here; mapToItem() is re-evaluated on every
// reorderPointerY change, which is the only thing that moves it.
Rectangle {
visible: root.reorderActive && root.reorderFrom >= 0
&& root.reorderFrom < root.upcoming.length
x: theme.spacingMd
y: root.reorderPointerY - root.mapToItem(null, 0, 0).y - height / 2
width: root.width - 2 * theme.spacingMd
height: 34
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: theme.accent
opacity: 0.95
Text {
anchors.fill: parent
anchors.leftMargin: theme.spacingSm
anchors.rightMargin: theme.spacingSm
verticalAlignment: Text.AlignVCenter
text: parent.visible ? (root.upcoming[root.reorderFrom].title || "") : ""
color: theme.textPrimary
font.pixelSize: 12
font.weight: theme.weightMedium
elide: Text.ElideRight
}
}
}
+187 -62
View File
@@ -6,11 +6,21 @@
// surface-elevated fill); New uses the flush 74px variant. Large mounts it
// with showArt/showBadges false — the cover lives in the sidebar dock and the
// AudioStamp moves to the right cluster, so the text column reclaims the
// stamp's 132px.
// stamp's 132px. `textCenter` is the SMALL-bar variant (SongCard.slint:329,
// mounted by PlayerBarSmall.slint:239-251): a 37px bordered cover, a 13px
// title and the title+meta collapsed into a tight centred group, no in-card
// stamp.
//
// The title click opens Track Info (SongCard.slint: `root.track-info()` from
// the title TouchArea, Qobuz-only) — the host bar owns the modal, this just
// emits `trackInfoRequested()`.
//
// META ROW (SongCard.slint:20-49 `MetaLink` + :486-540): [layers] artist · album.
// The two labels share the leftover width on the Slint's stretch budget —
// artist 2, album 1 — each capped at its natural width and free to shrink to
// nothing. A plain Row of unbounded Texts is what let the album slide past the
// card wall and vanish under the audio stamp / the bar column's `clip: true`
// (PlayerBar.qml:249): `elide` does NOTHING on a Text with no explicit width.
import QtQuick
import com.blitzfc.qbz
@@ -23,6 +33,18 @@ Rectangle {
property bool showBadges: true
property bool glass: false
property int artSize: glass ? 60 : 74
// Optional cover border (SongCard.slint art-border-width / -color). OFF by
// default so New/Classic/Large are untouched; Small passes 3px surface-card.
property int artBorderWidth: 0
property color artBorderColor: "transparent"
// Cover -> text gap (SongCard.slint art-text-gap: 11px default, 9px Small).
property int artTextGap: 11
// Title size (SongCard.slint title-font-size: Typography.body default,
// 13px on the Small bar).
property real titleFontSize: theme.fontBody
// Small bar: title+meta as a TIGHT centred group instead of the
// full-height, space-distributed column.
property bool textCenter: false
/// Title clicked — the host opens the Track Info modal.
signal trackInfoRequested()
@@ -35,6 +57,34 @@ Rectangle {
readonly property bool qobuzTrack: QbzPlayer.npHasTrack
&& /^[0-9]+$/.test(QbzPlayer.npTrackId)
// "Playing from" origin, 1:1 with SongCard.slint:151-156: the CURRENT
// track's own stamped container, falling back to its album when it carries
// none (the Slint fallback in playback.rs:1959-1965).
readonly property string ctxKind: QbzPlayer.npContextId !== ""
? QbzPlayer.npContextKind : "album"
readonly property string ctxId: QbzPlayer.npContextId !== ""
? QbzPlayer.npContextId : QbzPlayer.npAlbumId
// SongCard.slint fires `open-context(kind, id)`, which the bar routes to
// `media-action(kind, id, "open")` — qbz/src/main.rs:12695 (artist), :12701
// (album), :12707 (playlist), :13206 (label).
function openContext() {
if (root.ctxId === "")
return
if (root.ctxKind === "artist") {
QbzArtist.openArtist(root.ctxId)
} else if (root.ctxKind === "playlist") {
QbzBridge.openPlaylist(root.ctxId)
} else if (root.ctxKind === "album") {
QbzAlbum.openAlbum(root.ctxId)
} else if (QbzPlayer.npAlbumId !== "") {
// "label" (and anything added later) has no landing page in this
// port — LabelCard.qml documents the same gap. Fall back to the
// track's album so the glyph is never a dead control.
QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
QbzTheme { id: theme }
// Bake matching the LIVE Theme.text-primary (SongCard.slint:504 tints the
@@ -52,8 +102,8 @@ Rectangle {
Row {
anchors.left: parent.left
// SongCard.slint's inner-row width formula: the card minus the
// stamp + a 4px gap ONLY when the stamp is rendered (Large hides
// it — the AudioStamp moves to the right column — so the text
// stamp + a 4px gap ONLY when the stamp is rendered (Large and Small
// hide it — the AudioStamp moves to the right column — so the text
// column must reclaim those 132px).
anchors.right: stamp.visible ? stamp.left : parent.right
anchors.rightMargin: stamp.visible ? 4 : 0
@@ -70,6 +120,8 @@ Rectangle {
anchors.centerIn: parent
radius: 6
color: theme.surfaceElevated
border.width: root.artBorderWidth
border.color: root.artBorderColor
clip: true
RoundedImage {
visible: QbzPlayer.npHasTrack
@@ -106,18 +158,21 @@ Rectangle {
}
}
}
Item { width: root.showArt ? 11 : 0; height: 1 }
Item { width: root.showArt ? root.artTextGap : 0; height: 1 }
Column {
width: parent.width - (root.showArt ? root.artSize + 11 : 0)
width: parent.width - (root.showArt ? root.artSize + root.artTextGap : 0)
anchors.verticalCenter: parent.verticalCenter
spacing: 4
// textCenter tightens the inter-line gap to 1px so the two lines
// read as one block beside the small cover (SongCard.slint:333);
// the default column keeps its 4px.
spacing: root.textCenter ? 1 : 4
Text {
width: parent.width
text: QbzPlayer.npHasTrack ? QbzPlayer.npTitle
: QbzSession.tr("Nothing playing", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.pixelSize: root.titleFontSize
font.weight: theme.weightMedium
elide: Text.ElideRight
// Title -> Track Info (SongCard.slint). The MouseArea is a
@@ -130,65 +185,135 @@ Rectangle {
onClicked: if (root.qobuzTrack) root.trackInfoRequested()
}
}
Row {
// --- Meta row: [layers] artist · album ------------------------
Item {
id: metaRow
visible: QbzPlayer.npHasTrack
spacing: 7
width: parent.width
// Row height is driven by the 16x18 context box (SongCard.slint).
height: 18
// Context-stack icon ("Show track playing context" pref).
Rectangle {
visible: QbzPlayer.showContextIcon
width: 16
height: 18
radius: 3
color: ctxArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "layers"
width: 13
height: 13
// Natural (unelided) label widths. `implicitWidth` on a Text
// that already HAS an explicit width is not a safe source here
// — TextMetrics measures the string itself and never feeds the
// width binding back into the layout.
TextMetrics {
id: artistMetrics
font: artistText.font
text: artistText.text
}
TextMetrics {
id: albumMetrics
font: albumText.font
text: albumText.text
}
// Fixed furniture: the context box (+ its 7px gap when shown)
// and the separator dot with a 7px gap on each side.
readonly property real fixedW: (ctxBox.visible ? 16 + 7 : 0) + 3 + 14
readonly property real avail: Math.max(0, metaRow.width - metaRow.fixedW)
// Slint stretch 2 (artist) : 1 (album), each capped at its own
// natural width and free to shrink to 0 (SongCard.slint:22-31):
// a long album elides before the artist does, and both keep a
// readable slice instead of one pushing the other off the card.
readonly property var budget: {
var a = artistMetrics.width
var b = albumMetrics.width
var av = metaRow.avail
if (a + b <= av)
return [a, b]
var qa = av * 2 / 3
var qb = av - qa
if (a < qa) {
qa = a
qb = av - a
} else if (b < qb) {
qb = b
qa = av - b
}
return [Math.floor(qa), Math.floor(qb)]
}
readonly property bool artistLinked: QbzPlayer.npArtistId !== ""
readonly property bool albumLinked: QbzPlayer.npAlbumId !== ""
Row {
anchors.fill: parent
spacing: 7
// Context-stack icon ("Show track playing context" pref) —
// opens the container playback was launched from.
Rectangle {
id: ctxBox
visible: QbzPlayer.showContextIcon
width: 16
height: 18
radius: 3
color: ctxArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "layers"
width: 13
height: 13
// Flush LEFT (SongCard.slint pins x: 0) so the meta
// line starts on the title's column.
x: 0
anchors.verticalCenter: parent.verticalCenter
tintName: ctxArea.containsMouse ? root.tintStrong : "muted"
}
MouseArea {
id: ctxArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.ctxId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.openContext()
}
}
Text {
id: artistText
width: metaRow.budget[0]
height: parent.height
verticalAlignment: Text.AlignVCenter
text: QbzPlayer.npArtist
color: artistArea.containsMouse && metaRow.artistLinked
? theme.textPrimary : theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
MouseArea {
id: artistArea
anchors.fill: parent
hoverEnabled: true
cursorShape: metaRow.artistLinked ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (metaRow.artistLinked) QbzArtist.openArtist(QbzPlayer.npArtistId)
}
}
Rectangle {
width: 3
height: 3
radius: 1.5
color: theme.textMuted
anchors.verticalCenter: parent.verticalCenter
tintName: ctxArea.containsMouse ? root.tintStrong : "muted"
}
MouseArea {
id: ctxArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: if (QbzPlayer.npAlbumId !== "") QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzPlayer.npArtist
color: artistArea.containsMouse && QbzPlayer.npArtistId !== "" ? theme.accent : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
MouseArea {
id: artistArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npArtistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npArtistId !== "") QbzArtist.openArtist(QbzPlayer.npArtistId)
}
}
Rectangle {
width: 3
height: 3
radius: 1.5
color: theme.textMuted
anchors.verticalCenter: parent.verticalCenter
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzPlayer.npAlbum
color: albumArea.containsMouse && QbzPlayer.npAlbumId !== "" ? theme.accent : theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
MouseArea {
id: albumArea
anchors.fill: parent
hoverEnabled: true
cursorShape: QbzPlayer.npAlbumId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (QbzPlayer.npAlbumId !== "") QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
Text {
id: albumText
width: metaRow.budget[1]
height: parent.height
verticalAlignment: Text.AlignVCenter
text: QbzPlayer.npAlbum
color: albumArea.containsMouse && metaRow.albumLinked
? theme.textPrimary : theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
MouseArea {
id: albumArea
anchors.fill: parent
hoverEnabled: true
cursorShape: metaRow.albumLinked ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (metaRow.albumLinked) QbzAlbum.openAlbum(QbzPlayer.npAlbumId)
}
}
}
}
+129 -32
View File
@@ -1,52 +1,149 @@
// Tinted SVG icon — the QML equivalent of Slint's QbzIcon (glyphs recolored
// to a theme tint).
//
// POC-NOTE (icon tinting): the QBZ icon set is Feather-style SVGs with a
// hardcoded `stroke="#ffffff"`; Slint recolors at render time, QML's Image
// cannot. A ColorOverlay (Qt5Compat.GraphicalEffects) was tried first and
// renders NOTHING on the offscreen/software path (verified with a probe —
// effects need the GL path), so tinting is done with PRE-BAKED SVG
// variants: assets/icons/<tint>/<name>.svg for tint in {primary,
// secondary, muted, accent, warning} (script-generated from the originals,
// also rewriting fill="currentColor"). `tintName` switches variants
// dynamically (hover = secondary -> primary), served from qrc.
// HOW TINTING WORKS HERE (and why it is not what Slint does)
//
// TWO WAYS A BAKE SILENTLY LIES — both have already shipped here:
// Slint's QbzIcon is `Image { colorize: <live Theme token> }`: the renderer
// recolours the glyph every frame, so an icon always matches the current
// theme, all 36 of them, custom accent included. Qt has no equivalent on the
// path this port runs on — ColorOverlay / MultiEffect / shaders draw NOTHING
// on the software renderer (verified with a probe during the POC), which is
// why this file used to point straight at PRE-BAKED variants,
// assets/icons/<tint>/<name>.svg, each with a fixed hex burned in.
//
// 1. NO VARIANT ON DISK -> the Image resolves to nothing and the glyph is
// simply ABSENT. Nothing is logged. Every `name` an
// app site can ask for must exist in every `tintName` that site can ask
// for; `favorite/` and `amber/` are deliberately partial (favorite =
// heart/heart-filled/link/trash-2..., amber = monitor-speaker only), so
// only widen a caller's tint set after checking the dir.
// 2. THE BAKE RAN BUT RECOLOURED NOTHING -> the glyph renders in the SVG's
// OWN colour in every tint, which for a Feather-style file with no
// paint attribute means BLACK (SVG's initial `fill` is black, and
// `currentColor` with no inherited `color` resolves to black too). The
// original baker only rewrote `stroke="..."` / `fill="currentColor"` on
// the ROOT element, so three icons whose colour lives elsewhere came out
// byte-identical in all seven dirs: element-connect (fill-based, no fill
// attribute at all), home-gear (same, plus a `fill="none"` backdrop
// path that must stay none) and copy (`stroke="currentColor"`).
// Re-baked by putting the tint on the ROOT element and letting the
// children inherit it — the convention play-fill/qbz-symbolic already
// used. CHECK FOR THIS with a content hash: if
// primary/<name>.svg == black/<name>.svg the icon is not tinted at all.
// Fixed hexes cannot serve 36 themes: the bakes were cut against the dark
// palette, so `secondary` (#cccccc) and `muted` (#888888) were near-white
// glyphs on near-white surfaces on every LIGHT theme, and `accent` was
// #4285f4 whatever the theme's accent actually was.
//
// So the tinted set is now GENERATED AT RUNTIME, into the user cache dir, by
// src/icon_tint_qt.rs: `QbzIconTint.dirFor(tint, themeJson)` returns the
// file:// directory holding this tint's glyphs for the live theme, and the
// Image loads a real .svg off disk — byte for byte the same load path as the
// qrc bakes, no effects involved. Passing `QbzShell.themeJson` is what makes
// the binding re-resolve on a live theme switch (the same dependency trick as
// `QbzSession.tr(msgid, trRev)`), and it is the colour source at the same
// time.
//
// ---------------------------------------------------------------------------
// THE TINT VOCABULARY — read this before adding a call site
// ---------------------------------------------------------------------------
//
// THEME-FOLLOWING (runtime-tinted, correct under all 36 themes):
// "textPrimary" Theme.text-primary — max contrast on a THEME surface
// "secondary" Theme.text-secondary (alias: "textSecondary")
// "muted" Theme.text-muted (alias: "textMuted")
// "disabled" Theme.text-disabled (alias: "textDisabled")
// "accent" Theme.accent
// "accentText" the glyph colour ON an accent fill
// "warning" Theme.warning
// "favorite" Theme.favorite
//
// FIXED (theme-independent by design, served from the qrc bakes):
// "white" a light glyph on a host that is dark under EVERY theme:
// artwork scrims (#a6000000 / #b3000000), gradient discs,
// the close button's #e81123 hover
// "primary" LEGACY ALIAS OF "white". It has always been a literal
// #ffffff and ~14 call sites depend on that; it is NOT
// Theme.text-primary. New code says "white" when it means
// white and "textPrimary" when it means the theme token.
// "black" a dark glyph on a host painted light under every theme
// "amber" #e0b341, the audio stamp's brand accent (one icon)
//
// Getting this pair wrong is how the bug this file fixes happened in reverse:
// resolving "primary" to Theme.text-primary would paint a DARK glyph on a
// dark scrim on light themes. Slint keeps the two apart explicitly
// (CircleAction.slint:70-74) and so does this.
//
// ---------------------------------------------------------------------------
// THE QRC BAKES REMAIN, AS THE FLOOR
// ---------------------------------------------------------------------------
//
// `dirFor` returns "" — and this file falls back to assets/icons/<dir>/ —
// whenever the tint is fixed, there is no writable cache dir, the bake
// failed, the theme document has not been published yet, or QbzIconTint is
// not registered at all (see the `typeof` guard: an unregistered singleton
// resolves LAZILY and would otherwise take every icon in the app down with
// it). It also falls back per-icon on Image.Error, so a name that exists in
// the qrc set but not in the runtime masters degrades to the old fixed colour
// instead of rendering nothing.
//
// THE ONE WAY A BAKE STILL SILENTLY LIES — worth keeping in mind when adding
// an icon: NO VARIANT ON DISK -> the Image resolves to nothing and the glyph
// is simply ABSENT, with nothing logged. The runtime sets are always complete
// (every master, every tint), so this now only applies to the qrc fallback
// dirs, where `favorite/` and `amber/` are deliberately partial. A new icon
// goes into assets/icons/primary/ AND into the MASTERS table in
// src/icon_tint_qt.rs; the qrc dirs stay whatever they are.
import QtQuick
import com.blitzfc.qbz
Image {
id: root
// Icon file name, e.g. "compass" (".svg" appended).
property string name: ""
// One of: "primary" | "secondary" | "muted" | "accent" | "warning" |
// "black" (for glyphs on white discs).
// One of the names in the vocabulary above.
property string tintName: "secondary"
// The qrc directory this tint falls back to. Every tint name — including
// the ones with no directory of their own ("white", "textPrimary",
// "accentText" ...) — must land on a REAL dir, or the fallback renders
// nothing at all.
readonly property string qrcTint: {
switch (root.tintName) {
case "white":
case "textPrimary":
case "accentText":
return "primary"
case "textSecondary":
return "secondary"
case "textMuted":
case "textDisabled":
case "disabled":
return "muted"
default:
return root.tintName
}
}
// Absolute qrc URL: relative URLs in a shared component resolve
// against the CONSUMER's document depth (phase-23 dir layout — root
// consumers underflowed one level).
source: name === "" ? "" : "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/icons/" + tintName + "/" + name + ".svg"
readonly property string qrcDir:
"qrc:/qt/qml/com/blitzfc/qbz/qml/assets/icons/" + root.qrcTint
// The runtime-tinted directory for this tint under the live theme, or ""
// when the qrc bake must be used (see above).
readonly property string tintDir: {
// Dependency: re-resolve every icon when the theme is republished.
const themeJson = QbzShell.themeJson
// `typeof` on an unregistered type name does not throw (plain JS
// global lookup), so a missing registration degrades to the bakes
// instead of poisoning every source binding with `undefined`.
if (typeof QbzIconTint === "undefined")
return ""
const dir = QbzIconTint.dirFor(root.tintName, themeJson)
return (typeof dir === "string") ? dir : ""
}
// Set once, per icon, if the runtime set has no such glyph (a master the
// table does not carry yet). Cleared whenever the inputs change so a
// recycled delegate is never stuck on the fallback.
property bool qrcFallback: false
onNameChanged: root.qrcFallback = false
onTintNameChanged: root.qrcFallback = false
onTintDirChanged: root.qrcFallback = false
readonly property string activeDir:
(root.tintDir === "" || root.qrcFallback) ? root.qrcDir : root.tintDir
source: root.name === "" ? "" : root.activeDir + "/" + root.name + ".svg"
onStatusChanged: {
if (status === Image.Error && !root.qrcFallback && root.tintDir !== "")
root.qrcFallback = true
}
sourceSize: Qt.size(Math.max(1, Math.round(width * 2)), Math.max(1, Math.round(height * 2)))
fillMode: Image.PreserveAspectFit
asynchronous: false
+80 -8
View File
@@ -35,8 +35,38 @@
// Hosts that cannot reach this item (the image lives inside AlbumCard,
// PlaylistCard, …) use the equivalent self-probing arm of the placeholder
// itself: `QbzSkeleton { coverSource: <path> }` — see QbzSkeleton.qml.
//
// ── THE RASTER IS IN DEVICE PIXELS ─────────────────────────────────────────
// `onPaint` lays everything out in DEVICE pixels and pre-divides the context
// by the device pixel ratio, instead of drawing in the item's logical pixels.
//
// Measured on Qt 6.11 (200x200 item, 600x600 cover, xcb, QT_SCALE_FACTOR=2,
// error vs a Lanczos 600->400 reference): drawing in logical pixels lands at
// RMSE 0.100 and its content is bit-for-bit the DPR-1 200x200 raster blown up
// 2x (RMSE 0.0026 against exactly that upscale) — half the detail the screen
// can show. The same paint in device pixels lands at RMSE 0.061 with a real
// 400x400 raster. At DPR 1 `scale(1,1)` makes this byte-identical to the old
// body, so it costs nothing on an unscaled screen; the backing store is
// device-sized either way, so it costs no extra memory on a scaled one.
//
// `_dpr` is a property, not a local, so a window dragged from a 1x screen to
// a 2x one repaints instead of leaving the old raster stretched.
//
// ── WHAT THIS DOES **NOT** FIX (see also QbzSkeleton) ──────────────────────
// `Context2D.drawImage` does NOT filter when it scales: measured against
// ImageMagick references, a 600 -> 200 draw is EXACTLY nearest-neighbour
// (RMSE 0.000 vs -filter Point) and so is a mild 230 -> 200 draw. The Canvas
// is loss-FREE only at 1:1 (RMSE 0.000 with a 200px source). Qt gives no way
// to ask `Canvas.loadImage()` for a decode size — `sourceSize` on the probe
// does not leak into it (the QQuickPixmap cache keys on the requested size),
// `drawImage(<Image item>)` draws nothing, and `grabToImage` refuses a hidden
// item. The cure is therefore a SOURCE FILE already at the drawn device size,
// which has to come from the Rust artwork pipeline — see GLUE NEEDED in the
// handoff. Until it lands, cards fed a 600px cover are point-sampled.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
Canvas {
id: root
@@ -55,6 +85,35 @@ Canvas {
/// itself on every source change.
property string _paintedSource: ""
/// ── THE SCALED SOURCE ──────────────────────────────────────────────────
/// drawImage does not filter when it scales, so a 600px cover in a 200px
/// cell is point-sampled — measured RMSE 0.000 against an ImageMagick
/// `-filter Point` reference. The cure is a source file ALREADY at the
/// drawn device size, so the draw is 1:1 and there is no resample at all.
/// Rust produces the derivative once per (cover, size) and answers on
/// artScaledReady, keyed by the REQUEST so a recycled delegate cannot take
/// the previous row's cover.
property string _scaled: ""
readonly property string _effectiveSource: root._scaled !== "" ? root._scaled : root.source
/// Device pixels the art is actually drawn at, rounded up so a fractional
/// DPR never asks for less than the screen shows.
readonly property int _reqW: Math.ceil(root.width * root._dpr)
readonly property int _reqH: Math.ceil(root.height * root._dpr)
function _requestScaled() {
if (root.source === "" || root._reqW <= 0 || root._reqH <= 0) return
if (typeof QbzSession === "undefined") return
QbzSession.artScaled(root.source, root._reqW, root._reqH)
}
Connections {
target: typeof QbzSession !== "undefined" ? QbzSession : null
function onArtScaledReady(path, scaled) {
// Keyed on the REQUEST: a recycled delegate that has already moved
// on ignores the answer meant for its previous row.
if (path === root.source && scaled !== "") root._scaled = scaled
}
}
renderTarget: Canvas.Image
// GUI-thread raster. Cooperative/Threaded rasterize on the SCENE GRAPH
// RENDER THREAD, and this item is created and destroyed by list recycling
@@ -69,7 +128,7 @@ Canvas {
// the async loader notification.
Image {
id: probe
source: root.source
source: root._effectiveSource
visible: false
asynchronous: true
cache: true
@@ -80,19 +139,32 @@ Canvas {
// with NO cover assigns source = "", which emits no imageLoaded, so
// without an explicit repaint the canvas would keep showing the previous
// row's art under a placeholder that thinks it has nothing to cover.
onSourceChanged: { loadImage(source); requestPaint() }
Component.onCompleted: loadImage(source)
// A new cover invalidates the derivative: showing the previous row's
// scaled file while the new one resolves is the stale-content bug again.
onSourceChanged: { root._scaled = ""; loadImage(root._effectiveSource); _requestScaled(); requestPaint() }
on_EffectiveSourceChanged: { loadImage(root._effectiveSource); requestPaint() }
Component.onCompleted: { loadImage(root._effectiveSource); _requestScaled() }
onImageLoaded: requestPaint()
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
onWidthChanged: { requestPaint(); _requestScaled() }
onHeightChanged: { requestPaint(); _requestScaled() }
/// Device pixels per logical pixel for the screen this item is on. Held as
/// a property so moving the window between screens of different scales
/// re-rasterizes (see the DEVICE PIXELS note above).
readonly property real _dpr: Screen.devicePixelRatio > 0 ? Screen.devicePixelRatio : 1
on_DprChanged: { requestPaint(); _requestScaled() }
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var w = width
var h = height
// Everything below is in DEVICE pixels; the context is pre-divided so
// the item still occupies `width` x `height` logical pixels.
var dpr = root._dpr
var w = Math.round(width * dpr)
var h = Math.round(height * dpr)
if (w <= 0 || h <= 0) return
var r = Math.max(0, Math.min(radius, w / 2, h / 2))
if (dpr !== 1) ctx.scale(1 / dpr, 1 / dpr)
var r = Math.max(0, Math.min(radius * dpr, w / 2, h / 2))
if (r > 0) {
ctx.beginPath()
ctx.moveTo(r, 0)
+3
View File
@@ -387,6 +387,9 @@ Rectangle {
// a long editorial description gave the header.
height: page.y + headerDivider.y
tint: album.headerColor || ""
// Route A: the blurred field. Empty until the cover resolves, and the
// flat tint stands in meanwhile (HeaderGradient handles the swap).
atmosphere: album.headerAtmosphere || ""
active: root.headerAtmoOn
}
+3
View File
@@ -980,6 +980,9 @@ Rectangle {
// long bio pushes the fade down with no manual tuning.
height: page.y + jumpBar.y
tint: artist.headerColor || ""
// Route A: the blurred field. Empty until the cover resolves, and the
// flat tint stands in meanwhile (HeaderGradient handles the swap).
atmosphere: artist.headerAtmosphere || ""
active: root.headerAtmoOn
}
+189 -43
View File
@@ -5,8 +5,9 @@
// 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" |
// "artists" | "pinned" | "recentPlaceholder". Rail ORDER + VISIBILITY
// follow the persisted Discover prefs (phase 11, discover_prefs.db).
// "slimTracks" | "artists" | "pinned" | "mixes" | "recentPlaceholder". Rail
// ORDER + VISIBILITY follow the persisted Discover prefs (phase 11,
// discover_prefs.db).
//
// POC-NOTEs:
// - The genre filter (shared GenreFilterPopup, context "discover") and the
@@ -14,12 +15,14 @@
// selection feeds get_discover_index and persists to genre_filter.json; the
// configurator persists to discover_prefs.db and re-renders the tabs from
// the cached section data. The gear is DISABLED on the Recommendations tab
// — its Slint arm configures the external reco engine, which is not ported.
// — its Slint arm also carries the reco cache-window + "Refresh now"
// controls, which DiscoverConfigModal.qml does not have, so it would open
// empty.
// - Editor's Picks / For You mount the same rails, ordered by each tab's
// discover prefs (phase 13); Recommendations renders a placeholder — the
// external reco engine (external_reco.rs) is not ported. The tab bar is
// always fully visible (the Slint showRecommendations gate is not wired;
// the pref is ON for this user anyway).
// discover prefs (phase 13). Recommendations is the external-reco engine
// (src/recommendations_qt.rs), LAZY-loaded the first time the tab becomes
// visible, and the tab itself is gated on the persisted showRecommendations
// pref exactly as in Slint.
// - Card clicks / hover actions (play / favorite / more / pin) and
// "View all" / context menus are inert — album/artist pages, playback
// and per-user stores are later phases.
@@ -53,8 +56,28 @@ Rectangle {
readonly property var sections: JSON.parse(QbzHome.homeSectionsJson)
readonly property var editorSections: JSON.parse(QbzHome.editorSectionsJson)
readonly property var forYouSections: JSON.parse(QbzHome.forYouSectionsJson)
// 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)
property string activeTab: "home"
// Slint gates the 4th tab on the persisted `show_recommendations` pref
// (discover_prefs.db). The port publishes it inside the settings document
// (settings_qt SettingsDoc.showRecommendations) — read it straight off the
// bridge singleton, the NavFlyout precedent.
readonly property bool showRecommendations: {
try {
return JSON.parse(QbzBridge.settingsJson).showRecommendations !== false
} catch (e) {
return true
}
}
// Never leave the user parked on a tab that just disappeared.
onShowRecommendationsChanged: {
if (!showRecommendations && root.activeTab === "recommendations")
root.activeTab = "home"
}
// --- shared genre filter, "discover" context -------------------------
// Read STRAIGHT off the bridge singleton, not off genrePopup: the popup
// is declared LAST (z-order) and a creation-time binding that
@@ -83,13 +106,17 @@ Rectangle {
// Runs only while something can actually be shimmering: the first fetch
// (section skeletons), a card still waiting for its cover, or the grace
// window in which landed covers are still decoding (artHold).
readonly property bool skelNeeded: QbzHome.homeLoading || root.artPending
|| root.artHold
readonly property bool skelNeeded: QbzHome.homeLoading || QbzHome.recoLoading
|| root.artPending || root.artHold
// The sections document the active tab is rendering.
readonly property var activeSections:
root.activeTab === "editorPicks" ? root.editorSections
: root.activeTab === "forYou" ? root.forYouSections
: 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.
readonly property bool artPending: root.anyArtPending(
root.activeTab === "editorPicks" ? root.editorSections
: root.activeTab === "forYou" ? root.forYouSections : root.sections)
readonly property bool artPending: root.anyArtPending(root.activeSections)
function anyArtPending(model) {
for (var s = 0; s < model.length; s++) {
var items = model[s].items || []
@@ -227,10 +254,77 @@ Rectangle {
}
}
// Slim rows mount the shared qml/SlimCard.qml (SlimCard.slint).
// TrackSlimRow — the TRACK flavour of the slim row. Visually the shared
// qml/cards/SlimCard.qml (same 60px row, 44px thumb, title/subtitle),
// but the whole-row click PLAYS the track instead of opening an album.
// SlimCard hardcodes `QbzAlbum.openAlbum(card.id)`, so mounting it for a
// track row would navigate to an album id that does not exist — a control
// that renders and does the WRONG thing. The consolidation (a `kind` /
// `activated()` seam on SlimCard, then delete this) is in the handoff.
component TrackSlimRow: Rectangle {
property var card: ({})
height: 60
radius: theme.radiusSm
color: trArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 12
Rectangle {
width: 44
height: 44
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: card.artPath || ""
radius: 4
}
}
Column {
width: parent.width - 44 - 2 * 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: card.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontLink
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: card.artist || ""
color: theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
}
}
MouseArea {
id: trArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playTrack(card.id)
}
}
// Slim rows mount the shared qml/cards/SlimCard.qml (SlimCard.slint) for
// ALBUM data and TrackSlimRow for TRACK data — one paging shell, two row
// flavours (Slint's SlimCarousel is likewise shared by Popular albums and
// Recently Played Tracks).
// Popular slim grid (SlimCarousel.slint): 4x3 pages of 12, capped 24.
component SlimGrid: Column {
id: sgrid
property var sectionData: ({})
// true = the rows are TRACKS (click plays), false = ALBUMS (click opens).
property bool tracks: false
width: parent ? parent.width : 0
spacing: 12
@@ -242,10 +336,10 @@ Rectangle {
QbzSectionHeader {
title: sectionData.title
leftEnabled: grid.contentX > 1
rightEnabled: grid.contentX < maxScroll - 1
rightEnabled: grid.contentX < sgrid.maxScroll - 1
showViewAll: (sectionData.endpoint || "") !== ""
onPageLeft: grid.contentX = Math.max(0, grid.contentX - grid.width)
onPageRight: grid.contentX = Math.min(maxScroll, grid.contentX + grid.width)
onPageRight: grid.contentX = Math.min(sgrid.maxScroll, grid.contentX + grid.width)
}
Item {
width: parent.width
@@ -255,22 +349,38 @@ Rectangle {
id: grid
width: parent.width
height: parent.height
contentWidth: pageCount * width
contentWidth: sgrid.pageCount * width
contentHeight: height
boundsBehavior: Flickable.StopAtBounds
readonly property real cellWidth: (width - 3 * 8) / 4
Repeater {
model: sectionData.items
delegate: SlimCard {
readonly property int slot: index % perPage
readonly property int pageIdx: Math.floor(index / perPage)
visible: index < total
delegate: Item {
required property var modelData
required property int index
readonly property int slot: index % sgrid.perPage
readonly property int pageIdx: Math.floor(index / sgrid.perPage)
visible: index < sgrid.total
width: grid.cellWidth
height: 60
x: pageIdx * grid.width + (slot % 4) * (grid.cellWidth + 8)
y: Math.floor(slot / 4) * (60 + 8)
card: modelData
// The PinnedRail dispatch pattern: Components declared
// in the delegate scope, so `modelData` resolves.
Component {
id: albumRowComp
SlimCard { card: modelData }
}
Component {
id: trackRowComp
TrackSlimRow { card: modelData }
}
Loader {
anchors.fill: parent
sourceComponent: sgrid.tracks ? trackRowComp : albumRowComp
}
}
}
}
@@ -482,6 +592,7 @@ Rectangle {
sourceComponent: modelData.kind === "album" ? albumRailComp
: modelData.kind === "playlist" ? playlistRailComp
: modelData.kind === "slim" ? slimGridComp
: modelData.kind === "slimTracks" ? trackGridComp
: modelData.kind === "artists" ? artistRailComp
: modelData.kind === "pinned" ? pinnedRailComp
: modelData.kind === "mixes" ? mixesRailComp
@@ -527,6 +638,10 @@ Rectangle {
id: slimGridComp
SlimGrid { sectionData: parent.sectionData }
}
Component {
id: trackGridComp
SlimGrid { sectionData: parent.sectionData; tracks: true }
}
Component {
id: artistRailComp
Column {
@@ -633,12 +748,18 @@ Rectangle {
spacing: 16
QbzTabBar {
tabs: [
{ "id": "home", "label": QbzSession.tr("Home", QbzSession.trRev) },
{ "id": "editorPicks", "label": QbzSession.tr("Editor's Picks", QbzSession.trRev) },
{ "id": "forYou", "label": QbzSession.tr("For You", QbzSession.trRev) },
{ "id": "recommendations", "label": QbzSession.tr("Recommendations", QbzSession.trRev) },
]
// The 4th tab is present only while the pref is on (1:1
// with the Slint `if SettingsState.show-recommendations`).
tabs: {
var t = [
{ "id": "home", "label": QbzSession.tr("Home", QbzSession.trRev) },
{ "id": "editorPicks", "label": QbzSession.tr("Editor's Picks", QbzSession.trRev) },
{ "id": "forYou", "label": QbzSession.tr("For You", QbzSession.trRev) },
]
if (root.showRecommendations)
t.push({ "id": "recommendations", "label": QbzSession.tr("Recommendations", QbzSession.trRev) })
return t
}
activeId: root.activeTab
// Data is per-tab JSON (no refetch on switch); scroll
// resets to top.
@@ -843,26 +964,51 @@ Rectangle {
SectionRails { sectionsModel: root.forYouSections }
}
// ===== Recommendations (POC-NOTE placeholder) =============
// The tab follows the Slint gating (showRecommendations pref
// — ON for this user, so the tab shows); the CONTENT is the
// external reco engine (crates/qbz/src/external_reco.rs —
// seeded similar albums, weeklies builders, dismissal
// stores), which is not ported to the POC.
// ===== Recommendations (external reco engine) =============
// qbz-external-reco (Last.fm + ListenBrainz -> Qobuz), driven
// by src/recommendations_qt.rs. LAZY: the first time this
// column becomes visible it asks Rust to build the tab; every
// later entry repaints from memory / the engine's own result
// cache, so reopening Discover costs no external traffic.
//
// The load is kicked BOTH on mount and on becoming visible:
// the view is instantiated while Home is the active tab, so a
// visible-only hook would be thrown away on the mount pass and
// a mount-only hook would never fire for a tab switch.
Column {
id: recoTab
visible: root.activeTab === "recommendations"
width: parent.width - 64
spacing: 10
Text {
text: QbzSession.tr("Recommendations", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
spacing: 40
function kick() {
if (recoTab.visible)
QbzHome.loadRecommendations()
}
Text {
text: "QBZ Qt POC — the external recommendations engine is not ported (external_reco.rs)"
color: theme.textMuted
font.pixelSize: 13
Component.onCompleted: recoTab.kick()
onVisibleChanged: recoTab.kick()
// Same two-row shimmer as the other tabs, while the first
// build is in flight and nothing has painted yet.
TabSkeleton {
visible: QbzHome.recoLoading && root.recoSections.length === 0
phase: root.skelPhase
}
// Rails resolve progressively — a row that is still
// building, or whose service is not connected, is simply
// ABSENT from the document (never an empty frame).
SectionRails { sectionsModel: root.recoSections }
// Nothing built and nothing in flight: the Slint empty
// state, verbatim msgids.
QbzEmptyState {
visible: !QbzHome.recoLoading && root.recoSections.length === 0
width: parent.width
title: QbzSession.tr("No recommendations yet", QbzSession.trRev)
body: QbzSession.tr("Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations.", QbzSession.trRev)
actionLabel: QbzSession.tr("Open Settings", QbzSession.trRev)
onActionClicked: QbzShell.navigateTo("settings")
}
}
}
+37 -17
View File
@@ -2,8 +2,10 @@
// (phase 17). ONE JSON document (QbzBridge.playlistJson, playlist_qt.rs
// PlaylistDoc: header + track rows + ownership/follow/pin/sort/search).
//
// Header: 150px cover (server image, else first-track cover, else the
// placeholder glyph), "PLAYLIST" eyebrow, name, description-short + Read
// Header: 150px cover (the playlist's OWN Qobuz graphic contain-fitted,
// else the member-cover mosaic via the shared cards/PlaylistCollage.qml,
// which also supplies the placeholder glyph),
// "PLAYLIST" eyebrow, name, description-short + Read
// more (the shared AppShell text modal), owner • N tracks • duration,
// the action row: Play / Shuffle / heart (owned, wired) / pin (wired) /
// follow (foreign, subscribe API) / copy (foreign, create+add) / edit
@@ -36,6 +38,7 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../rows"
import "../theme"
@@ -84,9 +87,14 @@ Rectangle {
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
// True when this playlist carries artwork of its OWN (`image_rectangle`
// — editorial playlists). playlist_qt.rs publishes coverUrl/coverPath for
// that graphic ONLY; everything else falls back to the member-cover
// mosaic in `doc.covers`.
readonly property bool hasOwnArt: (doc.coverUrl || "") !== ""
// The cover is its OWN progressive item: it clears the moment the
// playlist's own cover lands, independently of the track rows.
readonly property bool coverPending: (doc.coverUrl || "") !== ""
readonly property bool coverPending: root.hasOwnArt
&& (doc.coverPath || "") === ""
// The header has no data at all until the first document lands
// (playlist_qt.rs publishes `{ loading: true }` with empty fields).
@@ -202,36 +210,48 @@ Rectangle {
width: parent.width
spacing: 24
// Cover.
// Cover — the SAME two arms as PlaylistCard.qml (the card fix of
// this session, applied to the detail header):
// 1. the playlist's OWN Qobuz graphic (`image_rectangle`,
// published as coverUrl/coverPath) — CONTAIN, because those
// are landscape and cropping cuts the wordmark;
// 2. otherwise the member-cover MOSAIC through the shared
// cards/PlaylistCollage.qml (no second collage), which also
// owns the list-music empty state.
// `doc.covers` are MEMBER-ALBUM covers, never the playlist's own
// artwork — binding one of them as the header image is the bug
// this replaces.
Rectangle {
width: 150
height: 150
radius: theme.radiusMd
color: theme.surfaceElevated
clip: true
// Arm 2 first: the collage is the BACKDROP. Declaration order
// is z-order, so the resolved single cover and the skeleton
// below must come after it.
PlaylistCollage {
anchors.fill: parent
visible: (doc.coverPath || "") === ""
// A playlist that HAS its own graphic passes no urls, so
// it shows the placeholder while that graphic downloads
// instead of flashing a member mosaic (PlaylistCard rule).
urls: root.hasOwnArt ? [] : (doc.covers || [])
radius: theme.radiusMd
}
RoundedImage {
visible: (doc.coverPath || "") !== ""
anchors.fill: parent
source: doc.coverPath || ""
radius: theme.radiusMd
}
QbzIcon {
// Hidden only while the placeholder is actually up; the
// moment it settles (or the cover lands) the designed
// empty-state glyph is what the user sees.
visible: (doc.coverPath || "") === ""
&& (!root.coverPending || plCoverSkel.settled)
name: "list-music"
width: 56
height: 56
anchors.centerIn: parent
tintName: "muted"
fit: root.hasOwnArt ? "contain" : "crop"
}
// Per-item: the cover shimmers until THIS playlist's cover
// lands, then clears on its own. settleMs bounds it — a
// failed download republishes the document with an empty
// coverPath and would otherwise shimmer forever; on settle
// the placeholder fades and the glyph above takes over.
// the placeholder fades and the collage below takes over
// (empty urls -> its list-music glyph, the old empty state).
QbzSkeleton {
id: plCoverSkel
visible: root.coverPending
+16 -2
View File
@@ -257,13 +257,27 @@ fn spawn_header_color(generation: u64, artwork_url: String) {
return;
}
let path = path.trim_start_matches("file://").to_string();
// One decode, two products: the flat tint (the FALLBACK arm) and the
// blurred atmosphere Slint actually renders behind the header.
let p2 = path.clone();
let hex = tokio::task::spawn_blocking(move || header_tint_hex(&path))
.await
.ok()
.flatten();
if let Some(hex) = hex {
let atmo = tokio::task::spawn_blocking(move || {
crate::atmosphere_qt::for_cover_blocking(&p2)
})
.await
.ok()
.flatten();
if hex.is_some() || atmo.is_some() {
publish_patch(generation, move |doc| {
doc["headerColor"] = json!(hex);
if let Some(hex) = hex {
doc["headerColor"] = json!(hex);
}
if let Some(atmo) = atmo {
doc["headerAtmosphere"] = json!(atmo);
}
});
}
});
+16 -2
View File
@@ -807,13 +807,27 @@ fn spawn_header_color(generation: u64, artwork_url: String) {
return;
}
let path = path.trim_start_matches("file://").to_string();
// One decode, two products: the flat tint (Slint's FALLBACK arm) and
// the blurred atmosphere it actually renders behind the header.
let p2 = path.clone();
let hex = tokio::task::spawn_blocking(move || crate::album_qt::header_tint_hex(&path))
.await
.ok()
.flatten();
if let Some(hex) = hex {
let atmo = tokio::task::spawn_blocking(move || {
crate::atmosphere_qt::for_cover_blocking(&p2)
})
.await
.ok()
.flatten();
if hex.is_some() || atmo.is_some() {
publish_patch(generation, move |doc| {
doc["headerColor"] = serde_json::json!(hex);
if let Some(hex) = hex {
doc["headerColor"] = serde_json::json!(hex);
}
if let Some(atmo) = atmo {
doc["headerAtmosphere"] = serde_json::json!(atmo);
}
});
}
});
+39
View File
@@ -446,3 +446,42 @@ mod tests {
assert_eq!(cached_path("/nonexistent/qbz/cover.jpg"), "");
}
}
// ---------------------------------------------------------------------------
// Scaled derivatives
// ---------------------------------------------------------------------------
/// A cover re-encoded to exactly `w`x`h`. Canvas `drawImage` does not filter
/// when it scales — a 600px cover drawn into a 200px cell throws away 8 of
/// every 9 pixels, which is the aliasing the owner sees as poor render
/// quality. Handing the Canvas a file that is ALREADY the target size makes
/// the draw 1:1 and removes the resample entirely.
///
/// Blocking: decodes and resizes. Call from `spawn_blocking`.
pub fn scaled_path(path: &str, w: u32, h: u32) -> Option<std::path::PathBuf> {
if w == 0 || h == 0 {
return None;
}
let src = path.strip_prefix("file://").unwrap_or(path);
let dir = dirs::cache_dir()?.join("qbz").join("images").join("scaled");
std::fs::create_dir_all(&dir).ok()?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(&src, &mut hasher);
let out = dir.join(format!(
"{:016x}_{w}x{h}.jpg",
std::hash::Hasher::finish(&hasher)
));
if out.is_file() {
return Some(out);
}
let img = image::open(src).ok()?;
// Upscaling would only cost bytes — hand back the source instead.
if img.width() <= w && img.height() <= h {
return Some(std::path::PathBuf::from(src));
}
let scaled = image::imageops::thumbnail(&img.to_rgb8(), w, h);
scaled.save(&out).ok()?;
Some(out)
}
+175
View File
@@ -0,0 +1,175 @@
//! Blurred artwork "atmosphere" behind the album and artist headers.
//!
//! Port of `qbz/src/immersive.rs::generate_atmosphere` + `color_adjust` +
//! `vignette`. This is the header treatment Slint actually SHOWS; its flat
//! two-band gradient is only the fallback arm for when the atmosphere is
//! unavailable (`AlbumPageView.slint:233` — `header-atmosphere.width <= 0`).
//! The port had shipped that fallback as if it were the design, which is why
//! the header read as a plain ugly gradient.
//!
//! Nothing here needs a shader: the result is a 128x128 RGBA image, so it works
//! on the software path and on the Pi exactly like any other picture. It is
//! written once per cover into the cache dir and the QML shows it as an Image.
//!
//! The pipeline, stage for stage with the Slint original:
//! source -> 8x8 (Triangle) -> 128x128 (CatmullRom) -> blur 16 ->
//! colour adjust -> vignette 0.20
//! The 8x8 step is not an optimization, it IS the look: everything past it is
//! working from eight pixels of colour, which is what makes the field read as
//! atmosphere rather than as a blurred cover.
use std::path::PathBuf;
use image::{imageops, Rgba, RgbaImage};
/// Saturation / brightness / contrast, `immersive.rs::saturate_brightness_contrast`.
fn saturate_brightness_contrast(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
let (mut rf, mut gf, mut bf) = (r as f32, g as f32, b as f32);
let gray = (rf + gf + bf) / 3.0;
let sat = 2.45;
rf = gray + (rf - gray) * sat;
gf = gray + (gf - gray) * sat;
bf = gray + (bf - gray) * sat;
let brightness = 0.92;
let contrast = 1.18;
let adjust = |v: f32| ((v * brightness - 128.0) * contrast + 128.0).clamp(0.0, 255.0) as u8;
(adjust(rf), adjust(gf), adjust(bf))
}
/// `immersive.rs::color_adjust` — saturate, then normalize each channel's range
/// toward [18, 250], blended by how bright the image already is.
fn color_adjust(mut img: RgbaImage) -> RgbaImage {
let (mut min_r, mut min_g, mut min_b) = (255.0f32, 255.0f32, 255.0f32);
let (mut max_r, mut max_g, mut max_b) = (0.0f32, 0.0f32, 0.0f32);
let mut total_brightness = 0.0f32;
let count = (img.width() * img.height()).max(1) as f32;
for px in img.pixels_mut() {
let (r, g, b) = saturate_brightness_contrast(px[0], px[1], px[2]);
px[0] = r;
px[1] = g;
px[2] = b;
let (rf, gf, bf) = (r as f32, g as f32, b as f32);
min_r = min_r.min(rf);
min_g = min_g.min(gf);
min_b = min_b.min(bf);
max_r = max_r.max(rf);
max_g = max_g.max(gf);
max_b = max_b.max(bf);
total_brightness += (rf + gf + bf) / 3.0;
}
let norm_strength = (total_brightness / count / 80.0).min(1.0);
let target_min = 18.0f32;
let target_range = 232.0f32;
let range_r = (max_r - min_r).max(1.0);
let range_g = (max_g - min_g).max(1.0);
let range_b = (max_b - min_b).max(1.0);
for px in img.pixels_mut() {
let (r, g, b) = (px[0] as f32, px[1] as f32, px[2] as f32);
let norm_r = target_min + ((r - min_r) / range_r) * target_range;
let norm_g = target_min + ((g - min_g) / range_g) * target_range;
let norm_b = target_min + ((b - min_b) / range_b) * target_range;
let lift_r = (r * 1.5).min(255.0);
let lift_g = (g * 1.5).min(255.0);
let lift_b = (b * 1.5).min(255.0);
// The red channel carries the extra 1.08 in the original — keep it.
px[0] = (lift_r + (norm_r - lift_r) * norm_strength)
.mul_add(1.08, 0.0)
.min(255.0) as u8;
px[1] = (lift_g + (norm_g - lift_g) * norm_strength).min(255.0) as u8;
px[2] = (lift_b + (norm_b - lift_b) * norm_strength).min(255.0) as u8;
}
img
}
/// `immersive.rs::vignette` — radial darkening from 20% to 70% of the shorter side.
fn vignette(mut img: RgbaImage, intensity: f32) -> RgbaImage {
let (w, h) = (img.width() as f32, img.height() as f32);
let (cx, cy) = (w / 2.0, h / 2.0);
let inner = w.min(h) * 0.20;
let outer = w.min(h) * 0.70;
for (x, y, px) in img.enumerate_pixels_mut() {
let dx = x as f32 - cx;
let dy = y as f32 - cy;
let d = (dx * dx + dy * dy).sqrt();
let t = ((d - inner) / (outer - inner)).clamp(0.0, 1.0);
let factor = 1.0 - intensity * t;
*px = Rgba([
(px[0] as f32 * factor) as u8,
(px[1] as f32 * factor) as u8,
(px[2] as f32 * factor) as u8,
px[3],
]);
}
img
}
/// The five stages, on an already-decoded cover.
fn atmosphere_from(src: &RgbaImage) -> RgbaImage {
let tiny = imageops::resize(src, 8, 8, imageops::FilterType::Triangle);
let scaled = imageops::resize(&tiny, 128, 128, imageops::FilterType::CatmullRom);
let blurred = imageops::blur(&scaled, 16.0);
vignette(color_adjust(blurred), 0.20)
}
fn cache_dir() -> Option<PathBuf> {
let dir = dirs::cache_dir()?.join("qbz").join("atmosphere");
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
/// Generate (or reuse) the atmosphere for a cover already on disk. Returns a
/// `file://` url for QML, or None when the cover cannot be read.
///
/// Blocking: decodes and resizes. Call it from `spawn_blocking`, never on the
/// Qt thread.
pub fn for_cover_blocking(cover_path: &str) -> Option<String> {
let src_path = cover_path.strip_prefix("file://").unwrap_or(cover_path);
let dir = cache_dir()?;
// Key on the cover path, like the artwork cache does.
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(&src_path, &mut hasher);
let key = format!("{:016x}.png", std::hash::Hasher::finish(&hasher));
let out = dir.join(&key);
if out.is_file() {
return Some(crate::artwork_qt::file_url(&out.to_string_lossy()));
}
let img = image::open(src_path).ok()?.to_rgba8();
let atmo = atmosphere_from(&img);
atmo.save(&out).ok()?;
Some(crate::artwork_qt::file_url(&out.to_string_lossy()))
}
#[cfg(test)]
mod tests {
use super::*;
fn flat(w: u32, h: u32, rgb: [u8; 3]) -> RgbaImage {
RgbaImage::from_pixel(w, h, Rgba([rgb[0], rgb[1], rgb[2], 255]))
}
#[test]
fn atmosphere_is_128_square() {
let out = atmosphere_from(&flat(600, 600, [90, 40, 140]));
assert_eq!((out.width(), out.height()), (128, 128));
}
#[test]
fn vignette_darkens_the_corner_more_than_the_centre() {
let out = atmosphere_from(&flat(64, 64, [200, 120, 60]));
let centre = out.get_pixel(64, 64);
let corner = out.get_pixel(2, 2);
let lum = |p: &Rgba<u8>| p[0] as u32 + p[1] as u32 + p[2] as u32;
assert!(lum(corner) < lum(centre), "corner {corner:?} vs centre {centre:?}");
}
#[test]
fn alpha_survives_the_pipeline() {
let out = atmosphere_from(&flat(32, 32, [10, 200, 90]));
assert!(out.pixels().all(|p| p[3] == 255));
}
}
+13 -5
View File
@@ -31,15 +31,20 @@ const LASTFM_SIMILAR_TTL_SECS: i64 = 30 * 24 * 60 * 60;
static CACHE_DIR: OnceLock<Option<std::path::PathBuf>> = OnceLock::new();
fn cache_dir() -> Option<std::path::PathBuf> {
/// The directory holding `external_reco_cache.db`. Shared with the Discover
/// Recommendations tab (`recommendations_qt`) ON PURPOSE: one resolution +
/// results cache for both surfaces, so a similar-albums lookup paid for here
/// is reused there and vice versa.
pub(crate) fn cache_dir() -> Option<std::path::PathBuf> {
CACHE_DIR
.get_or_init(|| dirs::cache_dir().map(|d| d.join("qbz")))
.clone()
}
/// The daily rotation seed, so the row varies day to day but is stable within
/// one day (matching the Slint `rotation_seed`).
fn rotation_seed() -> u64 {
/// one day (matching the Slint `rotation_seed`). Shared with
/// `recommendations_qt` so both surfaces rotate together.
pub(crate) fn rotation_seed() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() / 86_400)
@@ -48,8 +53,11 @@ fn rotation_seed() -> u64 {
// ── RecoCatalog over QbzCore (errors -> empty) ──────────────────────────────
struct CoreRecoCatalog {
runtime: Arc<AppRuntime<LoggingAdapter>>,
/// THE `RecoCatalog` adapter over `QbzCore` for this frontend. Shared with the
/// Discover Recommendations tab (`recommendations_qt`) — there is exactly one
/// adapter in the port, not one per surface.
pub(crate) struct CoreRecoCatalog {
pub(crate) runtime: Arc<AppRuntime<LoggingAdapter>>,
}
#[async_trait]
+19
View File
@@ -35,6 +35,12 @@ pub mod qbz_home {
// HomeSection shape, ordered by each tab's discover prefs).
#[qproperty(QString, editor_sections_json)]
#[qproperty(QString, for_you_sections_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
// connected is simply absent from the document.
#[qproperty(QString, reco_sections_json)]
#[qproperty(bool, reco_loading)]
type QbzHome = super::QbzHomeRust;
@@ -47,6 +53,11 @@ pub mod qbz_home {
/// index + rails and republish `homeSectionsJson`.
#[qinvokable]
fn reload_home(self: Pin<&mut QbzHome>);
/// Discover > Recommendations became visible: build the tab once per
/// session (idempotent; a re-entry only repaints from memory).
#[qinvokable]
fn load_recommendations(self: Pin<&mut QbzHome>);
}
impl cxx_qt::Threading for QbzHome {}
@@ -61,6 +72,8 @@ pub struct QbzHomeRust {
home_sections_json: QString,
editor_sections_json: QString,
for_you_sections_json: QString,
reco_sections_json: QString,
reco_loading: bool,
}
impl Default for QbzHomeRust {
@@ -71,6 +84,8 @@ impl Default for QbzHomeRust {
home_sections_json: QString::from("[]"),
editor_sections_json: QString::from("[]"),
for_you_sections_json: QString::from("[]"),
reco_sections_json: QString::from("[]"),
reco_loading: false,
}
}
}
@@ -99,4 +114,8 @@ impl qbz_home::QbzHome {
pub fn reload_home(self: Pin<&mut Self>) {
crate::reload_home();
}
pub fn load_recommendations(self: Pin<&mut Self>) {
crate::recommendations_qt::ensure_loaded();
}
}
+398 -77
View File
@@ -9,25 +9,25 @@
//! POC-NOTEs (skipped vs the Slint controller):
//! - Artist/album blacklist filtering (T8): the blacklist store is not
//! opened in this POC (phase-1 skip), so no rows are dropped.
//! - Recently-played rails (local play-history store): OUT OF SCOPE — the
//! view renders the Slint empty-data placeholders instead.
//! - Reco-scored taste ordering of favorite albums (reco store skipped):
//! favorites render in plain favorite order.
//! - Most Played Albums / Radio / similar-albums / rediscover / essentials /
//! to-follow / spotlight rows (For You): reco-engine and local-history
//! rails out of scope (their pref entries are skipped). Qobuz Mixes
//! renders as the four static navigation tiles (the mix DETAIL views are
//! out of scope — tiles inert).
//! - Recommendations tab: the external reco engine (crates/qbz/src/
//! external_reco.rs — seeded similar albums, weeklies builders, dismissal
//! stores) is not ported; the tab renders a placeholder while the
//! `showRecommendations` pref gates its visibility (1:1 Slint).
//! favorites render in plain favorite order, and Rediscover falls back to
//! the local "not in the recently-played window" heuristic (the same
//! fallback the Slint build uses while its reco store is cold).
//! - Radio Stations (For You): the port has no album-radio invokable, so the
//! rail is NOT built — a tile that starts nothing is worse than an absent
//! rail. Artist Spotlight likewise has no ported panel.
//! - Qobuz Mixes renders as the four static navigation tiles (the mix DETAIL
//! views are out of scope — tiles inert).
//! - The "View all" full-list pages: the rails show the link when the
//! section carries an endpoint (1:1 header) but the click is INERT — the
//! DiscoverBrowse page is out of scope.
//! - Editor's Picks / For You: RENDERING parity (same rails as the Slint
//! descriptor arms, prefs-driven order); the progressive per-branch lazy
//! load is simplified to the single discover-index fetch.
//! descriptor arms, prefs-driven order); the Slint per-branch LAZY For You
//! load is flattened into this one pass, so the rails that need a wave-1
//! seed (similar albums, artists to follow) add one extra concurrent
//! round trip to the home load.
//! - Recommendations tab: ported in `recommendations_qt` (this module only
//! supplies the shared `HomeSection`/`HomeCard` transport).
use std::sync::{Arc, Mutex};
@@ -48,6 +48,11 @@ pub struct HomeCard {
pub id: String,
pub title: String,
pub artist: String,
/// Artist rails only: the muted second line ArtistCard renders under the
/// name (the reco "Similar to X, Y" caption). Omitted from the wire when
/// empty — every QML reader is `|| ""`-guarded.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub subtitle: String,
#[serde(rename = "artistId")]
pub artist_id: String,
pub genre: String,
@@ -82,8 +87,10 @@ pub struct HomeCard {
pub struct HomeSection {
pub id: String,
pub title: String,
/// "album" | "slim" | "playlist" | "artists" | "pinned" |
/// "recentPlaceholder".
/// "album" | "slim" | "slimTracks" | "playlist" | "artists" | "pinned" |
/// "mixes" | "recentPlaceholder". `slim` rows are ALBUMS (click opens the
/// album); `slimTracks` rows are TRACKS (click plays) — same card, two
/// activations, so the kind carries the difference.
pub kind: String,
/// Placeholder hint for recentPlaceholder sections.
pub hint: String,
@@ -129,16 +136,43 @@ pub struct DiscoverSections {
pub for_you: Vec<HomeSection>,
}
/// The personalized (non-discover-index) rails, resolved alongside the index.
/// Every one of them self-hides while empty, 1:1 with the Slint arms.
#[derive(Default)]
pub struct Personalized {
/// "Library Albums" — the user's favorite albums.
pub favorite_albums: Vec<HomeCard>,
/// "Release Watch" — /release/watch.
pub release_watch: Vec<HomeCard>,
/// "Your Top Artists" — favorite artists.
pub top_artists: Vec<HomeCard>,
/// "Rediscover Your Library" — favorites absent from the recent window.
pub rediscover: Vec<HomeCard>,
/// "Artists to Follow" — similar artists off the favorite-artist seeds.
pub to_follow: Vec<HomeCard>,
/// "More From Your Library" / "Similar to {seed}" — /album/suggest.
pub similar: Vec<HomeCard>,
/// The localized title for `similar` (it names its seed album).
pub similar_title: String,
}
/// All sections any Discover tab can render, in construction order (the
/// per-tab assembly clones from here). Ids are the DiscoverySectionId keys;
/// "mostStreamed#album" is the EDITOR-tab variant (album carousel — the
/// Home tab renders the same data as the "Popular albums" slim grid).
fn build_candidates(
containers: qbz_models::DiscoverContainers,
favorite_albums: Vec<HomeCard>,
release_watch: Vec<HomeCard>,
top_artists: Vec<HomeCard>,
p: Personalized,
) -> Vec<HomeSection> {
let Personalized {
favorite_albums,
release_watch,
top_artists,
rediscover,
to_follow,
similar,
similar_title,
} = p;
let mut out: Vec<HomeSection> = Vec::new();
push_albums(&mut out, "newReleases", qbz_i18n::t("New Releases"), "/discover/newReleases", containers.new_releases);
@@ -202,25 +236,88 @@ fn build_candidates(
});
}
// Recently-played rails are OUT OF SCOPE (local store) — the Slint
// empty-data placeholders render instead (Home only; the For You arms
// self-hide on empty data, so the forYou assembly drops these).
out.push(HomeSection {
id: "recentlyPlayedAlbums".to_string(),
title: qbz_i18n::t("Recently Played Albums"),
kind: "recentPlaceholder".to_string(),
hint: qbz_i18n::t("Albums you play will appear here."),
endpoint: String::new(),
items: Vec::new(),
});
out.push(HomeSection {
id: "continueListening".to_string(),
title: qbz_i18n::t("Recently Played Tracks"),
kind: "recentPlaceholder".to_string(),
hint: qbz_i18n::t("Tracks you play will appear here."),
endpoint: String::new(),
items: Vec::new(),
});
// Recently-played rails — the SHARED local history file
// (`recently_qt`, the same `recently_played.json` the Slint build
// writes). With data they are a real album carousel / slim TRACK
// carousel; empty they keep the Slint placeholders, which the For You
// assembly drops (its arms self-hide instead).
let recent_albums: Vec<HomeCard> = crate::recently_qt::load_albums()
.into_iter()
.map(map_recent_album)
.collect();
if recent_albums.is_empty() {
out.push(HomeSection {
id: "recentlyPlayedAlbums".to_string(),
title: qbz_i18n::t("Recently Played Albums"),
kind: "recentPlaceholder".to_string(),
hint: qbz_i18n::t("Albums you play will appear here."),
endpoint: String::new(),
items: Vec::new(),
});
} else {
out.push(HomeSection {
id: "recentlyPlayedAlbums".to_string(),
title: qbz_i18n::t("Recently Played Albums"),
kind: "album".to_string(),
hint: String::new(),
// Slint's "View all" here opens a LOCAL page (not a /discover
// endpoint); that page is not ported, so no header link.
endpoint: String::new(),
items: recent_albums,
});
}
let recent_tracks: Vec<HomeCard> = crate::recently_qt::load_tracks()
.into_iter()
.take(24)
.map(map_recent_track)
.collect();
if recent_tracks.is_empty() {
out.push(HomeSection {
id: "continueListening".to_string(),
title: qbz_i18n::t("Recently Played Tracks"),
kind: "recentPlaceholder".to_string(),
hint: qbz_i18n::t("Tracks you play will appear here."),
endpoint: String::new(),
items: Vec::new(),
});
} else {
out.push(HomeSection {
id: "continueListening".to_string(),
title: qbz_i18n::t("Recently Played Tracks"),
kind: "slimTracks".to_string(),
hint: String::new(),
endpoint: String::new(),
items: recent_tracks,
});
}
// Most Played Albums — ranked by LOCAL play count
// (`album_play_history`, the shared per-app SQLite store). No network.
let most_played: Vec<HomeCard> = qbz_app::settings::album_play_history::top_albums(20)
.into_iter()
.map(|r| HomeCard {
is_pinned: crate::sidebar_qt::is_pinned("album", &r.album_id),
id: r.album_id,
title: r.title,
artist: r.artist,
artist_id: r.artist_id,
year: r.year,
quality_tier: r.quality_tier,
quality_label: r.quality_label,
art_url: r.artwork_url,
..HomeCard::default()
})
.collect();
if !most_played.is_empty() {
out.push(HomeSection {
id: "mostPlayedAlbums".to_string(),
title: qbz_i18n::t("Most Played Albums"),
kind: "album".to_string(),
hint: String::new(),
endpoint: String::new(),
items: most_played,
});
}
push_albums(
&mut out,
@@ -306,6 +403,39 @@ fn build_candidates(
items: top_artists,
});
}
// For You: "More From Your Library" (/album/suggest, seeded by the most
// recent — else the first favorite — album; the title names its seed,
// 1:1 with the Slint `apply_more_from_library`).
if !similar.is_empty() {
out.push(HomeSection {
id: "similarAlbums".to_string(),
title: similar_title,
kind: "album".to_string(),
hint: String::new(),
endpoint: String::new(),
items: similar,
});
}
if !rediscover.is_empty() {
out.push(HomeSection {
id: "rediscoverLibrary".to_string(),
title: qbz_i18n::t("Rediscover Your Library"),
kind: "album".to_string(),
hint: String::new(),
endpoint: String::new(),
items: rediscover,
});
}
if !to_follow.is_empty() {
out.push(HomeSection {
id: "artistsToFollow".to_string(),
title: qbz_i18n::t("Artists to Follow"),
kind: "artists".to_string(),
hint: String::new(),
endpoint: String::new(),
items: to_follow,
});
}
out
}
@@ -314,9 +444,16 @@ fn build_candidates(
/// pref ids in pref order (a disabled entry hides the section; a pref id
/// the POC does not implement is skipped). `most_streamed_variant` picks
/// the slim (Home) or album (Editor's Picks) candidate for the
/// "mostStreamed" pref id. When `include_tail` (Home only), candidates
/// with NO prefs entry at all append at the end (phase-11 behavior);
/// "mostStreamed" pref id. When `include_tail` (Home only), candidates the
/// prefs know NOTHING about append at the end (phase-11 behavior);
/// tab-specific "#" variants never leak through the tail.
///
/// The tail is measured against ALL THREE tab lists, not just this tab's: a
/// section that another tab owns but this one deliberately omits (For You's
/// `similarAlbums` / `rediscoverLibrary` / `artistsToFollow`) is an
/// intentional absence, and appending it here would silently graft For You
/// rails onto Home. This is the port's equivalent of the Slint
/// `HOME_RENDERABLE` guard.
fn order_by_prefs(
candidates: &[HomeSection],
prefs: &qbz_app::settings::discover_prefs::DiscoverPrefs,
@@ -324,6 +461,7 @@ fn order_by_prefs(
most_streamed_variant: &str,
include_tail: bool,
) -> Vec<HomeSection> {
use qbz_app::settings::discover_prefs::DiscoveryTab;
let tab_prefs = prefs.tab(tab);
let mut gated: Vec<HomeSection> = Vec::new();
for pref in tab_prefs {
@@ -340,8 +478,14 @@ fn order_by_prefs(
}
}
if include_tail {
let known: std::collections::HashSet<&str> =
tab_prefs.iter().map(|p| p.id.as_str()).collect();
let known: std::collections::HashSet<&str> = [
DiscoveryTab::Home,
DiscoveryTab::EditorPicks,
DiscoveryTab::ForYou,
]
.into_iter()
.flat_map(|t| prefs.tab(t).iter().map(|p| p.id.as_str()))
.collect();
for s in candidates {
if !s.id.contains('#') && !known.contains(s.id.as_str()) && !gated.iter().any(|g| g.id == s.id) {
gated.push(s.clone());
@@ -490,25 +634,78 @@ where
// list in the background so the popup opens instantly and a REMEMBERED
// Library > All selection resolves to names (see warm_up's docs).
crate::genre_filter_qt::warm_up();
let (response, favorite_albums, release_watch, top_artists) = tokio::join!(
// Wave 1 — the index plus everything that needs no seed. The favorites
// are fetched ONCE each and feed several rails (Library Albums +
// Rediscover; Top Artists + Artists to Follow).
let (response, fav_albums, release_watch, fav_artists) = tokio::join!(
runtime.core().get_discover_index(genre_ids),
favorite_album_cards(runtime),
fetch_fav_albums(runtime),
fetch_release_watch(runtime),
top_artist_cards(runtime),
fetch_fav_artists(runtime),
);
let response = response.map_err(|e| e.to_string())?;
log::info!(
"[qbz-qt] discover index fetched; building home sections (fav={}, rw={}, artists={})",
favorite_albums.len(),
release_watch.len(),
top_artists.len(),
// Wave 2 — the two rails that need a wave-1 (or local-history) seed.
// Both are silent when their seed is missing: no favorites and no
// history means no request at all.
let recent_albums = crate::recently_qt::load_albums();
// `/album/suggest` only resolves QOBUZ album ids, so a locally-played or
// Plex album is not eligible as the seed (the same source guard the Slint
// Radio rail applies; empty source = legacy entry, treated as Qobuz).
let seed: Option<(String, String)> = recent_albums
.iter()
.find(|a| a.source.is_empty() || a.source.eq_ignore_ascii_case("qobuz"))
.map(|a| (a.id.clone(), a.title.clone()))
.or_else(|| fav_albums.first().map(|a| (a.id.clone(), a.title.clone())))
.filter(|(id, _)| !id.is_empty());
let (similar, to_follow) = tokio::join!(
async {
match seed.as_ref() {
Some((id, _)) => fetch_suggest(runtime, id).await,
None => Vec::new(),
}
},
fetch_to_follow(runtime, &fav_artists),
);
let candidates = build_candidates(
response.containers,
favorite_albums,
// Rediscover: favorites the user has NOT played recently. The Slint build
// prefers its reco store's "forgotten favorites" when warm and falls back
// to exactly this heuristic when cold; this port only has the fallback.
let recent_ids: std::collections::HashSet<String> =
recent_albums.iter().map(|a| a.id.clone()).collect();
let rediscover: Vec<HomeCard> = fav_albums
.iter()
.filter(|a| !recent_ids.contains(&a.id))
.take(18)
.cloned()
.map(map_flat_album)
.collect();
let personalized = Personalized {
favorite_albums: fav_albums.iter().take(18).cloned().map(map_flat_album).collect(),
release_watch,
top_artists,
top_artists: fav_artists.iter().take(18).cloned().map(map_fav_artist).collect(),
rediscover,
to_follow,
similar_title: match seed.as_ref() {
Some((_, title)) if !title.is_empty() => {
qbz_i18n::t_args("Similar to {}", &[title.as_str()])
}
_ => qbz_i18n::t("More From Your Library"),
},
similar,
};
log::info!(
"[qbz-qt] discover index fetched; building home sections (fav={}, rw={}, artists={}, \
rediscover={}, toFollow={}, similar={})",
personalized.favorite_albums.len(),
personalized.release_watch.len(),
personalized.top_artists.len(),
personalized.rediscover.len(),
personalized.to_follow.len(),
personalized.similar.len(),
);
let candidates = build_candidates(response.containers, personalized);
let sections = assemble(&candidates, &load_prefs());
if let Ok(mut cache) = CANDIDATES.lock() {
*cache = candidates;
@@ -740,18 +937,53 @@ fn map_flat_album(album: Album) -> HomeCard {
}
}
/// "Library Albums" — favorite albums, capped at 18, in favorite order
/// (reco taste-ordering skipped — cold reco store = no reorder upstream).
async fn favorite_album_cards<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<HomeCard>
/// Map one recently-played album (local history) onto a card. The stored ISO
/// release date is localized here exactly as the discover cards do.
fn map_recent_album(a: crate::recently_qt::RecentAlbum) -> HomeCard {
HomeCard {
is_pinned: crate::sidebar_qt::is_pinned("album", &a.id),
id: a.id,
title: a.title,
artist: a.artist,
genre: a.genre,
year: if a.release_date.is_empty() {
String::new()
} else {
qbz_text_utils::dates::release_label(Some(a.release_date.as_str()))
},
quality_tier: a.quality_tier,
quality_label: a.quality_label,
art_url: a.artwork_url,
..HomeCard::default()
}
}
/// Map one recently-played track onto a slim row (`slimTracks` — click plays).
fn map_recent_track(t: crate::recently_qt::RecentTrack) -> HomeCard {
HomeCard {
id: t.id,
title: t.title,
artist: t.subtitle,
art_url: if t.artwork_url.is_empty() {
t.album_artwork_url
} else {
t.artwork_url
},
..HomeCard::default()
}
}
/// The user's favorite albums, in favorite order. ONE fetch feeding both the
/// "Library Albums" rail and Rediscover (reco taste-ordering skipped — the
/// port has no reco store, so favorites keep their Qobuz order).
async fn fetch_fav_albums<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<Album>
where
A: FrontendAdapter + Send + Sync + 'static,
{
match runtime.core().get_favorites("albums", 100, 0).await {
Ok(value) => qbz_models::lenient::parse_items_array::<Album>(&value, "albums", "home fav album")
.into_iter()
.take(18)
.map(map_flat_album)
.collect(),
Ok(value) => {
qbz_models::lenient::parse_items_array::<Album>(&value, "albums", "home fav album")
}
Err(e) => {
log::warn!("[qbz-qt] favorite albums fetch failed: {e}");
Vec::new()
@@ -774,25 +1006,16 @@ where
}
}
/// "Your Top Artists" — favorite artists, capped 18.
async fn top_artist_cards<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<HomeCard>
/// The user's favorite artists. ONE fetch feeding both "Your Top Artists"
/// and the "Artists to Follow" similar-artist seeds.
async fn fetch_fav_artists<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<Artist>
where
A: FrontendAdapter + Send + Sync + 'static,
{
match runtime.core().get_favorites("artists", 50, 0).await {
Ok(value) => qbz_models::lenient::parse_items_array::<Artist>(&value, "artists", "home artist")
.into_iter()
.take(18)
.map(|a| HomeCard {
id: a.id.to_string(),
title: a.name,
art_url: a
.image
.and_then(|img| img.best().cloned())
.unwrap_or_default(),
..HomeCard::default()
})
.collect(),
Ok(value) => {
qbz_models::lenient::parse_items_array::<Artist>(&value, "artists", "home artist")
}
Err(e) => {
log::warn!("[qbz-qt] favorite artists fetch failed: {e}");
Vec::new()
@@ -800,6 +1023,104 @@ where
}
}
fn map_fav_artist(a: Artist) -> HomeCard {
HomeCard {
is_pinned: crate::sidebar_qt::is_pinned("artist", &a.id.to_string()),
id: a.id.to_string(),
title: a.name,
item_kind: "artist".to_string(),
art_url: a
.image
.and_then(|img| img.best().cloned())
.unwrap_or_default(),
..HomeCard::default()
}
}
/// Seeds for "Artists to Follow" (foryou.rs `ARTIST_SEEDS`).
const ARTIST_SEEDS: usize = 4;
/// Similar artists requested per seed (foryou.rs `SIMILAR_PER_SEED`).
const SIMILAR_PER_SEED: u32 = 10;
/// Display cap for the row (foryou.rs `FOLLOW_MAX`).
const FOLLOW_MAX: usize = 18;
/// Similar artists for ONE seed (absent seed -> no request, empty result).
async fn seed_similar<A>(runtime: &Arc<AppRuntime<A>>, seed: Option<u64>) -> Vec<Artist>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let Some(id) = seed else {
return Vec::new();
};
match runtime.core().get_similar_artists(id, SIMILAR_PER_SEED, 0).await {
Ok(page) => page.items,
Err(e) => {
log::warn!("[qbz-qt] similar artists fetch failed (seed {id}): {e}");
Vec::new()
}
}
}
/// "Artists to Follow" — similar artists off up to [`ARTIST_SEEDS`]
/// favorites, excluding the ones already followed. The seed calls run
/// CONCURRENTLY, but the dedup + [`FOLLOW_MAX`] cap are then applied
/// sequentially IN SEED ORDER, so the membership matches the reference's
/// sequential loop exactly. No favorites -> no seeds -> no request.
async fn fetch_to_follow<A>(runtime: &Arc<AppRuntime<A>>, fav_artists: &[Artist]) -> Vec<HomeCard>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let seeds: Vec<u64> = fav_artists.iter().take(ARTIST_SEEDS).map(|a| a.id).collect();
if seeds.is_empty() {
return Vec::new();
}
// Fixed 4-wide fan-out (ARTIST_SEEDS); missing seeds resolve instantly.
let (s0, s1, s2, s3) = tokio::join!(
seed_similar(runtime, seeds.first().copied()),
seed_similar(runtime, seeds.get(1).copied()),
seed_similar(runtime, seeds.get(2).copied()),
seed_similar(runtime, seeds.get(3).copied()),
);
let mut seen: std::collections::HashSet<u64> = fav_artists.iter().map(|a| a.id).collect();
let mut out: Vec<HomeCard> = Vec::new();
'outer: for group in [s0, s1, s2, s3] {
for artist in group {
if out.len() >= FOLLOW_MAX {
break 'outer;
}
if seen.insert(artist.id) {
out.push(map_fav_artist(artist));
}
}
}
out
}
/// "More From Your Library" — `/album/suggest` for a seed album, capped 18.
async fn fetch_suggest<A>(runtime: &Arc<AppRuntime<A>>, album_id: &str) -> Vec<HomeCard>
where
A: FrontendAdapter + Send + Sync + 'static,
{
if album_id.is_empty() {
return Vec::new();
}
match runtime.core().get_album_suggest(album_id).await {
Ok(resp) => resp
.albums
.map(|p| p.items)
.unwrap_or_default()
.into_iter()
.take(18)
.map(map_flat_album)
.collect(),
Err(e) => {
log::warn!("[qbz-qt] album suggest fetch failed: {e}");
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+566
View File
@@ -0,0 +1,566 @@
//! Runtime icon tinting — the Qt equivalent of Slint's render-time `colorize`.
//!
//! # Why this file exists
//!
//! Slint's `QbzIcon` (ui/primitives/QbzIcon.slint) is three lines: an `Image`
//! with `colorize: <live Theme token>`. The renderer recolours the glyph every
//! frame, so an icon is ALWAYS the current theme's colour, for all 36 themes
//! and for a user's custom accent.
//!
//! Qt gives this port no such capability on the path it actually runs on: a
//! `ColorOverlay` / `MultiEffect` / shader draws NOTHING on the software
//! renderer (verified with a probe during the POC), which is why the port
//! shipped PRE-BAKED SVG variants instead — `qml/assets/icons/<tint>/`, one
//! directory per tint, each a fixed hex burned into the file.
//!
//! A fixed hex cannot serve 36 themes. The bakes were cut against the DARK
//! palette, so on every LIGHT theme `primary` (#ffffff), `secondary`
//! (#cccccc) and `muted` (#888888) are near-white glyphs on a near-white
//! surface (the header nav row, the Settings sub-nav and the app menu the
//! owner reported), and `accent` is always #4285f4 no matter what the theme's
//! accent is.
//!
//! # What this does
//!
//! On demand, per (tint token, live theme), it writes a COMPLETE tinted icon
//! set to the user cache dir and hands QML a `file://` directory URL:
//!
//! <cache>/qbz/icon-tints/<rrggbb>/<name>.svg
//!
//! The source of every glyph is the existing `primary/` bake (compiled in
//! with `include_str!`), whose only paint literal is `#ffffff` — the tint is
//! a literal string substitution, no XML parsing, so a glyph can never come
//! out geometrically different from the bake that already ships.
//!
//! The directory is keyed by the COLOUR, not by the theme, so the 36 themes
//! share whatever hexes they have in common (most share `#ffffff` /
//! `#0f0f0f` text ramps and a handful of accents), and switching back to a
//! previously used theme is a single `is_file()` check.
//!
//! Everything else stays exactly as it was: this module never touches the
//! qrc bakes, and every failure path (no cache dir, read-only home, an
//! unparsable theme document, a tint that is deliberately fixed) returns an
//! EMPTY string, which `QbzIcon.qml` reads as "use the qrc bake" — the
//! pre-existing behaviour. There is no state in which an icon can vanish
//! because of this file.
//!
//! # Which tints follow the theme, and the one that deliberately does not
//!
//! THEME-FOLLOWING (baked here): `textPrimary`, `secondary` / `textSecondary`,
//! `muted` / `textMuted`, `disabled` / `textDisabled`, `accent`, `accentText`,
//! `warning`, `favorite` — each named after its `ThemeColors` key
//! (theme_qt.rs `ThemeTokens`).
//!
//! FIXED (kept on their qrc bake): `black`, `amber` — and `primary`.
//!
//! `primary` is the interesting one, and it is NOT an oversight. The port's
//! `"primary"` tint has always been a literal `#ffffff`, and roughly a third
//! of its call sites depend on that: glyphs on artwork scrims (`#a6000000`,
//! `#b3000000`), on gradient discs, on `theme.accent` fills, on the close
//! button's fixed `#e81123` hover. Those are dark under EVERY theme, so
//! resolving `primary` to `Theme.text-primary` would paint a dark glyph on a
//! dark host — it would move the bug rather than fix it. Slint does not have
//! this problem because it passes the two meanings separately
//! (CircleAction.slint:70-74 hands `#ffffff` to the glyph on the accent disc
//! and `Theme.text-primary` to the one on the surface).
//!
//! So the vocabulary splits the two meanings instead of overloading one name:
//! - `white` — the honest name for the fixed light glyph (same qrc
//! bake `primary` already resolves to);
//! - `accentText`— the glyph colour ON an accent fill, theme-following;
//! - `textPrimary` — Slint's `Theme.text-primary`, theme-following, what
//! every "raise this glyph to max contrast on a THEME
//! surface" call site actually wanted.
//! `primary` stays a legacy alias of `white` so no existing call site moves
//! until it is migrated, and a migrated one is a pure rename with no polarity
//! ternary anywhere.
//!
//! # Cost
//!
//! One pass of 96 small string substitutions + writes (~380 KB) the first
//! time a colour is seen, on the Qt thread inside a binding evaluation
//! (measured in single-digit ms on any SSD); nothing at all afterwards. The
//! cache is pruned to `MAX_TINT_DIRS` directories, oldest first.
use std::collections::{BTreeSet, HashMap};
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use cxx_qt_lib::QString;
#[cxx_qt::bridge]
pub mod qbz_icon_tint {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
type QbzIconTint = super::QbzIconTintRust;
/// The directory URL holding `<name>.svg` for `tint` under the theme
/// described by `theme_json`, or "" when the caller must fall back to
/// the compiled-in qrc bake.
///
/// `theme_json` is `QbzShell.themeJson` — it is BOTH the colour
/// source and the QML binding dependency that makes every icon
/// re-resolve on a live theme switch (the same trick
/// `QbzSession.tr(msgid, trRev)` uses for the language switch).
#[qinvokable]
fn dir_for(self: &QbzIconTint, tint: QString, theme_json: QString) -> QString;
}
}
/// Rust side of the tint bridge — the state is process-global (below) because
/// the singleton is queried from hundreds of icon bindings and must not pay a
/// per-instance re-parse.
#[derive(Default)]
pub struct QbzIconTintRust {
_reserved: bool,
}
// ---------------------------------------------------------------------------
// The master glyphs
// ---------------------------------------------------------------------------
/// Every icon the app can ask for, as the `primary/` bake's source text.
///
/// This IS the shipped `qml/assets/icons/primary/` directory (96 files),
/// compiled in. Two consequences worth knowing:
/// - every runtime tint is COMPLETE, so the partial-directory trap the qrc
/// bakes have (`favorite/` = 52 files, `amber/` = 1) does not exist here;
/// - a new icon dropped into `primary/` needs a line ADDED here, otherwise
/// that one glyph silently drops back to its qrc bake (visible, just not
/// theme-correct). `QbzIcon.qml` handles the miss; it does not go blank.
#[rustfmt::skip]
const MASTERS: &[(&str, &str)] = &[
("add-to-list", include_str!("../qml/assets/icons/primary/add-to-list.svg")),
("arrow-up-down", include_str!("../qml/assets/icons/primary/arrow-up-down.svg")),
("award", include_str!("../qml/assets/icons/primary/award.svg")),
("blind-eye", include_str!("../qml/assets/icons/primary/blind-eye.svg")),
("book-open", include_str!("../qml/assets/icons/primary/book-open.svg")),
("bug", include_str!("../qml/assets/icons/primary/bug.svg")),
("cassette-tape", include_str!("../qml/assets/icons/primary/cassette-tape.svg")),
("cast", include_str!("../qml/assets/icons/primary/cast.svg")),
("cd", include_str!("../qml/assets/icons/primary/cd.svg")),
("check", include_str!("../qml/assets/icons/primary/check.svg")),
("chevron-down", include_str!("../qml/assets/icons/primary/chevron-down.svg")),
("chevron-left", include_str!("../qml/assets/icons/primary/chevron-left.svg")),
("chevron-right", include_str!("../qml/assets/icons/primary/chevron-right.svg")),
("chevron-up", include_str!("../qml/assets/icons/primary/chevron-up.svg")),
("clock", include_str!("../qml/assets/icons/primary/clock.svg")),
("cloud-download", include_str!("../qml/assets/icons/primary/cloud-download.svg")),
("cloud-off", include_str!("../qml/assets/icons/primary/cloud-off.svg")),
("cloud-upload", include_str!("../qml/assets/icons/primary/cloud-upload.svg")),
("compass", include_str!("../qml/assets/icons/primary/compass.svg")),
("copy", include_str!("../qml/assets/icons/primary/copy.svg")),
("disc-3", include_str!("../qml/assets/icons/primary/disc-3.svg")),
("disc", include_str!("../qml/assets/icons/primary/disc.svg")),
("element-connect", include_str!("../qml/assets/icons/primary/element-connect.svg")),
("ellipsis", include_str!("../qml/assets/icons/primary/ellipsis.svg")),
("eye-off", include_str!("../qml/assets/icons/primary/eye-off.svg")),
("eye", include_str!("../qml/assets/icons/primary/eye.svg")),
("folder-open", include_str!("../qml/assets/icons/primary/folder-open.svg")),
("folder-plus", include_str!("../qml/assets/icons/primary/folder-plus.svg")),
("folder", include_str!("../qml/assets/icons/primary/folder.svg")),
("hard-drive", include_str!("../qml/assets/icons/primary/hard-drive.svg")),
("heart-filled", include_str!("../qml/assets/icons/primary/heart-filled.svg")),
("heart", include_str!("../qml/assets/icons/primary/heart.svg")),
("home-gear", include_str!("../qml/assets/icons/primary/home-gear.svg")),
("import", include_str!("../qml/assets/icons/primary/import.svg")),
("infinity", include_str!("../qml/assets/icons/primary/infinity.svg")),
("info", include_str!("../qml/assets/icons/primary/info.svg")),
("layers", include_str!("../qml/assets/icons/primary/layers.svg")),
("layout-grid", include_str!("../qml/assets/icons/primary/layout-grid.svg")),
("library-big", include_str!("../qml/assets/icons/primary/library-big.svg")),
("link", include_str!("../qml/assets/icons/primary/link.svg")),
("list-end", include_str!("../qml/assets/icons/primary/list-end.svg")),
("list-filter", include_str!("../qml/assets/icons/primary/list-filter.svg")),
("list-music", include_str!("../qml/assets/icons/primary/list-music.svg")),
("list-ordered", include_str!("../qml/assets/icons/primary/list-ordered.svg")),
("list-plus", include_str!("../qml/assets/icons/primary/list-plus.svg")),
("list-start", include_str!("../qml/assets/icons/primary/list-start.svg")),
("list", include_str!("../qml/assets/icons/primary/list.svg")),
("list-x", include_str!("../qml/assets/icons/primary/list-x.svg")),
("loader-circle", include_str!("../qml/assets/icons/primary/loader-circle.svg")),
("log-out", include_str!("../qml/assets/icons/primary/log-out.svg")),
("map-pin", include_str!("../qml/assets/icons/primary/map-pin.svg")),
("maximize-2", include_str!("../qml/assets/icons/primary/maximize-2.svg")),
("menu", include_str!("../qml/assets/icons/primary/menu.svg")),
("mic-vocal", include_str!("../qml/assets/icons/primary/mic-vocal.svg")),
("minimize-2", include_str!("../qml/assets/icons/primary/minimize-2.svg")),
("minus", include_str!("../qml/assets/icons/primary/minus.svg")),
("monitor-speaker", include_str!("../qml/assets/icons/primary/monitor-speaker.svg")),
("moon", include_str!("../qml/assets/icons/primary/moon.svg")),
("mp3", include_str!("../qml/assets/icons/primary/mp3.svg")),
("music-library-2", include_str!("../qml/assets/icons/primary/music-library-2.svg")),
("music", include_str!("../qml/assets/icons/primary/music.svg")),
("panel-left", include_str!("../qml/assets/icons/primary/panel-left.svg")),
("panel-right-close", include_str!("../qml/assets/icons/primary/panel-right-close.svg")),
("pause", include_str!("../qml/assets/icons/primary/pause.svg")),
("pen-line", include_str!("../qml/assets/icons/primary/pen-line.svg")),
("picture-in-picture-2", include_str!("../qml/assets/icons/primary/picture-in-picture-2.svg")),
("pin-filled", include_str!("../qml/assets/icons/primary/pin-filled.svg")),
("pin", include_str!("../qml/assets/icons/primary/pin.svg")),
("play-fill", include_str!("../qml/assets/icons/primary/play-fill.svg")),
("plus", include_str!("../qml/assets/icons/primary/plus.svg")),
("qbz-symbolic", include_str!("../qml/assets/icons/primary/qbz-symbolic.svg")),
("radio", include_str!("../qml/assets/icons/primary/radio.svg")),
("refresh-cw", include_str!("../qml/assets/icons/primary/refresh-cw.svg")),
("repeat-1", include_str!("../qml/assets/icons/primary/repeat-1.svg")),
("repeat", include_str!("../qml/assets/icons/primary/repeat.svg")),
("rotate-ccw", include_str!("../qml/assets/icons/primary/rotate-ccw.svg")),
("rows-3", include_str!("../qml/assets/icons/primary/rows-3.svg")),
("search", include_str!("../qml/assets/icons/primary/search.svg")),
("settings-2", include_str!("../qml/assets/icons/primary/settings-2.svg")),
("shopping-bag", include_str!("../qml/assets/icons/primary/shopping-bag.svg")),
("shuffle", include_str!("../qml/assets/icons/primary/shuffle.svg")),
("skip-back", include_str!("../qml/assets/icons/primary/skip-back.svg")),
("skip-forward", include_str!("../qml/assets/icons/primary/skip-forward.svg")),
("sliders-horizontal", include_str!("../qml/assets/icons/primary/sliders-horizontal.svg")),
("square-check-big", include_str!("../qml/assets/icons/primary/square-check-big.svg")),
("sun-moon", include_str!("../qml/assets/icons/primary/sun-moon.svg")),
("sun", include_str!("../qml/assets/icons/primary/sun.svg")),
("thumbs-down", include_str!("../qml/assets/icons/primary/thumbs-down.svg")),
("translate", include_str!("../qml/assets/icons/primary/translate.svg")),
("trash-2", include_str!("../qml/assets/icons/primary/trash-2.svg")),
("trash-list", include_str!("../qml/assets/icons/primary/trash-list.svg")),
("user-plus", include_str!("../qml/assets/icons/primary/user-plus.svg")),
("user", include_str!("../qml/assets/icons/primary/user.svg")),
("volume-2", include_str!("../qml/assets/icons/primary/volume-2.svg")),
("volume-x", include_str!("../qml/assets/icons/primary/volume-x.svg")),
("x", include_str!("../qml/assets/icons/primary/x.svg")),
];
/// The only paint literal in a `primary/` master (verified over all 96 files:
/// 105 occurrences, no `#fff` short form). The uppercase form is accepted too
/// so a hand-added master cannot silently skip the substitution.
const MASTER_HEX_LOWER: &str = "#ffffff";
const MASTER_HEX_UPPER: &str = "#FFFFFF";
/// Written last into a tint directory; its presence means "complete".
const STAMP: &str = ".complete";
/// How many tinted sets to keep on disk (~380 KB each). 36 themes x 7 tokens
/// is the theoretical ceiling, but the palettes share most of their text ramp,
/// so this covers ordinary theme-hopping without unbounded growth.
const MAX_TINT_DIRS: usize = 32;
// ---------------------------------------------------------------------------
// Tint name -> ThemeColors token
// ---------------------------------------------------------------------------
/// Every `ThemeTokens` key a tint can resolve to — the set this module bakes.
const TOKENS: &[&str] = &[
"textPrimary",
"textSecondary",
"textMuted",
"textDisabled",
"accent",
"accentText",
"warning",
"favorite",
];
/// The `ThemeTokens` (theme_qt.rs) key a tint follows, or `None` for the tints
/// that are deliberately theme-independent and keep their qrc bake.
///
/// The short spellings are the ones the port already uses; the `text*` ones
/// name the token exactly and are what new call sites should say.
fn token_for(tint: &str) -> Option<&'static str> {
Some(match tint {
"textPrimary" => "textPrimary",
"secondary" | "textSecondary" => "textSecondary",
"muted" | "textMuted" => "textMuted",
"disabled" | "textDisabled" => "textDisabled",
"accent" => "accent",
"accentText" => "accentText",
"warning" => "warning",
"favorite" => "favorite",
// Fixed on purpose (see the module header):
// "primary" / "white": a light glyph on a host that is dark under
// every theme (scrims, gradients, accent fills)
// "black": a dark glyph on a host painted light
// "amber": #e0b341, the audio stamp's brand accent
_ => return None,
})
}
/// `#aarrggbb` (what theme_qt.rs `argb()` emits), `#rrggbb` or `#rgb` ->
/// lowercase `rrggbb`. The alpha byte is dropped: SVG paint attributes have no
/// ARGB notation, and every tint token in the registry is opaque.
fn rgb6(value: &str) -> Option<String> {
let s = value.trim().trim_start_matches('#');
if !s.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
match s.len() {
8 => Some(s[2..8].to_ascii_lowercase()),
6 => Some(s.to_ascii_lowercase()),
3 => Some(
s.chars()
.flat_map(|c| [c.to_ascii_lowercase(), c.to_ascii_lowercase()])
.collect(),
),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Process-global state
// ---------------------------------------------------------------------------
/// `<cache>/qbz/icon-tints`, resolved once. `None` = no usable cache dir, and
/// every caller falls back to qrc for the life of the process.
fn tint_root() -> Option<&'static Path> {
static ROOT: OnceLock<Option<PathBuf>> = OnceLock::new();
ROOT.get_or_init(|| {
let dir = dirs::cache_dir()?.join("qbz").join("icon-tints");
match fs::create_dir_all(&dir) {
Ok(()) => Some(dir),
Err(e) => {
log::warn!("[qbz-qt] icon tint cache unavailable ({e}); using the baked icons");
None
}
}
})
.as_deref()
}
/// The parsed tint map for the theme document last seen, keyed by its hash so
/// the hundreds of per-icon calls between two theme switches parse nothing.
static TINTS: Mutex<Option<(u64, HashMap<&'static str, String>)>> = Mutex::new(None);
/// Colours whose directory is known-good on disk (checked once per process).
static BAKED: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
fn hash_of(s: &str) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
/// token -> `rrggbb` for every theme-following tint in one document.
fn tints_from_json(json: &str) -> HashMap<&'static str, String> {
let mut out = HashMap::new();
let Ok(doc) = serde_json::from_str::<serde_json::Value>(json) else {
return out;
};
for token in TOKENS {
if let Some(hex) = doc.get(*token).and_then(|v| v.as_str()).and_then(rgb6) {
out.insert(*token, hex);
}
}
out
}
// ---------------------------------------------------------------------------
// Baking
// ---------------------------------------------------------------------------
/// Materialize `<root>/<hex>/` with all 96 glyphs. Returns false on any I/O
/// failure (caller falls back to qrc).
///
/// The set is built in a temp directory and RENAMED into place, so a crash or
/// a full disk mid-bake can never leave a half-populated directory that later
/// looks complete.
fn bake(root: &Path, hex: &str) -> bool {
let dir = root.join(hex);
if dir.join(STAMP).is_file() {
return true;
}
let tmp = root.join(format!(".tmp-{hex}-{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
if let Err(e) = fs::create_dir_all(&tmp) {
log::warn!(
"[qbz-qt] icon tint {hex}: cannot create {}: {e}",
tmp.display()
);
return false;
}
let color = format!("#{hex}");
for (name, source) in MASTERS {
let tinted = source
.replace(MASTER_HEX_LOWER, &color)
.replace(MASTER_HEX_UPPER, &color);
if let Err(e) = fs::write(tmp.join(format!("{name}.svg")), tinted) {
log::warn!("[qbz-qt] icon tint {hex}: write {name}.svg failed: {e}");
let _ = fs::remove_dir_all(&tmp);
return false;
}
}
if fs::write(tmp.join(STAMP), hex).is_err() {
let _ = fs::remove_dir_all(&tmp);
return false;
}
match fs::rename(&tmp, &dir) {
Ok(()) => {
log::debug!("[qbz-qt] icon tint {hex}: baked {} glyphs", MASTERS.len());
true
}
Err(_) => {
// Another run won the race; keep whatever is already there.
let _ = fs::remove_dir_all(&tmp);
dir.join(STAMP).is_file()
}
}
}
/// Keep the cache bounded: drop the oldest complete sets past `MAX_TINT_DIRS`.
/// The set just written has the newest mtime, so it is never the victim.
///
/// Returns the colours removed, so the caller can drop them from the
/// known-good set — otherwise a hex pruned mid-session would keep being
/// handed out as a URL to a directory that is no longer there.
fn prune(root: &Path) -> Vec<String> {
let Ok(entries) = fs::read_dir(root) else {
return Vec::new();
};
let mut dirs: Vec<(std::time::SystemTime, PathBuf)> = entries
.flatten()
.filter(|e| e.path().is_dir() && !e.file_name().to_string_lossy().starts_with(".tmp-"))
.filter_map(|e| {
let modified = e.metadata().ok()?.modified().ok()?;
Some((modified, e.path()))
})
.collect();
if dirs.len() <= MAX_TINT_DIRS {
return Vec::new();
}
dirs.sort_by_key(|(t, _)| *t);
let excess = dirs.len() - MAX_TINT_DIRS;
let mut dropped = Vec::new();
for (_, path) in dirs.into_iter().take(excess) {
if fs::remove_dir_all(&path).is_ok() {
if let Some(hex) = path.file_name().and_then(|n| n.to_str()) {
dropped.push(hex.to_string());
}
}
}
dropped
}
// ---------------------------------------------------------------------------
// The invokable
// ---------------------------------------------------------------------------
impl qbz_icon_tint::QbzIconTint {
pub fn dir_for(&self, tint: QString, theme_json: QString) -> QString {
let tint = tint.to_string();
// Fixed tints ("black", "amber") and anything unknown: qrc.
let Some(token) = token_for(&tint) else {
return QString::default();
};
let Some(root) = tint_root() else {
return QString::default();
};
let json = theme_json.to_string();
if json.is_empty() {
return QString::default();
}
let hex = {
let mut guard = TINTS.lock().unwrap_or_else(|e| e.into_inner());
let key = hash_of(&json);
if guard.as_ref().map(|(k, _)| *k) != Some(key) {
*guard = Some((key, tints_from_json(&json)));
}
match guard.as_ref().and_then(|(_, m)| m.get(token)) {
Some(hex) => hex.clone(),
// The document does not carry this token (a truncated or
// pre-publish payload): qrc.
None => return QString::default(),
}
};
{
let mut baked = BAKED.lock().unwrap_or_else(|e| e.into_inner());
if !baked.contains(&hex) {
if !bake(root, &hex) {
return QString::default();
}
baked.insert(hex.clone());
for dropped in prune(root) {
baked.remove(&dropped);
}
}
}
// Absolute paths start with '/', so this yields file:///home/...
QString::from(&format!("file://{}", root.join(&hex).display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_master_carries_the_substitutable_paint() {
for (name, source) in MASTERS {
assert!(
source.contains(MASTER_HEX_LOWER) || source.contains(MASTER_HEX_UPPER),
"{name}.svg has no #ffffff to tint — it would bake identical in every colour"
);
}
}
#[test]
fn masters_cover_every_shipped_glyph() {
// The qrc bake directories are the contract QML call sites resolve
// against; a name only in `muted/` (mp3) is exactly how the first
// gap shipped.
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("qml/assets/icons");
let mut shipped: BTreeSet<String> = BTreeSet::new();
for tint in ["primary", "secondary", "muted", "accent", "warning"] {
let Ok(entries) = fs::read_dir(dir.join(tint)) else {
continue;
};
for e in entries.flatten() {
if let Some(stem) = e.path().file_stem().and_then(|s| s.to_str()) {
shipped.insert(stem.to_string());
}
}
}
let known: BTreeSet<String> = MASTERS.iter().map(|(n, _)| n.to_string()).collect();
let missing: Vec<&String> = shipped.difference(&known).collect();
assert!(missing.is_empty(), "masters missing: {missing:?}");
}
#[test]
fn argb_and_rgb_notations_normalize() {
assert_eq!(rgb6("#ffcccccc").as_deref(), Some("cccccc"));
assert_eq!(rgb6("#4285F4").as_deref(), Some("4285f4"));
assert_eq!(rgb6("#abc").as_deref(), Some("aabbcc"));
assert_eq!(rgb6("transparent"), None);
}
#[test]
fn fixed_tints_never_resolve_to_a_token() {
// These three are the whole reason a light theme does not break the
// glyphs that sit on scrims, gradients and white discs.
assert!(token_for("primary").is_none());
assert!(token_for("white").is_none());
assert!(token_for("black").is_none());
assert!(token_for("amber").is_none());
assert_eq!(token_for("muted"), Some("textMuted"));
assert_eq!(token_for("textPrimary"), Some("textPrimary"));
assert_eq!(token_for("accentText"), Some("accentText"));
}
#[test]
fn every_token_is_bakeable() {
for token in TOKENS {
assert!(
token_for(token).is_some(),
"{token} is baked but no tint name resolves to it"
);
}
}
}
+4
View File
@@ -31,10 +31,14 @@ mod library_bridge;
mod album_bridge;
mod artist_bridge;
mod artwork_qt;
mod atmosphere_qt;
mod bridge;
mod discover_config_qt;
mod genre_filter_qt;
mod home_qt;
mod icon_tint_qt;
mod recently_qt;
mod recommendations_qt;
mod library_db_qt;
mod library_qt;
mod local_library_qt;
+39
View File
@@ -35,6 +35,14 @@ pub struct NowPlayingModel {
pub album: String,
pub album_id: String,
pub artist_id: String,
/// "Playing from" ORIGIN of the current track — the container the queue was
/// launched from, re-derived per track change (never a stale global).
/// `context_kind` is "album" | "artist" | "playlist" | "label";
/// `context_id` is that container's navigation id. Feeds the song-card
/// layers glyph (SongCard.slint `ctx-kind` / `ctx-id`, :151-156). Empty id
/// = no origin -> the card falls back to the track's own album.
pub context_kind: String,
pub context_id: String,
pub artwork_path: String,
pub elapsed_secs: i32,
pub duration_secs: i32,
@@ -111,6 +119,10 @@ fn publish(m: &NowPlayingModel) {
.set_np_album_id(QString::from(m.album_id.as_str()));
b.as_mut()
.set_np_artist_id(QString::from(m.artist_id.as_str()));
b.as_mut()
.set_np_context_kind(QString::from(m.context_kind.as_str()));
b.as_mut()
.set_np_context_id(QString::from(m.context_id.as_str()));
b.as_mut()
.set_np_artwork_path(QString::from(m.artwork_path.as_str()));
b.as_mut().set_np_elapsed_secs(m.elapsed_secs);
@@ -153,12 +165,31 @@ fn publish(m: &NowPlayingModel) {
pub fn publish_current() {
let (_, snapshot) = with_model(|_| ());
publish(&snapshot);
// The layers-glyph preference (see `publish_show_context_icon`).
publish_show_context_icon();
// Seed the two output LEDs + the volume-lock flag at shell entry too, so
// the stamp is correct before the first track ever plays (the bridge
// defaults are the unlit SYST/DEFAULT pair).
crate::output_labels::publish_current();
}
/// Re-publish the "Show track playing context" preference onto the bar (the
/// song-card layers glyph gate).
///
/// The bridge seeds `show_context_icon` exactly ONCE, in
/// `QbzPlayerRust::default()` (player_bridge.rs:260) — i.e. when the QML engine
/// constructs the singleton, which is before a session exists — and
/// `settings_qt::show_context_icon` (settings_qt.rs:422-424) returns FALSE
/// whenever the playback-preferences store is not open at that instant
/// (`unwrap_or(false)`). Nothing republished it afterwards, so the glyph could
/// latch OFF for the whole run and the Settings > Playback toggle wrote the DB
/// with no visible effect — a control that renders and does nothing. Call this
/// on shell entry and from the toggle.
pub fn publish_show_context_icon() {
let show = crate::settings_qt::show_context_icon();
crate::player_bridge::ui(move |mut b| b.as_mut().set_show_context_icon(show));
}
// --- Pure-UI toggles (mutate + republish) --------------------------------
pub fn set_volume(volume: f32) {
@@ -199,6 +230,12 @@ pub struct TrackMeta {
pub album: String,
pub album_id: String,
pub artist_id: String,
/// Container origin of THIS track ("album" | "artist" | "playlist" |
/// "label" + its id). Never optional at this seam: `refresh_now_playing`
/// resolves the fallback (the track's own album) before it gets here, so a
/// caller cannot publish a track with no origin at all.
pub context_kind: String,
pub context_id: String,
pub duration_secs: i32,
pub quality_tier: String,
pub quality_label: String,
@@ -220,6 +257,8 @@ pub fn set_track(meta: TrackMeta) {
m.album = meta.album;
m.album_id = meta.album_id;
m.artist_id = meta.artist_id;
m.context_kind = meta.context_kind;
m.context_id = meta.context_id;
m.duration_secs = meta.duration_secs;
m.elapsed_secs = 0;
m.cache = 0.0;
+216 -11
View File
@@ -55,6 +55,147 @@ pub(crate) fn current_quality() -> Quality {
static MUTED: AtomicBool = AtomicBool::new(false);
static PREMUTE_VOLUME: AtomicU64 = AtomicU64::new(0);
// ---------------------------------------------------------------------------
// Playback CONTEXT — the "playing from" origin (song-card layers glyph)
// ---------------------------------------------------------------------------
/// The container a queue was launched FROM. `kind` is "album" | "artist" |
/// "playlist" | "label" and `id` that container's navigation id — 1:1 with the
/// Slint `open-context(kind, id)` -> `media-action(kind, id, "open")` arms
/// (qbz/src/main.rs:12695 artist, :12701 album, :12707 playlist, :13206 label).
///
/// HARDENING (owner ask): the origin is NOT something a play path has to
/// remember. It travels WITH the queue — every play/enqueue entry point in this
/// module funnels through `set_queue_stamped` / `stamped`, which stamp it onto
/// EVERY track, and when the caller passes none it is DERIVED from the queue
/// itself (`derive_context`). A new entry point therefore cannot silently ship
/// an unstamped queue; the worst case is the same album fallback the Slint uses
/// in `refresh_now_playing_meta` (playback.rs:1959-1965).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlayContext {
pub kind: String,
pub id: String,
}
impl PlayContext {
/// None for an empty kind/id — an empty context must never be stamped (it
/// would shadow the per-track album fallback with a dead glyph).
pub fn new(kind: &str, id: &str) -> Option<Self> {
if kind.is_empty() || id.is_empty() {
return None;
}
Some(Self { kind: kind.to_string(), id: id.to_string() })
}
pub fn album(id: &str) -> Option<Self> {
Self::new("album", id)
}
pub fn artist(id: &str) -> Option<Self> {
Self::new("artist", id)
}
#[allow(dead_code)] // used once the playlist play path routes through here
pub fn playlist(id: &str) -> Option<Self> {
Self::new("playlist", id)
}
}
/// Infer the container from the queue itself: one shared album -> that album,
/// otherwise one shared artist -> that artist (an artist page's Popular Tracks
/// span many albums but ONE artist — the exact case that shipped contextless).
/// Nothing shared -> None, and the per-track album fallback takes over.
fn derive_context(tracks: &[QueueTrack]) -> Option<PlayContext> {
let mut album: Option<&str> = None;
let mut one_album = true;
let mut artist: Option<u64> = None;
let mut one_artist = true;
for track in tracks {
match track.album_id.as_deref().filter(|s| !s.is_empty()) {
None => one_album = false,
Some(id) => {
if let Some(prev) = album {
if prev != id {
one_album = false;
}
} else {
album = Some(id);
}
}
}
match track.artist_id {
None => one_artist = false,
Some(id) => {
if let Some(prev) = artist {
if prev != id {
one_artist = false;
}
} else {
artist = Some(id);
}
}
}
}
if one_album {
if let Some(id) = album {
return PlayContext::album(id);
}
}
// A SINGLE track that shares "one artist" is not an artist container — it
// is a bare track play, whose Slint fallback is its own album. Only a
// multi-track queue earns the artist origin.
if one_artist && tracks.len() > 1 {
if let Some(id) = artist {
return PlayContext::artist(&id.to_string());
}
}
None
}
/// Stamp the origin onto every track that does not already carry one. Tracks
/// that arrive pre-stamped (the album/playlist/local builders) keep theirs, so
/// a mixed queue stays honest per row.
fn stamp_context(tracks: &mut [QueueTrack], explicit: Option<PlayContext>) {
let resolved = match explicit {
Some(ctx) => Some(ctx),
None => derive_context(tracks),
};
let Some(ctx) = resolved else {
return;
};
for track in tracks.iter_mut() {
let already_stamped = track.context_kind.as_deref().is_some_and(|k| !k.is_empty())
&& track.context_id.as_deref().is_some_and(|i| !i.is_empty());
if already_stamped {
continue;
}
track.context_kind = Some(ctx.kind.clone());
track.context_id = Some(ctx.id.clone());
}
}
/// The ONLY `core().set_queue` call in this module — every play path goes
/// through it, so the origin can never be dropped on the floor.
pub(crate) async fn set_queue_stamped(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
mut tracks: Vec<QueueTrack>,
start: Option<usize>,
context: Option<PlayContext>,
) {
stamp_context(&mut tracks, context);
runtime.core().set_queue(tracks, start).await;
}
/// Same guarantee for the ADD paths (play-next / add-to-queue): appended tracks
/// carry their own origin, so the glyph stays right after the queue advances
/// into them.
fn stamped(mut tracks: Vec<QueueTrack>, context: Option<PlayContext>) -> Vec<QueueTrack> {
stamp_context(&mut tracks, context);
tracks
}
// ---------------------------------------------------------------------------
// Play an album (album-card click on Home)
// ---------------------------------------------------------------------------
@@ -193,7 +334,7 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
.collect();
if !top.is_empty() {
let first_id = top[0].id;
runtime.core().set_queue(top, Some(0)).await;
set_queue_stamped(runtime, top, Some(0), PlayContext::artist(artist_id)).await;
publish_queue(runtime).await;
runtime
.core()
@@ -232,7 +373,14 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
}
log::info!("[qbz-qt] artist-play {artist_id}: discography fallback, {} tracks", queue.len());
let first_id = queue[0].id;
runtime.core().set_queue(queue, Some(0)).await;
// The discography queue arrives pre-stamped per ALBUM (fetch_album_queue);
// the artist origin is what the user launched, so it wins here — the
// explicit context overrides the per-album stamp for the whole queue.
for track in queue.iter_mut() {
track.context_kind = None;
track.context_id = None;
}
set_queue_stamped(runtime, queue, Some(0), PlayContext::artist(artist_id)).await;
publish_queue(runtime).await;
runtime
.core()
@@ -251,7 +399,10 @@ pub async fn enqueue_album(
album_id: &str,
mode: &str,
) -> Result<(), String> {
let tracks = fetch_album_queue(runtime, album_id).await?;
let tracks = stamped(
fetch_album_queue(runtime, album_id).await?,
PlayContext::album(album_id),
);
log::info!("[qbz-qt] enqueue_album {album_id} ({mode}): {} tracks", tracks.len());
if mode == "next" {
// add_track_next inserts directly after the current track — feed
@@ -286,7 +437,7 @@ pub async fn play_album_from(
let start = start_index.min(tracks.len() - 1);
let first_id = tracks[start].id;
let count = tracks.len();
runtime.core().set_queue(tracks, Some(start)).await;
set_queue_stamped(runtime, tracks, Some(start), PlayContext::album(album_id)).await;
publish_queue(runtime).await;
log::info!("[qbz-qt] play_album: queue set ({count} tracks), playing track {first_id}");
runtime
@@ -309,7 +460,7 @@ pub async fn play_album_from_track(
let tracks = fetch_album_queue(runtime, album_id).await?;
let start = tracks.iter().position(|t| t.id == track_id).unwrap_or(0);
let first_id = tracks[start].id;
runtime.core().set_queue(tracks, Some(start)).await;
set_queue_stamped(runtime, tracks, Some(start), PlayContext::album(album_id)).await;
publish_queue(runtime).await;
runtime
.core()
@@ -322,11 +473,29 @@ pub async fn play_album_from_track(
/// Play a pre-built queue starting at `start` (ArtistView Popular Tracks:
/// the visible list becomes the queue, anchored at the clicked track).
///
/// The origin is DERIVED from the queue when the caller has none (see
/// `stamp_context`), so this path can never publish a contextless track — the
/// artist page's Popular Tracks span many albums but one artist and resolve to
/// ("artist", artist_id) without the caller lifting a finger.
pub async fn play_track_list(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
start: usize,
shuffle: bool,
) -> Result<(), String> {
play_track_list_in(runtime, tracks, start, shuffle, None).await
}
/// `play_track_list` with an EXPLICIT origin — preferred whenever the caller
/// knows it (artist page, playlist, label), because it survives a queue whose
/// tracks share nothing derivable.
pub async fn play_track_list_in(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
start: usize,
shuffle: bool,
context: Option<PlayContext>,
) -> Result<(), String> {
if tracks.is_empty() {
return Err("empty track list".to_string());
@@ -337,7 +506,7 @@ pub async fn play_track_list(
}
let start = start.min(tracks.len() - 1);
let first_id = tracks[start].id;
runtime.core().set_queue(tracks, Some(start)).await;
set_queue_stamped(runtime, tracks, Some(start), context).await;
publish_queue(runtime).await;
runtime
.core()
@@ -356,7 +525,7 @@ pub async fn enqueue_track_list(
if tracks.is_empty() {
return Err("empty track list".to_string());
}
runtime.core().add_tracks(tracks).await;
runtime.core().add_tracks(stamped(tracks, None)).await;
publish_queue(runtime).await;
Ok(())
}
@@ -450,7 +619,9 @@ pub async fn play_single_track(
track_id: u64,
) -> Result<(), String> {
let qt = feed_queue_track(track_id)?;
runtime.core().set_queue(vec![qt], Some(0)).await;
// Bare single-track play: no container origin, so the derive falls to the
// track's own album — same landing spot as the Slint fallback.
set_queue_stamped(runtime, vec![qt], Some(0), None).await;
publish_queue(runtime).await;
log::info!("[qbz-qt] play_single_track: playing {track_id}");
runtime
@@ -470,7 +641,9 @@ pub async fn enqueue_single_track(
track_id: u64,
mode: &str,
) -> Result<(), String> {
let qt = feed_queue_track(track_id)?;
let qt = stamped(vec![feed_queue_track(track_id)?], None)
.pop()
.expect("one track in, one track out");
match mode {
"next" => runtime.core().add_track_next(qt).await,
"later" => runtime.core().add_track_later(qt).await,
@@ -618,15 +791,40 @@ pub(crate) async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>
track.duration_secs,
);
let (tier, label) = quality_badge(&track);
let album_id = track.album_id.clone().unwrap_or_default();
// Album with its release variant appended ("Octavarium (2009 Remaster)"),
// 1:1 with the Slint `album_display` (playback.rs:1941-1949).
let album_display = match track
.album_version
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
Some(v) => format!("{} ({v})", track.album),
None => track.album.clone(),
};
// "Playing from" origin for the song-card layers glyph, re-derived from the
// CURRENT track's own stamp on EVERY change (never a stale global) —
// playback.rs:1953-1965. A track with no container origin falls back to its
// own album, exactly like the Slint.
let (context_kind, context_id) = match (
track.context_kind.as_deref().filter(|s| !s.is_empty()),
track.context_id.as_deref().filter(|s| !s.is_empty()),
) {
(Some(kind), Some(id)) => (kind.to_string(), id.to_string()),
_ => ("album".to_string(), album_id.clone()),
};
crate::now_playing::set_track(crate::now_playing::TrackMeta {
title,
artist: track.artist.clone(),
album: track.album.clone(),
album_id: track.album_id.clone().unwrap_or_default(),
album: album_display,
album_id,
artist_id: track
.artist_id
.map(|id| id.to_string())
.unwrap_or_default(),
context_kind,
context_id,
duration_secs: track.duration_secs as i32,
quality_tier: tier,
quality_label: label,
@@ -736,6 +934,13 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
// DE-DUPED track edge on purpose — firing it from
// refresh_now_playing would re-arm the timer on every republish.
crate::integrations_qt::on_track_change_edge(&runtime);
// Local play history — Recently Played and Most Played read the
// same file the other frontends write; without this the Qt build
// shows their history and never adds to it. Same de-duped edge
// as the scrobblers, never from refresh_now_playing.
if let Some(track) = runtime.core().get_queue_state().await.current_track {
crate::recently_qt::record_queue_track(&track);
}
last_track_id = track_id;
}
+14
View File
@@ -95,6 +95,12 @@ pub mod qbz_player {
#[qproperty(QString, np_album)]
#[qproperty(QString, np_album_id)]
#[qproperty(QString, np_artist_id)]
// "Playing from" ORIGIN of the current track (NowPlayingState
// context-kind / context-id): the container the queue was launched
// from, republished on every track change. kind is "album" | "artist"
// | "playlist" | "label"; the song-card layers glyph navigates there.
#[qproperty(QString, np_context_kind)]
#[qproperty(QString, np_context_id)]
// "Show track playing context" pref (Playback settings) — feeds the
// SongCard layers icon.
#[qproperty(bool, show_context_icon)]
@@ -212,6 +218,8 @@ pub struct QbzPlayerRust {
np_album: QString,
np_album_id: QString,
np_artist_id: QString,
np_context_kind: QString,
np_context_id: QString,
show_context_icon: bool,
}
@@ -257,6 +265,12 @@ impl Default for QbzPlayerRust {
np_album: QString::default(),
np_album_id: QString::default(),
np_artist_id: QString::default(),
np_context_kind: QString::default(),
np_context_id: QString::default(),
// Seed only — this runs when the QML engine constructs the
// singleton, which can be BEFORE the playback-preferences store is
// open (then it reads false). `now_playing::publish_show_context_icon`
// re-publishes it on shell entry and on the Settings toggle.
show_context_icon: crate::settings_qt::show_context_icon(),
}
}
+97 -23
View File
@@ -4,8 +4,9 @@
//!
//! Wired:
//! - load: `get_playlist` (full track list inline) + header mapping
//! (server cover else first-track cover, owner/count/total duration,
//! HTML-stripped description + word-boundary 160-char short).
//! (the playlist's OWN `image_rectangle` graphic else the member-cover
//! mosaic urls, owner/count/total duration, HTML-stripped description +
//! word-boundary 160-char short).
//! - play all / shuffle (the playlist track list as the queue).
//! - favorite toggle (owned), follow/unfollow (foreign, subscribe API,
//! optimistic flip + revert), copy-to-library (create + add all ids),
@@ -35,7 +36,7 @@ use std::sync::{Arc, Mutex};
use cxx_qt_lib::QString;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_models::{QueueTrack, Track};
use qbz_models::{Playlist, QueueTrack, Track};
use serde::Serialize;
// ---------------------------------------------------------------------------
@@ -86,10 +87,25 @@ pub struct PlaylistDoc {
pub description: String,
#[serde(rename = "descriptionShort")]
pub description_short: String,
/// The playlist's OWN Qobuz artwork (`image_rectangle`), and NOTHING
/// else — EMPTY for every playlist without one (user playlists, and
/// editorial ones whose graphic Qobuz omits). The `images*` lists are
/// member-ALBUM covers; binding them here is what put an album sleeve
/// where the playlist graphic belongs (same divergence the cards had).
/// The header renders this CONTAIN — the graphics are landscape and
/// cropping cuts the wordmark.
#[serde(rename = "coverUrl")]
pub cover_url: String,
#[serde(rename = "coverPath")]
pub cover_path: String,
/// Mosaic source for a playlist with no artwork of its own: up to four
/// de-duplicated MEMBER-ALBUM covers (`images300` > `images150` >
/// `images`, else the first four distinct track covers — the Slint
/// header's 2x2 collage reads `tracks[0..3].artwork`). The view feeds
/// them to the shared `cards/PlaylistCollage.qml`, which resolves the
/// urls itself and owns the empty (list-music) state.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub covers: Vec<String>,
pub tracks: Vec<PlaylistTrackRow>,
#[serde(rename = "trackCount")]
pub track_count: i32,
@@ -273,6 +289,63 @@ fn total_duration_label(tracks: &[PlaylistTrackRow]) -> String {
}
}
// ---------------------------------------------------------------------------
// Header artwork (the same split the cards use — library_qt.rs
// `playlist_own_image` / `playlist_cover_urls`; kept local because those are
// private to that module. Hoisting the pair into one shared helper is the
// obvious follow-up, see the report.)
// ---------------------------------------------------------------------------
/// The playlist's OWN Qobuz graphic (`image_rectangle`, `..._mini` as the
/// lighter fallback). Only editorial playlists carry one.
fn playlist_own_image(playlist: &Playlist) -> String {
[&playlist.image_rectangle, &playlist.image_rectangle_mini]
.into_iter()
.flatten()
.find_map(|list| list.iter().find(|u| !u.is_empty()).cloned())
.unwrap_or_default()
}
/// Up to four de-duplicated MEMBER-ALBUM covers for the header mosaic:
/// the server lists first (`images300` > `images150` > `images` — the same
/// picker as the sidebar tree and the cards, so one playlist shows the same
/// mosaic everywhere), else the first four distinct track covers (what the
/// Slint header's 2x2 collage reads).
fn collage_urls(playlist: &Playlist, tracks: &[Track]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let push = |url: String, out: &mut Vec<String>| {
if !url.is_empty() && !out.contains(&url) {
out.push(url);
}
};
let listed = [&playlist.images300, &playlist.images150, &playlist.images]
.into_iter()
.flatten()
.find(|v| !v.is_empty());
if let Some(list) = listed {
for url in list {
push(url.clone(), &mut out);
if out.len() == 4 {
return out;
}
}
}
if out.is_empty() {
for track in tracks {
let url = track
.album
.as_ref()
.and_then(|a| a.image.best().cloned())
.unwrap_or_default();
push(url, &mut out);
if out.len() == 4 {
break;
}
}
}
out
}
// ---------------------------------------------------------------------------
// Load
// ---------------------------------------------------------------------------
@@ -291,23 +364,21 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
publish(doc);
}
let pl = runtime
let mut pl = runtime
.core()
.get_playlist(playlist_id)
.await
.map_err(|e| format!("get_playlist {playlist_id} failed: {e}"))?;
let tracks = pl.tracks.map(|c| c.items).unwrap_or_default();
let cover_url = pl
.images
.as_ref()
.and_then(|imgs| imgs.first().cloned())
.or_else(|| {
tracks
.first()
.and_then(|t| t.album.as_ref())
.and_then(|a| a.image.best().cloned())
})
.unwrap_or_default();
// `take` (not clone): the header helpers below still need `&pl`, and the
// track list is the heavy half of the response.
let tracks = pl.tracks.take().map(|c| c.items).unwrap_or_default();
// Header artwork, two arms — 1:1 with the cards (PlaylistCard.qml):
// the playlist's OWN graphic, else the member-cover mosaic. `images[0]`
// (and the first track's sleeve) is a MEMBER-ALBUM cover: binding it
// here is exactly what rendered an album sleeve as the playlist's
// artwork, and it also starved the collage of its tiles.
let cover_url = playlist_own_image(&pl);
let covers = collage_urls(&pl, &tracks);
let description = pl
.description
.map(|d| qbz_text_utils::strip_html::strip_html(&d))
@@ -363,6 +434,7 @@ pub async fn load(runtime: &Arc<AppRuntime<LoggingAdapter>>, playlist_id: u64) -
doc.description = description;
doc.cover_url = cover_url.clone();
doc.cover_path = cover_path;
doc.covers = covers;
doc.tracks = rows;
doc.track_count = track_count;
doc.total_duration = total_duration;
@@ -569,13 +641,15 @@ pub fn toggle_pin() {
doc
});
if let Some(doc) = doc {
crate::toggle_pin(
"playlist".to_string(),
doc.id,
doc.name,
doc.owner,
doc.cover_url,
);
// Pin payload artwork: the playlist's own graphic, else its first
// member cover so the Pinned rail card still has art (PlaylistCard's
// `artworkUrl` falls back the same way).
let art = if doc.cover_url.is_empty() {
doc.covers.first().cloned().unwrap_or_default()
} else {
doc.cover_url.clone()
};
crate::toggle_pin("playlist".to_string(), doc.id, doc.name, doc.owner, art);
}
}
+100 -11
View File
@@ -175,6 +175,50 @@ fn paginate(total: usize, requested_page: usize) -> (usize, usize, usize, usize)
(page, page_count, start, end)
}
/// Queue-wide upcoming indices of the rows the panel is CURRENTLY showing, in
/// display order (search filter + pagination applied) — the hoisted port of
/// `crates/qbz/src/queue.rs::resolve_upcoming_index`, resolved once per action
/// so one call answers every row on the page.
///
/// WHY IT EXISTS: the QML hands PAGE-LOCAL row numbers, and `page * PAGE_SIZE
/// + row` is the queue index ONLY while the search box is empty. With a query
/// active the page walks the FILTERED list, so that arithmetic names a
/// different track than the one under the pointer — the core indexes the
/// unfiltered upcoming list. Every row mutation (play / remove /
/// remove-all-after / drag reorder) resolves through here, which is also what
/// makes the reorder's insertion slot land on the right gap when the panel is
/// paged or filtered.
///
/// The `paginate` call clamps the page exactly like `publish` does, so the
/// slice is the one on screen even if `VIEW.page` is momentarily past the end.
fn page_row_indices(upcoming: &[QueueTrack], query: &str, page: usize) -> Vec<usize> {
if query.is_empty() {
let (_, _, start, end) = paginate(upcoming.len(), page);
return (start..end).collect();
}
let matches: Vec<usize> = upcoming
.iter()
.enumerate()
.filter(|(_, t)| matches_query(t, query))
.map(|(i, _)| i)
.collect();
let (_, _, start, end) = paginate(matches.len(), page);
matches[start..end].to_vec()
}
/// `page_row_indices` against the live queue + the current view state. ONE
/// `get_queue_state_full` per user action (the mutations below all await a
/// core call anyway); the returned vector is at most PAGE_SIZE long.
async fn current_page_indices(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Vec<usize> {
let (search, page) = {
let view = VIEW.lock().unwrap();
(view.search.clone(), view.page)
};
let query = search.trim().to_lowercase();
let state = runtime.core().get_queue_state_full().await;
page_row_indices(&state.upcoming, &query, page)
}
/// Search predicate — same truth as `display_title(t)` + artist, minus the
/// intermediate `display_title` clone in the (dominant) version-less case.
/// Runs over the WHOLE upcoming list on every keystroke, so the saved
@@ -339,31 +383,73 @@ pub fn set_page(page: i32) {
}
pub async fn play_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
let start = VIEW.lock().unwrap().page * PAGE_SIZE;
let upcoming_index = start + page_index;
let Some(&upcoming_index) = current_page_indices(runtime).await.get(page_index) else {
log::warn!("[qbz-qt] queue: play_upcoming {page_index} out of range");
return;
};
if let Some(track) = runtime.core().play_upcoming_at(upcoming_index).await {
crate::playback_qt::play_queue_track_public(runtime, track.id).await;
}
}
pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
let start = VIEW.lock().unwrap().page * PAGE_SIZE;
let upcoming_index = start + page_index;
let Some(&upcoming_index) = current_page_indices(runtime).await.get(page_index) else {
log::warn!("[qbz-qt] queue: remove_upcoming {page_index} out of range");
return;
};
runtime.core().remove_upcoming_track(upcoming_index).await;
publish(runtime).await;
}
pub async fn remove_all_after(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
let start = VIEW.lock().unwrap().page * PAGE_SIZE;
let upcoming_index = start + page_index;
let Some(&upcoming_index) = current_page_indices(runtime).await.get(page_index) else {
log::warn!("[qbz-qt] queue: remove_all_after {page_index} out of range");
return;
};
runtime.core().remove_upcoming_after(upcoming_index).await;
publish(runtime).await;
}
/// Drag reorder with QUEUE-WIDE indices (the QML list is unfiltered when
/// drag is enabled, so page-local == page*40 + row).
pub async fn move_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, from: usize, to: usize) {
runtime.core().move_track(from, to).await;
/// Drag reorder — move the PAGE-LOCAL row `from_page` to the insertion slot
/// `to_slot` (0..=page_len), the contract of `QueueSidebar.slint`'s
/// `QueueState.reorder`. Both ends resolve to QUEUE-WIDE upcoming indices
/// through `page_row_indices` first: the panel hands page-local numbers like
/// every other row action, and under a search filter (or on page >= 1 of a
/// filtered list) a page-local number is a position in the FILTERED list, not
/// in the queue — passing it straight to the core moved a different track.
///
/// Slot semantics (Slint 1:1): slot k < page_len inserts BEFORE page-local row
/// k; slot == page_len appends after the last visible row (one past it in
/// queue-wide upcoming space). `to == from` and `to == from + 1` are the same
/// gap and are dropped by the panel before they get here; `from_q == to_q` is
/// re-checked below because the two page-local slots can collapse onto one
/// queue index once the filter is resolved.
///
/// POC-NOTE: the Slint arm first offers the move to the QConnect cloud
/// (WS-authoritative when connected); there is no QConnect seam in this port,
/// so the local core path is the only one.
pub async fn move_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
from_page: usize,
to_slot: usize,
) {
let page_rows = current_page_indices(runtime).await;
let Some(&from_q) = page_rows.get(from_page) else {
log::warn!("[qbz-qt] queue: reorder from {from_page} out of range");
return;
};
let to_q = if to_slot >= page_rows.len() {
match page_rows.last() {
Some(&last) => last + 1,
None => return,
}
} else {
page_rows[to_slot]
};
if from_q == to_q {
return;
}
runtime.core().move_track(from_q, to_q).await;
publish(runtime).await;
}
@@ -374,7 +460,10 @@ pub async fn play_history(runtime: &Arc<AppRuntime<LoggingAdapter>>, index: usiz
log::warn!("[qbz-qt] queue: play_history {index} out of range");
return;
};
runtime.core().set_queue(vec![track.clone()], Some(0)).await;
// Through the stamping seam: a history row already carries the origin it
// was played from, so it survives the replay; a legacy row with none falls
// back to its own album instead of publishing a dead layers glyph.
crate::playback_qt::set_queue_stamped(runtime, vec![track.clone()], Some(0), None).await;
crate::playback_qt::play_queue_track_public(runtime, track.id).await;
}
+307
View File
@@ -0,0 +1,307 @@
//! Recently-played store — Slint-free port of `crates/qbz/src/recently.rs`.
//!
//! Backs the two Discover rails the port was missing: "Recently Played
//! Albums" (album carousel) and "Recently Played Tracks" (slim carousel), on
//! Home AND For You.
//!
//! It is the SAME file the Slint and Tauri builds use —
//! `<data_dir>/qbz/recently_played.json`, an object
//! `{ "tracks": [...], "albums": [...] }` with the pre-#567 bare-array form
//! migrated on read by deriving the album list from the track window. Nothing
//! here is per-user: the path is app-wide, exactly as in the reference, so a
//! history recorded by the Slint build shows up here and vice versa.
//!
//! Read is free (one small JSON file, no network, no session). [`record`] is
//! the write side; its call site is the playback track-start edge, which lives
//! in `playback_qt.rs` — see the GLUE note on the function.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// How many recent tracks to keep (recently.rs `MAX_RECENT`).
const MAX_RECENT: usize = 24;
/// Independent album cap (#567) so a run of long albums cannot shrink the
/// distinct-album history.
const MAX_RECENT_ALBUMS: usize = 24;
/// One recently-played track. Every field defaults so a store written by an
/// older build (or a newer one with more fields) stays readable.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RecentTrack {
#[serde(default)]
pub id: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub subtitle: String,
#[serde(default)]
pub artwork_url: String,
#[serde(default)]
pub album_id: String,
#[serde(default)]
pub album_title: String,
#[serde(default)]
pub album_artist: String,
#[serde(default)]
pub album_artwork_url: String,
#[serde(default)]
pub quality_tier: String,
#[serde(default)]
pub quality_label: String,
#[serde(default)]
pub genre: String,
/// Raw ISO album release date; localized at render time.
#[serde(default)]
pub release_date: String,
#[serde(default)]
pub artist_id: Option<u64>,
/// "qobuz" | "plex" | "local"; empty = legacy entry, treated as Qobuz.
#[serde(default)]
pub source: String,
}
/// One recently-played album (its own history since #567).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RecentAlbum {
#[serde(default)]
pub id: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub artist: String,
#[serde(default)]
pub artwork_url: String,
#[serde(default)]
pub quality_tier: String,
#[serde(default)]
pub quality_label: String,
#[serde(default)]
pub genre: String,
#[serde(default)]
pub release_date: String,
#[serde(default)]
pub source: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct RecentStore {
#[serde(default)]
tracks: Vec<RecentTrack>,
#[serde(default)]
albums: Vec<RecentAlbum>,
}
fn store_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("recently_played.json"))
}
/// Pre-#567 derive: first-occurrence de-dup over the track window. Kept ONLY
/// for the legacy migration path in [`read_store`].
fn derive_albums(tracks: &[RecentTrack]) -> Vec<RecentAlbum> {
let mut albums: Vec<RecentAlbum> = Vec::new();
for track in tracks {
if track.album_id.is_empty() || albums.iter().any(|a| a.id == track.album_id) {
continue;
}
albums.push(RecentAlbum {
id: track.album_id.clone(),
title: track.album_title.clone(),
artist: track.album_artist.clone(),
artwork_url: track.album_artwork_url.clone(),
quality_tier: track.quality_tier.clone(),
quality_label: track.quality_label.clone(),
genre: track.genre.clone(),
release_date: track.release_date.clone(),
source: track.source.clone(),
});
}
albums
}
/// Read the whole store. Missing / unreadable -> empty (the rails then render
/// their placeholders, never an error).
fn read_store() -> RecentStore {
let Some(path) = store_path() else {
return RecentStore::default();
};
let Ok(bytes) = std::fs::read(&path) else {
return RecentStore::default();
};
if let Ok(store) = serde_json::from_slice::<RecentStore>(&bytes) {
return store;
}
let tracks: Vec<RecentTrack> = serde_json::from_slice(&bytes).unwrap_or_default();
let albums = derive_albums(&tracks);
RecentStore { tracks, albums }
}
fn write_store(store: &RecentStore) {
let Some(path) = store_path() else {
return;
};
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("[qbz-qt] recently-played store dir failed: {e}");
return;
}
}
match serde_json::to_vec_pretty(store) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
log::warn!("[qbz-qt] recently-played write failed: {e}");
}
}
Err(e) => log::warn!("[qbz-qt] recently-played serialize failed: {e}"),
}
}
/// Recently-played tracks, newest first.
pub(crate) fn load_tracks() -> Vec<RecentTrack> {
read_store().tracks
}
/// Recently-played albums, newest first.
pub(crate) fn load_albums() -> Vec<RecentAlbum> {
read_store().albums
}
/// Record a played track at the front of the track history (dedup by track
/// id, capped at [`MAX_RECENT`]) and its album at the front of the album
/// history (dedup by album id, capped at [`MAX_RECENT_ALBUMS`]).
///
/// GLUE: this is the write half; nothing in the Qt port calls it yet, so the
/// rails currently render whatever the Slint/Tauri builds recorded. The call
/// site is the playback track-start edge in `playback_qt.rs` — see the "GLUE
/// NEEDED" note in the handoff.
#[allow(dead_code)]
pub(crate) fn record(track: RecentTrack) {
if track.id.is_empty() {
return;
}
let mut store = read_store();
if !track.album_id.is_empty() {
store.albums.retain(|a| a.id != track.album_id);
store.albums.insert(
0,
RecentAlbum {
id: track.album_id.clone(),
title: track.album_title.clone(),
artist: track.album_artist.clone(),
artwork_url: track.album_artwork_url.clone(),
quality_tier: track.quality_tier.clone(),
quality_label: track.quality_label.clone(),
genre: track.genre.clone(),
release_date: track.release_date.clone(),
source: track.source.clone(),
},
);
store.albums.truncate(MAX_RECENT_ALBUMS);
}
store.tracks.retain(|t| t.id != track.id);
store.tracks.insert(0, track);
store.tracks.truncate(MAX_RECENT);
write_store(&store);
}
/// Record the current queue track into BOTH local play stores: the
/// recently-played history (this module) and the album play-count history
/// (`qbz_app::settings::album_play_history`, which ranks the "Most Played
/// Albums" rail). Keeps the playback glue a single line.
///
/// The queue model carries no album genre / release date (Slint stamps those
/// from a side `ALBUM_META` map filled by the album-fetch path), so a card
/// recorded here shows title / artist / artwork / quality and no genre-date
/// overlay line — never a wrong one.
///
/// GLUE: call from the DE-DUPED playback track-change edge, next to
/// `integrations_qt::on_track_change_edge` — see the handoff.
#[allow(dead_code)]
pub(crate) fn record_queue_track(track: &qbz_models::QueueTrack) {
let tier = crate::home_qt::quality_tier_from_depth(track.bit_depth);
let detail = crate::home_qt::quality_detail_from_parts(track.bit_depth, track.sample_rate);
let quality_label = if tier.is_empty() {
String::new()
} else {
format!(
"{}: {detail}",
if tier == "hires" { "Hi-Res" } else { "CD" }
)
};
let artwork = track.artwork_url.clone().unwrap_or_default();
let album_id = track.album_id.clone().unwrap_or_default();
let source = track.source.clone().unwrap_or_default();
let artist_id = track.artist_id.map(|id| id.to_string()).unwrap_or_default();
qbz_app::settings::album_play_history::record_album_play(
qbz_app::settings::album_play_history::AlbumPlayMeta {
album_id: &album_id,
title: &track.album,
artist: &track.artist,
artist_id: &artist_id,
artwork_url: &artwork,
quality_tier: tier,
quality_label: &quality_label,
year: "",
source: &source,
},
);
record(RecentTrack {
id: track.id.to_string(),
title: track.title.clone(),
subtitle: track.artist.clone(),
artwork_url: artwork.clone(),
album_id,
album_title: track.album.clone(),
album_artist: track.artist.clone(),
album_artwork_url: artwork,
quality_tier: tier.to_string(),
quality_label,
genre: String::new(),
release_date: String::new(),
artist_id: track.artist_id,
source,
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_bare_array_migrates_to_derived_albums() {
let tracks = vec![
RecentTrack {
id: "1".into(),
album_id: "a".into(),
album_title: "A".into(),
..Default::default()
},
RecentTrack {
id: "2".into(),
album_id: "a".into(),
..Default::default()
},
RecentTrack {
id: "3".into(),
album_id: "b".into(),
..Default::default()
},
];
let derived = derive_albums(&tracks);
assert_eq!(derived.len(), 2);
assert_eq!(derived[0].id, "a");
assert_eq!(derived[0].title, "A");
assert_eq!(derived[1].id, "b");
}
#[test]
fn tracks_without_an_album_are_skipped_by_the_derive() {
let tracks = vec![RecentTrack {
id: "1".into(),
..Default::default()
}];
assert!(derive_albums(&tracks).is_empty());
}
}
+612
View File
@@ -0,0 +1,612 @@
//! Discover > Recommendations (the 4th tab) — Slint-free port of
//! `crates/qbz/src/external_reco.rs`.
//!
//! The recommendation LOGIC is not reimplemented here: `qbz-external-reco` is
//! the frontend-agnostic engine (ADR-006) and this module is only its driver —
//! it assembles `RecoInputs`, calls the per-row builders, maps the resolved
//! rows onto the same `HomeSection` / `HomeCard` transport the other Discover
//! tabs use, and publishes progressively.
//!
//! Shared with the album page's Last.fm row (`external_reco_qt`): the
//! `RecoCatalog` adapter over `QbzCore`, the daily rotation seed, and — the
//! point of the exercise — the SAME `RecoCache` database, so a similar-albums
//! resolution paid for on an album page is reused here and reopening Discover
//! costs no external traffic at all.
//!
//! ## Strictly opt-in
//!
//! Every Last.fm / ListenBrainz row is built through a handle that is `None`
//! unless the service is CONNECTED (`*_is_authed()` + a username). The engine's
//! builders early-return `Vec::new()` on a `None` handle, so an unconnected
//! service produces no request at all — and an empty row is simply absent from
//! the published document (no frame, no spinner). With NEITHER connected the
//! engine's cold-start regime paints the Qobuz editorial fallback instead
//! (`is_cold_start` -> `build_editorial`), 1:1 with Slint: that is catalog
//! data, not integration data. The whole module is lazy — nothing runs until
//! the user actually opens the Recommendations tab.
//!
//! ## Progressive paint
//!
//! Slint pushes one Slint model per row and repaints that row alone. The Qt
//! transport is ONE JSON document (`recoSectionsJson`), so "progressive" here
//! means: each builder, as it resolves, writes its rows into [`ROWS`] and
//! republishes the whole document. Rows that have not resolved yet are absent
//! rather than empty, which is what the view needs anyway.
//!
//! ## POC-NOTEs vs the Slint controller
//! - "Not interested" (reco dismissals) has no seam in this port, so the
//! retained per-rail overflow that Slint keeps for live backfill is dropped;
//! `compose_artist_rails` is still the composition choke point (exclusions +
//! cross-rail dedup + display cap), fed with the followed-artist id set.
//! - The blacklist and reco-dismissal stores are not opened by this port, so
//! the exclusion set is the followed-artist ids only.
//! - `LocalHistory.known_artist_ids` comes from the user's followed artists
//! (the port has no local `reco` play-vector store); it seeds the "Deep cuts
//! from artists you know" row exactly as the reference does with its larger
//! played-or-favorited set.
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use cxx_qt_lib::QString;
use qbz_external_reco::{
build_deep_cut_albums, build_editorial, build_fresh_releases, build_rec_albums,
build_rec_artists_common, build_rec_artists_recent, build_weekly_exploration,
build_weekly_jams, compose_artist_rails, gather_history, is_cold_start, AlbumReco, ArtistReco,
ExternalCarousels, LastFmHandle, ListenBrainzHandle, LocalHistory, RecoCache, RecoInputs,
TrackReco, ARTIST_DISPLAY_CAP,
};
use qbz_integrations::lastfm::LastFmClient;
use qbz_integrations::listenbrainz::ListenBrainzClient;
use qbz_integrations::musicbrainz::MusicBrainzClient;
use qbz_models::Artist;
use crate::home_qt::{HomeCard, HomeSection};
/// The resolved rows, in engine terms. Cleared on a forced refresh.
#[derive(Default, Clone)]
struct Rows {
rec_artists_common: Vec<HomeCard>,
rec_artists_recent: Vec<HomeCard>,
rec_albums: Vec<HomeCard>,
fresh_releases: Vec<HomeCard>,
weekly_exploration: Vec<HomeCard>,
weekly_jams: Vec<HomeCard>,
deep_cut_albums: Vec<HomeCard>,
top_albums: Vec<HomeCard>,
top_artists: Vec<HomeCard>,
}
static ROWS: Mutex<Option<Rows>> = Mutex::new(None);
/// A build has completed at least once this session (the lazy latch).
static LOADED: AtomicBool = AtomicBool::new(false);
/// A build is in flight (guards against the tab being re-entered mid-load).
static IN_FLIGHT: AtomicBool = AtomicBool::new(false);
fn rows_snapshot() -> Rows {
ROWS.lock()
.ok()
.and_then(|g| g.clone())
.unwrap_or_default()
}
fn mutate(f: impl FnOnce(&mut Rows)) {
if let Ok(mut g) = ROWS.lock() {
f(g.get_or_insert_with(Rows::default));
}
}
// ---------------------------------------------------------------------------
// Row -> transport mapping
// ---------------------------------------------------------------------------
fn card_from_artist(a: &ArtistReco) -> HomeCard {
HomeCard {
id: a.qobuz_artist_id.to_string(),
title: a.name.clone(),
// ArtistCard renders `item.subtitle` as the muted "Similar to …" line.
subtitle: a.subtitle.clone(),
art_url: a.image_url.clone(),
item_kind: "artist".to_string(),
..HomeCard::default()
}
}
fn card_from_album(a: &AlbumReco) -> HomeCard {
HomeCard {
id: a.qobuz_album_id.clone(),
title: a.title.clone(),
artist: a.artist.clone(),
artist_id: a.artist_id.clone(),
year: a.year.clone(),
quality_tier: a.quality_tier.clone(),
quality_label: a.quality_label.clone(),
art_url: a.artwork_url.clone(),
..HomeCard::default()
}
}
fn card_from_track(t: &TrackReco) -> HomeCard {
HomeCard {
id: t.qobuz_track_id.to_string(),
title: t.title.clone(),
artist: t.artist.clone(),
art_url: t.artwork_url.clone(),
..HomeCard::default()
}
}
fn section(id: &str, title: String, kind: &str, items: Vec<HomeCard>) -> Option<HomeSection> {
if items.is_empty() {
return None;
}
Some(HomeSection {
id: id.to_string(),
title,
kind: kind.to_string(),
hint: String::new(),
endpoint: String::new(),
items,
})
}
/// The tab's lineup, in ExternalRecoView.slint order. Empty rows are ABSENT
/// (self-hide), never an empty frame.
fn sections(rows: &Rows) -> Vec<HomeSection> {
[
section(
"recArtistsCommon",
qbz_i18n::t("More like the artists you love"),
"artists",
rows.rec_artists_common.clone(),
),
section(
"recArtistsRecent",
qbz_i18n::t("Based on what you've been into lately"),
"artists",
rows.rec_artists_recent.clone(),
),
section(
"recAlbums",
qbz_i18n::t("Recommended Albums"),
"album",
rows.rec_albums.clone(),
),
section(
"freshReleases",
qbz_i18n::t("Fresh Releases"),
"album",
rows.fresh_releases.clone(),
),
section(
"weeklyExploration",
qbz_i18n::t("Weekly Exploration"),
"slimTracks",
rows.weekly_exploration.clone(),
),
section(
"weeklyJams",
qbz_i18n::t("Weekly Jams"),
"slimTracks",
rows.weekly_jams.clone(),
),
section(
"deepCutAlbums",
qbz_i18n::t("Deep cuts from artists you know"),
"album",
rows.deep_cut_albums.clone(),
),
section(
"topAlbums",
qbz_i18n::t("Top albums on Qobuz"),
"album",
rows.top_albums.clone(),
),
section(
"topArtists",
qbz_i18n::t("Popular artists on Qobuz"),
"artists",
rows.top_artists.clone(),
),
]
.into_iter()
.flatten()
.collect()
}
// ---------------------------------------------------------------------------
// Publish (+ the artwork dispatch every rail must join)
// ---------------------------------------------------------------------------
fn push(sections: &[HomeSection]) {
let json = serde_json::to_string(sections).unwrap_or_else(|_| "[]".to_string());
crate::home_bridge::ui(move |mut b| {
b.as_mut()
.set_reco_sections_json(QString::from(json.as_str()));
});
}
fn set_loading(value: bool) {
crate::home_bridge::ui(move |mut b| b.as_mut().set_reco_loading(value));
}
/// Serialize the current rows, attach every cover already on disk, publish,
/// and (when `download`) fetch the misses in the background and republish —
/// the same dispatch `main::reload_home` runs for the other three tabs. A rail
/// that skipped this would render as a wall of empty frames.
fn publish_snapshot(download: bool) {
let rows = rows_snapshot();
let mut secs = sections(&rows);
let missing = crate::artwork_qt::attach_cached(&mut secs);
push(&secs);
if download && !missing.is_empty() {
crate::spawn(async move {
crate::artwork_qt::download_missing(missing).await;
// download=false: the covers are on disk now, so this pass can
// only attach — it can never spawn another download round.
publish_snapshot(false);
});
}
}
// ---------------------------------------------------------------------------
// Entry points
// ---------------------------------------------------------------------------
/// Lazy first load — called when the Recommendations tab becomes visible.
/// Idempotent: a second open repaints from [`ROWS`] without touching the
/// network (and the engine's own results cache makes even a fresh process
/// cheap).
pub(crate) fn ensure_loaded() {
if LOADED.load(Ordering::SeqCst) {
// Republish so a tab re-entry repaints from memory (the document is a
// property, so this is also what recovers after a language switch).
publish_snapshot(true);
return;
}
start(false);
}
/// "Refresh now": drop the latch and rebuild every row, bypassing the results
/// blob (the engine still honours its per-week ListenBrainz cache).
#[allow(dead_code)]
pub(crate) fn refresh() {
LOADED.store(false, Ordering::SeqCst);
start(true);
}
fn start(force: bool) {
if crate::offline_fwd::engine().status().is_offline() {
log::info!("[qbz-qt] recommendations skipped (offline session)");
return;
}
if IN_FLIGHT.swap(true, Ordering::SeqCst) {
return;
}
set_loading(true);
crate::spawn(async move {
run(force).await;
IN_FLIGHT.store(false, Ordering::SeqCst);
LOADED.store(true, Ordering::SeqCst);
set_loading(false);
});
}
/// Followed-artist ids — the exclusion set for the artist rails AND the
/// "artists you know" seed for the deep-cut row (see the module POC-NOTEs).
async fn followed_artist_ids() -> HashSet<u64> {
let runtime = crate::app();
match runtime.core().get_favorites("artists", 100, 0).await {
Ok(value) => {
qbz_models::lenient::parse_items_array::<Artist>(&value, "artists", "reco artist")
.into_iter()
.map(|a| a.id)
.collect()
}
Err(e) => {
log::warn!("[qbz-qt] recommendations: followed artists fetch failed: {e}");
HashSet::new()
}
}
}
/// Apply the two Recommended-Artist rails through the engine's composition
/// choke point (exclusions + cross-rail dedup + display cap), then paint.
fn apply_artist_rails(
common_pool: Vec<ArtistReco>,
recent_pool: Vec<ArtistReco>,
excluded: &HashSet<u64>,
) {
let (common, recent) =
compose_artist_rails(common_pool, recent_pool, excluded, ARTIST_DISPLAY_CAP);
mutate(|r| {
r.rec_artists_common = common.visible.iter().map(card_from_artist).collect();
r.rec_artists_recent = recent.visible.iter().map(card_from_artist).collect();
});
publish_snapshot(true);
}
async fn run(force: bool) {
let cfg = crate::integrations_qt::scrobble_settings();
let lastfm_client = LastFmClient::new();
let lb_client = ListenBrainzClient::new();
if cfg.listenbrainz_is_authed() {
lb_client
.restore_token(
cfg.listenbrainz_token.clone(),
cfg.listenbrainz_username.clone(),
)
.await;
}
let mb_client = MusicBrainzClient::new();
// CONNECTED, not merely enabled: a `None` handle is what makes the row
// absent AND silent (the builders early-return before any request).
let lastfm = if cfg.lastfm_is_authed() && !cfg.lastfm_username.is_empty() {
Some(LastFmHandle {
username: cfg.lastfm_username.clone(),
client: &lastfm_client,
})
} else {
None
};
let listenbrainz = if cfg.listenbrainz_is_authed() && !cfg.listenbrainz_username.is_empty() {
Some(ListenBrainzHandle {
username: cfg.listenbrainz_username.clone(),
client: &lb_client,
})
} else {
None
};
let followed = followed_artist_ids().await;
let catalog = crate::external_reco_qt::CoreRecoCatalog {
runtime: crate::app(),
};
let cache = crate::external_reco_qt::cache_dir()
.and_then(|dir| match RecoCache::open_at(&dir) {
Ok(c) => {
let _ = c.cleanup_expired();
Some(c)
}
Err(e) => {
log::warn!("[qbz-qt] reco cache open failed ({e}) — running uncached");
None
}
})
.map(Mutex::new);
let inputs = RecoInputs {
lastfm,
listenbrainz,
musicbrainz: &mb_client,
catalog: &catalog,
cache: cache.as_ref(),
local: LocalHistory {
known_artist_ids: followed.clone(),
..Default::default()
},
rotation_seed: crate::external_reco_qt::rotation_seed(),
};
let source_key = format!(
"results:lf={}:lb={}",
inputs.lastfm.is_some(),
inputs.listenbrainz.is_some()
);
log::info!(
"[qbz-qt] recommendations: lastfm={} listenbrainz={} force={force}",
inputs.lastfm.is_some(),
inputs.listenbrainz.is_some(),
);
let ttl_secs = reco_cache_ttl_secs();
// 1. Results cache — paint the non-weekly rows instantly from a fresh
// blob. The two Weekly rows are NEVER trusted from it: they follow
// ListenBrainz's own weekly cadence and have their own per-week cache, so
// one transient empty build must not hide them for the whole window.
if !force {
let cached = inputs
.cache
.and_then(|c| c.lock().ok().and_then(|g| g.get_results(&source_key, ttl_secs)));
if let Some(json) = cached {
if let Ok(result) = serde_json::from_str::<ExternalCarousels>(&json) {
apply_all(result, &followed);
build_and_apply_weeklies(&inputs).await;
return;
}
}
}
// 2. Cache miss / stale / forced: build. Each branch paints as it resolves.
let cold_start = is_cold_start(&inputs);
let collector: Mutex<ExternalCarousels> = Mutex::new(ExternalCarousels::default());
if cold_start {
// No external source connected -> Qobuz editorial fallback (catalog
// data; no integration is contacted).
let (albums, artists) = build_editorial(&inputs).await;
if let Ok(mut g) = collector.lock() {
g.editorial_fallback = true;
g.top_albums = albums.clone();
g.top_artists = artists.clone();
}
mutate(|r| {
r.top_albums = albums.iter().map(card_from_album).collect();
r.top_artists = artists.iter().map(card_from_artist).collect();
});
publish_snapshot(true);
} else {
let history = gather_history(&inputs).await;
let col = &collector;
let b_artists = async {
let (common_pool, recent_pool) = tokio::join!(
build_rec_artists_common(&inputs, &history),
build_rec_artists_recent(&inputs, &history),
);
if let Ok(mut g) = col.lock() {
g.rec_artists_common = common_pool.clone();
g.rec_artists_recent = recent_pool.clone();
}
apply_artist_rails(common_pool, recent_pool, &followed);
};
let b_albums = async {
let r = build_rec_albums(&inputs, &history).await;
if let Ok(mut g) = col.lock() {
g.rec_albums = r.clone();
}
mutate(|rows| rows.rec_albums = r.iter().map(card_from_album).collect());
publish_snapshot(true);
};
let b_fresh = async {
let r = build_fresh_releases(&inputs).await;
if let Ok(mut g) = col.lock() {
g.fresh_releases = r.clone();
}
mutate(|rows| rows.fresh_releases = r.iter().map(card_from_album).collect());
publish_snapshot(true);
};
let b_explore = async {
let r = build_weekly_exploration(&inputs).await;
if let Ok(mut g) = col.lock() {
g.weekly_exploration = r.clone();
}
mutate(|rows| rows.weekly_exploration = r.iter().map(card_from_track).collect());
publish_snapshot(true);
};
let b_jams = async {
let r = build_weekly_jams(&inputs).await;
if let Ok(mut g) = col.lock() {
g.weekly_jams = r.clone();
}
mutate(|rows| rows.weekly_jams = r.iter().map(card_from_track).collect());
publish_snapshot(true);
};
let b_deep = async {
let r = build_deep_cut_albums(&inputs).await;
if let Ok(mut g) = col.lock() {
g.deep_cut_albums = r.clone();
}
mutate(|rows| rows.deep_cut_albums = r.iter().map(card_from_album).collect());
publish_snapshot(true);
};
tokio::join!(b_artists, b_albums, b_fresh, b_explore, b_jams, b_deep);
}
// 3. Store the build for instant future opens. GUARD against poisoning the
// cache with a transient ListenBrainz failure: if LB is connected but
// every LB-sourced row came back empty, skip the write so the next open
// re-fetches instead of hiding those rows for the whole window.
if let Some(cache_mutex) = inputs.cache {
let lb_all_empty = collector
.lock()
.map(|g| {
g.weekly_exploration.is_empty()
&& g.weekly_jams.is_empty()
&& g.fresh_releases.is_empty()
})
.unwrap_or(true);
if inputs.listenbrainz.is_some() && lb_all_empty {
log::warn!(
"[qbz-qt] ListenBrainz connected but all LB rows empty — skipping the \
results-cache write (likely transient; next open re-fetches)"
);
} else {
let json = collector.lock().ok().and_then(|g| serde_json::to_string(&*g).ok());
if let (Ok(guard), Some(json)) = (cache_mutex.lock(), json) {
guard.put_results(&source_key, &json);
}
}
}
}
/// Paint the NON-weekly rows from a cached blob (see `run`).
fn apply_all(r: ExternalCarousels, excluded: &HashSet<u64>) {
mutate(|rows| {
rows.rec_albums = r.rec_albums.iter().map(card_from_album).collect();
rows.fresh_releases = r.fresh_releases.iter().map(card_from_album).collect();
rows.deep_cut_albums = r.deep_cut_albums.iter().map(card_from_album).collect();
rows.top_albums = r.top_albums.iter().map(card_from_album).collect();
rows.top_artists = r.top_artists.iter().map(card_from_artist).collect();
});
apply_artist_rails(r.rec_artists_common, r.rec_artists_recent, excluded);
}
/// Rebuild + paint the two Weekly rows from their own per-week cache (one
/// ListenBrainz call + a SQLite read on a hit). Silent no-op when ListenBrainz
/// is not connected.
async fn build_and_apply_weeklies(inputs: &RecoInputs<'_>) {
if inputs.listenbrainz.is_none() {
return;
}
let (explore, jams) = tokio::join!(
build_weekly_exploration(inputs),
build_weekly_jams(inputs)
);
mutate(|rows| {
rows.weekly_exploration = explore.iter().map(card_from_track).collect();
rows.weekly_jams = jams.iter().map(card_from_track).collect();
});
publish_snapshot(true);
}
/// The Recommendations cache window in SECONDS, from the shared per-user
/// discover prefs (`reco_cache_ttl_hours`, default 48h) — the same store the
/// section configurator writes.
fn reco_cache_ttl_secs() -> i64 {
crate::home_qt::load_prefs().reco_cache_ttl_hours * 3600
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_rows_publish_no_sections() {
assert!(sections(&Rows::default()).is_empty());
}
#[test]
fn lineup_order_matches_the_slint_view() {
let rows = Rows {
rec_artists_common: vec![HomeCard::default()],
rec_artists_recent: vec![HomeCard::default()],
rec_albums: vec![HomeCard::default()],
fresh_releases: vec![HomeCard::default()],
weekly_exploration: vec![HomeCard::default()],
weekly_jams: vec![HomeCard::default()],
deep_cut_albums: vec![HomeCard::default()],
top_albums: vec![HomeCard::default()],
top_artists: vec![HomeCard::default()],
};
let ids: Vec<String> = sections(&rows).into_iter().map(|s| s.id).collect();
assert_eq!(
ids,
vec![
"recArtistsCommon",
"recArtistsRecent",
"recAlbums",
"freshReleases",
"weeklyExploration",
"weeklyJams",
"deepCutAlbums",
"topAlbums",
"topArtists",
]
);
}
#[test]
fn a_row_with_no_items_is_absent_not_empty() {
let rows = Rows {
rec_albums: vec![HomeCard::default()],
..Rows::default()
};
let secs = sections(&rows);
assert_eq!(secs.len(), 1);
assert_eq!(secs[0].id, "recAlbums");
}
}
+27
View File
@@ -84,6 +84,15 @@ pub mod qbz_session {
/// when the language changes live.
#[qinvokable]
fn tr(self: &QbzSession, msgid: QString, rev: i32) -> QString;
/// Ask for `path` (a file:// cover) re-encoded to exactly w*h DEVICE
/// pixels. Answers on `artScaledReady`; a derivative already on disk
/// answers on the next event-loop pass.
#[qinvokable]
fn art_scaled(self: Pin<&mut QbzSession>, path: QString, w: i32, h: i32);
/// (requested path, derivative url) — keyed by the REQUEST so a
/// recycled delegate cannot take the previous row's cover.
#[qsignal]
fn art_scaled_ready(self: Pin<&mut QbzSession>, path: QString, scaled: QString);
}
impl cxx_qt::Threading for QbzSession {}
@@ -185,4 +194,22 @@ impl qbz_session::QbzSession {
let _ = rev; // binding dependency only (see the bridge declaration)
QString::from(&qbz_i18n::t(&msgid.to_string()))
}
pub fn art_scaled(self: Pin<&mut Self>, path: QString, w: i32, h: i32) {
let (p, w, h) = (path.to_string(), w.max(0) as u32, h.max(0) as u32);
crate::spawn(async move {
let key = p.clone();
let out = tokio::task::spawn_blocking(move || crate::artwork_qt::scaled_path(&p, w, h))
.await
.ok()
.flatten();
if let Some(out) = out {
let url = crate::artwork_qt::file_url(&out.to_string_lossy());
ui(move |mut b| {
b.as_mut()
.art_scaled_ready(QString::from(key.as_str()), QString::from(url.as_str()));
});
}
});
}
}
+5 -1
View File
@@ -1281,7 +1281,11 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
with_playback(|s| s.set_autoplay_mode(mode)).map(|_| Apply::None)
}
"show-context-icon" => {
with_playback(|s| s.set_show_context_icon(value)).map(|_| Apply::None)
let r = with_playback(|s| s.set_show_context_icon(value)).map(|_| Apply::None);
// Republish: the bar reads this to decide whether the context glyph
// shows, so persisting alone left the toggle inert until a restart.
crate::now_playing::publish_current();
r
}
"persist-session" => {
with_playback(|s| s.set_persist_session(value)).map(|_| Apply::None)