diff --git a/applet/contents/code/icon.js b/applet/contents/code/icon.js index 2526e50..e1cf15c 100644 --- a/applet/contents/code/icon.js +++ b/applet/contents/code/icon.js @@ -1,37 +1,71 @@ /* Copyright 2014-2015 Harald Sitter This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) version 3, or any later version accepted by the membership of KDE e.V. (or its successor approved by the membership of KDE e.V.), which shall act as a proxy defined in Section 6 of version 3 of the license. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library. If not, see . */ function name(volume, muted, prefix) { if (!prefix) { prefix = "audio-volume"; } var icon = null; var percent = volume / maxVolumeValue; if (percent <= 0.0 || muted) { icon = prefix + "-muted"; } else if (percent <= 0.25) { icon = prefix + "-low"; } else if (percent <= 0.75) { icon = prefix + "-medium"; } else { icon = prefix + "-high"; } return icon; } + +function formFactorIcon(formFactor) { + switch(formFactor) { + case "internal": + return "audio-card"; + case "speaker": + return "audio-speakers-symbolic"; + case "phone": + return "phone"; + case "handset": + return "phone"; + case "tv": + return "video-television"; + case "webcam": + return "camera-web"; + case "microphone": + return "audio-input-microphone"; + case "headset": + return "audio-headset"; + case "headphone": + return "audio-headphones"; + case "hands-free": + return "hands-free"; + case "car": + return "car"; + case "hifi": + return "hifi"; + case "computer": + return "computer"; + case "portable": + return "portable"; + } + return ""; +} diff --git a/applet/contents/config/main.xml b/applet/contents/config/main.xml index 861dab1..baad6be 100644 --- a/applet/contents/config/main.xml +++ b/applet/contents/config/main.xml @@ -1,20 +1,24 @@ 100 5 true + + + true + diff --git a/applet/contents/ui/ConfigGeneral.qml b/applet/contents/ui/ConfigGeneral.qml index 9d85bd2..047aacf 100644 --- a/applet/contents/ui/ConfigGeneral.qml +++ b/applet/contents/ui/ConfigGeneral.qml @@ -1,90 +1,96 @@ /* Copyright 2016 David Rosca This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License or (at your option) version 3 or any later version accepted by the membership of KDE e.V. (or its successor approved by the membership of KDE e.V.), which shall act as a proxy defined in Section 14 of version 3 of the license. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ import QtQuick 2.0 import QtQuick.Layouts 1.0 import QtQuick.Controls 1.0 import org.kde.plasma.private.volume 0.1 Item { property alias cfg_maximumVolume: maximumVolume.value property alias cfg_volumeStep: volumeStep.value property alias cfg_volumeFeedback: volumeFeedback.checked + property alias cfg_outputChangeOsd: outputChangeOsd.checked ColumnLayout { Layout.fillWidth: true GroupBox { Layout.fillWidth: true flat: true title: i18n("Volume") GridLayout { columns: 2 Layout.fillWidth: true Label { Layout.alignment: Qt.AlignRight text: i18n("Maximum volume:") } SpinBox { id: maximumVolume minimumValue: 100 maximumValue: 150 stepSize: 1 suffix: i18n("%") } Label { Layout.alignment: Qt.AlignRight text: i18n("Volume step:") } SpinBox { id: volumeStep minimumValue: 1 maximumValue: 100 stepSize: 1 suffix: i18n("%") } } } GroupBox { Layout.fillWidth: true flat: true title: i18n("Behavior") ColumnLayout { CheckBox { id: volumeFeedback text: i18n("Volume feedback") enabled: feedback.valid } + + CheckBox { + id: outputChangeOsd + text: i18n("Visual feedback when default output device changes") + } } } } VolumeFeedback { id: feedback } } diff --git a/applet/contents/ui/DeviceListItem.qml b/applet/contents/ui/DeviceListItem.qml index b0ea177..26fb8ea 100644 --- a/applet/contents/ui/DeviceListItem.qml +++ b/applet/contents/ui/DeviceListItem.qml @@ -1,73 +1,43 @@ /* Copyright 2014-2015 Harald Sitter This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License or (at your option) version 3 or any later version accepted by the membership of KDE e.V. (or its successor approved by the membership of KDE e.V.), which shall act as a proxy defined in Section 14 of version 3 of the license. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ import QtQuick 2.0 +import "../code/icon.js" as Icon + ListItemBase { readonly property var currentPort: Ports[ActivePortIndex] property bool onlyOne: false draggable: false label: { if (!currentPort) { return Description } else { if (onlyOne) { return currentPort.description } else { return i18nc("label of device items", "%1 (%2)", currentPort.description, Description) } } } labelOpacity: onlyOne ? 1 : 0.6 - icon: { - switch(FormFactor) { - case "internal": - return "audio-card"; - case "speaker": - return "audio-speakers-symbolic"; - case "phone": - return "phone"; - case "handset": - return "phone"; - case "tv": - return "video-television"; - case "webcam": - return "camera-web"; - case "microphone": - return "audio-input-microphone"; - case "headset": - return "audio-headset"; - case "headphone": - return "audio-headphones"; - case "hands-free": - return "hands-free"; break; - case "car": - return "car"; break; - case "hifi": - return "hifi"; break; - case "computer": - return "computer"; break; - case "portable": - return "portable"; break; - } - return IconName; - } + icon: Icon.formFactorIcon(FormFactor) || IconName } diff --git a/applet/contents/ui/main.qml b/applet/contents/ui/main.qml index 07c4ca7..c5f610e 100644 --- a/applet/contents/ui/main.qml +++ b/applet/contents/ui/main.qml @@ -1,472 +1,492 @@ /* Copyright 2014-2015 Harald Sitter This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License or (at your option) version 3 or any later version accepted by the membership of KDE e.V. (or its successor approved by the membership of KDE e.V.), which shall act as a proxy defined in Section 14 of version 3 of the license. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ import QtQuick 2.2 import QtQuick.Layouts 1.0 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as PlasmaComponents import org.kde.plasma.extras 2.0 as PlasmaExtras import org.kde.plasma.plasmoid 2.0 import org.kde.plasma.private.volume 0.1 import "../code/icon.js" as Icon Item { id: main property bool volumeFeedback: Plasmoid.configuration.volumeFeedback property int maxVolumeValue: Math.round(Plasmoid.configuration.maximumVolume * PulseAudio.NormalVolume / 100.0) property int volumeStep: Math.round(Plasmoid.configuration.volumeStep * PulseAudio.NormalVolume / 100.0) property string displayName: i18n("Audio Volume") property QtObject draggedStream: null Layout.minimumHeight: units.gridUnit * 12 Layout.minimumWidth: units.gridUnit * 12 Layout.preferredHeight: units.gridUnit * 20 Layout.preferredWidth: units.gridUnit * 20 Plasmoid.icon: paSinkModel.preferredSink ? Icon.name(paSinkModel.preferredSink.volume, paSinkModel.preferredSink.muted) : Icon.name(0, true) Plasmoid.switchWidth: units.gridUnit * 12 Plasmoid.switchHeight: units.gridUnit * 12 Plasmoid.toolTipMainText: { var sink = paSinkModel.preferredSink; if (!sink) { return displayName; } if (sink.muted) { return i18n("Audio Muted"); } else { return i18n("Volume at %1%", volumePercent(sink.volume)); } } Plasmoid.toolTipSubText: paSinkModel.preferredSink ? paSinkModel.preferredSink.description : "" function boundVolume(volume) { return Math.max(PulseAudio.MinimalVolume, Math.min(volume, maxVolumeValue)); } function volumePercent(volume, max) { if (!max) { max = PulseAudio.NormalVolume; } return Math.round(volume / max * 100.0); } function increaseVolume() { if (!paSinkModel.preferredSink) { return; } var volume = boundVolume(paSinkModel.preferredSink.volume + volumeStep); var percent = volumePercent(volume, maxVolumeValue); paSinkModel.preferredSink.muted = percent == 0; paSinkModel.preferredSink.volume = volume; osd.show(percent); playFeedback(); } function decreaseVolume() { if (!paSinkModel.preferredSink) { return; } var volume = boundVolume(paSinkModel.preferredSink.volume - volumeStep); var percent = volumePercent(volume, maxVolumeValue); paSinkModel.preferredSink.muted = percent == 0; paSinkModel.preferredSink.volume = volume; osd.show(percent); playFeedback(); } function muteVolume() { if (!paSinkModel.preferredSink) { return; } var toMute = !paSinkModel.preferredSink.muted; paSinkModel.preferredSink.muted = toMute; osd.show(toMute ? 0 : volumePercent(paSinkModel.preferredSink.volume, maxVolumeValue)); playFeedback(); } function increaseMicrophoneVolume() { if (!paSourceModel.defaultSource) { return; } var volume = boundVolume(paSourceModel.defaultSource.volume + volumeStep); var percent = volumePercent(volume); paSourceModel.defaultSource.muted = percent == 0; paSourceModel.defaultSource.volume = volume; osd.showMicrophone(percent); } function decreaseMicrophoneVolume() { if (!paSourceModel.defaultSource) { return; } var volume = boundVolume(paSourceModel.defaultSource.volume - volumeStep); var percent = volumePercent(volume); paSourceModel.defaultSource.muted = percent == 0; paSourceModel.defaultSource.volume = volume; osd.showMicrophone(percent); } function muteMicrophone() { if (!paSourceModel.defaultSource) { return; } var toMute = !paSourceModel.defaultSource.muted; paSourceModel.defaultSource.muted = toMute; osd.showMicrophone(toMute? 0 : volumePercent(paSourceModel.defaultSource.volume)); } function playFeedback(sinkIndex) { if (!volumeFeedback) { return; } if (sinkIndex == undefined) { sinkIndex = paSinkModel.preferredSink.index; } feedback.play(sinkIndex); } SinkModel { id: paSinkModel + + property bool initalDefaultSinkIsSet: false + + onDefaultSinkChanged: { + if (!defaultSink || !plasmoid.configuration.outputChangeOsd) { + return; + } + + // avoid showing a OSD on startup + if (!initalDefaultSinkIsSet) { + initalDefaultSinkIsSet = true; + return; + } + + var icon = Icon.formFactorIcon(defaultSink.formFactor); + if (!icon) { + icon = Icon.name(defaultSink.volume, defaultSink.muted); + } + osd.showText(icon, defaultSink.description); + } } SourceModel { id: paSourceModel } Plasmoid.compactRepresentation: PlasmaCore.IconItem { source: plasmoid.icon active: mouseArea.containsMouse colorGroup: PlasmaCore.ColorScope.colorGroup MouseArea { id: mouseArea property int wheelDelta: 0 property bool wasExpanded: false anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.MiddleButton onPressed: { if (mouse.button == Qt.LeftButton) { wasExpanded = plasmoid.expanded; } else if (mouse.button == Qt.MiddleButton) { muteVolume(); } } onClicked: { if (mouse.button == Qt.LeftButton) { plasmoid.expanded = !wasExpanded; } } onWheel: { var delta = wheel.angleDelta.y || wheel.angleDelta.x; wheelDelta += delta; // Magic number 120 for common "one click" // See: http://qt-project.org/doc/qt-5/qml-qtquick-wheelevent.html#angleDelta-prop while (wheelDelta >= 120) { wheelDelta -= 120; increaseVolume(); } while (wheelDelta <= -120) { wheelDelta += 120; decreaseVolume(); } } } } GlobalActionCollection { // KGlobalAccel cannot transition from kmix to something else, so if // the user had a custom shortcut set for kmix those would get lost. // To avoid this we hijack kmix name and actions. Entirely mental but // best we can do to not cause annoyance for the user. // The display name actually is updated to whatever registered last // though, so as far as user visible strings go we should be fine. // As of 2015-07-21: // componentName: kmix // actions: increase_volume, decrease_volume, mute name: "kmix" displayName: main.displayName GlobalAction { objectName: "increase_volume" text: i18n("Increase Volume") shortcut: Qt.Key_VolumeUp onTriggered: increaseVolume() } GlobalAction { objectName: "decrease_volume" text: i18n("Decrease Volume") shortcut: Qt.Key_VolumeDown onTriggered: decreaseVolume() } GlobalAction { objectName: "mute" text: i18n("Mute") shortcut: Qt.Key_VolumeMute onTriggered: muteVolume() } GlobalAction { objectName: "increase_microphone_volume" text: i18n("Increase Microphone Volume") shortcut: Qt.Key_MicVolumeUp onTriggered: increaseMicrophoneVolume() } GlobalAction { objectName: "decrease_microphone_volume" text: i18n("Decrease Microphone Volume") shortcut: Qt.Key_MicVolumeDown onTriggered: decreaseMicrophoneVolume() } GlobalAction { objectName: "mic_mute" text: i18n("Mute Microphone") shortcut: Qt.Key_MicMute onTriggered: muteMicrophone() } } VolumeOSD { id: osd } VolumeFeedback { id: feedback } Plasmoid.fullRepresentation: ColumnLayout { spacing: units.smallSpacing function beginMoveStream(type, stream) { if (type == "sink") { sourceView.visible = false; sourceViewHeader.visible = false; } else if (type == "source") { sinkView.visible = false; sinkViewHeader.visible = false; } tabBar.currentTab = devicesTab; } function endMoveStream() { tabBar.currentTab = streamsTab; sourceView.visible = true; sourceViewHeader.visible = true; sinkView.visible = true; sinkViewHeader.visible = true; } RowLayout { spacing: units.smallSpacing Layout.fillWidth: true PlasmaComponents.TabBar { id: tabBar Layout.fillWidth: true activeFocusOnTab: true PlasmaComponents.TabButton { id: devicesTab text: i18n("Devices") } PlasmaComponents.TabButton { id: streamsTab text: i18n("Applications") } } PlasmaComponents.ToolButton { Layout.alignment: Qt.AlignBottom tooltip: plasmoid.action("configure").text iconName: "configure" Accessible.name: tooltip onClicked: { plasmoid.action("configure").trigger(); } } } PlasmaExtras.ScrollArea { id: scrollView; Layout.fillWidth: true Layout.fillHeight: true horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff flickableItem.boundsBehavior: Flickable.StopAtBounds; //our scroll isn't a list of delegates, all internal items are tab focussable, making this redundant activeFocusOnTab: false Item { width: streamsView.visible ? streamsView.width : devicesView.width height: streamsView.visible ? streamsView.height : devicesView.height ColumnLayout { id: streamsView visible: tabBar.currentTab == streamsTab readonly property bool simpleMode: (sinkInputView.count >= 1 && sourceOutputView.count == 0) || (sinkInputView.count == 0 && sourceOutputView.count >= 1) property int maximumWidth: scrollView.viewport.width width: maximumWidth Layout.maximumWidth: maximumWidth Header { Layout.fillWidth: true visible: sinkInputView.count > 0 && !streamsView.simpleMode text: i18n("Playback Streams") } ListView { id: sinkInputView Layout.fillWidth: true Layout.minimumHeight: contentHeight Layout.maximumHeight: contentHeight model: PulseObjectFilterModel { filters: [ { role: "VirtualStream", value: false } ] sourceModel: SinkInputModel {} } boundsBehavior: Flickable.StopAtBounds; delegate: StreamListItem { type: "sink-input" draggable: sinkView.count > 1 onlyOne: streamsView.simpleMode } } Header { Layout.fillWidth: true visible: sourceOutputView.count > 0 && !streamsView.simpleMode text: i18n("Capture Streams") } ListView { id: sourceOutputView Layout.fillWidth: true Layout.minimumHeight: contentHeight Layout.maximumHeight: contentHeight model: PulseObjectFilterModel { filters: [ { role: "VirtualStream", value: false } ] sourceModel: SourceOutputModel {} } boundsBehavior: Flickable.StopAtBounds; delegate: StreamListItem { type: "source-input" draggable: sourceView.count > 1 onlyOne: streamsView.simpleMode } } } ColumnLayout { id: devicesView visible: tabBar.currentTab == devicesTab readonly property bool simpleMode: sinkView.count == 1 && sourceView.count == 1 property int maximumWidth: scrollView.viewport.width width: maximumWidth Layout.maximumWidth: maximumWidth Header { id: sinkViewHeader Layout.fillWidth: true visible: sinkView.count > 0 && !devicesView.simpleMode text: i18n("Playback Devices") } ListView { id: sinkView Layout.fillWidth: true Layout.minimumHeight: contentHeight Layout.maximumHeight: contentHeight model: PulseObjectFilterModel { sortRole: "SortByDefault" sortOrder: Qt.DescendingOrder sourceModel: paSinkModel } boundsBehavior: Flickable.StopAtBounds; delegate: DeviceListItem { type: "sink" onlyOne: devicesView.simpleMode } } Header { id: sourceViewHeader Layout.fillWidth: true visible: sourceView.count > 0 && !devicesView.simpleMode text: i18n("Capture Devices") } ListView { id: sourceView Layout.fillWidth: true Layout.minimumHeight: contentHeight Layout.maximumHeight: contentHeight model: PulseObjectFilterModel { sortRole: "SortByDefault" sortOrder: Qt.DescendingOrder sourceModel: paSourceModel } boundsBehavior: Flickable.StopAtBounds; delegate: DeviceListItem { type: "source" onlyOne: devicesView.simpleMode } } } PlasmaExtras.Heading { level: 4 opacity: 0.8 width: parent.width height: scrollView.height visible: streamsView.visible && !sinkInputView.count && !sourceOutputView.count text: i18n("No applications playing or recording audio") wrapMode: Text.WordWrap verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignHCenter } PlasmaExtras.Heading { level: 4 opacity: 0.8 width: parent.width height: scrollView.height visible: devicesView.visible && !sinkView.count && !sourceView.count text: i18n("No output or input devices found") wrapMode: Text.WordWrap verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignHCenter } } } } } diff --git a/src/qml/dbus/osdService.xml b/src/qml/dbus/osdService.xml index 4e4a90e..379e8e4 100644 --- a/src/qml/dbus/osdService.xml +++ b/src/qml/dbus/osdService.xml @@ -1,39 +1,43 @@ + + + + diff --git a/src/qml/volumeosd.cpp b/src/qml/volumeosd.cpp index 41f6b52..e45dd21 100644 --- a/src/qml/volumeosd.cpp +++ b/src/qml/volumeosd.cpp @@ -1,44 +1,50 @@ /* Copyright 2014-2015 Harald Sitter This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) version 3, or any later version accepted by the membership of KDE e.V. (or its successor approved by the membership of KDE e.V.), which shall act as a proxy defined in Section 6 of version 3 of the license. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library. If not, see . */ #include "volumeosd.h" #include "osdservice.h" #define SERVICE QLatin1Literal("org.kde.plasmashell") #define PATH QLatin1Literal("/org/kde/osdService") #define CONNECTION QDBusConnection::sessionBus() VolumeOSD::VolumeOSD(QObject *parent) : QObject(parent) { } void VolumeOSD::show(int percent) { OsdServiceInterface osdService(SERVICE, PATH, CONNECTION); osdService.volumeChanged(percent); } void VolumeOSD::showMicrophone(int percent) { OsdServiceInterface osdService(SERVICE, PATH, CONNECTION); osdService.microphoneVolumeChanged(percent); } + +void VolumeOSD::showText(const QString &iconName, const QString &text) +{ + OsdServiceInterface osdService(SERVICE, PATH, CONNECTION); + osdService.showText(iconName, text); +} diff --git a/src/qml/volumeosd.h b/src/qml/volumeosd.h index 7cc8af1..e25a4ae 100644 --- a/src/qml/volumeosd.h +++ b/src/qml/volumeosd.h @@ -1,37 +1,38 @@ /* Copyright 2014-2015 Harald Sitter This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) version 3, or any later version accepted by the membership of KDE e.V. (or its successor approved by the membership of KDE e.V.), which shall act as a proxy defined in Section 6 of version 3 of the license. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library. If not, see . */ #ifndef VOLUMEOSD_H #define VOLUMEOSD_H #include class VolumeOSD : public QObject { Q_OBJECT public: VolumeOSD(QObject *parent = nullptr); public slots: void show(int percent); void showMicrophone(int percent); + void showText(const QString &iconName, const QString &text); }; #endif // VOLUMEOSD_H