Files
dotwryn/config/user/quickshell/VolumeOsd.qml
T

235 lines
6.2 KiB
QML
Raw Normal View History

2026-10-23 16:19:22 -06:00
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
Scope {
id: osd
readonly property PwNode sink: Pipewire.defaultAudioSink
readonly property PwNode source: Pipewire.defaultAudioSource
property string label: ""
property real level: 0
property bool muted: false
property bool shown: false
property string warning: ""
// going past 100% : at 100%, it takes 'unlockPresses' separate presses of
// volume-up (the first shows a warning); above 100% there is no limit
readonly property real step: 0.10
readonly property int unlockPresses: 3
readonly property int repeatGuardMs: 150 // key-repeat events don't count toward unlocking
property int pressesAtMax: 0
property real lastUpPress: 0
IpcHandler {
target: "volume"
function up(): void { osd.volumeUp() }
}
Timer {
id: unlockReset
interval: 3000
onTriggered: osd.pressesAtMax = 0
}
function volumeUp(): void {
const audio = sink?.audio
if (!audio) return
const now = Date.now()
const isRepeat = now - lastUpPress < repeatGuardMs
lastUpPress = now
const percent = Math.round(audio.volume * 100)
if (percent < 100) { // normal range; stop exactly at 100%
pressesAtMax = 0
audio.volume = Math.min(1.0, audio.volume + step)
return
}
if (percent > 100) { // already past 100% : no limit
audio.volume = audio.volume + step
return
}
// exactly 100%
if (!isRepeat) {
pressesAtMax += 1
unlockReset.restart()
}
if (pressesAtMax >= unlockPresses) {
pressesAtMax = 0
audio.volume = audio.volume + step
return
}
if (isRepeat && shown) { // holding the key at 100% : just keep the warning up
hideTimer.restart()
return
}
const remaining = unlockPresses - pressesAtMax
show("volume", sink, "volume-max", `press ${remaining} more to go past 100%`)
}
// sound effects : names from $DOTWRYN/config/sfx.map, files in DESKTOP__SFX_PATH
property string sfxPath: ""
property var sfxFiles: ({})
// default devices must be tracked for their volume / mute to update
PwObjectTracker { objects: [osd.sink, osd.source] }
Process { // DESKTOP__SFX_PATH lives in the scwrypts environment; read it once
running: true
command: ["scwrypts", "--group", "scwrypts", "--type", "zsh", "--name", "scwrypts/environment/getenv", "--", "DESKTOP__SFX_PATH"]
environment: ({ SCWRYPTS_LOG_LEVEL: "0" })
stdout: StdioCollector {
onStreamFinished: osd.sfxPath = this.text.trim().replace(/^\$HOME|^~/, Quickshell.env("HOME"))
}
}
FileView {
path: Quickshell.env("DOTWRYN") + "/config/sfx.map"
watchChanges: true
onFileChanged: reload()
onLoaded: {
const files = {}
for (const line of text().split("\n")) {
const [name, file] = line.trim().split(/\s+/)
if (name && file && !name.startsWith("#")) files[name] = file
}
osd.sfxFiles = files
}
}
function playSfx(name: string): void {
if (sfxPath === "" || !sfxFiles[name]) return
Quickshell.execDetached(["canberra-gtk-play", "-f", `${sfxPath}/${sfxFiles[name]}`])
}
function show(label: string, node: PwNode, sfx: string, warning: string): void {
if (!node?.audio) return
osd.warning = warning ?? ""
osd.label = label
osd.level = node.audio.volume
osd.muted = node.audio.muted
osd.shown = true
hideTimer.restart()
playSfx(sfx)
}
// ignore the initial values reported while the devices bind at startup
property bool armed: false
Timer { running: true; interval: 1500; onTriggered: osd.armed = true }
Connections {
target: osd.sink?.audio ?? null
ignoreUnknownSignals: true
function onVolumeChanged() { if (osd.armed) osd.show("volume", osd.sink, "volume", "") }
function onMutedChanged() { if (osd.armed) osd.show("volume", osd.sink, "mute", "") }
}
Connections {
target: osd.source?.audio ?? null
ignoreUnknownSignals: true
function onVolumeChanged() { if (osd.armed) osd.show("mic", osd.source, "volume", "") }
function onMutedChanged() { if (osd.armed) osd.show("mic", osd.source, "mute", "") }
}
Timer {
id: hideTimer
interval: 1500
onTriggered: osd.shown = false
}
PanelWindow {
visible: osd.shown
anchors.bottom: true
margins.bottom: 120
exclusiveZone: 0
readonly property bool boosted: osd.level > 1.005
implicitWidth: 400
implicitHeight: osd.warning !== "" ? 84 : 56
color: "transparent"
Rectangle {
anchors.fill: parent
color: Colors.background
border.width: 2
border.color: osd.muted || parent.boosted || osd.warning !== "" ? Colors.alert : Colors.secondary
TextMetrics {
id: percentSlot
font.family: Colors.font
font.pointSize: Colors.fontSize
font.bold: true
text: "1000%"
}
Text { // past-100% warning; sits above the row so the row never moves
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: row.top
anchors.bottomMargin: 8
visible: osd.warning !== ""
text: osd.warning
color: Colors.alert
font.family: Colors.font
font.pointSize: Colors.fontSize - 1
font.bold: true
}
Row {
id: row
// pinned to the bottom edge : the popup is anchored to the screen
// bottom, so it grows upward for the warning and the bar stays put
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 18
spacing: 14
Text {
anchors.verticalCenter: parent.verticalCenter
width: 64
text: osd.muted ? `${osd.label} \u2298` : osd.label
color: osd.muted ? Colors.alert : Colors.foreground
font.family: Colors.font
font.pointSize: Colors.fontSize
font.bold: true
}
Rectangle { // level bar
anchors.verticalCenter: parent.verticalCenter
width: 200
height: 10
color: Colors.disabled
Rectangle {
width: parent.width * Math.min(osd.level, 1)
height: parent.height
color: osd.muted || osd.level > 1.005 ? Colors.alert : Colors.secondary
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: percentSlot.advanceWidth
horizontalAlignment: Text.AlignRight
text: `${Math.round(osd.level * 100)}%`
color: osd.level > 1.005 ? Colors.alert : Colors.foreground
font.family: Colors.font
font.pointSize: Colors.fontSize
font.bold: true
}
}
}
}
}