- AudioRecorder: peak replaced with RMS (sqrt mean of squares) - AudioDeviceSettings: level meter timer 100ms -> 25ms - SoundManager: playRawPcm uses ma_audio_buffer_ref instead of ma_decoder (fixes speaker test) - InputBar: recording overlay positioned over text input, no layout shift - messagelist: removed old recording indicator widget
259 lines
9.6 KiB
C++
259 lines
9.6 KiB
C++
#include "inputbar.h"
|
|
#include "emojipanel.h"
|
|
#include "audiorecorder.h"
|
|
#include "voicemessageencoder.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>
|
|
|
|
static QPushButton *makeIconBtn(const QString &text, const QString &tooltip, QWidget *parent) {
|
|
auto *btn = new QPushButton(text, parent);
|
|
btn->setFixedSize(34, 34);
|
|
btn->setCursor(Qt::PointingHandCursor);
|
|
btn->setToolTip(tooltip);
|
|
btn->setStyleSheet(
|
|
"QPushButton { border: none; border-radius: 4px; font-size: 18px; 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);
|
|
|
|
auto *attachBtn = makeIconBtn(QString::fromUtf8("\xF0\x9F\x93\x8E"),
|
|
QString::fromUtf8("Прикрепить"), this);
|
|
connect(attachBtn, &QPushButton::clicked, this, [this]() {
|
|
QString path = QFileDialog::getOpenFileName(this, QString::fromUtf8("Прикрепить файл"));
|
|
if (!path.isEmpty()) {
|
|
qDebug() << "Attached:" << path;
|
|
}
|
|
});
|
|
layout->addWidget(wrapBottomBtn(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(QString::fromUtf8("\xF0\x9F\x99\x82"),
|
|
QString::fromUtf8("Смайлики"), this);
|
|
layout->addWidget(wrapBottomBtn(m_emojiBtn, 3), 0, Qt::AlignBottom);
|
|
|
|
m_pttBtn = makeIconBtn(QString::fromUtf8("\xF0\x9F\x8E\xA4"),
|
|
QString::fromUtf8("Голосовое сообщение (удерживайте)"), this);
|
|
layout->addWidget(wrapBottomBtn(m_pttBtn, 3), 0, Qt::AlignBottom);
|
|
|
|
auto *sendBtn = makeIconBtn(QString::fromUtf8("\xE2\x9E\xA4"),
|
|
QString::fromUtf8("Отправить"), this);
|
|
sendBtn->setStyleSheet(sendBtn->styleSheet() +
|
|
"QPushButton { color: palette(highlight); font-weight: bold; }");
|
|
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);
|
|
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; color: white; 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; font-size: 18px; background: transparent; }"
|
|
"QPushButton:hover { background: palette(light); }");
|
|
|
|
int durationMs = m_recorder->durationMs();
|
|
if (durationMs < 500) {
|
|
qDebug("InputBar: voice message too short (%dms), discarded", durationMs);
|
|
return;
|
|
}
|
|
|
|
const std::vector<int16_t>& pcm = m_recorder->buffer();
|
|
if (pcm.empty()) return;
|
|
|
|
QString mediaPath = m_mediaDirBase + "/" + m_channelIdForRecord;
|
|
VoiceEncoder::ensureMediaDir(mediaPath);
|
|
|
|
QString fileName = VoiceEncoder::generateFileName();
|
|
QString filePath = mediaPath + "/" + fileName;
|
|
float durationSec = 0;
|
|
int frames = VoiceEncoder::encodeToFile(pcm, 48000, 1, filePath, durationSec);
|
|
if (frames > 0) {
|
|
m_voiceFilePath = filePath;
|
|
qDebug("InputBar: voice message ready: %s (%.1fs)", qPrintable(filePath), durationSec);
|
|
emit sendVoiceMessage(filePath, durationMs);
|
|
}
|
|
}
|
|
|
|
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"));
|
|
}
|
|
}
|
|
|
|
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')));
|
|
}
|
|
}
|