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 {
width: swipeView.width
height: swipeView.height
radius: Appearance.rounding.normal
radius: Appearance.rounding.small
}
}
@@ -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: () => {
@@ -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 {
@@ -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 {
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"
}
}
}
}
@@ -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
}
}
}
}