add quickshell bar

This commit is contained in:
Johannes Knopp
2026-04-06 01:30:28 +02:00
parent 8d95eeb892
commit c2b28df404
15 changed files with 1043 additions and 0 deletions

View File

@ -0,0 +1,222 @@
import Quickshell
import Quickshell.Services.Pipewire
import QtQuick
import QtQuick.Layouts
import "../components"
Item {
id: root
implicitWidth: chip.implicitWidth + 10
implicitHeight: 24
// Track all PipeWire nodes so their properties are populated.
// Without this, nodes in Pipewire.nodes are "unbound" and have null audio/type.
PwObjectTracker {
objects: Pipewire.nodes.values
}
// ── helpers ────────────────────────────────────────────────────────
function safeVolume(node) {
if (!node || !node.ready || !node.audio) return 0
const v = node.audio.volume
return (v !== undefined && !isNaN(v)) ? v : 0
}
function setVolume(node, v) {
if (!node || !node.ready || !node.audio) return
node.audio.volume = Math.max(0, Math.min(1, v))
}
function volIcon(vol, muted) {
if (muted) return "\uf6a9" // fa-volume-mute
if (vol > 0.6) return "\uf028" // fa-volume-up
if (vol > 0.2) return "\uf027" // fa-volume-down
return "\uf026" // fa-volume-off
}
// ── bar chip (default sink) ─────────────────────────────────────────
readonly property var defaultSink: Pipewire.defaultAudioSink
readonly property real defaultVolume: safeVolume(defaultSink)
readonly property bool defaultMuted: defaultSink?.audio?.muted ?? false
Row {
id: chip
anchors.centerIn: parent
spacing: 5
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.volIcon(root.defaultVolume, root.defaultMuted)
font.family: "JetBrainsMono Nerd Font Mono"
font.pixelSize: 14
color: root.defaultMuted ? Theme.textDim : Theme.text
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.defaultMuted ? "mute" : Math.round(root.defaultVolume * 100) + "%"
font.pixelSize: 11
color: Theme.textDim
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
if (root.defaultSink?.ready && root.defaultSink?.audio)
root.defaultSink.audio.muted = !root.defaultSink.audio.muted
} else {
mixerPopup.visible = !mixerPopup.visible
}
}
onWheel: wheel => {
if (!root.defaultSink?.ready) return
root.setVolume(root.defaultSink, root.defaultVolume + wheel.angleDelta.y / 120 * 0.05)
}
}
// ── mixer popup ─────────────────────────────────────────────────────
PopupWindow {
id: mixerPopup
visible: false
anchor.item: root
anchor.edges: Edges.Bottom
anchor.gravity: Edges.Bottom
implicitWidth: mixerBg.implicitWidth
implicitHeight: mixerBg.implicitHeight
Rectangle {
id: mixerBg
anchors.fill: parent
color: Theme.bg
border.color: Theme.border
border.width: Theme.borderWidth
radius: Theme.radius
implicitWidth: 300
implicitHeight: mixerCol.implicitHeight + 24
ColumnLayout {
id: mixerCol
anchors { fill: parent; margins: 12 }
spacing: 4
// ── Output devices ──────────────────────────────────────
Text {
text: "Output Devices"
color: Theme.textDim
font.pixelSize: 10
Layout.fillWidth: true
Layout.bottomMargin: 2
}
Repeater {
model: Pipewire.nodes
delegate: NodeRow {
required property var modelData
Layout.fillWidth: true
node: modelData
visible: modelData.isSink && !modelData.isStream && modelData.ready
}
}
// ── Application streams ────────────────────────────────
Text {
text: "Applications"
color: Theme.textDim
font.pixelSize: 10
Layout.fillWidth: true
Layout.topMargin: 6
Layout.bottomMargin: 2
}
Repeater {
model: Pipewire.nodes
delegate: NodeRow {
required property var modelData
Layout.fillWidth: true
node: modelData
// exclude monitor sources; include only audio output streams
visible: modelData.isStream && !modelData.isSink && modelData.ready
&& !(modelData.description ?? "").toLowerCase().includes("monitor")
}
}
}
}
}
// ── per-node row component ──────────────────────────────────────────
component NodeRow: RowLayout {
id: row
required property var node
spacing: 8
implicitHeight: 28
// mute toggle
Text {
id: nodeIcon
text: root.volIcon(root.safeVolume(row.node), row.node.audio?.muted ?? false)
font.family: "JetBrainsMono Nerd Font Mono"
font.pixelSize: 13
color: (row.node.audio?.muted ?? false) ? Theme.textDim : Theme.text
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (row.node.ready && row.node.audio)
row.node.audio.muted = !row.node.audio.muted
}
}
}
// name
Text {
text: row.node.description || row.node.nickname || row.node.name || "?"
font.pixelSize: 11
color: Theme.textDim
Layout.preferredWidth: 90
elide: Text.ElideRight
}
// horizontal slider
Item {
Layout.fillWidth: true
implicitHeight: 16
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 3
radius: 2
color: Theme.progressTrack
Rectangle {
width: parent.width * Math.min(1, root.safeVolume(row.node))
height: parent.height
radius: parent.radius
color: Theme.accent
}
}
MouseArea {
anchors { fill: parent; topMargin: -4; bottomMargin: -4 }
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
function seek(mx) {
root.setVolume(row.node, mx / width)
}
onClicked: seek(mouseX)
onPositionChanged: if (pressed) seek(mouseX)
}
}
// percentage
Text {
text: Math.round(root.safeVolume(row.node) * 100) + "%"
font.pixelSize: 10
color: Theme.textDim
Layout.preferredWidth: 30
horizontalAlignment: Text.AlignRight
}
}
}