// MediaCavaWidget.qml - cava visualiser + media info + volume in one pill import QtQuick import QtQuick.Layouts import Quickshell.Io import Quickshell.Services.Mpris import ".." Rectangle { id: root // ── Cava ────────────────────────────────────────────────── property var bars: Array(12).fill(0) property bool silence: bars.every(v => v === 0) property bool allBlank: bars.every(v => v < 28.5) property bool silentMode: false readonly property var blocks: [" ","▁","▂","▃","▄","▅","▆","▇","█"] // ── Audio (wpctl - avoids PipeWire binding issues) ──────── property real vol: 0.0 property bool muted: false property string volIcon: { if (muted || vol === 0) return " " if (vol < 0.34) return " " if (vol < 0.67) return " " return " " } // ── Media ───────────────────────────────────────────────── property MprisPlayer activePlayer: { var players = Mpris.players.values for (var i = 0; i < players.length; i++) if (players[i].identity.toLowerCase() === "spotify") return players[i] return players.length > 0 ? players[0] : null } property string trackText: { if (!activePlayer) return "" var p = activePlayer var info = "" if (p.trackArtists && p.trackTitle) info = Array.from(p.trackArtists).join(", ") + " - " + p.trackTitle else if (p.trackTitle) info = p.trackTitle if (info.length > 45) info = info.substring(0, 45) + "..." if (p.playbackState !== MprisPlaybackState.Playing && info) info = " " + info return info + " " } // ── Silence delay ───────────────────────────────────────── onAllBlankChanged: { if (allBlank) silenceTimer.start() else { silenceTimer.stop(); silentMode = false } } Timer { id: silenceTimer interval: Theme.cavaDissapearTime repeat: false onTriggered: root.silentMode = true } // ── Hover → media popup ─────────────────────────────────── property bool hovered: mouseArea.containsMouse onHoveredChanged: { if (hovered) GlobalStates.popups.open("media", mapToItem(null, width / 2, 0).x) } // ── Appearance ──────────────────────────────────────────── implicitHeight: Theme.barHeight implicitWidth: contentRow.implicitWidth + Theme.pillPadH * 2 radius: Theme.radius clip: true color: root.hovered ? Theme.pillHover : Theme.pill Behavior on color { ColorAnimation { duration: 150 } } // Volume fill - slightly more opaque than background, fills left→right by volume % Rectangle { anchors { left: parent.left; top: parent.top; bottom: parent.bottom } width: parent.width * Math.min(root.vol, 1.0) radius: Theme.radius color: root.hovered ? Theme.setColorAlpha(Theme.pill, 0.3) : Theme.setColorAlpha(Theme.pill, 0.9) Behavior on width { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } } // ── Content ─────────────────────────────────────────────── RowLayout { id: contentRow anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: Theme.pillPadH spacing: 4 // Cava bars -d slides right and collapses after silence delay Item { id: cavaContainer clip: true implicitHeight: Theme.barHeight implicitWidth: root.silentMode ? 0 : cavaRow.implicitWidth Behavior on implicitWidth { NumberAnimation { duration: 350; easing.type: Easing.InOutCubic } } FontMetrics { id: barMetrics font.family: Theme.fontMono font.pixelSize: Theme.fontSize + 1 } Row { id: cavaRow anchors.verticalCenter: parent.verticalCenter spacing: 1 x: root.silentMode ? 16 : 0 Behavior on x { NumberAnimation { duration: 350; easing.type: Easing.InOutCubic } } Repeater { model: root.bars.length Text { required property int index width: barMetrics.advanceWidth("█") horizontalAlignment: Text.AlignHCenter text: root.silence ? " " : root.blocks[Math.min(Math.floor(root.bars[index] / 28.5), 8)] font { family: Theme.fontMono; pixelSize: Theme.fontSize + 1 } color: Theme.pillText } } } } // Track info Text { visible: root.trackText !== "" text: root.trackText font { family: Theme.fontSans; pixelSize: Theme.fontSize } color: Theme.pillText elide: Text.ElideRight maximumLineCount: 1 } // Volume icon Text { text: root.volIcon font { family: Theme.fontMono; pixelSize: Theme.fontSize } color: "#fab387" } // Volume percentage Text { text: root.muted ? "muted" : Math.round(root.vol * 100) + "%" font { family: Theme.fontSans; pixelSize: Theme.fontSize } color: Theme.pillText } } MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: (m) => { if (m.button === Qt.RightButton) { Exec.run(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]) root.muted = !root.muted } } onWheel: (w) => { var step = w.angleDelta.y > 0 ? "4%+" : "4%-" Exec.run(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", step]) // Optimistic update - poll syncs it within 1s root.vol = Math.max(0, Math.min(1.5, root.vol + (w.angleDelta.y > 0 ? 0.04 : -0.04))) } } // ── Volume poll (wpctl) ─────────────────────────────────── Process { id: volProc running: false command: ["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"] stdout: SplitParser { onRead: (line) => { // "Volume: 0.40" or "Volume: 0.40 [MUTED]" var m = /Volume:\s+([\d.]+)(.*)/.exec(line) if (m) { root.vol = parseFloat(m[1]) root.muted = m[2].includes("MUTED") } } } onExited: volProc.running = false } Timer { interval: 1000 running: true repeat: true triggeredOnStart: true onTriggered: volProc.running = true } // ── Cava process ────────────────────────────────────────── Component.onCompleted: writeCfg.running = true Process { id: writeCfg running: false command: ["bash", "-c", "mkdir -p /tmp/qs-cava && cat > /tmp/qs-cava/cava.ini <<'CFG'\n" + "[general]\n" + "framerate = 30\n" + "bars = 12\n" + "[input]\n" + "method = pipewire\n" + "source = auto\n" + "[smoothing]\n" + "noise_reduction = 77\n" + "monstercat = 1\n" + "[output]\n" + "method = raw\n" + "raw_target = /dev/stdout\n" + "data_format = ascii\n" + "ascii_max_range = 255\n" + "bar_delimiter = 59\n" + "CFG\n" ] onExited: cavaProc.running = true } Process { id: cavaProc running: false command: ["cava", "-p", "/tmp/qs-cava/cava.ini"] stdout: SplitParser { onRead: (line) => { const parts = line.trim().replace(/;$/, "").split(";") if (parts.length >= root.bars.length) root.bars = parts.slice(0, root.bars.length).map(v => parseInt(v) || 0) } } onExited: cavaProc.running = true } }