controlcenter: add per-application audio controls (#1098)

* feat: per-application audio controls in controlcenter and popout

* removed: per-application volume control removed from audio popout
This commit is contained in:
ATDMA 2026-01-19 00:04:17 -05:00 committed by GitHub
parent 60a567bbba
commit a17fe72be5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 179 additions and 2 deletions

View file

@ -460,6 +460,151 @@ Item {
}
}
}
SectionHeader {
title: qsTr("Applications")
description: qsTr("Control volume for individual applications")
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.small
Repeater {
model: Audio.streams
Layout.fillWidth: true
delegate: ColumnLayout {
required property var modelData
required property int index
Layout.fillWidth: true
spacing: Appearance.spacing.smaller
RowLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.normal
MaterialIcon {
text: "apps"
font.pointSize: Appearance.font.size.normal
fill: 0
}
StyledText {
Layout.fillWidth: true
elide: Text.ElideRight
maximumLineCount: 1
text: Audio.getStreamName(modelData)
font.pointSize: Appearance.font.size.normal
font.weight: 500
}
StyledInputField {
id: streamVolumeInput
Layout.preferredWidth: 70
validator: IntValidator { bottom: 0; top: 100 }
enabled: !Audio.getStreamMuted(modelData)
Component.onCompleted: {
text = Math.round(Audio.getStreamVolume(modelData) * 100).toString();
}
Connections {
target: modelData
function onAudioChanged() {
if (!streamVolumeInput.hasFocus && modelData?.audio) {
streamVolumeInput.text = Math.round(modelData.audio.volume * 100).toString();
}
}
}
onTextEdited: (text) => {
if (hasFocus) {
const val = parseInt(text);
if (!isNaN(val) && val >= 0 && val <= 100) {
Audio.setStreamVolume(modelData, val / 100);
}
}
}
onEditingFinished: {
const val = parseInt(text);
if (isNaN(val) || val < 0 || val > 100) {
text = Math.round(Audio.getStreamVolume(modelData) * 100).toString();
}
}
}
StyledText {
text: "%"
color: Colours.palette.m3outline
font.pointSize: Appearance.font.size.normal
opacity: Audio.getStreamMuted(modelData) ? 0.5 : 1
}
StyledRect {
implicitWidth: implicitHeight
implicitHeight: streamMuteIcon.implicitHeight + Appearance.padding.normal * 2
radius: Appearance.rounding.normal
color: Audio.getStreamMuted(modelData) ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer
StateLayer {
function onClicked(): void {
Audio.setStreamMuted(modelData, !Audio.getStreamMuted(modelData));
}
}
MaterialIcon {
id: streamMuteIcon
anchors.centerIn: parent
text: Audio.getStreamMuted(modelData) ? "volume_off" : "volume_up"
color: Audio.getStreamMuted(modelData) ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
}
}
}
StyledSlider {
Layout.fillWidth: true
implicitHeight: Appearance.padding.normal * 3
value: Audio.getStreamVolume(modelData)
enabled: !Audio.getStreamMuted(modelData)
opacity: enabled ? 1 : 0.5
onMoved: {
Audio.setStreamVolume(modelData, value);
if (!streamVolumeInput.hasFocus) {
streamVolumeInput.text = Math.round(value * 100).toString();
}
}
Connections {
target: modelData
function onAudioChanged() {
if (modelData?.audio) {
value = modelData.audio.volume;
}
}
}
}
}
}
StyledText {
Layout.fillWidth: true
visible: Audio.streams.length === 0
text: qsTr("No applications currently playing audio")
color: Colours.palette.m3outline
font.pointSize: Appearance.font.size.small
horizontalAlignment: Text.AlignHCenter
}
}
}
}
}
}

View file

@ -19,15 +19,20 @@ Singleton {
acc.sinks.push(node);
else if (node.audio)
acc.sources.push(node);
} else if (node.isStream && node.audio) {
// Application streams (output streams)
acc.streams.push(node);
}
return acc;
}, {
sources: [],
sinks: []
sinks: [],
streams: []
})
readonly property list<PwNode> sinks: nodes.sinks
readonly property list<PwNode> sources: nodes.sources
readonly property list<PwNode> streams: nodes.streams
readonly property PwNode sink: Pipewire.defaultAudioSink
readonly property PwNode source: Pipewire.defaultAudioSource
@ -79,6 +84,33 @@ Singleton {
Pipewire.preferredDefaultAudioSource = newSource;
}
function setStreamVolume(stream: PwNode, newVolume: real): void {
if (stream?.ready && stream?.audio) {
stream.audio.muted = false;
stream.audio.volume = Math.max(0, Math.min(Config.services.maxVolume, newVolume));
}
}
function setStreamMuted(stream: PwNode, muted: bool): void {
if (stream?.ready && stream?.audio) {
stream.audio.muted = muted;
}
}
function getStreamVolume(stream: PwNode): real {
return stream?.audio?.volume ?? 0;
}
function getStreamMuted(stream: PwNode): bool {
return !!stream?.audio?.muted;
}
function getStreamName(stream: PwNode): string {
if (!stream) return qsTr("Unknown");
// Try application name first, then description, then name
return stream.applicationName || stream.description || stream.name || qsTr("Unknown Application");
}
onSinkChanged: {
if (!sink?.ready)
return;
@ -109,7 +141,7 @@ Singleton {
}
PwObjectTracker {
objects: [...root.sinks, ...root.sources]
objects: [...root.sinks, ...root.sources, ...root.streams]
}
CavaProvider {