From 1ed44ee6f3894e1ad7449108f61ab225b49ec47f Mon Sep 17 00:00:00 2001 From: bbedward Date: Mon, 9 Feb 2026 09:26:34 -0500 Subject: [PATCH] audio: add per-device max volume limit setting --- quickshell/Common/SessionData.qml | 31 +++++++ quickshell/Common/settings/SessionSpec.js | 4 +- .../ControlCenter/Components/DragDropGrid.qml | 3 +- .../Details/AudioOutputDetail.qml | 42 +++++----- .../ControlCenter/Widgets/AudioSliderRow.qml | 4 +- .../DankBar/Widgets/ControlCenterButton.qml | 3 +- .../Modules/DankDash/MediaPlayerTab.qml | 6 +- quickshell/Modules/OSD/VolumeOSD.qml | 19 +++-- quickshell/Modules/Settings/AudioTab.qml | 64 +++++++++++--- quickshell/Services/AudioService.qml | 84 +++++++++++++++---- 10 files changed, 197 insertions(+), 63 deletions(-) diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index e76974c0..3d2c5772 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -121,6 +121,8 @@ Singleton { property string vpnLastConnected: "" + property var deviceMaxVolumes: ({}) + Component.onCompleted: { if (!isGreeterMode) { loadSettings(); @@ -1052,6 +1054,35 @@ Singleton { saveSettings(); } + function setDeviceMaxVolume(nodeName, maxPercent) { + if (!nodeName) + return; + const updated = Object.assign({}, deviceMaxVolumes); + const clamped = Math.max(100, Math.min(200, Math.round(maxPercent))); + if (clamped === 100) { + delete updated[nodeName]; + } else { + updated[nodeName] = clamped; + } + deviceMaxVolumes = updated; + saveSettings(); + } + + function getDeviceMaxVolume(nodeName) { + if (!nodeName) + return 100; + return deviceMaxVolumes[nodeName] ?? 100; + } + + function removeDeviceMaxVolume(nodeName) { + if (!nodeName) + return; + const updated = Object.assign({}, deviceMaxVolumes); + delete updated[nodeName]; + deviceMaxVolumes = updated; + saveSettings(); + } + function syncWallpaperForCurrentMode() { if (!perModeWallpaper) return; diff --git a/quickshell/Common/settings/SessionSpec.js b/quickshell/Common/settings/SessionSpec.js index 38da1eea..220359c3 100644 --- a/quickshell/Common/settings/SessionSpec.js +++ b/quickshell/Common/settings/SessionSpec.js @@ -72,7 +72,9 @@ var SPEC = { appOverrides: { def: {} }, searchAppActions: { def: true }, - vpnLastConnected: { def: "" } + vpnLastConnected: { def: "" }, + + deviceMaxVolumes: { def: {} } }; function getValidKeys() { diff --git a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml index cb8c6c67..697a1896 100644 --- a/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml +++ b/quickshell/Modules/ControlCenter/Components/DragDropGrid.qml @@ -451,10 +451,11 @@ Column { if (!AudioService.sink || !AudioService.sink.audio) return; let delta = wheelEvent.angleDelta.y; + let maxVol = AudioService.sinkMaxVolume; let currentVolume = AudioService.sink.audio.volume * 100; let newVolume; if (delta > 0) - newVolume = Math.min(100, currentVolume + 5); + newVolume = Math.min(maxVol, currentVolume + 5); else newVolume = Math.max(0, currentVolume - 5); AudioService.sink.audio.muted = false; diff --git a/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml b/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml index 0e033310..03c6a148 100644 --- a/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/AudioOutputDetail.qml @@ -102,8 +102,8 @@ Rectangle { width: parent.width - (Theme.iconSize + Theme.spacingS * 2) enabled: AudioService.sink && AudioService.sink.audio minimum: 0 - maximum: 100 - value: AudioService.sink && AudioService.sink.audio ? Math.min(100, Math.round(AudioService.sink.audio.volume * 100)) : 0 + maximum: AudioService.sinkMaxVolume + value: AudioService.sink && AudioService.sink.audio ? Math.min(AudioService.sinkMaxVolume, Math.round(AudioService.sink.audio.volume * 100)) : 0 showValue: true unit: "%" valueOverride: actualVolumePercent @@ -136,15 +136,15 @@ Rectangle { function normalizePinList(value) { if (Array.isArray(value)) - return value.filter(v => v) + return value.filter(v => v); if (typeof value === "string" && value.length > 0) - return [value] - return [] + return [value]; + return []; } function getPinnedOutputs() { - const pins = SettingsData.audioOutputDevicePins || {} - return normalizePinList(pins["preferredOutput"]) + const pins = SettingsData.audioOutputDevicePins || {}; + return normalizePinList(pins["preferredOutput"]); } Column { @@ -163,14 +163,14 @@ Rectangle { let sorted = [...nodes]; sorted.sort((a, b) => { // Pinned device first - const aPinnedIndex = pinnedList.indexOf(a.name) - const bPinnedIndex = pinnedList.indexOf(b.name) + const aPinnedIndex = pinnedList.indexOf(a.name); + const bPinnedIndex = pinnedList.indexOf(b.name); if (aPinnedIndex !== -1 || bPinnedIndex !== -1) { if (aPinnedIndex === -1) - return 1 + return 1; if (bPinnedIndex === -1) - return -1 - return aPinnedIndex - bPinnedIndex + return -1; + return aPinnedIndex - bPinnedIndex; } // Then active device if (a === AudioService.sink && b !== AudioService.sink) @@ -292,24 +292,24 @@ Rectangle { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { - const pins = JSON.parse(JSON.stringify(SettingsData.audioOutputDevicePins || {})) - let pinnedList = audioContent.normalizePinList(pins["preferredOutput"]) - const pinIndex = pinnedList.indexOf(modelData.name) + const pins = JSON.parse(JSON.stringify(SettingsData.audioOutputDevicePins || {})); + let pinnedList = audioContent.normalizePinList(pins["preferredOutput"]); + const pinIndex = pinnedList.indexOf(modelData.name); if (pinIndex !== -1) { - pinnedList.splice(pinIndex, 1) + pinnedList.splice(pinIndex, 1); } else { - pinnedList.unshift(modelData.name) + pinnedList.unshift(modelData.name); if (pinnedList.length > audioContent.maxPinnedOutputs) - pinnedList = pinnedList.slice(0, audioContent.maxPinnedOutputs) + pinnedList = pinnedList.slice(0, audioContent.maxPinnedOutputs); } if (pinnedList.length > 0) - pins["preferredOutput"] = pinnedList + pins["preferredOutput"] = pinnedList; else - delete pins["preferredOutput"] + delete pins["preferredOutput"]; - SettingsData.set("audioOutputDevicePins", pins) + SettingsData.set("audioOutputDevicePins", pins); } } } diff --git a/quickshell/Modules/ControlCenter/Widgets/AudioSliderRow.qml b/quickshell/Modules/ControlCenter/Widgets/AudioSliderRow.qml index 7f272cec..07e3adc4 100644 --- a/quickshell/Modules/ControlCenter/Widgets/AudioSliderRow.qml +++ b/quickshell/Modules/ControlCenter/Widgets/AudioSliderRow.qml @@ -69,7 +69,7 @@ Row { width: parent.width - (Theme.iconSize + Theme.spacingS * 2) enabled: defaultSink !== null minimum: 0 - maximum: 100 + maximum: AudioService.sinkMaxVolume showValue: true unit: "%" valueOverride: actualVolumePercent @@ -91,7 +91,7 @@ Row { Binding { target: volumeSlider property: "value" - value: defaultSink ? Math.min(100, Math.round(defaultSink.audio.volume * 100)) : 0 + value: defaultSink ? Math.min(AudioService.sinkMaxVolume, Math.round(defaultSink.audio.volume * 100)) : 0 when: !volumeSlider.isDragging } } diff --git a/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml b/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml index 0741ee98..24d34c22 100644 --- a/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml +++ b/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml @@ -140,8 +140,9 @@ BasePill { volumeAccumulator = 0; } + const maxVol = AudioService.sinkMaxVolume; const currentVolume = AudioService.sink.audio.volume * 100; - const newVolume = delta > 0 ? Math.min(100, currentVolume + step) : Math.max(0, currentVolume - step); + const newVolume = delta > 0 ? Math.min(maxVol, currentVolume + step) : Math.max(0, currentVolume - step); AudioService.sink.audio.muted = false; AudioService.sink.audio.volume = newVolume / 100; AudioService.playVolumeChangeSoundIfEnabled(); diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index 8d1ca06a..76df2153 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -201,8 +201,9 @@ Item { function adjustVolume(step) { if (!volumeAvailable) return; + const maxVol = usePlayerVolume ? 100 : AudioService.sinkMaxVolume; const current = Math.round(currentVolume * 100); - const newVolume = Math.min(100, Math.max(0, current + step)); + const newVolume = Math.min(maxVol, Math.max(0, current + step)); SessionData.suppressOSDTemporarily(); if (usePlayerVolume) { @@ -778,7 +779,8 @@ Item { SessionData.suppressOSDTemporarily(); const delta = wheelEvent.angleDelta.y; const current = (currentVolume * 100) || 0; - const newVolume = delta > 0 ? Math.min(100, current + 5) : Math.max(0, current - 5); + const maxVol = usePlayerVolume ? 100 : AudioService.sinkMaxVolume; + const newVolume = delta > 0 ? Math.min(maxVol, current + 5) : Math.max(0, current - 5); if (usePlayerVolume) { activePlayer.volume = newVolume / 100; diff --git a/quickshell/Modules/OSD/VolumeOSD.qml b/quickshell/Modules/OSD/VolumeOSD.qml index 2a65da22..6233437a 100644 --- a/quickshell/Modules/OSD/VolumeOSD.qml +++ b/quickshell/Modules/OSD/VolumeOSD.qml @@ -95,7 +95,7 @@ DankOSD { x: parent.gap * 2 + Theme.iconSize anchors.verticalCenter: parent.verticalCenter minimum: 0 - maximum: 100 + maximum: AudioService.sinkMaxVolume enabled: AudioService.sink && AudioService.sink.audio showValue: true unit: "%" @@ -105,7 +105,7 @@ DankOSD { Component.onCompleted: { if (AudioService.sink && AudioService.sink.audio) { - value = Math.min(100, Math.round(AudioService.sink.audio.volume * 100)); + value = Math.min(AudioService.sinkMaxVolume, Math.round(AudioService.sink.audio.volume * 100)); } } @@ -126,7 +126,7 @@ DankOSD { function onVolumeChanged() { if (volumeSlider && !volumeSlider.pressed) { - volumeSlider.value = Math.min(100, Math.round(AudioService.sink.audio.volume * 100)); + volumeSlider.value = Math.min(AudioService.sinkMaxVolume, Math.round(AudioService.sink.audio.volume * 100)); } } } @@ -179,7 +179,7 @@ DankOSD { y: gap * 2 + Theme.iconSize property bool dragging: false - property int value: AudioService.sink && AudioService.sink.audio ? Math.min(100, Math.round(AudioService.sink.audio.volume * 100)) : 0 + property int value: AudioService.sink && AudioService.sink.audio ? Math.min(AudioService.sinkMaxVolume, Math.round(AudioService.sink.audio.volume * 100)) : 0 Rectangle { id: vertTrack @@ -193,7 +193,7 @@ DankOSD { Rectangle { id: vertFill width: parent.width - height: (vertSlider.value / 100) * parent.height + height: (vertSlider.value / AudioService.sinkMaxVolume) * parent.height anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter color: Theme.primary @@ -206,7 +206,7 @@ DankOSD { height: 8 radius: Theme.cornerRadius y: { - const ratio = vertSlider.value / 100; + const ratio = vertSlider.value / AudioService.sinkMaxVolume; const travel = parent.height - height; return Math.max(0, Math.min(travel, travel * (1 - ratio))); } @@ -249,8 +249,9 @@ DankOSD { function updateVolume(mouse) { if (AudioService.sink && AudioService.sink.audio) { + const maxVol = AudioService.sinkMaxVolume; const ratio = 1.0 - (mouse.y / height); - const volume = Math.max(0, Math.min(100, Math.round(ratio * 100))); + const volume = Math.max(0, Math.min(maxVol, Math.round(ratio * maxVol))); SessionData.suppressOSDTemporarily(); AudioService.sink.audio.volume = volume / 100; resetHideTimer(); @@ -262,7 +263,7 @@ DankOSD { target: AudioService.sink && AudioService.sink.audio ? AudioService.sink.audio : null function onVolumeChanged() { - vertSlider.value = Math.min(100, Math.round(AudioService.sink.audio.volume * 100)); + vertSlider.value = Math.min(AudioService.sinkMaxVolume, Math.round(AudioService.sink.audio.volume * 100)); } } } @@ -284,7 +285,7 @@ DankOSD { if (!useVertical) { const slider = contentLoader.item.item.children[0].children[1]; if (slider && slider.value !== undefined) { - slider.value = Math.min(100, Math.round(AudioService.sink.audio.volume * 100)); + slider.value = Math.min(AudioService.sinkMaxVolume, Math.round(AudioService.sink.audio.volume * 100)); } } } diff --git a/quickshell/Modules/Settings/AudioTab.qml b/quickshell/Modules/Settings/AudioTab.qml index cf2a0be7..bd394204 100644 --- a/quickshell/Modules/Settings/AudioTab.qml +++ b/quickshell/Modules/Settings/AudioTab.qml @@ -1,5 +1,4 @@ import QtQuick -import QtQuick.Controls import Quickshell.Services.Pipewire import qs.Common import qs.Services @@ -131,21 +130,64 @@ Item { Repeater { model: root.outputDevices - delegate: DeviceAliasRow { + delegate: Column { required property var modelData + width: parent?.width ?? 0 + spacing: 0 - deviceNode: modelData - deviceType: "output" + DeviceAliasRow { + deviceNode: modelData + deviceType: "output" - onEditRequested: device => { - root.editingDevice = device; - root.editingDeviceType = "output"; - root.newDeviceName = AudioService.displayName(device); - root.showEditDialog = true; + onEditRequested: device => { + root.editingDevice = device; + root.editingDeviceType = "output"; + root.newDeviceName = AudioService.displayName(device); + root.showEditDialog = true; + } + + onResetRequested: device => { + AudioService.removeDeviceAlias(device.name); + } } - onResetRequested: device => { - AudioService.removeDeviceAlias(device.name); + Item { + width: parent.width + height: 36 + + StyledText { + id: maxVolLabel + text: I18n.tr("Max Volume", "Audio settings: maximum volume limit per device") + x: Theme.spacingM + Theme.iconSize + Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankSlider { + id: maxVolSlider + anchors.left: maxVolLabel.right + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + height: 36 + minimum: 100 + maximum: 200 + step: 5 + showValue: true + unit: "%" + onSliderValueChanged: newValue => { + SessionData.setDeviceMaxVolume(modelData.name, newValue); + } + } + + Binding { + target: maxVolSlider + property: "value" + value: SessionData.deviceMaxVolumes[modelData.name] ?? 100 + when: !maxVolSlider.isDragging + } } } } diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index 46479c30..0becb4f6 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -39,12 +39,37 @@ Singleton { } property bool wireplumberReloading: false + readonly property int sinkMaxVolume: { + const name = sink?.name ?? ""; + if (!name) + return 100; + return SessionData.deviceMaxVolumes[name] ?? 100; + } + signal micMuteChanged signal audioOutputCycled(string deviceName) signal deviceAliasChanged(string nodeName, string newAlias) - signal wireplumberReloadStarted() + signal wireplumberReloadStarted signal wireplumberReloadCompleted(bool success) + function getMaxVolumePercent(node) { + if (!node?.name) + return 100; + return SessionData.deviceMaxVolumes[node.name] ?? 100; + } + + Connections { + target: SessionData + function onDeviceMaxVolumesChanged() { + if (!root.sink?.audio) + return; + const maxVol = root.sinkMaxVolume; + const currentPercent = Math.round(root.sink.audio.volume * 100); + if (currentPercent > maxVol) + root.sink.audio.volume = maxVol / 100; + } + } + function getAvailableSinks() { return Pipewire.nodes.values.filter(node => node.audio && node.isSink && !node.isStream); } @@ -814,11 +839,11 @@ EOFCONFIG } function setVolume(percentage) { - if (!root.sink?.audio) { + if (!root.sink?.audio) return "No audio sink available"; - } - const clampedVolume = Math.max(0, Math.min(100, percentage)); + const maxVol = root.sinkMaxVolume; + const clampedVolume = Math.max(0, Math.min(maxVol, percentage)); root.sink.audio.volume = clampedVolume / 100; return `Volume set to ${clampedVolume}%`; } @@ -859,34 +884,32 @@ EOFCONFIG } function increment(step: string): string { - if (!root.sink?.audio) { + if (!root.sink?.audio) return "No audio sink available"; - } - if (root.sink.audio.muted) { + if (root.sink.audio.muted) root.sink.audio.muted = false; - } + const maxVol = root.sinkMaxVolume; const currentVolume = Math.round(root.sink.audio.volume * 100); const stepValue = parseInt(step || "5"); - const newVolume = Math.max(0, Math.min(100, currentVolume + stepValue)); + const newVolume = Math.max(0, Math.min(maxVol, currentVolume + stepValue)); root.sink.audio.volume = newVolume / 100; return `Volume increased to ${newVolume}%`; } function decrement(step: string): string { - if (!root.sink?.audio) { + if (!root.sink?.audio) return "No audio sink available"; - } - if (root.sink.audio.muted) { + if (root.sink.audio.muted) root.sink.audio.muted = false; - } + const maxVol = root.sinkMaxVolume; const currentVolume = Math.round(root.sink.audio.volume * 100); const stepValue = parseInt(step || "5"); - const newVolume = Math.max(0, Math.min(100, currentVolume - stepValue)); + const newVolume = Math.max(0, Math.min(maxVol, currentVolume - stepValue)); root.sink.audio.volume = newVolume / 100; return `Volume decreased to ${newVolume}%`; @@ -912,7 +935,8 @@ EOFCONFIG if (root.sink?.audio) { const volume = Math.round(root.sink.audio.volume * 100); const muteStatus = root.sink.audio.muted ? " (muted)" : ""; - result += `Output: ${volume}%${muteStatus}\n`; + const maxVol = root.sinkMaxVolume; + result += `Output: ${volume}%${muteStatus} (max: ${maxVol}%)\n`; } else { result += "Output: No sink available\n"; } @@ -928,6 +952,36 @@ EOFCONFIG return result; } + function getmaxvolume(): string { + return `${root.sinkMaxVolume}`; + } + + function setmaxvolume(percent: string): string { + if (!root.sink?.name) + return "No audio sink available"; + const val = parseInt(percent); + if (isNaN(val)) + return "Invalid percentage"; + SessionData.setDeviceMaxVolume(root.sink.name, val); + return `Max volume set to ${SessionData.getDeviceMaxVolume(root.sink.name)}%`; + } + + function getmaxvolumefor(nodeName: string): string { + if (!nodeName) + return "No node name specified"; + return `${SessionData.getDeviceMaxVolume(nodeName)}`; + } + + function setmaxvolumefor(nodeName: string, percent: string): string { + if (!nodeName) + return "No node name specified"; + const val = parseInt(percent); + if (isNaN(val)) + return "Invalid percentage"; + SessionData.setDeviceMaxVolume(nodeName, val); + return `Max volume for ${nodeName} set to ${SessionData.getDeviceMaxVolume(nodeName)}%`; + } + function cycleoutput(): string { const result = root.cycleAudioOutput(); if (!result)