From 850a260aac835d23121c0f19cefdf5f0f611d9f9 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 26 Jul 2026 21:19:29 +0300 Subject: [PATCH] fix: voice message display, playback, recording UI, audio settings - messagelist: parse content_type from JSON when DB column is empty - messagelist: recording indicator (red dot + timer + cancel button) - messagedelegate: editorEvent for play/pause toggle + progress bar - messagedelegate: VoicePlayState tracking with QTimer - InputBar: proper recordingContext (mediaDir + channelId) - DbManager: dbPath() for media directory resolution - AudioRecorder: peakLevel() for mic level meter - VoiceEncoder: codec quality presets (Low/Standard/High) - audiodevicesettingspage: Default device option first - audiodevicesettingspage: mic level meter + test microphone - audiodevicesettingspage: test speaker button - audiodevicesettingspage: codec preset combo (Low/Standard/High) - mainwindow: load opus_codec_preset from ui_state on startup --- tools/chatgui/db/db_manager.h | 3 + tools/chatgui/src/audiodevicesettingspage.cpp | 181 ++++++++++++++++-- tools/chatgui/src/audiodevicesettingspage.h | 20 ++ tools/chatgui/src/audiorecorder.cpp | 13 ++ tools/chatgui/src/audiorecorder.h | 3 + tools/chatgui/src/inputbar.cpp | 9 +- tools/chatgui/src/inputbar.h | 4 + tools/chatgui/src/mainwindow.cpp | 10 +- tools/chatgui/src/messagedelegate.cpp | 155 +++++++++++++-- tools/chatgui/src/messagedelegate.h | 5 + tools/chatgui/src/messagelist.cpp | 113 ++++++++--- tools/chatgui/src/messagelist.h | 5 + tools/chatgui/src/voicemessageencoder.cpp | 16 +- tools/chatgui/src/voicemessageencoder.h | 3 + 14 files changed, 483 insertions(+), 57 deletions(-) diff --git a/tools/chatgui/db/db_manager.h b/tools/chatgui/db/db_manager.h index 7acf8e07..2e89b1e1 100644 --- a/tools/chatgui/db/db_manager.h +++ b/tools/chatgui/db/db_manager.h @@ -81,6 +81,8 @@ public: bool isOpen() const { return m_db != nullptr; } void setDb(sqlite3* db) { m_db = db; } + void setDbPath(const QString& path) { m_dbPath = path; } + QString dbPath() const { return m_dbPath; } quint64 myNodeId() const { return m_myNodeId; } void setMyNodeId(quint64 nodeId) { m_myNodeId = nodeId; } @@ -144,6 +146,7 @@ public: IntegrityResult integrityOptimize() const; sqlite3* m_db = nullptr; + QString m_dbPath; quint64 m_myNodeId = 0; /* Кэш открытых курсоров для sync-операций */ diff --git a/tools/chatgui/src/audiodevicesettingspage.cpp b/tools/chatgui/src/audiodevicesettingspage.cpp index e462b32d..30798014 100644 --- a/tools/chatgui/src/audiodevicesettingspage.cpp +++ b/tools/chatgui/src/audiodevicesettingspage.cpp @@ -1,5 +1,7 @@ #include "audiodevicesettingspage.h" #include "sound_manager.h" +#include "audiorecorder.h" +#include "voicemessageencoder.h" #include "../db/db_manager.h" #include "../transport/gui_bridge.h" #include "../../lib/mem.h" @@ -7,9 +9,12 @@ extern "C" { #include "../transport/chat_core.h" } #include +#include #include #include +#include #include +#include AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, QWidget* parent) : QWidget(parent), m_db(db) @@ -17,13 +22,8 @@ AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, QWidget* parent) auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 12, 0, 0); - auto* infoLabel = new QLabel("Select audio devices for playback and recording.\nChanges take effect after restart.", this); - infoLabel->setWordWrap(true); - infoLabel->setStyleSheet("color: palette(text); font-size: 12px;"); - layout->addWidget(infoLabel); - auto* formLayout = new QFormLayout(); - formLayout->setContentsMargins(0, 16, 0, 0); + formLayout->setContentsMargins(0, 0, 0, 0); formLayout->setSpacing(10); auto* outLabel = new QLabel("Playback device:", this); @@ -39,48 +39,142 @@ AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, QWidget* parent) formLayout->addRow(inLabel, m_inputCombo); layout->addLayout(formLayout); + + /* --- Separator --- */ + auto* sep1 = new QFrame(this); + sep1->setFrameShape(QFrame::HLine); + sep1->setStyleSheet("QFrame { color: palette(mid); }"); + layout->addWidget(sep1); + + /* --- Microphone test --- */ + auto* micLabel = new QLabel("Microphone test", this); + micLabel->setStyleSheet("font-size: 13px; font-weight: bold;"); + layout->addWidget(micLabel); + + auto* micRow = new QHBoxLayout(); + micRow->setSpacing(8); + m_micLevelBar = new QProgressBar(this); + m_micLevelBar->setRange(0, 100); + m_micLevelBar->setValue(0); + m_micLevelBar->setTextVisible(false); + m_micLevelBar->setFixedHeight(16); + m_micLevelBar->setStyleSheet( + "QProgressBar { border: 1px solid palette(mid); border-radius: 4px; background: #333; }" + "QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0," + " stop:0 #27ae60, stop:0.6 #f1c40f, stop:1 #e74c3c); border-radius: 3px; }"); + micRow->addWidget(m_micLevelBar, 1); + + m_testMicBtn = new QPushButton("Test Microphone", this); + m_testMicBtn->setCheckable(true); + m_testMicBtn->setStyleSheet( + "QPushButton { border: 1px solid palette(mid); border-radius: 4px;" + " padding: 4px 12px; font-size: 12px; background: palette(button); }" + "QPushButton:checked { background: #c0392b; color: white; }"); + connect(m_testMicBtn, &QPushButton::toggled, this, &AudioDeviceSettingsPage::onTestMicToggled); + micRow->addWidget(m_testMicBtn); + layout->addLayout(micRow); + + /* --- Separator --- */ + auto* sep2 = new QFrame(this); + sep2->setFrameShape(QFrame::HLine); + sep2->setStyleSheet("QFrame { color: palette(mid); }"); + layout->addWidget(sep2); + + /* --- Speaker test --- */ + auto* spkLabel = new QLabel("Speaker test", this); + spkLabel->setStyleSheet("font-size: 13px; font-weight: bold;"); + layout->addWidget(spkLabel); + + m_testSpeakerBtn = new QPushButton("Test Speaker (440Hz)", this); + m_testSpeakerBtn->setStyleSheet( + "QPushButton { border: 1px solid palette(mid); border-radius: 4px;" + " padding: 6px 16px; font-size: 13px; background: palette(button); }" + "QPushButton:hover { background: palette(light); }"); + connect(m_testSpeakerBtn, &QPushButton::clicked, this, &AudioDeviceSettingsPage::onTestSpeaker); + layout->addWidget(m_testSpeakerBtn); + + /* --- Separator --- */ + auto* sep3 = new QFrame(this); + sep3->setFrameShape(QFrame::HLine); + sep3->setStyleSheet("QFrame { color: palette(mid); }"); + layout->addWidget(sep3); + + /* --- Codec quality --- */ + auto* codecLabel = new QLabel("Voice codec quality", this); + codecLabel->setStyleSheet("font-size: 13px; font-weight: bold;"); + layout->addWidget(codecLabel); + + m_codecPresetCombo = new QComboBox(this); + m_codecPresetCombo->addItem("Low (16 kbps, fast)", 0); + m_codecPresetCombo->addItem("Standard (32 kbps)", 1); + m_codecPresetCombo->addItem("High (64 kbps, best)", 2); + m_codecPresetCombo->setStyleSheet(m_outputCombo->styleSheet()); + layout->addWidget(m_codecPresetCombo); + layout->addStretch(); + + m_levelTimer = new QTimer(this); + m_levelTimer->setInterval(100); + connect(m_levelTimer, &QTimer::timeout, this, &AudioDeviceSettingsPage::onLevelTimer); +} + +AudioDeviceSettingsPage::~AudioDeviceSettingsPage() { + stopMicTest(); } void AudioDeviceSettingsPage::loadFromDb() { auto playbackDevs = SoundManager::enumeratePlaybackDevices(); auto captureDevs = SoundManager::enumerateCaptureDevices(); + /* Output devices */ m_outputCombo->clear(); + m_outputCombo->addItem("Default (auto)", -1); for (int i = 0; i < playbackDevs.size(); i++) { QString label = playbackDevs[i].name; if (playbackDevs[i].isDefault) label += " (default)"; m_outputCombo->addItem(label, i); - if (playbackDevs[i].isDefault && m_savedOutputIdx < 0) m_savedOutputIdx = i; } + /* Input devices */ m_inputCombo->clear(); + m_inputCombo->addItem("Default (auto)", -1); for (int i = 0; i < captureDevs.size(); i++) { QString label = captureDevs[i].name; if (captureDevs[i].isDefault) label += " (default)"; m_inputCombo->addItem(label, i); - if (captureDevs[i].isDefault && m_savedInputIdx < 0) m_savedInputIdx = i; } if (m_db) { - m_savedOutputIdx = m_db->getUiStateInt("audio_output_device", m_savedOutputIdx); - m_savedInputIdx = m_db->getUiStateInt("audio_input_device", m_savedInputIdx); + m_savedOutputIdx = m_db->getUiStateInt("audio_output_device", -1); + m_savedInputIdx = m_db->getUiStateInt("audio_input_device", -1); + m_savedCodecPreset = m_db->getUiStateInt("opus_codec_preset", 1); } - if (m_savedOutputIdx >= 0 && m_savedOutputIdx < m_outputCombo->count()) - m_outputCombo->setCurrentIndex(m_savedOutputIdx); - if (m_savedInputIdx >= 0 && m_savedInputIdx < m_inputCombo->count()) - m_inputCombo->setCurrentIndex(m_savedInputIdx); + /* Select saved indices */ + for (int i = 0; i < m_outputCombo->count(); i++) { + if (m_outputCombo->itemData(i).toInt() == m_savedOutputIdx) + { m_outputCombo->setCurrentIndex(i); break; } + } + for (int i = 0; i < m_inputCombo->count(); i++) { + if (m_inputCombo->itemData(i).toInt() == m_savedInputIdx) + { m_inputCombo->setCurrentIndex(i); break; } + } + for (int i = 0; i < m_codecPresetCombo->count(); i++) { + if (m_codecPresetCombo->itemData(i).toInt() == m_savedCodecPreset) + { m_codecPresetCombo->setCurrentIndex(i); break; } + } } void AudioDeviceSettingsPage::applyAndSave() { int outIdx = m_outputCombo->currentData().toInt(); int inIdx = m_inputCombo->currentData().toInt(); + int codecPreset = m_codecPresetCombo->currentData().toInt(); auto saveKey = [](const char* key, int val) { QByteArray v = QByteArray::number(val); size_t klen = strlen(key), vlen = (size_t)v.size(); size_t total = klen + 1 + vlen + 1; + if (total > 256) return; void* arg = u_malloc(total); memcpy(arg, key, klen + 1); memcpy((char*)arg + klen + 1, v.constData(), vlen + 1); @@ -88,8 +182,65 @@ void AudioDeviceSettingsPage::applyAndSave() { }; saveKey("audio_output_device", outIdx); saveKey("audio_input_device", inIdx); + saveKey("opus_codec_preset", codecPreset); + + VoiceEncoder::setPreset(codecPreset); m_savedOutputIdx = outIdx; m_savedInputIdx = inIdx; - qDebug("AudioDeviceSettings: saved output=%d input=%d", outIdx, inIdx); + m_savedCodecPreset = codecPreset; + qDebug("AudioDeviceSettings: saved output=%d input=%d codec=%d", outIdx, inIdx, codecPreset); +} + +void AudioDeviceSettingsPage::onTestMicToggled() { + if (m_testMicBtn->isChecked()) { + m_testRecorder = new AudioRecorder(this); + m_testRecorder->init(); + int inIdx = m_inputCombo->currentData().toInt(); + m_testRecorder->setCaptureDeviceIndex(inIdx); + m_testRecorder->startRecording(); + m_micTestActive = true; + m_levelTimer->start(); + m_testMicBtn->setText("Stop Test"); + m_micLevelBar->setValue(0); + } else { + stopMicTest(); + m_testMicBtn->setText("Test Microphone"); + } +} + +void AudioDeviceSettingsPage::stopMicTest() { + m_levelTimer->stop(); + if (m_testRecorder) { + m_testRecorder->stopRecording(); + m_testRecorder->shutdown(); + m_testRecorder->deleteLater(); + m_testRecorder = nullptr; + } + m_micTestActive = false; + m_micLevelBar->setValue(0); + m_testMicBtn->setChecked(false); +} + +void AudioDeviceSettingsPage::onLevelTimer() { + if (!m_micTestActive || !m_testRecorder || !m_testRecorder->isRecording()) { + stopMicTest(); + return; + } + float peak = m_testRecorder->peakLevel(); + /* Linear to dB: 20*log10(peak), map -60..0 dB to 0..100 */ + float dbVal = peak > 0.0001f ? 20.0f * std::log10(peak) : -70.0f; + int pct = (int)((dbVal + 70.0f) / 70.0f * 100.0f); + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + m_micLevelBar->setValue(pct); +} + +void AudioDeviceSettingsPage::onTestSpeaker() { + int sampleRate = 48000; + int samples = sampleRate / 4; /* 250ms */ + std::vector pcm(samples); + for (int i = 0; i < samples; i++) + pcm[i] = (int16_t)(12000.0 * sin(2.0 * M_PI * 440.0 * i / sampleRate)); + SoundManager::instance()->playRawPcm(pcm.data(), samples, sampleRate, 1); } diff --git a/tools/chatgui/src/audiodevicesettingspage.h b/tools/chatgui/src/audiodevicesettingspage.h index 6dd9db37..3bef10ba 100644 --- a/tools/chatgui/src/audiodevicesettingspage.h +++ b/tools/chatgui/src/audiodevicesettingspage.h @@ -1,19 +1,39 @@ #pragma once #include #include +#include +#include +#include class DbManager; +class AudioRecorder; class AudioDeviceSettingsPage : public QWidget { Q_OBJECT public: explicit AudioDeviceSettingsPage(DbManager* db, QWidget* parent = nullptr); + ~AudioDeviceSettingsPage() override; void loadFromDb(); void applyAndSave(); +private slots: + void onTestMicToggled(); + void onTestSpeaker(); + void onLevelTimer(); + private: + void stopMicTest(); + DbManager* m_db; + AudioRecorder* m_testRecorder = nullptr; QComboBox* m_outputCombo; QComboBox* m_inputCombo; + QComboBox* m_codecPresetCombo; + QProgressBar* m_micLevelBar; + QPushButton* m_testMicBtn; + QPushButton* m_testSpeakerBtn; + QTimer* m_levelTimer; + bool m_micTestActive = false; int m_savedOutputIdx = -1; int m_savedInputIdx = -1; + int m_savedCodecPreset = 1; }; diff --git a/tools/chatgui/src/audiorecorder.cpp b/tools/chatgui/src/audiorecorder.cpp index 86dcde60..0c7b5626 100644 --- a/tools/chatgui/src/audiorecorder.cpp +++ b/tools/chatgui/src/audiorecorder.cpp @@ -19,6 +19,13 @@ void audioCaptureCallback(ma_device* pDevice, void* pOutput, const void* pInput, size_t add = (size_t)frameCount * (size_t)self->m_channels; self->m_pcmBuffer.resize(cur + add); std::memcpy(self->m_pcmBuffer.data() + cur, src, add * sizeof(int16_t)); + + float maxAbs = 0.0f; + for (unsigned int i = 0; i < frameCount * (unsigned int)self->m_channels; i++) { + float v = (float)abs(src[i]) / 32768.0f; + if (v > maxAbs) maxAbs = v; + } + self->m_peakLevel = maxAbs; } AudioRecorder::AudioRecorder(QObject* parent) @@ -127,3 +134,9 @@ void AudioRecorder::stopRecording() { int AudioRecorder::durationMs() const { return m_elapsedMs; } + +float AudioRecorder::peakLevel() const { + float v = m_peakLevel; + m_peakLevel = 0.0f; + return v; +} diff --git a/tools/chatgui/src/audiorecorder.h b/tools/chatgui/src/audiorecorder.h index 3dae9c9b..af390be7 100644 --- a/tools/chatgui/src/audiorecorder.h +++ b/tools/chatgui/src/audiorecorder.h @@ -25,6 +25,8 @@ public: void stopRecording(); bool isRecording() const { return m_recording; } + float peakLevel() const; + const std::vector& buffer() const { return m_pcmBuffer; } int durationMs() const; @@ -42,6 +44,7 @@ private: int m_captureDeviceIndex = -1; std::vector m_pcmBuffer; + mutable float m_peakLevel = 0.0f; QTimer m_durationTimer; int m_elapsedMs = 0; diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index 39a68bba..07de3681 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -154,6 +154,11 @@ void InputBar::onSendClicked() { #include "audiorecorder.h" #include "voicemessageencoder.h" +void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& channelId) { + m_mediaDirBase = mediaDirBase; + m_channelIdForRecord = channelId; +} + void InputBar::onPttPressed() { if (!m_recorder || !m_recorder->isInitialized()) return; m_recorder->startRecording(); @@ -179,9 +184,7 @@ void InputBar::onPttReleased() { const std::vector& pcm = m_recorder->buffer(); if (pcm.empty()) return; - QString mediaPath = VoiceEncoder::mediaDir( - QString::fromUtf8(qgetenv("CHAT_DATA_DIR").isEmpty() ? "chat_data" : qgetenv("CHAT_DATA_DIR")), - "current"); + QString mediaPath = m_mediaDirBase + "/" + m_channelIdForRecord; VoiceEncoder::ensureMediaDir(mediaPath); QString fileName = VoiceEncoder::generateFileName(); diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index 796c5ee1..2a23e5a1 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -13,6 +13,8 @@ public: explicit InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent = nullptr); + void setRecordingContext(const QString& mediaDirBase, const QString& channelId); + signals: void sendMessage(const QString &text); void sendVoiceMessage(const QString &filePath, int durationMs); @@ -34,4 +36,6 @@ private: AudioRecorder *m_recorder; bool m_pttActive = false; QString m_voiceFilePath; + QString m_mediaDirBase; + QString m_channelIdForRecord; }; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 573c8e0d..133b0bd7 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -9,6 +9,7 @@ #include "invite_link.h" #include "sound_manager.h" #include "audiorecorder.h" +#include "voicemessageencoder.h" #include "animtimer.h" #include "../db/db_manager.h" #include "../transport/utun_node.h" @@ -75,6 +76,7 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath, { setWindowTitle("Chat"); resize(900, 600); + if (m_db) m_db->setDbPath(m_cfgDbPath); setupUi(); setupTray(); setupSoundFromConfig(); @@ -202,10 +204,12 @@ void MainWindow::setupSoundFromConfig() { if (m_recorder) { m_recorder->init(); - if (m_db && m_db->isOpen()) + if (m_db && m_db->isOpen()) { m_recorder->setCaptureDeviceIndex(m_db->getUiStateInt("audio_input_device", -1)); - qDebug("MainWindow: AudioRecorder initialized (captureDevice=%d)", - m_recorder->captureDeviceIndex()); + VoiceEncoder::setPreset(m_db->getUiStateInt("opus_codec_preset", 1)); + } + qDebug("MainWindow: AudioRecorder initialized (captureDevice=%d, opusPreset=%d)", + m_recorder->captureDeviceIndex(), VoiceEncoder::preset()); } } diff --git a/tools/chatgui/src/messagedelegate.cpp b/tools/chatgui/src/messagedelegate.cpp index 72cb2c8b..064929d3 100644 --- a/tools/chatgui/src/messagedelegate.cpp +++ b/tools/chatgui/src/messagedelegate.cpp @@ -4,6 +4,7 @@ #include "emoji.h" #include "animtimer.h" #include "lottieicon.h" +#include "voiceplayback.h" #include #include #include @@ -11,10 +12,88 @@ #include #include #include +#include +#include +#include +#include MessageDelegate::MessageDelegate(QObject *parent) : QStyledItemDelegate(parent) {} +struct VoicePlayState { + bool playing = false; + qint64 startMs = 0; + float durationSec = 0.0f; +}; +static QMap s_voiceStates; +static QTimer* s_voiceTimer = nullptr; + +static void ensureVoiceTimer() { + if (!s_voiceTimer) { + s_voiceTimer = new QTimer(); + s_voiceTimer->setInterval(100); + QObject::connect(s_voiceTimer, &QTimer::timeout, []() { + bool anyPlaying = false; + qint64 now = QDateTime::currentMSecsSinceEpoch(); + for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ) { + if (!it->playing) { ++it; continue; } + float elapsed = (now - it->startMs) / 1000.0f; + if (elapsed >= it->durationSec && it->durationSec > 0) { + it->playing = false; + ++it; + } else { + anyPlaying = true; + ++it; + } + } + if (!anyPlaying) s_voiceTimer->stop(); + // Trigger repaint through any visible view + if (auto* w = QApplication::activeWindow()) w->update(); + }); + } +} + +bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model, + const QStyleOptionViewItem &option, + const QModelIndex &index) { + if (event->type() != QEvent::MouseButtonRelease) return false; + Layout L = calcLayout(option, index); + if (!L.isVoice) return false; + + QMouseEvent* me = static_cast(event); + if (!L.voicePlayBtnRect.contains(me->pos())) return false; + + QString filePath = index.data(MsgVoiceFileRole).toString(); + if (filePath.isEmpty()) return false; + + ensureVoiceTimer(); + VoicePlayState& st = s_voiceStates[filePath]; + + if (st.playing) { + st.playing = false; + model->setData(index, 0, MsgVoicePlayStateRole); + } else { + // Stop any other playing voice + for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) + it->playing = false; + + // Parse duration + QString durStr = index.data(MsgVoiceDurationRole).toString(); + durStr.remove('s'); + float dur = durStr.toFloat(); + if (dur <= 0) dur = 1.0f; + + st.playing = true; + st.startMs = QDateTime::currentMSecsSinceEpoch(); + st.durationSec = dur; + + VoicePlayer::playOpusFile(filePath); + s_voiceTimer->start(); + model->setData(index, 1, MsgVoicePlayStateRole); + } + return true; +} + QSize MessageDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { Layout L = calcLayout(option, index); @@ -482,19 +561,42 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio drawBubble(painter, L, option); if (!L.narrow) drawAvatar(painter, L, index); - /* Play button */ + QString filePath = index.data(MsgVoiceFileRole).toString(); + VoicePlayState st; + if (!filePath.isEmpty() && s_voiceStates.contains(filePath)) { + st = s_voiceStates[filePath]; + } else { + int stateVal = index.data(MsgVoicePlayStateRole).toInt(); + st.playing = (stateVal == 1); + } + + bool isPlaying = st.playing; + QRectF playR = L.voicePlayBtnRect; + + /* Play/Pause button */ painter->setPen(Qt::NoPen); painter->setBrush(QColor(0x33, 0x90, 0xEC)); - QPainterPath playPath; - QRectF playR = L.voicePlayBtnRect; - qreal cx = playR.center().x() - playR.width() / 6.0; - qreal cy = playR.center().y(); - qreal hs = playR.height() / 2.5; - playPath.moveTo(cx, cy - hs); - playPath.lineTo(cx + hs * 1.5, cy); - playPath.lineTo(cx, cy + hs); - playPath.closeSubpath(); - painter->drawPath(playPath); + + if (isPlaying) { + /* Pause icon (two vertical bars) */ + qreal bw = playR.width() / 5.0; + qreal gap = playR.width() / 8.0; + qreal top = playR.center().y() - playR.height() / 3.0; + qreal bot = playR.center().y() + playR.height() / 3.0; + painter->drawRoundedRect(QRectF(playR.center().x() - gap - bw, top, bw, bot - top), 1, 1); + painter->drawRoundedRect(QRectF(playR.center().x() + gap, top, bw, bot - top), 1, 1); + } else { + /* Play icon (triangle) */ + QPainterPath playPath; + qreal cx = playR.center().x() - playR.width() / 6.0; + qreal cy = playR.center().y(); + qreal hs = playR.height() / 2.5; + playPath.moveTo(cx, cy - hs); + playPath.lineTo(cx + hs * 1.5, cy); + playPath.lineTo(cx, cy + hs); + playPath.closeSubpath(); + painter->drawPath(playPath); + } /* Duration text */ painter->setPen(option.palette.text().color()); @@ -502,13 +604,42 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio if (!L.voiceDuration.isEmpty()) painter->drawText(L.voiceDurationRect, Qt::AlignLeft | Qt::AlignVCenter, L.voiceDuration); + /* Progress bar */ + int pbLeft = L.bubbleRect.left() + kPadH; + int pbWidth = L.bubbleWidth - 2 * kPadH; + int pbTop = L.voicePlayBtnRect.bottom() + 6; + int pbHeight = 3; + + if (isPlaying && st.durationSec > 0) { + qint64 now = QDateTime::currentMSecsSinceEpoch(); + float elapsed = (now - st.startMs) / 1000.0f; + if (elapsed > st.durationSec) elapsed = st.durationSec; + float frac = elapsed / st.durationSec; + + painter->setPen(Qt::NoPen); + painter->setBrush(QColor(0x33, 0x90, 0xEC, 60)); + painter->drawRoundedRect(QRect(pbLeft, pbTop, pbWidth, pbHeight), 2, 2); + painter->setBrush(QColor(0x33, 0x90, 0xEC)); + painter->drawRoundedRect(QRect(pbLeft, pbTop, (int)(pbWidth * frac), pbHeight), 2, 2); + + /* Position text */ + QFont timeFont = option.font; + timeFont.setPointSize(timeFont.pointSize() - 2); + painter->setFont(timeFont); + painter->setPen(option.palette.text().color().lighter(150)); + int secs = (int)elapsed; + QString posStr = QString("%1:%2").arg(secs / 60).arg(secs % 60, 2, 10, QChar('0')); + QRect posR(pbLeft, pbTop + 4, pbWidth / 2, 14); + painter->drawText(posR, Qt::AlignLeft | Qt::AlignVCenter, posStr); + } + /* Time */ QFont timeFont = option.font; timeFont.setPointSize(timeFont.pointSize() - 1); painter->setFont(timeFont); painter->setPen(option.palette.text().color().lighter(150)); QString timeStr = index.data(MsgTimeRole).toString(); - QRect timeR = QRect(L.bubbleRect.right() - kPadH - 50, L.voicePlayBtnRect.bottom() + 4, 50, 16); + QRect timeR = QRect(L.bubbleRect.right() - kPadH - 50, pbTop + 4, 50, 16); painter->drawText(timeR, Qt::AlignRight | Qt::AlignVCenter, timeStr); painter->restore(); diff --git a/tools/chatgui/src/messagedelegate.h b/tools/chatgui/src/messagedelegate.h index 03bf704f..69abbab5 100644 --- a/tools/chatgui/src/messagedelegate.h +++ b/tools/chatgui/src/messagedelegate.h @@ -17,6 +17,8 @@ enum MessageDataRole { MsgContentTypeRole = Qt::UserRole + 12, MsgVoiceDurationRole = Qt::UserRole + 13, MsgVoiceFileRole = Qt::UserRole + 14, + MsgVoicePlayStateRole = Qt::UserRole + 15, + MsgVoicePlayPosRole = Qt::UserRole + 16, MsgAuthorNodeIdRole = Qt::UserRole + 20, }; @@ -29,6 +31,9 @@ public: const QModelIndex &index) const override; QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + bool editorEvent(QEvent *event, QAbstractItemModel *model, + const QStyleOptionViewItem &option, + const QModelIndex &index) override; private: struct Layout { diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 8e926fdf..375205be 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -6,6 +6,7 @@ #include "emoji.h" #include "animtimer.h" #include "lottieicon.h" +#include "audiorecorder.h" #include "../db/db_manager.h" #include "../../lib/debug_config.h" #include @@ -17,6 +18,7 @@ #include #include #include +#include #include #include @@ -58,6 +60,51 @@ static QVariant makeReaction(const QString &emoji, int count, return m; } +static QString parseJsonField(const QByteArray& data, const char* field) { + QByteArray pat = QByteArray("\"") + field + "\":\""; + int idx = data.indexOf(pat); + if (idx < 0) return {}; + idx += pat.size(); + int end = data.indexOf('"', idx); + if (end < 0) return {}; + return QString::fromUtf8(data.mid(idx, end - idx)); +} + +static QString parseJsonFieldNum(const QByteArray& data, const char* field) { + QByteArray pat = QByteArray("\"") + field + "\":"; + int idx = data.indexOf(pat); + if (idx < 0) return {}; + idx += pat.size(); + int end = data.indexOf(',', idx); + if (end < 0) end = data.indexOf('}', idx); + if (end < 0) return {}; + return QString::fromUtf8(data.mid(idx, end - idx)); +} + +static QString contentTypeFromJson(const QByteArray& data) { + return parseJsonField(data, "ct"); +} + +static void setVoiceMessageRoles(QStandardItem* item, const QByteArray& data, + const QString& contentType, const QString& mediaDirBase, + const QString& channelId) { + (void)channelId; + QString ct = contentType; + if (ct.isEmpty()) ct = contentTypeFromJson(data); + if (ct.isEmpty()) ct = "text/plain"; + item->setData(ct, MsgContentTypeRole); + + if (ct == "audio/opus") { + QString dur = parseJsonFieldNum(data, "dur"); + if (!dur.isEmpty()) item->setData(dur + "s", MsgVoiceDurationRole); + QString fname = parseJsonField(data, "d"); + if (!fname.isEmpty()) { + QString fullPath = mediaDirBase + "/" + channelId + "/" + fname; + item->setData(fullPath, MsgVoiceFileRole); + } + } +} + static void setMsg(QStandardItem *item, const QString &author, const QString &text, const QString &time, const QPixmap &avatar, bool edited, @@ -84,6 +131,7 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent , m_inputBar(nullptr) , m_emojiPanel(nullptr) , m_db(db) + , m_recorder(recorder) { auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -123,6 +171,41 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent m_inputBar = new InputBar(m_emojiPanel, recorder, this); layout->addWidget(m_inputBar); + /* Recording indicator - hidden by default */ + m_recordIndicator = new QWidget(this); + m_recordIndicator->setFixedHeight(28); + m_recordIndicator->hide(); + auto* recLayout = new QHBoxLayout(m_recordIndicator); + recLayout->setContentsMargins(8, 2, 8, 2); + recLayout->setSpacing(6); + m_recordTimeLabel = new QLabel(m_recordIndicator); + m_recordTimeLabel->setStyleSheet("color: #e74c3c; font-size: 13px; font-weight: bold;"); + recLayout->addWidget(m_recordTimeLabel); + recLayout->addStretch(); + auto* cancelRecBtn = new QPushButton(QString::fromUtf8("\xE2\x9C\x95"), m_recordIndicator); + cancelRecBtn->setFixedSize(22, 22); + cancelRecBtn->setStyleSheet("QPushButton { border: none; color: #e74c3c; font-size: 14px; background: transparent; }"); + connect(cancelRecBtn, &QPushButton::clicked, this, [this]() { + if (m_recorder && m_recorder->isRecording()) m_recorder->stopRecording(); + }); + recLayout->addWidget(cancelRecBtn); + layout->addWidget(m_recordIndicator); + + if (recorder) { + connect(recorder, &AudioRecorder::recordingStarted, this, [this]() { + m_recordIndicator->show(); + m_recordTimeLabel->setText(QString::fromUtf8("\xF0\x9F\x94\xB4 Recording 0:00")); + }); + connect(recorder, &AudioRecorder::recordingStopped, this, [this](int) { + m_recordIndicator->hide(); + }); + connect(recorder, &AudioRecorder::durationChanged, this, [this](int ms) { + int secs = ms / 1000; + m_recordTimeLabel->setText(QString::fromUtf8("\xF0\x9F\x94\xB4 Recording %1:%2") + .arg(secs / 60).arg(secs % 60, 2, 10, QChar('0'))); + }); + } + connect(m_inputBar, &InputBar::sendMessage, this, [this](const QString &text) { if (m_currentChannelId.isEmpty()) { qWarning("MessageList: no channel selected, message not sent"); @@ -225,6 +308,7 @@ void MessageList::loadChannel(const QString& channelId) { m_model->clear(); m_avatarCache.clear(); m_currentChannelId = channelId; + m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId); m_readDebounce->stop(); if (!m_db || !m_db->isOpen()) return; @@ -252,18 +336,8 @@ void MessageList::loadChannel(const QString& channelId) { item->setData(m.id, MsgIdRole); item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole); item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole); - item->setData(m.contentType, MsgContentTypeRole); - if (m.contentType == "audio/opus") { - /* JSON data contains {"dur":X.X} — parse duration */ - QByteArray d = m.data; - int durIdx = d.indexOf("\"dur\":"); - if (durIdx >= 0) { - int durEnd = d.indexOf(',', durIdx); - if (durEnd < 0) durEnd = d.indexOf('}', durIdx); - QByteArray durStr = d.mid(durIdx + 6, durEnd - durIdx - 6); - item->setData(QString::fromUtf8(durStr) + "s", MsgVoiceDurationRole); - } - } + setVoiceMessageRoles(item, m.data, m.contentType, + m_db ? m_db->dbPath() + "/media" : QString(), channelId); m_model->appendRow(item); } @@ -327,17 +401,8 @@ void MessageList::refresh() { item->setData(m.id, MsgIdRole); item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole); item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole); - item->setData(m.contentType, MsgContentTypeRole); - if (m.contentType == "audio/opus") { - QByteArray d = m.data; - int durIdx = d.indexOf("\"dur\":"); - if (durIdx >= 0) { - int durEnd = d.indexOf(',', durIdx); - if (durEnd < 0) durEnd = d.indexOf('}', durIdx); - QByteArray durStr = d.mid(durIdx + 6, durEnd - durIdx - 6); - item->setData(QString::fromUtf8(durStr) + "s", MsgVoiceDurationRole); - } - } + setVoiceMessageRoles(item, m.data, m.contentType, + m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId); m_model->appendRow(item); while (m_model->rowCount() > 100) m_model->removeRow(0); @@ -371,6 +436,8 @@ void MessageList::addMessage(const QString& channelId, quint64 authorNodeId, bool isSelf = m_db && (authorNodeId == m_db->myNodeId()); item->setData(QVariant::fromValue(authorNodeId), MsgAuthorNodeIdRole); item->setData(isSelf ? 1 : 0, MsgIsOutgoingRole); + setVoiceMessageRoles(item, content, QString(), + m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId); m_model->appendRow(item); if (m_model->rowCount() > 50) diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index 013c1d74..c9ec4574 100644 --- a/tools/chatgui/src/messagelist.h +++ b/tools/chatgui/src/messagelist.h @@ -5,6 +5,7 @@ #include #include +class QLabel; class ChatView; class InputBar; class EmojiPanel; @@ -43,8 +44,12 @@ private: qint64 m_savedTopMsgId = 0; bool m_wasAtBottom = false; QMap m_localNicks; + AudioRecorder* m_recorder = nullptr; + QWidget* m_recordIndicator = nullptr; + QLabel* m_recordTimeLabel = nullptr; QString authorName(quint64 nodeId) const; + void updateRecordingUI(); void restoreScrollPosition(const QString& channelId); void updateReadPosition(); }; diff --git a/tools/chatgui/src/voicemessageencoder.cpp b/tools/chatgui/src/voicemessageencoder.cpp index 54f49373..d8f9008a 100644 --- a/tools/chatgui/src/voicemessageencoder.cpp +++ b/tools/chatgui/src/voicemessageencoder.cpp @@ -11,9 +11,21 @@ extern "C" { #include static const int FRAME_MS = 20; -static const uint32_t OPUS_MAGIC = 0x5355504F; /* "OPUS" */ +static const uint32_t OPUS_MAGIC = 0x5355504F; static const int MAX_PACKET = 4000; +static int s_preset = 1; /* 0=Low, 1=Standard, 2=High */ +static const int s_bitrates[] = {16000, 32000, 64000}; +static const int s_complexities[] = {2, 5, 10}; + +void VoiceEncoder::setPreset(int preset) { + if (preset < 0) preset = 0; + if (preset > 2) preset = 2; + s_preset = preset; +} + +int VoiceEncoder::preset() { return s_preset; } + int VoiceEncoder::encodeToFile(const std::vector& pcm, int sampleRate, int channels, const QString& filePath, float& outDurationSec) { if (pcm.empty() || sampleRate <= 0 || channels <= 0) { @@ -30,6 +42,8 @@ int VoiceEncoder::encodeToFile(const std::vector& pcm, int sampleRate, qWarning("VoiceEncoder: encoder create failed"); return -1; } + opus_codec_encoder_bitrate_set(enc, s_bitrates[s_preset]); + opus_codec_encoder_complexity_set(enc, s_complexities[s_preset]); QFile file(filePath); if (!file.open(QIODevice::WriteOnly)) { diff --git a/tools/chatgui/src/voicemessageencoder.h b/tools/chatgui/src/voicemessageencoder.h index 7bcf6a75..5a47870c 100644 --- a/tools/chatgui/src/voicemessageencoder.h +++ b/tools/chatgui/src/voicemessageencoder.h @@ -6,6 +6,9 @@ class VoiceEncoder { public: + static void setPreset(int preset); + static int preset(); + static int encodeToFile(const std::vector& pcm, int sampleRate, int channels, const QString& filePath, float& outDurationSec); static QString generateFileName();