Files
dotfiles/dotfiles/.config/quickshell/modules/bar/Volume.qml
2026-08-24 15:06:58 +01:00

447 lines
16 KiB
QML

import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.Pipewire
import "../../config"
Item {
id: root
implicitWidth: label.implicitWidth + 4
implicitHeight: Appearance.barHeight
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
// anchor to the bar itself rather than to this icon, keeping it a fixed
// distance from the bar and the screen edge regardless of where this
// icon sits in the row.
required property var barWindow
readonly property var sink: Pipewire.defaultAudioSink
readonly property var source: Pipewire.defaultAudioSource
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
readonly property real outputVolume: (root.sink && root.sink.audio) ? root.sink.audio.volume : 0
readonly property bool outputMuted: (root.sink && root.sink.audio) ? root.sink.audio.muted : false
readonly property real inputVolume: (root.source && root.source.audio) ? root.source.audio.volume : 0
readonly property bool inputMuted: (root.source && root.source.audio) ? root.source.audio.muted : false
readonly property var candidateSinks: root.nodes.filter(function(n) { return n && n.isSink && !n.isStream })
readonly property var candidateSources: root.nodes.filter(function(n) { return n && !n.isSink && !n.isStream && n.audio })
PwObjectTracker { objects: root.candidateSinks }
PwObjectTracker { objects: root.candidateSources }
// Live mic level for the input gauge below — separate from inputVolume
// (the configured gain), this tracks the actual incoming signal.
// Only runs while the panel is open, to skip metering when unused.
PwNodePeakMonitor {
id: inputPeak
node: root.source
enabled: panel.visible
}
// --- Device label/icon heuristics, ported from omarchy's audio panel
// Model.js (github.com/basecamp/omarchy shell/plugins/panels/audio/Model.js),
// adapted to this project's Material Symbols icon set instead of Nerd Font.
function friendlyDeviceLabel(text) {
var out = String(text || "").trim()
out = out.replace(/^sof-soundwire\s+/i, "")
out = out.replace(/^built-?in audio\s+/i, "")
out = out.replace(/\s+Output$/i, "")
out = out.replace(/\s+Input$/i, "")
out = out.replace(/Microphones/gi, "Microphone")
return out
}
function deviceLabel(node) {
if (!node) return "Unknown"
var nick = friendlyDeviceLabel(node.nickname || "")
if (nick) return nick
return friendlyDeviceLabel(node.description || node.name || "Unknown")
}
function deviceBlob(node) {
if (!node) return ""
var p = node.properties || {}
return [node.name, node.description, node.nickname,
p["device.icon-name"] || "", p["device.product.name"] || ""].join(" ").toLowerCase()
}
function isHeadphoneNode(node) {
var blob = deviceBlob(node)
return blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1
|| blob.indexOf("earbud") !== -1 || blob.indexOf("earphone") !== -1
|| blob.indexOf("airpod") !== -1
}
function sinkIcon(node) {
if (!node) return "speaker"
if (isHeadphoneNode(node)) return "headphones"
var blob = deviceBlob(node)
if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio"
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "tv"
return "speaker"
}
function sourceIcon(node) {
if (!node) return "mic"
var blob = deviceBlob(node)
if (blob.indexOf("headset") !== -1) return "headphones"
if (blob.indexOf("bluetooth") !== -1) return "bluetooth_audio"
if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "videocam"
return "mic"
}
Text {
id: label
anchors.centerIn: parent
font.family: Appearance.iconFontFamily
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 16
color: Colors.foreground
text: (root.outputMuted || root.outputVolume <= 0) ? "volume_off"
: (root.outputVolume < 0.5 ? "volume_down" : "volume_up")
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
panel.visible = !panel.visible
grab.active = panel.visible
}
onWheel: (wheel) => {
if (!root.sink || !root.sink.audio) return
const step = 0.05
const delta = wheel.angleDelta.y > 0 ? step : -step
root.sink.audio.volume = Math.max(0, Math.min(1, root.sink.audio.volume + delta))
}
}
// --- Reusable pieces -----------------------------------------------
component VolumeSlider: Item {
id: sliderRoot
property real value: 0
property bool enabled: true
signal moved(real newValue)
implicitHeight: 16
Rectangle {
id: track
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 4
radius: 0
color: Colors.muted
opacity: 0.35
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: track.width * Math.max(0, Math.min(1, sliderRoot.value))
height: 4
radius: 0
color: Colors.primary
opacity: sliderRoot.enabled ? 1 : 0.4
}
Rectangle {
width: 12
height: 12
radius: 6
color: Colors.primary
opacity: sliderRoot.enabled ? 1 : 0.4
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(track.width - width, track.width * sliderRoot.value - width / 2))
}
MouseArea {
anchors.fill: parent
enabled: sliderRoot.enabled
cursorShape: Qt.PointingHandCursor
function updateFromX(mx) {
sliderRoot.moved(Math.max(0, Math.min(1, mx / track.width)))
}
onPressed: (mouse) => updateFromX(mouse.x)
onPositionChanged: (mouse) => { if (pressed) updateFromX(mouse.x) }
}
}
// Read-only live level gauge — same track styling as VolumeSlider but no
// handle/interaction, filled from an actual signal level instead of a
// configured value.
component LevelMeter: Item {
id: meterRoot
property real level: 0
implicitHeight: 4
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 4
radius: 0
color: Colors.muted
opacity: 0.25
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
height: 4
radius: 0
color: Colors.paletteColor(2)
width: parent.width * Math.max(0, Math.min(1, meterRoot.level))
Behavior on width { NumberAnimation { duration: 80 } }
}
}
component ToggleSwitch: Item {
id: toggleRoot
property bool checked: false
signal toggled()
implicitWidth: 34
implicitHeight: 18
Rectangle {
anchors.fill: parent
radius: 0
color: toggleRoot.checked ? Colors.primary : Colors.muted
opacity: toggleRoot.checked ? 1 : 0.35
}
Rectangle {
width: parent.height - 4
height: parent.height - 4
radius: 0
color: Colors.background
anchors.verticalCenter: parent.verticalCenter
x: toggleRoot.checked ? parent.width - width - 2 : 2
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: toggleRoot.toggled()
}
}
component DeviceRow: Rectangle {
id: deviceRow
required property var node
required property string icon
required property bool active
signal activate()
width: parent ? parent.width : 0
height: 30
radius: 0
color: rowMouse.containsMouse ? Colors.primary
: (deviceRow.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
Text {
Layout.preferredWidth: 18
Layout.minimumWidth: 18
Layout.maximumWidth: 18
horizontalAlignment: Text.AlignHCenter
text: deviceRow.icon
font.family: Appearance.iconFontFamily
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
Text {
Layout.fillWidth: true
text: root.deviceLabel(deviceRow.node)
elide: Text.ElideRight
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
font.bold: deviceRow.active
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
}
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: deviceRow.activate()
}
}
// --- Panel -----------------------------------------------------------
PopupWindow {
id: panel
visible: false
color: "transparent"
// Padding between the panel's border and its content. implicitWidth
// is contentWidth + padding on both sides, so the content area
// itself stays a fixed size regardless of how much padding surrounds it.
readonly property int contentWidth: 244
readonly property int contentPadding: 16
implicitWidth: contentWidth + contentPadding * 2
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
anchor {
window: root.barWindow
// Unlike anchor.item, anchor.window doesn't default rect to the
// window's own bounds — set it explicitly so edges/gravity below
// resolve against the full bar (and thus the true screen edges)
// instead of a near-zero rect at the window's origin.
rect.x: 0
rect.y: 0
rect.width: root.barWindow.width
rect.height: root.barWindow.height
edges: Edges.Bottom | Edges.Right
gravity: Edges.Bottom | Edges.Left
margins.bottom: -4
margins.right: 0
}
HyprlandFocusGrab {
id: grab
windows: [panel]
onCleared: panel.visible = false
}
Rectangle {
anchors.fill: parent
radius: 0
color: Colors.surface
border.color: Colors.muted
border.width: 3
Column {
id: mainColumn
anchors.fill: parent
anchors.margins: panel.contentPadding
spacing: 10
// Header
RowLayout {
width: parent.width
spacing: 8
Text {
text: label.text
font.family: Appearance.iconFontFamily
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 40
color: Colors.foreground
}
Text {
Layout.fillWidth: true
text: "Audio"
font.family: Appearance.fontFamily
font.pixelSize: 18
font.bold: true
color: Colors.foreground
}
ToggleSwitch {
checked: !root.outputMuted
onToggled: if (root.sink && root.sink.audio) root.sink.audio.muted = !root.sink.audio.muted
}
}
// Output
Column {
width: parent.width
spacing: 6
RowLayout {
width: parent.width
Text {
Layout.fillWidth: true
text: "OUTPUT"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
Text {
text: Math.round(root.outputVolume * 100) + "%"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
}
VolumeSlider {
width: parent.width
value: root.outputVolume
enabled: !!root.sink
onMoved: (v) => { if (root.sink && root.sink.audio) root.sink.audio.volume = v }
}
Column {
width: parent.width
spacing: 2
Repeater {
model: root.candidateSinks
DeviceRow {
required property var modelData
node: modelData
icon: root.sinkIcon(modelData)
active: !!(root.sink && modelData && root.sink.id === modelData.id)
onActivate: Pipewire.preferredDefaultAudioSink = modelData
}
}
}
}
// Input
Column {
width: parent.width
spacing: 6
RowLayout {
width: parent.width
Text {
Layout.fillWidth: true
text: "INPUT"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
Text {
text: Math.round(root.inputVolume * 100) + "%"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
}
VolumeSlider {
width: parent.width
value: root.inputVolume
enabled: !!root.source
onMoved: (v) => { if (root.source && root.source.audio) root.source.audio.volume = v }
}
LevelMeter {
width: parent.width
level: inputPeak.peak
}
Column {
width: parent.width
spacing: 2
Repeater {
model: root.candidateSources
DeviceRow {
required property var modelData
node: modelData
icon: root.sourceIcon(modelData)
active: !!(root.source && modelData && root.source.id === modelData.id)
onActivate: Pipewire.preferredDefaultAudioSource = modelData
}
}
}
}
}
}
}
}