feat(qbz-qt): add atmosphere to local album headers

cx
This commit is contained in:
vicrodh committed 2026-08-26 00:11:00 -06:00
1 parent bae1523abf
commit cdc5eb99e7
5 files changed
+170 -47

No files matched your search

+73 -33
View File
@@ -33,6 +33,10 @@ Rectangle {
readonly property bool ambientOn: theme.ambientOn
radius: 12
// Same reach as Qobuz AlbumView: through the divider, the following
// spacer and half of the track-toolbar band.
readonly property int atmoReach: 1 + 8 + 26
QbzTheme { id: theme }
// ---------------------------- document -------------------------------
@@ -58,6 +62,22 @@ Rectangle {
}
return doc && doc.compactHeader === true
}
readonly property bool headerGradientPref: {
var raw = QbzBridge.settingsJson
if (raw && raw.length > 2) {
try {
var d = JSON.parse(raw)
if (d.albumHeaderGradient !== undefined)
return d.albumHeaderGradient === true
} catch (e) { /* fall through to the document copy */ }
}
return !doc || doc.headerGradient !== false
}
readonly property bool headerAtmoOn: headerGradientPref && !ambientOn
readonly property bool headerLight: headerGradientPref || ambientOn
readonly property color hdrStrong: headerLight ? "#ffffff" : theme.textPrimary
readonly property color hdrBody: headerLight ? "#e0ffffff" : theme.textSecondary
readonly property bool hdrOverlay: headerLight
property bool headerLayoutReady: false
property int headerLayoutEpoch: 0
property real headerSavedOffset: 0
@@ -287,12 +307,36 @@ Rectangle {
// Cover — the same id-keyed artwork channel every local surface uses.
property var artMap: ({})
property string headerColor: ""
property string headerAtmosphere: ""
property string headerAtmosphereKey: ""
property string headerAtmospherePath: ""
function requestHeaderAtmosphere(key, path) {
if (!key || !path)
return
if (root.headerAtmosphereKey === key
&& root.headerAtmospherePath === path)
return
root.headerAtmosphereKey = key
root.headerAtmospherePath = path
QbzLocal.albumHeaderAtmosphere(key, path)
}
Connections {
target: QbzLocal
function onLocalArtworkReady(key, path) {
var m = root.artMap
m[key] = path
root.artMap = Object.assign({}, m)
if (root.album && key === (root.album.artKey || ""))
root.requestHeaderAtmosphere(key, path)
}
function onLocalAlbumAtmosphereReady(key, path, tint, atmosphere) {
if (!root.album || key !== (root.album.artKey || "")
|| key !== root.headerAtmosphereKey
|| path !== root.headerAtmospherePath)
return
root.headerColor = tint
root.headerAtmosphere = atmosphere
}
}
/// The disc rows the document publishes for a MULTI-disc album
@@ -331,7 +375,18 @@ Rectangle {
if (discRows[i].artKey) keys.push(discRows[i].artKey)
if (keys.length > 0) QbzLocal.artworkWindow(JSON.stringify(keys))
}
onAlbumChanged: requestHeaderArtwork()
onAlbumChanged: {
var key = root.album ? (root.album.artKey || "") : ""
if (key !== root.headerAtmosphereKey) {
root.headerColor = ""
root.headerAtmosphere = ""
root.headerAtmosphereKey = ""
root.headerAtmospherePath = ""
}
root.requestHeaderArtwork()
var path = key ? (root.artMap[key] || "") : ""
if (path !== "") root.requestHeaderAtmosphere(key, path)
}
onDiscRowsChanged: requestHeaderArtwork()
// ------------------------- skeleton pulse ----------------------------
@@ -362,37 +417,6 @@ Rectangle {
}
// ============================ page ===================================
// Neutral header band (local albums have no artwork-derived tint yet).
//
// SUPPRESSED under the app-wide dynamic background, exactly like the Qobuz
// page's artwork atmosphere (AlbumView.qml `headerAtmoOn = pref &&
// !ambientOn`, AlbumPageView.slint:168): the two backdrops clash, and the
// dynamic background is already providing the dark one. This band never got
// that gate, which is why the defect showed HERE and not on the Qobuz album
// page — the owner spotted it as a lost bezel, and the bezel was the
// symptom: the band is full-bleed at y=0 with no radius, so it painted over
// the content pane's rounded top corners. With the background OFF nothing
// changes, and nothing has to: AppShell's BezelCorner nubs are visible in
// that state and mask exactly these corners. They are hidden while the
// field is meant to show through them, which is what uncovered a band that
// had been square all along.
//
// Header text is `theme.textPrimary` here (not the Qobuz page's
// light-on-dark `hdrStrong`), so it does not depend on this band to stay
// readable — checked before removing the backdrop from under it.
Rectangle {
visible: !root.ambientOn
x: 0
y: 0
width: parent.width
height: 340
gradient: Gradient {
GradientStop { position: 0.0; color: "#181820" }
GradientStop { position: 0.16; color: "#181820" }
GradientStop { position: 1.0; color: "#00181820" }
}
}
Flickable {
id: flick
anchors.fill: parent
@@ -401,6 +425,19 @@ Rectangle {
contentHeight: page.height + 100
boundsBehavior: Flickable.StopAtBounds
// Exact shared Qobuz/artist atmosphere component. It lives inside
// the scroll content and paints first, so the header scrolls over it
// without adding another clipping or shader layer.
HeaderGradient {
x: 0
y: 0
width: flick.width
height: page.y + headerDivider.y + root.atmoReach
tint: root.headerColor
atmosphere: root.headerAtmosphere
active: root.headerAtmoOn
}
Column {
id: page
x: 32
@@ -449,12 +486,15 @@ Rectangle {
coverPending: root.coverPending
skelPhase: root.skelPhase
artSettleMs: root.artSettleMs
strongColor: root.hdrStrong
bodyColor: root.hdrBody
overlay: root.hdrOverlay
onOpenArtist: function (name) { root.openArtist(name) }
onCompactToggled: root.toggleCompactHeader()
}
Item { width: 1; height: 20 }
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Rectangle { id: headerDivider; width: parent.width; height: 1; color: "transparent" }
Item { width: 1; height: 8 }
// ---- Loading ----
@@ -35,6 +35,11 @@ Row {
property bool artPassActive: false
property bool skelPhase: false
property int artSettleMs: 0
/// Header palette selected by LocalAlbumView from the same atmosphere /
/// ambient rules as Qobuz AlbumView.
property color strongColor: theme.textPrimary
property color bodyColor: theme.textSecondary
property bool overlay: false
signal openArtist(string name)
signal compactToggled()
@@ -88,7 +93,7 @@ Row {
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root.album ? (root.album.title || "") : ""
color: theme.textPrimary
color: root.strongColor
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
@@ -108,7 +113,7 @@ Row {
: Math.min(implicitWidth,
Math.max(80, compactHeading.width * 0.62))
text: root.album ? (root.album.title || "") : ""
color: theme.textPrimary
color: root.strongColor
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
@@ -117,7 +122,7 @@ Row {
id: compactTitleSeparator
visible: root.album && (root.album.artist || "") !== ""
text: " • "
color: theme.textSecondary
color: root.bodyColor
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
}
@@ -126,7 +131,7 @@ Row {
- compactAlbumTitle.width - compactTitleSeparator.width)
text: root.album ? (root.album.artist || "") : ""
color: compactArtistArea.containsMouse
? theme.textPrimary : theme.textSecondary
? root.strongColor : root.bodyColor
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
@@ -147,6 +152,7 @@ Row {
btnSize: root.compact ? 22 : 26
iconSize: root.compact ? 11 : 13
name: root.compact ? "maximize-2" : "minimize-2"
tintOverride: root.overlay ? "white" : ""
onClicked: root.compactToggled()
HoverHandler { id: localHeaderModeHover }
ToolTip.visible: localHeaderModeHover.hovered
@@ -162,7 +168,7 @@ Row {
spacing: 8
Text {
text: root.album ? (root.album.artist || "") : ""
color: artistArea.containsMouse ? theme.textPrimary : theme.textSecondary
color: artistArea.containsMouse ? root.strongColor : root.bodyColor
font.pixelSize: theme.fontHeading
font.weight: theme.weightBold
MouseArea {
@@ -182,7 +188,7 @@ Row {
? QbzSession.tr("Show less", QbzSession.trRev)
: "+" + (root.allArtists.length - 1) + " "
+ QbzSession.tr("more artists", QbzSession.trRev)
color: moreArea.containsMouse ? theme.accent : theme.textMuted
color: moreArea.containsMouse ? root.strongColor : root.bodyColor
font.pixelSize: theme.fontBody
MouseArea {
id: moreArea
@@ -208,7 +214,7 @@ Row {
required property string modelData
height: 24
text: modelData
color: nameArea.containsMouse ? theme.accent : theme.textSecondary
color: nameArea.containsMouse ? theme.accent : root.bodyColor
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
MouseArea {
@@ -225,7 +231,7 @@ Row {
Text {
width: parent.width
text: root.compact ? root.compactInfoLine : root.infoLine
color: theme.textSecondary
color: root.bodyColor
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
@@ -239,11 +245,13 @@ Row {
primary: true
compactPrimary: root.compact
diameterOverride: root.compact ? 28 : 0
overlay: root.overlay
onClicked: QbzLocal.albumSelectedAction("play", "")
}
QbzCircleAction {
name: "shuffle"
diameterOverride: root.compact ? 28 : 0
overlay: root.overlay
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.albumSelectedAction("shuffle", "")
}
@@ -251,18 +259,21 @@ Row {
QbzCircleAction {
name: "pen-line"
diameterOverride: root.compact ? 28 : 0
overlay: root.overlay
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.albumEditTags(root.album.id)
}
QbzCircleAction {
name: "list-plus"
diameterOverride: root.compact ? 28 : 0
overlay: root.overlay
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.albumAddToPlaylist(root.album.id)
}
QbzCircleAction {
name: "cassette-tape"
diameterOverride: root.compact ? 28 : 0
overlay: root.overlay
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.albumAddToMixtape(root.album.id)
}
@@ -281,13 +292,14 @@ Row {
anchors.verticalCenter: parent.verticalCenter
text: root.versions.length + " "
+ QbzSession.tr("versions", QbzSession.trRev) + ":"
color: theme.textMuted
color: root.bodyColor
font.pixelSize: theme.fontLegal
}
VersionPicker {
anchors.verticalCenter: parent.verticalCenter
versions: root.versions
current: root.album ? (root.album.versionIndex || 0) : 0
overlay: root.overlay
onPicked: function (i) { QbzLocal.albumSelectVersion(i) }
}
}
@@ -22,6 +22,9 @@ Rectangle {
/// arm the current source is the button and the full description stays in
/// the flyout; the routed AlbumView keeps the labelled select.
property bool compact: false
/// Light-on-dark palette when mounted over an artwork atmosphere. The
/// flyout remains a normal themed surface; only its launcher is over art.
property bool overlay: false
signal picked(int index)
QbzTheme { id: theme }
@@ -42,8 +45,11 @@ Rectangle {
height: 30
width: compact ? 28 : row.width
radius: 6
color: pickArea.containsMouse && versions.length > 1
? theme.surfaceHover : (compact ? "transparent" : theme.surfaceElevated)
color: root.overlay
? (pickArea.containsMouse && versions.length > 1
? "#3dffffff" : (compact ? "transparent" : "#24ffffff"))
: (pickArea.containsMouse && versions.length > 1
? theme.surfaceHover : (compact ? "transparent" : theme.surfaceElevated))
Row {
id: row
@@ -59,6 +65,7 @@ Rectangle {
// hard-drive beside them. Colour logos are for cards — a list of
// them fights the text it labels.
mono: true
localTint: root.overlay ? "white" : "secondary"
// No size overrides on purpose: LocalAlbumView.slint:24-26 draws
// ALL THREE kinds at a flat 14px here, which is SourceIcon's
// default (the row glyphs are the ones that grow the marks).
@@ -66,7 +73,7 @@ Rectangle {
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.optionText(root.currentVersion)
color: theme.textSecondary
color: root.overlay ? "#e0ffffff" : theme.textSecondary
font.pixelSize: theme.fontLegal
}
QbzIcon {
@@ -74,7 +81,7 @@ Rectangle {
name: "chevron-down"
width: 12
height: 12
tintName: "muted"
tintName: root.overlay ? "white" : "muted"
}
}
Item {
@@ -88,7 +95,7 @@ Rectangle {
glyphSize: 15
plexSize: 16
qobuzSize: 16
localTint: "muted"
localTint: root.overlay ? "white" : "muted"
}
QbzIcon {
visible: root.versions.length > 1
+5
View File
@@ -120,6 +120,10 @@ pub struct AlbumDetailDoc {
/// LocalAlbumView switches to the live settings document once published.
#[serde(rename = "compactHeader")]
pub compact_header: bool,
/// Cold-start fallback for the same artwork-header preference consumed
/// by Qobuz AlbumView. The live settings document wins once available.
#[serde(rename = "headerGradient")]
pub header_gradient: bool,
}
/// The bounded payload consumed by one expanded row in the Genres browser.
@@ -351,6 +355,7 @@ pub fn version_doc(id: &str, index: usize) -> Option<AlbumDetailDoc> {
tracks: rows,
discs,
compact_header: crate::settings_qt::pref_bool("compact_album_header", false),
header_gradient: crate::settings_qt::pref_bool("album_header_gradient", true),
})
}
+59
View File
@@ -660,11 +660,29 @@ pub mod qbz_local {
/// emits `localArtworkReady` per hit.
#[qinvokable]
fn artwork_window(self: Pin<&mut QbzLocal>, keys_json: QString);
/// Derive the same flat tint + blurred atmosphere used by Qobuz
/// AlbumView from an already-resolved local cover. Decode work stays
/// off the Qt thread; key/path return with the result as generation
/// guards for QML.
#[qinvokable]
fn album_header_atmosphere(
self: Pin<&mut QbzLocal>,
key: QString,
path: QString,
);
/// (key, "file://…") — id-keyed so a cover can never land on the
/// wrong row.
#[qsignal]
fn local_artwork_ready(self: Pin<&mut QbzLocal>, key: QString, path: QString);
#[qsignal]
fn local_album_atmosphere_ready(
self: Pin<&mut QbzLocal>,
key: QString,
path: QString,
tint: QString,
atmosphere: QString,
);
#[qsignal]
fn local_genre_album_ready(
self: Pin<&mut QbzLocal>,
id: QString,
@@ -1511,6 +1529,47 @@ impl qbz_local::QbzLocal {
tokio::join!(cold, remote);
});
}
pub fn album_header_atmosphere(
self: Pin<&mut Self>,
key: QString,
path: QString,
) {
let key = key.to_string();
let path = path.to_string();
if key.is_empty() || path.is_empty() {
return;
}
let raw_path = match crate::artwork_qt::classify(&path) {
crate::artwork_qt::ArtUrl::LocalFile(path) => path,
_ => return,
};
crate::spawn(async move {
let tint_path = raw_path.clone();
let atmo_path = raw_path;
let (tint, atmosphere) = tokio::join!(
tokio::task::spawn_blocking(move || {
crate::album_qt::header_tint_hex(&tint_path)
}),
tokio::task::spawn_blocking(move || {
crate::atmosphere_qt::for_cover_blocking(&atmo_path)
}),
);
let tint = tint.ok().flatten().unwrap_or_default();
let atmosphere = atmosphere.ok().flatten().unwrap_or_default();
if tint.is_empty() && atmosphere.is_empty() {
return;
}
ui(move |mut b| {
b.as_mut().local_album_atmosphere_ready(
QString::from(key.as_str()),
QString::from(path.as_str()),
QString::from(tint.as_str()),
QString::from(atmosphere.as_str()),
);
});
});
}
// --- Tree selection + bulk actions --------------------------------------
pub fn tree_set_select_mode(self: Pin<&mut Self>, on: bool) {