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

+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")
}
}
}