aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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