Add application volume mixer

This commit is contained in:
Gabriel Brown
2026-08-18 15:00:18 -04:00
parent 0f3bddc452
commit db34b1e6ed
7 changed files with 236 additions and 7 deletions
@@ -0,0 +1,48 @@
import Quickshell.Services.Pipewire
import QtQuick
import qs.config
import qs.services
Column {
id: root
property var applications: AudioDevices.applications
property bool pipewireReady: Pipewire.ready
readonly property int rowCount: applicationRepeater.count
readonly property string statusText: {
if (!root.pipewireReady)
return "PipeWire is unavailable";
if (root.applications.length === 0)
return "Applications playing sound will appear here";
return "";
}
width: parent ? parent.width : 620
spacing: 8
Repeater {
id: applicationRepeater
model: root.applications
ApplicationVolumeRow {
required property var modelData
width: root.width
application: modelData
}
}
Text {
width: parent.width
visible: root.statusText !== ""
text: root.statusText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
topPadding: 18
bottomPadding: 18
}
}
@@ -0,0 +1,106 @@
import Quickshell.Services.Pipewire
import QtQuick
import qs.config
import qs.modules.quicksettings
import qs.services
import qs.widgets
Rectangle {
id: root
required property var application
readonly property real volume: AudioDevices.applicationVolume(root.application)
readonly property bool muted: AudioDevices.applicationMuted(root.application)
width: parent ? parent.width : 620
implicitHeight: 78
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.025)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
PwObjectTracker {
objects: root.application?.nodes ?? []
}
ThemedIcon {
id: applicationIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.top: parent.top
anchors.topMargin: 11
size: 20
icon: root.application?.icon ?? "audio-x-generic-symbolic"
iconFallback: "audio-x-generic-symbolic"
tint: Theme.fg
}
Column {
anchors.left: applicationIcon.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: applicationIcon.verticalCenter
spacing: 1
Text {
width: parent.width
text: root.application?.label ?? "Unknown application"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: (root.application?.nodes?.length ?? 0) > 1
text: `${root.application?.nodes?.length ?? 0} audio streams`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
IconButton {
id: muteButton
anchors.left: parent.left
anchors.leftMargin: 8
anchors.bottom: parent.bottom
anchors.bottomMargin: 6
size: 30
iconSize: 17
icon: root.muted || root.volume <= 0.001
? "audio-volume-muted-symbolic"
: "audio-volume-high-symbolic"
iconFallback: "audio-volume-high-symbolic"
onClicked: AudioDevices.setApplicationMuted(root.application, !root.muted)
}
ValueSlider {
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 => AudioDevices.setApplicationVolume(root.application, 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
}
}
@@ -32,7 +32,7 @@ SettingRow {
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
readonly property bool available: root.node?.audio
readonly property bool available: !!root.node?.audio
&& root.leftIndex >= 0 && root.rightIndex >= 0
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
@@ -31,6 +31,15 @@ SettingsPage {
}
}
SettingsCard {
title: "Applications"
subtitle: "Control each application currently playing through PipeWire."
ApplicationMixer {
width: parent.width
}
}
SettingsCard {
title: "Sound feedback"
subtitle: "Use the same event preferences as GTK and GNOME applications."
@@ -67,8 +76,8 @@ SettingsPage {
title: "Advanced sound"
ActionRow {
label: "Application volumes and profiles"
detail: "Open Fedora's complete sound panel"
label: "Device profiles"
detail: "Open Fedora's complete device profile panel"
divider: false
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("sound")
@@ -46,6 +46,8 @@ PasswordField 1.0 PasswordField.qml
AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml
SoundDeviceRow 1.0 SoundDeviceRow.qml
ApplicationMixer 1.0 ApplicationMixer.qml
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml
+52 -1
View File
@@ -9,11 +9,55 @@ import qs.services
import qs.modules.settings
ShellRoot {
id: root
QtObject {
id: fixtureAudio
property real volume: 0.42
property bool muted: false
}
QtObject {
id: fixtureNode
property QtObject audio: fixtureAudio
}
readonly property var fixtureApplication: ({
key: "fixture.player",
label: "Fixture Player",
icon: "audio-x-generic-symbolic",
nodes: [fixtureNode]
})
SoundPage {
width: 760
height: 900
}
ApplicationMixer {
id: populatedMixer
width: 620
visible: false
applications: [root.fixtureApplication]
pipewireReady: true
}
ApplicationMixer {
id: emptyMixer
width: 620
visible: false
applications: []
pipewireReady: true
}
ApplicationMixer {
id: unavailableMixer
width: 620
visible: false
applications: []
pipewireReady: false
}
PwObjectTracker {
objects: AudioDevices.outputs.concat(AudioDevices.inputs)
}
@@ -26,8 +70,15 @@ ShellRoot {
ready: Pipewire.ready,
outputs: AudioDevices.outputs.length,
inputs: AudioDevices.inputs.length,
applications: AudioDevices.applications.length,
defaultOutput: AudioDevices.label(AudioDevices.current(true)),
defaultInput: AudioDevices.label(AudioDevices.current(false))
defaultInput: AudioDevices.label(AudioDevices.current(false)),
populatedRows: populatedMixer.rowCount,
populatedStatus: populatedMixer.statusText,
emptyRows: emptyMixer.rowCount,
emptyStatus: emptyMixer.statusText,
unavailableRows: unavailableMixer.rowCount,
unavailableStatus: unavailableMixer.statusText
});
}
}
+16 -3
View File
@@ -10,6 +10,8 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml"
device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml"
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml"
application_mixer="$repo_dir/config/dot/quickshell/modules/settings/ApplicationMixer.qml"
application_row="$repo_dir/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml"
balance="$repo_dir/config/dot/quickshell/modules/settings/AudioBalance.qml"
audio_devices="$repo_dir/config/dot/quickshell/services/AudioDevices.qml"
sound_feedback="$repo_dir/config/dot/quickshell/services/SoundFeedback.qml"
@@ -17,6 +19,7 @@ quick_devices="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioDevice
harness="$repo_dir/config/dot/quickshell/sound-page-harness.qml"
config_home="$(mktemp -d /tmp/panama-sound-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-sound-state.XXXXXX)"
shell_log="$state_home/quickshell.log"
fail() {
printf 'sound page contract: %s\n' "$1" >&2
@@ -33,7 +36,7 @@ cleanup() {
}
trap cleanup EXIT
for file in "$sound_page" "$device_list" "$device_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do
for file in "$sound_page" "$device_list" "$device_row" "$application_mixer" "$application_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done
@@ -61,6 +64,8 @@ rg -Fq 'root.node.audio.volumes = next;' "$balance" || fail 'balance does not wr
[[ "$(rg -c 'SoundDeviceList \{' "$sound_page")" -eq 2 ]] || fail 'Sound page does not expose output and input device lists'
rg -Fq 'AudioBalance {' "$sound_page" || fail 'Sound page has no output balance control'
rg -Fq 'ApplicationMixer {' "$sound_page" || fail 'Sound page has no Applications mixer'
rg -Fq 'label: "Device profiles"' "$sound_page" || fail 'advanced handoff is not limited to device profiles'
rg -Fq 'SystemSettings.openGnomePanel("sound")' "$sound_page" || fail 'advanced GNOME Sound handoff was removed'
rg -Fq 'SoundFeedback.setEventSounds(checked)' "$sound_page" || fail 'event sounds are not controllable'
rg -Fq 'SoundFeedback.setInputFeedback(checked)' "$sound_page" || fail 'input feedback sounds are not controllable'
@@ -76,7 +81,7 @@ printf 'sound page static contract: PASS\n'
# Instantiate the complete page against the real, read-only PipeWire graph.
# Merely constructing these controls must never change a default or volume.
qs_for_harness --daemonize >/dev/null
qs_for_harness --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 60); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' && break
sleep 0.1
@@ -90,10 +95,18 @@ for _ in $(seq 1 60); do
sleep 0.1
done
jq -e '.ready == true and .outputs > 0 and .inputs > 0
and (.defaultOutput | length > 0) and (.defaultInput | length > 0)' \
and .applications >= 0
and (.defaultOutput | length > 0) and (.defaultInput | length > 0)
and .populatedRows == 1 and .populatedStatus == ""
and .emptyRows == 0 and .emptyStatus == "Applications playing sound will appear here"
and .unavailableRows == 0 and .unavailableStatus == "PipeWire is unavailable"' \
<<<"$status" >/dev/null \
|| fail "real PipeWire graph was not represented: $status"
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign|PwObjectTracker' "$shell_log"; then
fail 'Sound page emitted a QML runtime warning'
fi
trap - EXIT
cleanup
printf 'sound page runtime: PASS (%s outputs, %s inputs)\n' \