Files
Panama/config/dot/quickshell/modules/settings/SoundDeviceRow.qml
T

362 lines
13 KiB
QML

// 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 color 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
StatusBadge {
visible: root.airplay && !root.ghost
text: "AirPlay"
tone: Theme.cyan
}
StatusBadge {
visible: root.bluetooth && !root.ghost
text: "Bluetooth"
tone: Theme.accent
}
StatusBadge {
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
}
}
}