aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules')
-rw-r--r--desktop/modules/sound/Service.qml12
-rw-r--r--desktop/modules/sound/SoundPage.qml191
2 files changed, 179 insertions, 24 deletions
diff --git a/desktop/modules/sound/Service.qml b/desktop/modules/sound/Service.qml
index e509a94..22a4520 100644
--- a/desktop/modules/sound/Service.qml
+++ b/desktop/modules/sound/Service.qml
@@ -24,6 +24,18 @@ Scope {
readonly property real volume: sink?.audio?.volume ?? 0
readonly property bool muted: sink?.audio?.muted ?? false
+ // The devices that can be made the default. A stream is an application,
+ // not a device, and a monitor node is the loopback view of a sink rather
+ // than something to record from, so both are filtered out. `isSink` true
+ // means the node accepts audio (an output device); false means it outputs
+ // audio (an input device, a microphone).
+ readonly property var outputs:
+ Pipewire.nodes.values.filter(n => n.audio && !n.isStream && n.isSink
+ && !String(n.name).endsWith(".monitor"))
+ readonly property var inputs:
+ Pipewire.nodes.values.filter(n => n.audio && !n.isStream && !n.isSink
+ && !String(n.name).endsWith(".monitor"))
+
// Which node changed last, and whether it was the input. The OSD draws
// this one; null means nothing to show.
property PwNode active: null
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));
}