nexoniarz-config-nixos/dotfiles/quickshell/Osd.qml
Nexoniarz 1f5f9795bc Add on-screen display, sidebar keybinds, function keys, gh package
- Add Osd.qml: themed top-center popup for volume/mic/brightness/media
  changes. Volume and mic react directly to Pipewire so they fire for
  any change, not just our own keybinds; brightness is notified
  explicitly (no live service exists for DDC/CI writes); media is
  driven by two playerctl --follow watchers (status + metadata) so
  track changes, pause/resume, and stop all show correctly without
  double-firing
- Add Super+Shift+Z/X to toggle the Left/RightBar sidebars, via a new
  Quickshell IpcHandler wired to the same expand/collapse logic as the
  BottomBar arrows
- Add function-key binds for volume up/down/mute, mic mute, brightness
  up/down, and media play/pause/stop/next/prev (wpctl/playerctl);
  extend brightness-ctl with relative up/down steps for both the
  backlight and DDC/CI paths
- Add gh and playerctl to packages
2026-08-30 16:08:44 +02:00

199 lines
6.9 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Services.Pipewire
// On-screen display: a themed top-center popup (same placement/layer-shell
// pattern as PolkitDialog and mako notifications) for volume, mic mute,
// brightness, and media changes — the transient overlay every other DE
// shows instead of making you open a panel to check.
//
// Volume/mic react directly to Pipewire, so they fire for ANY change
// (function keys, the RightBar sliders, pavucontrol, etc.), not just ones
// that went through our own keybinds. Brightness has no such live service
// to bind to (DDC/CI writes aren't broadcast anywhere), so its two
// hyprland.conf binds explicitly call `quickshell ipc call osd
// brightness <pct>` after brightness-ctl. Media is driven by two
// long-running `playerctl --follow` processes (status, and metadata for
// track-change detection).
Item {
id: root
property string osdText: ""
property bool showBar: false
property real barValue: 0
property bool barDanger: false
function present(text, bar, value, danger) {
root.osdText = text;
root.showBar = bar;
root.barValue = value || 0;
root.barDanger = !!danger;
osdWindow.visible = true;
hideTimer.restart();
}
IpcHandler {
target: "osd"
function brightness(pct: string): void {
var v = parseInt(pct);
if (isNaN(v)) return;
root.present("Brightness " + v + "%", true, v / 100, false);
}
}
Timer {
id: hideTimer
interval: 1600
onTriggered: osdWindow.visible = false
}
// -- Volume / mic --
PwObjectTracker {
objects: {
var arr = [];
if (Pipewire.defaultAudioSink) arr.push(Pipewire.defaultAudioSink);
if (Pipewire.defaultAudioSource) arr.push(Pipewire.defaultAudioSource);
return arr;
}
}
// Pipewire reports the already-current volume/mute state once as soon
// as it connects — without these guards that fires an OSD on every
// Quickshell start/login, not just on real changes.
property bool sinkReady: false
property bool sourceReady: false
Connections {
target: Pipewire.defaultAudioSink ? Pipewire.defaultAudioSink.audio : null
function onVolumesChanged() {
if (!root.sinkReady) { root.sinkReady = true; return; }
var sink = Pipewire.defaultAudioSink;
if (sink.audio.muted) return; // covered by onMutedChanged instead
root.present("Volume " + Math.round(sink.audio.volume * 100) + "%", true, sink.audio.volume, false);
}
function onMutedChanged() {
if (!root.sinkReady) { root.sinkReady = true; return; }
var sink = Pipewire.defaultAudioSink;
root.present(sink.audio.muted ? "Volume Muted" : "Volume " + Math.round(sink.audio.volume * 100) + "%",
!sink.audio.muted, sink.audio.volume, sink.audio.muted);
}
}
Connections {
target: Pipewire.defaultAudioSource ? Pipewire.defaultAudioSource.audio : null
function onMutedChanged() {
if (!root.sourceReady) { root.sourceReady = true; return; }
var source = Pipewire.defaultAudioSource;
root.present(source.audio.muted ? "Microphone Muted" : "Microphone Unmuted",
false, 0, source.audio.muted);
}
}
// -- Media (MPRIS via playerctl) --
property string lastTitle: ""
property double lastMetaAt: 0
Process {
id: statusFollow
command: ["playerctl", "--follow", "status"]
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
var status = data.trim();
if (status === "Paused") {
root.present("⏸ Paused", false, 0, false);
} else if (status === "Stopped") {
root.present("⏹ Stopped", false, 0, false);
root.lastTitle = "";
} else if (status === "Playing") {
// A track change fires both a metadata line and a
// Playing status line in short order — the metadata
// handler below already popped the OSD for that case,
// so only announce "Resumed" if nothing else just did.
if (Date.now() - root.lastMetaAt > 400) {
root.present("▶ Resumed", false, 0, false);
}
}
}
}
}
Process {
id: metadataFollow
command: ["playerctl", "--follow", "metadata", "--format", "{{title}}\t{{artist}}"]
stdout: SplitParser {
splitMarker: "\n"
onRead: (data) => {
var parts = data.split("\t");
var title = (parts[0] || "").trim();
var artist = (parts[1] || "").trim();
if (!title || title === root.lastTitle) return;
root.lastTitle = title;
root.lastMetaAt = Date.now();
root.present("▶ " + title + (artist ? " — " + artist : ""), false, 0, false);
}
}
}
Component.onCompleted: {
statusFollow.running = true;
metadataFollow.running = true;
}
PanelWindow {
id: osdWindow
visible: false
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "osd"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
focusable: false
anchors { top: true }
margins.top: 12
exclusiveZone: 0
color: "transparent"
implicitWidth: 300
implicitHeight: osdContent.implicitHeight + 24
Rectangle {
anchors.fill: parent
color: Theme.bg
border.width: 2
border.color: root.barDanger ? Theme.danger : Theme.accent
Column {
id: osdContent
x: 14
y: 12
width: parent.width - 28
spacing: 8
Text {
width: parent.width
text: root.osdText
color: Theme.fg
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 12
font.bold: true
elide: Text.ElideRight
}
Rectangle {
visible: root.showBar
width: parent.width
height: 6
color: Theme.bgAlt
border.width: 1
border.color: Theme.border
Rectangle {
width: parent.width * Math.max(0, Math.min(1, root.barValue))
height: parent.height
color: root.barDanger ? Theme.danger : Theme.accent
}
}
}
}
}
}