From 6771248d2964137aea65180cae8251f75cbd39af Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Thu, 21 Aug 2025 22:42:56 +0200 Subject: [PATCH] Add audio visualizer to LockScreen --- Modules/LockScreen/LockScreen.qml | 139 +++++++++++++++++++++++++++++- Services/CavaService.qml | 3 +- Services/PanelService.qml | 3 + shell.qml | 3 + 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/Modules/LockScreen/LockScreen.qml b/Modules/LockScreen/LockScreen.qml index a02d530..2f840af 100644 --- a/Modules/LockScreen/LockScreen.qml +++ b/Modules/LockScreen/LockScreen.qml @@ -11,6 +11,7 @@ import Quickshell.Widgets import qs.Commons import qs.Services import qs.Widgets +import qs.Modules.Audio Loader { id: lockScreen @@ -294,7 +295,7 @@ Loader { spacing: Style.marginM * scaling Layout.alignment: Qt.AlignHCenter - // Animated avatar with glow effect + // Animated avatar with glow effect or audio visualizer Rectangle { width: 120 * scaling height: 120 * scaling @@ -304,7 +305,140 @@ Loader { border.width: Math.max(1, Style.borderL * scaling) anchors.horizontalCenter: parent.horizontalCenter - // Glow effect + // Circular audio visualizer when music is playing + Loader { + active: MediaService.isPlaying && Settings.data.audio.visualizerType == "linear" + anchors.centerIn: parent + width: 160 * scaling + height: 160 * scaling + + sourceComponent: Item { + Repeater { + model: CavaService.values.length + + Rectangle { + property real linearAngle: (index / CavaService.values.length) * 2 * Math.PI + property real linearRadius: 70 * scaling + property real linearBarLength: Math.max(2, CavaService.values[index] * 30 * scaling) + property real linearBarWidth: 3 * scaling + + width: linearBarWidth + height: linearBarLength + color: Color.mPrimary + radius: linearBarWidth * 0.5 + + x: parent.width * 0.5 + Math.cos(linearAngle) * linearRadius - width * 0.5 + y: parent.height * 0.5 + Math.sin(linearAngle) * linearRadius - height * 0.5 + + transform: Rotation { + origin.x: linearBarWidth * 0.5 + origin.y: linearBarLength * 0.5 + angle: (linearAngle * 180 / Math.PI) + 90 + } + } + } + } + } + + Loader { + active: MediaService.isPlaying && Settings.data.audio.visualizerType == "mirrored" + anchors.centerIn: parent + width: 160 * scaling + height: 160 * scaling + + sourceComponent: Item { + Repeater { + model: CavaService.values.length * 2 + + Rectangle { + property int mirroredValueIndex: index < CavaService.values.length ? index : (CavaService.values.length * 2 - 1 - index) + property real mirroredAngle: (index / (CavaService.values.length * 2)) * 2 * Math.PI + property real mirroredRadius: 70 * scaling + property real mirroredBarLength: Math.max(2, CavaService.values[mirroredValueIndex] * 30 * scaling) + property real mirroredBarWidth: 3 * scaling + + width: mirroredBarWidth + height: mirroredBarLength + color: Color.mPrimary + radius: mirroredBarWidth * 0.5 + + x: parent.width * 0.5 + Math.cos(mirroredAngle) * mirroredRadius - width * 0.5 + y: parent.height * 0.5 + Math.sin(mirroredAngle) * mirroredRadius - height * 0.5 + + transform: Rotation { + origin.x: mirroredBarWidth * 0.5 + origin.y: mirroredBarLength * 0.5 + angle: (mirroredAngle * 180 / Math.PI) + 90 + } + } + } + } + } + + Loader { + active: MediaService.isPlaying && Settings.data.audio.visualizerType == "wave" + anchors.centerIn: parent + width: 160 * scaling + height: 160 * scaling + + sourceComponent: Item { + Canvas { + id: waveCanvas + anchors.fill: parent + antialiasing: true + + onPaint: { + var ctx = getContext("2d") + ctx.reset() + + if (CavaService.values.length === 0) { + return + } + + ctx.strokeStyle = Color.mPrimary + ctx.lineWidth = 2 * scaling + ctx.lineCap = "round" + + var centerX = width * 0.5 + var centerY = height * 0.5 + var baseRadius = 60 * scaling + var maxAmplitude = 20 * scaling + + ctx.beginPath() + + for (var i = 0; i <= CavaService.values.length; i++) { + var index = i % CavaService.values.length + var angle = (i / CavaService.values.length) * 2 * Math.PI + var amplitude = CavaService.values[index] * maxAmplitude + var radius = baseRadius + amplitude + + var x = centerX + Math.cos(angle) * radius + var y = centerY + Math.sin(angle) * radius + + if (i === 0) { + ctx.moveTo(x, y) + } else { + ctx.lineTo(x, y) + } + } + + ctx.closePath() + ctx.stroke() + } + } + + Timer { + interval: 16 // ~60 FPS + running: true + repeat: true + onTriggered: { + waveCanvas.requestPaint() + } + } + } + } + + // Glow effect when no music is playing Rectangle { anchors.centerIn: parent width: parent.width + 24 * scaling @@ -314,6 +448,7 @@ Loader { border.color: Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.3) border.width: Math.max(1, Style.borderM * scaling) z: -1 + visible: !MediaService.isPlaying SequentialAnimation on scale { loops: Animation.Infinite diff --git a/Services/CavaService.qml b/Services/CavaService.qml index bdc09ba..fede625 100644 --- a/Services/CavaService.qml +++ b/Services/CavaService.qml @@ -38,7 +38,8 @@ Singleton { id: process stdinEnabled: true running: (Settings.data.audio.visualizerType !== "none") && (PanelService.sidePanel.active - || Settings.data.audio.showMiniplayerCava) + || Settings.data.audio.showMiniplayerCava + || (PanelService.lockScreen && PanelService.lockScreen.active)) command: ["cava", "-p", "/dev/stdin"] onExited: { stdinEnabled = true diff --git a/Services/PanelService.qml b/Services/PanelService.qml index 940e242..e2d82f7 100644 --- a/Services/PanelService.qml +++ b/Services/PanelService.qml @@ -8,6 +8,9 @@ Singleton { // A ref. to the sidePanel, so it's accessible from other services property var sidePanel: null + // A ref. to the lockScreen, so it's accessible from other services + property var lockScreen: null + // Currently opened panel property var openedPanel: null diff --git a/shell.qml b/shell.qml index c337f11..cb8dde2 100644 --- a/shell.qml +++ b/shell.qml @@ -78,6 +78,9 @@ ShellRoot { // Save a ref. to our sidePanel so we can access it from services PanelService.sidePanel = sidePanel + // Save a ref. to our lockScreen so we can access it from services + PanelService.lockScreen = lockScreen + // Ensure our singleton is created as soon as possible so we start fetching weather asap LocationService.init() }