diff --git a/Modules/Bar/Audio/AudioPanel.qml b/Modules/Bar/Audio/AudioPanel.qml new file mode 100644 index 00000000..c8e16e7d --- /dev/null +++ b/Modules/Bar/Audio/AudioPanel.qml @@ -0,0 +1,244 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Services.Pipewire +import qs.Commons +import qs.Services +import qs.Widgets + +NPanel { + id: root + + property real localOutputVolume: AudioService.volume || 0 + property bool localOutputVolumeChanging: false + + property real localInputVolume: AudioService.inputVolume || 0 + property bool localInputVolumeChanging: false + + preferredWidth: 380 * Style.uiScaleRatio + preferredHeight: 500 * Style.uiScaleRatio + panelKeyboardFocus: true + + // Connections to update local volumes when AudioService changes + Connections { + target: AudioService.sink?.audio ? AudioService.sink?.audio : null + function onVolumeChanged() { + if (!localOutputVolumeChanging) { + localOutputVolume = AudioService.volume + } + } + } + + Connections { + target: AudioService.source?.audio ? AudioService.source?.audio : null + function onVolumeChanged() { + if (!localInputVolumeChanging) { + localInputVolume = AudioService.inputVolume + } + } + } + + // Timer to debounce volume changes + Timer { + interval: 100 + running: true + repeat: true + onTriggered: { + if (Math.abs(localOutputVolume - AudioService.volume) >= 0.01) { + AudioService.setVolume(localOutputVolume) + } + if (Math.abs(localInputVolume - AudioService.inputVolume) >= 0.01) { + AudioService.setInputVolume(localInputVolume) + } + } + } + + panelContent: Rectangle { + color: Color.transparent + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + // HEADER + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NIcon { + icon: "settings-audio" + pointSize: Style.fontSizeXXL + color: Color.mPrimary + } + + NText { + text: I18n.tr("settings.audio.title") + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NIconButton { + icon: AudioService.getOutputIcon() + tooltipText: I18n.tr("tooltips.output-devices") + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + AudioService.setOutputMuted(!AudioService.muted) + } + } + + NIconButton { + icon: AudioService.getInputIcon() + tooltipText: I18n.tr("tooltips.refresh-devices") + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + AudioService.setInputMuted(!AudioService.inputMuted) + } + } + + NIconButton { + icon: "close" + tooltipText: I18n.tr("tooltips.close") + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + root.close() + } + } + } + + NDivider { + Layout.fillWidth: true + } + + NScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + clip: true + contentWidth: availableWidth + + // AudioService Devices + ColumnLayout { + spacing: Style.marginS + Layout.fillWidth: true + + // ------------------------------- + // Output Devices + ButtonGroup { + id: sinks + } + + ColumnLayout { + spacing: 0 + Layout.fillWidth: true + Layout.bottomMargin: Style.marginL + + RowLayout { + spacing: Style.spacingM * Style.uiScaling + Layout.bottomMargin: Style.marginL + + NText { + text: I18n.tr("settings.audio.devices.output-device.label") + pointSize: Style.fontSizeL + color: Color.mPrimary + Layout.preferredWidth: root.preferredWidth * 0.3 + } + + // Output Volume Slider + NValueSlider { + Layout.fillWidth: true + Layout.maximumWidth: root.preferredWidth * 0.6 + from: 0 + to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0 + value: localOutputVolume + stepSize: 0.01 + heightRatio: 0.5 + onMoved: value => localOutputVolume = value + onPressedChanged: (pressed, value) => localOutputVolumeChanging = pressed + text: Math.round(localOutputVolume * 100) + "%" + } + } + + Repeater { + model: AudioService.sinks + NRadioButton { + ButtonGroup.group: sinks + required property PwNode modelData + pointSize: Style.fontSizeS + text: modelData.description + checked: AudioService.sink?.id === modelData.id + onClicked: { + AudioService.setAudioSink(modelData) + localVolume = AudioService.volume + } + Layout.fillWidth: true + } + } + } + + NDivider { + Layout.fillWidth: true + } + + // ------------------------------- + // Input Devices + ButtonGroup { + id: sources + } + + ColumnLayout { + spacing: 0 + Layout.fillWidth: true + + RowLayout { + spacing: Style.spacingM * Style.uiScaling + Layout.bottomMargin: Style.marginL + + NText { + text: I18n.tr("settings.audio.devices.input-device.label") + pointSize: Style.fontSizeL + color: Color.mPrimary + Layout.preferredWidth: root.preferredWidth * 0.3 + } + + // Input Volume Slider + NValueSlider { + Layout.fillWidth: true + Layout.maximumWidth: root.preferredWidth * 0.6 + from: 0 + to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0 + value: localInputVolume + stepSize: 0.01 + heightRatio: 0.5 + onMoved: value => localInputVolume = value + onPressedChanged: (pressed, value) => localInputVolumeChanging = pressed + text: Math.round(localInputVolume * 100) + "%" + } + } + + Repeater { + model: AudioService.sources + //Layout.fillWidth: true + NRadioButton { + ButtonGroup.group: sources + required property PwNode modelData + pointSize: Style.fontSizeS + text: modelData.description + checked: AudioService.source?.id === modelData.id + onClicked: AudioService.setAudioSource(modelData) + Layout.fillWidth: true + } + } + } + Item { + Layout.fillHeight: true + } + } + } + } + } +} diff --git a/Modules/Bar/Widgets/Microphone.qml b/Modules/Bar/Widgets/Microphone.qml index 6fe3d709..163a07fa 100644 --- a/Modules/Bar/Widgets/Microphone.qml +++ b/Modules/Bar/Widgets/Microphone.qml @@ -40,13 +40,6 @@ Item { implicitWidth: pill.width implicitHeight: pill.height - function getIcon() { - if (AudioService.inputMuted) { - return "microphone-mute" - } - return (AudioService.inputVolume <= Number.EPSILON) ? "microphone-mute" : "microphone" - } - // Connection used to open the pill when input volume changes Connections { target: AudioService.source?.audio ? AudioService.source?.audio : null @@ -90,7 +83,7 @@ Item { id: pill oppositeDirection: BarService.getPillDirection(root) - icon: getIcon() + icon: AudioService.getInputIcon() density: Settings.data.bar.density autoHide: false // Important to be false so we can hover as long as we want text: Math.round(AudioService.inputVolume * 100) diff --git a/Modules/Bar/Widgets/Volume.qml b/Modules/Bar/Widgets/Volume.qml index edf75b9d..c4afacee 100644 --- a/Modules/Bar/Widgets/Volume.qml +++ b/Modules/Bar/Widgets/Volume.qml @@ -40,13 +40,6 @@ Item { implicitWidth: pill.width implicitHeight: pill.height - function getIcon() { - if (AudioService.muted) { - return "volume-mute" - } - return (AudioService.volume <= Number.EPSILON) ? "volume-zero" : (AudioService.volume <= 0.5) ? "volume-low" : "volume-high" - } - // Connection used to open the pill when volume changes Connections { target: AudioService.sink?.audio ? AudioService.sink?.audio : null @@ -76,7 +69,7 @@ Item { density: Settings.data.bar.density oppositeDirection: BarService.getPillDirection(root) - icon: getIcon() + icon: AudioService.getOutputIcon() autoHide: false // Important to be false so we can hover as long as we want text: Math.round(AudioService.volume * 100) suffix: "%" @@ -100,9 +93,7 @@ Item { AudioService.setOutputMuted(!AudioService.muted) } onRightClicked: { - var settingsPanel = PanelService.getPanel("settingsPanel") - settingsPanel.requestedTab = SettingsPanel.Tab.Audio - settingsPanel.open() + PanelService.getPanel("audioPanel")?.toggle(this) } onMiddleClicked: { Quickshell.execDetached(["pwvucontrol"]) diff --git a/Services/AudioService.qml b/Services/AudioService.qml index 2fbb8630..52b47e50 100644 --- a/Services/AudioService.qml +++ b/Services/AudioService.qml @@ -148,4 +148,18 @@ Singleton { root._inputVolume = newSource?.audio?.volume ?? 0 root._inputMuted = !!newSource?.audio?.muted } + + function getOutputIcon() { + if (muted) { + return "volume-mute" + } + return (volume <= Number.EPSILON) ? "volume-zero" : (volume <= 0.5) ? "volume-low" : "volume-high" + } + + function getInputIcon() { + if (inputMuted) { + return "microphone-mute" + } + return (inputVolume <= Number.EPSILON) ? "microphone-mute" : "microphone" + } } diff --git a/Widgets/NRadioButton.qml b/Widgets/NRadioButton.qml index a6f8048c..d61b0719 100644 --- a/Widgets/NRadioButton.qml +++ b/Widgets/NRadioButton.qml @@ -7,11 +7,13 @@ import qs.Widgets RadioButton { id: root + property real pointSize: Style.fontSizeM + indicator: Rectangle { id: outerCircle - implicitWidth: Style.baseWidgetSize * 0.625 - implicitHeight: Style.baseWidgetSize * 0.625 + implicitWidth: Style.baseWidgetSize * 0.625 * pointSize / Style.fontSizeM + implicitHeight: Style.baseWidgetSize * 0.625 * pointSize / Style.fontSizeM radius: width * 0.5 color: Color.transparent border.color: root.checked ? Color.mPrimary : Color.mOnSurface @@ -41,7 +43,7 @@ RadioButton { contentItem: NText { text: root.text - pointSize: Style.fontSizeM + pointSize: root.pointSize anchors.verticalCenter: parent.verticalCenter anchors.left: outerCircle.right anchors.right: parent.right diff --git a/Widgets/NValueSlider.qml b/Widgets/NValueSlider.qml index 9449d699..19cb8e4c 100644 --- a/Widgets/NValueSlider.qml +++ b/Widgets/NValueSlider.qml @@ -47,7 +47,7 @@ RowLayout { pointSize: root.textSize family: Settings.data.ui.fontFixed Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: 45 * Scale.uiScaleRatio + Layout.preferredWidth: 45 * Style.uiScaleRatio horizontalAlignment: Text.AlignRight } } diff --git a/shell.qml b/shell.qml index 155ce72f..6cba46b7 100644 --- a/shell.qml +++ b/shell.qml @@ -27,6 +27,7 @@ import qs.Modules.SessionMenu // Bar & Bar Components import qs.Modules.Bar import qs.Modules.Bar.Extras +import qs.Modules.Bar.Audio import qs.Modules.Bar.Bluetooth import qs.Modules.Bar.Battery import qs.Modules.Bar.Calendar @@ -168,6 +169,11 @@ ShellRoot { objectName: "bluetoothPanel" } + AudioPanel { + id: audioPanel + objectName: "audioPanel" + } + WallpaperPanel { id: wallpaperPanel objectName: "wallpaperPanel"