diff options
| -rw-r--r-- | desktop/modules/sound/Osd.qml | 164 | ||||
| -rw-r--r-- | desktop/modules/sound/README.md | 8 |
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 |
