diff options
| -rw-r--r-- | modules/controlcenter/audio/AudioPane.qml | 145 | ||||
| -rw-r--r-- | services/Audio.qml | 36 |
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 { |