A-B: Remove gui_bridge_post_uasync_fn log, MainWindow::onMessageReceived, InputBar send text B1: Move lifecycle messages to GENERAL (utun_node, main, mainwindow, animtimer, sound_manager, messagelist, db_manager) B2: Move chat events to CHAT_SYNC (MSG_RECEIVED, CHANNEL_UPDATED, CHANNEL_PEERS_ONLINE, DB_READY) B3: Move peer/connect events to MEMBER_SYNC (CONNECT_RESULT, NEW_PEER, AUTO_CONNECT, NODE_CHANGED) C: Lower level of C-side spam (recalc, send OK, PUSH ACK, connect_channel) INFO->DEBUG D: Fix duplicate PUSH log line in db_sync.c E: Add debounce to CHANNEL_PEERS_ONLINE — log only on change Misc: Remove sort=0 from ChannelList::loadChannels, improve DB_READY message
396 lines
15 KiB
C++
396 lines
15 KiB
C++
#include "inputbar.h"
|
|
#include "emojipanel.h"
|
|
#include "audiorecorder.h"
|
|
#include "voicemessageencoder.h"
|
|
#include "media_blocks.h"
|
|
#include "debug_ui.h"
|
|
#include <QDebug>
|
|
#include <QTextEdit>
|
|
#include <QPushButton>
|
|
#include <QHBoxLayout>
|
|
#include <QVBoxLayout>
|
|
#include <QFileDialog>
|
|
#include <QKeyEvent>
|
|
#include <QFontMetrics>
|
|
#include <QScrollBar>
|
|
#include <QApplication>
|
|
#include <QLabel>
|
|
#include <QResizeEvent>
|
|
#include <QDir>
|
|
#include <QProgressBar>
|
|
#include <QIcon>
|
|
#include <cmath>
|
|
|
|
static QPushButton *makeIconBtn(const QIcon& icon, const QString &tooltip, QWidget *parent) {
|
|
auto *btn = new QPushButton(parent);
|
|
btn->setIcon(icon);
|
|
btn->setIconSize(QSize(20, 20));
|
|
btn->setFixedSize(34, 34);
|
|
btn->setCursor(Qt::PointingHandCursor);
|
|
btn->setToolTip(tooltip);
|
|
btn->setStyleSheet(
|
|
"QPushButton { border: none; border-radius: 4px; background: transparent; }"
|
|
"QPushButton:hover { background: palette(light); }"
|
|
);
|
|
return btn;
|
|
}
|
|
|
|
static QWidget *wrapBottomBtn(QPushButton *btn, int pad) {
|
|
auto *w = new QWidget(btn->parentWidget());
|
|
auto *l = new QVBoxLayout(w);
|
|
l->setContentsMargins(0, 0, 0, 0);
|
|
l->setSpacing(0);
|
|
l->addWidget(btn);
|
|
l->addSpacing(pad);
|
|
return w;
|
|
}
|
|
|
|
QString InputBar::formatFileSize(qint64 bytes) {
|
|
if (bytes < 1024) return QString("%1B").arg(bytes);
|
|
double kb = bytes / 1024.0;
|
|
if (kb < 1024.0) return QString("%1KB").arg(kb, 0, 'f', kb < 10.0 ? 1 : 0);
|
|
double mb = kb / 1024.0;
|
|
if (mb < 1024.0) return QString("%1MB").arg(mb, 0, 'f', mb < 10.0 ? 1 : 0);
|
|
double gb = mb / 1024.0;
|
|
return QString("%1GB").arg(gb, 0, 'f', 1);
|
|
}
|
|
|
|
InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent)
|
|
: QWidget(parent)
|
|
, m_input(new QTextEdit(this))
|
|
, m_emojiBtn(nullptr)
|
|
, m_pttBtn(nullptr)
|
|
, m_emojiPanel(emojiPanel)
|
|
, m_recorder(recorder)
|
|
{
|
|
auto *mainLayout = new QVBoxLayout(this);
|
|
mainLayout->setContentsMargins(8, 4, 8, 0);
|
|
mainLayout->setSpacing(2);
|
|
|
|
/* Attachment preview bar (hidden until file selected) */
|
|
m_attachPreview = new QWidget(this);
|
|
auto *prevLay = new QHBoxLayout(m_attachPreview);
|
|
prevLay->setContentsMargins(0, 2, 0, 2);
|
|
prevLay->setSpacing(4);
|
|
m_attachPreviewLabel = new QLabel(m_attachPreview);
|
|
m_attachPreviewLabel->setStyleSheet("background: palette(light); border-radius: 6px; padding: 2px 8px;");
|
|
prevLay->addWidget(m_attachPreviewLabel, 1);
|
|
m_attachClearBtn = new QPushButton(QString::fromUtf8("\xE2\x9C\x95"), m_attachPreview);
|
|
m_attachClearBtn->setFixedSize(20, 20);
|
|
m_attachClearBtn->setCursor(Qt::PointingHandCursor);
|
|
m_attachClearBtn->setStyleSheet("QPushButton { border: none; background: transparent; color: palette(text); }"
|
|
"QPushButton:hover { color: #e74c3c; }");
|
|
connect(m_attachClearBtn, &QPushButton::clicked, this, &InputBar::clearPendingAttachment);
|
|
prevLay->addWidget(m_attachClearBtn);
|
|
m_attachPreview->hide();
|
|
mainLayout->addWidget(m_attachPreview);
|
|
|
|
auto *layout = new QHBoxLayout();
|
|
layout->setSpacing(4);
|
|
|
|
m_attachBtn = makeIconBtn(QIcon(":/icons/attach.png"),
|
|
QString::fromUtf8("Прикрепить"), this);
|
|
connect(m_attachBtn, &QPushButton::clicked, this, &InputBar::onAttachClicked);
|
|
layout->addWidget(wrapBottomBtn(m_attachBtn, 3), 0, Qt::AlignBottom);
|
|
|
|
m_input->setAcceptRichText(false);
|
|
m_input->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
m_input->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
m_input->setTabChangesFocus(true);
|
|
m_input->setStyleSheet(
|
|
"QTextEdit { border: 1px solid palette(mid); border-radius: 6px;"
|
|
" padding: 4px 8px; background: palette(base); }"
|
|
);
|
|
m_input->installEventFilter(this);
|
|
QFontMetrics fm(m_input->font());
|
|
m_input->setFixedHeight(fm.lineSpacing() + 12);
|
|
layout->addWidget(m_input, 1);
|
|
|
|
m_emojiBtn = makeIconBtn(QIcon(":/icons/emoji.png"),
|
|
QString::fromUtf8("Смайлики"), this);
|
|
layout->addWidget(wrapBottomBtn(m_emojiBtn, 3), 0, Qt::AlignBottom);
|
|
|
|
m_pttBtn = makeIconBtn(QIcon(":/icons/mic.png"),
|
|
QString::fromUtf8("Голосовое сообщение (удерживайте)"), this);
|
|
layout->addWidget(wrapBottomBtn(m_pttBtn, 3), 0, Qt::AlignBottom);
|
|
|
|
auto *sendBtn = makeIconBtn(QIcon(":/icons/send.png"),
|
|
QString::fromUtf8("Отправить"), this);
|
|
sendBtn->setStyleSheet(sendBtn->styleSheet() +
|
|
"QPushButton { border: 1px solid palette(highlight); }");
|
|
layout->addWidget(sendBtn, 0, Qt::AlignBottom);
|
|
|
|
connect(m_input, &QTextEdit::textChanged, this, &InputBar::adjustInputHeight);
|
|
connect(sendBtn, &QPushButton::clicked, this, &InputBar::onSendClicked);
|
|
|
|
/* PTT button: press to record, release to stop */
|
|
connect(m_pttBtn, &QPushButton::pressed, this, &InputBar::onPttPressed);
|
|
connect(m_pttBtn, &QPushButton::released, this, &InputBar::onPttReleased);
|
|
|
|
if (m_emojiPanel) {
|
|
connect(m_emojiBtn, &QPushButton::clicked, this, [this]() {
|
|
m_emojiPanel->setVisible(!m_emojiPanel->isVisible());
|
|
});
|
|
connect(m_emojiPanel, &EmojiPanel::emojiSelected, this, [this](const QString &emoji) {
|
|
m_input->insertPlainText(emoji);
|
|
m_input->setFocus();
|
|
});
|
|
}
|
|
|
|
/* Recording overlay — appears over the text input during recording */
|
|
m_recordOverlay = new QWidget(this);
|
|
m_recordOverlay->setStyleSheet("background: rgba(231, 76, 60, 0.88); border-radius: 6px;");
|
|
m_recordOverlay->hide();
|
|
auto* roLayout = new QHBoxLayout(m_recordOverlay);
|
|
roLayout->setContentsMargins(12, 0, 8, 0);
|
|
roLayout->setSpacing(6);
|
|
m_recordLabel = new QLabel(m_recordOverlay);
|
|
m_recordLabel->setStyleSheet("color: white; font-size: 14px; font-weight: bold; background: transparent;");
|
|
roLayout->addWidget(m_recordLabel);
|
|
|
|
m_levelBar = new QProgressBar(m_recordOverlay);
|
|
m_levelBar->setRange(0, 100);
|
|
m_levelBar->setValue(0);
|
|
m_levelBar->setTextVisible(false);
|
|
m_levelBar->setFixedSize(80, 14);
|
|
m_levelBar->setStyleSheet(
|
|
"QProgressBar { border: 1px solid rgba(255,255,255,0.2); border-radius: 3px; background: rgba(0,0,0,0.3); }"
|
|
"QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0,"
|
|
" stop:0 #27ae60, stop:0.6 #f1c40f, stop:1 #e74c3c); border-radius: 2px; }");
|
|
roLayout->addWidget(m_levelBar);
|
|
|
|
roLayout->addStretch();
|
|
m_cancelRecBtn = new QPushButton(QString::fromUtf8("\xE2\x9C\x95"), m_recordOverlay);
|
|
m_cancelRecBtn->setFixedSize(24, 24);
|
|
m_cancelRecBtn->setStyleSheet(
|
|
"QPushButton { border: none; color: white; font-size: 16px; background: transparent; }"
|
|
"QPushButton:hover { color: #fdd; }");
|
|
connect(m_cancelRecBtn, &QPushButton::clicked, this, [this]() {
|
|
if (m_recorder && m_recorder->isRecording()) m_recorder->stopRecording();
|
|
});
|
|
roLayout->addWidget(m_cancelRecBtn);
|
|
|
|
if (recorder) {
|
|
connect(recorder, &AudioRecorder::recordingStarted, this, &InputBar::showRecordOverlay);
|
|
connect(recorder, &AudioRecorder::recordingStopped, this, [this](int) { hideRecordOverlay(); });
|
|
connect(recorder, &AudioRecorder::durationChanged, this, &InputBar::updateRecordingUI);
|
|
}
|
|
|
|
mainLayout->addLayout(layout);
|
|
}
|
|
|
|
bool InputBar::eventFilter(QObject *obj, QEvent *event) {
|
|
if (obj == m_input && event->type() == QEvent::KeyPress) {
|
|
auto *ke = static_cast<QKeyEvent *>(event);
|
|
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
|
|
if (ke->modifiers() & Qt::ControlModifier) {
|
|
m_input->insertPlainText("\n");
|
|
return true;
|
|
} else {
|
|
onSendClicked();
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return QWidget::eventFilter(obj, event);
|
|
}
|
|
|
|
void InputBar::adjustInputHeight() {
|
|
m_input->document()->adjustSize();
|
|
|
|
int lineSpacing = m_input->fontMetrics().lineSpacing();
|
|
int docH = (int)m_input->document()->size().height() + 12;
|
|
int maxH = 12 * lineSpacing + 12;
|
|
int h = qBound(lineSpacing + 12, docH, maxH);
|
|
m_input->setFixedHeight(h);
|
|
|
|
bool needScroll = docH > maxH;
|
|
m_input->setVerticalScrollBarPolicy(needScroll ? Qt::ScrollBarAlwaysOn
|
|
: Qt::ScrollBarAlwaysOff);
|
|
if (needScroll) {
|
|
QTextCursor c = m_input->textCursor();
|
|
if (c.atEnd())
|
|
m_input->verticalScrollBar()->setValue(m_input->verticalScrollBar()->maximum());
|
|
else
|
|
m_input->ensureCursorVisible();
|
|
}
|
|
}
|
|
|
|
void InputBar::onSendClicked() {
|
|
QString text = m_input->toPlainText();
|
|
while (!text.isEmpty() && (text.back() == ' ' || text.back() == '\t'))
|
|
text.chop(1);
|
|
|
|
bool hasAttach = !m_pendingAttachSrc.isEmpty();
|
|
if (!hasAttach && text.isEmpty()) return;
|
|
|
|
if (hasAttach) {
|
|
GUI_INFO("send: file=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName));
|
|
emit sendFileAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName);
|
|
m_pendingAttachSrc.clear();
|
|
m_pendingAttachDest.clear();
|
|
m_pendingAttachName.clear();
|
|
m_pendingAttachSize = 0;
|
|
m_attachPreview->hide();
|
|
}
|
|
if (!text.isEmpty()) {
|
|
emit sendMessage(text);
|
|
m_input->clear();
|
|
if (m_emojiPanel) m_emojiPanel->hide();
|
|
m_input->setFocus();
|
|
}
|
|
}
|
|
|
|
void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& channelId) {
|
|
m_mediaDirBase = mediaDirBase;
|
|
m_channelIdForRecord = channelId;
|
|
}
|
|
|
|
void InputBar::onPttPressed() {
|
|
if (!m_recorder || !m_recorder->isInitialized()) return;
|
|
m_recorder->startRecording();
|
|
m_pttActive = true;
|
|
m_pttBtn->setStyleSheet(m_pttBtn->styleSheet() +
|
|
"QPushButton { background: #e74c3c; border-radius: 4px; }");
|
|
}
|
|
|
|
void InputBar::onPttReleased() {
|
|
if (!m_pttActive) return;
|
|
m_pttActive = false;
|
|
m_recorder->stopRecording();
|
|
m_pttBtn->setStyleSheet(
|
|
"QPushButton { border: none; border-radius: 4px; background: transparent; }"
|
|
"QPushButton:hover { background: palette(light); }");
|
|
|
|
int durationMs = m_recorder->durationMs();
|
|
if (durationMs < 500) {
|
|
GUI_INFO("InputBar: voice message too short (%dms), discarded", durationMs);
|
|
return;
|
|
}
|
|
|
|
const int16_t* pcm = m_recorder->bufferData();
|
|
size_t pcmSize = m_recorder->bufferSize();
|
|
if (pcmSize == 0) return;
|
|
|
|
QString mediaPath = m_mediaDirBase + "/" + m_channelIdForRecord;
|
|
VoiceEncoder::ensureMediaDir(mediaPath);
|
|
GUI_INFO("InputBar::onPttReleased: mediaDir=%s exists=%d channel=%s",
|
|
qPrintable(mediaPath), QDir(mediaPath).exists(), qPrintable(m_channelIdForRecord));
|
|
|
|
QString fileName = VoiceEncoder::generateFileName();
|
|
QString mediaFile = mediaPath + "/" + fileName;
|
|
float durationSec = 0;
|
|
int frames = VoiceEncoder::encodeToFile(pcm, pcmSize, 48000, 1, mediaFile, durationSec);
|
|
if (frames <= 0) {
|
|
QFile::remove(mediaFile);
|
|
return;
|
|
}
|
|
|
|
m_voiceFilePath = mediaFile;
|
|
|
|
const auto& wf = m_recorder->waveformLevels();
|
|
QString wfStr = "wf=";
|
|
static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
int N = (int)wf.size();
|
|
for (int i = 0; i < 100; i++) {
|
|
int s = i * N / 100, e = (i + 1) * N / 100;
|
|
if (s >= N) s = N - 1;
|
|
if (e <= s) e = s + 1;
|
|
if (e > N) e = N;
|
|
float sum = 0; int cnt = 0;
|
|
for (int j = s; j < e; j++) { sum += wf[j]; cnt++; }
|
|
float v = cnt > 0 ? sum / cnt : 0;
|
|
float db = v > 0.00001f ? 20.0f * log10f(v) : -100.0f;
|
|
int idx = (int)((db + 60.0f) / 60.0f * 64.0f);
|
|
if (idx < 0) idx = 0; if (idx > 63) idx = 63;
|
|
wfStr += b64[idx];
|
|
}
|
|
wfStr += QString(";dur=%1;").arg(durationMs);
|
|
GUI_INFO("InputBar: voice message ready: file=%s dur=%.1fs",
|
|
qPrintable(mediaFile), durationSec);
|
|
emit sendVoiceMessage(mediaFile, mediaFile, durationMs, wfStr);
|
|
}
|
|
|
|
void InputBar::onAttachClicked() {
|
|
if (m_channelIdForRecord.isEmpty()) return;
|
|
QString srcPath = QFileDialog::getOpenFileName(this, QString::fromUtf8("Прикрепить файл"));
|
|
if (srcPath.isEmpty()) return;
|
|
|
|
QFileInfo fi(srcPath);
|
|
if (fi.size() == 0) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "InputBar::onAttachClicked: empty file"); return; }
|
|
|
|
QString mediaPath = m_mediaDirBase + "/" + m_channelIdForRecord;
|
|
VoiceEncoder::ensureMediaDir(mediaPath);
|
|
QDir().mkpath(mediaPath);
|
|
|
|
QDateTime now = QDateTime::currentDateTime();
|
|
QString ts = now.toString("yyyyMMdd-HHmmss");
|
|
int rnd = rand() & 0xFFFF;
|
|
QString destPath = QString("%1/%2_%3_%4.%5")
|
|
.arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0')).arg(fi.suffix().isEmpty() ? "bin" : fi.suffix());
|
|
|
|
m_pendingAttachSrc = srcPath;
|
|
m_pendingAttachDest = destPath;
|
|
m_pendingAttachName = fi.fileName();
|
|
m_pendingAttachSize = fi.size();
|
|
|
|
QString labelText = QString::fromUtf8("\xF0\x9F\x93\x8E %1 (%2)")
|
|
.arg(fi.fileName(), formatFileSize(fi.size()));
|
|
m_attachPreviewLabel->setText(labelText);
|
|
m_attachPreview->show();
|
|
|
|
GUI_INFO("InputBar::onAttachClicked: pending %s size=%lld",
|
|
qPrintable(srcPath), (long long)fi.size());
|
|
}
|
|
|
|
void InputBar::clearPendingAttachment() {
|
|
m_pendingAttachSrc.clear();
|
|
m_pendingAttachDest.clear();
|
|
m_pendingAttachName.clear();
|
|
m_pendingAttachSize = 0;
|
|
m_attachPreview->hide();
|
|
}
|
|
|
|
void InputBar::resizeEvent(QResizeEvent* event) {
|
|
QWidget::resizeEvent(event);
|
|
if (m_recordOverlay) {
|
|
QRect r = m_input->geometry();
|
|
int margin = 4;
|
|
m_recordOverlay->setGeometry(r.adjusted(margin, margin, -margin, -margin));
|
|
m_recordOverlay->raise();
|
|
}
|
|
}
|
|
|
|
void InputBar::showRecordOverlay() {
|
|
if (m_recordOverlay) {
|
|
m_recordOverlay->setGeometry(m_input->geometry().adjusted(4, 4, -4, -4));
|
|
m_recordOverlay->show();
|
|
m_recordOverlay->raise();
|
|
m_recordLabel->setText(QString::fromUtf8("\xF0\x9F\x94\xB4 Recording 0:00"));
|
|
if (m_levelBar) m_levelBar->setValue(0);
|
|
}
|
|
}
|
|
|
|
void InputBar::hideRecordOverlay() {
|
|
if (m_recordOverlay) m_recordOverlay->hide();
|
|
}
|
|
|
|
void InputBar::updateRecordingUI(int ms) {
|
|
if (m_recordLabel) {
|
|
int secs = ms / 1000;
|
|
m_recordLabel->setText(QString::fromUtf8("\xF0\x9F\x94\xB4 Recording %1:%2")
|
|
.arg(secs / 60).arg(secs % 60, 2, 10, QChar('0')));
|
|
}
|
|
if (m_levelBar && m_recorder) {
|
|
float peak = m_recorder->peakLevel();
|
|
float db = peak > 0.0001f ? 20.0f * log10f(peak) : -70.0f;
|
|
int pct = (int)((db + 70.0f) / 70.0f * 100.0f);
|
|
if (pct < 0) pct = 0; if (pct > 100) pct = 100;
|
|
m_levelBar->setValue(pct);
|
|
const char* color = pct < 60 ? "#27ae60" : pct < 85 ? "#f1c40f" : "#e74c3c";
|
|
m_levelBar->setStyleSheet(QString(
|
|
"QProgressBar { border: 1px solid rgba(255,255,255,0.2); border-radius: 3px; background: rgba(0,0,0,0.3); }"
|
|
"QProgressBar::chunk { background: %1; border-radius: 2px; }").arg(color));
|
|
}
|
|
}
|