Nine outputs named after their chipsets cannot be told apart by reading, so each one gets a Test button that plays a short sample out of that device. Targeted by node name rather than by making it the default first, because finding out which is which should not move where everything else is playing. That belongs in its own service rather than in AudioDevices. sound-page-contract forbids Process, pactl and wpctl in the files that own device state, and it is right to: shelling out there races the PipeWire service that owns those same objects. Playback is a different thing -- pw-play opens its own stream and mutates no device, so there is nothing to race -- but the rule's letter covered it, and weakening a guard to fit a new case is how guards stop meaning anything. SoundTest exists so AudioDevices stays native bindings only. Worth recording next to the call: pw-play falls back to the default output for a target it cannot find, rather than failing. A stale node name would play from the wrong device and look exactly like a successful test, which is why the name is taken straight from the live node. The Dock's icon size was a constant in Theme. It goes through the preference schema like everything else, so validation, search, the generated docs and the write sweep all pick it up without being told about it separately -- and two contracts duly failed until docs/settings.md and the per-page commands were regenerated. Dock position is deliberately not here. It is not a setting but a rework: the dock is anchored bottom, and the reveal strip, tooltip placement, intellihide and the qs-dock rule in hypr/rules.lua all assume that. Doing it properly means changing compositor rules on a machine somebody uses daily, which is not something to start as a side effect of adding a slider. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
183 lines
5.6 KiB
QML
183 lines
5.6 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Services.Pipewire
|
|
import qs.config
|
|
import qs.widgets
|
|
import qs.services
|
|
import qs.modules.quicksettings
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
required property var node
|
|
property bool output: true
|
|
property bool selected: false
|
|
|
|
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)
|
|
|
|
PwObjectTracker {
|
|
objects: root.node ? [root.node] : []
|
|
}
|
|
|
|
PwNodePeakMonitor {
|
|
id: inputPeak
|
|
node: root.node
|
|
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.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";
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
}
|
|
}
|
|
}
|