diff --git a/src/chatbar/CMakeLists.txt b/src/chatbar/CMakeLists.txt index ad32f2ba3..13cc7a693 100644 --- a/src/chatbar/CMakeLists.txt +++ b/src/chatbar/CMakeLists.txt @@ -16,4 +16,5 @@ ecm_add_qml_module(Chatbar GENERATE_PLUGIN_SOURCE EmojiDialog.qml EmojiTonesPicker.qml ImageEditorPage.qml + VoiceMessageDialog.qml ) diff --git a/src/chatbar/ChatBar.qml b/src/chatbar/ChatBar.qml index 68f2cc982..d1a3b05d1 100644 --- a/src/chatbar/ChatBar.qml +++ b/src/chatbar/ChatBar.qml @@ -150,6 +150,19 @@ QQC2.Control { } tooltip: text }, + BusyAction { + icon.name: "microphone" + isBusy: false + text: i18nc("@action:button", "Send a Voice Message") + displayHint: QQC2.AbstractButton.IconOnly + onTriggered: { + let dialog = voiceMessageDialog.createObject(root, { + room: root.currentRoom + }) as VoiceMessageDialog; + dialog.open(); + } + tooltip: text + }, BusyAction { id: sendAction @@ -548,6 +561,11 @@ QQC2.Control { NewPollDialog {} } + Component { + id: voiceMessageDialog + VoiceMessageDialog {} + } + CompletionMenu { id: completionMenu chatDocumentHandler: documentHandler diff --git a/src/chatbar/VoiceMessageDialog.qml b/src/chatbar/VoiceMessageDialog.qml new file mode 100644 index 000000000..2c6df941f --- /dev/null +++ b/src/chatbar/VoiceMessageDialog.qml @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import QtMultimedia + +import org.kde.kirigami as Kirigami +import org.kde.coreaddons + +import org.kde.neochat + +QQC2.Dialog { + id: root + + required property NeoChatRoom room + + VoiceRecorder { + id: voiceRecorder + readonly property bool recording: recorder.recorderState == MediaRecorder.RecordingState + room: root.room + } + + width: Kirigami.Units.gridUnit * 24 + + standardButtons: QQC2.DialogButtonBox.Cancel + + title: i18nc("@title:dialog", "Record Voice Message") + + contentItem: ColumnLayout { + QQC2.RoundButton { + icon.name: voiceRecorder.recording ? "media-playback-stop" : "media-record" + text: voiceRecorder.recording ? i18nc("@action:button Stop audio recording", "Stop Recording") : i18nc("@action:button Start audio recording", "Start Recording") + Layout.preferredHeight: Kirigami.Units.gridUnit * 4 + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + Layout.alignment: Qt.AlignHCenter + display: QQC2.RoundButton.IconOnly + enabled: voiceRecorder.isSupported + + onClicked: voiceRecorder.recording ? voiceRecorder.stopRecording() : voiceRecorder.startRecording() + } + RowLayout { + Layout.alignment: Qt.AlignHCenter + QQC2.Label { + text: i18nc("@info Duration being the length of an audio recording", "Duration: %1", Format.formatDuration(voiceRecorder.recorder.duration)) + } + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + text: i18nc("@info", "Voice message recording requires a newer Qt version than is currently installed on this system.") + visible: !voiceRecorder.isSupported + } + } + + footer: QQC2.DialogButtonBox { + QQC2.Button { + text: i18nc("@action:button Send the voice message", "Send") + icon.name: "document-send" + QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole + onClicked: voiceRecorder.send() + enabled: !voiceRecorder.recording && voiceRecorder.recorder.duration > 0 && voiceRecorder.isSupported + } + } +} diff --git a/src/libneochat/CMakeLists.txt b/src/libneochat/CMakeLists.txt index 309d98927..18a76b1bd 100644 --- a/src/libneochat/CMakeLists.txt +++ b/src/libneochat/CMakeLists.txt @@ -23,6 +23,7 @@ target_sources(LibNeoChat PRIVATE texthandler.cpp urlhelper.cpp utils.cpp + voicerecorder.cpp enums/chatbartype.h enums/messagecomponenttype.h enums/messagetype.h diff --git a/src/libneochat/voicerecorder.cpp b/src/libneochat/voicerecorder.cpp new file mode 100644 index 000000000..621e2c030 --- /dev/null +++ b/src/libneochat/voicerecorder.cpp @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "voicerecorder.h" + +#include +#include + +#include + +#include + +using namespace Qt::Literals::StringLiterals; + +VoiceRecorder::VoiceRecorder(QObject *parent) + : QObject(parent) + , m_buffer(new QBuffer) + , m_format(QMediaFormat::FileFormat::Ogg) +{ + m_session.setAudioInput(&m_input); + m_recorder.setAudioBitRate(24000); + m_recorder.setAudioSampleRate(48000); + m_format.setAudioCodec(QMediaFormat::AudioCodec::Opus); + m_recorder.setAudioChannelCount(1); + m_recorder.setMediaFormat(m_format); + m_buffer->open(QIODevice::ReadWrite); + m_recorder.setOutputDevice(m_buffer); + m_session.setRecorder(&m_recorder); +} + +VoiceRecorder::~VoiceRecorder() +{ + delete m_buffer; +} + +void VoiceRecorder::startRecording() +{ + m_buffer->setData({}); + m_recorder.record(); +} + +void VoiceRecorder::stopRecording() +{ + m_recorder.stop(); +} + +QMediaRecorder *VoiceRecorder::recorder() +{ + return &m_recorder; +} + +void VoiceRecorder::send() +{ + Quotient::FileSourceInfo fileMetadata; + QByteArray data; + m_buffer->seek(0); + + if (m_room->usesEncryption()) { + std::tie(fileMetadata, data) = Quotient::encryptFile(m_buffer->data()); + m_buffer->close(); + m_buffer->setData(data); + m_buffer->open(QIODevice::ReadOnly); + } + + auto room = m_room; + auto buffer = m_buffer; + auto duration = m_recorder.duration(); + m_buffer = nullptr; + m_room->connection()->uploadContent(buffer, {}, u"audio/ogg"_s).then([fileMetadata, room, buffer, duration](const auto &job) mutable { + QJsonObject mscFile{ + {u"mimetype"_s, u"audio/ogg"_s}, + {u"name"_s, u"Voice Message"_s}, + {u"size"_s, buffer->size()}, + }; + + if (room->usesEncryption()) { + mscFile[u"file"_s] = toJson(fileMetadata); + } else { + mscFile[u"url"_s] = job->contentUri().toString(); + } + + Quotient::setUrlInSourceInfo(fileMetadata, job->contentUri()); + QJsonObject content{ + {u"body"_s, u"Voice message"_s}, + {u"msgtype"_s, u"m.audio"_s}, + {u"org.matrix.msc1767.text"_s, + QJsonObject{{u"body"_s, u"Voice Message (%1, %2)"_s.arg(KFormat().formatDuration(duration), KFormat().formatByteSize(buffer->size()))}}}, + {u"org.matrix.msc1767.file"_s, mscFile}, + {u"info"_s, + QJsonObject{ + {u"mimetype"_s, u"audio/ogg"_s}, + {u"size"_s, buffer->size()}, + {u"duration"_s, duration}, + }}, + {u"org.matrix.msc1767.audio"_s, + QJsonObject{ + {u"duration"_s, duration}, + {u"waveform"_s, QJsonArray{}}, // TODO + }}, + {u"org.matrix.msc3245.voice"_s, QJsonObject{}}}; + if (room->usesEncryption()) { + content[u"file"_s] = toJson(fileMetadata); + } else { + content[u"url"_s] = job->contentUri().toString(); + } + room->postJson(u"m.room.message"_s, content); + }); +} + +void VoiceRecorder::setRoom(NeoChatRoom *room) +{ + m_room = room; + Q_EMIT roomChanged(); +} + +NeoChatRoom *VoiceRecorder::room() const +{ + return m_room.get(); +} + +bool VoiceRecorder::isSupported() const +{ + return m_format.isSupported(QMediaFormat::Encode); +} diff --git a/src/libneochat/voicerecorder.h b/src/libneochat/voicerecorder.h new file mode 100644 index 000000000..bb22ea2ef --- /dev/null +++ b/src/libneochat/voicerecorder.h @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +#include "neochatroom.h" +#include +#include +#include +#include +#include + +class VoiceRecorder : public QObject +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(QMediaRecorder *recorder READ recorder CONSTANT) + Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged REQUIRED) + // TODO: Remove once no longer required + Q_PROPERTY(bool isSupported READ isSupported CONSTANT) + +public: + explicit VoiceRecorder(QObject *parent = nullptr); + ~VoiceRecorder() override; + + Q_INVOKABLE void startRecording(); + Q_INVOKABLE void stopRecording(); + Q_INVOKABLE void send(); + + QMediaRecorder *recorder(); + + NeoChatRoom *room() const; + void setRoom(NeoChatRoom *room); + + bool isSupported() const; + +Q_SIGNALS: + void roomChanged(); + +private: + QAudioInput m_input; + QMediaCaptureSession m_session; + QMediaRecorder m_recorder; + QBuffer *m_buffer; + QPointer m_room; + QMediaFormat m_format; +};