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:
parent
60a567bbba
commit
a17fe72be5
2 changed files with 179 additions and 2 deletions
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,20 @@ Singleton {
|
||||||
acc.sinks.push(node);
|
acc.sinks.push(node);
|
||||||
else if (node.audio)
|
else if (node.audio)
|
||||||
acc.sources.push(node);
|
acc.sources.push(node);
|
||||||
|
} else if (node.isStream && node.audio) {
|
||||||
|
// Application streams (output streams)
|
||||||
|
acc.streams.push(node);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {
|
}, {
|
||||||
sources: [],
|
sources: [],
|
||||||
sinks: []
|
sinks: [],
|
||||||
|
streams: []
|
||||||
})
|
})
|
||||||
|
|
||||||
readonly property list<PwNode> sinks: nodes.sinks
|
readonly property list<PwNode> sinks: nodes.sinks
|
||||||
readonly property list<PwNode> sources: nodes.sources
|
readonly property list<PwNode> sources: nodes.sources
|
||||||
|
readonly property list<PwNode> streams: nodes.streams
|
||||||
|
|
||||||
readonly property PwNode sink: Pipewire.defaultAudioSink
|
readonly property PwNode sink: Pipewire.defaultAudioSink
|
||||||
readonly property PwNode source: Pipewire.defaultAudioSource
|
readonly property PwNode source: Pipewire.defaultAudioSource
|
||||||
|
|
@ -79,6 +84,33 @@ Singleton {
|
||||||
Pipewire.preferredDefaultAudioSource = newSource;
|
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: {
|
onSinkChanged: {
|
||||||
if (!sink?.ready)
|
if (!sink?.ready)
|
||||||
return;
|
return;
|
||||||
|
|
@ -109,7 +141,7 @@ Singleton {
|
||||||
}
|
}
|
||||||
|
|
||||||
PwObjectTracker {
|
PwObjectTracker {
|
||||||
objects: [...root.sinks, ...root.sources]
|
objects: [...root.sinks, ...root.sources, ...root.streams]
|
||||||
}
|
}
|
||||||
|
|
||||||
CavaProvider {
|
CavaProvider {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue