diff --git a/.config/quickshell/modules/common/ConfigOptions.qml b/.config/quickshell/modules/common/ConfigOptions.qml index 91ba70408..529c04272 100644 --- a/.config/quickshell/modules/common/ConfigOptions.qml +++ b/.config/quickshell/modules/common/ConfigOptions.qml @@ -12,6 +12,14 @@ Singleton { property int fakeScreenRounding: 1 // 0: None | 1: Always | 2: When not fullscreen } + property QtObject audio: QtObject { // Values in % + property QtObject protection: QtObject { // Prevent sudden bangs + property bool enable: true + property real maxAllowedIncrease: 10 + property real maxAllowed: 90 // Realistically should already provide some protection when it's 99... + } + } + property QtObject apps: QtObject { property string bluetooth: "better-control --bluetooth" property string imageViewer: "loupe" diff --git a/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayBrightness.qml b/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayBrightness.qml index decb7537c..765386bc8 100644 --- a/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayBrightness.qml +++ b/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayBrightness.qml @@ -73,7 +73,7 @@ Scope { item: osdValuesWrapper } - implicitWidth: Appearance.sizes.osdWidth + implicitWidth: columnLayout.implicitWidth implicitHeight: columnLayout.implicitHeight visible: osdLoader.active diff --git a/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayVolume.qml b/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayVolume.qml index e1904354e..5d23b4405 100644 --- a/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayVolume.qml +++ b/.config/quickshell/modules/onScreenDisplay/OnScreenDisplayVolume.qml @@ -12,6 +12,7 @@ import Quickshell.Hyprland Scope { id: root property bool showOsdValues: false + property string protectionMessage: "" property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name) function triggerOsd() { @@ -25,7 +26,8 @@ Scope { repeat: false running: false onTriggered: { - showOsdValues = false + root.showOsdValues = false + root.protectionMessage = "" } } @@ -36,7 +38,7 @@ Scope { } } - Connections { + Connections { // Listen to volume changes target: Audio.sink?.audio ?? null function onVolumeChanged() { if (!Audio.ready) return @@ -48,6 +50,14 @@ Scope { } } + Connections { // Listen to protection triggers + target: Audio + function onSinkProtectionTriggered(reason) { + root.protectionMessage = reason; + root.triggerOsd() + } + } + Loader { id: osdLoader active: showOsdValues @@ -75,7 +85,7 @@ Scope { item: osdValuesWrapper } - implicitWidth: Appearance.sizes.osdWidth + implicitWidth: columnLayout.implicitWidth implicitHeight: columnLayout.implicitHeight visible: osdLoader.active @@ -85,8 +95,8 @@ Scope { Item { id: osdValuesWrapper // Extra space for shadow - implicitHeight: osdValues.implicitHeight + Appearance.sizes.elevationMargin * 2 - implicitWidth: osdValues.implicitWidth + implicitHeight: contentColumnLayout.implicitHeight + Appearance.sizes.elevationMargin * 2 + implicitWidth: contentColumnLayout.implicitWidth clip: true MouseArea { @@ -95,20 +105,63 @@ Scope { onEntered: root.showOsdValues = false } - Behavior on implicitHeight { - NumberAnimation { - duration: Appearance.animation.menuDecel.duration - easing.type: Appearance.animation.menuDecel.type + ColumnLayout { + id: contentColumnLayout + anchors { + top: parent.top + left: parent.left + right: parent.right + leftMargin: Appearance.sizes.elevationMargin + rightMargin: Appearance.sizes.elevationMargin } - } + spacing: 0 - OsdValueIndicator { - id: osdValues - anchors.fill: parent - anchors.margins: Appearance.sizes.elevationMargin - value: Audio.sink?.audio.volume ?? 0 - icon: Audio.sink?.audio.muted ? "volume_off" : "volume_up" - name: qsTr("Volume") + OsdValueIndicator { + id: osdValues + Layout.fillWidth: true + value: Audio.sink?.audio.volume ?? 0 + icon: Audio.sink?.audio.muted ? "volume_off" : "volume_up" + name: qsTr("Volume") + } + + Item { + id: protectionMessageWrapper + implicitHeight: protectionMessageBackground.implicitHeight + implicitWidth: protectionMessageBackground.implicitWidth + Layout.alignment: Qt.AlignHCenter + opacity: root.protectionMessage !== "" ? 1 : 0 + + StyledRectangularShadow { + target: protectionMessageBackground + } + Rectangle { + id: protectionMessageBackground + anchors.centerIn: parent + color: Appearance.m3colors.m3error + property real padding: 10 + implicitHeight: protectionMessageRowLayout.implicitHeight + padding * 2 + implicitWidth: protectionMessageRowLayout.implicitWidth + padding * 2 + radius: Appearance.rounding.normal + + RowLayout { + id: protectionMessageRowLayout + anchors.centerIn: parent + MaterialSymbol { + id: protectionMessageIcon + text: "dangerous" + iconSize: Appearance.font.pixelSize.hugeass + color: Appearance.m3colors.m3onError + } + StyledText { + id: protectionMessageTextWidget + horizontalAlignment: Text.AlignHCenter + color: Appearance.m3colors.m3onError + wrapMode: Text.Wrap + text: root.protectionMessage + } + } + } + } } } } diff --git a/.config/quickshell/services/Audio.qml b/.config/quickshell/services/Audio.qml index 8b5f9b760..dd46f0fbb 100644 --- a/.config/quickshell/services/Audio.qml +++ b/.config/quickshell/services/Audio.qml @@ -1,3 +1,4 @@ +import "root:/modules/common" import QtQuick import Quickshell import Quickshell.Services.Pipewire @@ -11,11 +12,43 @@ Singleton { id: root property bool ready: Pipewire.defaultAudioSink?.ready ?? false - property var sink: Pipewire.defaultAudioSink - property var source: Pipewire.defaultAudioSource + property PwNode sink: Pipewire.defaultAudioSink + property PwNode source: Pipewire.defaultAudioSource + + signal sinkProtectionTriggered(string reason); PwObjectTracker { objects: [sink, source] + Component.onCompleted: { + sink.audio.volume = sink.audio.volume; // Trigger initial volume change + } + } + + Connections { // Protection against sudden volume changes + target: sink?.audio ?? null + property bool lastReady: false + property real lastVolume: 0 + function onVolumeChanged() { + if (!ConfigOptions.audio.protection.enable) return; + if (!lastReady) { + lastVolume = sink.audio.volume; + lastReady = true; + return; + } + const newVolume = sink.audio.volume; + const maxAllowedIncrease = ConfigOptions.audio.protection.maxAllowedIncrease / 100; + const maxAllowed = ConfigOptions.audio.protection.maxAllowed / 100; + + if (newVolume - lastVolume > maxAllowedIncrease) { + sink.audio.volume = lastVolume; + root.sinkProtectionTriggered("Illegal increment"); + } else if (newVolume > maxAllowed) { + sink.audio.volume = lastVolume; + root.sinkProtectionTriggered("Exceeded max allowed"); + } + lastVolume = sink.audio.volume; + } + } }