feat(qbz-qt): add atmosphere to local album headers
cx
This commit is contained in:
5 files changed
+170
-47
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user