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:
1 parent
398078a28f
commit
b96ed8bd00
69 files changed
+5372
-51
No files matched your search
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user