diff --git a/config/dot/quickshell/modules/settings/ApplicationMixer.qml b/config/dot/quickshell/modules/settings/ApplicationMixer.qml new file mode 100644 index 0000000..d87c1ca --- /dev/null +++ b/config/dot/quickshell/modules/settings/ApplicationMixer.qml @@ -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 + } +} diff --git a/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml b/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml new file mode 100644 index 0000000..e5a8a1c --- /dev/null +++ b/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml @@ -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 + } +} diff --git a/config/dot/quickshell/modules/settings/AudioBalance.qml b/config/dot/quickshell/modules/settings/AudioBalance.qml index 1458153..5a0965f 100644 --- a/config/dot/quickshell/modules/settings/AudioBalance.qml +++ b/config/dot/quickshell/modules/settings/AudioBalance.qml @@ -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) diff --git a/config/dot/quickshell/modules/settings/SoundPage.qml b/config/dot/quickshell/modules/settings/SoundPage.qml index 3254ecc..0f430e6 100644 --- a/config/dot/quickshell/modules/settings/SoundPage.qml +++ b/config/dot/quickshell/modules/settings/SoundPage.qml @@ -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") diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index afcc332..ed91864 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -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 diff --git a/config/dot/quickshell/sound-page-harness.qml b/config/dot/quickshell/sound-page-harness.qml index a653197..27a0e8e 100644 --- a/config/dot/quickshell/sound-page-harness.qml +++ b/config/dot/quickshell/sound-page-harness.qml @@ -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 }); } } diff --git a/tests/quickshell/sound-page-contract.sh b/tests/quickshell/sound-page-contract.sh index 4cd2a25..a267c99 100755 --- a/tests/quickshell/sound-page-contract.sh +++ b/tests/quickshell/sound-page-contract.sh @@ -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' \