From f49b5dd03722f6971a6ef9f70af89a169dc69e95 Mon Sep 17 00:00:00 2001 From: bbedward Date: Sun, 7 Dec 2025 12:23:00 -0500 Subject: [PATCH] media player: replace color quantizer with album art --- .../Modules/DankDash/MediaPlayerTab.qml | 195 ++++++------------ quickshell/Widgets/M3WaveProgress.qml | 118 +++++++---- 2 files changed, 144 insertions(+), 169 deletions(-) diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index a766d82d..f393859c 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -51,17 +51,9 @@ Item { readonly property bool usePlayerVolume: activePlayer && activePlayer.volumeSupported && !__isChromeBrowser readonly property real currentVolume: usePlayerVolume ? activePlayer.volume : (AudioService.sink?.audio?.volume ?? 0) - // Palette that stays stable across track switches until new colors are ready - property color dom: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 1.0) - property color acc: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.25) - property color _nextDom: dom - property color _nextAcc: acc - - // Track-switch hold (prevents banner flicker only during switches) property bool isSwitching: false - property bool paletteReady: false property string _lastArtUrl: "" - property url _cqSource: "" + property string _bgArtSource: "" // Derived "no players" state: always correct, no timers. readonly property int _playerCount: allPlayers ? allPlayers.length : 0 @@ -69,7 +61,6 @@ Item { readonly property bool _trulyIdle: activePlayer && activePlayer.playbackState === MprisPlaybackState.Stopped && !activePlayer.trackTitle && !activePlayer.trackArtist readonly property bool showNoPlayerNow: (!_switchHold) && (_noneAvailable || _trulyIdle) - // Short hold only during track switches (not when players disappear) property bool _switchHold: false Timer { id: _switchHoldTimer @@ -86,11 +77,9 @@ Item { } isSwitching = true; _switchHold = true; - paletteReady = false; _switchHoldTimer.restart(); - if (activePlayer.trackArtUrl) { + if (activePlayer.trackArtUrl) loadArtwork(activePlayer.trackArtUrl); - } } property string activeTrackArtFile: "" @@ -108,13 +97,13 @@ Item { imageDownloader.command = ["curl", "-L", "-s", "--user-agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", "-o", filename, url]; imageDownloader.targetFile = filename; imageDownloader.running = true; - } else { - _preloadImage.source = url; + return; } + _bgArtSource = url; } function maybeFinishSwitch() { - if (activePlayer && activePlayer.trackTitle !== "" && paletteReady) { + if (activePlayer && activePlayer.trackTitle !== "") { isSwitching = false; _switchHold = false; } @@ -219,9 +208,8 @@ Item { property string targetFile: "" onExited: exitCode => { - if (exitCode === 0 && targetFile) { - _preloadImage.source = "file://" + targetFile; - } + if (exitCode === 0 && targetFile) + _bgArtSource = "file://" + targetFile; } } @@ -230,121 +218,70 @@ Item { running: false } - Image { - id: _preloadImage - source: "" - asynchronous: true - cache: true - visible: false - onStatusChanged: { - if (status === Image.Ready) { - _cqSource = source; - colorQuantizer.source = _cqSource; - } else if (status === Image.Error) { - _cqSource = ""; - } - } - } - - ColorQuantizer { - id: colorQuantizer - source: _cqSource !== "" ? _cqSource : undefined - depth: 8 - rescaleSize: 32 - onColorsChanged: { - if (!colors || colors.length === 0) - return; - function enhanceColor(color) { - const satBoost = 1.4; - const valueBoost = 1.2; - return Qt.hsva(color.hsvHue, Math.min(1, color.hsvSaturation * satBoost), Math.min(1, color.hsvValue * valueBoost), color.a); - } - - function getExtremeColor(startIdx, direction = 1) { - let bestColor = colors[startIdx]; - let bestScore = 0; - - for (let i = startIdx; i >= 0 && i < colors.length; i += direction) { - const c = colors[i]; - const saturation = c.hsvSaturation; - const brightness = c.hsvValue; - const contrast = Math.abs(brightness - 0.5) * 2; - const score = saturation * 0.7 + contrast * 0.3; - - if (score > bestScore) { - bestScore = score; - bestColor = c; - } - } - - return enhanceColor(bestColor); - } - - _pendingDom = getExtremeColor(Math.floor(colors.length * 0.2), 1); - _pendingAcc = getExtremeColor(Math.floor(colors.length * 0.8), -1); - paletteApplyDelay.restart(); - } - } - - property color _pendingDom: dom - property color _pendingAcc: acc - Timer { - id: paletteApplyDelay - interval: 90 - repeat: false - onTriggered: { - const dist = (c1, c2) => { - const dr = c1.r - c2.r, dg = c1.g - c2.g, db = c1.b - c2.b; - return Math.sqrt(dr * dr + dg * dg + db * db); - }; - const domChanged = dist(_pendingDom, dom) > 0.02; - const accChanged = dist(_pendingAcc, acc) > 0.02; - if (domChanged || accChanged) { - dom = _pendingDom; - acc = _pendingAcc; - } - paletteReady = true; - maybeFinishSwitch(); - } - } - property bool isSeeking: false - Rectangle { + Item { + id: bgContainer anchors.fill: parent - radius: Theme.cornerRadius - opacity: 1.0 - gradient: Gradient { - GradientStop { - position: 0.0 - color: Qt.rgba(dom.r, dom.g, dom.b, paletteReady ? 0.38 : 0.06) - } - GradientStop { - position: 0.3 - color: Qt.rgba(acc.r, acc.g, acc.b, paletteReady ? 0.28 : 0.05) - } - GradientStop { - position: 1.0 - color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, paletteReady ? 0.92 : 0.985) - } - } - Behavior on opacity { - NumberAnimation { - duration: 160 - } - } - } + visible: _bgArtSource !== "" - Behavior on dom { - ColorAnimation { - duration: 220 - easing.type: Easing.InOutQuad + Image { + id: bgImage + anchors.centerIn: parent + width: Math.max(parent.width, parent.height) * 1.1 + height: width + source: _bgArtSource + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + visible: false + onStatusChanged: { + if (status === Image.Ready) + maybeFinishSwitch(); + } } - } - Behavior on acc { - ColorAnimation { - duration: 220 - easing.type: Easing.InOutQuad + + Item { + id: blurredBg + anchors.fill: parent + visible: false + + MultiEffect { + anchors.centerIn: parent + width: bgImage.width + height: bgImage.height + source: bgImage + blurEnabled: true + blurMax: 64 + blur: 0.8 + saturation: -0.2 + brightness: -0.25 + } + } + + Rectangle { + id: bgMask + anchors.fill: parent + radius: Theme.cornerRadius + visible: false + layer.enabled: true + } + + MultiEffect { + anchors.fill: parent + source: blurredBg + maskEnabled: true + maskSource: bgMask + maskThresholdMin: 0.5 + maskSpreadAtMin: 1.0 + opacity: 0.7 + } + + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: Theme.surface + opacity: 0.3 } } diff --git a/quickshell/Widgets/M3WaveProgress.qml b/quickshell/Widgets/M3WaveProgress.qml index 9ace62d0..30b40797 100644 --- a/quickshell/Widgets/M3WaveProgress.qml +++ b/quickshell/Widgets/M3WaveProgress.qml @@ -17,12 +17,19 @@ Item { property color playheadColor: Theme.primary property real dpr: (root.window ? root.window.devicePixelRatio : 1) - function snap(v) { return Math.round(v * dpr) / dpr } + function snap(v) { + return Math.round(v * dpr) / dpr; + } readonly property real playX: snap(root.width * root.value) readonly property real midY: snap(height / 2) - Behavior on currentAmp { NumberAnimation { duration: 300; easing.type: Easing.OutCubic } } + Behavior on currentAmp { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } onIsPlayingChanged: currentAmp = isPlaying ? amp : 0 Shape { @@ -38,8 +45,16 @@ Item { capStyle: ShapePath.RoundCap joinStyle: ShapePath.RoundJoin fillColor: "transparent" - PathMove { id: flatStart; x: 0; y: root.midY } - PathLine { id: flatEnd; x: root.width; y: root.midY } + PathMove { + id: flatStart + x: Math.min(root.width, snap(root.playX + playhead.width / 2)) + y: root.midY + } + PathLine { + id: flatEnd + x: root.width + y: root.midY + } } } @@ -48,7 +63,7 @@ Item { anchors.fill: parent clip: true - readonly property real startX: snap(root.lineWidth/2) + readonly property real startX: snap(root.lineWidth / 2) readonly property real aaBias: (0.25 / root.dpr) readonly property real endX: Math.max(startX, Math.min(root.playX - startX - aaBias, width)) @@ -77,7 +92,10 @@ Item { capStyle: ShapePath.RoundCap joinStyle: ShapePath.RoundJoin fillColor: "transparent" - PathSvg { id: waveSvg; path: "" } + PathSvg { + id: waveSvg + path: "" + } } } } @@ -88,8 +106,8 @@ Item { height: snap(root.lineWidth) radius: width / 2 color: root.fillColor - x: waveClip.startX - width/2 - y: root.midY - height/2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase) + x: waveClip.startX - width / 2 + y: root.midY - height / 2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase) visible: waveClip.endX > waveClip.startX z: 2 } @@ -100,8 +118,8 @@ Item { height: snap(root.lineWidth) radius: width / 2 color: root.fillColor - x: waveClip.endX - width/2 - y: root.midY - height/2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase) + x: waveClip.endX - width / 2 + y: root.midY - height / 2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase) visible: waveClip.endX > waveClip.startX z: 2 } @@ -119,48 +137,68 @@ Item { } property real k: (2 * Math.PI) / Math.max(1e-6, wavelength) - function wrapMod(a, m) { let r = a % m; return r < 0 ? r + m : r } + function wrapMod(a, m) { + let r = a % m; + return r < 0 ? r + m : r; + } readonly property real waveOffsetX: -wrapMod(phase / k, wavelength) FrameAnimation { running: root.visible && (root.isPlaying || root.currentAmp > 0) onTriggered: { - if (root.isPlaying) root.phase += 0.03 * frameTime * 60 - startCap.y = root.midY - startCap.height/2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase) - endCap.y = root.midY - endCap.height/2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase) + if (root.isPlaying) + root.phase += 0.03 * frameTime * 60; + startCap.y = root.midY - startCap.height / 2 + root.currentAmp * Math.sin((waveClip.startX / root.wavelength) * 2 * Math.PI + root.phase); + endCap.y = root.midY - endCap.height / 2 + root.currentAmp * Math.sin((waveClip.endX / root.wavelength) * 2 * Math.PI + root.phase); } } function buildStaticWave() { - const start = waveClip.startX - 2 * root.wavelength - const end = width + 2 * root.wavelength - if (end <= start) { waveSvg.path = ""; return } - - const kLocal = k - const halfPeriod = root.wavelength / 2 - function y0(x) { return root.midY + root.currentAmp * Math.sin(kLocal * x) } - function dy0(x) { return root.currentAmp * Math.cos(kLocal * x) * kLocal } - - let x0 = start - let d = `M ${x0} ${y0(x0)}` - while (x0 < end) { - const x1 = Math.min(x0 + halfPeriod, end) - const dx = x1 - x0 - const yA = y0(x0), yB = y0(x1) - const dyA = dy0(x0), dyB = dy0(x1) - const c1x = x0 + dx/3 - const c1y = yA + (dyA * dx)/3 - const c2x = x1 - dx/3 - const c2y = yB - (dyB * dx)/3 - d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${x1} ${yB}` - x0 = x1 + const start = waveClip.startX - 2 * root.wavelength; + const end = width + 2 * root.wavelength; + if (end <= start) { + waveSvg.path = ""; + return; } - waveSvg.path = d + + const kLocal = k; + const halfPeriod = root.wavelength / 2; + function y0(x) { + return root.midY + root.currentAmp * Math.sin(kLocal * x); + } + function dy0(x) { + return root.currentAmp * Math.cos(kLocal * x) * kLocal; + } + + let x0 = start; + let d = `M ${x0} ${y0(x0)}`; + while (x0 < end) { + const x1 = Math.min(x0 + halfPeriod, end); + const dx = x1 - x0; + const yA = y0(x0), yB = y0(x1); + const dyA = dy0(x0), dyB = dy0(x1); + const c1x = x0 + dx / 3; + const c1y = yA + (dyA * dx) / 3; + const c2x = x1 - dx / 3; + const c2y = yB - (dyB * dx) / 3; + d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${x1} ${yB}`; + x0 = x1; + } + waveSvg.path = d; } - Component.onCompleted: { currentAmp = isPlaying ? amp : 0; buildStaticWave() } - onWidthChanged: { flatStart.x = 0; flatEnd.x = width; buildStaticWave() } + Component.onCompleted: { + currentAmp = isPlaying ? amp : 0; + buildStaticWave(); + } + onWidthChanged: { + flatEnd.x = width; + buildStaticWave(); + } onHeightChanged: buildStaticWave() onCurrentAmpChanged: buildStaticWave() - onWavelengthChanged: { k = (2 * Math.PI) / Math.max(1e-6, wavelength); buildStaticWave() } + onWavelengthChanged: { + k = (2 * Math.PI) / Math.max(1e-6, wavelength); + buildStaticWave(); + } }