From aaff1ab61e8ed4298937c1b7293fbb50833ac99e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Thi=E1=BB=87n=20L=E1=BB=99c?= Date: Fri, 22 May 2026 20:00:12 +0700 Subject: [PATCH] feat: implement interactive microphone volume OSD and IPC controls (#2406) * feat: implement interactive microphone volume OSD and persistence Addresses #2388 * refactor: reduce scope to interactive microphone OSD and IPC controls only --- quickshell/Common/KeybindActions.js | 2 +- quickshell/Common/SettingsData.qml | 1 + quickshell/DMSShell.qml | 2 +- quickshell/DMSShellIPC.qml | 30 +++ quickshell/Modules/OSD/MicMuteOSD.qml | 29 --- quickshell/Modules/OSD/MicVolumeOSD.qml | 253 ++++++++++++++++++++++++ quickshell/Services/AudioService.qml | 43 +++- 7 files changed, 325 insertions(+), 35 deletions(-) delete mode 100644 quickshell/Modules/OSD/MicMuteOSD.qml create mode 100644 quickshell/Modules/OSD/MicVolumeOSD.qml diff --git a/quickshell/Common/KeybindActions.js b/quickshell/Common/KeybindActions.js index 0d25ba28..18ac906e 100644 --- a/quickshell/Common/KeybindActions.js +++ b/quickshell/Common/KeybindActions.js @@ -66,7 +66,7 @@ const DMS_ACTIONS = [ { id: "spawn dms ipc call mpris increment 5", label: "Player Volume Up (5%)" }, { id: "spawn dms ipc call mpris decrement 5", label: "Player Volume Down (5%)" }, { id: "spawn dms ipc call audio mute", label: "Volume Mute Toggle" }, - { id: "spawn dms ipc call audio micmute", label: "Microphone Mute Toggle" }, + { id: "spawn dms ipc call mic mute", label: "Microphone Mute Toggle" }, { id: "spawn dms ipc call audio cycleoutput", label: "Audio Output: Cycle" }, { id: "spawn dms ipc call brightness increment 5 \"\"", label: "Brightness Up" }, { id: "spawn dms ipc call brightness increment 1 \"\"", label: "Brightness Up (1%)" }, diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 145de077..399b827e 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -708,6 +708,7 @@ Singleton { property bool osdBrightnessEnabled: true property bool osdIdleInhibitorEnabled: true property bool osdMicMuteEnabled: true + property bool osdMicVolumeEnabled: true property bool osdCapsLockEnabled: true property bool osdPowerProfileEnabled: true property bool osdAudioOutputEnabled: true diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 94b6cc71..b022afaa 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -1114,7 +1114,7 @@ Item { Variants { model: SettingsData.getFilteredScreens("osd") - delegate: MicMuteOSD { + delegate: MicVolumeOSD { modelData: item } } diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 53a69d32..83567d07 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -1794,6 +1794,36 @@ Item { target: "outputs" } + IpcHandler { + target: "mic" + + function setvolume(percentage: string): string { + return AudioService.setMicVolume(parseInt(percentage)); + } + + function increment(step: string): string { + return AudioService.incrementMicVolume(step); + } + + function decrement(step: string): string { + return AudioService.decrementMicVolume(step); + } + + function mute(): string { + return AudioService.toggleMicMute(); + } + + function status(): string { + if (!AudioService.source || !AudioService.source.audio) { + return "No audio source available"; + } + + const volume = Math.round(AudioService.source.audio.volume * 100); + const muteStatus = AudioService.source.audio.muted ? " (muted)" : ""; + return `Microphone: ${volume}%${muteStatus}`; + } + } + IpcHandler { function findTrayItem(itemId: string): var { if (!itemId) diff --git a/quickshell/Modules/OSD/MicMuteOSD.qml b/quickshell/Modules/OSD/MicMuteOSD.qml deleted file mode 100644 index 71162acd..00000000 --- a/quickshell/Modules/OSD/MicMuteOSD.qml +++ /dev/null @@ -1,29 +0,0 @@ -import QtQuick -import qs.Common -import qs.Services -import qs.Widgets - -DankOSD { - id: root - - osdWidth: Theme.iconSize + Theme.spacingS * 2 - osdHeight: Theme.iconSize + Theme.spacingS * 2 - autoHideInterval: 2000 - enableMouseInteraction: false - - Connections { - target: AudioService - function onMicMuteChanged() { - if (SettingsData.osdMicMuteEnabled) { - root.show() - } - } - } - - content: DankIcon { - anchors.centerIn: parent - name: AudioService.source && AudioService.source.audio && AudioService.source.audio.muted ? "mic_off" : "mic" - size: Theme.iconSize - color: AudioService.source && AudioService.source.audio && AudioService.source.audio.muted ? Theme.error : Theme.primary - } -} diff --git a/quickshell/Modules/OSD/MicVolumeOSD.qml b/quickshell/Modules/OSD/MicVolumeOSD.qml new file mode 100644 index 00000000..ae145c40 --- /dev/null +++ b/quickshell/Modules/OSD/MicVolumeOSD.qml @@ -0,0 +1,253 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +DankOSD { + id: root + + readonly property bool useVertical: isVerticalLayout + property int _displayVolume: 0 + + function _syncVolume() { + if (!AudioService.source?.audio) + return; + _displayVolume = Math.round(AudioService.source.audio.volume * 100); + } + + osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(260, Screen.width - Theme.spacingM * 2) + osdHeight: useVertical ? Math.min(260, Screen.height - Theme.spacingM * 2) : (40 + Theme.spacingS * 2) + autoHideInterval: 3000 + enableMouseInteraction: true + + Connections { + target: AudioService.source?.audio ?? null + + function onVolumeChanged() { + root._syncVolume(); + if (SettingsData.osdMicVolumeEnabled) + root.show(); + } + + function onMutedChanged() { + if (SettingsData.osdMicMuteEnabled) + root.show(); + } + } + + Connections { + target: AudioService + + function onSourceChanged() { + root._syncVolume(); + if (root.shouldBeVisible && SettingsData.osdMicVolumeEnabled) + root.show(); + } + } + + content: Loader { + anchors.fill: parent + sourceComponent: useVertical ? verticalContent : horizontalContent + } + + Component { + id: horizontalContent + + Item { + property int gap: Theme.spacingS + + anchors.centerIn: parent + width: parent.width - Theme.spacingS * 2 + height: 40 + + Rectangle { + width: Theme.iconSize + height: Theme.iconSize + radius: Theme.iconSize / 2 + color: "transparent" + x: parent.gap + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + anchors.centerIn: parent + name: AudioService.source?.audio?.muted ? "mic_off" : "mic" + size: Theme.iconSize + color: muteButton.containsMouse ? Theme.primary : (AudioService.source?.audio?.muted ? Theme.error : Theme.surfaceText) + } + + MouseArea { + id: muteButton + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: AudioService.toggleMicMute() + onContainsMouseChanged: setChildHovered(containsMouse || volumeSlider.containsMouse) + } + } + + DankSlider { + id: volumeSlider + + width: parent.width - Theme.iconSize - parent.gap * 3 + height: 40 + x: parent.gap * 2 + Theme.iconSize + anchors.verticalCenter: parent.verticalCenter + minimum: 0 + maximum: 100 + enabled: AudioService.source?.audio ?? false + showValue: true + unit: "%" + thumbOutlineColor: Theme.surfaceContainer + valueOverride: root._displayVolume + alwaysShowValue: SettingsData.osdAlwaysShowValue + + Component.onCompleted: { + root._syncVolume(); + value = root._displayVolume; + } + + onSliderValueChanged: newValue => { + if (!AudioService.source?.audio) + return; + SessionData.suppressOSDTemporarily(); + AudioService.source.audio.volume = newValue / 100; + resetHideTimer(); + } + + onContainsMouseChanged: setChildHovered(containsMouse || muteButton.containsMouse) + + Binding on value { + value: root._displayVolume + when: !volumeSlider.pressed + } + } + } + } + + Component { + id: verticalContent + + Item { + anchors.fill: parent + property int gap: Theme.spacingS + + Rectangle { + width: Theme.iconSize + height: Theme.iconSize + radius: Theme.iconSize / 2 + color: "transparent" + anchors.horizontalCenter: parent.horizontalCenter + y: gap + + DankIcon { + anchors.centerIn: parent + name: AudioService.source?.audio?.muted ? "mic_off" : "mic" + size: Theme.iconSize + color: muteButtonVert.containsMouse ? Theme.primary : (AudioService.source?.audio?.muted ? Theme.error : Theme.surfaceText) + } + + MouseArea { + id: muteButtonVert + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: AudioService.toggleMicMute() + onContainsMouseChanged: setChildHovered(containsMouse || vertSliderArea.containsMouse) + } + } + + Item { + id: vertSlider + width: 12 + height: parent.height - Theme.iconSize - gap * 3 - 24 + anchors.horizontalCenter: parent.horizontalCenter + y: gap * 2 + Theme.iconSize + + property bool dragging: false + property int value: root._displayVolume + + Rectangle { + id: vertTrack + width: parent.width + height: parent.height + anchors.centerIn: parent + color: Theme.outline + radius: Theme.cornerRadius + } + + Rectangle { + id: vertFill + width: parent.width + height: (vertSlider.value / 100) * parent.height + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + color: AudioService.source?.audio?.muted ? Theme.error : Theme.primary + radius: Theme.cornerRadius + } + + Rectangle { + id: vertHandle + width: 24 + height: 8 + radius: Theme.cornerRadius + y: { + const ratio = vertSlider.value / 100; + const travel = parent.height - height; + return Math.max(0, Math.min(travel, travel * (1 - ratio))); + } + anchors.horizontalCenter: parent.horizontalCenter + color: AudioService.source?.audio?.muted ? Theme.error : Theme.primary + border.width: 3 + border.color: Theme.surfaceContainer + } + + MouseArea { + id: vertSliderArea + anchors.fill: parent + anchors.margins: -12 + enabled: AudioService.source?.audio ?? false + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onContainsMouseChanged: setChildHovered(containsMouse || muteButtonVert.containsMouse) + + onPressed: mouse => { + vertSlider.dragging = true; + updateVolume(mouse); + } + + onReleased: vertSlider.dragging = false + + onPositionChanged: mouse => { + if (pressed) + updateVolume(mouse); + } + + onClicked: mouse => updateVolume(mouse) + + function updateVolume(mouse) { + if (!AudioService.source?.audio) + return; + const ratio = 1.0 - (mouse.y / height); + const volume = Math.max(0, Math.min(100, Math.round(ratio * 100))); + SessionData.suppressOSDTemporarily(); + AudioService.source.audio.volume = volume / 100; + resetHideTimer(); + } + } + } + + StyledText { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottomMargin: gap + text: vertSlider.value + "%" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + visible: SettingsData.osdAlwaysShowValue + } + } + } +} diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index 9769925b..bf608daa 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -397,6 +397,14 @@ EOFCONFIG } } + Connections { + target: root.source?.audio ?? null + + function onMutedChanged() { + root.micMuteChanged(); + } + } + function checkGsettings() { Proc.runCommand("checkGsettings", ["sh", "-c", "gsettings get org.gnome.desktop.sound theme-name 2>/dev/null"], (output, exitCode) => { gsettingsAvailable = (exitCode === 0); @@ -844,6 +852,36 @@ EOFCONFIG return root.source.audio.muted ? "Microphone muted" : "Microphone unmuted"; } + function incrementMicVolume(step) { + if (!root.source?.audio) + return "No audio source available"; + + if (root.source.audio.muted) + root.source.audio.muted = false; + + const currentVolume = Math.round(root.source.audio.volume * 100); + const stepValue = parseInt(step || "5"); + const newVolume = Math.max(0, Math.min(100, currentVolume + stepValue)); + + root.source.audio.volume = newVolume / 100; + return `Microphone volume increased to ${newVolume}%`; + } + + function decrementMicVolume(step) { + if (!root.source?.audio) + return "No audio source available"; + + if (root.source.audio.muted) + root.source.audio.muted = false; + + const currentVolume = Math.round(root.source.audio.volume * 100); + const stepValue = parseInt(step || "5"); + const newVolume = Math.max(0, Math.min(100, currentVolume - stepValue)); + + root.source.audio.volume = newVolume / 100; + return `Microphone volume decreased to ${newVolume}%`; + } + IpcHandler { target: "audio" @@ -892,9 +930,7 @@ EOFCONFIG } function micmute(): string { - const result = root.toggleMicMute(); - root.micMuteChanged(); - return result; + return root.toggleMicMute(); } function status(): string { @@ -957,7 +993,6 @@ EOFCONFIG return `Switched to: ${result}`; } } - Connections { target: SettingsData function onUseSystemSoundThemeChanged() {