Browse Source

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
topo_upd
Evgeny 2 months ago
parent
commit
850a260aac
  1. 3
      tools/chatgui/db/db_manager.h
  2. 181
      tools/chatgui/src/audiodevicesettingspage.cpp
  3. 20
      tools/chatgui/src/audiodevicesettingspage.h
  4. 13
      tools/chatgui/src/audiorecorder.cpp
  5. 3
      tools/chatgui/src/audiorecorder.h
  6. 9
      tools/chatgui/src/inputbar.cpp
  7. 4
      tools/chatgui/src/inputbar.h
  8. 10
      tools/chatgui/src/mainwindow.cpp
  9. 155
      tools/chatgui/src/messagedelegate.cpp
  10. 5
      tools/chatgui/src/messagedelegate.h
  11. 113
      tools/chatgui/src/messagelist.cpp
  12. 5
      tools/chatgui/src/messagelist.h
  13. 16
      tools/chatgui/src/voicemessageencoder.cpp
  14. 3
      tools/chatgui/src/voicemessageencoder.h

3
tools/chatgui/db/db_manager.h

@ -81,6 +81,8 @@ public:
bool isOpen() const { return m_db != nullptr; } bool isOpen() const { return m_db != nullptr; }
void setDb(sqlite3* db) { m_db = db; } 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; } quint64 myNodeId() const { return m_myNodeId; }
void setMyNodeId(quint64 nodeId) { m_myNodeId = nodeId; } void setMyNodeId(quint64 nodeId) { m_myNodeId = nodeId; }
@ -144,6 +146,7 @@ public:
IntegrityResult integrityOptimize() const; IntegrityResult integrityOptimize() const;
sqlite3* m_db = nullptr; sqlite3* m_db = nullptr;
QString m_dbPath;
quint64 m_myNodeId = 0; quint64 m_myNodeId = 0;
/* Кэш открытых курсоров для sync-операций */ /* Кэш открытых курсоров для sync-операций */

181
tools/chatgui/src/audiodevicesettingspage.cpp

