call: P2P аудиозвонки — сигналинг, релей медиа, аудио-контур, headless/desktop/android
- src/call/: сигналинг (call.c, call_proto.h, ETCP_RT_ID_CALL 0x36) + релей медиа + headless аудио-сокет (call_headless.c, call_audio_bind) + Opus/jitter/тоны - chat: события CHAT_EVT_CALL_* (34–40), CHAT_EVT_DM_MESSAGES (41), DM-трамплины - chatgui: call window, miniaudio I/O, audio_frame_ring, test_audio_jitter - chatgui-android: CallScreen/IncomingCallDialog, аудио-роутинг, JNI, рингтон - tests: test_call, test_call_headless
This commit is contained in:
1 parent
0157f7f96b
commit
b11e77e4c0
78 files changed
+7053
-70
No files matched your search
@@ -471,6 +471,10 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
|
||||
QString name = m_model->data(index, Qt::DisplayRole).toString();
|
||||
|
||||
QMenu menu(m_listView);
|
||||
QAction* pmAction = menu.addAction(QString::fromUtf8("Написать сообщение"));
|
||||
if (nodeId == m_db->myNodeId()) pmAction->setEnabled(false);
|
||||
QAction* callAction = menu.addAction(QString::fromUtf8("Позвонить"));
|
||||
if (nodeId == m_db->myNodeId() || !m_model->isOnlineAt(index.row())) callAction->setEnabled(false);
|
||||
QAction* connectAction = menu.addAction("Connect");
|
||||
if (nodeId == m_db->myNodeId()) connectAction->setEnabled(false);
|
||||
QAction* renameAction = menu.addAction("Rename");
|
||||
@@ -478,6 +482,16 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
|
||||
QAction* editAction = menu.addAction("Edit");
|
||||
QAction* chosen = menu.exec(m_listView->viewport()->mapToGlobal(pos));
|
||||
|
||||
if (chosen == pmAction) {
|
||||
emit pmRequested(m_channelId, nodeId, name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (chosen == callAction) {
|
||||
emit callRequested(m_channelId, nodeId, name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (chosen == connectAction) {
|
||||
struct chat_connect_node_req* r = (struct chat_connect_node_req*)u_malloc(sizeof(*r));
|
||||
if (!r) return;
|
||||
|
||||
@@ -67,6 +67,8 @@ public:
|
||||
|
||||
signals:
|
||||
void memberRenamed();
|
||||
void callRequested(const QString& channelId, quint64 nodeId, const QString& name);
|
||||
void pmRequested(const QString& channelId, quint64 nodeId, const QString& name);
|
||||
|
||||
private slots:
|
||||
void onMemberClicked(const QModelIndex& index);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
/* Кольцевой буфер кадров переменной длины (SPSC, mutex-protected).
|
||||
*
|
||||
* Producer (uasync-поток) и consumer (аудио-поток). Память предвыделена одним
|
||||
* блоком — ноль аллокаций на кадр. При переполнении дропается старейший кадр
|
||||
* (семантика джиттер-буфера).
|
||||
*/
|
||||
class AudioFrameRing {
|
||||
public:
|
||||
AudioFrameRing(int capacity, int slotSize)
|
||||
: cap_(capacity), slotSize_(slotSize),
|
||||
buf_((size_t)capacity * (size_t)slotSize, 0),
|
||||
lens_((size_t)capacity, 0) {}
|
||||
|
||||
/* Producer: положить кадр. Возвращает false если len > slotSize.
|
||||
* При переполнении дропает старейший кадр (успех). */
|
||||
bool push(const uint8_t* data, int len) {
|
||||
if (len <= 0 || len > slotSize_) return false;
|
||||
std::lock_guard<std::mutex> lk(mtx_);
|
||||
if (count_ == cap_) {
|
||||
head_ = (head_ + 1) % cap_; /* drop oldest */
|
||||
count_--;
|
||||
}
|
||||
std::memcpy(buf_.data() + (size_t)tail_ * slotSize_, data, (size_t)len);
|
||||
lens_[(size_t)tail_] = (uint16_t)len;
|
||||
tail_ = (tail_ + 1) % cap_;
|
||||
count_++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Consumer: взять старейший кадр. Возвращает длину (0 = пусто). */
|
||||
int pop(uint8_t* out, int outCap) {
|
||||
std::lock_guard<std::mutex> lk(mtx_);
|
||||
if (count_ == 0) return 0;
|
||||
int len = (int)lens_[(size_t)head_];
|
||||
if (out && len > 0 && len <= outCap)
|
||||
std::memcpy(out, buf_.data() + (size_t)head_ * slotSize_, (size_t)len);
|
||||
head_ = (head_ + 1) % cap_;
|
||||
count_--;
|
||||
return len;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
std::lock_guard<std::mutex> lk(mtx_);
|
||||
head_ = tail_ = count_ = 0;
|
||||
}
|
||||
|
||||
int size() const {
|
||||
std::lock_guard<std::mutex> lk(mtx_);
|
||||
return count_;
|
||||
}
|
||||
|
||||
int capacity() const { return cap_; }
|
||||
|
||||
private:
|
||||
int cap_, slotSize_;
|
||||
int head_ = 0, tail_ = 0, count_ = 0;
|
||||
std::vector<uint8_t> buf_;
|
||||
std::vector<uint16_t> lens_;
|
||||
mutable std::mutex mtx_;
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
#include "call_audio_engine.h"
|
||||
#include "sound_manager.h"
|
||||
#include "../transport/gui_bridge.h"
|
||||
|
||||
#include "miniaudio.h"
|
||||
|
||||
extern "C" {
|
||||
#include "call/call_audio.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
}
|
||||
|
||||
#include <QTimer>
|
||||
#include <cstring>
|
||||
|
||||
static CallAudioEngine* g_callAudioEngine = nullptr;
|
||||
|
||||
static void callAudioDataCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) {
|
||||
CallAudioEngine* e = (CallAudioEngine*)dev->pUserData;
|
||||
if (e) e->dataCallback(in, out, frames);
|
||||
}
|
||||
|
||||
CallAudioEngine* CallAudioEngine::instance() {
|
||||
if (!g_callAudioEngine) g_callAudioEngine = new CallAudioEngine();
|
||||
return g_callAudioEngine;
|
||||
}
|
||||
|
||||
int CallAudioEngine::start(quint64 callId) {
|
||||
if (m_active) {
|
||||
if (m_callId == callId) return 0;
|
||||
stop();
|
||||
}
|
||||
m_callId = callId;
|
||||
|
||||
struct UTUN_INSTANCE* inst = gui_bridge_get_inst();
|
||||
if (!inst) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no instance, cannot start");
|
||||
return -1;
|
||||
}
|
||||
if (call_audio_init(inst) != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_init failed");
|
||||
return -1;
|
||||
}
|
||||
if (call_audio_start(callId) != 0) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_start failed call=%016llx", (unsigned long long)callId);
|
||||
return -1;
|
||||
}
|
||||
|
||||
m_captureBuf.assign(CALL_AUDIO_FRAME_SAMPLES, 0);
|
||||
m_capturePos = 0;
|
||||
m_muted = false;
|
||||
|
||||
if (initDevice() != 0) {
|
||||
call_audio_release(callId);
|
||||
return -1;
|
||||
}
|
||||
|
||||
m_active = true;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: started call=%016llx", (unsigned long long)callId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int CallAudioEngine::initDevice() {
|
||||
ma_context* ctx = SoundManager::instance()->context();
|
||||
if (!ctx) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no audio context");
|
||||
return -1;
|
||||
}
|
||||
|
||||
m_device = new ma_device;
|
||||
std::memset(m_device, 0, sizeof(*m_device));
|
||||
ma_device_config config = ma_device_config_init(ma_device_type_duplex);
|
||||
config.capture.format = ma_format_s16;
|
||||
config.capture.channels = 1;
|
||||
config.playback.format = ma_format_s16;
|
||||
config.playback.channels = 1;
|
||||
config.sampleRate = CALL_AUDIO_SAMPLE_RATE;
|
||||
config.periodSizeInFrames = 480;
|
||||
config.dataCallback = callAudioDataCallback;
|
||||
config.pUserData = this;
|
||||
|
||||
if (m_playbackDeviceIndex >= 0) {
|
||||
ma_device_info* pPlaybackDevices = nullptr;
|
||||
ma_uint32 playbackCount = 0;
|
||||
if (ma_context_get_devices(ctx, &pPlaybackDevices, &playbackCount, nullptr, nullptr) == MA_SUCCESS &&
|
||||
(ma_uint32)m_playbackDeviceIndex < playbackCount) {
|
||||
config.playback.pDeviceID = &pPlaybackDevices[m_playbackDeviceIndex].id;
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "CallAudio: using playback device[%d]: %s",
|
||||
m_playbackDeviceIndex, pPlaybackDevices[m_playbackDeviceIndex].name);
|
||||
} else {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: playback device[%d] not found, using default",
|
||||
m_playbackDeviceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device init failed, trying default");
|
||||
config.playback.pDeviceID = nullptr;
|
||||
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: default device init also failed");
|
||||
delete m_device; m_device = nullptr;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (ma_device_start(m_device) != MA_SUCCESS) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device start failed");
|
||||
ma_device_uninit(m_device);
|
||||
delete m_device; m_device = nullptr;
|
||||
return -1;
|
||||
}
|
||||
m_deviceStarted = true;
|
||||
m_capturePos = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void CallAudioEngine::setMuted(bool muted) {
|
||||
if (m_muted.load() == muted) return;
|
||||
m_muted = muted;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: mute %s", muted ? "on" : "off");
|
||||
}
|
||||
|
||||
void CallAudioEngine::setPlaybackDeviceIndex(int index) {
|
||||
if (index == m_playbackDeviceIndex) return;
|
||||
int prev = m_playbackDeviceIndex;
|
||||
m_playbackDeviceIndex = index;
|
||||
if (m_active) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device %d -> %d", prev, index);
|
||||
teardownDevice();
|
||||
if (initDevice() != 0)
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device failed (kept index=%d)", index);
|
||||
}
|
||||
}
|
||||
|
||||
void CallAudioEngine::beginEnd(quint64 callId) {
|
||||
if (!m_active || m_callId != callId) return;
|
||||
call_audio_begin_end(callId); /* старт тона завершения */
|
||||
QTimer::singleShot(500, this, [this, callId]() {
|
||||
if (m_callId == callId) stop();
|
||||
});
|
||||
}
|
||||
|
||||
void CallAudioEngine::stop() {
|
||||
if (!m_active && !m_device && !m_deviceStarted) return;
|
||||
m_active = false;
|
||||
m_muted = false;
|
||||
quint64 cid = m_callId.load();
|
||||
|
||||
teardownDevice();
|
||||
call_audio_release(cid);
|
||||
|
||||
m_callId = 0;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: stopped");
|
||||
}
|
||||
|
||||
void CallAudioEngine::teardownDevice() {
|
||||
if (m_deviceStarted) {
|
||||
ma_device_stop(m_device);
|
||||
ma_device_uninit(m_device);
|
||||
m_deviceStarted = false;
|
||||
}
|
||||
if (m_device) { delete m_device; m_device = nullptr; }
|
||||
}
|
||||
|
||||
void CallAudioEngine::dataCallback(const void* in, void* out, unsigned int frames) {
|
||||
const int16_t* in16 = (const int16_t*)in;
|
||||
int16_t* out16 = (int16_t*)out;
|
||||
|
||||
/* захват: копим 960 samples → encode+send (сервисный слой). mute → тишина. */
|
||||
if (in16) {
|
||||
bool muted = m_muted.load();
|
||||
for (unsigned int i = 0; i < frames; i++) {
|
||||
m_captureBuf[m_capturePos++] = muted ? 0 : in16[i];
|
||||
if (m_capturePos >= (int)m_captureBuf.size()) {
|
||||
call_audio_feed_pcm(m_callId.load(), m_captureBuf.data(), (int)m_captureBuf.size());
|
||||
m_capturePos = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* воспроизведение: финальный PCM из сервисного слоя (jitter+stretch+тоны) */
|
||||
int n = call_audio_pull_pcm(m_callId.load(), out16, (int)frames);
|
||||
if (n < (int)frames)
|
||||
std::memset(out16 + n, 0, (size_t)(frames - n) * sizeof(int16_t));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
struct ma_device;
|
||||
|
||||
/* Аудио-контур звонка (desktop): только miniaudio I/O + захват/воспроизведение.
|
||||
* Весь аудиопроцессинг (Opus, джиттер-буфер, time-stretch, тоны) — в сервисном
|
||||
* слое src/call/call_audio.c; сюда приходит/отсюда уходит финальный PCM.
|
||||
*
|
||||
* Потоки:
|
||||
* - аудио-поток (miniaudio callback): capture → call_audio_feed_pcm,
|
||||
* call_audio_pull_pcm → playback;
|
||||
* - GUI-поток: start()/beginEnd()/stop() по ACCEPTED/ENDED. */
|
||||
class CallAudioEngine : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
static CallAudioEngine* instance();
|
||||
|
||||
/* Запустить полнодуплекс для звонка (после ACCEPTED). 0 = успех. */
|
||||
int start(quint64 callId);
|
||||
|
||||
/* По CALL_ENDED: тон завершения (~450мс), затем авто-teardown. */
|
||||
void beginEnd(quint64 callId);
|
||||
|
||||
/* Немедленный teardown (device + освобождение call_audio). */
|
||||
void stop();
|
||||
|
||||
bool isActive() const { return m_active.load(); }
|
||||
quint64 callId() const { return m_callId.load(); }
|
||||
|
||||
/* Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
|
||||
void setMuted(bool muted);
|
||||
bool isMuted() const { return m_muted.load(); }
|
||||
|
||||
/* Устройство вывода звонка (-1 = default). Смена на лету пересоздаёт ma_device. */
|
||||
void setPlaybackDeviceIndex(int index);
|
||||
int playbackDeviceIndex() const { return m_playbackDeviceIndex; }
|
||||
|
||||
/* аудио-поток: miniaudio data callback (захват + воспроизведение) */
|
||||
void dataCallback(const void* in, void* out, unsigned int frames);
|
||||
|
||||
private:
|
||||
CallAudioEngine() = default;
|
||||
void teardownDevice();
|
||||
int initDevice();
|
||||
|
||||
std::atomic<bool> m_active{false};
|
||||
std::atomic<uint64_t> m_callId{0};
|
||||
std::atomic<bool> m_muted{false};
|
||||
|
||||
int m_playbackDeviceIndex = -1; /* только GUI-поток */
|
||||
|
||||
ma_device* m_device = nullptr;
|
||||
bool m_deviceStarted = false;
|
||||
|
||||
std::vector<int16_t> m_captureBuf; /* 960 samples, аудио-поток */
|
||||
int m_capturePos = 0;
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
#include "callwindow.h"
|
||||
#include "../transport/gui_bridge.h"
|
||||
#include "call_audio_engine.h"
|
||||
#include "sound_manager.h"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QComboBox>
|
||||
#include <QTimer>
|
||||
#include <QDateTime>
|
||||
#include <QCloseEvent>
|
||||
#include <cstring>
|
||||
|
||||
extern "C" {
|
||||
#include "call/call.h"
|
||||
#include "../../lib/mem.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
}
|
||||
|
||||
CallWindow::CallWindow(QWidget* parent)
|
||||
: QWidget(parent, Qt::Window) {
|
||||
setWindowTitle(QStringLiteral("Звонок"));
|
||||
setFixedSize(320, 300);
|
||||
setAttribute(Qt::WA_DeleteOnClose, false);
|
||||
|
||||
m_peerLabel = new QLabel(this);
|
||||
m_peerLabel->setAlignment(Qt::AlignCenter);
|
||||
m_statusLabel = new QLabel(this);
|
||||
m_statusLabel->setAlignment(Qt::AlignCenter);
|
||||
m_durationLabel = new QLabel(QStringLiteral("00:00"), this);
|
||||
m_durationLabel->setAlignment(Qt::AlignCenter);
|
||||
|
||||
m_acceptBtn = new QPushButton(QStringLiteral("Принять"), this);
|
||||
m_declineBtn = new QPushButton(QStringLiteral("Отклонить"), this);
|
||||
m_hangupBtn = new QPushButton(QStringLiteral("Завершить"), this);
|
||||
|
||||
m_muteBtn = new QPushButton(QStringLiteral("Mute"), this);
|
||||
m_muteBtn->setCheckable(true);
|
||||
m_muteBtn->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; }");
|
||||
|
||||
m_deviceCombo = new QComboBox(this);
|
||||
m_deviceCombo->setStyleSheet(
|
||||
"QComboBox { border: 1px solid palette(mid); border-radius: 4px;"
|
||||
" padding: 6px 10px; font-size: 13px; background: palette(base); }");
|
||||
|
||||
QVBoxLayout* lay = new QVBoxLayout(this);
|
||||
lay->addWidget(m_peerLabel);
|
||||
lay->addWidget(m_statusLabel);
|
||||
lay->addWidget(m_durationLabel);
|
||||
QHBoxLayout* btn = new QHBoxLayout();
|
||||
btn->addWidget(m_acceptBtn);
|
||||
btn->addWidget(m_declineBtn);
|
||||
btn->addWidget(m_hangupBtn);
|
||||
lay->addLayout(btn);
|
||||
lay->addWidget(m_muteBtn);
|
||||
lay->addWidget(m_deviceCombo);
|
||||
|
||||
connect(m_acceptBtn, &QPushButton::clicked, this, &CallWindow::onAccept);
|
||||
connect(m_declineBtn, &QPushButton::clicked, this, &CallWindow::onDecline);
|
||||
connect(m_hangupBtn, &QPushButton::clicked, this, &CallWindow::onHangup);
|
||||
connect(m_muteBtn, &QPushButton::toggled, this, &CallWindow::onMuteToggled);
|
||||
connect(m_deviceCombo, QOverload<int>::of(&QComboBox::activated), this, &CallWindow::onDeviceChanged);
|
||||
|
||||
m_durationTimer = new QTimer(this);
|
||||
m_durationTimer->setInterval(1000);
|
||||
connect(m_durationTimer, &QTimer::timeout, this, &CallWindow::updateDuration);
|
||||
|
||||
setState(Outgoing);
|
||||
}
|
||||
|
||||
CallWindow::~CallWindow() = default;
|
||||
|
||||
void CallWindow::setPeerName(const QString& name) {
|
||||
m_peerLabel->setText(name);
|
||||
}
|
||||
|
||||
void CallWindow::setState(State s) {
|
||||
m_state = s;
|
||||
switch (s) {
|
||||
case Outgoing:
|
||||
m_statusLabel->setText(QStringLiteral("Звоним…"));
|
||||
m_durationLabel->hide();
|
||||
break;
|
||||
case Incoming:
|
||||
m_statusLabel->setText(QStringLiteral("Входящий звонок…"));
|
||||
m_durationLabel->hide();
|
||||
break;
|
||||
case Active:
|
||||
m_statusLabel->setText(QStringLiteral("Разговор"));
|
||||
m_durationLabel->setText(QStringLiteral("00:00"));
|
||||
m_durationLabel->show();
|
||||
m_startMs = QDateTime::currentMSecsSinceEpoch();
|
||||
m_durationTimer->start();
|
||||
populateDeviceCombo();
|
||||
m_muteBtn->setChecked(false);
|
||||
break;
|
||||
case Ended:
|
||||
m_statusLabel->setText(QStringLiteral("Завершён"));
|
||||
m_durationTimer->stop();
|
||||
break;
|
||||
}
|
||||
updateButtons();
|
||||
}
|
||||
|
||||
void CallWindow::populateDeviceCombo() {
|
||||
m_deviceCombo->blockSignals(true);
|
||||
m_deviceCombo->clear();
|
||||
m_deviceCombo->addItem(QStringLiteral("Default (auto)"), -1);
|
||||
const auto devs = SoundManager::enumeratePlaybackDevices();
|
||||
for (int i = 0; i < devs.size(); i++) {
|
||||
QString label = devs[i].name;
|
||||
if (devs[i].isDefault) label += QStringLiteral(" (default)");
|
||||
m_deviceCombo->addItem(label, i);
|
||||
}
|
||||
int cur = CallAudioEngine::instance()->playbackDeviceIndex();
|
||||
for (int i = 0; i < m_deviceCombo->count(); i++) {
|
||||
if (m_deviceCombo->itemData(i).toInt() == cur) { m_deviceCombo->setCurrentIndex(i); break; }
|
||||
}
|
||||
m_deviceCombo->blockSignals(false);
|
||||
}
|
||||
|
||||
void CallWindow::updateButtons() {
|
||||
m_acceptBtn->setVisible(m_state == Incoming);
|
||||
m_declineBtn->setVisible(m_state == Incoming);
|
||||
m_hangupBtn->setVisible(m_state == Outgoing || m_state == Active);
|
||||
m_muteBtn->setVisible(m_state == Active);
|
||||
m_deviceCombo->setVisible(m_state == Active);
|
||||
}
|
||||
|
||||
void CallWindow::postCallFn(void (*fn)(void*), uint64_t callId) {
|
||||
struct call_id_arg* a = (struct call_id_arg*)u_malloc(sizeof(*a));
|
||||
if (!a) return;
|
||||
a->inst = gui_bridge_get_inst();
|
||||
a->call_id = callId;
|
||||
gui_bridge_post_uasync_fn(fn, a);
|
||||
}
|
||||
|
||||
void CallWindow::onAccept() {
|
||||
setState(Active);
|
||||
postCallFn(call_accept_trampoline, m_callId);
|
||||
}
|
||||
|
||||
void CallWindow::onDecline() {
|
||||
postCallFn(call_decline_trampoline, m_callId);
|
||||
}
|
||||
|
||||
void CallWindow::onHangup() {
|
||||
postCallFn(call_hangup_trampoline, m_callId);
|
||||
}
|
||||
|
||||
void CallWindow::onMuteToggled(bool checked) {
|
||||
CallAudioEngine::instance()->setMuted(checked);
|
||||
m_muteBtn->setText(checked ? QStringLiteral("Вкл. микрофон") : QStringLiteral("Mute"));
|
||||
}
|
||||
|
||||
void CallWindow::onDeviceChanged(int index) {
|
||||
int devIdx = m_deviceCombo->itemData(index).toInt();
|
||||
CallAudioEngine::instance()->setPlaybackDeviceIndex(devIdx);
|
||||
}
|
||||
|
||||
void CallWindow::closeEvent(QCloseEvent* event) {
|
||||
if (m_state == Active) postCallFn(call_hangup_trampoline, m_callId);
|
||||
emit closed(m_callId);
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void CallWindow::updateDuration() {
|
||||
if (m_state != Active) return;
|
||||
qint64 ms = QDateTime::currentMSecsSinceEpoch() - m_startMs;
|
||||
qint64 s = ms / 1000;
|
||||
m_durationLabel->setText(QStringLiteral("%1:%2")
|
||||
.arg(s / 60, 2, 10, QChar('0'))
|
||||
.arg(s % 60, 2, 10, QChar('0')));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
#include <cstdint>
|
||||
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class QComboBox;
|
||||
class QTimer;
|
||||
|
||||
/* Окно звонка: состояния Outgoing (звоним), Incoming (принять/отклонить),
|
||||
* Active (идёт разговор, таймер), Ended (завершён). */
|
||||
class CallWindow : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum State { Outgoing, Incoming, Active, Ended };
|
||||
|
||||
explicit CallWindow(QWidget* parent = nullptr);
|
||||
~CallWindow() override;
|
||||
|
||||
void setCallId(quint64 id) { m_callId = id; }
|
||||
quint64 callId() const { return m_callId; }
|
||||
|
||||
void setPeerName(const QString& name);
|
||||
void setState(State s);
|
||||
State state() const { return m_state; }
|
||||
|
||||
signals:
|
||||
void closed(quint64 callId);
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent* event) override;
|
||||
|
||||
private slots:
|
||||
void onAccept();
|
||||
void onDecline();
|
||||
void onHangup();
|
||||
void updateDuration();
|
||||
void onMuteToggled(bool checked);
|
||||
void onDeviceChanged(int index);
|
||||
|
||||
private:
|
||||
void updateButtons();
|
||||
void populateDeviceCombo();
|
||||
void postCallFn(void (*fn)(void*), uint64_t callId);
|
||||
|
||||
quint64 m_callId = 0;
|
||||
State m_state = Outgoing;
|
||||
qint64 m_startMs = 0;
|
||||
|
||||
QLabel* m_peerLabel;
|
||||
QLabel* m_statusLabel;
|
||||
QLabel* m_durationLabel;
|
||||
QPushButton* m_acceptBtn;
|
||||
QPushButton* m_declineBtn;
|
||||
QPushButton* m_hangupBtn;
|
||||
QPushButton* m_muteBtn;
|
||||
QComboBox* m_deviceCombo;
|
||||
QTimer* m_durationTimer;
|
||||
};
|
||||
@@ -30,6 +30,20 @@ void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
|
||||
const QModelIndex &index) const {
|
||||
painter->save();
|
||||
|
||||
/* ── заголовок секции (например, «Личные») ── */
|
||||
if (index.data(ChannelIsSectionRole).toBool()) {
|
||||
painter->fillRect(option.rect, option.palette.window());
|
||||
painter->setPen(option.palette.mid().color());
|
||||
QFont f = option.font;
|
||||
f.setBold(true);
|
||||
painter->setFont(f);
|
||||
QRect tr = option.rect.adjusted(12, 0, -8, 0);
|
||||
painter->drawText(tr, Qt::AlignLeft | Qt::AlignVCenter,
|
||||
index.data(ChannelNameRole).toString());
|
||||
painter->restore();
|
||||
return;
|
||||
}
|
||||
|
||||
const int barWidth = 4;
|
||||
const int margin = 8;
|
||||
const int iconSize = 32;
|
||||
|
||||
@@ -13,7 +13,8 @@ enum ChannelDataRole {
|
||||
ChannelInviteByLinkBtnHoveredRole = Qt::UserRole + 8, /* bool: hover for invite-by-link button */
|
||||
ChannelSettingsBtnHoveredRole = Qt::UserRole + 9, /* bool: hover for per-channel settings gear */
|
||||
ChannelUnreadCountRole = Qt::UserRole + 6, /* int: unread message count, 0 if none */
|
||||
ChannelLastMsgAtRole = Qt::UserRole + 7 /* qint64: last message timestamp for sorting */
|
||||
ChannelLastMsgAtRole = Qt::UserRole + 7, /* qint64: last message timestamp for sorting */
|
||||
ChannelIsSectionRole = Qt::UserRole + 10 /* bool: 1 = заголовок секции (не выбирается) */
|
||||
};
|
||||
|
||||
class ChannelDelegate : public QStyledItemDelegate {
|
||||
|
||||
@@ -28,6 +28,7 @@ protected:
|
||||
void mousePressEvent(QMouseEvent* e) override {
|
||||
QModelIndex idx = indexAt(e->pos());
|
||||
if (!idx.isValid()) return;
|
||||
if (idx.data(ChannelIsSectionRole).toBool()) return;
|
||||
QRect gearRect = ChannelDelegate::settingsButtonRect(visualRect(idx));
|
||||
if (gearRect.contains(e->pos())) {
|
||||
emit settingsClicked(idx.data(ChannelChannelIdRole).toString());
|
||||
@@ -53,7 +54,7 @@ protected:
|
||||
m_hoveredInviteByLinkRow = -1;
|
||||
m_hoveredSettingsRow = -1;
|
||||
QModelIndex idx = indexAt(e->pos());
|
||||
if (idx.isValid()) {
|
||||
if (idx.isValid() && !idx.data(ChannelIsSectionRole).toBool()) {
|
||||
QRect gearRect = ChannelDelegate::settingsButtonRect(visualRect(idx));
|
||||
QRect invRect = ChannelDelegate::inviteByLinkButtonRect(visualRect(idx));
|
||||
QRect plusRect = ChannelDelegate::inviteButtonRect(visualRect(idx));
|
||||
@@ -81,6 +82,8 @@ protected:
|
||||
QListView::mouseMoveEvent(e);
|
||||
}
|
||||
QItemSelectionModel::SelectionFlags selectionCommand(const QModelIndex& idx, const QEvent* e) const override {
|
||||
if (idx.isValid() && idx.data(ChannelIsSectionRole).toBool())
|
||||
return QItemSelectionModel::NoUpdate;
|
||||
if (idx.isValid())
|
||||
return QItemSelectionModel::ClearAndSelect;
|
||||
return QItemSelectionModel::NoUpdate;
|
||||
@@ -236,6 +239,34 @@ void ChannelList::loadChannels() {
|
||||
(long long)ch.lastMsgAt);
|
||||
ci++;
|
||||
}
|
||||
|
||||
/* ── секция «Личные» (DM-беседы) ── */
|
||||
auto dms = m_db->getDmConversations();
|
||||
if (!dms.isEmpty()) {
|
||||
auto *section = new QStandardItem();
|
||||
section->setData(QStringLiteral("Личные"), ChannelNameRole);
|
||||
section->setData(true, ChannelIsSectionRole);
|
||||
section->setFlags(Qt::NoItemFlags);
|
||||
m_model->appendRow(section);
|
||||
|
||||
static const QColor dmColor(0x8E, 0x44, 0xAD);
|
||||
for (const auto& dm : dms) {
|
||||
QString name = dm.peerName;
|
||||
if (name.isEmpty()) name = QString("0x%1").arg(dm.peerNodeId, 4, 16, QChar('0')).toUpper();
|
||||
auto letter = name.mid(0, 1).toUpper();
|
||||
if (letter.isEmpty()) letter = "@";
|
||||
auto *item = new QStandardItem();
|
||||
item->setData(name, ChannelNameRole);
|
||||
item->setData(QStringLiteral("dm:") + dm.convId, ChannelChannelIdRole);
|
||||
item->setData(QString(), ChannelLastMessageRole);
|
||||
item->setData(0, ChannelUnreadCountRole);
|
||||
item->setData(dm.lastTs, ChannelLastMsgAtRole);
|
||||
item->setData(makeChannelIcon(dmColor, letter), ChannelIconRole);
|
||||
m_model->appendRow(item);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "ChannelList::loadChannels dm row=%d conv='%s' peer='%s'",
|
||||
m_model->rowCount()-1, qPrintable(dm.convId), qPrintable(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
sel->blockSignals(false);
|
||||
|
||||
|
||||
@@ -277,6 +277,11 @@ void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& c
|
||||
m_channelIdForRecord = channelId;
|
||||
}
|
||||
|
||||
void InputBar::setMediaEnabled(bool enabled) {
|
||||
if (m_pttBtn) m_pttBtn->setEnabled(enabled);
|
||||
if (m_attachBtn) m_attachBtn->setEnabled(enabled);
|
||||
}
|
||||
|
||||
void InputBar::onPttPressed() {
|
||||
if (!m_recorder || !m_recorder->isInitialized()) return;
|
||||
m_recorder->startRecording();
|
||||
|
||||
@@ -18,6 +18,9 @@ public:
|
||||
|
||||
void setRecordingContext(const QString& mediaDirBase, const QString& channelId);
|
||||
|
||||
/* Включить/выключить медиа-кнопки (PTT/attach). Для DM (text-only) — выключить. */
|
||||
void setMediaEnabled(bool enabled);
|
||||
|
||||
signals:
|
||||
void sendMessage(const QString &text);
|
||||
void sendVoiceMessage(const QString& mediaSrc, const QString& mediaDest,
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
#include "voiceplayback.h"
|
||||
#include "audiorecorder.h"
|
||||
#include "voicemessageencoder.h"
|
||||
#include "callwindow.h"
|
||||
#include "call_audio_engine.h"
|
||||
|
||||
extern "C" {
|
||||
#include "../../lib/audio_compressor.h"
|
||||
@@ -39,10 +41,13 @@ extern "C" {
|
||||
#include <cmath>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QRandomGenerator>
|
||||
#include <csignal>
|
||||
|
||||
extern "C" {
|
||||
#include "chat/chat_core.h"
|
||||
#include "call/call.h"
|
||||
#include "dm/dm_core.h"
|
||||
#include "../../lib/mem.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
void chat_core_attachment_download_trampoline(void* arg);
|
||||
@@ -417,6 +422,38 @@ void MainWindow::setupBridgeCallbacks() {
|
||||
if (s_mainWindow->m_currentChannelId == chId)
|
||||
s_mainWindow->m_accountList->setChannel(chId);
|
||||
});
|
||||
|
||||
/* ── звонки ── */
|
||||
gui_bridge_set_call_incoming_cb([](const char* ch_id, int ch_id_len, uint64_t caller, uint64_t call_id) {
|
||||
if (!s_mainWindow) return;
|
||||
s_mainWindow->onCallIncoming(QString::fromUtf8(ch_id, ch_id_len), caller, call_id);
|
||||
});
|
||||
gui_bridge_set_call_ringing_cb([](uint64_t call_id) {
|
||||
if (s_mainWindow) s_mainWindow->onCallRinging(call_id);
|
||||
});
|
||||
gui_bridge_set_call_accepted_cb([](uint64_t call_id) {
|
||||
if (s_mainWindow) s_mainWindow->onCallAccepted(call_id);
|
||||
});
|
||||
gui_bridge_set_call_ended_cb([](uint64_t call_id, uint8_t reason) {
|
||||
if (s_mainWindow) s_mainWindow->onCallEnded(call_id, reason);
|
||||
});
|
||||
gui_bridge_set_call_declined_cb([](uint64_t call_id, uint8_t reason) {
|
||||
if (s_mainWindow) s_mainWindow->onCallEnded(call_id, reason);
|
||||
});
|
||||
|
||||
/* ── DM (p2p чат) ── */
|
||||
gui_bridge_set_dm_conv_updated_cb([](const char* conv_id, int conv_id_len) {
|
||||
if (s_mainWindow)
|
||||
s_mainWindow->onDmConvUpdated(QString::fromUtf8(conv_id, conv_id_len));
|
||||
});
|
||||
gui_bridge_set_dm_msg_received_cb([](const char* conv_id, int conv_id_len, uint64_t author) {
|
||||
if (s_mainWindow)
|
||||
s_mainWindow->onDmMsgReceived(QString::fromUtf8(conv_id, conv_id_len), author);
|
||||
});
|
||||
gui_bridge_set_dm_messages_cb([](const uint8_t* data, int len) {
|
||||
if (s_mainWindow)
|
||||
s_mainWindow->m_messageList->onDmMessages(data, len);
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::setupConnects() {
|
||||
@@ -425,6 +462,12 @@ void MainWindow::setupConnects() {
|
||||
saveLastChannelId(channelId);
|
||||
m_currentChannelId = channelId;
|
||||
m_messageList->loadChannel(channelId);
|
||||
if (channelId.startsWith(QStringLiteral("dm:"))) {
|
||||
/* DM: беседа уже создана/обслуживается dm_core */
|
||||
m_accountList->setChannel(QString());
|
||||
m_channelList->setChannelUnreadCount(channelId, 0);
|
||||
return;
|
||||
}
|
||||
m_accountList->setChannel(channelId);
|
||||
struct chat_create_auto_arg* arg = (struct chat_create_auto_arg*)u_calloc(1, sizeof(*arg));
|
||||
if (arg) {
|
||||
@@ -442,6 +485,8 @@ void MainWindow::setupConnects() {
|
||||
connect(m_channelList, &ChannelList::channelSettingsRequested, this, &MainWindow::showChannelSettings);
|
||||
connect(m_accountList, &AccountList::memberRenamed,
|
||||
m_messageList, &MessageList::reloadLocalNicks);
|
||||
connect(m_accountList, &AccountList::callRequested, this, &MainWindow::onCallRequested);
|
||||
connect(m_accountList, &AccountList::pmRequested, this, &MainWindow::onPmRequested);
|
||||
connect(m_messageList, &MessageList::readPositionChanged,
|
||||
this, [this](const QString& channelId, qint64 lastReadMsgId) {
|
||||
Q_UNUSED(lastReadMsgId);
|
||||
@@ -453,6 +498,107 @@ void MainWindow::setupConnects() {
|
||||
connect(m_messageList, &MessageList::openImageRequested, this, &MainWindow::openImage);
|
||||
}
|
||||
|
||||
/* ── звонки ── */
|
||||
|
||||
void MainWindow::onCallRequested(const QString& chId, quint64 nodeId, const QString& name) {
|
||||
quint64 callId = QRandomGenerator::global()->generate64();
|
||||
if (!callId) callId = 1;
|
||||
|
||||
struct call_start_arg* a = (struct call_start_arg*)u_malloc(sizeof(*a));
|
||||
if (!a) return;
|
||||
memset(a, 0, sizeof(*a));
|
||||
a->inst = gui_bridge_get_inst();
|
||||
strncpy(a->ch_id, chId.toUtf8().constData(), sizeof(a->ch_id) - 1);
|
||||
a->peer_node_id = nodeId;
|
||||
a->call_id = callId;
|
||||
gui_bridge_post_uasync_fn(call_start_trampoline, a);
|
||||
|
||||
CallWindow* w = new CallWindow(this);
|
||||
w->setCallId(callId);
|
||||
w->setPeerName(name);
|
||||
w->setState(CallWindow::Outgoing);
|
||||
connect(w, &CallWindow::closed, this, &MainWindow::closeCallWindow);
|
||||
m_activeCalls.insert(callId, w);
|
||||
w->show();
|
||||
}
|
||||
|
||||
void MainWindow::onCallIncoming(const QString& chId, quint64 caller, quint64 callId) {
|
||||
Q_UNUSED(chId);
|
||||
if (m_activeCalls.contains(callId)) return;
|
||||
QString name = m_db->getDisplayName(caller);
|
||||
if (name.isEmpty()) name = QStringLiteral("0x%1").arg(caller, 16, 16, QChar('0'));
|
||||
|
||||
CallWindow* w = new CallWindow(this);
|
||||
w->setCallId(callId);
|
||||
w->setPeerName(name);
|
||||
w->setState(CallWindow::Incoming);
|
||||
connect(w, &CallWindow::closed, this, &MainWindow::closeCallWindow);
|
||||
m_activeCalls.insert(callId, w);
|
||||
w->show();
|
||||
w->raise();
|
||||
w->activateWindow();
|
||||
}
|
||||
|
||||
void MainWindow::onCallRinging(quint64 callId) {
|
||||
/* caller слышит гудок — окно уже показано в Outgoing */
|
||||
Q_UNUSED(callId);
|
||||
}
|
||||
|
||||
void MainWindow::onCallAccepted(quint64 callId) {
|
||||
CallWindow* w = m_activeCalls.value(callId, nullptr);
|
||||
if (w) w->setState(CallWindow::Active);
|
||||
if (m_db)
|
||||
CallAudioEngine::instance()->setPlaybackDeviceIndex(m_db->getUiStateInt("audio_output_device", -1));
|
||||
CallAudioEngine::instance()->start(callId);
|
||||
}
|
||||
|
||||
void MainWindow::onCallEnded(quint64 callId, uint8_t reason) {
|
||||
Q_UNUSED(reason);
|
||||
/* тон завершения (~450мс) → авто-teardown аудио-движка */
|
||||
if (CallAudioEngine::instance()->callId() == callId)
|
||||
CallAudioEngine::instance()->beginEnd(callId);
|
||||
closeCallWindow(callId);
|
||||
}
|
||||
|
||||
void MainWindow::closeCallWindow(quint64 callId) {
|
||||
CallWindow* w = m_activeCalls.take(callId);
|
||||
if (!w) return;
|
||||
w->disconnect(this);
|
||||
w->deleteLater();
|
||||
}
|
||||
|
||||
/* ── DM (p2p чат) ── */
|
||||
|
||||
void MainWindow::onPmRequested(const QString& chId, quint64 nodeId, const QString& name) {
|
||||
struct dm_start_req* r = (struct dm_start_req*)u_malloc(sizeof(*r));
|
||||
if (!r) return;
|
||||
memset(r, 0, sizeof(*r));
|
||||
r->inst = gui_bridge_get_inst();
|
||||
r->peer_node_id = nodeId;
|
||||
strncpy(r->source_ch_id, chId.toUtf8().constData(), sizeof(r->source_ch_id) - 1);
|
||||
strncpy(r->peer_name, name.toUtf8().constData(), sizeof(r->peer_name) - 1);
|
||||
gui_bridge_post_uasync_fn(dm_start_trampoline, r);
|
||||
/* открытие беседы произойдёт по CHAT_EVT_DM_CONV_UPDATED (onDmConvUpdated) */
|
||||
}
|
||||
|
||||
void MainWindow::onDmConvUpdated(const QString& convId) {
|
||||
if (!m_channelList) return;
|
||||
QString key = QStringLiteral("dm:") + convId;
|
||||
m_channelList->loadChannels();
|
||||
m_channelList->selectChannel(key);
|
||||
}
|
||||
|
||||
void MainWindow::onDmMsgReceived(const QString& convId, quint64 author) {
|
||||
QString key = QStringLiteral("dm:") + convId;
|
||||
if (m_currentChannelId == key) {
|
||||
m_messageList->refresh();
|
||||
return;
|
||||
}
|
||||
if (author == m_db->myNodeId()) return;
|
||||
if (m_channelList)
|
||||
m_channelList->setChannelUnreadCount(key, m_channelList->getChannelUnreadCount(key) + 1);
|
||||
}
|
||||
|
||||
void MainWindow::setupTray() {
|
||||
m_trayIcon = new QSystemTrayIcon(this);
|
||||
m_trayIcon->setIcon(style()->standardIcon(QStyle::SP_ComputerIcon));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <QMenu>
|
||||
#include <QQueue>
|
||||
#include <QSet>
|
||||
#include <QHash>
|
||||
#include <QTimer>
|
||||
|
||||
class ChannelList;
|
||||
@@ -17,6 +18,8 @@ class AudioRecorder;
|
||||
class ConnMonitorWindow;
|
||||
class VideoPlayerWindow;
|
||||
class ImageViewerWindow;
|
||||
class CallWindow;
|
||||
class CallAudioEngine;
|
||||
|
||||
struct AutoPlayItem {
|
||||
QString channelId;
|
||||
@@ -63,6 +66,19 @@ public:
|
||||
void requestShutdown();
|
||||
UtunNode* utunNode() const { return m_node; }
|
||||
|
||||
/* звонки */
|
||||
void onCallRequested(const QString& chId, quint64 nodeId, const QString& name);
|
||||
void onCallIncoming(const QString& chId, quint64 caller, quint64 callId);
|
||||
void onCallRinging(quint64 callId);
|
||||
void onCallAccepted(quint64 callId);
|
||||
void onCallEnded(quint64 callId, uint8_t reason);
|
||||
void closeCallWindow(quint64 callId);
|
||||
|
||||
/* DM (p2p чат) */
|
||||
void onPmRequested(const QString& chId, quint64 nodeId, const QString& name);
|
||||
void onDmConvUpdated(const QString& convId);
|
||||
void onDmMsgReceived(const QString& convId, quint64 author);
|
||||
|
||||
private:
|
||||
void setupUi();
|
||||
void saveWindowGeometry();
|
||||
@@ -101,4 +117,6 @@ private:
|
||||
QTimer* m_autoPlayTimer = nullptr;
|
||||
QTimer* m_shutdownTimer = nullptr;
|
||||
QSet<int64_t> m_autoPlayDownloading; /* msgIds with download in progress */
|
||||
|
||||
QHash<quint64, CallWindow*> m_activeCalls; /* call_id → окно звонка */
|
||||
};
|
||||
@@ -92,6 +92,11 @@ quint64 MemberListModel::nodeIdAt(int row) const {
|
||||
return it != m_cache.end() ? it->nodeId : 0;
|
||||
}
|
||||
|
||||
bool MemberListModel::isOnlineAt(int row) const {
|
||||
auto it = m_cache.find(row);
|
||||
return it != m_cache.end() ? it->online : false;
|
||||
}
|
||||
|
||||
static void deserialize_member(const uint8_t* src, MemberListItem& item) {
|
||||
memcpy(&item.nodeId, src, 8);
|
||||
uint8_t online = src[8];
|
||||
|
||||
@@ -34,6 +34,7 @@ public:
|
||||
void refresh();
|
||||
void refreshRtt();
|
||||
quint64 nodeIdAt(int row) const;
|
||||
bool isOnlineAt(int row) const;
|
||||
|
||||
/* Called from bridge callbacks (GUI thread) */
|
||||
void onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen);
|
||||
|
||||
@@ -35,6 +35,7 @@ static std::vector<std::unique_ptr<LottieIcon>> s_icons;
|
||||
extern "C" {
|
||||
#include "../transport/gui_bridge.h"
|
||||
#include "chat/chat_core.h"
|
||||
#include "dm/dm_core.h"
|
||||
#include "video/video.h"
|
||||
#include "../../lib/u_async.h"
|
||||
#include "../../lib/mem.h"
|
||||
@@ -357,6 +358,21 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDmChannel(m_currentChannelId)) {
|
||||
QByteArray textData = text.toUtf8();
|
||||
QString conv = dmConvId(m_currentChannelId);
|
||||
struct dm_send_req* req = (struct dm_send_req*)u_malloc(sizeof(struct dm_send_req) + textData.size());
|
||||
if (!req) return;
|
||||
memset(req, 0, sizeof(*req));
|
||||
req->inst = gui_bridge_get_inst();
|
||||
strncpy(req->conv_id, conv.toUtf8().constData(), sizeof(req->conv_id) - 1);
|
||||
req->data = (uint8_t*)(req + 1);
|
||||
req->data_len = (uint32_t)textData.size();
|
||||
memcpy(req->data, textData.constData(), textData.size());
|
||||
gui_bridge_post_uasync_fn(dm_send_trampoline, req);
|
||||
return;
|
||||
}
|
||||
|
||||
QByteArray textData = text.toUtf8();
|
||||
auto ts = QDateTime::currentMSecsSinceEpoch();
|
||||
|
||||
@@ -545,6 +561,19 @@ void MessageList::loadChannel(const QString& channelId) {
|
||||
m_currentChannelId = channelId;
|
||||
m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId);
|
||||
m_readDebounce->stop();
|
||||
|
||||
/* DM: только текст; сообщения расшифровываются в dm_core (uasync-поток) */
|
||||
if (isDmChannel(channelId)) {
|
||||
m_inputBar->setMediaEnabled(false);
|
||||
requestDmMessages(channelId);
|
||||
if (prevFocus)
|
||||
prevFocus->setFocus();
|
||||
else
|
||||
m_inputBar->setFocus();
|
||||
return;
|
||||
}
|
||||
m_inputBar->setMediaEnabled(true);
|
||||
|
||||
if (!m_db || !m_db->isOpen()) return;
|
||||
|
||||
m_localNicks = m_db->getLocalNicknames(channelId);
|
||||
@@ -606,7 +635,14 @@ void MessageList::loadChannel(const QString& channelId) {
|
||||
}
|
||||
|
||||
void MessageList::refresh() {
|
||||
if (m_currentChannelId.isEmpty() || !m_db || !m_db->isOpen()) return;
|
||||
if (m_currentChannelId.isEmpty()) return;
|
||||
|
||||
if (isDmChannel(m_currentChannelId)) {
|
||||
requestDmMessages(m_currentChannelId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_db || !m_db->isOpen()) return;
|
||||
|
||||
qint64 maxId = 0;
|
||||
for (int r = 0; r < m_model->rowCount(); r++)
|
||||
@@ -909,3 +945,73 @@ void MessageList::onMessageContextMenu(const QPoint& pos) {
|
||||
gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── DM (p2p чат) ── */
|
||||
|
||||
bool MessageList::isDmChannel(const QString& channelId) {
|
||||
return channelId.startsWith(QStringLiteral("dm:"));
|
||||
}
|
||||
|
||||
QString MessageList::dmConvId(const QString& channelId) {
|
||||
return channelId.mid(3);
|
||||
}
|
||||
|
||||
void MessageList::requestDmMessages(const QString& channelId) {
|
||||
QString conv = dmConvId(channelId);
|
||||
struct dm_messages_req* req = (struct dm_messages_req*)u_malloc(sizeof(*req));
|
||||
if (!req) return;
|
||||
memset(req, 0, sizeof(*req));
|
||||
req->inst = gui_bridge_get_inst();
|
||||
strncpy(req->conv_id, conv.toUtf8().constData(), sizeof(req->conv_id) - 1);
|
||||
gui_bridge_post_uasync_fn(dm_messages_trampoline, req);
|
||||
}
|
||||
|
||||
void MessageList::onDmMessages(const uint8_t* data, int len) {
|
||||
if (len < 3) return;
|
||||
uint8_t cl = data[0];
|
||||
if (len < 1 + (int)cl + 2) return;
|
||||
QString convId = QString::fromUtf8((const char*)data + 1, cl);
|
||||
if (m_currentChannelId != QStringLiteral("dm:") + convId) return; /* stale response */
|
||||
|
||||
uint16_t count; memcpy(&count, data + 1 + cl, 2);
|
||||
const uint8_t* p = data + 1 + cl + 2;
|
||||
const uint8_t* end = data + len;
|
||||
|
||||
m_model->clear();
|
||||
m_avatarCache.clear();
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (p + 1 + 8 + 8 + 8 + 1 > end) break;
|
||||
int dir = p[0]; p += 1;
|
||||
uint64_t seq; memcpy(&seq, p, 8); p += 8;
|
||||
uint64_t ts; memcpy(&ts, p, 8); p += 8;
|
||||
quint64 author; memcpy(&author, p, 8); p += 8;
|
||||
uint8_t ctl = *p; p += 1;
|
||||
if (p + ctl > end) break;
|
||||
p += ctl; /* ct игнорируем: DM v1 — текст */
|
||||
if (p + 2 > end) break;
|
||||
uint16_t dlen; memcpy(&dlen, p, 2); p += 2;
|
||||
if (p + dlen > end) break;
|
||||
QString text = QString::fromUtf8((const char*)p, dlen);
|
||||
p += dlen;
|
||||
|
||||
AccountRow acc = m_db ? m_db->getAccount(author) : AccountRow();
|
||||
QString name = authorName(author);
|
||||
QString letter = acc.avatarLetter.isEmpty() ? name.mid(0, 1) : acc.avatarLetter;
|
||||
QColor color(acc.avatarColor);
|
||||
if (!m_avatarCache.contains(author))
|
||||
m_avatarCache[author] = color;
|
||||
auto avatar = makeAvatar(color, letter);
|
||||
auto time = QDateTime::fromMSecsSinceEpoch((qint64)ts).toString("HH:mm");
|
||||
|
||||
auto *item = new QStandardItem();
|
||||
setMsg(item, name, text, time, avatar, false,
|
||||
false, QString(), QString(), QVariantList{});
|
||||
item->setData((qint64)seq, MsgIdRole);
|
||||
item->setData(QVariant::fromValue(author), MsgAuthorNodeIdRole);
|
||||
item->setData(dir == 1 ? 1 : 0, MsgIsOutgoingRole);
|
||||
m_model->appendRow(item);
|
||||
}
|
||||
|
||||
m_view->scrollToBottom();
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public:
|
||||
static void cleanupAnimations();
|
||||
void updateMessageProgress(const QString& chId, int64_t msgId, int blocksDone, int numBlocks);
|
||||
void onAttachmentDownloaded(const QString& chId, int64_t msgId);
|
||||
void onDmMessages(const uint8_t* data, int len);
|
||||
|
||||
signals:
|
||||
void readPositionChanged(const QString& channelId, qint64 lastReadMsgId);
|
||||
@@ -52,4 +53,7 @@ private:
|
||||
void restoreScrollPosition(const QString& channelId);
|
||||
void updateReadPosition();
|
||||
void onMessageContextMenu(const QPoint& pos);
|
||||
void requestDmMessages(const QString& channelId);
|
||||
static bool isDmChannel(const QString& channelId);
|
||||
static QString dmConvId(const QString& channelId);
|
||||
};
|
||||
Reference in new issue
Block a user