// 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"; } // 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.fontFamily; 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.fontFamily; 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; } } } } } // 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). Item { id: meter width: parent.width height: 20 readonly property real level: Math.min(audio?.volume ?? 0, 1) 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: audio?.muted ? Theme.red : Theme.accent opacity: audio?.muted ? 0.5 : 1 } } MouseArea { anchors.fill: parent enabled: audio !== null preventStealing: true cursorShape: Qt.PointingHandCursor onPressed: mouse => set(mouse.x) onPositionChanged: mouse => { if (pressed) set(mouse.x); } onWheel: wheel => { if (!audio || wheel.angleDelta.y === 0) return; if (wheel.angleDelta.y > 0) { audio.muted = false; audio.volume = Math.min(1, audio.volume + 0.05); } else { audio.volume = Math.max(0, audio.volume - 0.05); } wheel.accepted = true; } function set(x) { if (audio) audio.volume = Math.max(0, Math.min(1, x / width)); } } } } } Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } // What is playing, with transport. Same Player singleton the OSD uses, // so playerctld's duplicate is already filtered out by dbusName. Column { width: parent.width spacing: 8 visible: Player.active Text { width: parent.width elide: Text.ElideRight text: Player.title || "Nothing playing" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } color: Theme.text } Text { width: parent.width elide: Text.ElideRight visible: Player.artist !== "" text: Player.artist font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.subtext } Row { spacing: 4 TransportButton { glyph: "" enabled: Player.current?.canGoPrevious ?? false onClicked: Player.current?.previous() } TransportButton { glyph: Player.playing ? "" : "" enabled: Player.current?.canTogglePlaying ?? false onClicked: Player.current?.togglePlaying() } TransportButton { glyph: "" enabled: Player.current?.canGoNext ?? false onClicked: Player.current?.next() } } } }