@ -1,5 +1,7 @@
#include "audiodevicesettingspage.h" #include "audiodevicesettingspage.h"
#include "sound_manager.h" #include "sound_manager.h"
#include "audiorecorder.h"
#include "voicemessageencoder.h"
#include "../db/db_manager.h" #include "../db/db_manager.h"
#include "../transport/gui_bridge.h" #include "../transport/gui_bridge.h"
#include "../../lib/mem.h" #include "../../lib/mem.h"
@ -7,9 +9,12 @@ extern "C" {
#include "../transport/chat_core.h" #include "../transport/chat_core.h"
} }
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QFormLayout> #include <QFormLayout>
#include <QFrame>
#include <QDebug> #include <QDebug>
#include <cmath>
AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, QWidget* parent) AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, QWidget* parent)
: QWidget(parent), m_db(db) : QWidget(parent), m_db(db)
@ -17,13 +22,8 @@ AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, QWidget* parent)
auto* layout = new QVBoxLayout(this); auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 12, 0, 0); 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(); auto* formLayout = new QFormLayout();
formLayout->setContentsMargins(0, 16, 0, 0); formLayout->setContentsMargins(0, 0, 0, 0);
formLayout->setSpacing(10); formLayout->setSpacing(10);
auto* outLabel = new QLabel("Playback device:", this); auto* outLabel = new QLabel("Playback device:", this);
@ -39,48 +39,142 @@ AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, QWidget* parent)
formLayout->addRow(inLabel, m_inputCombo); formLayout->addRow(inLabel, m_inputCombo);
layout->addLayout(formLayout); 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(); 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() { void AudioDeviceSettingsPage::loadFromDb() {
auto playbackDevs = SoundManager::enumeratePlaybackDevices(); auto playbackDevs = SoundManager::enumeratePlaybackDevices();
auto captureDevs = SoundManager::enumerateCaptureDevices(); auto captureDevs = SoundManager::enumerateCaptureDevices();
/* Output devices */
m_outputCombo->clear(); m_outputCombo->clear();
m_outputCombo->addItem("Default (auto)", -1);
for (int i = 0; i < playbackDevs.size(); i++) { for (int i = 0; i < playbackDevs.size(); i++) {
QString label = playbackDevs[i].name; QString label = playbackDevs[i].name;
if (playbackDevs[i].isDefault) label += " (default)"; if (playbackDevs[i].isDefault) label += " (default)";
m_outputCombo->addItem(label, i); m_outputCombo->addItem(label, i);
if (playbackDevs[i].isDefault && m_savedOutputIdx < 0) m_savedOutputIdx = i;
} }
/* Input devices */
m_inputCombo->clear(); m_inputCombo->clear();
m_inputCombo->addItem("Default (auto)", -1);
for (int i = 0; i < captureDevs.size(); i++) { for (int i = 0; i < captureDevs.size(); i++) {
QString label = captureDevs[i].name; QString label = captureDevs[i].name;
if (captureDevs[i].isDefault) label += " (default)"; if (captureDevs[i].isDefault) label += " (default)";
m_inputCombo->addItem(label, i); m_inputCombo->addItem(label, i);
if (captureDevs[i].isDefault && m_savedInputIdx < 0) m_savedInputIdx = i;
} }
if (m_db) { if (m_db) {
m_savedOutputIdx = m_db->getUiStateInt("audio_output_device", m_savedOutputIdx); m_savedOutputIdx = m_db->getUiStateInt("audio_output_device", -1);
m_savedInputIdx = m_db->getUiStateInt("audio_input_device", m_savedInputIdx); 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()) /* Select saved indices */
m_outputCombo->setCurrentIndex(m_savedOutputIdx); for (int i = 0; i < m_outputCombo->count(); i++) {
if (m_savedInputIdx >= 0 && m_savedInputIdx < m_inputCombo->count()) if (m_outputCombo->itemData(i).toInt() == m_savedOutputIdx)
m_inputCombo->setCurrentIndex(m_savedInputIdx); { 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() { void AudioDeviceSettingsPage::applyAndSave() {
int outIdx = m_outputCombo->currentData().toInt(); int outIdx = m_outputCombo->currentData().toInt();
int inIdx = m_inputCombo->currentData().toInt(); int inIdx = m_inputCombo->currentData().toInt();
int codecPreset = m_codecPresetCombo->currentData().toInt();
auto saveKey = [](const char* key, int val) { auto saveKey = [](const char* key, int val) {
QByteArray v = QByteArray::number(val); QByteArray v = QByteArray::number(val);
size_t klen = strlen(key), vlen = (size_t)v.size(); size_t klen = strlen(key), vlen = (size_t)v.size();
size_t total = klen + 1 + vlen + 1; size_t total = klen + 1 + vlen + 1;
if (total > 256) return;
void* arg = u_malloc(total); void* arg = u_malloc(total);
memcpy(arg, key, klen + 1); memcpy(arg, key, klen + 1);
memcpy((char*)arg + klen + 1, v.constData(), vlen + 1); memcpy((char*)arg + klen + 1, v.constData(), vlen + 1);
@ -88,8 +182,65 @@ void AudioDeviceSettingsPage::applyAndSave() {
}; };
saveKey("audio_output_device", outIdx); saveKey("audio_output_device", outIdx);
saveKey("audio_input_device", inIdx); saveKey("audio_input_device", inIdx);
saveKey("opus_codec_preset", codecPreset);
VoiceEncoder::setPreset(codecPreset);
m_savedOutputIdx = outIdx; m_savedOutputIdx = outIdx;
m_savedInputIdx = inIdx; 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<int16_t> 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);
} }

20
tools/chatgui/src/audiodevicesettingspage.h

@ -1,19 +1,39 @@
#pragma once #pragma once
#include <QWidget> #include <QWidget>
#include <QComboBox> #include <QComboBox>
#include <QProgressBar>
#include <QPushButton>
#include <QTimer>
class DbManager; class DbManager;
class AudioRecorder;
class AudioDeviceSettingsPage : public QWidget { class AudioDeviceSettingsPage : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit AudioDeviceSettingsPage(DbManager* db, QWidget* parent = nullptr); explicit AudioDeviceSettingsPage(DbManager* db, QWidget* parent = nullptr);
~AudioDeviceSettingsPage() override;
void loadFromDb(); void loadFromDb();
void applyAndSave(); void applyAndSave();
private slots:
void onTestMicToggled();
void onTestSpeaker();
void onLevelTimer();
private: private:
void stopMicTest();
DbManager* m_db; DbManager* m_db;
AudioRecorder* m_testRecorder = nullptr;
QComboBox* m_outputCombo; QComboBox* m_outputCombo;
QComboBox* m_inputCombo; 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_savedOutputIdx = -1;
int m_savedInputIdx = -1; int m_savedInputIdx = -1;
int m_savedCodecPreset = 1;
}; };

13
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; size_t add = (size_t)frameCount * (size_t)self->m_channels;
self->m_pcmBuffer.resize(cur + add); self->m_pcmBuffer.resize(cur + add);
std::memcpy(self->m_pcmBuffer.data() + cur, src, add * sizeof(int16_t)); 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) AudioRecorder::AudioRecorder(QObject* parent)
@ -127,3 +134,9 @@ void AudioRecorder::stopRecording() {
int AudioRecorder::durationMs() const { int AudioRecorder::durationMs() const {
return m_elapsedMs; return m_elapsedMs;
} }
float AudioRecorder::peakLevel() const {
float v = m_peakLevel;
m_peakLevel = 0.0f;
return v;
}

3
tools/chatgui/src/audiorecorder.h

@ -25,6 +25,8 @@ public:
void stopRecording(); void stopRecording();
bool isRecording() const { return m_recording; } bool isRecording() const { return m_recording; }
float peakLevel() const;
const std::vector<int16_t>& buffer() const { return m_pcmBuffer; } const std::vector<int16_t>& buffer() const { return m_pcmBuffer; }
int durationMs() const; int durationMs() const;
@ -42,6 +44,7 @@ private:
int m_captureDeviceIndex = -1; int m_captureDeviceIndex = -1;
std::vector<int16_t> m_pcmBuffer; std::vector<int16_t> m_pcmBuffer;
mutable float m_peakLevel = 0.0f;
QTimer m_durationTimer; QTimer m_durationTimer;
int m_elapsedMs = 0; int m_elapsedMs = 0;

9
tools/chatgui/src/inputbar.cpp

@ -154,6 +154,11 @@ void InputBar::onSendClicked() {
#include "audiorecorder.h" #include "audiorecorder.h"
#include "voicemessageencoder.h" #include "voicemessageencoder.h"
void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& channelId) {
m_mediaDirBase = mediaDirBase;
m_channelIdForRecord = channelId;
}
void InputBar::onPttPressed() { void InputBar::onPttPressed() {
if (!m_recorder || !m_recorder->isInitialized()) return; if (!m_recorder || !m_recorder->isInitialized()) return;
m_recorder->startRecording(); m_recorder->startRecording();
@ -179,9 +184,7 @@ void InputBar::onPttReleased() {
const std::vector<int16_t>& pcm = m_recorder->buffer(); const std::vector<int16_t>& pcm = m_recorder->buffer();
if (pcm.empty()) return; if (pcm.empty()) return;
QString mediaPath = VoiceEncoder::mediaDir( QString mediaPath = m_mediaDirBase + "/" + m_channelIdForRecord;
QString::fromUtf8(qgetenv("CHAT_DATA_DIR").isEmpty() ? "chat_data" : qgetenv("CHAT_DATA_DIR")),
"current");
VoiceEncoder::ensureMediaDir(mediaPath); VoiceEncoder::ensureMediaDir(mediaPath);
QString fileName = VoiceEncoder::generateFileName(); QString fileName = VoiceEncoder::generateFileName();

4
tools/chatgui/src/inputbar.h

@ -13,6 +13,8 @@ public:
explicit InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, explicit InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder,
QWidget *parent = nullptr); QWidget *parent = nullptr);
void setRecordingContext(const QString& mediaDirBase, const QString& channelId);
signals: signals:
void sendMessage(const QString &text); void sendMessage(const QString &text);
void sendVoiceMessage(const QString &filePath, int durationMs); void sendVoiceMessage(const QString &filePath, int durationMs);
@ -34,4 +36,6 @@ private:
AudioRecorder *m_recorder; AudioRecorder *m_recorder;
bool m_pttActive = false; bool m_pttActive = false;
QString m_voiceFilePath; QString m_voiceFilePath;
QString m_mediaDirBase;
QString m_channelIdForRecord;
}; };

10
tools/chatgui/src/mainwindow.cpp

@ -9,6 +9,7 @@
#include "invite_link.h" #include "invite_link.h"
#include "sound_manager.h" #include "sound_manager.h"
#include "audiorecorder.h" #include "audiorecorder.h"
#include "voicemessageencoder.h"
#include "animtimer.h" #include "animtimer.h"
#include "../db/db_manager.h" #include "../db/db_manager.h"
#include "../transport/utun_node.h" #include "../transport/utun_node.h"
@ -75,6 +76,7 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath,
{ {
setWindowTitle("Chat"); setWindowTitle("Chat");
resize(900, 600); resize(900, 600);
if (m_db) m_db->setDbPath(m_cfgDbPath);
setupUi(); setupUi();
setupTray(); setupTray();
setupSoundFromConfig(); setupSoundFromConfig();
@ -202,10 +204,12 @@ void MainWindow::setupSoundFromConfig() {
if (m_recorder) { if (m_recorder) {
m_recorder->init(); 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)); m_recorder->setCaptureDeviceIndex(m_db->getUiStateInt("audio_input_device", -1));
qDebug("MainWindow: AudioRecorder initialized (captureDevice=%d)", VoiceEncoder::setPreset(m_db->getUiStateInt("opus_codec_preset", 1));
m_recorder->captureDeviceIndex()); }
qDebug("MainWindow: AudioRecorder initialized (captureDevice=%d, opusPreset=%d)",
m_recorder->captureDeviceIndex(), VoiceEncoder::preset());
} }
} }

155
tools/chatgui/src/messagedelegate.cpp

@ -4,6 +4,7 @@
#include "emoji.h" #include "emoji.h"
#include "animtimer.h" #include "animtimer.h"
#include "lottieicon.h" #include "lottieicon.h"
#include "voiceplayback.h"
#include <cmath> #include <cmath>
#include <QPainter> #include <QPainter>
#include <QPainterPath> #include <QPainterPath>
@ -11,10 +12,88 @@
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QFontMetrics> #include <QFontMetrics>
#include <QTextLayout> #include <QTextLayout>
#include <QMouseEvent>
#include <QMap>
#include <QDateTime>
#include <QTimer>
MessageDelegate::MessageDelegate(QObject *parent) MessageDelegate::MessageDelegate(QObject *parent)
: QStyledItemDelegate(parent) {} : QStyledItemDelegate(parent) {}
struct VoicePlayState {
bool playing = false;
qint64 startMs = 0;
float durationSec = 0.0f;
};
static QMap<QString, VoicePlayState> 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<QMouseEvent*>(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, QSize MessageDelegate::sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const { const QModelIndex &index) const {
Layout L = calcLayout(option, index); Layout L = calcLayout(option, index);
@ -482,19 +561,42 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
drawBubble(painter, L, option); drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index); 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->setPen(Qt::NoPen);
painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->setBrush(QColor(0x33, 0x90, 0xEC));
QPainterPath playPath;
QRectF playR = L.voicePlayBtnRect; if (isPlaying) {
qreal cx = playR.center().x() - playR.width() / 6.0; /* Pause icon (two vertical bars) */
qreal cy = playR.center().y(); qreal bw = playR.width() / 5.0;
qreal hs = playR.height() / 2.5; qreal gap = playR.width() / 8.0;
playPath.moveTo(cx, cy - hs); qreal top = playR.center().y() - playR.height() / 3.0;
playPath.lineTo(cx + hs * 1.5, cy); qreal bot = playR.center().y() + playR.height() / 3.0;
playPath.lineTo(cx, cy + hs); painter->drawRoundedRect(QRectF(playR.center().x() - gap - bw, top, bw, bot - top), 1, 1);
playPath.closeSubpath(); painter->drawRoundedRect(QRectF(playR.center().x() + gap, top, bw, bot - top), 1, 1);
painter->drawPath(playPath); } 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 */ /* Duration text */
painter->setPen(option.palette.text().color()); painter->setPen(option.palette.text().color());
@ -502,13 +604,42 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
if (!L.voiceDuration.isEmpty()) if (!L.voiceDuration.isEmpty())
painter->drawText(L.voiceDurationRect, Qt::AlignLeft | Qt::AlignVCenter, L.voiceDuration); 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 */ /* Time */
QFont timeFont = option.font; QFont timeFont = option.font;
timeFont.setPointSize(timeFont.pointSize() - 1); timeFont.setPointSize(timeFont.pointSize() - 1);
painter->setFont(timeFont); painter->setFont(timeFont);
painter->setPen(option.palette.text().color().lighter(150)); painter->setPen(option.palette.text().color().lighter(150));
QString timeStr = index.data(MsgTimeRole).toString(); 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->drawText(timeR, Qt::AlignRight | Qt::AlignVCenter, timeStr);
painter->restore(); painter->restore();

5
tools/chatgui/src/messagedelegate.h

@ -17,6 +17,8 @@ enum MessageDataRole {
MsgContentTypeRole = Qt::UserRole + 12, MsgContentTypeRole = Qt::UserRole + 12,
MsgVoiceDurationRole = Qt::UserRole + 13, MsgVoiceDurationRole = Qt::UserRole + 13,
MsgVoiceFileRole = Qt::UserRole + 14, MsgVoiceFileRole = Qt::UserRole + 14,
MsgVoicePlayStateRole = Qt::UserRole + 15,
MsgVoicePlayPosRole = Qt::UserRole + 16,
MsgAuthorNodeIdRole = Qt::UserRole + 20, MsgAuthorNodeIdRole = Qt::UserRole + 20,
}; };
@ -29,6 +31,9 @@ public:
const QModelIndex &index) const override; const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option, QSize sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const override; const QModelIndex &index) const override;
bool editorEvent(QEvent *event, QAbstractItemModel *model,
const QStyleOptionViewItem &option,
const QModelIndex &index) override;
private: private:
struct Layout { struct Layout {

113
tools/chatgui/src/messagelist.cpp

@ -6,6 +6,7 @@
#include "emoji.h" #include "emoji.h"
#include "animtimer.h" #include "animtimer.h"
#include "lottieicon.h" #include "lottieicon.h"
#include "audiorecorder.h"
#include "../db/db_manager.h" #include "../db/db_manager.h"
#include "../../lib/debug_config.h" #include "../../lib/debug_config.h"
#include <QVBoxLayout> #include <QVBoxLayout>
@ -17,6 +18,7 @@
#include <QApplication> #include <QApplication>
#include <QDebug> #include <QDebug>
#include <QFileInfo> #include <QFileInfo>
#include <QLabel>
#include <vector> #include <vector>
#include <memory> #include <memory>
@ -58,6 +60,51 @@ static QVariant makeReaction(const QString &emoji, int count,
return m; 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, static void setMsg(QStandardItem *item,
const QString &author, const QString &text, const QString &time, const QString &author, const QString &text, const QString &time,
const QPixmap &avatar, bool edited, const QPixmap &avatar, bool edited,
@ -84,6 +131,7 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
, m_inputBar(nullptr) , m_inputBar(nullptr)
, m_emojiPanel(nullptr) , m_emojiPanel(nullptr)
, m_db(db) , m_db(db)
, m_recorder(recorder)
{ {
auto *layout = new QVBoxLayout(this); auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0); 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); m_inputBar = new InputBar(m_emojiPanel, recorder, this);
layout->addWidget(m_inputBar); 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) { connect(m_inputBar, &InputBar::sendMessage, this, [this](const QString &text) {
if (m_currentChannelId.isEmpty()) { if (m_currentChannelId.isEmpty()) {
qWarning("MessageList: no channel selected, message not sent"); qWarning("MessageList: no channel selected, message not sent");
@ -225,6 +308,7 @@ void MessageList::loadChannel(const QString& channelId) {
m_model->clear(); m_model->clear();
m_avatarCache.clear(); m_avatarCache.clear();
m_currentChannelId = channelId; m_currentChannelId = channelId;
m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId);
m_readDebounce->stop(); m_readDebounce->stop();
if (!m_db || !m_db->isOpen()) return; if (!m_db || !m_db->isOpen()) return;
@ -252,18 +336,8 @@ void MessageList::loadChannel(const QString& channelId) {
item->setData(m.id, MsgIdRole); item->setData(m.id, MsgIdRole);
item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole); item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole);
item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole); item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole);
item->setData(m.contentType, MsgContentTypeRole); setVoiceMessageRoles(item, m.data, m.contentType,
if (m.contentType == "audio/opus") { m_db ? m_db->dbPath() + "/media" : QString(), channelId);
/* 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);
}
}
m_model->appendRow(item); m_model->appendRow(item);
} }
@ -327,17 +401,8 @@ void MessageList::refresh() {
item->setData(m.id, MsgIdRole); item->setData(m.id, MsgIdRole);
item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole); item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole);
item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole); item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole);
item->setData(m.contentType, MsgContentTypeRole); setVoiceMessageRoles(item, m.data, m.contentType,
if (m.contentType == "audio/opus") { m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId);
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);
}
}
m_model->appendRow(item); m_model->appendRow(item);
while (m_model->rowCount() > 100) while (m_model->rowCount() > 100)
m_model->removeRow(0); m_model->removeRow(0);
@ -371,6 +436,8 @@ void MessageList::addMessage(const QString& channelId, quint64 authorNodeId,
bool isSelf = m_db && (authorNodeId == m_db->myNodeId()); bool isSelf = m_db && (authorNodeId == m_db->myNodeId());
item->setData(QVariant::fromValue(authorNodeId), MsgAuthorNodeIdRole); item->setData(QVariant::fromValue(authorNodeId), MsgAuthorNodeIdRole);
item->setData(isSelf ? 1 : 0, MsgIsOutgoingRole); item->setData(isSelf ? 1 : 0, MsgIsOutgoingRole);
setVoiceMessageRoles(item, content, QString(),
m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId);
m_model->appendRow(item); m_model->appendRow(item);
if (m_model->rowCount() > 50) if (m_model->rowCount() > 50)

5
tools/chatgui/src/messagelist.h

@ -5,6 +5,7 @@
#include <QHash> #include <QHash>
#include <QTimer> #include <QTimer>
class QLabel;
class ChatView; class ChatView;
class InputBar; class InputBar;
class EmojiPanel; class EmojiPanel;
@ -43,8 +44,12 @@ private:
qint64 m_savedTopMsgId = 0; qint64 m_savedTopMsgId = 0;
bool m_wasAtBottom = false; bool m_wasAtBottom = false;
QMap<quint64, QString> m_localNicks; QMap<quint64, QString> m_localNicks;
AudioRecorder* m_recorder = nullptr;
QWidget* m_recordIndicator = nullptr;
QLabel* m_recordTimeLabel = nullptr;
QString authorName(quint64 nodeId) const; QString authorName(quint64 nodeId) const;
void updateRecordingUI();
void restoreScrollPosition(const QString& channelId); void restoreScrollPosition(const QString& channelId);
void updateReadPosition(); void updateReadPosition();
}; };

16
tools/chatgui/src/voicemessageencoder.cpp

@ -11,9 +11,21 @@ extern "C" {
#include <cstring> #include <cstring>
static const int FRAME_MS = 20; 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 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<int16_t>& pcm, int sampleRate, int channels, int VoiceEncoder::encodeToFile(const std::vector<int16_t>& pcm, int sampleRate, int channels,
const QString& filePath, float& outDurationSec) { const QString& filePath, float& outDurationSec) {
if (pcm.empty() || sampleRate <= 0 || channels <= 0) { if (pcm.empty() || sampleRate <= 0 || channels <= 0) {
@ -30,6 +42,8 @@ int VoiceEncoder::encodeToFile(const std::vector<int16_t>& pcm, int sampleRate,
qWarning("VoiceEncoder: encoder create failed"); qWarning("VoiceEncoder: encoder create failed");
return -1; 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); QFile file(filePath);
if (!file.open(QIODevice::WriteOnly)) { if (!file.open(QIODevice::WriteOnly)) {

3
tools/chatgui/src/voicemessageencoder.h

@ -6,6 +6,9 @@
class VoiceEncoder { class VoiceEncoder {
public: public:
static void setPreset(int preset);
static int preset();
static int encodeToFile(const std::vector<int16_t>& pcm, int sampleRate, int channels, static int encodeToFile(const std::vector<int16_t>& pcm, int sampleRate, int channels,
const QString& filePath, float& outDurationSec); const QString& filePath, float& outDurationSec);
static QString generateFileName(); static QString generateFileName();

Loading…
Cancel
Save