quickshell: add volume widget with per-app sliders

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rope 2026-05-27 09:18:28 +01:00
parent 70f1547557
commit dc797ba09b

View file

@ -601,6 +601,7 @@ in
import Quickshell.Hyprland import Quickshell.Hyprland
import Quickshell.Services.SystemTray import Quickshell.Services.SystemTray
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import Quickshell.Services.Pipewire
import Quickshell.Widgets import Quickshell.Widgets
import Quickshell.Io import Quickshell.Io
import QtQuick import QtQuick
@ -727,6 +728,58 @@ in
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: 10 spacing: 10
// Volume
Item {
id: volWidget
width: volText.width
height: 30
property var sink: Pipewire.defaultAudioSink
property int vol: sink && sink.audio ? Math.round(sink.audio.volume * 100) : 0
property bool muted: sink && sink.audio ? sink.audio.muted : false
property string volIcon: muted ? "\u{f0581}"
: vol > 66 ? "\u{f057e}"
: vol > 33 ? "\u{f0580}"
: vol > 0 ? "\u{f057f}"
: "\u{f0581}"
function openVolDropdown() {
bar.toggleDropdown(volDropdown, function() {
let pos = volWidget.mapToItem(bar.contentItem, volWidget.width / 2, 0);
volDropdown.dropdownX = pos.x;
});
}
Text {
id: volText
anchors.verticalCenter: parent.verticalCenter
text: volWidget.volIcon + " " + volWidget.vol + "%"
color: volWidget.muted ? Theme.base03 : Theme.base05
font.family: "FiraMono Nerd Font"
font.pixelSize: 13
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onClicked: (event) => {
if (event.button === Qt.MiddleButton) {
if (volWidget.sink && volWidget.sink.audio)
volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
} else {
volWidget.openVolDropdown();
}
}
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown();
else bar.activeDropdown.resetAutoClose();
}
}
}
}
// Network status // Network status
Item { Item {
id: netWidget id: netWidget
@ -1308,6 +1361,200 @@ in
} }
} }
// Volume dropdown
BarDropdown {
id: volDropdown
fullWidth: volDropdownCol.width + 24
fullHeight: volDropdownCol.height + 16
autoCloseMs: 3000
Column {
id: volDropdownCol
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 8
width: 260
spacing: 8
// Master volume
Text {
text: "\u{f057e} Master"
color: Theme.base05
font.family: "FiraMono Nerd Font"
font.pixelSize: 13
font.weight: Font.Medium
}
Row {
width: parent.width
spacing: 8
Rectangle {
id: masterSliderBg
width: parent.width - masterVolLabel.width - 8
height: 20
radius: 4
color: Theme.base01
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: volWidget.sink && volWidget.sink.audio
? Math.min(1, volWidget.sink.audio.volume) * parent.width : 0
height: parent.height
radius: 4
color: volWidget.muted ? Theme.base03 : Theme.base0D
Behavior on width { NumberAnimation { duration: 80 } }
}
MouseArea {
anchors.fill: parent
onPressed: (mouse) => setVolume(mouse)
onPositionChanged: (mouse) => { if (pressed) setVolume(mouse); }
function setVolume(mouse) {
if (!volWidget.sink || !volWidget.sink.audio) return;
let v = Math.max(0, Math.min(1, mouse.x / width));
volWidget.sink.audio.volume = v;
}
}
}
Text {
id: masterVolLabel
width: 36
text: volWidget.vol + "%"
color: Theme.base05
font.family: "FiraMono Nerd Font"
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
// Mute button
Rectangle {
width: parent.width
height: 28
color: masterMuteMa.containsMouse ? Theme.base02 : "transparent"
radius: 4
Text {
anchors.centerIn: parent
text: volWidget.muted ? "\u{f0581} Unmute" : "\u{f057e} Mute"
color: Theme.base05
font.family: "FiraMono Nerd Font"
font.pixelSize: 12
}
MouseArea {
id: masterMuteMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (volWidget.sink && volWidget.sink.audio)
volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
}
}
}
// Separator
Rectangle {
width: parent.width - 20
anchors.horizontalCenter: parent.horizontalCenter
height: 1
color: Theme.base02
visible: appStreamsRepeater.count > 0
}
// App streams header
Text {
visible: appStreamsRepeater.count > 0
text: "\u{f0641} Applications"
color: Theme.base05
font.family: "FiraMono Nerd Font"
font.pixelSize: 13
font.weight: Font.Medium
}
// Per-app streams
Column {
width: parent.width
spacing: 6
Repeater {
id: appStreamsRepeater
model: {
let streams = [];
for (let i = 0; i < Pipewire.nodes.values.length; i++) {
let node = Pipewire.nodes.values[i];
if (node.isStream && node.audio) streams.push(node);
}
return streams;
}
Column {
required property var modelData
width: parent.width
spacing: 2
Text {
text: modelData.name || "Unknown"
color: Theme.base04
font.family: "FiraMono Nerd Font"
font.pixelSize: 11
elide: Text.ElideRight
width: parent.width
}
Row {
width: parent.width
spacing: 8
Rectangle {
width: parent.width - appVolLabel.width - 8
height: 16
radius: 3
color: Theme.base01
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: modelData.audio
? Math.min(1, modelData.audio.volume) * parent.width : 0
height: parent.height
radius: 3
color: modelData.audio && modelData.audio.muted
? Theme.base03 : Theme.base0C
Behavior on width { NumberAnimation { duration: 80 } }
}
MouseArea {
anchors.fill: parent
onPressed: (mouse) => setVol(mouse)
onPositionChanged: (mouse) => { if (pressed) setVol(mouse); }
function setVol(mouse) {
if (!modelData.audio) return;
let v = Math.max(0, Math.min(1, mouse.x / width));
modelData.audio.volume = v;
}
}
}
Text {
id: appVolLabel
width: 36
text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%"
color: Theme.base04
font.family: "FiraMono Nerd Font"
font.pixelSize: 10
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
}
}
// Network dropdown // Network dropdown
BarDropdown { BarDropdown {
id: netDropdown id: netDropdown