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,76 +11,296 @@ import Quickshell.Services.Pipewire
Item { Item {
id: root id: root
Flickable { property bool showDeviceSelector: false
id: flickable property bool deviceSelectorInput
anchors.fill: parent property int dialogMargins: 16
contentHeight: volumeMixerColumnLayout.height property PwNode selectedDevice
layer.enabled: true function showDeviceSelectorDialog(input) {
layer.effect: OpacityMask { root.selectedDevice = null
maskSource: Rectangle { root.showDeviceSelector = true
width: flickable.width root.deviceSelectorInput = input
height: flickable.height }
radius: Appearance.rounding.normal
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 { Rectangle { // Scrim
id: volumeMixerColumnLayout id: scrimOverlay
anchors.top: parent.top 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.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.margins: 10 anchors.verticalCenter: parent.verticalCenter
spacing: 10 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 StyledText {
PwNodeLinkTracker { id: dialogTitle
id: linkTracker Layout.topMargin: dialogMargins
node: Pipewire.defaultAudioSink 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 { Rectangle {
model: linkTracker.linkGroups color: Appearance.m3colors.m3outline
implicitHeight: 1
Layout.fillWidth: true
Layout.leftMargin: dialogMargins
Layout.rightMargin: dialogMargins
}
VolumeMixerEntry { Flickable {
required property PwLinkGroup modelData id: dialogFlickable
node: modelData.source // target = default sink, source = what we need 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"
}
}
}
} }
@@ -8,45 +8,51 @@ 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 ] }
spacing: 10 implicitHeight: rowLayout.implicitHeight
Image { RowLayout {
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter id: rowLayout
visible: source != "" anchors.fill: parent
sourceSize.width: 50 spacing: 10
sourceSize.height: 50
source: {
const icon = node.properties["application.icon-name"] ?? "audio-volume-high-symbolic";
return `image://icon/${icon}`;
}
}
ColumnLayout { Image {
Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
RowLayout { visible: source != ""
StyledText { sourceSize.width: 50
Layout.fillWidth: true sourceSize.height: 50
font.pixelSize: Appearance.font.pixelSize.normal source: {
elide: Text.ElideRight const icon = root.node.properties["application.icon-name"] ?? "audio-volume-high-symbolic";
text: { return `image://icon/${icon}`;
// 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;
}
} }
} }
RowLayout { ColumnLayout {
StyledSlider { Layout.fillWidth: true
value: node.audio.volume RowLayout {
onValueChanged: node.audio.volume = value 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
}
} }
} }
} }