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);
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue