You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

333 lines
13 KiB

#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;
}
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 *layout = new QHBoxLayout(this);
layout->setContentsMargins(8, 4, 8, 4);
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);
}
}
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();
GUI_INFO("send: len=%d hasLF=%d text='%s'",
text.length(), text.contains('\n'), qPrintable(text));
while (!text.isEmpty() && (text.back() == ' ' || text.back() == '\t'))
text.chop(1);
if (text.isEmpty()) return;
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());
GUI_INFO("InputBar::onAttachClicked: src=%s dest=%s",
qPrintable(srcPath), qPrintable(destPath));
emit sendFileAttachment(srcPath, destPath, fi.fileName());
}
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));
}
}