mixer: add audio device selector

This commit is contained in:
end-4
2025-04-21 00:47:34 +02:00
parent 9164ad2471
commit eca98598cf
6 changed files with 389 additions and 98 deletions
@@ -121,7 +121,7 @@ Rectangle {
maskSource: Rectangle { maskSource: Rectangle {
width: swipeView.width width: swipeView.width
height: swipeView.height height: swipeView.height
radius: Appearance.rounding.normal radius: Appearance.rounding.small
} }
} }
@@ -140,7 +140,8 @@ Item {
NotificationStatusButton { NotificationStatusButton {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
Layout.topMargin: 5 Layout.margins: 5
Layout.topMargin: 10
buttonIcon: "clear_all" buttonIcon: "clear_all"
buttonText: "Clear" buttonText: "Clear"
onClicked: () => { onClicked: () => {
@@ -201,9 +201,9 @@ Item {
Item { Item {
anchors.fill: parent anchors.fill: parent
visible: false z: 9999
z: 1000
visible: opacity > 0
opacity: root.showAddDialog ? 1 : 0 opacity: root.showAddDialog ? 1 : 0
Behavior on opacity { Behavior on opacity {
NumberAnimation { NumberAnimation {
@@ -211,9 +211,6 @@ Item {
easing.type: Appearance.animation.elementDecelFast.type easing.type: Appearance.animation.elementDecelFast.type
} }
} }
onOpacityChanged: {
visible = opacity > 0
}
onVisibleChanged: { onVisibleChanged: {
if (!visible) { if (!visible) {
@@ -236,9 +233,12 @@ Item {
Rectangle { // The dialog Rectangle { // The dialog
id: 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 implicitHeight: dialogColumnLayout.implicitHeight
anchors.centerIn: parent
color: Appearance.m3colors.m3surfaceContainerHigh color: Appearance.m3colors.m3surfaceContainerHigh
radius: Appearance.rounding.normal radius: Appearance.rounding.normal
@@ -252,8 +252,8 @@ Item {
} }
ColumnLayout { ColumnLayout {
anchors.fill: parent
id: dialogColumnLayout id: dialogColumnLayout
anchors.fill: parent
spacing: 16 spacing: 16
StyledText { StyledText {
@@ -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
}
}
}
}
@@ -11,6 +11,30 @@ import Quickshell.Services.Pipewire
Item { Item {
id: root id: root
property bool showDeviceSelector: false
property bool deviceSelectorInput
property int dialogMargins: 16
property PwNode selectedDevice
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 { Flickable {
id: flickable id: flickable
anchors.fill: parent anchors.fill: parent
@@ -33,7 +57,7 @@ Item {
anchors.margins: 10 anchors.margins: 10
spacing: 10 spacing: 10
// get a list of nodes that output to the default sink // Get a list of nodes that output to the default sink
PwNodeLinkTracker { PwNodeLinkTracker {
id: linkTracker id: linkTracker
node: Pipewire.defaultAudioSink node: Pipewire.defaultAudioSink
@@ -43,8 +67,11 @@ Item {
model: linkTracker.linkGroups model: linkTracker.linkGroups
VolumeMixerEntry { VolumeMixerEntry {
Layout.fillWidth: true
// Get links to the default sinnk
required property PwLinkGroup modelData required property PwLinkGroup modelData
node: modelData.source // target = default sink, source = what we need // Consider sources that output to the default sink
node: modelData.source
} }
} }
} }
@@ -84,3 +111,196 @@ Item {
} }
} }
} }
// 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
}
}
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.verticalCenter: parent.verticalCenter
anchors.margins: 30
implicitHeight: dialogColumnLayout.implicitHeight
ColumnLayout {
id: dialogColumnLayout
anchors.fill: parent
spacing: 16
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`
}
Rectangle {
color: Appearance.m3colors.m3outline
implicitHeight: 1
Layout.fillWidth: true
Layout.leftMargin: dialogMargins
Layout.rightMargin: dialogMargins
}
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
}
}
}
}
}
}
}
}
}
@@ -8,12 +8,16 @@ import QtQuick.Layouts
import Quickshell.Widgets import Quickshell.Widgets
import Quickshell.Services.Pipewire import Quickshell.Services.Pipewire
Item {
RowLayout {
id: root id: root
required property PwNode node; required property PwNode node;
PwObjectTracker { objects: [ node ] } PwObjectTracker { objects: [ node ] }
implicitHeight: rowLayout.implicitHeight
RowLayout {
id: rowLayout
anchors.fill: parent
spacing: 10 spacing: 10
Image { Image {
@@ -22,7 +26,7 @@ RowLayout {
sourceSize.width: 50 sourceSize.width: 50
sourceSize.height: 50 sourceSize.height: 50
source: { source: {
const icon = node.properties["application.icon-name"] ?? "audio-volume-high-symbolic"; const icon = root.node.properties["application.icon-name"] ?? "audio-volume-high-symbolic";
return `image://icon/${icon}`; return `image://icon/${icon}`;
} }
} }
@@ -36,8 +40,8 @@ RowLayout {
elide: Text.ElideRight elide: Text.ElideRight
text: { text: {
// application.name -> description -> name // application.name -> description -> name
const app = node.properties["application.name"] ?? (node.description != "" ? node.description : node.name); const app = root.node.properties["application.name"] ?? (root.node.description != "" ? root.node.description : root.node.name);
const media = node.properties["media.name"]; const media = root.node.properties["media.name"];
return media != undefined ? `${app} ${media}` : app; return media != undefined ? `${app} ${media}` : app;
} }
} }
@@ -45,8 +49,10 @@ RowLayout {
RowLayout { RowLayout {
StyledSlider { StyledSlider {
value: node.audio.volume id: slider
onValueChanged: node.audio.volume = value value: root.node.audio.volume
onValueChanged: root.node.audio.volume = value
}
} }
} }
} }