From eca98598cf29acf32a532189a64ce8d098a74967 Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Mon, 21 Apr 2025 00:47:34 +0200 Subject: [PATCH] mixer: add audio device selector --- .../sidebarRight/CenterWidgetGroup.qml | 2 +- .../notifications/NotificationList.qml | 3 +- .../modules/sidebarRight/todo/TodoWidget.qml | 16 +- .../volumeMixer/AudioDeviceSelectorButton.qml | 64 ++++ .../sidebarRight/volumeMixer/VolumeMixer.qml | 336 +++++++++++++++--- .../volumeMixer/VolumeMixerEntry.qml | 66 ++-- 6 files changed, 389 insertions(+), 98 deletions(-) create mode 100644 .config/quickshell/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml diff --git a/.config/quickshell/modules/sidebarRight/CenterWidgetGroup.qml b/.config/quickshell/modules/sidebarRight/CenterWidgetGroup.qml index 2322a505d..7fe0fd29d 100644 --- a/.config/quickshell/modules/sidebarRight/CenterWidgetGroup.qml +++ b/.config/quickshell/modules/sidebarRight/CenterWidgetGroup.qml @@ -121,7 +121,7 @@ Rectangle { maskSource: Rectangle { width: swipeView.width height: swipeView.height - radius: Appearance.rounding.normal + radius: Appearance.rounding.small } } diff --git a/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml b/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml index e5b6465ea..c04015b0e 100644 --- a/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml +++ b/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml @@ -140,7 +140,8 @@ Item { NotificationStatusButton { Layout.alignment: Qt.AlignVCenter - Layout.topMargin: 5 + Layout.margins: 5 + Layout.topMargin: 10 buttonIcon: "clear_all" buttonText: "Clear" onClicked: () => { diff --git a/.config/quickshell/modules/sidebarRight/todo/TodoWidget.qml b/.config/quickshell/modules/sidebarRight/todo/TodoWidget.qml index 0fbc927a2..d473eda2d 100644 --- a/.config/quickshell/modules/sidebarRight/todo/TodoWidget.qml +++ b/.config/quickshell/modules/sidebarRight/todo/TodoWidget.qml @@ -201,9 +201,9 @@ Item { Item { anchors.fill: parent - visible: false - z: 1000 + z: 9999 + visible: opacity > 0 opacity: root.showAddDialog ? 1 : 0 Behavior on opacity { NumberAnimation { @@ -211,9 +211,6 @@ Item { easing.type: Appearance.animation.elementDecelFast.type } } - onOpacityChanged: { - visible = opacity > 0 - } onVisibleChanged: { if (!visible) { @@ -236,9 +233,12 @@ Item { Rectangle { // The dialog id: dialog - implicitWidth: parent.width - dialogMargins * 2 + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: root.dialogMargins implicitHeight: dialogColumnLayout.implicitHeight - anchors.centerIn: parent + color: Appearance.m3colors.m3surfaceContainerHigh radius: Appearance.rounding.normal @@ -252,8 +252,8 @@ Item { } ColumnLayout { - anchors.fill: parent id: dialogColumnLayout + anchors.fill: parent spacing: 16 StyledText { diff --git a/.config/quickshell/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml b/.config/quickshell/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml new file mode 100644 index 000000000..fc2dd17e7 --- /dev/null +++ b/.config/quickshell/modules/sidebarRight/volumeMixer/AudioDeviceSelectorButton.qml @@ -0,0 +1,64 @@ +import "root:/modules/common" +import "root:/modules/common/widgets" +import "root:/services" +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Widgets +import Quickshell.Services.Pipewire + +Button { + id: button + required property bool input + + background: Rectangle { + anchors.fill: parent + radius: Appearance.rounding.small + color: (button.down) ? Appearance.colors.colLayer2Active : (button.hovered ? Appearance.colors.colLayer2Hover : Appearance.colors.colLayer2) + + Behavior on color { + ColorAnimation { + duration: Appearance.animation.elementDecel.duration + easing.type: Appearance.animation.elementDecel.type + } + + } + } + + PointingHandInteraction {} + + contentItem: RowLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: false + Layout.leftMargin: 5 + font.pixelSize: Appearance.font.pixelSize.hugeass + text: input ? "mic_external_on" : "media_output" + } + + ColumnLayout { + Layout.fillWidth: true + Layout.rightMargin: 5 + spacing: 0 + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.normal + text: input ? "Input" : "Output" + color: Appearance.colors.colOnLayer2 + } + StyledText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: Appearance.font.pixelSize.smaller + text: input ? Pipewire.defaultAudioSource?.description : Pipewire.defaultAudioSink?.description + color: Appearance.m3colors.m3outline + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixer.qml b/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixer.qml index 0c24fed9f..447422e43 100644 --- a/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixer.qml +++ b/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixer.qml @@ -11,76 +11,296 @@ import Quickshell.Services.Pipewire Item { id: root - Flickable { - id: flickable - anchors.fill: parent - contentHeight: volumeMixerColumnLayout.height + property bool showDeviceSelector: false + property bool deviceSelectorInput + property int dialogMargins: 16 + property PwNode selectedDevice - layer.enabled: true - layer.effect: OpacityMask { - maskSource: Rectangle { - width: flickable.width - height: flickable.height - radius: Appearance.rounding.normal + function showDeviceSelectorDialog(input) { + root.selectedDevice = null + root.showDeviceSelector = true + root.deviceSelectorInput = input + } + + Keys.onPressed: (event) => { + // Close dialog on pressing Esc if open + if (event.key === Qt.Key_Escape && root.showDeviceSelector) { + root.showDeviceSelector = false + event.accepted = true; + } + } + + ColumnLayout { + anchors.fill: parent + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Flickable { + id: flickable + anchors.fill: parent + contentHeight: volumeMixerColumnLayout.height + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: flickable.width + height: flickable.height + radius: Appearance.rounding.normal + } + } + + ColumnLayout { + id: volumeMixerColumnLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 10 + spacing: 10 + + // Get a list of nodes that output to the default sink + PwNodeLinkTracker { + id: linkTracker + node: Pipewire.defaultAudioSink + } + + Repeater { + model: linkTracker.linkGroups + + VolumeMixerEntry { + Layout.fillWidth: true + // Get links to the default sinnk + required property PwLinkGroup modelData + // Consider sources that output to the default sink + node: modelData.source + } + } + } + } + + // Placeholder when list is empty + Item { + anchors.fill: flickable + + visible: opacity > 0 + opacity: (linkTracker.linkGroups.length === 0) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.menuDecel.duration + easing.type: Appearance.animation.menuDecel.type + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 5 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: 55 + color: Appearance.m3colors.m3outline + text: "brand_awareness" + } + StyledText { + Layout.alignment: Qt.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.m3colors.m3outline + horizontalAlignment: Text.AlignHCenter + text: "No audio source" + } + } + } + } + // Device selector + RowLayout { + id: deviceSelectorRowLayout + Layout.fillWidth: true + Layout.fillHeight: false + AudioDeviceSelectorButton { + Layout.fillWidth: true + input: false + onClicked: root.showDeviceSelectorDialog(input) + } + AudioDeviceSelectorButton { + Layout.fillWidth: true + input: true + onClicked: root.showDeviceSelectorDialog(input) + } + } + } + + // Device selector dialog + Item { + anchors.fill: parent + z: 9999 + + visible: opacity > 0 + opacity: root.showDeviceSelector ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementDecelFast.duration + easing.type: Appearance.animation.elementDecelFast.type } } - ColumnLayout { - id: volumeMixerColumnLayout - anchors.top: parent.top + Rectangle { // Scrim + id: scrimOverlay + anchors.fill: parent + radius: Appearance.rounding.small + color: Appearance.colors.colScrim + MouseArea { + hoverEnabled: true + anchors.fill: parent + preventStealing: true + propagateComposedEvents: false + } + } + + Rectangle { // The dialog + id: dialog + color: Appearance.m3colors.m3surfaceContainerHigh + radius: Appearance.rounding.normal anchors.left: parent.left anchors.right: parent.right - anchors.margins: 10 - spacing: 10 + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 30 + implicitHeight: dialogColumnLayout.implicitHeight + + ColumnLayout { + id: dialogColumnLayout + anchors.fill: parent + spacing: 16 - // get a list of nodes that output to the default sink - PwNodeLinkTracker { - id: linkTracker - node: Pipewire.defaultAudioSink - } + StyledText { + id: dialogTitle + Layout.topMargin: dialogMargins + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + Layout.alignment: Qt.AlignLeft + color: Appearance.m3colors.m3onSurface + font.pixelSize: Appearance.font.pixelSize.larger + text: `Select ${root.deviceSelectorInput ? "input" : "output"} device` + } - Repeater { - model: linkTracker.linkGroups + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + } - VolumeMixerEntry { - required property PwLinkGroup modelData - node: modelData.source // target = default sink, source = what we need + Flickable { + id: dialogFlickable + Layout.fillWidth: true + clip: true + implicitHeight: Math.min(scrimOverlay.height - dialogMargins * 8 - dialogTitle.height - dialogButtonsRowLayout.height, devicesColumnLayout.implicitHeight) + + contentHeight: devicesColumnLayout.implicitHeight + + ColumnLayout { + id: devicesColumnLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + Layout.fillWidth: true + + Repeater { + model: Pipewire.nodes.values.filter(node => { + return !node.isStream && node.isSink !== root.deviceSelectorInput && node.audio + }) + + delegate: RadioButton { + Layout.leftMargin: root.dialogMargins + Layout.rightMargin: root.dialogMargins + Layout.fillWidth: true + leftInset: 4 + rightInset: 4 + topInset: 4 + bottomInset: 4 + checked: modelData.id === Pipewire.defaultAudioSink.id + + onCheckedChanged: { + if (checked) { + root.selectedDevice = modelData + } + } + + indicator: Item{} + + contentItem: RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { + id: radio + Layout.fillWidth: false + Layout.alignment: Qt.AlignVCenter + width: 20 + height: 20 + radius: 10 + border.color: checked ? Appearance.m3colors.m3primary : Appearance.m3colors.m3outline + border.width: 2 + color: "transparent" + + Rectangle { + anchors.centerIn: parent + width: 10 + height: 10 + radius: 5 + color: checked ? Appearance.m3colors.m3primary : "transparent" + visible: checked + } + } + StyledText { + text: modelData.description + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Appearance.m3colors.m3onSurface + } + } + } + } + } + } + + Rectangle { + color: Appearance.m3colors.m3outline + implicitHeight: 1 + Layout.fillWidth: true + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + } + + RowLayout { + id: dialogButtonsRowLayout + Layout.bottomMargin: dialogMargins + Layout.leftMargin: dialogMargins + Layout.rightMargin: dialogMargins + Layout.alignment: Qt.AlignRight + + DialogButton { + buttonText: "Cancel" + onClicked: { + root.showDeviceSelector = false + } + } + DialogButton { + buttonText: "OK" + onClicked: { + root.showDeviceSelector = false + if (root.selectedDevice) { + if (root.deviceSelectorInput) { + Pipewire.preferredDefaultAudioSource = root.selectedDevice + } else { + Pipewire.preferredDefaultAudioSink = root.selectedDevice + } + } + } + } } } } } - // Placeholder when list is empty - Item { - anchors.fill: flickable - - visible: opacity > 0 - opacity: (linkTracker.linkGroups.length === 0) ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: Appearance.animation.menuDecel.duration - easing.type: Appearance.animation.menuDecel.type - } - } - - ColumnLayout { - anchors.centerIn: parent - spacing: 5 - - MaterialSymbol { - Layout.alignment: Qt.AlignHCenter - font.pixelSize: 55 - color: Appearance.m3colors.m3outline - text: "brand_awareness" - } - StyledText { - Layout.alignment: Qt.AlignHCenter - font.pixelSize: Appearance.font.pixelSize.normal - color: Appearance.m3colors.m3outline - horizontalAlignment: Text.AlignHCenter - text: "No audio source" - } - } - } } \ No newline at end of file diff --git a/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml b/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml index 79975135e..ec4e6f6c2 100644 --- a/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml +++ b/.config/quickshell/modules/sidebarRight/volumeMixer/VolumeMixerEntry.qml @@ -8,45 +8,51 @@ import QtQuick.Layouts import Quickshell.Widgets import Quickshell.Services.Pipewire - -RowLayout { +Item { id: root required property PwNode node; PwObjectTracker { objects: [ node ] } - spacing: 10 + implicitHeight: rowLayout.implicitHeight - Image { - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - visible: source != "" - sourceSize.width: 50 - sourceSize.height: 50 - source: { - const icon = node.properties["application.icon-name"] ?? "audio-volume-high-symbolic"; - return `image://icon/${icon}`; - } - } + RowLayout { + id: rowLayout + anchors.fill: parent + spacing: 10 - ColumnLayout { - Layout.fillWidth: true - RowLayout { - StyledText { - Layout.fillWidth: true - font.pixelSize: Appearance.font.pixelSize.normal - elide: Text.ElideRight - text: { - // application.name -> description -> name - const app = node.properties["application.name"] ?? (node.description != "" ? node.description : node.name); - const media = node.properties["media.name"]; - return media != undefined ? `${app} • ${media}` : app; - } + Image { + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + visible: source != "" + sourceSize.width: 50 + sourceSize.height: 50 + source: { + const icon = root.node.properties["application.icon-name"] ?? "audio-volume-high-symbolic"; + return `image://icon/${icon}`; } } - RowLayout { - StyledSlider { - value: node.audio.volume - onValueChanged: node.audio.volume = value + ColumnLayout { + Layout.fillWidth: true + RowLayout { + StyledText { + Layout.fillWidth: true + font.pixelSize: Appearance.font.pixelSize.normal + elide: Text.ElideRight + text: { + // application.name -> description -> name + const app = root.node.properties["application.name"] ?? (root.node.description != "" ? root.node.description : root.node.name); + const media = root.node.properties["media.name"]; + return media != undefined ? `${app} • ${media}` : app; + } + } + } + + RowLayout { + StyledSlider { + id: slider + value: root.node.audio.volume + onValueChanged: root.node.audio.volume = value + } } } }