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.
334 lines
13 KiB
334 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 tempPath = mediaPath + "/" + fileName; |
|
float durationSec = 0; |
|
int frames = VoiceEncoder::encodeToFile(pcm, pcmSize, 48000, 1, tempPath, durationSec); |
|
if (frames <= 0) { |
|
QFile::remove(tempPath); |
|
return; |
|
} |
|
|
|
MediaBlockInfo blk = media_split_to_blocks(tempPath, mediaPath, true); |
|
if (blk.numBlocks <= 0) { |
|
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "InputBar: block split failed"); |
|
return; |
|
} |
|
|
|
m_voiceFilePath = QString("%1/%2_%3_%4.%5").arg(mediaPath, blk.dt, blk.basename, blk.suffix, blk.ext); |
|
|
|
const auto& wf = m_recorder->waveformLevels(); |
|
QString wfStr; |
|
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; |
|
if (i > 0) wfStr += ','; |
|
wfStr += QString::number(v, 'f', 3); |
|
} |
|
GUI_INFO("InputBar: voice message ready: dt=%s stem=%s blocks=%d dur=%.1fs", |
|
qPrintable(blk.dt), qPrintable(blk.basename), blk.numBlocks, durationSec); |
|
emit sendVoiceMessage(blk.dt, blk.basename, blk.suffix, blk.ext, |
|
blk.numBlocks, blk.blockSize, 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); |
|
|
|
MediaBlockInfo blk = media_split_to_blocks(srcPath, mediaPath, false); |
|
if (blk.numBlocks <= 0) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "InputBar::onAttachClicked: split failed"); return; } |
|
|
|
GUI_INFO("InputBar::onAttachClicked: file=%s dt=%s blocks=%d", |
|
qPrintable(fi.fileName()), qPrintable(blk.dt), blk.numBlocks); |
|
emit sendFileAttachment(blk.dt, blk.basename, blk.suffix, blk.ext, |
|
blk.numBlocks, blk.blockSize, 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)); |
|
} |
|
}
|
|
|