From 31ae919a7aecc49c225c7bb28a7d1b7c1a57037b Mon Sep 17 00:00:00 2001 From: quadbyte Date: Wed, 13 Aug 2025 22:19:44 -0400 Subject: [PATCH] Decent cava linear visualizer --- Modules/Audio/CircularSpectrum.qml | 52 ++++ Modules/Audio/LinearSpectrum.qml | 61 ++++ Modules/Settings/Tabs/Audio.qml | 14 +- Modules/SidePanel/Cards/MediaCard.qml | 408 ++------------------------ Services/Cava.qml | 54 ++-- Services/GitHub.qml | 2 +- Services/Settings.qml | 6 +- 7 files changed, 178 insertions(+), 419 deletions(-) create mode 100644 Modules/Audio/CircularSpectrum.qml create mode 100644 Modules/Audio/LinearSpectrum.qml diff --git a/Modules/Audio/CircularSpectrum.qml b/Modules/Audio/CircularSpectrum.qml new file mode 100644 index 0000000..e4e0850 --- /dev/null +++ b/Modules/Audio/CircularSpectrum.qml @@ -0,0 +1,52 @@ +import QtQuick +import qs.Services + +Item { + id: root + property int innerRadius: 32 * scaling + property int outerRadius: 64 * scaling + property color fillColor: Colors.accentPrimary + property color strokeColor: Colors.textPrimary + property int strokeWidth: 0 * scaling + property var values: [] + property int usableOuter: 64 + + width: usableOuter * 2 + height: usableOuter * 2 + + Repeater { + model: root.values.length + Rectangle { + property real value: root.values[index] + property real angle: (index / root.values.length) * 360 + width: Math.max(2 * scaling, (root.innerRadius * 2 * Math.PI) / root.values.length - 4 * scaling) + height: value * (usableOuter - root.innerRadius) + radius: width / 2 + color: root.fillColor + border.color: root.strokeColor + border.width: root.strokeWidth + antialiasing: true + + x: root.width / 2 - width / 2 * Math.cos(Math.PI / 2 + 2 * Math.PI * index / root.values.length) - width / 2 + y: root.height / 2 - height + + transform: [ + Rotation { + origin.x: width / 2 + origin.y: height + //angle: (index / root.values.length) * 360 + }, + Translate { + x: root.innerRadius * Math.cos(2 * Math.PI * index / root.values.length) + y: root.innerRadius * Math.sin(2 * Math.PI * index / root.values.length) + } + ] + + Behavior on height { + SmoothedAnimation { + duration: 120 + } + } + } + } +} diff --git a/Modules/Audio/LinearSpectrum.qml b/Modules/Audio/LinearSpectrum.qml new file mode 100644 index 0000000..ae3e565 --- /dev/null +++ b/Modules/Audio/LinearSpectrum.qml @@ -0,0 +1,61 @@ +import QtQuick +import qs.Services + +Item { + id: root + property color fillColor: Colors.accentPrimary + property color strokeColor: Colors.textPrimary + property int strokeWidth: 0 + property var values: [] + + property real xScale: width / (values.length * 2) + + Repeater { + model: values.length + Rectangle { + property real amp: values[values.length - 1 - index] + + color: fillColor + border.color: strokeColor + border.width: strokeWidth + antialiasing: true + + x: index * xScale + y: root.height - height + + width: xScale * 0.5 + height: root.height * amp + + Behavior on height { + SmoothedAnimation { + duration: 5 + } + } + } + } + + Repeater { + model: values.length + Rectangle { + property real amp: values[index] + + color: fillColor + border.color: strokeColor + border.width: strokeWidth + antialiasing: true + + x: (values.length + index) * xScale + y: root.height - height + + width: xScale * 0.5 + height: root.height * amp + + Behavior on height { + SmoothedAnimation { + duration: 5 + } + } + } + } + +} diff --git a/Modules/Settings/Tabs/Audio.qml b/Modules/Settings/Tabs/Audio.qml index 5554aa5..dc6a052 100644 --- a/Modules/Settings/Tabs/Audio.qml +++ b/Modules/Settings/Tabs/Audio.qml @@ -256,17 +256,11 @@ ColumnLayout { id: audioVisualizerCombo label: "Visualization Type" description: "Choose a visualization type for media playback" - optionsKeys: ["radial", "bars", "wave"] - optionsLabels: ["Radial", "Bars", "Wave"] - currentKey: Settings.data.audio ? Settings.data.audio.audioVisualizer.type : "radial" + optionsKeys: ["none", "linear"] + optionsLabels: ["None", "Linear"] + currentKey: Settings.data.audio.visualizerType onSelected: function (key) { - if (!Settings.data.audio) { - Settings.data.audio = {} - } - if (!Settings.data.audio.audioVisualizer) { - Settings.data.audio.audioVisualizer = {} - } - Settings.data.audio.audioVisualizer.type = key + Settings.data.audio.visualizerType = key } } } diff --git a/Modules/SidePanel/Cards/MediaCard.qml b/Modules/SidePanel/Cards/MediaCard.qml index 63b5780..c3b4266 100644 --- a/Modules/SidePanel/Cards/MediaCard.qml +++ b/Modules/SidePanel/Cards/MediaCard.qml @@ -1,6 +1,8 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Quickshell +import qs.Modules.Audio import qs.Services import qs.Widgets @@ -21,6 +23,7 @@ NBox { anchors.fill: parent anchors.margins: Style.marginLarge * scaling + // Fallback ColumnLayout { id: fallback visible: !main.visible @@ -46,6 +49,7 @@ NBox { } } + // MediaPlayer Main Content ColumnLayout { id: main @@ -312,383 +316,29 @@ NBox { } } } + + Loader { + active: Settings.data.audio.visualizerType == "linear" + Layout.alignment: Qt.AlignHCenter + + sourceComponent: + LinearSpectrum { + width: 300 * scaling + height: 80 * scaling + values: Cava.values + fillColor: Colors.textPrimary + Layout.alignment: Qt.AlignHCenter + } + } + + // CircularSpectrum { + // visible: Settings.data.audio.visualizerType == "radial" + // values: Cava.values + // innerRadius: 30 * scaling // Position just outside 60x60 album art + // outerRadius: 48 * scaling // Extend bars outward from album art + // fillColor: Colors.accentPrimary + // strokeColor: Colors.accentPrimary + // strokeWidth: 0 * scaling + // } } -} // import QtQuick// import QtQuick.Controls// import QtQuick.Layouts// import QtQuick.Effects// import qs.Settings// import qs.Components// import qs.Services -// Rectangle { -// id: musicCard -// color: "transparent" - -// Rectangle { -// id: card -// anchors.fill: parent -// color: Theme.surface -// radius: 18 * scaling - -// // Show fallback UI if no player is available -// Item { -// width: parent.width -// height: parent.height -// visible: !MediaPlayer.currentPlayer - -// ColumnLayout { -// anchors.centerIn: parent -// spacing: 16 * scaling - -// Text { -// text: "music_note" -// font.family: "Material Symbols Outlined" -// font.pixelSize: Theme.fontSizeHeader * scaling -// color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.3) -// Layout.alignment: Qt.AlignHCenter -// } - -// Text { -// text: MediaPlayer.hasPlayer ? "No controllable player selected" : "No music player detected" -// color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.6) -// font.family: Theme.fontFamily -// font.pixelSize: Theme.fontSizeSmall * scaling -// Layout.alignment: Qt.AlignHCenter -// } -// } -// } - -// // Main player UI -// ColumnLayout { -// anchors.fill: parent -// anchors.margins: 18 * scaling -// spacing: 12 * scaling -// visible: !!MediaPlayer.currentPlayer - -// // Player selector -// ComboBox { -// id: playerSelector -// Layout.fillWidth: true -// Layout.preferredHeight: 40 * scaling -// visible: MediaPlayer.getAvailablePlayers().length > 1 -// model: MediaPlayer.getAvailablePlayers() -// textRole: "identity" -// currentIndex: MediaPlayer.selectedPlayerIndex - -// background: Rectangle { -// implicitWidth: 120 * scaling -// implicitHeight: 40 * scaling -// color: Theme.surfaceVariant -// border.color: playerSelector.activeFocus ? Theme.accentPrimary : Theme.outline -// border.width: 1 * scaling -// radius: 16 * scaling -// } - -// contentItem: Text { -// leftPadding: 12 * scaling -// rightPadding: playerSelector.indicator.width + playerSelector.spacing -// text: playerSelector.displayText -// font.pixelSize: 13 * scaling -// color: Theme.textPrimary -// verticalAlignment: Text.AlignVCenter -// elide: Text.ElideRight -// } - -// indicator: Text { -// x: playerSelector.width - width - 12 * scaling -// y: playerSelector.topPadding + (playerSelector.availableHeight - height) / 2 -// text: "arrow_drop_down" -// font.family: "Material Symbols Outlined" -// font.pixelSize: 24 * scaling -// color: Theme.textPrimary -// } - -// popup: Popup { -// y: playerSelector.height -// width: playerSelector.width -// implicitHeight: contentItem.implicitHeight -// padding: 1 * scaling - -// contentItem: ListView { -// clip: true -// implicitHeight: contentHeight -// model: playerSelector.popup.visible ? playerSelector.delegateModel : null -// currentIndex: playerSelector.highlightedIndex - -// ScrollIndicator.vertical: ScrollIndicator {} -// } - -// background: Rectangle { -// color: Theme.surfaceVariant -// border.color: Theme.outline -// border.width: 1 * scaling -// radius: 16 -// } -// } - -// delegate: ItemDelegate { -// width: playerSelector.width -// contentItem: Text { -// text: modelData.identity -// font.pixelSize: 13 * scaling -// color: Theme.textPrimary -// verticalAlignment: Text.AlignVCenter -// elide: Text.ElideRight -// } -// highlighted: playerSelector.highlightedIndex === index - -// background: Rectangle { -// color: highlighted ? Theme.accentPrimary.toString().replace(/#/, "#1A") : "transparent" -// } -// } - -// onActivated: { -// MediaPlayer.selectedPlayerIndex = index; -// MediaPlayer.updateCurrentPlayer(); -// } -// } - -// // Album art with spectrum visualizer -// RowLayout { -// spacing: 12 * scaling -// Layout.fillWidth: true - -// // Album art container with circular spectrum overlay -// Item { -// id: albumArtContainer -// width: 96 * scaling -// height: 96 * scaling // enough for spectrum and art (will adjust if needed) -// Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter - -// // Circular spectrum visualizer around album art -// CircularSpectrum { -// id: spectrum -// values: MediaPlayer.cavaValues -// anchors.centerIn: parent -// innerRadius: 30 * scaling // Position just outside 60x60 album art -// outerRadius: 48 * scaling // Extend bars outward from album art -// fillColor: Theme.accentPrimary -// strokeColor: Theme.accentPrimary -// strokeWidth: 0 * scaling -// z: 0 -// } - -// // Album art image -// Rectangle { -// id: albumArtwork -// width: 60 * scaling -// height: 60 * scaling -// anchors.centerIn: parent -// radius: width * 0.5 -// color: Qt.darker(Theme.surface, 1.1) -// border.color: Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.3) -// border.width: 1 * scaling - -// Image { -// id: albumArt -// anchors.fill: parent -// anchors.margins: 2 * scaling -// fillMode: Image.PreserveAspectCrop -// smooth: true -// mipmap: true -// cache: false -// asynchronous: true -// sourceSize.width: 60 * scaling -// sourceSize.height: 60 * scaling -// source: MediaPlayer.trackArtUrl -// visible: source.toString() !== "" - -// // Apply circular mask for rounded corners -// layer.enabled: true -// layer.effect: MultiEffect { -// maskEnabled: true -// maskSource: mask -// } -// } - -// Item { -// id: mask - -// anchors.fill: albumArt -// layer.enabled: true -// visible: false - -// Rectangle { -// width: albumArt.width -// height: albumArt.height -// radius: albumArt.width / 2 // circle -// } -// } - -// // Fallback icon when no album art available -// Text { -// anchors.centerIn: parent -// text: "album" -// font.family: "Material Symbols Outlined" -// font.pixelSize: Theme.fontSizeBody * scaling -// color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.4) -// visible: !albumArt.visible -// } -// } -// } - -// // Progress bar -// Rectangle { -// id: progressBarBackground -// width: parent.width -// height: 6 * scaling -// radius: 3 -// color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.15) -// Layout.fillWidth: true - -// property real progressRatio: { -// if (!MediaPlayer.currentPlayer || !MediaPlayer.isPlaying || MediaPlayer.trackLength <= 0) { -// return 0; -// } -// return Math.min(1, MediaPlayer.currentPosition / MediaPlayer.trackLength); -// } - -// Rectangle { -// id: progressFill -// width: progressBarBackground.progressRatio * parent.width -// height: parent.height -// radius: parent.radius -// color: Theme.accentPrimary - -// Behavior on width { -// NumberAnimation { -// duration: 200 -// } -// } -// } - -// // Interactive progress handle -// Rectangle { -// id: progressHandle -// width: 12 * scaling -// height: 12 * scaling -// radius: width * 0.5 -// color: Theme.accentPrimary -// border.color: Qt.lighter(Theme.accentPrimary, 1.3) -// border.width: 1 * scaling - -// x: Math.max(0, Math.min(parent.width - width, progressFill.width - width / 2)) -// anchors.verticalCenter: parent.verticalCenter - -// visible: MediaPlayer.trackLength > 0 -// scale: progressMouseArea.containsMouse || progressMouseArea.pressed ? 1.2 : 1.0 - -// Behavior on scale { -// NumberAnimation { -// duration: 150 -// } -// } -// } - -// // Mouse area for seeking -// MouseArea { -// id: progressMouseArea -// anchors.fill: parent -// hoverEnabled: true -// cursorShape: Qt.PointingHandCursor -// enabled: MediaPlayer.trackLength > 0 && MediaPlayer.canSeek - -// onClicked: function (mouse) { -// let ratio = mouse.x / width; -// MediaPlayer.seekByRatio(ratio); -// } - -// onPositionChanged: function (mouse) { -// if (pressed) { -// let ratio = Math.max(0, Math.min(1, mouse.x / width)); -// MediaPlayer.seekByRatio(ratio); -// } -// } -// } -// } - -// // Media controls -// RowLayout { -// spacing: 4 * scaling -// Layout.fillWidth: true -// Layout.alignment: Qt.AlignHCenter - -// // Previous button -// Rectangle { -// width: 28 * scaling -// height: 28 * scaling -// radius: width * 0.5 -// color: previousButton.containsMouse ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.2) : Qt.darker(Theme.surface, 1.1) -// border.color: Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.3) -// border.width: 1 * scaling - -// MouseArea { -// id: previousButton -// anchors.fill: parent -// hoverEnabled: true -// cursorShape: Qt.PointingHandCursor -// enabled: MediaPlayer.canGoPrevious -// onClicked: MediaPlayer.previous() -// } - -// Text { -// anchors.centerIn: parent -// text: "skip_previous" -// font.family: "Material Symbols Outlined" -// font.pixelSize: Theme.fontSizeCaption * scaling -// color: previousButton.enabled ? Theme.accentPrimary : Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.3) -// } -// } - -// // Play/Pause button -// Rectangle { -// width: 36 * scaling -// height: 36 * scaling -// radius: width * 0.5 -// color: playButton.containsMouse ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.2) : Qt.darker(Theme.surface, 1.1) -// border.color: Theme.accentPrimary -// border.width: 2 * scaling - -// MouseArea { -// id: playButton -// anchors.fill: parent -// hoverEnabled: true -// cursorShape: Qt.PointingHandCursor -// enabled: MediaPlayer.canPlay || MediaPlayer.canPause -// onClicked: MediaPlayer.playPause() -// } - -// Text { -// anchors.centerIn: parent -// text: MediaPlayer.isPlaying ? "pause" : "play_arrow" -// font.family: "Material Symbols Outlined" -// font.pixelSize: Theme.fontSizeBody * scaling -// color: playButton.enabled ? Theme.accentPrimary : Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.3) -// } -// } - -// // Next button -// Rectangle { -// width: 28 * scaling -// height: 28 * scaling -// radius: width * 0.5 -// color: nextButton.containsMouse ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.2) : Qt.darker(Theme.surface, 1.1) -// border.color: Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.3) -// border.width: 1 * scaling - -// MouseArea { -// id: nextButton -// anchors.fill: parent -// hoverEnabled: true -// cursorShape: Qt.PointingHandCursor -// enabled: MediaPlayer.canGoNext -// onClicked: MediaPlayer.next() -// } - -// Text { -// anchors.centerIn: parent -// text: "skip_next" -// font.family: "Material Symbols Outlined" -// font.pixelSize: Theme.fontSizeCaption * scaling -// color: nextButton.enabled ? Theme.accentPrimary : Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.3) -// } -// } -// } -// } -// } -// } - +} diff --git a/Services/Cava.qml b/Services/Cava.qml index 78f7050..2f3c069 100644 --- a/Services/Cava.qml +++ b/Services/Cava.qml @@ -7,43 +7,46 @@ import Quickshell.Io Singleton { id: root - property var values: Array(count).fill(0) - property int count: 44 - property int noiseReduction: 60 - property string channels: "mono" - property string monoOption: "average" + property var values: Array(barsCount).fill(0) + property int barsCount: 20 property var config: ({ "general": { - "bars": count, - "framerate": 30, - "autosens": 1 + "bars": barsCount, + "mode": "normal", + "framerate": 60, + "autosens": 0, + "overshoot": 0, + "sensitivity": 200, + "lower_cutoff_freq": 50, + "higher_cutoff_freq": 12000 }, "smoothing": { "monstercat": 1, - "gravity": 1000000, - "noise_reduction": noiseReduction + "gravity": 100, + "noise_reduction": 77 }, "output": { "method": "raw", "bit_format": 8, - "channels": channels, - "mono_option": monoOption + "channels": "mono", + "mono_option": "average" } }) Process { id: process - property int index: 0 + property int fillIndex: 0 stdinEnabled: true running: MediaPlayer.isPlaying command: ["cava", "-p", "/dev/stdin"] onExited: { stdinEnabled = true - index = 0 - values = Array(count).fill(0) + fillIndex = 0 + values = Array(barsCount).fill(0) } onStarted: { + for (const k in config) { if (typeof config[k] !== "object") { write(k + "=" + config[k] + "\n") @@ -56,20 +59,23 @@ Singleton { } } stdinEnabled = false + fillIndex = 0 + values = Array(barsCount).fill(0) } stdout: SplitParser { splitMarker: "" onRead: data => { - const newValues = Array(count).fill(0) - for (var i = 0; i < values.length; i++) { - newValues[i] = values[i] + if (process.fillIndex + data.length >= barsCount) { + process.fillIndex = 0 } - if (process.index + data.length > count) { - process.index = 0 - } - for (var i = 0; i < data.length; i += 1) { - newValues[process.index] = Math.min(data.charCodeAt(i), 128) / 128 - process.index = (process.index + 1) % count + + // copy array + var newValues = values.slice(0) + + for (var i = 0; i < data.length; i++) { + var amp = Math.min(data.charCodeAt(i), 128) / 128 + newValues[process.fillIndex] = amp * amp + process.fillIndex = (process.fillIndex + 1) % barsCount } values = newValues } diff --git a/Services/GitHub.qml b/Services/GitHub.qml index 620a616..1bfac4c 100644 --- a/Services/GitHub.qml +++ b/Services/GitHub.qml @@ -51,7 +51,7 @@ Singleton { function loadFromCache() { const now = Time.timestamp if (!data.timestamp || (now >= data.timestamp + githubUpdateFrequency)) { - console.log("[GitHub] Cache expired or missing, fetching new data from GitHub...") + console.log("[GitHub] Cache expired or missing, fetching new data") fetchFromGitHub() return } diff --git a/Services/Settings.qml b/Services/Settings.qml index 3b0cbbb..9f2ca39 100644 --- a/Services/Settings.qml +++ b/Services/Settings.qml @@ -173,11 +173,7 @@ Singleton { property JsonObject audio audio: JsonObject { - property JsonObject audioVisualizer - - audioVisualizer: JsonObject { - property string type: "radial" - } + property string visualizerType: "linear" } // ui