volumeosd: Refactor and simplify logic

This commit makes a much needed cleanup and overhaul of the volume OSD.

Changes:
- Fix context menus for app streams
- Remove dedicated mute button in cards (simplifies the UI for touch
  devices)
- Add selection background for card delegates
- Sync audio models with plasma-pa
- Simplify implementation of cards
- Sync volume slider implementation with plasma-pa
This commit is contained in:
Devin Lin 2025-09-13 18:59:09 -04:00
parent 2871d486ae
commit 67ca0fd6fc
8 changed files with 336 additions and 305 deletions

View file

@ -91,6 +91,7 @@ ecm_target_qml_sources(mobileshellplugin SOURCES
qml/popups/volumeosd/VolumeChangedPopup.qml qml/popups/volumeosd/VolumeChangedPopup.qml
qml/popups/volumeosd/VolumeOSD.qml qml/popups/volumeosd/VolumeOSD.qml
qml/popups/volumeosd/VolumeOSDProvider.qml qml/popups/volumeosd/VolumeOSDProvider.qml
qml/popups/volumeosd/VolumeSlider.qml
qml/popups/PopupProviderLoader.qml qml/popups/PopupProviderLoader.qml
qml/statusbar/indicators/BatteryIndicator.qml qml/statusbar/indicators/BatteryIndicator.qml

View file

@ -27,53 +27,68 @@ ColumnLayout {
property real scale: 1.0 property real scale: 1.0
PulseObjectFilterModel { // Input devices
id: paSinkFilterModel readonly property SourceModel paSourceModel: SourceModel { id: paSourceModel }
sortRoleName: "SortByDefault"
sortOrder: Qt.DescendingOrder
filterOutInactiveDevices: true
sourceModel: MobileShell.AudioInfo.paSinkModel
}
SourceModel { // Output devices
id: paSourceModel readonly property SinkModel paSinkModel: SinkModel { id: paSinkModel }
}
PulseObjectFilterModel { // Confusingly, Sink Input is what PulseAudio calls streams that send audio to an output device
readonly property SinkInputModel paSinkInputModel: SinkInputModel { id: paSinkInputModel }
// Confusingly, Source Output is what PulseAudio calls streams that take audio from an input device
readonly property SourceOutputModel paSourceOutputModel: SourceOutputModel { id: paSourceOutputModel }
// Active input devices
readonly property PulseObjectFilterModel paSourceFilterModel: PulseObjectFilterModel {
id: paSourceFilterModel id: paSourceFilterModel
sortRoleName: "SortByDefault"
sortOrder: Qt.DescendingOrder
filterOutInactiveDevices: true filterOutInactiveDevices: true
filterVirtualDevices: true
sourceModel: paSourceModel sourceModel: paSourceModel
} }
CardModel { // Active output devices
id: paCardModel readonly property PulseObjectFilterModel paSinkFilterModel: PulseObjectFilterModel {
id: paSinkFilterModel
filterOutInactiveDevices: true
filterVirtualDevices: true
sourceModel: paSinkModel
} }
// ui elements // non-virtual streams going to output devices
readonly property PulseObjectFilterModel paSinkInputFilterModel: PulseObjectFilterModel {
id: paSinkInputFilterModel
filters: [
{ role: "VirtualStream", value: false },
{ role: "Client", value: (client) => client.name !== "libcanberra" },
]
sourceModel: paSinkInputModel
}
// non-virtual streams coming from input devices
readonly property PulseObjectFilterModel paSourceOutputFilterModel: PulseObjectFilterModel {
id: paSourceOutputFilterModel
filters: [ { role: "VirtualStream", value: false } ]
sourceModel: paSourceOutputModel
}
readonly property CardModel paCardModel: CardModel { id: paCardModel }
// UI elements
PopupCard { PopupCard {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: Kirigami.Units.gridUnit Layout.bottomMargin: Kirigami.Units.gridUnit
scaleFactor: audioApplet.scale
transform: Scale {
origin.x: Math.round(implicitWidth / 2)
origin.y: Math.round(height / 2)
xScale: audioApplet.scale
yScale: audioApplet.scale
}
contentItem: ColumnLayout { contentItem: ColumnLayout {
anchors.rightMargin: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
anchors.leftMargin: Kirigami.Units.smallSpacing
Kirigami.Heading { Kirigami.Heading {
level: 2 level: 2
text: i18n("Outputs") text: i18n("Output Devices")
wrapMode: Text.Wrap
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.leftMargin: Kirigami.Units.smallSpacing
} }
Repeater { Repeater {
@ -83,9 +98,8 @@ ColumnLayout {
model: paSinkFilterModel model: paSinkFilterModel
delegate: DeviceListItem { delegate: DeviceListItem {
Layout.fillWidth: true Layout.fillWidth: true
Layout.margins: Kirigami.Units.smallSpacing
type: "sink" type: "sink"
onlyone: sinkView.count === 1 onlyOne: sinkView.count === 1
} }
} }
} }
@ -94,24 +108,16 @@ ColumnLayout {
PopupCard { PopupCard {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: Kirigami.Units.gridUnit Layout.bottomMargin: Kirigami.Units.gridUnit
scaleFactor: audioApplet.scale
transform: Scale {
origin.x: Math.round(implicitWidth / 2)
origin.y: Math.round(height / 2)
xScale: audioApplet.scale
yScale: audioApplet.scale
}
contentItem: ColumnLayout { contentItem: ColumnLayout {
anchors.rightMargin: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
anchors.leftMargin: Kirigami.Units.smallSpacing
Kirigami.Heading { Kirigami.Heading {
level: 2 level: 2
text: i18n("Inputs") text: i18n("Input Devices")
wrapMode: Text.Wrap
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.leftMargin: Kirigami.Units.smallSpacing
} }
Repeater { Repeater {
@ -121,38 +127,30 @@ ColumnLayout {
model: paSourceFilterModel model: paSourceFilterModel
delegate: DeviceListItem { delegate: DeviceListItem {
Layout.fillWidth: true Layout.fillWidth: true
Layout.margins: Kirigami.Units.smallSpacing
type: "source" type: "source"
onlyone: sinkView.count === 1 onlyOne: sourceView.count === 1
} }
} }
} }
} }
PopupCard { PopupCard {
visible: sourceInputView.model.count + sourceMediaInputView.model.count !== 0 visible: (sourceMediaInputView.count + sourceInputView.count) > 0
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: Kirigami.Units.gridUnit Layout.bottomMargin: Kirigami.Units.gridUnit
scaleFactor: audioApplet.scale
transform: Scale {
origin.x: Math.round(implicitWidth / 2)
origin.y: Math.round(height / 2)
xScale: audioApplet.scale
yScale: audioApplet.scale
}
contentItem: ColumnLayout { contentItem: ColumnLayout {
anchors.rightMargin: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
anchors.leftMargin: Kirigami.Units.smallSpacing
Kirigami.Heading { Kirigami.Heading {
level: 2 level: 2
text: i18n("Playback Streams") text: i18n("Playback Streams")
wrapMode: Text.Wrap
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.leftMargin: Kirigami.Units.smallSpacing
} }
// "Grouped" media sources (ex. Notifications)
Repeater { Repeater {
id: sourceMediaInputView id: sourceMediaInputView
Layout.fillWidth: true Layout.fillWidth: true
@ -166,25 +164,22 @@ ColumnLayout {
Layout.margins: Kirigami.Units.smallSpacing Layout.margins: Kirigami.Units.smallSpacing
width: sourceOutputView.width width: sourceOutputView.width
type: "sink-input" type: "sink-input"
devicesModel: sourceView.model devicesModel: paSinkFilterModel
} }
} }
// Regular playback streams
Repeater { Repeater {
id: sourceInputView id: sourceInputView
Layout.fillWidth: true Layout.fillWidth: true
model: PulseObjectFilterModel { model: paSinkInputFilterModel
filters: [ { role: "VirtualStream", value: false } ]
sourceModel: SinkInputModel {}
}
delegate: StreamListItem { delegate: StreamListItem {
Layout.fillWidth: true Layout.fillWidth: true
Layout.margins: Kirigami.Units.smallSpacing
width: sourceOutputView.width width: sourceOutputView.width
type: "sink-input" type: "sink-input"
devicesModel: sourceView.model devicesModel: paSinkFilterModel
} }
} }
} }
@ -194,40 +189,29 @@ ColumnLayout {
visible: sourceOutputView.model.count !== 0 visible: sourceOutputView.model.count !== 0
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: Kirigami.Units.gridUnit Layout.bottomMargin: Kirigami.Units.gridUnit
scaleFactor: audioApplet.scale
transform: Scale {
origin.x: Math.round(implicitWidth / 2)
origin.y: Math.round(height / 2)
xScale: audioApplet.scale
yScale: audioApplet.scale
}
contentItem: ColumnLayout { contentItem: ColumnLayout {
anchors.rightMargin: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
anchors.leftMargin: Kirigami.Units.smallSpacing
Kirigami.Heading { Kirigami.Heading {
level: 2 level: 2
text: i18n("Recording Streams") text: i18n("Recording Streams")
wrapMode: Text.Wrap
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.leftMargin: Kirigami.Units.smallSpacing
} }
Repeater { Repeater {
id: sourceOutputView id: sourceOutputView
Layout.fillWidth: true Layout.fillWidth: true
model: PulseObjectFilterModel { model: paSourceOutputFilterModel
filters: [ { role: "VirtualStream", value: false } ]
sourceModel: SourceOutputModel {}
}
delegate: StreamListItem { delegate: StreamListItem {
Layout.fillWidth: true Layout.fillWidth: true
Layout.margins: Kirigami.Units.smallSpacing
width: sourceOutputView.width width: sourceOutputView.width
type: "source-output" type: "source-output"
devicesModel: sourceView.model devicesModel: paSourceFilterModel
} }
} }
} }

View file

@ -11,25 +11,24 @@ import org.kde.plasma.private.volume 0.1
// adapted from https://invent.kde.org/plasma/plasma-pa/-/blob/master/applet/contents/ui/DeviceListItem.qml // adapted from https://invent.kde.org/plasma/plasma-pa/-/blob/master/applet/contents/ui/DeviceListItem.qml
ListItemBase { ListItemBase {
readonly property var currentPort: Ports[ActivePortIndex] readonly property var currentPort: model.Ports[model.ActivePortIndex]
readonly property var currentActivePortIndex: ActivePortIndex readonly property var currentActivePortIndex: model.ActivePortIndex
readonly property var currentMuted: Muted readonly property var currentMuted: model.Muted
readonly property var activePortIndex: ActivePortIndex readonly property var activePortIndex: model.ActivePortIndex
property bool onlyone: false
label: { label: {
if (currentPort && currentPort.description) { if (currentPort && currentPort.description) {
if (onlyone || !Description) { if (onlyOne || !model.Description) {
return currentPort.description; return currentPort.description;
} else { } else {
return i18nc("label of device items", "%1 (%2)", currentPort.description, Description); return i18nc("label of device items", "%1 (%2)", currentPort.description, model.Description);
} }
} }
if (Description) { if (model.Description) {
return Description; return model.Description;
} }
if (Name) { if (model.Name) {
return Name; return model.Name;
} }
return i18n("Device name not found"); return i18n("Device name not found");
} }

View file

@ -6,52 +6,57 @@
* SPDX-License-Identifier: GPL-2.0-or-later * SPDX-License-Identifier: GPL-2.0-or-later
*/ */
import QtQuick 2.15 import QtQuick
import QtQuick.Controls 2.15 as Controls import QtQuick.Controls as Controls
import QtQuick.Layouts 1.1 import QtQuick.Layouts
import QtQuick.Window 2.2
import org.kde.kirigami 2.20 as Kirigami import org.kde.kirigami as Kirigami
import org.kde.ksvg 1.0 as KSvg import org.kde.ksvg as KSvg
import org.kde.kquickcontrolsaddons 2.0 import org.kde.kquickcontrolsaddons
import org.kde.plasma.core as PlasmaCore import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.components 3.0 as PlasmaComponents import org.kde.plasma.components as PlasmaComponents
import org.kde.plasma.private.volume 0.1 import org.kde.plasma.extras as PlasmaExtras
import org.kde.plasma.private.volume
import "icon.js" as Icon import "icon.js" as Icon
// adapted from https://invent.kde.org/plasma/plasma-pa/-/blob/master/applet/contents/ui/ListItemBase.qml // adapted from https://invent.kde.org/plasma/plasma-pa/-/blob/master/applet/contents/ui/ListItemBase.qml
Controls.Control { Controls.AbstractButton {
id: baseItem id: baseItem
property string label property string label
property alias listIcon: clientIcon.source property alias listIcon: clientIcon.source
property string type // sink, source, source-output property string type // sink, source, source-output
MouseArea { property bool onlyOne: false
id: clickArea
anchors.fill: parent // Whether this item is selected
z: -1 readonly property bool supportsSelection: (baseItem.type == "sink" || baseItem.type == "source")
readonly property bool selected: supportsSelection && (model.PulseObject.hasOwnProperty("default") ? model.PulseObject.default : false)
onClicked: { onClicked: {
if (selectButton.visible) { // Set as the default audio device
model.PulseObject.default = true; model.PulseObject.default = true
}
} }
topPadding: Kirigami.Units.mediumSpacing
bottomPadding: Kirigami.Units.mediumSpacing
leftPadding: Kirigami.Units.mediumSpacing
rightPadding: Kirigami.Units.mediumSpacing
background: Rectangle {
radius: Kirigami.Units.cornerRadius
// border.width: 1
// border.color: baseItem.selected ? Kirigami.Theme.highlightColor : 'transparent'
color: (baseItem.selected || baseItem.down)
? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.3)
: 'transparent'
} }
contentItem: RowLayout { contentItem: RowLayout {
id: row id: row
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
PlasmaComponents.RadioButton {
id: selectButton
Layout.alignment: Qt.AlignTop
Layout.topMargin: Math.round(row.height / 2 - implicitHeight - Kirigami.Units.smallSpacing / 2) // align with text
checked: model.PulseObject.hasOwnProperty("default") ? model.PulseObject.default : false
visible: (baseItem.type == "sink" && sinkView.model.count > 1) || (baseItem.type == "source" && sourceView.model.count > 1)
onClicked: model.PulseObject.default = true
}
// application icon // application icon
Kirigami.Icon { Kirigami.Icon {
id: clientIcon id: clientIcon
@ -68,6 +73,24 @@ Controls.Control {
} }
} }
RowLayout {
spacing: 0
Layout.maximumWidth: Infinity // Ignore maximum width of children
visible: (baseItem.type === "sink" || baseItem.type === "source") && !baseItem.onlyOne
PlasmaComponents.RadioButton {
id: defaultButton
Accessible.ignored: true // read out from delegate
activeFocusOnTab: false // toggle from delegate
checked: model.PulseObject?.default ?? false
onToggled: {
if (checked) {
baseItem.click();
}
}
}
}
ColumnLayout { ColumnLayout {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true Layout.fillWidth: true
@ -87,9 +110,10 @@ Controls.Control {
} }
PlasmaComponents.ToolButton { PlasmaComponents.ToolButton {
id: viewButton
Layout.alignment: Qt.AlignBottom Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: -Kirigami.Units.smallSpacing Layout.bottomMargin: -Kirigami.Units.smallSpacing
icon.name: "application-menu" icon.name: "view-more-symbolic"
checkable: true checkable: true
checked: contextMenu.visible && contextMenu.visualParent === this checked: contextMenu.visible && contextMenu.visualParent === this
visible: contextMenu.hasContent visible: contextMenu.hasContent
@ -132,94 +156,26 @@ Controls.Control {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
PlasmaComponents.ToolButton {
icon.name: Icon.name(Volume / PulseAudio.NormalVolume * 100.0, Muted)
text: Muted ? i18n("Unmute") : i18n("Mute")
display: Controls.AbstractButton.IconOnly
Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
onClicked: {
Muted = !Muted
}
}
// this slider was effectively copied from the source (linked at the top of the file) // this slider was effectively copied from the source (linked at the top of the file)
PlasmaComponents.Slider { VolumeSlider {
id: slider id: slider
Layout.fillWidth: true Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
// Helper properties to allow async slider updates.
// While we are sliding we must not react to value updates
// as otherwise we can easily end up in a loop where value
// changes trigger volume changes trigger value changes.
property int volume: Volume
property bool ignoreValueChange: true
readonly property bool forceRaiseMaxVolume: volume >= PulseAudio.NormalVolume * 1.01
from: PulseAudio.MinimalVolume from: PulseAudio.MinimalVolume
to: PulseAudio.NormalVolume to: model.Volume >= PulseAudio.NormalVolume * 1.01 ? PulseAudio.MaximalVolume : PulseAudio.NormalVolume
stepSize: to / (to / PulseAudio.NormalVolume * 100.0) stepSize: PulseAudio.NormalVolume / 100.0
property real myStepSize: PulseAudio.NormalVolume / 100.0
visible: model.HasVolume === true // (may be undefined) visible: model.HasVolume !== false // Devices always have volume but Streams don't necessarily
enabled: model.VolumeWritable enabled: model.VolumeWritable
opacity: Muted ? 0.5 : 1 muted: model.Muted
volumeObject: model.PulseObject
activeFocusOnTab: false // access from delegate
Accessible.name: i18nc("Accessibility data on volume slider", "Adjust volume for %1", baseItem.label) value: to, model.Volume
onMoved: {
background: KSvg.FrameSvgItem { model.Volume = value;
imagePath: "widgets/slider" model.Muted = value === 0;
prefix: "groove"
width: parent.availableWidth
height: margins.top + margins.bottom
anchors.centerIn: parent
scale: parent.mirrored ? -1 : 1
KSvg.FrameSvgItem {
imagePath: "widgets/slider"
prefix: "groove-highlight"
anchors.left: parent.left
y: (parent.height - height) / 2
width: Math.max(margins.left + margins.right, slider.handle.x * meter.volume)
height: Math.max(margins.top + margins.bottom, parent.height)
opacity: meter.available && (meter.volume > 0 || animation.running)
VolumeMonitor {
id: meter
target: parent.visible ? model.PulseObject : null
} }
Behavior on width {
NumberAnimation {
id: animation
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutQuad
}
}
}
}
Component.onCompleted: {
ignoreValueChange = false;
}
onVolumeChanged: {
var oldIgnoreValueChange = ignoreValueChange;
ignoreValueChange = true;
value = Volume;
ignoreValueChange = oldIgnoreValueChange;
}
onValueChanged: {
if (!ignoreValueChange) {
Volume = value;
Muted = value == 0;
if (!pressed) {
updateTimer.restart();
}
}
}
onPressedChanged: { onPressedChanged: {
if (!pressed) { if (!pressed) {
// Make sure to sync the volume once the button was // Make sure to sync the volume once the button was
@ -227,14 +183,28 @@ Controls.Control {
// Otherwise it might be that the slider is at v10 // Otherwise it might be that the slider is at v10
// whereas PA rejected the volume change and is // whereas PA rejected the volume change and is
// still at v15 (e.g.). // still at v15 (e.g.).
updateTimer.restart(); value = Qt.binding(() => model.Volume);
} }
} }
Timer { function updateVolume() {
id: updateTimer if (model.Volume > PulseAudio.NormalVolume) {
interval: 200 model.Volume = PulseAudio.NormalVolume;
onTriggered: slider.value = Volume }
}
SequentialAnimation {
id: seqAnimation
NumberAnimation {
id: toAnimation
target: slider
property: "to"
duration: Kirigami.Units.shortDuration
easing.type: Easing.InOutQuad
}
ScriptAction {
script: slider.updateVolume()
}
} }
} }
PlasmaComponents.Label { PlasmaComponents.Label {
@ -264,8 +234,4 @@ Controls.Control {
} }
} }
} }
function setVolumeByPercent(targetPercent) {
model.PulseObject.volume = Math.round(PulseAudio.NormalVolume * (targetPercent/100));
}
} }

View file

@ -18,13 +18,21 @@ import org.kde.ksvg 1.0 as KSvg
import org.kde.plasma.components 3.0 as PlasmaComponents import org.kde.plasma.components 3.0 as PlasmaComponents
import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell as MobileShell
// capture presses on the audio applet so it doesn't close the overlay
Controls.Control { Controls.Control {
id: content id: content
implicitWidth: Math.min(Kirigami.Units.gridUnit * 20, Screen.width - Kirigami.Units.gridUnit * 2)
padding: Kirigami.Units.smallSpacing * 2
property bool popupBackground: false property bool popupBackground: false
property real scaleFactor: 1.0
implicitWidth: Math.min(Kirigami.Units.gridUnit * 20, Screen.width - Kirigami.Units.gridUnit * 2)
padding: Kirigami.Units.largeSpacing
transform: Scale {
origin.x: Math.round(implicitWidth / 2)
origin.y: Math.round(height / 2)
xScale: scaleFactor
yScale: scaleFactor
}
Kirigami.Theme.colorSet: Kirigami.Theme.View Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false Kirigami.Theme.inherit: false

View file

@ -44,7 +44,7 @@ Window {
if (cards.state == "closed") { if (cards.state == "closed") {
hideTimer.stop(); hideTimer.stop();
window.open(); window.open();
} else if (!volumeSlider.isPressed) { } else if (!volumeSlider.pressed) {
hideTimer.restart(); hideTimer.restart();
} }
} }
@ -154,81 +154,41 @@ Window {
property int volumePercent: PreferredDevice.sink.volume / PulseAudio.NormalVolume * 100.0 property int volumePercent: PreferredDevice.sink.volume / PulseAudio.NormalVolume * 100.0
PlasmaComponents.ToolButton { PlasmaComponents.ToolButton {
icon.name: !PreferredDevice.sink || PreferredDevice.sink.muted ? "audio-volume-muted" : MobileShell.AudioInfo.icon icon.name: !PreferredDevice.sink || (PreferredDevice.sink.muted ? "audio-volume-muted" : MobileShell.AudioInfo.icon)
text: !PreferredDevice.sink || PreferredDevice.sink.muted ? i18n("Unmute") : i18n("Mute") text: !PreferredDevice.sink || (PreferredDevice.sink.muted ? i18n("Unmute") : i18n("Mute"))
display: Controls.AbstractButton.IconOnly display: Controls.AbstractButton.IconOnly
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: Kirigami.Units.iconSizes.medium Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium Layout.preferredHeight: Kirigami.Units.iconSizes.medium
Layout.rightMargin: Kirigami.Units.smallSpacing Layout.rightMargin: Kirigami.Units.smallSpacing
onClicked: { onClicked: {
hideTimer.restart(); hideTimer.restart();
PreferredDevice.sink.muted = !PreferredDevice.sink.muted; PreferredDevice.sink.muted = !PreferredDevice.sink.muted;
} }
} }
PlasmaComponents.Slider { VolumeSlider {
id: volumeSlider id: volumeSlider
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
Layout.rightMargin: Kirigami.Units.smallSpacing * 2 Layout.rightMargin: Kirigami.Units.smallSpacing * 2
property real volume: PreferredDevice.sink.volume
property bool muted: PreferredDevice.sink.muted
property bool ignoreValueChange: false
property bool isPressed: false
from: PulseAudio.MinimalVolume from: PulseAudio.MinimalVolume
to: PulseAudio.NormalVolume to: PulseAudio.NormalVolume
stepSize: to / (to / PulseAudio.NormalVolume * 100.0) stepSize: to / (to / PulseAudio.NormalVolume * 100.0)
opacity: muted ? 0.5 : 1.0
Component.onCompleted: { volumeObject: PreferredDevice.sink
ignoreValueChange = false; muted: PreferredDevice.sink.muted
} value: PreferredDevice.sink.volume
onVolumeChanged: { onMoved: {
if (!window.visible) {
return;
}
var oldIgnoreValueChange = ignoreValueChange;
ignoreValueChange = true;
value = muted ? 0 : PreferredDevice.sink.volume;
ignoreValueChange = oldIgnoreValueChange;
if (volumeSlider.isPressed) {
return;
}
window.open();
hideTimer.restart();
}
onMutedChanged: {
var oldIgnoreValueChange = ignoreValueChange;
ignoreValueChange = true;
value = muted ? 0 : PreferredDevice.sink.volume;
ignoreValueChange = oldIgnoreValueChange;
if (!window.visible || volumeSlider.isPressed) {
return;
}
window.open();
hideTimer.restart();
}
onValueChanged: {
if (!ignoreValueChange) {
PreferredDevice.sink.muted = false;
PreferredDevice.sink.volume = value; PreferredDevice.sink.volume = value;
if (!volumeSlider.isPressed) { PreferredDevice.sink.muted = value === 0;
updateTimer.restart();
} }
}
}
onPressedChanged: { onPressedChanged: {
volumeSlider.isPressed = pressed;
if (pressed) { if (pressed) {
window.open();
hideTimer.stop(); hideTimer.stop();
} else { } else {
// Make sure to sync the volume once the button was // Make sure to sync the volume once the button was
@ -236,16 +196,10 @@ Window {
// Otherwise it might be that the slider is at v10 // Otherwise it might be that the slider is at v10
// whereas PA rejected the volume change and is // whereas PA rejected the volume change and is
// still at v15 (e.g.). // still at v15 (e.g.).
value = Qt.binding(() => PreferredDevice.sink.volume);
hideTimer.restart(); hideTimer.restart();
updateTimer.restart();
} }
} }
Timer {
id: updateTimer
interval: 200
onTriggered: volumeSlider.value = PreferredDevice.sink.volume
}
} }
PlasmaComponents.ToolButton { PlasmaComponents.ToolButton {

View file

@ -177,21 +177,10 @@ Window {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: Kirigami.Units.gridUnit Layout.bottomMargin: Kirigami.Units.gridUnit
transform: Scale { scaleFactor: flickable.scale
origin.x: Math.round(implicitWidth / 2)
origin.y: Math.round(height / 2)
xScale: flickable.scale
yScale: flickable.scale
}
contentItem: RowLayout { contentItem: PlasmaComponents.ToolButton {
id: audioSettingsButton
PlasmaComponents.ToolButton {
property int addedPadding: Kirigami.Units.smallSpacing * 2
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
Layout.preferredWidth: parent.width - addedPadding * 2
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
Layout.margins: addedPadding
contentItem: Item { contentItem: Item {
anchors.fill: parent anchors.fill: parent
@ -221,4 +210,3 @@ Window {
} }
} }
} }
}

View file

@ -0,0 +1,131 @@
/*
SPDX-FileCopyrightText: 2014-2015 Harald Sitter <sitter@kde.org>
SPDX-FileCopyrightText: 2019 Sefa Eyeoglu <contact@scrumplex.net>
SPDX-FileCopyrightText: 2022 ivan (@ratijas) tkachenko <me@ratijas.tk>
SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
import QtQuick
import QtQuick.Layouts
import org.kde.kquickcontrolsaddons
import org.kde.plasma.components as PC3
import org.kde.ksvg as KSvg
import org.kde.kirigami as Kirigami
import org.kde.plasma.private.volume
// Audio volume slider. Value represents desired volume level in
// device-specific units, while volume property reports current volume level
// normalized to 0..1 range.
PC3.Slider {
id: control
property VolumeObject volumeObject
// When muted, the whole slider will appear slightly faded, but remain
// functional and interactive.
property bool muted: false
// Current (monitored) volume. To be animated. Do not update too fast
// (i.e. faster or close to screen refresh rate), otherwise it won't
// animate smoothly.
property real volume: meter.volume
VolumeMonitor {
id: meter
target: control.visible ? control.volumeObject : null
}
Behavior on volume {
NumberAnimation {
id: animate
duration: Kirigami.Units.shortDuration
easing.type: Easing.OutQuad
}
}
// When a maximum volume limit is raised/lower, animate the change.
Behavior on to {
NumberAnimation {
duration: Kirigami.Units.shortDuration
easing.type: Easing.InOutQuad
}
}
opacity: muted ? 0.5 : 1
// Prevents the groove from showing through the handle
layer.enabled: opacity < 1
wheelEnabled: false
// `wheelEnabled: true` doesn't work we can't both respect stepsize
// on scroll and allow fine-tuning on drag.
// So we have to implement the scroll handling ourselves. See
// https://bugreports.qt.io/browse/QTBUG-93081
WheelHandler {
orientation: Qt.Vertical | Qt.Horizontal
property int wheelDelta: 0
acceptedButtons: Qt.NoButton
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: wheel => {
const lastValue = control.value
// We want a positive delta to increase the slider for up/right scrolling,
// independently of the scrolling inversion setting
// The x-axis is also inverted (scrolling right produce negative values)
const delta = (wheel.angleDelta.y || -wheel.angleDelta.x) * (wheel.inverted ? -1 : 1)
wheelDelta += delta;
// magic number 120 for common "one click"
// See: https://doc.qt.io/qt-6/qml-qtquick-wheelevent.html#angleDelta-prop
while (wheelDelta >= 120) {
wheelDelta -= 120;
control.increase();
}
while (wheelDelta <= -120) {
wheelDelta += 120;
control.decrease();
}
if (lastValue !== control.value) {
control.moved();
}
}
}
background: KSvg.FrameSvgItem {
imagePath: "widgets/slider"
prefix: "groove"
implicitWidth: control.horizontal ? Kirigami.Units.gridUnit * 12 : fixedMargins.left + fixedMargins.right
implicitHeight: control.vertical ? Kirigami.Units.gridUnit * 12 : fixedMargins.top + fixedMargins.bottom
width: control.horizontal ? Math.max(fixedMargins.left + fixedMargins.right, control.availableWidth) : implicitWidth
height: control.vertical ? Math.max(fixedMargins.top + fixedMargins.bottom, control.availableHeight) : implicitHeight
x: control.leftPadding + (control.horizontal ? 0 : Math.round((control.availableWidth - width) / 2))
y: control.topPadding + (control.vertical ? 0 : Math.round((control.availableHeight - height) / 2))
KSvg.FrameSvgItem {
imagePath: "widgets/slider"
prefix: "groove-highlight"
anchors.left: parent.left
anchors.bottom: parent.bottom
LayoutMirroring.enabled: control.mirrored
width: control.horizontal ? Math.max(fixedMargins.left + fixedMargins.right, Math.round(control.position * (control.availableWidth - control.handle.width / 2) + (control.handle.width / 2))) : parent.width
height: control.vertical ? Math.max(fixedMargins.top + fixedMargins.bottom, Math.round(control.position * (control.availableHeight - control.handle.height / 2) + (control.handle.height / 2))) : parent.height
}
KSvg.FrameSvgItem {
imagePath: "widgets/slider"
prefix: "groove-highlight"
status: KSvg.FrameSvgItem.Selected
visible: meter.available && control.volume > 0
anchors.left: parent.left
anchors.bottom: parent.bottom
LayoutMirroring.enabled: control.mirrored
width: control.horizontal ? Math.max(fixedMargins.left + fixedMargins.right, Math.round(control.volume * control.position * control.availableWidth)) : parent.width
height: control.vertical ? Math.max(fixedMargins.top + fixedMargins.bottom, Math.round(control.volume * control.position * control.availableHeight)) : parent.height
}
}
}