// 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) } }