add volume and brightness control

This commit is contained in:
2026-08-18 12:36:52 +02:00
parent 1d59b7808b
commit 7ff0f584e0
14 changed files with 984 additions and 2 deletions
+59
View File
@@ -0,0 +1,59 @@
pragma Singleton
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
// The default audio sink. Quickshell talks to pipewire directly, so there is no
// wpctl or pactl to shell out to and no polling: the properties below change as
// the graph does, whoever moved them.
//
// Everything is expressed against the default sink rather than a remembered
// one, so switching outputs — plugging in a headset, say — moves the panel with
// the sound.
Singleton {
id: root
readonly property PwNode sink: Pipewire.defaultAudioSink
// Pipewire objects only carry live properties while something is tracking
// them, so `audio` is null until the tracker below binds the node.
readonly property bool available: Config.showVolume && root.sink !== null && root.sink.ready && root.sink.audio !== null
// 0-1 for the usable range, though pipewire allows more and another client
// may well have set more. Reported as-is; the slider is what clamps.
readonly property real volume: root.available ? root.sink.audio.volume : 0
readonly property bool muted: root.available ? root.sink.audio.muted : false
function setVolume(value: real): void {
if (!root.available)
return;
root.sink.audio.volume = Math.max(0, Math.min(1, value));
}
// Raising the volume from silence unmutes: a volume key that appears to do
// nothing is worse than one that does slightly more than it said.
function step(delta: real): void {
if (!root.available)
return;
if (delta > 0 && root.muted)
root.setMuted(false);
root.setVolume(root.volume + delta);
}
function setMuted(muted: bool): void {
if (root.available)
root.sink.audio.muted = muted;
}
function toggleMute(): void {
root.setMuted(!root.muted);
}
PwObjectTracker {
objects: root.sink ? [root.sink] : []
}
}
+146
View File
@@ -0,0 +1,146 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
// The display backlight, driven through brightnessctl.
//
// Writing to /sys/class/backlight needs privileges a session usually does not
// have, so the write goes through brightnessctl, which asks logind when it
// cannot open the file itself. Reads do not: actual_brightness is world
// readable, so the current value comes straight off sysfs rather than from a
// process spawned every couple of seconds.
//
// sysfs attributes do not raise inotify events, so `refresh()` is polled by
// whoever is displaying the value — only the controls panel, and only while it
// is on screen. Nothing here runs with the panel closed.
Singleton {
id: root
// Device name as brightnessctl knows it, e.g. "intel_backlight", or "" on a
// machine with no backlight — a desktop, or a laptop whose panel is not
// controllable. Without one there is no brightness row.
property string device: ""
property int max: 0
property int current: 0
readonly property bool available: Config.showBrightness && root.device !== "" && root.max > 0
readonly property real value: root.max > 0 ? root.current / root.max : 0
// Never all the way off: a backlight at zero is indistinguishable from a
// broken panel, and on most laptops the only way back is the hardware key.
readonly property int minimum: Math.max(1, Math.round(root.max * Config.minBrightness))
// What was last asked for, and whether a request landed while the previous
// one was still in flight. Dragging the slider produces far more requests
// than brightnessctl can service, so they coalesce onto the newest.
property int pending: 0
property bool queued: false
function set(fraction: real): void {
if (!root.available)
return;
const raw = Math.max(root.minimum, Math.min(root.max, Math.round(fraction * root.max)));
if (raw === root.current && !write.running)
return;
// Optimistic, like the keyboard button: the slider has to track the
// finger, not the process. The next refresh overwrites this with what
// the hardware actually did.
root.current = raw;
root.pending = raw;
if (write.running)
root.queued = true;
else
root.dispatch();
}
function step(delta: real): void {
root.set(root.value + delta);
}
function dispatch(): void {
write.command = ["brightnessctl", "-m", "-q", "-c", "backlight", "-d", root.device, "set", String(root.pending)];
write.running = true;
}
function refresh(): void {
// A write in flight is the newer truth: sysfs still holds the value the
// drag has already moved past.
if (root.available && !write.running && !root.queued)
actual.reload();
}
// `brightnessctl -m` prints one CSV line per device:
// device,class,current,percent,max
function parseInfo(text: string): void {
const fields = text.trim().split("\n")[0].split(",");
if (fields.length < 5)
return;
const max = parseInt(fields[4]);
if (!(max > 0))
return;
root.device = fields[0];
root.max = max;
root.current = parseInt(fields[2]) || 0;
}
Process {
id: write
}
// Guarded by `command -v` so a machine without brightnessctl gets a quiet
// "no backlight" rather than a failed spawn on every start.
Process {
running: Config.showBrightness
command: ["sh", "-c", "command -v brightnessctl >/dev/null 2>&1 && brightnessctl -m -c backlight || true"]
stdout: StdioCollector {
onStreamFinished: root.parseInfo(this.text)
}
}
// actual_brightness, not brightness: what the hardware settled on, which is
// also what the hardware keys move.
FileView {
id: actual
path: root.available ? `/sys/class/backlight/${root.device}/actual_brightness` : ""
printErrors: false
onLoaded: {
if (write.running || root.queued)
return;
const value = parseInt(this.text());
if (value >= 0)
root.current = value;
}
}
Connections {
target: write
function onExited(exitCode: int, exitStatus: int): void {
if (root.queued) {
root.queued = false;
root.dispatch();
return;
}
// What the hardware settled on, which is the value the next step
// has to count from — and, when the write was refused (no logind,
// or a device that will not take the value), the number that goes
// back under the slider.
actual.reload();
}
}
}