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:
31 files changed
+3548
-329
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user