aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/sound
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/sound')
-rw-r--r--desktop/modules/sound/README.md16
-rw-r--r--desktop/modules/sound/Service.qml8
-rw-r--r--desktop/modules/sound/SoundPage.qml217
3 files changed, 157 insertions, 84 deletions
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 }
}
}
}