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:
Gabriel Brown
2026-08-24 11:39:16 -04:00
parent 9bc68ba358
commit b58371bb35
38 changed files with 3884 additions and 213 deletions
@@ -0,0 +1,154 @@
// The level of the device a card is about, written straight through to
// PipeWire -- there is nothing to apply, so there is nothing to commit and
// nothing to debounce.
//
// The range is a property rather than a constant because over-amplification
// extends it to 150%. The track keeps its full length either way, so the same
// slider position always means the same level; the region past 100% is marked
// in the warning tone, because that is the part that can distort.
//
// Not a SettingRow: the control stacks below the label in a narrow window
// exactly as SliderRow and GradientSliderRow do, which SettingRow's fixed
// trailing column cannot.
import QtQuick
import Quickshell.Services.Pipewire
import qs.config
import qs.widgets
Item {
id: root
property var node: null
property string label: "Volume"
property string detail: ""
property bool divider: true
property real maximum: 1
readonly property bool available: !!root.node?.audio
readonly property real volume: root.node?.audio?.volume ?? 0
readonly property bool muted: root.node?.audio?.muted ?? false
readonly property bool amplified: root.volume > 1.001
readonly property bool inline: root.width >= 520
readonly property int controlSpan: 300
width: parent ? parent.width : 620
implicitHeight: root.inline
? Math.max(56, copy.implicitHeight + 20)
: copy.implicitHeight + 32 + 30
opacity: root.available ? 1 : 0.45
// Reading or writing audio state on an untracked node silently returns
// zero, so the row binds the node it speaks for even though the device list
// above it is already tracking the same object.
PwObjectTracker {
objects: root.node ? [root.node] : []
}
function setVolume(ratio: real): void {
if (!root.node?.audio)
return;
const value = Math.max(0, Math.min(root.maximum, ratio * root.maximum));
// Moving the slider is also how you unmute, same as GNOME.
root.node.audio.muted = false;
root.node.audio.volume = value;
}
Column {
id: copy
x: 0
y: root.inline ? (root.height - height) / 2 : 10
width: root.inline ? root.width - root.controlSpan - 20 : root.width
spacing: 3
Text {
width: parent.width
text: root.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.detail !== ""
text: root.detail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Item {
id: control
width: root.inline ? root.controlSpan : root.width
height: 32
x: root.inline ? root.width - width : 0
y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10
ValueSlider {
id: slider
anchors.left: parent.left
anchors.right: readout.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
enabled: root.available
value: root.muted ? 0 : root.volume / root.maximum
onMoved: ratio => root.setVolume(ratio)
}
// Everything above 100%. Drawn over the track rather than into it, so
// the slider itself stays the one the rest of the shell uses.
Rectangle {
anchors.right: slider.right
anchors.verticalCenter: slider.verticalCenter
width: root.maximum > 1 ? slider.width * (1 - 1 / root.maximum) : 0
height: 10
radius: 2
visible: root.maximum > 1
color: Theme.alpha(Theme.warn, 0.30)
border.width: 0
}
Rectangle {
anchors.verticalCenter: slider.verticalCenter
x: slider.x + slider.width / root.maximum - 1
width: 2
height: 16
radius: 1
visible: root.maximum > 1
color: Theme.alpha(Theme.warn, 0.7)
border.width: 0
}
Text {
id: readout
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 46
horizontalAlignment: Text.AlignRight
text: Math.round(root.volume * 100) + "%"
color: root.amplified ? Theme.warn : Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
}