diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,7 @@ Plasma ) find_package(PulseAudio 5.0.0 REQUIRED) +find_package(Canberra REQUIRED) find_package(GLIB2 REQUIRED) add_subdirectory(applet) diff --git a/applet/contents/config/main.xml b/applet/contents/config/main.xml --- a/applet/contents/config/main.xml +++ b/applet/contents/config/main.xml @@ -12,6 +12,9 @@ 5 + + true + diff --git a/applet/contents/ui/ConfigGeneral.qml b/applet/contents/ui/ConfigGeneral.qml --- a/applet/contents/ui/ConfigGeneral.qml +++ b/applet/contents/ui/ConfigGeneral.qml @@ -22,38 +22,69 @@ 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 - GridLayout { - columns: 2 + ColumnLayout { Layout.fillWidth: true - Label { - Layout.alignment: Qt.AlignRight - text: i18n("Maximum volume:") - } + GroupBox { + Layout.fillWidth: true + flat: true + title: i18n("Volume") - SpinBox { - id: maximumVolume - minimumValue: 100 - maximumValue: 150 - stepSize: 1 - suffix: i18n("%") - } + 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:") + Label { + Layout.alignment: Qt.AlignRight + text: i18n("Volume step:") + } + + SpinBox { + id: volumeStep + minimumValue: 1 + maximumValue: 100 + stepSize: 1 + suffix: i18n("%") + } + } } - 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 + } + } } } + + VolumeFeedback { + id: feedback + } } diff --git a/applet/contents/ui/ListItemBase.qml b/applet/contents/ui/ListItemBase.qml --- a/applet/contents/ui/ListItemBase.qml +++ b/applet/contents/ui/ListItemBase.qml @@ -158,6 +158,10 @@ Volume = value; Muted = false; + if (type == "sink") { + playFeedback(CardIndex); + } + if (!pressed) { updateTimer.restart(); } diff --git a/applet/contents/ui/main.qml b/applet/contents/ui/main.qml --- a/applet/contents/ui/main.qml +++ b/applet/contents/ui/main.qml @@ -33,6 +33,7 @@ Item { id: main + property bool volumeFeedback: Plasmoid.configuration.volumeFeedback property int maxVolumePercent: Plasmoid.configuration.maximumVolume property int maxVolumeValue: Math.round(maxVolumePercent * PulseAudio.NormalVolume / 100.0) property int volumeStep: Math.round(Plasmoid.configuration.volumeStep * PulseAudio.NormalVolume / 100.0) @@ -69,6 +70,7 @@ sinkModel.preferredSink.muted = false; sinkModel.preferredSink.volume = volume; osd.show(volumePercent(volume, maxVolumeValue)); + playFeedback(); } function decreaseVolume() { @@ -79,6 +81,7 @@ sinkModel.preferredSink.muted = false; sinkModel.preferredSink.volume = volume; osd.show(volumePercent(volume, maxVolumeValue)); + playFeedback(); } function muteVolume() { @@ -88,6 +91,7 @@ var toMute = !sinkModel.preferredSink.muted; sinkModel.preferredSink.muted = toMute; osd.show(toMute ? 0 : volumePercent(sinkModel.preferredSink.volume, maxVolumeValue)); + playFeedback(); } function increaseMicrophoneVolume() { @@ -140,6 +144,16 @@ sinkViewHeader.visible = true; } + function playFeedback(sinkIndex) { + if (!volumeFeedback) { + return; + } + if (!sinkIndex) { + sinkIndex = sinkModel.preferredSink.cardIndex; + } + feedback.play(sinkIndex); + } + Plasmoid.compactRepresentation: PlasmaCore.IconItem { source: plasmoid.icon active: mouseArea.containsMouse @@ -237,6 +251,10 @@ id: osd } + VolumeFeedback { + id: feedback + } + PlasmaComponents.TabBar { id: tabBar diff --git a/cmake/FindCanberra.cmake b/cmake/FindCanberra.cmake new file mode 100644 --- /dev/null +++ b/cmake/FindCanberra.cmake @@ -0,0 +1,50 @@ +# - Find libcanberra's libraries and headers. +# This module defines the following variables: +# +# CANBERRA_FOUND - true if libcanberra was found +# CANBERRA_LIBRARIES - libcanberra libraries to link against +# CANBERRA_INCLUDE_DIRS - include path for libcanberra +# +# Copyright (c) 2012 Raphael Kubo da Costa +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the University nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +find_package(PkgConfig) +pkg_check_modules(PC_CANBERRA libcanberra) + +find_library(CANBERRA_LIBRARIES + NAMES canberra + HINTS ${PC_CANBERRA_LIBRARY_DIRS} ${PC_CANBERRA_LIBDIR} +) + +find_path(CANBERRA_INCLUDE_DIRS + NAMES canberra.h + HINTS ${PC_CANBERRA_INCLUDE_DIRS} ${PC_CANBERRA_INCLUDEDIR} +) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(Canberra REQUIRED_VARS CANBERRA_LIBRARIES CANBERRA_INCLUDE_DIRS) + +mark_as_advanced(CANBERRA_LIBRARIES CANBERRA_INCLUDE_DIRS) diff --git a/src/qml/CMakeLists.txt b/src/qml/CMakeLists.txt --- a/src/qml/CMakeLists.txt +++ b/src/qml/CMakeLists.txt @@ -7,6 +7,7 @@ globalactioncollection.cpp plugin.cpp volumeosd.cpp + volumefeedback.cpp ) set_property(SOURCE dbus/osdService.xml APPEND PROPERTY CLASSNAME OsdServiceInterface) @@ -18,6 +19,7 @@ Qt5::Quick KF5::GlobalAccel QPulseAudioPrivate + ${CANBERRA_LIBRARIES} ) set(PRIVATE_QML_INSTALL_DIR ${QML_INSTALL_DIR}/org/kde/plasma/private/volume) diff --git a/src/qml/plugin.cpp b/src/qml/plugin.cpp --- a/src/qml/plugin.cpp +++ b/src/qml/plugin.cpp @@ -31,6 +31,7 @@ #include "globalactioncollection.h" #include "volumeosd.h" +#include "volumefeedback.h" static QJSValue pulseaudio_singleton(QQmlEngine *engine, QJSEngine *scriptEngine) { @@ -55,6 +56,7 @@ qmlRegisterType(uri, 0, 1, "GlobalAction"); qmlRegisterType(uri, 0, 1, "GlobalActionCollection"); qmlRegisterType(uri, 0, 1, "VolumeOSD"); + qmlRegisterType(uri, 0, 1, "VolumeFeedback"); qmlRegisterSingletonType(uri, 0, 1, "PulseAudio", pulseaudio_singleton); qmlRegisterType(); diff --git a/src/qml/volumefeedback.h b/src/qml/volumefeedback.h new file mode 100644 --- /dev/null +++ b/src/qml/volumefeedback.h @@ -0,0 +1,46 @@ +/* + Copyright 2016 David Rosca + + 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 VOLUMEFEEDBACK_H +#define VOLUMEFEEDBACK_H + +#include + +#include + +class VolumeFeedback : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool valid READ isValid CONSTANT) + +public: + explicit VolumeFeedback(QObject *parent = nullptr); + ~VolumeFeedback(); + + bool isValid() const; + +public slots: + void play(quint32 sinkIndex); + +private: + ca_context *m_context = nullptr; +}; + +#endif // VOLUMEFEEDBACK_H diff --git a/src/qml/volumefeedback.cpp b/src/qml/volumefeedback.cpp new file mode 100644 --- /dev/null +++ b/src/qml/volumefeedback.cpp @@ -0,0 +1,83 @@ +/* + Copyright 2008 Helio Chissini de Castro + Copyright 2016 David Rosca + + 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 "volumefeedback.h" + +VolumeFeedback::VolumeFeedback(QObject *parent) + : QObject(parent) +{ + if (ca_context_create(&m_context) < 0) { + m_context = nullptr; + return; + } + if (ca_context_set_driver(m_context, "pulse") < 0) { + ca_context_destroy(m_context); + m_context = nullptr; + return; + } +} + +VolumeFeedback::~VolumeFeedback() +{ + if (m_context) { + ca_context_destroy(m_context); + } +} + +bool VolumeFeedback::isValid() const +{ + return m_context; +} + +void VolumeFeedback::play(quint32 sinkIndex) +{ + if (!m_context) { + return; + } + + int playing = 0; + const int cindex = 2; // Note "2" is simply the index we've picked. It's somewhat irrelevant. + ca_context_playing(m_context, cindex, &playing); + + // NB Depending on how this is desired to work, we may want to simply + // skip playing, or cancel the currently playing sound and play our + // new one... for now, let's do the latter. + if (playing) { + ca_context_cancel(m_context, cindex); + } + + char dev[64]; + snprintf(dev, sizeof(dev), "%lu", (unsigned long) sinkIndex); + ca_context_change_device(m_context, dev); + + // Ideally we'd use something like ca_gtk_play_for_widget()... + ca_context_play( + m_context, + cindex, + CA_PROP_EVENT_DESCRIPTION, "Volume Control Feedback Sound", + CA_PROP_EVENT_ID, "audio-volume-change", + CA_PROP_CANBERRA_CACHE_CONTROL, "permanent", + CA_PROP_CANBERRA_ENABLE, "1", + nullptr + ); + + ca_context_change_device(m_context, nullptr); +}