// One audio device: what it is, how it is attached, and whether it is the one // in use. // // Trailing controls are measured, never guessed. The old layout subtracted the // Use button's width from the name column and forgot both the Test button // beside it and the spacing between them, so every output row overflowed its // card by exactly that much. The name column now ends where the trailing Row // begins, and that Row is as wide as whatever it happens to contain. // // Selecting is the row itself rather than a button, which is what freed the // width in the first place: the radio on the right says which device is in use, // and clicking anywhere that is not a control makes it this one. import QtQuick import Quickshell import Quickshell.Services.Pipewire import qs.config import qs.widgets import qs.services import qs.modules.quicksettings Item { id: root property var node: null property bool output: true property bool selected: false property bool divider: true // A default that is configured but not present -- AirPods in their case. // It renders as itself, dimmed and inert, rather than as an absence that // leaves the list looking like it forgot. property bool ghost: false property string ghostLabel: "" // The channel strip is per row and stays open until it is dismissed, so // walking a surround set is one click per speaker rather than two. property bool testOpen: false readonly property real volume: root.node?.audio?.volume ?? 0 readonly property bool muted: root.node?.audio?.muted ?? false readonly property var properties: root.node?.properties ?? ({}) readonly property string deviceApi: String(root.properties["device.api"] ?? "") readonly property bool airplay: root.deviceApi === "raop" readonly property bool bluetooth: root.deviceApi === "bluez5" width: parent ? parent.width : 620 implicitHeight: content.implicitHeight opacity: root.ghost ? 0.55 : 1 PwObjectTracker { objects: root.node ? [root.node] : [] } PwNodePeakMonitor { id: inputPeak node: root.node enabled: !root.output && root.selected } function iconName(): string { if (!root.output) return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic"; if (root.airplay) return "network-wireless-symbolic"; if (root.bluetooth) return "bluetooth-active-symbolic"; if (root.muted || root.volume <= 0.001) return "audio-volume-muted-symbolic"; return "audio-speakers-symbolic"; } // 48000 -> "48 kHz", 44100 -> "44.1 kHz". Absent on plenty of nodes, which // is why every part of the subtitle is dropped rather than defaulted. function rateLabel(): string { const rate = Number(root.properties["audio.rate"] ?? 0); if (!Number.isFinite(rate) || rate <= 0) return ""; const khz = rate / 1000; return (khz % 1 === 0 ? String(khz) : khz.toFixed(1)) + " kHz"; } // "S24_32LE" -> "24-bit", "F32LE" -> "32-bit float". function formatLabel(): string { const format = String(root.properties["audio.format"] ?? ""); const bits = format.match(/(\d+)/); if (!bits) return ""; return bits[1] + "-bit" + (format.startsWith("F") ? " float" : ""); } function channelLabel(): string { const channels = root.node?.audio?.channels?.length ?? 0; if (channels === 1) return "Mono"; if (channels === 2) return "Stereo"; return channels > 2 ? channels + " channels" : ""; } readonly property string subtitle: { if (root.ghost) return root.output ? "Your preferred output — audio will move here when they connect" : "Your preferred input — Panama will listen here when it connects"; const parts = [root.channelLabel(), root.rateLabel(), root.formatLabel()] .filter(part => part !== ""); if (parts.length > 0) return parts.join(" · "); const nickname = String(root.node?.nickname ?? ""); return nickname !== "" && nickname !== AudioDevices.label(root.node) ? nickname : ""; } Column { id: content width: parent.width spacing: 0 Rectangle { id: body width: parent.width implicitHeight: 58 radius: Theme.cardRadius color: rowHover.hovered && !root.ghost ? Theme.alpha(Theme.fg, 0.05) : "transparent" border.width: 0 Rectangle { id: iconTile anchors.left: parent.left anchors.leftMargin: 4 anchors.verticalCenter: parent.verticalCenter width: 34 height: 34 radius: 10 color: root.selected ? Theme.alpha(Theme.accent, 0.16) : Theme.alpha(Theme.fg, 0.07) border.width: 0 ThemedIcon { anchors.centerIn: parent size: 18 icon: root.iconName() iconFallback: root.output ? "audio-card-symbolic" : "audio-input-microphone-symbolic" tint: root.selected ? Theme.accent : Theme.fg } } Row { id: trailing anchors.right: parent.right anchors.rightMargin: 6 anchors.verticalCenter: parent.verticalCenter spacing: 10 // The live level of the source being listened to. Native and // event-driven: it repaints when a peak arrives and never on a // timer, and it exists only for the selected input. Rectangle { id: meter anchors.verticalCenter: parent.verticalCenter visible: !root.output && root.selected && !root.ghost width: Math.min(190, Math.max(90, root.width * 0.26)) height: 8 radius: 4 clip: true color: Theme.alpha(Theme.fg, 0.09) border.width: 0 Item { anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom width: parent.width * Math.max(0, Math.min(1, inputPeak.peak)) clip: true // Full-width gradient behind a clipped window, so a // given level is always the same colour rather than a // squashed copy of the whole ramp. Rectangle { width: meter.width height: parent.height radius: meter.radius border.width: 0 gradient: Gradient { orientation: Gradient.Horizontal GradientStop { position: 0.0; color: Theme.ok } GradientStop { position: 0.82; color: Theme.warn } GradientStop { position: 1.0; color: Theme.danger } } } } } // Mute belongs to the device in use, next to the level it // silences. Scrolling it nudges that device's own volume, the // same gesture the speaker icon carries everywhere else. IconButton { id: muteButton anchors.verticalCenter: parent.verticalCenter visible: root.selected && !root.ghost size: 30 iconSize: 17 icon: root.muted ? (root.output ? "audio-volume-muted-symbolic" : "microphone-sensitivity-muted-symbolic") : (root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic") iconFallback: "audio-volume-high-symbolic" tint: root.muted ? Theme.danger : Theme.fgDim onClicked: { if (root.node?.audio) root.node.audio.muted = !root.node.audio.muted; } WheelHandler { onWheel: event => { if (!root.node?.audio) return; const step = event.angleDelta.y > 0 ? 0.05 : -0.05; const value = Math.max(0, Math.min(1, root.volume + step)); root.node.audio.muted = false; root.node.audio.volume = value; } } } SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: root.output && root.selected && !root.ghost text: root.testOpen ? "Done" : "Test" onClicked: root.testOpen = !root.testOpen } // Which device is in use. A radio rather than a button, because // choosing one is choosing all the others away. Rectangle { anchors.verticalCenter: parent.verticalCenter width: 18 height: 18 radius: 9 color: "transparent" border.width: 2 border.color: root.selected ? Theme.accent : Theme.fgMuted Rectangle { anchors.centerIn: parent width: 10 height: 10 radius: 5 visible: root.selected color: Theme.accent border.width: 0 } } } Column { anchors.left: iconTile.right anchors.leftMargin: 13 anchors.right: trailing.left anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter spacing: 2 Row { id: nameLine width: parent.width spacing: 8 // The name takes what the badges leave and elides into it, // so a long device name never pushes its own badge out of // the row. Text { anchors.verticalCenter: parent.verticalCenter width: Math.min(implicitWidth, Math.max(0, nameLine.width - (badges.width > 0 ? badges.width + nameLine.spacing : 0))) text: root.ghost ? root.ghostLabel : AudioDevices.label(root.node) color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: root.selected ? Font.DemiBold : Font.Medium elide: Text.ElideRight } Row { id: badges anchors.verticalCenter: parent.verticalCenter spacing: 6 SoundBadge { visible: root.airplay && !root.ghost text: "AirPlay" tone: Theme.cyan } SoundBadge { visible: root.bluetooth && !root.ghost text: "Bluetooth" tone: Theme.accent } SoundBadge { visible: root.ghost text: "Returns when connected" tone: Theme.fgMuted } } } Text { width: parent.width visible: root.subtitle !== "" text: root.subtitle color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } } HoverHandler { id: rowHover enabled: !root.ghost && !root.selected cursorShape: Qt.PointingHandCursor } TapHandler { enabled: !root.ghost && !root.selected onTapped: AudioDevices.select(root.output, root.node) } Rectangle { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom height: 1 visible: root.divider color: Theme.alpha(Theme.fg, 0.05) } } SoundChannelStrip { width: parent.width visible: root.testOpen && root.output && root.selected && !root.ghost node: root.node topPadding: 4 bottomPadding: 10 } } }