From 0eabda31644fc9e6279641a92097403abe2d9538 Mon Sep 17 00:00:00 2001 From: Ron Harel <55725807+ronharel02@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:35:56 +0300 Subject: [PATCH] Improve seek and scrub indicator/ animations in the media controls widget. (#2181) --- quickshell/Widgets/DankSeekbar.qml | 242 +++++++++++++++++--------- quickshell/Widgets/M3WaveProgress.qml | 80 ++++++++- 2 files changed, 236 insertions(+), 86 deletions(-) diff --git a/quickshell/Widgets/DankSeekbar.qml b/quickshell/Widgets/DankSeekbar.qml index d30249a0..85f13a50 100644 --- a/quickshell/Widgets/DankSeekbar.qml +++ b/quickshell/Widgets/DankSeekbar.qml @@ -8,13 +8,122 @@ Item { id: root property MprisPlayer activePlayer - property real value: { - if (!activePlayer || activePlayer.length <= 0) return 0 - const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length) - const calculatedRatio = pos / activePlayer.length - return Math.max(0, Math.min(1, calculatedRatio)) + property real seekPreviewRatio: -1 + readonly property real playerValue: { + if (!activePlayer || activePlayer.length <= 0) + return 0; + const pos = (activePlayer.position || 0) % Math.max(1, activePlayer.length); + const calculatedRatio = pos / activePlayer.length; + return Math.max(0, Math.min(1, calculatedRatio)); } + property real value: seekPreviewRatio >= 0 ? seekPreviewRatio : playerValue property bool isSeeking: false + property bool isDraggingSeek: false + property real committedSeekRatio: -1 + property int previewSettleChecksRemaining: 0 + property real dragThreshold: 4 + property int holdIndicatorDelay: 180 + + function clampRatio(ratio) { + return Math.max(0, Math.min(1, ratio)); + } + + function ratioForPosition(position) { + if (!activePlayer || activePlayer.length <= 0) + return 0; + return clampRatio(position / activePlayer.length); + } + + function positionForRatio(ratio) { + if (!activePlayer || activePlayer.length <= 0) + return 0; + const rawPosition = clampRatio(ratio) * activePlayer.length; + return Math.min(rawPosition, activePlayer.length * 0.99); + } + + function updatePreviewFromMouse(mouseX, width) { + if (!activePlayer || activePlayer.length <= 0 || width <= 0) + return; + seekPreviewRatio = clampRatio(mouseX / width); + } + + function clearCommittedSeekPreview() { + previewSettleTimer.stop(); + committedSeekRatio = -1; + previewSettleChecksRemaining = 0; + if (!isSeeking) + seekPreviewRatio = -1; + } + + function beginCommittedSeekPreview(position) { + seekPreviewRatio = ratioForPosition(position); + committedSeekRatio = seekPreviewRatio; + previewSettleChecksRemaining = 15; + previewSettleTimer.restart(); + } + + function handleSeekPressed(mouse, width, mouseArea, holdTimer) { + isSeeking = true; + isDraggingSeek = false; + mouseArea.pressX = mouse.x; + clearCommittedSeekPreview(); + holdTimer.restart(); + if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { + updatePreviewFromMouse(mouse.x, width); + mouseArea.pendingSeekPosition = positionForRatio(seekPreviewRatio); + } + } + + function handleSeekReleased(mouseArea, holdTimer) { + holdTimer.stop(); + isSeeking = false; + isDraggingSeek = false; + if (mouseArea.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) { + const clamped = Math.min(mouseArea.pendingSeekPosition, activePlayer.length * 0.99); + activePlayer.position = clamped; + mouseArea.pendingSeekPosition = -1; + beginCommittedSeekPreview(clamped); + } else { + seekPreviewRatio = -1; + } + } + + function handleSeekPositionChanged(mouse, width, mouseArea) { + if (mouseArea.pressed && isSeeking && activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { + if (!isDraggingSeek && Math.abs(mouse.x - mouseArea.pressX) >= dragThreshold) + isDraggingSeek = true; + updatePreviewFromMouse(mouse.x, width); + mouseArea.pendingSeekPosition = positionForRatio(seekPreviewRatio); + } + } + + function handleSeekCanceled(mouseArea, holdTimer) { + holdTimer.stop(); + isSeeking = false; + isDraggingSeek = false; + mouseArea.pendingSeekPosition = -1; + clearCommittedSeekPreview(); + } + + Timer { + id: previewSettleTimer + interval: 80 + repeat: true + onTriggered: { + if (root.isSeeking || root.committedSeekRatio < 0) { + stop(); + return; + } + + const previewSettled = Math.abs(root.playerValue - root.committedSeekRatio) <= 0.0015; + if (previewSettled || root.previewSettleChecksRemaining <= 0) { + root.clearCommittedSeekPreview(); + return; + } + + root.previewSettleChecksRemaining -= 1; + } + } implicitHeight: 20 @@ -29,58 +138,35 @@ Item { M3WaveProgress { value: root.value + actualValue: root.playerValue + showActualPlaybackState: root.isSeeking + actualProgressColor: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.45) isPlaying: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing MouseArea { + id: waveMouseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0 property real pendingSeekPosition: -1 + property real pressX: 0 Timer { - id: waveSeekDebounceTimer - interval: 150 + id: waveHoldIndicatorTimer + interval: root.holdIndicatorDelay + repeat: false onTriggered: { - if (parent.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) { - const clamped = Math.min(parent.pendingSeekPosition, activePlayer.length * 0.99) - activePlayer.position = clamped - parent.pendingSeekPosition = -1 - } + if (parent.pressed && root.isSeeking) + root.isDraggingSeek = true; } } - onPressed: (mouse) => { - root.isSeeking = true - if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { - const r = Math.max(0, Math.min(1, mouse.x / parent.width)) - pendingSeekPosition = r * activePlayer.length - waveSeekDebounceTimer.restart() - } - } - onReleased: { - root.isSeeking = false - waveSeekDebounceTimer.stop() - if (pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) { - const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99) - activePlayer.position = clamped - pendingSeekPosition = -1 - } - } - onPositionChanged: (mouse) => { - if (pressed && root.isSeeking && activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { - const r = Math.max(0, Math.min(1, mouse.x / parent.width)) - pendingSeekPosition = r * activePlayer.length - waveSeekDebounceTimer.restart() - } - } - onClicked: (mouse) => { - if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { - const r = Math.max(0, Math.min(1, mouse.x / parent.width)) - activePlayer.position = r * activePlayer.length - } - } + onPressed: mouse => root.handleSeekPressed(mouse, parent.width, waveMouseArea, waveHoldIndicatorTimer) + onReleased: root.handleSeekReleased(waveMouseArea, waveHoldIndicatorTimer) + onPositionChanged: mouse => root.handleSeekPositionChanged(mouse, parent.width, waveMouseArea) + onCanceled: root.handleSeekCanceled(waveMouseArea, waveHoldIndicatorTimer) } } } @@ -93,6 +179,7 @@ Item { property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40) property color fillColor: Theme.primary property color playheadColor: Theme.primary + property color actualProgressColor: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.45) readonly property real midY: height / 2 Rectangle { @@ -110,7 +197,22 @@ Item { anchors.verticalCenter: parent.verticalCenter color: parent.fillColor radius: height / 2 - Behavior on width { NumberAnimation { duration: 80 } } + Behavior on width { + NumberAnimation { + duration: 80 + } + } + } + + Rectangle { + visible: root.isDraggingSeek + width: 2 + height: Math.max(parent.lineWidth + 4, 10) + radius: width / 2 + color: parent.actualProgressColor + x: Math.max(0, Math.min(parent.width, parent.width * root.playerValue)) - width / 2 + y: parent.midY - height / 2 + z: 2 } Rectangle { @@ -122,59 +224,37 @@ Item { x: Math.max(0, Math.min(parent.width, parent.width * root.value)) - width / 2 y: parent.midY - height / 2 z: 3 - Behavior on x { NumberAnimation { duration: 80 } } + Behavior on x { + NumberAnimation { + duration: 80 + } + } } MouseArea { + id: flatMouseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor enabled: activePlayer && activePlayer.canSeek && activePlayer.length > 0 property real pendingSeekPosition: -1 + property real pressX: 0 Timer { - id: flatSeekDebounceTimer - interval: 150 + id: flatHoldIndicatorTimer + interval: root.holdIndicatorDelay + repeat: false onTriggered: { - if (parent.pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) { - const clamped = Math.min(parent.pendingSeekPosition, activePlayer.length * 0.99) - activePlayer.position = clamped - parent.pendingSeekPosition = -1 - } + if (parent.pressed && root.isSeeking) + root.isDraggingSeek = true; } } - onPressed: (mouse) => { - root.isSeeking = true - if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { - const r = Math.max(0, Math.min(1, mouse.x / parent.width)) - pendingSeekPosition = r * activePlayer.length - flatSeekDebounceTimer.restart() - } - } - onReleased: { - root.isSeeking = false - flatSeekDebounceTimer.stop() - if (pendingSeekPosition >= 0 && activePlayer && activePlayer.canSeek && activePlayer.length > 0) { - const clamped = Math.min(pendingSeekPosition, activePlayer.length * 0.99) - activePlayer.position = clamped - pendingSeekPosition = -1 - } - } - onPositionChanged: (mouse) => { - if (pressed && root.isSeeking && activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { - const r = Math.max(0, Math.min(1, mouse.x / parent.width)) - pendingSeekPosition = r * activePlayer.length - flatSeekDebounceTimer.restart() - } - } - onClicked: (mouse) => { - if (activePlayer && activePlayer.length > 0 && activePlayer.canSeek) { - const r = Math.max(0, Math.min(1, mouse.x / parent.width)) - activePlayer.position = r * activePlayer.length - } - } + onPressed: mouse => root.handleSeekPressed(mouse, parent.width, flatMouseArea, flatHoldIndicatorTimer) + onReleased: root.handleSeekReleased(flatMouseArea, flatHoldIndicatorTimer) + onPositionChanged: mouse => root.handleSeekPositionChanged(mouse, parent.width, flatMouseArea) + onCanceled: root.handleSeekCanceled(flatMouseArea, flatHoldIndicatorTimer) } } } diff --git a/quickshell/Widgets/M3WaveProgress.qml b/quickshell/Widgets/M3WaveProgress.qml index 30b40797..051e092e 100644 --- a/quickshell/Widgets/M3WaveProgress.qml +++ b/quickshell/Widgets/M3WaveProgress.qml @@ -6,6 +6,8 @@ Item { id: root property real value: 0 + property real actualValue: value + property bool showActualPlaybackState: false property real lineWidth: 2 property real wavelength: 20 property real amp: 1.6 @@ -15,6 +17,7 @@ Item { property color trackColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.40) property color fillColor: Theme.primary property color playheadColor: Theme.primary + property color actualProgressColor: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.45) property real dpr: (root.window ? root.window.devicePixelRatio : 1) function snap(v) { @@ -22,7 +25,12 @@ Item { } readonly property real playX: snap(root.width * root.value) + readonly property real actualX: snap(root.width * root.actualValue) readonly property real midY: snap(height / 2) + readonly property bool previewAhead: root.showActualPlaybackState && root.value > root.actualValue + readonly property bool previewBehind: root.showActualPlaybackState && root.value < root.actualValue + readonly property real previewGapStartX: Math.min(root.playX, root.actualX) + readonly property real previewGapEndX: Math.max(root.playX, root.actualX) Behavior on currentAmp { NumberAnimation { @@ -65,7 +73,9 @@ Item { 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)) + readonly property real endX: root.previewAhead ? Math.max(startX, Math.min(root.actualX - aaBias, width)) : Math.max(startX, Math.min(root.playX - startX - aaBias, width)) + readonly property real gapStartX: root.previewAhead ? Math.max(startX, Math.min(root.actualX + aaBias, width)) : Math.max(startX, Math.min(root.playX + playhead.width / 2, width)) + readonly property real gapEndX: root.previewAhead ? Math.max(gapStartX, Math.min(root.playX - playhead.width / 2 - aaBias, width)) : Math.max(gapStartX, Math.min(root.actualX - aaBias, width)) Rectangle { id: mask @@ -100,6 +110,37 @@ Item { } } + Rectangle { + id: actualMask + anchors.top: parent.top + anchors.bottom: parent.bottom + x: waveClip.gapStartX + width: Math.max(0, waveClip.gapEndX - waveClip.gapStartX) + color: "transparent" + clip: true + visible: (root.previewBehind || root.previewAhead) && width > 0 + + Shape { + anchors.top: parent.top + anchors.bottom: parent.bottom + width: root.width + 4 * root.wavelength + antialiasing: true + preferredRendererType: Shape.CurveRenderer + x: waveOffsetX + + ShapePath { + strokeColor: root.actualProgressColor + strokeWidth: snap(root.lineWidth) + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + fillColor: "transparent" + PathSvg { + path: waveSvg.path + } + } + } + } + Rectangle { id: startCap width: snap(root.lineWidth) @@ -107,7 +148,7 @@ Item { 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) + y: waveY(waveClip.startX) - height / 2 visible: waveClip.endX > waveClip.startX z: 2 } @@ -119,10 +160,34 @@ Item { 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) + y: waveY(waveClip.endX) - height / 2 visible: waveClip.endX > waveClip.startX z: 2 } + + Rectangle { + id: actualEndCap + width: snap(root.lineWidth) + height: snap(root.lineWidth) + radius: width / 2 + color: root.actualProgressColor + x: waveClip.gapEndX - width / 2 + y: waveY(waveClip.gapEndX) - height / 2 + visible: (root.previewBehind || root.previewAhead) && actualMask.width > 0 + z: 2 + } + + Rectangle { + id: actualMarker + width: 2 + height: Math.max(root.lineWidth + 4, 10) + radius: width / 2 + color: root.actualProgressColor + x: root.actualX - width / 2 + y: root.midY - height / 2 + visible: root.showActualPlaybackState + z: 2 + } } Rectangle { @@ -141,6 +206,10 @@ Item { let r = a % m; return r < 0 ? r + m : r; } + function waveY(x, amplitude = root.currentAmp, phaseOffset = root.phase) { + return root.midY + amplitude * Math.sin((x / root.wavelength) * 2 * Math.PI + phaseOffset); + } + readonly property real waveOffsetX: -wrapMod(phase / k, wavelength) FrameAnimation { @@ -148,8 +217,9 @@ Item { 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); + startCap.y = waveY(waveClip.startX) - startCap.height / 2; + endCap.y = waveY(waveClip.endX) - endCap.height / 2; + actualEndCap.y = waveY(waveClip.gapEndX) - actualEndCap.height / 2; } }