diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-14 13:03:51 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-14 13:03:51 +0200 |
| commit | 3dee371ed7e460d68018839e0ff17b436e2c6b52 (patch) | |
| tree | 4b976e375b0c3daf5fbaa72b4d9fa4529275f061 /desktop/modules/sound/SoundPage.qml | |
| parent | 60eae607ab75988fc441bb4d87ef790175009572 (diff) | |
| download | quickshell-3dee371ed7e460d68018839e0ff17b436e2c6b52.tar.gz quickshell-3dee371ed7e460d68018839e0ff17b436e2c6b52.zip | |
feat(sound): switch default device, control the level
The sound page showed the default sink and source but gave no way to
choose another device and the meter was inert. Each section now lists
the real audio devices, streams and monitor nodes filtered out, behind
an in-place dropdown that sets Pipewire.preferredDefaultAudioSink or
preferredDefaultAudioSource. The meter takes the wheel, click and drag,
and the state glyph beside the percentage toggles mute.
The list expands in place rather than in a popup because a
QtQuick.Controls popup is a separate window and does not position
reliably on a layer surface. The meter accepts the wheel and prevents
stealing so the page's ScrollView neither scrolls nor flicks instead.
Diffstat (limited to 'desktop/modules/sound/SoundPage.qml')
| -rw-r--r-- | desktop/modules/sound/SoundPage.qml | 191 |
1 files changed, 167 insertions, 24 deletions
diff --git a/desktop/modules/sound/SoundPage.qml b/desktop/modules/sound/SoundPage.qml index d530e9d..7a757c9 100644 --- a/desktop/modules/sound/SoundPage.qml +++ b/desktop/modules/sound/SoundPage.qml @@ -22,16 +22,38 @@ Column { spacing: 16 - // Output and input, each with its own slider. + // A device's human name, most readable first. + function devLabel(n) { + if (!n) return "—"; + return n.description || n.nickname || n.name || ("node " + n.id); + } + + // The state glyph for a level row, the same shapes the OSD uses: a + // microphone for the input, a speaker whose fill follows the level for the + // output, red when muted. + function stateIcon(audio, input) { + if (input) return audio?.muted ? "\uf131" : "\uf130"; + if (!audio || audio.muted || audio.volume <= 0) return "\uf026"; + return audio.volume < 0.5 ? "\uf027" : "\uf028"; + } + + // Output and input. Each shows the current device, a slider for it, and an + // in-place device list that sets the default. The list expands within the + // page rather than opening a popup: a QtQuick.Controls popup is a separate + // window and does not position reliably on this layer surface. Repeater { model: [ - { label: "Output", node: page.service.sink }, - { label: "Input", node: page.service.source }, + { label: "Output", node: page.service.sink, devices: page.service.outputs, + setDefault: n => Pipewire.preferredDefaultAudioSink = n }, + { label: "Input", node: page.service.source, devices: page.service.inputs, + setDefault: n => Pipewire.preferredDefaultAudioSource = n }, ] Column { + id: section required property var modelData readonly property var audio: modelData.node?.audio ?? null + property bool expanded: false width: page.width spacing: 6 @@ -48,42 +70,163 @@ Column { color: Theme.text } - Text { - anchors.right: parent.right - text: !audio ? "—" : audio.muted ? "muted" : Math.round(audio.volume * 100) + "%" - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } - color: audio?.muted ? Theme.red : Theme.subtext + // State glyph and level, clickable to mute/unmute. + Row { + id: levelRow + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 6 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: page.stateIcon(audio, modelData.label === "Input") + font { family: Theme.fontFamily; pixelSize: Theme.fontSize } + color: audio?.muted ? Theme.red : Theme.accent + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: !audio ? "—" : audio.muted ? "muted" : Math.round(audio.volume * 100) + "%" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + color: audio?.muted ? Theme.red : Theme.subtext + } + } + + MouseArea { + anchors.fill: levelRow + enabled: audio !== null + cursorShape: Qt.PointingHandCursor + onClicked: if (audio) audio.muted = !audio.muted + } + } + + // The current device; click to open the device list. + Item { + width: parent.width + implicitHeight: devRow.implicitHeight + + Row { + id: devRow + width: parent.width + spacing: 6 + + Text { + width: parent.width - chevron.implicitWidth - parent.spacing + elide: Text.ElideRight + text: page.devLabel(modelData.node) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + + Text { + id: chevron + text: section.expanded ? "\uf077" : "\uf078" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: section.expanded = !section.expanded } } - Text { + // The devices, expanded in place. The current one is highlighted; + // clicking a row makes it the default and folds the list away. + Column { width: parent.width - elide: Text.ElideRight - text: modelData.node?.description ?? modelData.node?.name ?? "" - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - color: Theme.overlay + spacing: 2 + visible: section.expanded + + Repeater { + model: section.modelData.devices + + Rectangle { + required property var modelData + readonly property bool current: section.modelData.node === modelData + + width: section.width + implicitHeight: devText.implicitHeight + 8 + radius: 6 + color: devArea.containsMouse ? Qt.alpha(Theme.accent, 0.18) + : current ? Qt.alpha(Theme.accent, 0.10) : "transparent" + + Text { + id: devText + anchors { + left: parent.left; right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: 8; rightMargin: 8 + } + elide: Text.ElideRight + text: page.devLabel(modelData) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3; bold: current } + color: current ? Theme.accent : Theme.text + } + + MouseArea { + id: devArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + section.modelData.setDefault(modelData); + section.expanded = false; + } + } + } + } } - // Click or drag anywhere on the bar to set the level. - Rectangle { + // The meter. The hit area is taller than the 8px bar so it is easy + // to grab; preventStealing stops the page's Flickable taking the + // press, and the wheel is accepted here so the page does not scroll + // instead. Click sets, drag scrubs, wheel steps, wheel-up unmutes + // (the same thing the volume keybind does). + Item { + id: meter width: parent.width - height: 8 - radius: 4 - color: Theme.surface + height: 20 + + readonly property real level: Math.min(audio?.volume ?? 0, 1) Rectangle { - height: parent.height - radius: parent.radius - width: parent.width * Math.min(audio?.volume ?? 0, 1) - color: audio?.muted ? Theme.red : Theme.accent - opacity: audio?.muted ? 0.5 : 1 + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 8 + radius: 4 + color: Theme.surface + + Rectangle { + height: parent.height + radius: parent.radius + width: parent.width * meter.level + color: audio?.muted ? Theme.red : Theme.accent + opacity: audio?.muted ? 0.5 : 1 + } } MouseArea { anchors.fill: parent enabled: audio !== null - onPositionChanged: mouse => set(mouse.x) + preventStealing: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => set(mouse.x) + onPositionChanged: mouse => { if (pressed) set(mouse.x); } + + onWheel: wheel => { + if (!audio || wheel.angleDelta.y === 0) return; + if (wheel.angleDelta.y > 0) { + audio.muted = false; + audio.volume = Math.min(1, audio.volume + 0.05); + } else { + audio.volume = Math.max(0, audio.volume - 0.05); + } + wheel.accepted = true; + } + function set(x) { if (audio) audio.volume = Math.max(0, Math.min(1, x / width)); } |
