Give Sound the whole story, and keep the buttons inside the card
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,3 +1,16 @@
|
||||
// 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
|
||||
@@ -6,18 +19,35 @@ import qs.widgets
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
Rectangle {
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var node
|
||||
property var node: null
|
||||
property bool output: true
|
||||
property bool selected: false
|
||||
property bool divider: true
|
||||
|
||||
implicitHeight: root.output || !root.selected ? 88 : 105
|
||||
radius: Theme.cardRadius
|
||||
color: root.selected ? Theme.alpha(Theme.accent, 0.09) : Theme.alpha(Theme.fg, 0.025)
|
||||
border.width: 1
|
||||
border.color: root.selected ? Theme.alpha(Theme.accent, 0.34) : Theme.alpha(Theme.fg, 0.07)
|
||||
// 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] : []
|
||||
@@ -29,154 +59,303 @@ Rectangle {
|
||||
enabled: !root.output && root.selected
|
||||
}
|
||||
|
||||
readonly property real volume: root.node?.audio?.volume ?? 0
|
||||
readonly property bool muted: root.node?.audio?.muted ?? false
|
||||
|
||||
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";
|
||||
if (root.volume < 0.34)
|
||||
return "audio-volume-low-symbolic";
|
||||
if (root.volume < 0.67)
|
||||
return "audio-volume-medium-symbolic";
|
||||
return "audio-volume-high-symbolic";
|
||||
return "audio-speakers-symbolic";
|
||||
}
|
||||
|
||||
Row {
|
||||
id: heading
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 10
|
||||
anchors.topMargin: 8
|
||||
height: 28
|
||||
spacing: 10
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 20
|
||||
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
||||
iconFallback: "audio-card-symbolic"
|
||||
tint: root.selected ? Theme.accent : Theme.fg
|
||||
}
|
||||
|
||||
Column {
|
||||
width: Math.max(0, parent.width - 20 - useButton.width - parent.spacing * 2)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: 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
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.node.nickname && root.node.nickname !== AudioDevices.label(root.node)
|
||||
text: root.node.nickname ?? ""
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
// Outputs only. Testing an input would mean recording and playing
|
||||
// it back, which is a different thing than this button implies.
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.output
|
||||
text: "Test"
|
||||
onClicked: SoundTest.play(root.node)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: useButton
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.selected ? 78 : 64
|
||||
text: root.selected ? "Default" : "Use"
|
||||
enabled: !root.selected
|
||||
onClicked: AudioDevices.select(root.output, root.node)
|
||||
}
|
||||
}
|
||||
// 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";
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: muteButton
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.top: heading.bottom
|
||||
anchors.topMargin: 7
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.iconName()
|
||||
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
|
||||
onClicked: {
|
||||
if (root.node?.audio)
|
||||
root.node.audio.muted = !root.node.audio.muted;
|
||||
}
|
||||
// "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" : "");
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
id: volumeSlider
|
||||
anchors.left: muteButton.right
|
||||
anchors.leftMargin: 7
|
||||
anchors.right: volumeText.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
value: root.muted ? 0 : root.volume
|
||||
onMoved: value => {
|
||||
if (!root.node?.audio)
|
||||
return;
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = value;
|
||||
}
|
||||
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" : "";
|
||||
}
|
||||
|
||||
Text {
|
||||
id: volumeText
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
width: 38
|
||||
text: Math.round(root.volume * 100) + "%"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
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 : "";
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: volumeSlider.left
|
||||
anchors.right: volumeText.right
|
||||
anchors.top: muteButton.bottom
|
||||
anchors.topMargin: 5
|
||||
height: 5
|
||||
radius: height / 2
|
||||
visible: !root.output && root.selected
|
||||
color: Theme.alpha(Theme.fg, 0.10)
|
||||
Column {
|
||||
id: content
|
||||
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
|
||||
radius: parent.radius
|
||||
color: inputPeak.peak > 0.88 ? Theme.danger : Theme.accentSecondary
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user