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 leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight) 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.leftIndex >= 0 && root.rightIndex >= 0
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex) && 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 { SettingsCard {
title: "Sound feedback" title: "Sound feedback"
subtitle: "Use the same event preferences as GTK and GNOME applications." subtitle: "Use the same event preferences as GTK and GNOME applications."
@@ -67,8 +76,8 @@ SettingsPage {
title: "Advanced sound" title: "Advanced sound"
ActionRow { ActionRow {
label: "Application volumes and profiles" label: "Device profiles"
detail: "Open Fedora's complete sound panel" detail: "Open Fedora's complete device profile panel"
divider: false divider: false
action: "Open panel" action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("sound") onTriggered: SystemSettings.openGnomePanel("sound")
@@ -46,6 +46,8 @@ PasswordField 1.0 PasswordField.qml
AudioBalance 1.0 AudioBalance.qml AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml SoundDeviceList 1.0 SoundDeviceList.qml
SoundDeviceRow 1.0 SoundDeviceRow.qml SoundDeviceRow 1.0 SoundDeviceRow.qml
ApplicationMixer 1.0 ApplicationMixer.qml
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml FontPicker 1.0 FontPicker.qml
+52 -1
View File
@@ -9,11 +9,55 @@ import qs.services
import qs.modules.settings import qs.modules.settings
ShellRoot { 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 { SoundPage {
width: 760 width: 760
height: 900 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 { PwObjectTracker {
objects: AudioDevices.outputs.concat(AudioDevices.inputs) objects: AudioDevices.outputs.concat(AudioDevices.inputs)
} }
@@ -26,8 +70,15 @@ ShellRoot {
ready: Pipewire.ready, ready: Pipewire.ready,
outputs: AudioDevices.outputs.length, outputs: AudioDevices.outputs.length,
inputs: AudioDevices.inputs.length, inputs: AudioDevices.inputs.length,
applications: AudioDevices.applications.length,
defaultOutput: AudioDevices.label(AudioDevices.current(true)), 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" sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml"
device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml" device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml"
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.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" balance="$repo_dir/config/dot/quickshell/modules/settings/AudioBalance.qml"
audio_devices="$repo_dir/config/dot/quickshell/services/AudioDevices.qml" audio_devices="$repo_dir/config/dot/quickshell/services/AudioDevices.qml"
sound_feedback="$repo_dir/config/dot/quickshell/services/SoundFeedback.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" harness="$repo_dir/config/dot/quickshell/sound-page-harness.qml"
config_home="$(mktemp -d /tmp/panama-sound-config.XXXXXX)" config_home="$(mktemp -d /tmp/panama-sound-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-sound-state.XXXXXX)" state_home="$(mktemp -d /tmp/panama-sound-state.XXXXXX)"
shell_log="$state_home/quickshell.log"
fail() { fail() {
printf 'sound page contract: %s\n' "$1" >&2 printf 'sound page contract: %s\n' "$1" >&2
@@ -33,7 +36,7 @@ cleanup() {
} }
trap cleanup EXIT 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/"}" [[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done 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 -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 '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 '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.setEventSounds(checked)' "$sound_page" || fail 'event sounds are not controllable'
rg -Fq 'SoundFeedback.setInputFeedback(checked)' "$sound_page" || fail 'input feedback 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. # Instantiate the complete page against the real, read-only PipeWire graph.
# Merely constructing these controls must never change a default or volume. # 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 for _ in $(seq 1 60); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' && break qs_for_harness ipc show 2>/dev/null | rg -q '^target sound-page-test$' && break
sleep 0.1 sleep 0.1
@@ -90,10 +95,18 @@ for _ in $(seq 1 60); do
sleep 0.1 sleep 0.1
done done
jq -e '.ready == true and .outputs > 0 and .inputs > 0 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 \ <<<"$status" >/dev/null \
|| fail "real PipeWire graph was not represented: $status" || 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 trap - EXIT
cleanup cleanup
printf 'sound page runtime: PASS (%s outputs, %s inputs)\n' \ printf 'sound page runtime: PASS (%s outputs, %s inputs)\n' \