Warning, /plasma/plasma-pa/applet/contents/ui/main.qml is written in an unsupported language. File is not indexed.

0001 /*
0002     SPDX-FileCopyrightText: 2014-2015 Harald Sitter <sitter@kde.org>
0003 
0004     SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0005 */
0006 
0007 import QtQuick
0008 import QtQuick.Layouts
0009 
0010 import org.kde.plasma.core as PlasmaCore
0011 import org.kde.ksvg as KSvg
0012 import org.kde.kirigami as Kirigami
0013 import org.kde.kitemmodels as KItemModels
0014 import org.kde.plasma.components as PC3
0015 import org.kde.plasma.extras as PlasmaExtras
0016 import org.kde.plasma.plasmoid
0017 
0018 import org.kde.kcmutils as KCMUtils
0019 import org.kde.config as KConfig
0020 
0021 import org.kde.plasma.private.volume
0022 
0023 import "../code/icon.js" as Icon
0024 
0025 PlasmoidItem {
0026     id: main
0027 
0028     GlobalConfig {
0029         id: config
0030     }
0031 
0032     property bool volumeFeedback: config.audioFeedback
0033     property bool globalMute: config.globalMute
0034     property int currentMaxVolumePercent: config.raiseMaximumVolume ? 150 : 100
0035     property int currentMaxVolumeValue: currentMaxVolumePercent * PulseAudio.NormalVolume / 100.00
0036     property int volumePercentStep: config.volumeStep
0037     property string displayName: i18n("Audio Volume")
0038     property QtObject draggedStream: null
0039 
0040     Connections {
0041         target: paSinkModel.preferredSink
0042         function onVolumeChanged() {
0043             osd.showVolume(volumePercent(paSinkModel.preferredSink.volume))
0044         }
0045     }
0046 
0047     property bool showVirtualDevices: Plasmoid.configuration.showVirtualDevices
0048 
0049     // DEFAULT_SINK_NAME in module-always-sink.c
0050     readonly property string dummyOutputName: "auto_null"
0051     readonly property string noDevicePlaceholderMessage: i18n("No output or input devices found")
0052 
0053     switchHeight: Layout.minimumHeight
0054     switchWidth: Layout.minimumWidth
0055 
0056     Plasmoid.icon: paSinkModel.preferredSink && !isDummyOutput(paSinkModel.preferredSink) ? Icon.name(paSinkModel.preferredSink.volume, paSinkModel.preferredSink.muted)
0057                                                                                           : Icon.name(0, true)
0058     toolTipMainText: {
0059         var sink = paSinkModel.preferredSink;
0060         if (!sink || isDummyOutput(sink)) {
0061             return displayName;
0062         }
0063 
0064         if (sink.muted) {
0065             return i18n("Audio Muted");
0066         } else {
0067             return i18n("Volume at %1%", volumePercent(sink.volume));
0068         }
0069     }
0070     toolTipSubText: {
0071         let lines = [];
0072 
0073         if (paSinkModel.preferredSink && paSinkFilterModel.count > 1 && !isDummyOutput(paSinkModel.preferredSink)) {
0074             lines.push(nodeName(paSinkModel.preferredSink))
0075         }
0076 
0077         if (paSinkFilterModel.count > 0) {
0078             lines.push(main.globalMute ? i18n("Middle-click to unmute")
0079                                     : i18n("Middle-click to mute all audio"));
0080             lines.push(i18n("Scroll to adjust volume"));
0081         } else {
0082             lines.push(main.noDevicePlaceholderMessage);
0083         }
0084         return lines.join("\n");
0085     }
0086 
0087     function nodeName(pulseObject) {
0088         const nodeNick = pulseObject.pulseProperties["node.nick"]
0089         if (nodeNick) {
0090             return nodeNick
0091         }
0092 
0093         if (pulseObject.description) {
0094             return pulseObject.description
0095         }
0096 
0097         if (pulseObject.name) {
0098             return pulseObject.name
0099         }
0100 
0101         return i18n("Device name not found")
0102     }
0103 
0104     function isDummyOutput(output) {
0105         return output && output.name === dummyOutputName;
0106     }
0107 
0108     function boundVolume(volume) {
0109         return Math.max(PulseAudio.MinimalVolume, Math.min(volume, currentMaxVolumeValue));
0110     }
0111 
0112     function volumePercent(volume) {
0113         return Math.round(volume / PulseAudio.NormalVolume * 100.0);
0114     }
0115 
0116     // Increment a VolumeObject (Sink or Source) by %volume.
0117     function changeVolumeByPercent(volumeObject, deltaPercent) {
0118         const oldVolume = volumeObject.volume;
0119         const oldPercent = volumePercent(oldVolume);
0120         const targetPercent = oldPercent + deltaPercent;
0121         const newVolume = boundVolume(Math.round(PulseAudio.NormalVolume * (targetPercent/100)));
0122         const newPercent = volumePercent(newVolume);
0123         volumeObject.muted = newPercent == 0;
0124         volumeObject.volume = newVolume;
0125         return newPercent;
0126     }
0127 
0128     // Increment the preferredSink by %volume.
0129     function changeSpeakerVolume(deltaPercent) {
0130         if (!paSinkModel.preferredSink || isDummyOutput(paSinkModel.preferredSink)) {
0131             return;
0132         }
0133         const newPercent = changeVolumeByPercent(paSinkModel.preferredSink, deltaPercent);
0134         osd.showVolume(newPercent);
0135         playFeedback();
0136     }
0137 
0138     function increaseVolume(modifiers) {
0139         if (globalMute) {
0140             disableGlobalMute();
0141         }
0142         changeSpeakerVolume((modifiers & Qt.ShiftModifier) ? 1 : volumePercentStep);
0143     }
0144 
0145     function decreaseVolume(modifiers) {
0146         if (globalMute) {
0147             disableGlobalMute();
0148         }
0149         changeSpeakerVolume((modifiers & Qt.ShiftModifier) ? -1 : -volumePercentStep);
0150     }
0151 
0152     function muteVolume() {
0153         if (!paSinkModel.preferredSink || isDummyOutput(paSinkModel.preferredSink)) {
0154             return;
0155         }
0156         var toMute = !paSinkModel.preferredSink.muted;
0157         if (toMute) {
0158             enableGlobalMute();
0159             osd.showMute(0);
0160         } else {
0161             if (globalMute) {
0162                 disableGlobalMute();
0163             }
0164             paSinkModel.preferredSink.muted = toMute;
0165             osd.showMute(volumePercent(paSinkModel.preferredSink.volume));
0166             playFeedback();
0167         }
0168     }
0169 
0170     // Increment the defaultSource by %volume.
0171     function changeMicrophoneVolume(deltaPercent) {
0172         if (!paSourceModel.defaultSource) {
0173             return;
0174         }
0175         const newPercent = changeVolumeByPercent(paSourceModel.defaultSource, deltaPercent);
0176         osd.showMic(newPercent);
0177     }
0178 
0179     function increaseMicrophoneVolume() {
0180         changeMicrophoneVolume(volumePercentStep);
0181     }
0182 
0183     function decreaseMicrophoneVolume() {
0184         changeMicrophoneVolume(-volumePercentStep);
0185     }
0186 
0187     function muteMicrophone() {
0188         if (!paSourceModel.defaultSource) {
0189             return;
0190         }
0191         var toMute = !paSourceModel.defaultSource.muted;
0192         paSourceModel.defaultSource.muted = toMute;
0193         osd.showMicMute(toMute? 0 : volumePercent(paSourceModel.defaultSource.volume));
0194     }
0195 
0196     function playFeedback(sinkIndex) {
0197         if (!volumeFeedback) {
0198             return;
0199         }
0200         if (sinkIndex == undefined) {
0201             sinkIndex = paSinkModel.preferredSink.index;
0202         }
0203         feedback.play(sinkIndex);
0204     }
0205 
0206 
0207     function enableGlobalMute() {
0208         var role = paSinkModel.KItemModels.KRoleNames.role("Muted");
0209         var rowCount = paSinkModel.rowCount();
0210         // List for devices that are already muted. Will use to keep muted after disable GlobalMute.
0211         var globalMuteDevices = [];
0212 
0213         for (var i = 0; i < rowCount; i++) {
0214             var idx = paSinkModel.index(i, 0);
0215             var name = paSinkModel.data(idx, paSinkModel.KItemModels.KRoleNames.role("Name"));
0216             if (paSinkModel.data(idx, role) === false) {
0217                 paSinkModel.setData(idx, true, role);
0218             } else {
0219                 globalMuteDevices.push(name + "." + paSinkModel.data(idx, paSinkModel.KItemModels.KRoleNames.role("ActivePortIndex")));
0220             }
0221         }
0222         // If all the devices were muted, will unmute them all with disable GlobalMute.
0223         config.globalMuteDevices = globalMuteDevices.length < rowCount ? globalMuteDevices : [];
0224         config.globalMute = true;
0225         config.save();
0226     }
0227 
0228     function disableGlobalMute() {
0229         var role = paSinkModel.KItemModels.KRoleNames.role("Muted");
0230         for (var i = 0; i < paSinkModel.rowCount(); i++) {
0231             var idx = paSinkModel.index(i, 0);
0232             var name = paSinkModel.data(idx, paSinkModel.KItemModels.KRoleNames.role("Name")) + "." + paSinkModel.data(idx, paSinkModel.KItemModels.KRoleNames.role("ActivePortIndex"));
0233             if (config.globalMuteDevices.indexOf(name) === -1) {
0234                 paSinkModel.setData(idx, false, role);
0235             }
0236         }
0237         config.globalMuteDevices = [];
0238         config.globalMute = false;
0239         config.save();
0240     }
0241 
0242     // Output devices
0243     readonly property SinkModel paSinkModel: SinkModel {
0244         id: paSinkModel
0245 
0246         property bool initalDefaultSinkIsSet: false
0247 
0248         onDefaultSinkChanged: {
0249             if (!defaultSink || !config.defaultOutputDeviceOsd) {
0250                 return;
0251             }
0252 
0253             // avoid showing a OSD on startup
0254             if (!initalDefaultSinkIsSet) {
0255                 initalDefaultSinkIsSet = true;
0256                 return;
0257             }
0258 
0259             var description = nodeName(defaultSink);
0260             if (isDummyOutput(defaultSink)) {
0261                 description = i18n("No output device");
0262             } else {
0263                 const cardModelIdx = paCardModel.indexOfCardNumber(defaultSink.cardIndex);
0264                 if (cardModelIdx.valid) {
0265                     const cardProperties = paCardModel.data(cardModelIdx, paCardModel.KItemModels.KRoleNames.role("Properties"));
0266                     const cardBluetoothBattery = cardProperties["bluetooth.battery"];
0267                     // This property is returned as a string with percent sign,
0268                     // parse it into an int in case they change it to a number later.
0269                     const batteryInt = parseInt(cardBluetoothBattery, 10);
0270 
0271                     if (!isNaN(batteryInt)) {
0272                         description = i18nc("Device name (Battery percent)", "%1 (%2% Battery)", description, batteryInt);
0273                     }
0274                 }
0275             }
0276 
0277             var icon = Icon.formFactorIcon(defaultSink.formFactor);
0278             if (!icon) {
0279                 // Show "muted" icon for Dummy output
0280                 if (isDummyOutput(defaultSink)) {
0281                     icon = "audio-volume-muted";
0282                 }
0283             }
0284 
0285             if (!icon) {
0286                 icon = Icon.name(defaultSink.volume, defaultSink.muted);
0287             }
0288             osd.showText(icon, description);
0289         }
0290 
0291         onRowsInserted: {
0292             if (globalMute) {
0293                 var role = paSinkModel.KItemModels.KRoleNames.role("Muted");
0294                 for (var i = 0; i < paSinkModel.rowCount(); i++) {
0295                     var idx = paSinkModel.index(i, 0);
0296                     if (paSinkModel.data(idx, role) === false) {
0297                         paSinkModel.setData(idx, true, role);
0298                     }
0299                 }
0300             }
0301         }
0302     }
0303 
0304     // Input devices
0305     readonly property SourceModel paSourceModel: SourceModel { id: paSourceModel }
0306 
0307     // Confusingly, Sink Input is what PulseAudio calls streams that send audio to an output device
0308     readonly property SinkInputModel paSinkInputModel: SinkInputModel { id: paSinkInputModel }
0309 
0310     // Confusingly, Source Output is what PulseAudio calls streams that take audio from an input device
0311     readonly property SourceOutputModel paSourceOutputModel: SourceOutputModel { id: paSourceOutputModel }
0312 
0313     // active output devices
0314     readonly property PulseObjectFilterModel paSinkFilterModel: PulseObjectFilterModel {
0315         id: paSinkFilterModel
0316         sortRoleName: "SortByDefault"
0317         sortOrder: Qt.DescendingOrder
0318         filterOutInactiveDevices: true
0319         filterVirtualDevices: !main.showVirtualDevices
0320         sourceModel: paSinkModel
0321     }
0322 
0323     // active input devices
0324     readonly property PulseObjectFilterModel paSourceFilterModel: PulseObjectFilterModel {
0325         id: paSourceFilterModel
0326         sortRoleName: "SortByDefault"
0327         sortOrder: Qt.DescendingOrder
0328         filterOutInactiveDevices: true
0329         filterVirtualDevices: !main.showVirtualDevices
0330         sourceModel: paSourceModel
0331     }
0332 
0333     // non-virtual streams going to output devices
0334     readonly property PulseObjectFilterModel paSinkInputFilterModel: PulseObjectFilterModel {
0335         id: paSinkInputFilterModel
0336         filters: [ { role: "VirtualStream", value: false } ]
0337         sourceModel: paSinkInputModel
0338     }
0339 
0340     // non-virtual streams coming from input devices
0341     readonly property PulseObjectFilterModel paSourceOutputFilterModel: PulseObjectFilterModel {
0342         id: paSourceOutputFilterModel
0343         filters: [ { role: "VirtualStream", value: false } ]
0344         sourceModel: paSourceOutputModel
0345     }
0346 
0347     readonly property CardModel paCardModel: CardModel {
0348         id: paCardModel
0349 
0350         function indexOfCardNumber(cardNumber) {
0351             const indexRole = KItemModels.KRoleNames.role("Index");
0352             for (let idx = 0; idx < count; ++idx) {
0353                 if (data(index(idx, 0), indexRole) === cardNumber) {
0354                     return index(idx, 0);
0355                 }
0356             }
0357             return index(-1, 0);
0358         }
0359     }
0360 
0361     // Only exists because the default CompactRepresentation doesn't expose:
0362     // - scroll actions
0363     // - a middle-click action
0364     // TODO remove once it gains those features.
0365     compactRepresentation: MouseArea {
0366         property int wheelDelta: 0
0367         property bool wasExpanded: false
0368 
0369         anchors.fill: parent
0370         hoverEnabled: true
0371         acceptedButtons: Qt.LeftButton | Qt.MiddleButton
0372         onPressed: mouse => {
0373             if (mouse.button == Qt.LeftButton) {
0374                 wasExpanded = main.expanded;
0375             } else if (mouse.button == Qt.MiddleButton) {
0376                 muteVolume();
0377             }
0378         }
0379         onClicked: mouse => {
0380             if (mouse.button == Qt.LeftButton) {
0381                 main.expanded = !wasExpanded;
0382             }
0383         }
0384         onWheel: wheel => {
0385             const delta = (wheel.inverted ? -1 : 1) * (wheel.angleDelta.y ? wheel.angleDelta.y : -wheel.angleDelta.x);
0386             wheelDelta += delta;
0387             // Magic number 120 for common "one click"
0388             // See: https://qt-project.org/doc/qt-5/qml-qtquick-wheelevent.html#angleDelta-prop
0389             while (wheelDelta >= 120) {
0390                 wheelDelta -= 120;
0391                 increaseVolume(wheel.modifiers);
0392             }
0393             while (wheelDelta <= -120) {
0394                 wheelDelta += 120;
0395                 decreaseVolume(wheel.modifiers);
0396             }
0397         }
0398         Kirigami.Icon {
0399             anchors.fill: parent
0400             source: plasmoid.icon
0401             active: parent.containsMouse
0402         }
0403     }
0404 
0405     GlobalActionCollection {
0406         // KGlobalAccel cannot transition from kmix to something else, so if
0407         // the user had a custom shortcut set for kmix those would get lost.
0408         // To avoid this we hijack kmix name and actions. Entirely mental but
0409         // best we can do to not cause annoyance for the user.
0410         // The display name actually is updated to whatever registered last
0411         // though, so as far as user visible strings go we should be fine.
0412         // As of 2015-07-21:
0413         //   componentName: kmix
0414         //   actions: increase_volume, decrease_volume, mute
0415         name: "kmix"
0416         displayName: main.displayName
0417         GlobalAction {
0418             objectName: "increase_volume"
0419             text: i18n("Increase Volume")
0420             shortcut: Qt.Key_VolumeUp
0421             onTriggered: increaseVolume(Qt.NoModifier)
0422         }
0423         GlobalAction {
0424             objectName: "increase_volume_small"
0425             text: i18nc("@action shortcut", "Increase Volume by 1%")
0426             shortcut: Qt.ShiftModifier | Qt.Key_VolumeUp
0427             onTriggered: increaseVolume(Qt.ShiftModifier)
0428         }
0429         GlobalAction {
0430             objectName: "decrease_volume"
0431             text: i18n("Decrease Volume")
0432             shortcut: Qt.Key_VolumeDown
0433             onTriggered: decreaseVolume(Qt.NoModifier)
0434         }
0435         GlobalAction {
0436             objectName: "decrease_volume_small"
0437             text: i18nc("@action shortcut", "Decrease Volume by 1%")
0438             shortcut: Qt.ShiftModifier | Qt.Key_VolumeDown
0439             onTriggered: decreaseVolume(Qt.ShiftModifier)
0440         }
0441         GlobalAction {
0442             objectName: "mute"
0443             text: i18n("Mute")
0444             shortcut: Qt.Key_VolumeMute
0445             onTriggered: muteVolume()
0446         }
0447         GlobalAction {
0448             objectName: "increase_microphone_volume"
0449             text: i18n("Increase Microphone Volume")
0450             shortcut: Qt.Key_MicVolumeUp
0451             onTriggered: increaseMicrophoneVolume()
0452         }
0453         GlobalAction {
0454             objectName: "decrease_microphone_volume"
0455             text: i18n("Decrease Microphone Volume")
0456             shortcut: Qt.Key_MicVolumeDown
0457             onTriggered: decreaseMicrophoneVolume()
0458         }
0459         GlobalAction {
0460             objectName: "mic_mute"
0461             text: i18n("Mute Microphone")
0462             shortcuts: [Qt.Key_MicMute, Qt.MetaModifier | Qt.Key_VolumeMute]
0463             onTriggered: muteMicrophone()
0464         }
0465     }
0466 
0467     VolumeOSD {
0468         id: osd
0469 
0470         function showVolume(text) {
0471             if (!config.volumeOsd) {
0472                 return
0473             }
0474             show(text, currentMaxVolumePercent)
0475         }
0476 
0477         function showMute(text) {
0478             if (!config.muteOsd) {
0479                 return
0480             }
0481             show(text, currentMaxVolumePercent)
0482         }
0483 
0484         function showMic(text) {
0485             if (!config.microphoneSensitivityOsd) {
0486                 return
0487             }
0488             showMicrophone(text)
0489         }
0490 
0491         function showMicMute(text) {
0492             if (!config.muteOsd) {
0493                 return
0494             }
0495             showMicrophone(text)
0496         }
0497     }
0498 
0499     VolumeFeedback {
0500         id: feedback
0501     }
0502 
0503     fullRepresentation: PlasmaExtras.Representation {
0504         id: fullRep
0505 
0506         Layout.minimumHeight: Kirigami.Units.gridUnit * 8
0507         Layout.minimumWidth: Kirigami.Units.gridUnit * 14
0508         Layout.preferredHeight: Kirigami.Units.gridUnit * 21
0509         Layout.preferredWidth: Kirigami.Units.gridUnit * 24
0510 
0511         collapseMarginsHint: true
0512 
0513         KeyNavigation.down: tabBar.currentItem
0514 
0515         function beginMoveStream(type, stream) {
0516             if (type === "sink") {
0517                 contentView.hiddenTypes = "source"
0518             } else if (type === "source") {
0519                 contentView.hiddenTypes = "sink"
0520             }
0521             tabBar.setCurrentIndex(devicesTab.PC3.TabBar.index)
0522         }
0523 
0524         function endMoveStream() {
0525             contentView.hiddenTypes = []
0526             tabBar.setCurrentIndex(streamsTab.PC3.TabBar.index)
0527         }
0528 
0529         header: PlasmaExtras.PlasmoidHeading {
0530             // Make this toolbar's buttons align vertically with the ones above
0531             rightPadding: -1
0532             // Allow tabbar to touch the header's bottom border
0533             bottomPadding: -bottomInset
0534 
0535             RowLayout {
0536                 anchors.fill: parent
0537                 spacing: Kirigami.Units.smallSpacing
0538 
0539                 PC3.TabBar {
0540                     id: tabBar
0541                     Layout.fillWidth: true
0542                     Layout.fillHeight: true
0543 
0544                     currentIndex: {
0545                         switch (plasmoid.configuration.currentTab) {
0546                         case "devices":
0547                             return devicesTab.PC3.TabBar.index;
0548                         case "streams":
0549                             return streamsTab.PC3.TabBar.index;
0550                         }
0551                     }
0552 
0553                     KeyNavigation.down: contentView.currentItem.contentItem.upperListView.itemAtIndex(0)
0554 
0555                     onCurrentIndexChanged: {
0556                         switch (currentIndex) {
0557                         case devicesTab.PC3.TabBar.index:
0558                             plasmoid.configuration.currentTab = "devices";
0559                             break;
0560                         case streamsTab.PC3.TabBar.index:
0561                             plasmoid.configuration.currentTab = "streams";
0562                             break;
0563                         }
0564                     }
0565 
0566                     PC3.TabButton {
0567                         id: devicesTab
0568                         text: i18n("Devices")
0569 
0570                         KeyNavigation.up: fullRep.KeyNavigation.up
0571                     }
0572 
0573                     PC3.TabButton {
0574                         id: streamsTab
0575                         text: i18n("Applications")
0576 
0577                         KeyNavigation.up: fullRep.KeyNavigation.up
0578                     }
0579                 }
0580 
0581                 PC3.ToolButton {
0582                     id: globalMuteCheckbox
0583 
0584                     visible: !(plasmoid.containmentDisplayHints & PlasmaCore.Types.ContainmentDrawsPlasmoidHeading)
0585 
0586                     icon.name: "audio-volume-muted"
0587                     onClicked: {
0588                         if (!globalMute) {
0589                             enableGlobalMute();
0590                         } else {
0591                             disableGlobalMute();
0592                         }
0593                     }
0594                     checked: globalMute
0595 
0596                     Accessible.name: i18n("Force mute all playback devices")
0597                     PC3.ToolTip {
0598                         text: i18n("Force mute all playback devices")
0599                     }
0600                 }
0601 
0602                 PC3.ToolButton {
0603                     visible: !(plasmoid.containmentDisplayHints & PlasmaCore.Types.ContainmentDrawsPlasmoidHeading)
0604 
0605                     icon.name: "configure"
0606                     onClicked: plasmoid.internalAction("configure").trigger()
0607 
0608                     Accessible.name: plasmoid.internalAction("configure").text
0609                     PC3.ToolTip {
0610                         text: plasmoid.internalAction("configure").text
0611                     }
0612                 }
0613             }
0614         }
0615 
0616         // NOTE: using a StackView instead of a SwipeView partly because
0617         // SwipeView would never start with the correct initial view when the
0618         // last saved view was the streams view.
0619         // We also don't need to be able to swipe between views.
0620         contentItem: HorizontalStackView {
0621             id: contentView
0622             property var hiddenTypes: []
0623             initialItem: plasmoid.configuration.currentTab === "streams" ? streamsView : devicesView
0624             movementTransitionsEnabled: currentItem !== null
0625             TwoPartView {
0626                 id: devicesView
0627                 upperModel: paSinkFilterModel
0628                 upperType: "sink"
0629                 lowerModel: paSourceFilterModel
0630                 lowerType: "source"
0631                 iconName: "audio-volume-muted"
0632                 placeholderText: main.noDevicePlaceholderMessage
0633                 upperDelegate: DeviceListItem {
0634                     width: ListView.view.width
0635                     type: devicesView.upperType
0636                 }
0637                 lowerDelegate: DeviceListItem {
0638                     width: ListView.view.width
0639                     type: devicesView.lowerType
0640                 }
0641             }
0642             // NOTE: Don't unload this while dragging and dropping a stream
0643             // to a device or else the D&D operation will be cancelled.
0644             TwoPartView {
0645                 id: streamsView
0646                 upperModel: paSinkInputFilterModel
0647                 upperType: "sink-input"
0648                 lowerModel: paSourceOutputFilterModel
0649                 lowerType: "source-output"
0650                 iconName: "edit-none"
0651                 placeholderText: i18n("No applications playing or recording audio")
0652                 upperDelegate: StreamListItem {
0653                     width: ListView.view.width
0654                     type: streamsView.upperType
0655                     devicesModel: paSinkFilterModel
0656                 }
0657                 lowerDelegate: StreamListItem {
0658                     width: ListView.view.width
0659                     type: streamsView.lowerType
0660                     devicesModel: paSourceFilterModel
0661                 }
0662             }
0663             Connections {
0664                 target: tabBar
0665                 function onCurrentIndexChanged() {
0666                     if (tabBar.currentItem === devicesTab) {
0667                         contentView.reverseTransitions = false
0668                         contentView.replace(devicesView)
0669                     } else if (tabBar.currentItem === streamsTab) {
0670                         contentView.reverseTransitions = true
0671                         contentView.replace(streamsView)
0672                     }
0673                 }
0674             }
0675         }
0676 
0677         component TwoPartView : PC3.ScrollView {
0678             id: scrollView
0679             required property PulseObjectFilterModel upperModel
0680             required property string upperType
0681             required property Component upperDelegate
0682             required property PulseObjectFilterModel lowerModel
0683             required property string lowerType
0684             required property Component lowerDelegate
0685             property string iconName: ""
0686             property string placeholderText: ""
0687 
0688              // HACK: workaround for https://bugreports.qt.io/browse/QTBUG-83890
0689             PC3.ScrollBar.horizontal.policy: PC3.ScrollBar.AlwaysOff
0690 
0691             Loader {
0692                 parent: scrollView
0693                 anchors.centerIn: parent
0694                 width: parent.width -  Kirigami.Units.gridUnit * 4
0695                 active: visible
0696                 visible: scrollView.placeholderText.length > 0 && !upperSection.visible && !lowerSection.visible
0697                 sourceComponent: PlasmaExtras.PlaceholderMessage {
0698                     iconName: scrollView.iconName
0699                     text: scrollView.placeholderText
0700                 }
0701             }
0702             contentItem: Flickable {
0703                 contentHeight: layout.implicitHeight
0704                 clip: true
0705 
0706                 property ListView upperListView: upperSection.visible ? upperSection : lowerSection
0707                 property ListView lowerListView: lowerSection.visible ? lowerSection : upperSection
0708 
0709                 ColumnLayout {
0710                     id: layout
0711                     width: parent.width
0712                     spacing: 0
0713                     ListView {
0714                         id: upperSection
0715                         visible: count && !contentView.hiddenTypes.includes(scrollView.upperType)
0716                         interactive: false
0717                         Layout.fillWidth: true
0718                         implicitHeight: contentHeight
0719                         model: scrollView.upperModel
0720                         delegate: scrollView.upperDelegate
0721                         focus: visible
0722 
0723                         Keys.onDownPressed: event => {
0724                             if (currentIndex < count - 1) {
0725                                 incrementCurrentIndex();
0726                                 currentItem.forceActiveFocus();
0727                             } else if (lowerSection.visible) {
0728                                 lowerSection.currentIndex = 0;
0729                                 lowerSection.currentItem.forceActiveFocus();
0730                             } else {
0731                                 raiseMaximumVolumeCheckbox.forceActiveFocus(Qt.TabFocusReason);
0732                             }
0733                             event.accepted = true;
0734                         }
0735                         Keys.onUpPressed: event => {
0736                             if (currentIndex > 0) {
0737                                 decrementCurrentIndex();
0738                                 currentItem.forceActiveFocus();
0739                             } else {
0740                                 tabBar.currentItem.forceActiveFocus(Qt.BacktabFocusReason);
0741                             }
0742                             event.accepted = true;
0743                         }
0744                     }
0745                     KSvg.SvgItem {
0746                         imagePath: "widgets/line"
0747                         elementId: "horizontal-line"
0748                         Layout.fillWidth: true
0749                         Layout.leftMargin: Kirigami.Units.smallSpacing * 2
0750                         Layout.rightMargin: Layout.leftMargin
0751                         Layout.topMargin: Kirigami.Units.smallSpacing
0752                         visible: upperSection.visible && lowerSection.visible
0753                     }
0754                     ListView {
0755                         id: lowerSection
0756                         visible: count && !contentView.hiddenTypes.includes(scrollView.lowerType)
0757                         interactive: false
0758                         Layout.fillWidth: true
0759                         implicitHeight: contentHeight
0760                         model: scrollView.lowerModel
0761                         delegate: scrollView.lowerDelegate
0762                         focus: visible && !upperSection.visible
0763 
0764                         Keys.onDownPressed: event => {
0765                             if (currentIndex < count - 1) {
0766                                 incrementCurrentIndex();
0767                                 currentItem.forceActiveFocus();
0768                             } else {
0769                                 raiseMaximumVolumeCheckbox.forceActiveFocus(Qt.TabFocusReason);
0770                             }
0771                             event.accepted = true;
0772                         }
0773                         Keys.onUpPressed: event => {
0774                             if (currentIndex > 0) {
0775                                 decrementCurrentIndex();
0776                                 currentItem.forceActiveFocus();
0777                             } else if (upperSection.visible) {
0778                                 upperSection.currentIndex = upperSection.count - 1;
0779                                 upperSection.currentItem.forceActiveFocus();
0780                             } else {
0781                                 tabBar.currentItem.forceActiveFocus(Qt.BacktabFocusReason);
0782                             }
0783                             event.accepted = true;
0784                         }
0785                     }
0786                 }
0787             }
0788         }
0789 
0790         footer: PlasmaExtras.PlasmoidHeading {
0791             height: fullRep.header.height
0792             PC3.Switch {
0793                 id: raiseMaximumVolumeCheckbox
0794                 anchors.left: parent.left
0795                 anchors.leftMargin: Kirigami.Units.smallSpacing
0796                 anchors.verticalCenter: parent.verticalCenter
0797 
0798                 checked: config.raiseMaximumVolume
0799 
0800                 Accessible.onPressAction: raiseMaximumVolumeCheckbox.toggle()
0801                 KeyNavigation.backtab: contentView.currentItem.contentItem.lowerListView.itemAtIndex(contentView.currentItem.contentItem.lowerListView.count - 1)
0802                 Keys.onUpPressed: event => {
0803                     KeyNavigation.backtab.forceActiveFocus(Qt.BacktabFocusReason);
0804                 }
0805 
0806                 text: i18n("Raise maximum volume")
0807 
0808                 onToggled: {
0809                     config.raiseMaximumVolume = checked;
0810                     config.save();
0811                 }
0812             }
0813         }
0814     }
0815 
0816     Plasmoid.contextualActions: [
0817         PlasmaCore.Action {
0818             text: i18n("Force mute all playback devices")
0819             icon.name: "audio-volume-muted"
0820             checkable: true
0821             checked: globalMute
0822             onTriggered: {
0823                 if (!globalMute) {
0824                     enableGlobalMute();
0825                 } else {
0826                     disableGlobalMute();
0827                 }
0828             }
0829         },
0830         PlasmaCore.Action {
0831             text: i18n("Show virtual devices")
0832             icon.name: "audio-card"
0833             checkable: true
0834             checked: plasmoid.configuration.showVirtualDevices
0835             onTriggered: Plasmoid.configuration.showVirtualDevices = !Plasmoid.configuration.showVirtualDevices
0836         }
0837     ]
0838 
0839     PlasmaCore.Action {
0840         id: configureAction
0841         text: i18n("&Configure Audio Devices…")
0842         icon.name: "configure"
0843         shortcut: "alt+d, s"
0844         visible: KConfig.KAuthorized.authorizeControlModule("kcm_pulseaudio")
0845         onTriggered: KCMUtils.KCMLauncher.openSystemSettings("kcm_pulseaudio")
0846     }
0847 
0848     Component.onCompleted: {
0849         MicrophoneIndicator.init();
0850         Plasmoid.setInternalAction("configure", configureAction);
0851 
0852         // migrate settings if they aren't default
0853         // this needs to be done per instance of the applet
0854         if (Plasmoid.configuration.migrated) {
0855             return;
0856         }
0857         if (Plasmoid.configuration.volumeFeedback === false && config.audioFeedback) {
0858             config.audioFeedback = false;
0859             config.save();
0860         }
0861         if (Plasmoid.configuration.volumeStep && Plasmoid.configuration.volumeStep !== 5 && config.volumeStep === 5) {
0862             config.volumeStep = Plasmoid.configuration.volumeStep;
0863             config.save();
0864         }
0865         if (Plasmoid.configuration.raiseMaximumVolume === true && !config.raiseMaximumVolume) {
0866             config.raiseMaximumVolume = true;
0867             config.save();
0868         }
0869         if (Plasmoid.configuration.volumeOsd === false && config.volumeOsd) {
0870             config.volumeOsd = false;
0871             config.save();
0872         }
0873         if (Plasmoid.configuration.muteOsd === false && config.muteOsd) {
0874             config.muteOsd = false;
0875             config.save();
0876         }
0877         if (Plasmoid.configuration.micOsd === false && config.microphoneSensitivityOsd) {
0878             config.microphoneSensitivityOsd = false;
0879             config.save();
0880         }
0881         if (Plasmoid.configuration.globalMute === true && !config.globalMute) {
0882             config.globalMute = true;
0883             config.save();
0884         }
0885         if (Plasmoid.configuration.globalMuteDevices.length !== 0) {
0886             for (const device in Plasmoid.configuration.globalMuteDevices) {
0887                 if (!config.globalMuteDevices.includes(device)) {
0888                     config.globalMuteDevices.push(device);
0889                 }
0890             }
0891             config.save();
0892         }
0893         Plasmoid.configuration.migrated = true;
0894     }
0895 }