feat(qt): custom-theme editor, artist portrait, playlist rail, About/What's New and diagnostics
Five surfaces the Qt frontend was missing, landed together because they share
five wiring files (build.rs, main.rs, shell_bridge.rs, AppShell.qml,
HeaderBar.qml) and splitting them would produce commits that do not build.
ARTIST PAGE
- The Playlists section used a hand-rolled Rectangle delegate: a MouseArea with
no onClicked, and a RoundedImage with no fit:, so the default crop blew an
800x380 playlist banner up inside a 200x200 box and showed the middle 47% at
2.1x. Replaced by the real cards/PlaylistCard.qml — already live on five other
surfaces — mounted in a new views/PlaylistRail.qml modelled on SectionRail.
ArtistPlaylist publishes the five roles the card reads.
- The portrait gains what the album cover has: the right-click menu the Slint
reference already has (Add/Change/Remove image, Open in browser, Save as…) and
the lightbox this port invented for albums and playlists. Custom images are
keyed by artist NAME into the same custom_artwork.json the Slint build writes,
so they round-trip between the two.
- CoverLightbox is now aspect-aware. It forced a square box on the premise that
covers are square; artist portraits are not (1080x720 measured in the cache).
- Removing an override invalidates the pipeline's resolved path for that url:
artwork_qt::cached_path consults the override FIRST, so coverMap held the
custom file and the header kept showing the picture the user had just deleted.
- artwork_qt::local_path is the inverse of file_url. trim_start_matches("file://")
is not: it leaves the percent-escapes in place, so any path holding % # or ?
came back as a name that does not exist on disk.
- One new msgid, "View image", in the seven non-en locales — matching its sibling
"View cover", since en carries none of that family and falls back to the msgid.
CUSTOM THEME
- The editor, the colour picker and the swatch grid, ported 1:1 from
AppearanceSettings.slint and ColorPicker.slint. The 11 editable base tokens,
their expansion into the full 55-value palette and the on-disk format all stay
in the shared qbz-theme crate, so a theme authored in the shipping Slint build
already rendered here and now round-trips both ways.
- Hue is normalised 0..1 at every Qt.hsva boundary; the reference stores 0..360,
and porting the number instead of the domain would render every swatch red.
- The first-selection seed is captured BEFORE set_theme persists the slug,
because current_slug() re-reads it from disk — a seed taken afterwards would
resolve "custom", find no file and snapshot the default it was meant to replace.
- The in-memory base is dropped when the theme row enters "custom": it is
memoized for the life of the process and filled at bridge construction, so a
process that booted without the file would keep serving the OLED default even
after the shipping Slint build wrote the user's theme.
- The disk write is debounced to drag-end and atomic; the reference persists on
every drag frame with a truncating write, on a file the other build has open.
ABOUT + WHAT'S NEW
- Both modals, with their producers. render_markdown is transcribed line for line
from the reference: h1 and h2 collapse to one level, an indent-0 bullet becomes
a section plus a TOC entry, bold and backticks are stripped rather than styled.
Text.MarkdownText is a different subset and would have rendered a different
document from the same source. Pinned by tests, including the real 2.0.2 body.
- The codename is a single const, so the release checklist has one Qt-side place
to bump.
DIAGNOSTICS
- The panel in Settings > Developer, the Report an Issue modal and its menu row.
- The log bundle carried eight hand-built header fields where the reference
carries about seventy, so every issue filed from a Qt build arrived with no
kernel, no distro, no install method, no GPU, no exclusive-mode / DAC /
sample-rate state and no playback context. The report is injected INTO
qbz_log::bundle rather than replacing it, keeping the <details> wrapper that
makes a pasted bundle readable.
- The GTK / WebKit / GSK / GDK rows are CUT. In a Qt process those libraries are
not mapped, so their runtime column can only read "—", and their saved column
would come from a graphics.json this binary never reads or writes — foreign
state presented as this app's configuration.
SHELL
- The four always-on-top overlays (toast, tooltip, art preview, immersive) relied
on declaration order and carried no z, so any z-bearing modal buried them: a
toast raised by a background event behind an opaque panel is never seen. They
now sit in a band above the modals, keeping their relative order.
- build.rs asked cargo to watch ../../.git/HEAD, which does not exist in a git
worktree (.git is a file). Cargo treats a missing rerun-if-changed target as
always-dirty, so the whole build script — per-shader qsb, the recursive asset
sweep, cxx-qt/moc codegen — re-ran on every cargo invocation.
Verified: cargo test -p qbz-qt 343 passed, cargo test -p qbz-library 83 passed,
qml_singleton_xref and qml_resolution_audit clean over 35 bridges and 265 QML
files, and qmlcachegen compiles every one of them. Smoke-tested by hand.
Contracts, recon and adversarial reviews:
qbz-nix-docs/qt-frontend/2026-08-14-six-item-round/
cc
This commit is contained in:
46 files changed
+6365
-148
No files matched your search
@@ -1,6 +1,8 @@
|
||||
// Artist detail page — QML port of artist/ArtistPageView.slint.
|
||||
//
|
||||
// Header (200px circular portrait, name, bio + Read more, CircleAction
|
||||
// Header (200px circular portrait — right-click for the image menu
|
||||
// (Add/Change/Remove custom image, View image, Open in browser, Save as…),
|
||||
// left-click for the lightbox — name, bio + Read more, CircleAction
|
||||
// row: Follow / Radio / Network / ⋯, From-catalog/In-library toggle),
|
||||
// JUMP TO bar (jump-scroll), Popular Tracks (artwork + album column rows,
|
||||
// Load more 5→all, play/shuffle-all), Latest release, release sections
|
||||
@@ -803,6 +805,7 @@ Rectangle {
|
||||
}
|
||||
onArtistChanged: {
|
||||
syncArtistState()
|
||||
invalidatePortraitOverride()
|
||||
dispatchCovers()
|
||||
}
|
||||
// Cover dispatch keys off the raw document (artist.artUrl etc.), so
|
||||
@@ -925,6 +928,29 @@ Rectangle {
|
||||
if (fresh.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(fresh))
|
||||
}
|
||||
|
||||
// A custom portrait is consulted by `artwork_qt::cached_path` (it checks
|
||||
// `cover_artwork_qt::override_for_url` FIRST), so once one exists
|
||||
// `coverMap[artist.artUrl]` holds the OVERRIDE file, not the Qobuz one.
|
||||
// Removing the override has to invalidate that entry as well, or the
|
||||
// header and the lightbox keep showing the picture the user just deleted:
|
||||
// `dispatchedCovers` dedupes by url and is cleared only on an artist
|
||||
// change, so the republish alone never re-resolves it. Add/Change need the
|
||||
// same invalidation in reverse.
|
||||
property string _lastCustomPortrait: ""
|
||||
function invalidatePortraitOverride() {
|
||||
var cur = (artist && artist.customImagePath) ? artist.customImagePath : ""
|
||||
if (cur === root._lastCustomPortrait) return
|
||||
root._lastCustomPortrait = cur
|
||||
var url = (artist && artist.artUrl) ? artist.artUrl : ""
|
||||
if (url === "") return
|
||||
var m = Object.assign({}, root.coverMap)
|
||||
delete m[url]
|
||||
root.coverMap = m
|
||||
// Drop the dedupe key too, so the dispatchCovers() that follows in the
|
||||
// same handler actually re-requests it.
|
||||
delete root.dispatchedCovers[url]
|
||||
}
|
||||
|
||||
// THE RULE FOR THIS FUNCTION: every section of the page that binds
|
||||
// `root.coverMap` has to contribute its urls here, or its covers are
|
||||
// never requested and the section renders empty tiles forever — nothing
|
||||
@@ -1999,19 +2025,49 @@ Rectangle {
|
||||
width: parent.width - 64
|
||||
spacing: 32
|
||||
|
||||
// Circular portrait (rounded Rectangle + clip round-clips
|
||||
// on this Qt build — verified against the phase-3 circles).
|
||||
// Circular portrait. The circle comes from RoundedImage's
|
||||
// MASK, not from a clip: QML's `clip` is rectangular and does
|
||||
// NOT follow a Rectangle's radius — theme/RoundedImage.qml:3-6
|
||||
// says so and proved it with an isolated scene on this Qt
|
||||
// build, and :508 measures this exact radius-100-on-200px
|
||||
// case. The `clip: true` that used to sit here (with a comment
|
||||
// claiming the opposite) therefore rounded nothing, cost an
|
||||
// unconditional batch root, and would have swallowed anything
|
||||
// mounted inside the frame — which is why the menu and the
|
||||
// lightbox are view-root siblings, exactly as
|
||||
// AlbumView.qml:586-588 keeps them.
|
||||
Rectangle {
|
||||
width: 200
|
||||
height: 200
|
||||
radius: 100
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.coverMap[artist.artUrl] || ""
|
||||
// A custom portrait (the SHARED custom_artwork store,
|
||||
// keyed by artist NAME) beats the url-keyed pipeline
|
||||
// image — the album header's rule on the artist axis.
|
||||
source: (artist.customImageUrl || "") !== ""
|
||||
? artist.customImageUrl
|
||||
: (root.coverMap[artist.artUrl] || "")
|
||||
radius: 100
|
||||
}
|
||||
// Left-click: the lightbox (NEW in this port — the
|
||||
// reference lets left clicks pass through,
|
||||
// ArtistPageView.slint:290-293). Right-click: the portrait
|
||||
// menu the reference DOES have (ArtistPageView.slint:292-353),
|
||||
// which this port simply never paid.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
portraitMenu.openAtCursor(portraitMenuAnchor, mouse.x, mouse.y)
|
||||
else if (root.bestArtistImage() !== "")
|
||||
portraitLightbox.openWith(root.bestArtistImage())
|
||||
}
|
||||
}
|
||||
Item { id: portraitMenuAnchor; anchors.fill: parent }
|
||||
}
|
||||
|
||||
Column {
|
||||
@@ -2509,67 +2565,27 @@ Rectangle {
|
||||
}
|
||||
|
||||
// --- Playlists --------------------------------------------
|
||||
Column {
|
||||
// The reference mounts the SHARED playlist carousel here
|
||||
// (ArtistPageView.slint:985-995 -> discover/PlaylistCarousel
|
||||
// -> discover/PlaylistCard), so this page gets the same card
|
||||
// Home, Search, Browse, Label and the Library feed already
|
||||
// mount: body click opens, overlay play/favourite/⋯, pin
|
||||
// badge, context menu — and the banner rendered CONTAIN
|
||||
// instead of cropped 2.11:1 into a square.
|
||||
//
|
||||
// What stood here was a hand-rolled Rectangle delegate whose
|
||||
// MouseArea had no onClicked at all (only a cursor, which is
|
||||
// why it LOOKED clickable) and a RoundedImage with no `fit:`,
|
||||
// i.e. the default crop. Its POC-NOTE claimed there was no
|
||||
// playlist view yet; views/PlaylistView.qml has existed and
|
||||
// been routed for a long time.
|
||||
Item { visible: playlists.length > 0; width: 1; height: 32 }
|
||||
PlaylistRail {
|
||||
visible: playlists.length > 0
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Item { width: 1; height: 32 }
|
||||
Text {
|
||||
text: QbzSession.tr("Playlists", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
ListView {
|
||||
width: parent.width
|
||||
height: 246
|
||||
orientation: ListView.Horizontal
|
||||
spacing: 32
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: playlists
|
||||
delegate: Rectangle {
|
||||
width: 200
|
||||
height: 246
|
||||
color: "transparent"
|
||||
Column {
|
||||
spacing: 0
|
||||
Rectangle {
|
||||
width: 200
|
||||
height: 200
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: root.coverMap[modelData.artUrl] || ""
|
||||
radius: theme.radiusSm
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// POC-NOTE: no playlist view yet.
|
||||
}
|
||||
}
|
||||
Item { width: 1; height: 6 }
|
||||
Text {
|
||||
width: 200
|
||||
text: modelData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontBody - 2
|
||||
font.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: 200
|
||||
text: modelData.subtitle
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLink - 1
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
title: QbzSession.tr("Playlists", QbzSession.trRev)
|
||||
items: playlists
|
||||
coverMap: root.coverMap
|
||||
}
|
||||
|
||||
// --- Other (collapsed) ------------------------------------
|
||||
@@ -3425,4 +3441,104 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Artist portrait: menu + lightbox -----------------------------------
|
||||
// View-root siblings, never descendants of the portrait frame (the frame
|
||||
// is a 200px circle; anything inside it would be masked away).
|
||||
|
||||
/// Best available portrait source for the lightbox: the custom override
|
||||
/// first, then the file the artwork pipeline ALREADY downloaded for this
|
||||
/// page, then the remote URL as a last resort.
|
||||
///
|
||||
/// Deliberate divergence from AlbumView.bestCoverSource(), which goes
|
||||
/// straight to the remote URL and re-downloads a file it already has on
|
||||
/// disk. Preferring the cache also matters more here: the artist `large`
|
||||
/// segment is the ORIGINAL upload (measured on this machine's cache:
|
||||
/// n=149, median 1439px, max 5679px), so the fetch it avoids is a big one.
|
||||
function bestArtistImage() {
|
||||
var custom = artist.customImageUrl || ""
|
||||
if (custom !== "") return custom
|
||||
var cached = root.coverMap[artist.artUrl] || ""
|
||||
if (cached !== "") return cached
|
||||
return artist.artUrl || ""
|
||||
}
|
||||
|
||||
/// The portrait menu's rows, rebuilt per open so Add/Change/Remove track
|
||||
/// the live flag (ArtistPageView.slint:307-351, plus "View image" — the
|
||||
/// lightbox entry, this port's addition and the twin of the album menu's
|
||||
/// "View cover").
|
||||
///
|
||||
/// The two url-only rows are hidden when there is nothing for them to act
|
||||
/// on. The reference passes `ArtistState.artwork-url` blindly and shows
|
||||
/// them even for an artist Qobuz has no portrait for, where they do
|
||||
/// nothing — a small, deliberate divergence rather than a copied hole.
|
||||
/// "Save as…" survives an empty url when a custom image exists, because
|
||||
/// that path saves the local file.
|
||||
function buildPortraitMenuModel() {
|
||||
var rows = []
|
||||
if (artist.hasCustomImage === true) {
|
||||
rows.push({ "label": QbzSession.tr("Change image", QbzSession.trRev), "icon": "image-plus", "action": "add" })
|
||||
rows.push({ "label": QbzSession.tr("Remove image", QbzSession.trRev), "icon": "trash-2", "action": "remove" })
|
||||
} else {
|
||||
rows.push({ "label": QbzSession.tr("Add image", QbzSession.trRev), "icon": "image-plus", "action": "add" })
|
||||
}
|
||||
if (root.bestArtistImage() !== "")
|
||||
rows.push({ "label": QbzSession.tr("View image", QbzSession.trRev), "icon": "eye", "action": "view" })
|
||||
if ((artist.artUrl || "") !== "")
|
||||
rows.push({ "label": QbzSession.tr("Open in browser", QbzSession.trRev), "icon": "external-link", "action": "browser" })
|
||||
if ((artist.artUrl || "") !== "" || artist.hasCustomImage === true)
|
||||
rows.push({ "label": QbzSession.tr("Save as…", QbzSession.trRev), "icon": "cloud-download", "action": "save" })
|
||||
return rows
|
||||
}
|
||||
|
||||
QbzContextMenu {
|
||||
id: portraitMenu
|
||||
menuWidth: 196
|
||||
onAboutToShow: portraitMenuRepeater.model = root.buildPortraitMenuModel()
|
||||
Repeater {
|
||||
id: portraitMenuRepeater
|
||||
model: []
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
height: 33
|
||||
radius: 5
|
||||
color: pmiArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
spacing: 8
|
||||
QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
|
||||
Text {
|
||||
height: parent.height
|
||||
width: parent.width - 23
|
||||
text: modelData.label
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: pmiArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
portraitMenu.close()
|
||||
var a = modelData.action
|
||||
// The store is keyed by NAME (ArtistPageView.slint:312),
|
||||
// so the name — not the id — is what travels.
|
||||
if (a === "add") QbzArtist.imageAddCustom(artist.name || "", artist.artUrl || "")
|
||||
else if (a === "remove") QbzArtist.imageRemoveCustom(artist.name || "", artist.artUrl || "")
|
||||
else if (a === "view") portraitLightbox.openWith(root.bestArtistImage())
|
||||
else if (a === "browser") QbzShell.openExternalUrl(artist.artUrl)
|
||||
else if (a === "save") QbzArtist.imageSaveAs(artist.name || "", artist.artUrl || "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CoverLightbox { id: portraitLightbox }
|
||||
}
|
||||
Reference in new issue
Block a user