// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell import Quickshell.Services.Pipewire import QtQuick import "../.." Column { id: page required property var service signal back spacing: 16 // A device's human name, most readable first. function devLabel(n) { if (!n) return "—"; return n.description || n.nickname || n.name || ("node " + n.id); } // The state glyph for a level row, the same shapes the OSD uses: a // microphone for the input, a speaker whose fill follows the level for the // output, red when muted. function stateIcon(audio, input) { if (input) return audio?.muted ? "\uf131" : "\uf130"; if (!audio || audio.muted || audio.volume <= 0) return "\uf026"; return audio.volume < 0.5 ? "\uf027" : "\uf028"; } // The meter. The hit area is taller than the 8px bar so it is easy to // grab; preventStealing stops the page's Flickable taking the press, and // the wheel is accepted here so the page does not scroll instead. Click // sets, drag scrubs, wheel steps, wheel-up unmutes (the same thing the // volume keybind does). Past 100% a tick marks where boost starts. component Meter: Item { id: meter property var audio: null property real max: 1 readonly property real level: Math.min((audio?.volume ?? 0) / max, 1) width: parent.width height: 20 Rectangle { anchors.verticalCenter: parent.verticalCenter width: parent.width height: 8 radius: 4 color: Theme.surface Rectangle { height: parent.height radius: parent.radius width: parent.width * meter.level color: meter.audio?.muted ? Theme.red : Theme.accent opacity: meter.audio?.muted ? 0.5 : 1 } } Rectangle { visible: meter.max > 1 anchors.verticalCenter: parent.verticalCenter x: parent.width / meter.max - 1 width: 2 height: 14 radius: 1 color: Theme.overlay } MouseArea { anchors.fill: parent enabled: meter.audio !== null preventStealing: true cursorShape: Qt.PointingHandCursor onPressed: mouse => set(mouse.x) onPositionChanged: mouse => { if (pressed) set(mouse.x); } // Wheel-up never lowers: a level already past max (set by // something else) stays where it is rather than snapping down. onWheel: wheel => { const a = meter.audio; if (!a || wheel.angleDelta.y === 0) return; if (wheel.angleDelta.y > 0) { a.muted = false; a.volume = Math.max(a.volume, Math.min(meter.max, a.volume + 0.05)); } else { a.volume = Math.max(0, a.volume - 0.05); } wheel.accepted = true; } function set(x) { if (meter.audio) meter.audio.volume = Math.max(0, Math.min(meter.max, x / width * meter.max)); } } } // Output and input. Each shows the current device, a slider for it, and an // in-place device list that sets the default. The list expands within the // page rather than opening a popup: a QtQuick.Controls popup is a separate // window and does not position reliably on this layer surface. Repeater { model: [ { label: "Output", node: page.service.sink, devices: page.service.outputs, setDefault: n => Pipewire.preferredDefaultAudioSink = n }, { label: "Input", node: page.service.source, devices: page.service.inputs, setDefault: n => Pipewire.preferredDefaultAudioSource = n }, ] Column { id: section required property var modelData readonly property var audio: modelData.node?.audio ?? null property bool expanded: false width: page.width spacing: 6 Item { width: parent.width implicitHeight: name.implicitHeight Text { id: name anchors.left: parent.left text: modelData.label font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } color: Theme.text } // State glyph and level, clickable to mute/unmute. Row { id: levelRow anchors { right: parent.right; verticalCenter: parent.verticalCenter } spacing: 6 Text { anchors.verticalCenter: parent.verticalCenter text: page.stateIcon(audio, modelData.label === "Input") font { family: Theme.iconFamily; pixelSize: Theme.fontSize } color: audio?.muted ? Theme.red : Theme.accent } Text { anchors.verticalCenter: parent.verticalCenter text: !audio ? "—" : audio.muted ? "muted" : Math.round(audio.volume * 100) + "%" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } color: audio?.muted ? Theme.red : Theme.subtext } } MouseArea { anchors.fill: levelRow enabled: audio !== null cursorShape: Qt.PointingHandCursor onClicked: if (audio) audio.muted = !audio.muted } } // The current device; click to open the device list. Item { width: parent.width implicitHeight: devRow.implicitHeight Row { id: devRow width: parent.width spacing: 6 Text { width: parent.width - chevron.implicitWidth - parent.spacing elide: Text.ElideRight text: page.devLabel(modelData.node) font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text } Text { id: chevron text: section.expanded ? "\uf077" : "\uf078" font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } color: Theme.overlay } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: section.expanded = !section.expanded } } // The devices, expanded in place. The current one is highlighted; // clicking a row makes it the default and folds the list away. Column { width: parent.width spacing: 2 visible: section.expanded Repeater { model: section.modelData.devices Rectangle { required property var modelData readonly property bool current: section.modelData.node === modelData width: section.width implicitHeight: devText.implicitHeight + 8 radius: 6 color: devArea.containsMouse ? Qt.alpha(Theme.accent, 0.18) : current ? Qt.alpha(Theme.accent, 0.10) : "transparent" Text { id: devText anchors { left: parent.left; right: parent.right verticalCenter: parent.verticalCenter leftMargin: 8; rightMargin: 8 } elide: Text.ElideRight text: page.devLabel(modelData) font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3; bold: current } color: current ? Theme.accent : Theme.text } MouseArea { id: devArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { section.modelData.setDefault(modelData); section.expanded = false; } } } } } Meter { audio: section.audio // Output goes to 150%, the ceiling the volume keybind already // uses (wpctl -l 1.5). A boosted microphone only clips. max: section.modelData.label === "Output" ? 1.5 : 1 } } } // Applications playing right now, each with its own level. Playback // streams only: a recording stream is the privacy dot's business. Nothing // binds these outside the page, so the tracker lives here and stops when // the drawer closes. PwObjectTracker { objects: page.service.streams } Column { width: parent.width spacing: 10 visible: page.service.streams.length > 0 Text { text: "Applications" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } color: Theme.text } Repeater { model: page.service.streams Column { id: stream required property var modelData readonly property var audio: modelData.audio width: page.width spacing: 2 Item { width: parent.width implicitHeight: appName.implicitHeight Text { id: appName anchors { left: parent.left; right: appLevel.left; rightMargin: 8 } elide: Text.ElideRight text: stream.modelData.properties?.["application.name"] || page.devLabel(stream.modelData) font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text } Text { id: appLevel anchors { right: parent.right; verticalCenter: parent.verticalCenter } text: stream.audio.muted ? "muted" : Math.round(stream.audio.volume * 100) + "%" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: stream.audio.muted ? Theme.red : Theme.subtext MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: stream.audio.muted = !stream.audio.muted } } } // What the app is playing, when it says: two browser tabs are // otherwise two identical rows. Text { width: parent.width visible: text !== "" elide: Text.ElideRight text: stream.modelData.properties?.["media.name"] ?? "" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.subtext } Meter { audio: stream.audio } } } } }