From e242147340994dbb48ded3273a34079a841af25b Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 15 Oct 2026 13:06:36 +0200 Subject: feat(sound): per-app volume on the drawer page, output to 150% The island spec put per-app volume on the drawer page, and it was never built. Each application playing audio now gets a row with its name, what it is playing (two browser tabs are otherwise identical rows), a click-to-mute level and its own slider. Streams are matched on PwNodeType.AudioOutStream. Quickshell reports isSink true for a playback stream, so the obvious isStream && !isSink filter found nothing while Firefox played. Their properties are empty until bound, so the page carries a PwObjectTracker for them, which stops with the drawer. speech-dispatcher-dummy is always present and never audible, and is hidden. The output slider runs to 150%, the ceiling the volume keybind already uses (wpctl -l 1.5), with a tick at 100%. Input and apps stop at 100%: a boosted microphone only clips. The meter became an inline component shared by both, and wheel-up never lowers a level already past its maximum. Now playing is dropped from the page: the OSD already carries it. Co-Authored-By: Claude Opus 5.5 --- desktop/modules/sound/README.md | 16 +++ desktop/modules/sound/Service.qml | 8 ++ desktop/modules/sound/SoundPage.qml | 217 ++++++++++++++++++++++-------------- 3 files changed, 157 insertions(+), 84 deletions(-) (limited to 'desktop/modules/sound') diff --git a/desktop/modules/sound/README.md b/desktop/modules/sound/README.md index 5205be8..365be4a 100644 --- a/desktop/modules/sound/README.md +++ b/desktop/modules/sound/README.md @@ -33,6 +33,22 @@ From Hyprland, to start the shell with the session: exec-once = qs -p ~/Programming/GIT/quickshell/desktop +## The page + +Output and input, each with its device, a device picker and a slider, then +one row per application playing audio, with its own slider and mute. The +output slider runs to 150%, the ceiling the volume keybind uses +(`wpctl -l 1.5`), with a tick at 100%; input and applications stop at 100%. +Now playing is the OSD's job, not the page's. + +Applications are matched on `PwNodeType.AudioOutStream`, not on `isSink`: +Quickshell reports `isSink` true for a playback stream, so the obvious +`isStream && !isSink` filter returned nothing while Firefox was playing. A +stream's `properties` (`application.name`, `media.name`) are empty until +something binds the node, so the page carries its own `PwObjectTracker` for +the streams. `speech-dispatcher-dummy` is a playback stream that is always +present and never audible, and is filtered out by name. + ## Now playing When an MPRIS player is running, a track row sits above the volume bar: album diff --git a/desktop/modules/sound/Service.qml b/desktop/modules/sound/Service.qml index 22a4520..5f10c34 100644 --- a/desktop/modules/sound/Service.qml +++ b/desktop/modules/sound/Service.qml @@ -36,6 +36,14 @@ Scope { Pipewire.nodes.values.filter(n => n.audio && !n.isStream && !n.isSink && !String(n.name).endsWith(".monitor")) + // Applications playing audio. Matched on the node type: `isSink` reads + // true for a playback stream (Firefox, measured), the reverse of what it + // means for a device, so it cannot tell playback from recording here. + readonly property var streams: + Pipewire.nodes.values.filter(n => n.type === PwNodeType.AudioOutStream + // always present, never audible + && n.name !== "speech-dispatcher-dummy") + // 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 31f9e7c..3f39c48 100644 --- a/desktop/modules/sound/SoundPage.qml +++ b/desktop/modules/sound/SoundPage.qml @@ -37,6 +37,76 @@ Column { return audio.volume < 0.5 ? "\uf027" : "\uf028"; } + // 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). Past 100% a tick marks where boost starts. + component Meter: Item { + id: meter + + property var audio: null + property real max: 1 + readonly property real level: Math.min((audio?.volume ?? 0) / max, 1) + + width: parent.width + height: 20 + + Rectangle { + 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: meter.audio?.muted ? Theme.red : Theme.accent + opacity: meter.audio?.muted ? 0.5 : 1 + } + } + + Rectangle { + visible: meter.max > 1 + anchors.verticalCenter: parent.verticalCenter + x: parent.width / meter.max - 1 + width: 2 + height: 14 + radius: 1 + color: Theme.overlay + } + + MouseArea { + anchors.fill: parent + enabled: meter.audio !== null + preventStealing: true + cursorShape: Qt.PointingHandCursor + + onPressed: mouse => set(mouse.x) + onPositionChanged: mouse => { if (pressed) set(mouse.x); } + + // Wheel-up never lowers: a level already past max (set by + // something else) stays where it is rather than snapping down. + onWheel: wheel => { + const a = meter.audio; + if (!a || wheel.angleDelta.y === 0) return; + if (wheel.angleDelta.y > 0) { + a.muted = false; + a.volume = Math.max(a.volume, Math.min(meter.max, a.volume + 0.05)); + } else { + a.volume = Math.max(0, a.volume - 0.05); + } + wheel.accepted = true; + } + + function set(x) { + if (meter.audio) meter.audio.volume = Math.max(0, Math.min(meter.max, x / width * meter.max)); + } + } + } + // 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 @@ -179,104 +249,83 @@ Column { } } - // 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: 20 - - readonly property real level: Math.min(audio?.volume ?? 0, 1) - - Rectangle { - 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 - 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)); - } - } + Meter { + audio: section.audio + // Output goes to 150%, the ceiling the volume keybind already + // uses (wpctl -l 1.5). A boosted microphone only clips. + max: section.modelData.label === "Output" ? 1.5 : 1 } } } - Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + // Applications playing right now, each with its own level. Playback + // streams only: a recording stream is the privacy dot's business. Nothing + // binds these outside the page, so the tracker lives here and stops when + // the drawer closes. + PwObjectTracker { objects: page.service.streams } - // What is playing, with transport. Same Player singleton the OSD uses, - // so playerctld's duplicate is already filtered out by dbusName. Column { width: parent.width - spacing: 8 - visible: Player.active + spacing: 10 + visible: page.service.streams.length > 0 Text { - width: parent.width - elide: Text.ElideRight - text: Player.title || "Nothing playing" + text: "Applications" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } color: Theme.text } - Text { - width: parent.width - elide: Text.ElideRight - visible: Player.artist !== "" - text: Player.artist - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - color: Theme.subtext - } + Repeater { + model: page.service.streams - Row { - spacing: 4 - TransportButton { - glyph: "" - enabled: Player.current?.canGoPrevious ?? false - onClicked: Player.current?.previous() - } - TransportButton { - glyph: Player.playing ? "" : "" - enabled: Player.current?.canTogglePlaying ?? false - onClicked: Player.current?.togglePlaying() - } - TransportButton { - glyph: "" - enabled: Player.current?.canGoNext ?? false - onClicked: Player.current?.next() + Column { + id: stream + required property var modelData + readonly property var audio: modelData.audio + + width: page.width + spacing: 2 + + Item { + width: parent.width + implicitHeight: appName.implicitHeight + + Text { + id: appName + anchors { left: parent.left; right: appLevel.left; rightMargin: 8 } + elide: Text.ElideRight + text: stream.modelData.properties?.["application.name"] || page.devLabel(stream.modelData) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + + Text { + id: appLevel + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: stream.audio.muted ? "muted" : Math.round(stream.audio.volume * 100) + "%" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: stream.audio.muted ? Theme.red : Theme.subtext + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: stream.audio.muted = !stream.audio.muted + } + } + } + + // What the app is playing, when it says: two browser tabs are + // otherwise two identical rows. + Text { + width: parent.width + visible: text !== "" + elide: Text.ElideRight + text: stream.modelData.properties?.["media.name"] ?? "" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + + Meter { audio: stream.audio } } } } -- cgit v1.2.3