add volume and brightness control
This commit is contained in:
@@ -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] : []
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user