summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/controlcenter/audio/AudioPane.qml145
-rw-r--r--services/Audio.qml36
2 files changed, 179 insertions, 2 deletions
diff --git a/modules/controlcenter/audio/AudioPane.qml b/modules/controlcenter/audio/AudioPane.qml
index 76122f9..e4a1a64 100644
--- a/modules/controlcenter/audio/AudioPane.qml
+++ b/modules/controlcenter/audio/AudioPane.qml
@@ -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
+ }
+ }
+ }
}
}
}
diff --git a/services/Audio.qml b/services/Audio.qml
index 71ccb86..20d9cc8 100644
--- a/services/Audio.qml
+++ b/services/Audio.qml
@@ -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 {