From 5d4e55a9c15810f34ea6c379051353672c3eb9fd Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Mon, 26 Jan 2026 18:52:04 +0100 Subject: [PATCH] volume tabs --- Bar.qml | 2 +- Daemons/Audio.qml | 113 ++++++++++ Helpers/Network.qml | 11 + Modules/AudioPopup.qml | 349 +++++++++++++++++++----------- Modules/Bar/BarLoader.qml | 1 + Modules/Network/NetworkPopout.qml | 28 ++- Modules/TrayItem.qml | 12 +- Modules/TrayWidget.qml | 4 + 8 files changed, 391 insertions(+), 129 deletions(-) create mode 100644 Helpers/Network.qml diff --git a/Bar.qml b/Bar.qml index 50c12d3..50e10ae 100644 --- a/Bar.qml +++ b/Bar.qml @@ -56,7 +56,7 @@ Scope { height: bar.screen.height - backgroundRect.implicitHeight intersection: Intersection.Xor - regions: popoutRegions.instances + regions: panels.popouts.hasCurrent ? None : popoutRegions.instances } Variants { diff --git a/Daemons/Audio.qml b/Daemons/Audio.qml index 9e019f8..c610eae 100644 --- a/Daemons/Audio.qml +++ b/Daemons/Audio.qml @@ -74,6 +74,13 @@ Singleton { Pipewire.preferredDefaultAudioSource = newSource; } + function setAppAudioVolume(appStream: PwNode, newVolume: real): void { + if ( appStream?.ready && appStream?.audio ) { + appStream.audio.muted = false; + appStream.audio.volume = Math.max(0, Math.min(100, newVolume)); + } + } + onSinkChanged: { if (!sink?.ready) return; @@ -100,4 +107,110 @@ Singleton { PwObjectTracker { objects: [...root.sinks, ...root.sources] } + + PwNodeLinkTracker { + id: sinkLinkTracker + node: root.sink + } + + PwObjectTracker { + objects: root.appStreams + } + + readonly property var appStreams: { + var defaultSink = root.sink; + var defaultSinkId = defaultSink.id; + var connectedStreamIds = {}; + var connectedStreams = []; + + if ( !sinkLinkTracker.linkGroups ) { + return []; + } + + var linkGroupsCount = 0; + if (sinkLinkTracker.linkGroups.length !== undefined) { + linkGroupsCount = sinkLinkTracker.linkGroups.length; + } else if (sinkLinkTracker.linkGroups.count !== undefined) { + linkGroupsCount = sinkLinkTracker.linkGroups.count; + } else { + return []; + } + + if ( linkGroupsCount === 0 ) { + return []; + } + + var intermediateNodeIds = {}; + var nodesToCheck = []; + + for (var i = 0; i < linkGroupsCount; i++) { + var linkGroup; + if (sinkLinkTracker.linkGroups.get) { + linkGroup = sinkLinkTracker.linkGroups.get(i); + } else { + linkGroup = sinkLinkTracker.linkGroups[i]; + } + + if (!linkGroup || !linkGroup.source) { + continue; + } + + var sourceNode = linkGroup.source; + + if (sourceNode.isStream && sourceNode.audio) { + if (!connectedStreamIds[sourceNode.id]) { + connectedStreamIds[sourceNode.id] = true; + connectedStreams.push(sourceNode); + } + } else { + intermediateNodeIds[sourceNode.id] = true; + nodesToCheck.push(sourceNode); + } + } + + if (nodesToCheck.length > 0 || connectedStreams.length === 0) { + try { + var allNodes = []; + if (Pipewire.nodes) { + if (Pipewire.nodes.count !== undefined) { + var nodeCount = Pipewire.nodes.count; + for (var n = 0; n < nodeCount; n++) { + var node; + if (Pipewire.nodes.get) { + node = Pipewire.nodes.get(n); + } else { + node = Pipewire.nodes[n]; + } + if (node) + allNodes.push(node); + } + } else if (Pipewire.nodes.values) { + allNodes = Pipewire.nodes.values; + } + } + + for (var j = 0; j < allNodes.length; j++) { + var node = allNodes[j]; + if (!node || !node.isStream || !node.audio) { + continue; + } + + var streamId = node.id; + if (connectedStreamIds[streamId]) { + continue; + } + + if (Object.keys(intermediateNodeIds).length > 0) { + connectedStreamIds[streamId] = true; + connectedStreams.push(node); + } else if (connectedStreams.length === 0) { + connectedStreamIds[streamId] = true; + connectedStreams.push(node); + } + } + } catch (e) + {} + } + return connectedStreams; + } } diff --git a/Helpers/Network.qml b/Helpers/Network.qml new file mode 100644 index 0000000..f04c9e5 --- /dev/null +++ b/Helpers/Network.qml @@ -0,0 +1,11 @@ +pragma Singleton + +import Quickshell +import Quickshell.Networking + +Singleton { + id: root + + property list devices: Networking.devices + property NetworkDevice activeDevice: devices.find(d => d.connected) +} diff --git a/Modules/AudioPopup.qml b/Modules/AudioPopup.qml index 6ec691e..5e315f5 100644 --- a/Modules/AudioPopup.qml +++ b/Modules/AudioPopup.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import Quickshell import Quickshell.Services.Pipewire +import Quickshell.Widgets import QtQuick import QtQuick.Layouts import QtQuick.Controls @@ -17,148 +18,246 @@ Item { required property var wrapper - ButtonGroup { - id: sinks - } - - ButtonGroup { - id: sources - } - ColumnLayout { id: layout anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter - spacing: 12 + implicitWidth: stack.currentItem ? stack.currentItem.childrenRect.height : 0 + spacing: 12 - CustomText { - text: qsTr("Output device") - font.weight: 500 - } + RowLayout { + id: tabBar + spacing: 6 + Layout.fillWidth: true + property int tabHeight: 36 - Repeater { - model: Audio.sinks + CustomClippingRect { + radius: 6 + Layout.fillWidth: true + Layout.preferredHeight: tabBar.tabHeight - CustomRadioButton { - id: control + color: stack.currentIndex === 0 ? DynamicColors.tPalette.m3primaryContainer : "transparent" - required property PwNode modelData + StateLayer { - ButtonGroup.group: sinks - checked: Audio.sink?.id === modelData.id - onClicked: Audio.setAudioSink(modelData) - text: modelData.description - } - } + function onClicked(): void { + stack.currentIndex = 0; + } - CustomText { - Layout.topMargin: 10 - text: qsTr("Input device") - font.weight: 500 - } + CustomText { + text: qsTr("Volumes") + anchors.centerIn: parent + } + } + } - Repeater { - model: Audio.sources + CustomClippingRect { + radius: 6 + Layout.fillWidth: true + Layout.preferredHeight: tabBar.tabHeight - CustomRadioButton { - required property PwNode modelData + color: stack.currentIndex === 1 ? DynamicColors.tPalette.m3primaryContainer : "transparent" - ButtonGroup.group: sources - checked: Audio.source?.id === modelData.id - onClicked: Audio.setAudioSource(modelData) - text: modelData.description - } - } + StateLayer { - CustomText { - Layout.topMargin: 10 - Layout.bottomMargin: -7 / 2 - text: qsTr("Output Volume (%1)").arg(Audio.muted ? qsTr("Muted") : `${Math.round(Audio.volume * 100)}%`) - font.weight: 500 - } + function onClicked(): void { + stack.currentIndex = 1; + } - CustomMouseArea { - Layout.fillWidth: true - implicitHeight: 10 + CustomText { + text: qsTr("Devices") + anchors.centerIn: parent + } + } + } + } - CustomSlider { - anchors.left: parent.left - anchors.right: parent.right - implicitHeight: parent.implicitHeight + StackLayout { + id: stack + Layout.fillWidth: true + currentIndex: 0 - value: Audio.volume - onMoved: Audio.setVolume(value) - - Behavior on value { - Anim {} - } - } - } - - CustomText { - Layout.topMargin: 10 - Layout.bottomMargin: -7 / 2 - text: qsTr("Input Volume (%1)").arg(Audio.sourceMuted ? qsTr("Muted") : `${Math.round(Audio.sourceVolume * 100)}%`) - font.weight: 500 - } - - CustomMouseArea { - Layout.fillWidth: true - implicitHeight: 10 - - CustomSlider { - anchors.left: parent.left - anchors.right: parent.right - implicitHeight: parent.implicitHeight - - value: Audio.sourceVolume - onMoved: Audio.setSourceVolume(value) - - Behavior on value { - Anim {} - } - } - } - - CustomRect { - Layout.topMargin: 12 - visible: true - - implicitWidth: expandBtn.implicitWidth + 10 * 2 - implicitHeight: expandBtn.implicitHeight + 5 - - radius: 4 - color: DynamicColors.palette.m3primaryContainer - - StateLayer { - color: DynamicColors.palette.m3onPrimaryContainer - - function onClicked(): void { - Quickshell.execDetached(["app2unit", "--", "hyprpwcenter"]); - root.wrapper.hasCurrent = false; - } - } - - RowLayout { - id: expandBtn - - anchors.centerIn: parent - spacing: 7 - - CustomText { - Layout.leftMargin: 7 - text: qsTr("Open settings") - color: DynamicColors.palette.m3onPrimaryContainer - } - - MaterialIcon { - Layout.topMargin: 2 - text: "chevron_right" - color: DynamicColors.palette.m3onPrimaryContainer - font.pointSize: 18 - } - } - } + VolumesTab {} + DevicesTab {} + } } + + component VolumesTab: ColumnLayout { + spacing: 12 + + CustomText { + text: qsTr("Output Volume (%1)") + .arg(Audio.muted + ? qsTr("Muted") + : `${Math.round(Audio.volume * 100)}%`) + font.weight: 500 + } + + CustomMouseArea { + Layout.fillWidth: true + implicitHeight: 10 + + CustomSlider { + anchors.fill: parent + value: Audio.volume + onMoved: Audio.setVolume(value) + + Behavior on value { Anim {} } + } + } + + CustomText { + Layout.topMargin: 10 + text: qsTr("Input Volume (%1)") + .arg(Audio.sourceMuted + ? qsTr("Muted") + : `${Math.round(Audio.sourceVolume * 100)}%`) + font.weight: 500 + } + + CustomMouseArea { + Layout.fillWidth: true + implicitHeight: 10 + + CustomSlider { + anchors.fill: parent + value: Audio.sourceVolume + onMoved: Audio.setSourceVolume(value) + + Behavior on value { Anim {} } + } + } + + Repeater { + model: Audio.appStreams + + Item { + id: appBox + + Layout.topMargin: 10 + Layout.fillWidth: true + Layout.preferredHeight: 42 + visible: !isCaptureStream + required property PwNode modelData + + PwObjectTracker { + objects: appBox.modelData ? [appBox.modelData] : [] + } + + readonly property bool isCaptureStream: { + if (!modelData || !modelData.properties) + return false; + const props = modelData.properties; + // Exclude capture streams - check for stream.capture.sink property + if (props["stream.capture.sink"] !== undefined) { + return true; + } + const mediaClass = props["media.class"] || ""; + // Exclude Stream/Input (capture) but allow Stream/Output (playback) + if (mediaClass.includes("Capture") || mediaClass === "Stream/Input" || mediaClass === "Stream/Input/Audio") { + return true; + } + const mediaRole = props["media.role"] || ""; + if (mediaRole === "Capture") { + return true; + } + return false; + } + + RowLayout { + id: layoutVolume + anchors.fill: parent + spacing: 15 + + IconImage { + property string iconPath: Quickshell.iconPath(DesktopEntries.byId(appBox.modelData.name).icon) + source: iconPath !== "" ? iconPath : Quickshell.iconPath("application-x-executable") + Layout.alignment: Qt.AlignVCenter + implicitSize: 42 + } + + ColumnLayout { + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.fillHeight: true + + TextMetrics { + id: metrics + text: appBox.modelData.properties["media.name"] + elide: Text.ElideRight + elideWidth: root.width - 50 + } + + CustomText { + text: metrics.elidedText + elide: Text.ElideRight + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + } + + CustomMouseArea { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + Layout.bottomMargin: 5 + implicitHeight: 10 + CustomSlider { + anchors.fill: parent + value: appBox.modelData.audio.volume + onMoved: { + Audio.setAppAudioVolume(appBox.modelData, value) + console.log(layoutVolume.implicitHeight) + } + } + } + } + } + } + } + } + + component DevicesTab: ColumnLayout { + spacing: 12 + + ButtonGroup { id: sinks } + ButtonGroup { id: sources } + + CustomText { + text: qsTr("Output device") + font.weight: 500 + } + + Repeater { + model: Audio.sinks + + CustomRadioButton { + required property PwNode modelData + + ButtonGroup.group: sinks + checked: Audio.sink?.id === modelData.id + onClicked: Audio.setAudioSink(modelData) + text: modelData.description + } + } + + CustomText { + Layout.topMargin: 10 + text: qsTr("Input device") + font.weight: 500 + } + + Repeater { + model: Audio.sources + + CustomRadioButton { + required property PwNode modelData + + ButtonGroup.group: sources + checked: Audio.source?.id === modelData.id + onClicked: Audio.setAudioSource(modelData) + text: modelData.description + } + } + } } diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index bff7bab..c179dbd 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -89,6 +89,7 @@ RowLayout { delegate: WrappedLoader { sourceComponent: TrayWidget { bar: root.bar + popouts: root.popouts } } } diff --git a/Modules/Network/NetworkPopout.qml b/Modules/Network/NetworkPopout.qml index 76ea531..91f2f8a 100644 --- a/Modules/Network/NetworkPopout.qml +++ b/Modules/Network/NetworkPopout.qml @@ -1,9 +1,35 @@ pragma ComponentBehavior: Bound import Quickshell +import Quickshell.Networking import QtQuick import QtQuick.Layouts import qs.Modules +import qs.Helpers -ColumnLayout { +Item { + id: root + + required property var wrapper + + ColumnLayout { + id: layout + + spacing: 8 + + Repeater { + model: Network.devices + + CustomRadioButton { + id: network + visible: modelData.name !== "lo" + + required property NetworkDevice modelData + + checked: Network.activeDevice?.name === modelData.name + onClicked: + text: modelData.description + } + } + } } diff --git a/Modules/TrayItem.qml b/Modules/TrayItem.qml index bf90c09..afde255 100644 --- a/Modules/TrayItem.qml +++ b/Modules/TrayItem.qml @@ -12,13 +12,21 @@ Item { required property SystemTrayItem item required property PanelWindow bar + required property int ind + required property Wrapper popouts property bool hasLoaded: false MouseArea { anchors.fill: parent - acceptedButtons: Qt.LeftButton + acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: { - root.item.activate(); + if ( mouse.button === Qt.LeftButton ) { + root.item.activate(); + } else if ( mouse.button === Qt.RightButton ) { + root.popouts.currentName = `traymenu${ root.ind }`; + root.popouts.currentCenter = Qt.binding( () => root.mapToItem( root.bar, root.implicitWidth / 2, 0 ).x ); + root.popouts.hasCurrent = true; + } } } diff --git a/Modules/TrayWidget.qml b/Modules/TrayWidget.qml index ef5ab72..dc4c97c 100644 --- a/Modules/TrayWidget.qml +++ b/Modules/TrayWidget.qml @@ -12,6 +12,7 @@ Row { anchors.bottom: parent.bottom required property PanelWindow bar + required property Wrapper popouts readonly property alias items: repeater spacing: 0 @@ -22,6 +23,9 @@ Row { TrayItem { id: trayItem required property SystemTrayItem modelData + required property int index + ind: index + popouts: root.popouts implicitHeight: 34 implicitWidth: 28 item: modelData