Files
home/.local/share/plasma/plasmoids/KdeControlStation/contents/ui/pages/VolumePage.qml
2025-10-08 10:35:48 +02:00

389 lines
15 KiB
QML

import QtQuick 2.15
import QtQuick.Controls 2.0
import QtQuick.Layouts 1.15
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.components as PlasmaComponents
import org.kde.plasma.components as PC3
import org.kde.kirigami as Kirigami
import org.kde.kcmutils as KCM// KCMLauncher
import org.kde.config as KConfig // KAuthorized.authorizeControlModule
import org.kde.ksvg as KSvg
import org.kde.plasma.extras as PlasmaExtras
import org.kde.plasma.private.volume
import "../lib" as Lib
import "components/volume" as VolumeComponents
PageTemplate {
id: volumePage
sectionTitle: i18n("Audio Volume")
property int contentItemHeight: contentItem.implicitHeight
GlobalConfig {
id: config
}
property bool volumeFeedback: config.audioFeedback
property bool globalMute: config.globalMute
property string displayName: i18n("Audio Volume")
property QtObject draggedStream: null
property bool showVirtualDevices: true
property string currentTab: "devices"
// DEFAULT_SINK_NAME in module-always-sink.c
readonly property string dummyOutputName: "auto_null"
readonly property string noDevicePlaceholderMessage: i18n("No output or input devices found")
function nodeName(pulseObject) {
const nodeNick = pulseObject.pulseProperties["node.nick"]
if (nodeNick) {
return nodeNick
}
if (pulseObject.description) {
return pulseObject.description
}
if (pulseObject.name) {
return pulseObject.name
}
return i18n("Device name not found")
}
function isDummyOutput(output) {
return output && output.name === dummyOutputName;
}
function volumePercent(volume) {
return Math.round(volume / PulseAudio.NormalVolume * 100.0);
}
function playFeedback(sinkIndex) {
if (!volumeFeedback) {
return;
}
if (sinkIndex == undefined) {
sinkIndex = PreferredDevice.sink.index;
}
feedback.play(sinkIndex);
}
// Output devices
readonly property SinkModel paSinkModel: SinkModel { id: paSinkModel }
// Input devices
readonly property SourceModel paSourceModel: SourceModel { id: paSourceModel }
// 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 output devices
readonly property PulseObjectFilterModel paSinkFilterModel: PulseObjectFilterModel {
id: paSinkFilterModel
filterOutInactiveDevices: true
filterVirtualDevices: !volumePage.showVirtualDevices
sourceModel: paSinkModel
}
// active input devices
readonly property PulseObjectFilterModel paSourceFilterModel: PulseObjectFilterModel {
id: paSourceFilterModel
filterOutInactiveDevices: true
filterVirtualDevices: !volumePage.showVirtualDevices
sourceModel: paSourceModel
}
// non-virtual streams going to output devices
readonly property PulseObjectFilterModel paSinkInputFilterModel: PulseObjectFilterModel {
id: paSinkInputFilterModel
filters: [ { role: "VirtualStream", value: false } ]
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
function indexOfCardNumber(cardNumber) {
const indexRole = KItemModels.KRoleNames.role("Index");
for (let idx = 0; idx < count; ++idx) {
if (data(index(idx, 0), indexRole) === cardNumber) {
return index(idx, 0);
}
}
return index(-1, 0);
}
}
VolumeFeedback {
id: feedback
}
ColumnLayout {
id: contentItem
spacing: Kirigami.Units.gridUnit
anchors.fill: parent
RowLayout {
//anchors.fill: parent
spacing: Kirigami.Units.smallSpacing
PC3.TabBar {
id: tabBar
Layout.fillWidth: true
//Layout.fillHeight: true
currentIndex: {
switch (volumePage.currentTab) {
case "devices":
return devicesTab.PC3.TabBar.index;
case "streams":
return streamsTab.PC3.TabBar.index;
}
}
KeyNavigation.down: contentView.currentItem.contentItem.upperListView.itemAtIndex(0)
onCurrentIndexChanged: {
switch (currentIndex) {
case devicesTab.PC3.TabBar.index:
volumePage.currentTab = "devices";
break;
case streamsTab.PC3.TabBar.index:
volumePage.currentTab = "streams";
break;
}
}
PC3.TabButton {
id: devicesTab
text: i18n("Devices")
KeyNavigation.up: fullRep.KeyNavigation.up
}
PC3.TabButton {
id: streamsTab
text: i18n("Applications")
KeyNavigation.up: fullRep.KeyNavigation.up
}
}
PC3.ToolButton {
id: globalMuteCheckbox
visible: true// !(plasmoid.containmentDisplayHints & PlasmaCore.Types.ContainmentDrawsPlasmoidHeading)
icon.name: "audio-volume-muted"
onClicked: {
GlobalService.globalMute()
}
checked: globalMute
Accessible.name: i18n("Force mute all playback devices")
PC3.ToolTip {
text: i18n("Force mute all playback devices")
}
}
PC3.ToolButton {
visible: true // !(plasmoid.containmentDisplayHints & PlasmaCore.Types.ContainmentDrawsPlasmoidHeading)
icon.name: "configure"
onClicked: KCM.KCMLauncher.openSystemSettings("kcm_pulseaudio")
Accessible.name: i18n("Configure Audio Devices")
PC3.ToolTip {
text: i18n("Configure Audio Devices")
}
}
}
VolumeComponents.HorizontalStackView {
id: contentView
initialItem: volumePage.currentTab === "streams" ? streamsView : devicesView
movementTransitionsEnabled: currentItem !== null
Layout.fillHeight: true
Layout.fillWidth: true
TwoPartView {
id: devicesView
upperModel: paSinkFilterModel
upperType: "sink"
lowerModel: paSourceFilterModel
lowerType: "source"
iconName: "audio-volume-muted"
placeholderText: volumePage.noDevicePlaceholderMessage
upperDelegate: VolumeComponents.DeviceListItem {
width: ListView.view.width
type: devicesView.upperType
}
lowerDelegate: VolumeComponents.DeviceListItem {
width: ListView.view.width
type: devicesView.lowerType
}
}
// NOTE: Don't unload this while dragging and dropping a stream
// to a device or else the D&D operation will be cancelled.
TwoPartView {
id: streamsView
upperModel: paSinkInputFilterModel
upperType: "sink-input"
lowerModel: paSourceOutputFilterModel
lowerType: "source-output"
iconName: "edit-none"
placeholderText: i18n("No applications playing or recording audio")
upperDelegate: VolumeComponents.StreamListItem {
width: ListView.view.width
type: streamsView.upperType
devicesModel: paSinkFilterModel
}
lowerDelegate: VolumeComponents.StreamListItem {
width: ListView.view.width
type: streamsView.lowerType
devicesModel: paSourceFilterModel
}
}
Connections {
target: tabBar
function onCurrentIndexChanged() {
if (tabBar.currentItem === devicesTab) {
contentView.reverseTransitions = false
contentView.replace(devicesView)
} else if (tabBar.currentItem === streamsTab) {
contentView.reverseTransitions = true
contentView.replace(streamsView)
}
}
}
}
component TwoPartView : PC3.ScrollView {
id: scrollView
required property PulseObjectFilterModel upperModel
required property string upperType
required property Component upperDelegate
required property PulseObjectFilterModel lowerModel
required property string lowerType
required property Component lowerDelegate
property string iconName: ""
property string placeholderText: ""
// HACK: workaround for https://bugreports.qt.io/browse/QTBUG-83890
PC3.ScrollBar.horizontal.policy: PC3.ScrollBar.AlwaysOff
Loader {
//parent: scrollView
anchors.fill: parent
// width: parent.width - Kirigami.Units.gridUnit * 4
active: visible
visible: scrollView.placeholderText.length > 0 && !upperSection.visible && !lowerSection.visible
sourceComponent: PlasmaExtras.PlaceholderMessage {
iconName: scrollView.iconName
text: scrollView.placeholderText
}
}
contentItem: Flickable {
contentHeight: layout.implicitHeight
clip: true
property ListView upperListView: upperSection.visible ? upperSection : lowerSection
property ListView lowerListView: lowerSection.visible ? lowerSection : upperSection
ColumnLayout {
id: layout
anchors.fill: parent
spacing: 0
ListView {
id: upperSection
visible: count //&& !fullRep.hiddenTypes.includes(scrollView.upperType)
interactive: false
Layout.fillWidth: true
implicitHeight: contentHeight
model: scrollView.upperModel
delegate: scrollView.upperDelegate
focus: visible
Keys.onDownPressed: event => {
if (currentIndex < count - 1) {
incrementCurrentIndex();
currentItem.forceActiveFocus();
} else if (lowerSection.visible) {
lowerSection.currentIndex = 0;
lowerSection.currentItem.forceActiveFocus();
} else {
raiseMaximumVolumeCheckbox.forceActiveFocus(Qt.TabFocusReason);
}
event.accepted = true;
}
Keys.onUpPressed: event => {
if (currentIndex > 0) {
decrementCurrentIndex();
currentItem.forceActiveFocus();
} else {
tabBar.currentItem.forceActiveFocus(Qt.BacktabFocusReason);
}
event.accepted = true;
}
}
KSvg.SvgItem {
imagePath: "widgets/line"
elementId: "horizontal-line"
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.smallSpacing * 2
Layout.rightMargin: Layout.leftMargin
Layout.topMargin: Kirigami.Units.smallSpacing
visible: upperSection.visible && lowerSection.visible
}
ListView {
id: lowerSection
visible: count //&& !fullRep.hiddenTypes.includes(scrollView.lowerType)
interactive: false
Layout.fillWidth: true
implicitHeight: contentHeight
model: scrollView.lowerModel
delegate: scrollView.lowerDelegate
focus: visible && !upperSection.visible
Keys.onDownPressed: event => {
if (currentIndex < count - 1) {
incrementCurrentIndex();
currentItem.forceActiveFocus();
} else {
raiseMaximumVolumeCheckbox.forceActiveFocus(Qt.TabFocusReason);
}
event.accepted = true;
}
Keys.onUpPressed: event => {
if (currentIndex > 0) {
decrementCurrentIndex();
currentItem.forceActiveFocus();
} else if (upperSection.visible) {
upperSection.currentIndex = upperSection.count - 1;
upperSection.currentItem.forceActiveFocus();
} else {
tabBar.currentItem.forceActiveFocus(Qt.BacktabFocusReason);
}
event.accepted = true;
}
}
}
}
}
}
}