mirror of
https://github.com/Nexoniarz/nexoniarz-config-nixos.git
synced 2026-11-03 04:24:27 +00:00
- 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
199 lines
6.9 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|