249 lines
9.0 KiB
QML
249 lines
9.0 KiB
QML
// 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
|
|
}
|
|
}
|