aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-29 13:04:12 +0200
committerDanilo M. <danix@danix.xyz>2026-09-29 13:04:12 +0200
commitb33c7f5ec094469becd4f842e2bed37efb85dcfb (patch)
treeeb924a74094f15dd4511fbc43172a2902d4a63d3
parentae193343b9633eb79287c4e2589b95c02f538e48 (diff)
downloadquickshell-b33c7f5ec094469becd4f842e2bed37efb85dcfb.tar.gz
quickshell-b33c7f5ec094469becd4f842e2bed37efb85dcfb.zip
feat(sound): crossfade album art, fade to plain without it
The OSD background held one Image, so a track change swapped covers in a single frame. Two images now take turns: the new cover loads into the hidden one and is brought to the front only once it reports Ready, then both fade over 300ms. Waiting for Ready is what keeps a slow or remote art server from fading through black. The blur, scrim, sheen and grain are grouped under one item whose opacity follows artOn. It goes false when the player has no art or the cover for the current track errors, so the panel fades back to its plain translucent look instead of snapping, or keeping a stale cover that reads as a fault. It goes true again only when a new cover has loaded, so returning from plain never flashes the previous one. The back image's source is cleared before being set, so a URL it already holds still reloads and reports a status. onWantChanged misses the value at load, hence the Component.onCompleted call. Not yet seen on screen: the shell reloaded it cleanly but the fade was not watched through a track change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--desktop/modules/sound/Osd.qml164
-rw-r--r--desktop/modules/sound/README.md8
2 files changed, 113 insertions, 59 deletions
diff --git a/desktop/modules/sound/Osd.qml b/desktop/modules/sound/Osd.qml
index dfcf876..ede8cf1 100644
--- a/desktop/modules/sound/Osd.qml
+++ b/desktop/modules/sound/Osd.qml
@@ -87,15 +87,59 @@ Scope {
// player has any. It is opaque, so it hides the compositor's
// frosting; without art the panel keeps the translucent look.
// Inset by the border width so the border stays visible.
- Image {
+ //
+ // Two images take turns so a track change crossfades: the new
+ // cover loads into the hidden one and is brought to the front
+ // only once decoded, so a slow server never fades through black.
+ // No art, or a cover that fails to load, fades the whole layer
+ // out to the plain panel rather than leaving a stale cover.
+ Item {
id: bgArt
anchors.fill: parent
anchors.margins: 1
- source: Player.active ? Player.artUrl : ""
- cache: false
- asynchronous: true
- fillMode: Image.PreserveAspectCrop
visible: false
+
+ readonly property string want: Player.active ? Player.artUrl : ""
+ property int front: 0
+ property bool artOn: false
+
+ onWantChanged: {
+ if (want === "") { artOn = false; return; }
+ const back = front === 0 ? artB : artA;
+ // Cleared first so a URL the back image already holds
+ // still loads and reports a status.
+ back.source = "";
+ back.source = want;
+ }
+ // The handler above only sees changes, not the value at load.
+ Component.onCompleted: wantChanged()
+
+ function loaded(img, idx) {
+ if (img.source != want) return;
+ if (img.status === Image.Ready) { front = idx; artOn = true; }
+ else if (img.status === Image.Error) artOn = false;
+ }
+
+ Image {
+ id: artA
+ anchors.fill: parent
+ cache: false
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ opacity: bgArt.front === 0 ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 300 } }
+ onStatusChanged: bgArt.loaded(this, 0)
+ }
+ Image {
+ id: artB
+ anchors.fill: parent
+ cache: false
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ opacity: bgArt.front === 1 ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 300 } }
+ onStatusChanged: bgArt.loaded(this, 1)
+ }
}
// clip only cuts to the bounding box, so the corners come from a
@@ -108,67 +152,71 @@ Scope {
visible: false
}
- MultiEffect {
- id: bgEffect
+ // Everything drawn over the art fades with it.
+ Item {
anchors.fill: bgArt
- visible: bgArt.status === Image.Ready && bgArt.source != ""
- source: bgArt
- autoPaddingEnabled: false
- blurEnabled: true
- blur: 0.5
- maskEnabled: true
- maskSource: bgMask
- maskThresholdMin: 0.5
- maskSpreadAtMin: 1.0
- }
-
- // Keeps text and the accent bar legible on a bright cover. A
- // sibling, not a child: nested inside the MultiEffect it did not
- // paint, and a light cover showed through at full brightness.
- Rectangle {
- anchors.fill: bgArt
- visible: bgEffect.visible
- radius: bgMask.radius
- color: Qt.alpha(Theme.base, 0.82)
- }
+ opacity: bgArt.artOn ? 1 : 0
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: 300 } }
- // Frost on the scrim, faked: a faint top-down sheen and fixed
- // grain. The art is opaque, so the compositor's frosting cannot
- // show here without costing the scrim its legibility.
- Rectangle {
- anchors.fill: bgArt
- visible: bgEffect.visible
- radius: bgMask.radius
- gradient: Gradient {
- GradientStop { position: 0.0; color: Qt.alpha(Theme.text, 0.08) }
- GradientStop { position: 0.6; color: "transparent" }
- }
- }
-
- // ponytail: grain painted once with Canvas; a ShaderEffect would
- // animate or scale better but needs a compiled .qsb beside it.
- Canvas {
- anchors.fill: bgArt
- visible: bgEffect.visible
- layer.enabled: true
- layer.effect: MultiEffect {
+ MultiEffect {
+ anchors.fill: parent
+ source: bgArt
+ autoPaddingEnabled: false
+ blurEnabled: true
+ blur: 0.5
maskEnabled: true
maskSource: bgMask
maskThresholdMin: 0.5
maskSpreadAtMin: 1.0
}
- onPaint: {
- if (width <= 0 || height <= 0) return;
- const ctx = getContext("2d");
- const img = ctx.createImageData(width, height);
- for (let i = 0; i < img.data.length; i += 4) {
- const v = Math.random() * 255;
- img.data[i] = v;
- img.data[i + 1] = v;
- img.data[i + 2] = v;
- img.data[i + 3] = 14;
+
+ // Keeps text and the accent bar legible on a bright cover. A
+ // sibling, not a child: nested inside the MultiEffect it did
+ // not paint, and a light cover showed through at full
+ // brightness.
+ Rectangle {
+ anchors.fill: parent
+ radius: bgMask.radius
+ color: Qt.alpha(Theme.base, 0.82)
+ }
+
+ // Frost on the scrim, faked: a faint top-down sheen and fixed
+ // grain. The art is opaque, so the compositor's frosting
+ // cannot show here without costing the scrim its legibility.
+ Rectangle {
+ anchors.fill: parent
+ radius: bgMask.radius
+ gradient: Gradient {
+ GradientStop { position: 0.0; color: Qt.alpha(Theme.text, 0.08) }
+ GradientStop { position: 0.6; color: "transparent" }
+ }
+ }
+
+ // ponytail: grain painted once with Canvas; a ShaderEffect
+ // would animate or scale better but needs a compiled .qsb.
+ Canvas {
+ anchors.fill: parent
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskEnabled: true
+ maskSource: bgMask
+ maskThresholdMin: 0.5
+ maskSpreadAtMin: 1.0
+ }
+ onPaint: {
+ if (width <= 0 || height <= 0) return;
+ const ctx = getContext("2d");
+ const img = ctx.createImageData(width, height);
+ for (let i = 0; i < img.data.length; i += 4) {
+ const v = Math.random() * 255;
+ img.data[i] = v;
+ img.data[i + 1] = v;
+ img.data[i + 2] = v;
+ img.data[i + 3] = 14;
+ }
+ ctx.putImageData(img, 0, 0);
}
- ctx.putImageData(img, 0, 0);
}
}
diff --git a/desktop/modules/sound/README.md b/desktop/modules/sound/README.md
index d92d017..5205be8 100644
--- a/desktop/modules/sound/README.md
+++ b/desktop/modules/sound/README.md
@@ -44,7 +44,13 @@ When the track has art, it also fills the whole panel as a blurred background
under a dark scrim (`Theme.base` at 0.82), and the thumbnail stays sharp in
the row. The palette is dark, so the scrim is what keeps text legible on a
light cover; blur alone did not. The art is opaque, so it hides the
-compositor's frosting while it shows. Without art the panel is unchanged.
+compositor's frosting while it shows, so a faint sheen and grain fake it on
+the scrim. Without art the panel is unchanged.
+
+A new cover crossfades in once it has decoded, so a slow art server never
+fades through black. When the track has no art, or its cover fails to load,
+the whole art layer fades out to the plain panel instead of keeping the last
+cover.
Hovering the panel freezes its fade so the buttons can be clicked; moving away
starts the countdown again. Without a hover it behaves exactly as it did before