radio: PTT-радио в каналах (src/radio + chatgui + chatgui-android)

Новый модуль src/radio (radio/radio_audio/radio_headless) — push-to-talk
радио поверх topo-групп, доставка аудио в канал, headless-режим и тесты.
Интеграция в chatgui (ptt_hotkey_manager, radio_audio_engine, радионастройки,
вызовы) и chatgui-android (RadioAudioEngine, RadioOverlayService, UI).
This commit is contained in:
evgeny committed 2026-09-19 17:09:00 +03:00
1 parent 398078a28f
commit b96ed8bd00
69 files changed
+5372 -51

No files matched your search

+135
View File
@@ -37,12 +37,18 @@ extern "C" {
#include "chat/chat_core.h"
#include "dm/dm_core.h"
#include "video/video.h"
#include "radio/radio.h"
#include "../../lib/u_async.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
void chat_core_save_ui_state_trampoline(void* arg);
}
#include "radio_audio_engine.h"
#include <QLabel>
#include <QPushButton>
#include <QHBoxLayout>
static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = 36) {
QPixmap pix(size, size);
pix.fill(Qt::transparent);
@@ -308,6 +314,38 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
/* ── шапка чата: название канала + переключатель рации ── */
{
auto* header = new QWidget(this);
auto* hl = new QHBoxLayout(header);
hl->setContentsMargins(10, 6, 10, 6);
hl->setSpacing(8);
m_channelTitle = new QLabel(header);
m_channelTitle->setStyleSheet("font-weight: bold; font-size: 14px;");
m_channelTitle->setText(QStringLiteral("Канал"));
hl->addWidget(m_channelTitle, 1);
m_talkingLabel = new QLabel(header);
m_talkingLabel->setStyleSheet("color: #27ae60; font-size: 12px;");
m_talkingLabel->hide();
hl->addWidget(m_talkingLabel);
m_radioBtn = new QPushButton(QStringLiteral("\xF0\x9F\x93\xBB"), header);
m_radioBtn->setCheckable(true);
m_radioBtn->setFixedSize(34, 30);
m_radioBtn->setCursor(Qt::PointingHandCursor);
m_radioBtn->setToolTip(QStringLiteral("Рация: включить/выключить прослушивание канала"));
m_radioBtn->setStyleSheet(
"QPushButton { border: 1px solid palette(mid); border-radius: 6px; background: palette(base); font-size: 15px; }"
"QPushButton:checked { background: #27ae60; border-color: #27ae60; }");
connect(m_radioBtn, &QPushButton::toggled, this, &MessageList::onRadioToggled);
hl->addWidget(m_radioBtn);
header->setStyleSheet("background: palette(window);");
layout->addWidget(header);
}
m_view->setModel(m_model);
auto* msgDelegate = new MessageDelegate(this);
msgDelegate->setVideoOpenCallback([this](const QString& path) {
@@ -328,6 +366,22 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
m_view->setBackgroundImage("../resources/bg.jpg");
layout->addWidget(m_view, 1);
/* ── плавающая PTT-кнопка поверх чата (видна при включённой рации) ── */
m_pttBtn = new QPushButton(QStringLiteral("\xF0\x9F\x8E\xA4"), this);
m_pttBtn->setFixedSize(96, 96);
m_pttBtn->setCursor(Qt::PointingHandCursor);
m_pttBtn->setToolTip(QStringLiteral("Держите и говорите"));
m_pttBtn->setStyleSheet(
"QPushButton { border: 3px solid white; border-radius: 48px; background: #e74c3c; color: white; font-size: 36px; }"
"QPushButton:pressed { background: #c0392b; }");
m_pttBtn->hide();
connect(m_pttBtn, &QPushButton::pressed, this, []() {
RadioAudioEngine::instance()->talkBegin();
});
connect(m_pttBtn, &QPushButton::released, this, []() {
RadioAudioEngine::instance()->talkEnd();
});
if (s_icons.empty()) {
for (const auto &cat : builtinEmojiSet()) {
for (const auto &e : cat.items) {
@@ -548,6 +602,15 @@ void MessageList::loadChannel(const QString& channelId) {
if (!m_currentChannelId.isEmpty()) {
if (!sameChannel) {
saveChannelState();
/* выключаем рацию предыдущего канала при переключении */
if (m_radioOn) {
m_radioBtn->blockSignals(true);
m_radioBtn->setChecked(false);
m_radioBtn->blockSignals(false);
m_radioOn = false;
RadioAudioEngine::instance()->stop();
updateRadioUi();
}
} else {
QModelIndex topIdx = m_view->indexAt(QPoint(0, 0));
m_savedTopMsgId = topIdx.isValid() ? topIdx.data(MsgIdRole).toLongLong() : 0;
@@ -562,8 +625,18 @@ void MessageList::loadChannel(const QString& channelId) {
m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId);
m_readDebounce->stop();
/* шапка: название канала */
QString title = channelId;
if (m_db && !isDmChannel(channelId)) {
for (const auto& ch : m_db->getChannels()) {
if (ch.channelId == channelId) { title = ch.name; break; }
}
}
if (m_channelTitle) m_channelTitle->setText(title);
/* DM: только текст; сообщения расшифровываются в dm_core (uasync-поток) */
if (isDmChannel(channelId)) {
m_radioBtn->setEnabled(false);
m_inputBar->setMediaEnabled(false);
requestDmMessages(channelId);
if (prevFocus)
@@ -572,6 +645,7 @@ void MessageList::loadChannel(const QString& channelId) {
m_inputBar->setFocus();
return;
}
m_radioBtn->setEnabled(true);
m_inputBar->setMediaEnabled(true);
if (!m_db || !m_db->isOpen()) return;
@@ -1015,3 +1089,64 @@ void MessageList::onDmMessages(const uint8_t* data, int len) {
m_view->scrollToBottom();
}
/* ── рация ── */
void MessageList::onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on) {
if (m_currentChannelId.isEmpty()) return;
if (groupId != m_currentChannelId.toULongLong()) return;
if (!m_talkingLabel) return;
if (on) {
QString name = authorName(srcNodeId);
if (name.isEmpty()) name = QStringLiteral("0x%1").arg(srcNodeId, 0, 16);
m_talkingLabel->setText(QStringLiteral("\xF0\x9F\x8E\x99 %1 говорит").arg(name));
m_talkingLabel->show();
} else {
m_talkingLabel->hide();
}
}
void MessageList::onRadioToggled(bool on) {
if (m_currentChannelId.isEmpty() || isDmChannel(m_currentChannelId)) return;
quint64 groupId = m_currentChannelId.toULongLong();
if (groupId == 0) return;
struct radio_set_active_arg* a = (struct radio_set_active_arg*)u_malloc(sizeof(*a));
if (!a) return;
a->inst = gui_bridge_get_inst();
a->group_id = groupId;
a->on = on ? 1 : 0;
gui_bridge_post_uasync_fn(radio_set_active_trampoline, a);
m_radioOn = on;
if (on) {
RadioAudioEngine::instance()->start(groupId);
} else {
RadioAudioEngine::instance()->stop();
}
updateRadioUi();
}
void MessageList::updateRadioUi() {
if (!m_pttBtn) return;
bool show = m_radioOn && !isDmChannel(m_currentChannelId);
m_pttBtn->setVisible(show);
if (show) {
m_pttBtn->raise();
layoutPttButton();
}
}
void MessageList::layoutPttButton() {
if (!m_pttBtn || !m_pttBtn->isVisible()) return;
int w = width();
int x = (w - m_pttBtn->width()) / 2;
int y = 48; /* под шапкой, поверх списка сообщений */
m_pttBtn->move(x, y);
}
void MessageList::resizeEvent(QResizeEvent* event) {
QWidget::resizeEvent(event);
layoutPttButton();
}