362 lines
13 KiB
QML
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
|
|
}
|
|
}
|
|
}
|