import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell.Services.Pipewire import qs.Components import qs.Settings PanelWithOverlay { id: ioSelector signal closed() property int tabIndex: 0 property Item anchorItem: null // Bind all Pipewire nodes so their properties are valid PwObjectTracker { id: nodeTracker objects: Pipewire.nodes } Rectangle { color: Theme.backgroundPrimary radius: 20 width: 340 height: 340 anchors.top: parent.top anchors.right: parent.right anchors.topMargin: 4 anchors.rightMargin: 4 ColumnLayout { anchors.fill: parent anchors.margins: 16 spacing: 10 // Tabs centered inside the window RowLayout { Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter spacing: 0 Tabs { id: ioTabs tabsModel: [ { label: "Output", icon: "volume_up" }, { label: "Input", icon: "mic" } ] currentIndex: tabIndex onTabChanged: { tabIndex = currentIndex; } } } // Add vertical space between tabs and entries Item { height: 36; Layout.fillWidth: true } // Output Devices Flickable { id: sinkList visible: tabIndex === 0 contentHeight: sinkColumn.height clip: true interactive: contentHeight > height width: parent.width height: 220 ScrollBar.vertical: ScrollBar {} ColumnLayout { id: sinkColumn width: sinkList.width spacing: 6 Repeater { model: ioSelector.sinkNodes() Rectangle { width: parent.width height: 36 color: "transparent" radius: 6 RowLayout { anchors.fill: parent anchors.margins: 6 spacing: 8 Text { text: "volume_up" font.family: "Material Symbols Outlined" font.pixelSize: 16 color: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary Layout.alignment: Qt.AlignVCenter } ColumnLayout { Layout.fillWidth: true spacing: 1 Text { text: modelData.nickname || modelData.description || modelData.name font.bold: true font.pixelSize: 12 color: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary elide: Text.ElideRight } Text { text: modelData.description !== modelData.nickname ? modelData.description : "" font.pixelSize: 10 color: Theme.textSecondary elide: Text.ElideRight } } Item { Layout.fillWidth: true } Rectangle { visible: Pipewire.preferredDefaultAudioSink !== modelData width: 60; height: 20 radius: 4 color: Theme.accentPrimary border.color: Theme.accentPrimary border.width: 1 Layout.alignment: Qt.AlignVCenter Text { anchors.centerIn: parent text: "Set" color: Theme.onAccent font.pixelSize: 10 font.bold: true } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Pipewire.preferredDefaultAudioSink = modelData } } Text { text: "(Current)" visible: Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === modelData.id color: Theme.accentPrimary font.pixelSize: 10 Layout.alignment: Qt.AlignVCenter } } } } } } // Input Devices Flickable { id: sourceList visible: tabIndex === 1 contentHeight: sourceColumn.height clip: true interactive: contentHeight > height width: parent.width height: 220 ScrollBar.vertical: ScrollBar {} ColumnLayout { id: sourceColumn width: sourceList.width spacing: 6 Repeater { model: ioSelector.sourceNodes() Rectangle { width: parent.width height: 36 color: "transparent" radius: 6 RowLayout { anchors.fill: parent anchors.margins: 6 spacing: 8 Text { text: "mic" font.family: "Material Symbols Outlined" font.pixelSize: 16 color: (Pipewire.defaultAudioSource && Pipewire.defaultAudioSource.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary Layout.alignment: Qt.AlignVCenter } ColumnLayout { Layout.fillWidth: true spacing: 1 Text { text: modelData.nickname || modelData.description || modelData.name font.bold: true font.pixelSize: 12 color: (Pipewire.defaultAudioSource && Pipewire.defaultAudioSource.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary elide: Text.ElideRight } Text { text: modelData.description !== modelData.nickname ? modelData.description : "" font.pixelSize: 10 color: Theme.textSecondary elide: Text.ElideRight } } Item { Layout.fillWidth: true } Rectangle { visible: Pipewire.preferredDefaultAudioSource !== modelData width: 60; height: 20 radius: 4 color: Theme.accentPrimary border.color: Theme.accentPrimary border.width: 1 Layout.alignment: Qt.AlignVCenter Text { anchors.centerIn: parent text: "Set" color: Theme.onAccent font.pixelSize: 10 font.bold: true } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Pipewire.preferredDefaultAudioSource = modelData } } Text { text: "(Current)" visible: Pipewire.defaultAudioSource && Pipewire.defaultAudioSource.id === modelData.id color: Theme.accentPrimary font.pixelSize: 10 Layout.alignment: Qt.AlignVCenter } } } } } } } } function sinkNodes() { let nodes = Pipewire.nodes && Pipewire.nodes.values ? Pipewire.nodes.values.filter(function(n) { return n.isSink && n.audio }) : []; if (Pipewire.defaultAudioSink) { nodes = nodes.slice().sort(function(a, b) { if (a.id === Pipewire.defaultAudioSink.id) return -1; if (b.id === Pipewire.defaultAudioSink.id) return 1; return 0; }); } return nodes; } function sourceNodes() { let nodes = Pipewire.nodes && Pipewire.nodes.values ? Pipewire.nodes.values.filter(function(n) { return !n.isSink && n.audio }) : []; if (Pipewire.defaultAudioSource) { nodes = nodes.slice().sort(function(a, b) { if (a.id === Pipewire.defaultAudioSource.id) return -1; if (b.id === Pipewire.defaultAudioSource.id) return 1; return 0; }); } return nodes; } Component.onCompleted: { if (Pipewire.nodes && Pipewire.nodes.values) { for (var i = 0; i < Pipewire.nodes.values.length; ++i) { var n = Pipewire.nodes.values[i]; } } } Connections { target: Pipewire function onReadyChanged() { if (Pipewire.ready && Pipewire.nodes && Pipewire.nodes.values) { for (var i = 0; i < Pipewire.nodes.values.length; ++i) { var n = Pipewire.nodes.values[i]; } } } function onDefaultAudioSinkChanged() { } function onDefaultAudioSourceChanged() { } } Component.onDestruction: { } onVisibleChanged: { if (!visible) panelClosed(); } }