#include "inputbar.h" #include "emojipanel.h" #include "audiorecorder.h" #include "voicemessageencoder.h" #include "media_blocks.h" #include "debug_ui.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include extern "C" { #include "video/video.h" } static bool isImageFile(const QString& suffix) { QString s = suffix.toLower(); return s == "jpg" || s == "jpeg" || s == "png" || s == "gif" || s == "bmp" || s == "webp"; } 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); m_videoProgressTimer = new QTimer(this); m_videoProgressTimer->setInterval(150); connect(m_videoProgressTimer, &QTimer::timeout, this, &InputBar::onVideoProgressTick); 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(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) { if (m_pendingAttachAsImage) { GUI_INFO("send: image=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); emit sendImageAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); m_attachPreview->hide(); } else if (m_pendingAttachAsVideo) { GUI_INFO("send: video=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); emit sendVideoAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); m_attachPreviewLabel->setText(QString::fromUtf8("\xF0\x9F\x8E\xAC Перекодирование… 0%")); m_attachPreview->show(); m_videoProgressTimer->start(); } else { GUI_INFO("send: file=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); emit sendFileAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); m_attachPreview->hide(); } m_pendingAttachSrc.clear(); m_pendingAttachDest.clear(); m_pendingAttachName.clear(); m_pendingAttachSize = 0; m_pendingAttachAsVideo = false; m_pendingAttachAsImage = false; } 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::setMediaEnabled(bool enabled) { if (m_pttBtn) m_pttBtn->setEnabled(enabled); if (m_attachBtn) m_attachBtn->setEnabled(enabled); } 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; bool asImage = isImageFile(fi.suffix()); bool asVideo = false; if (!asImage && video_probe(srcPath.toUtf8().constData())) { QMessageBox box(this); box.setWindowTitle(QString::fromUtf8("Отправить видео")); box.setText(QString::fromUtf8("Файл содержит видео. Как отправить?")); QPushButton *fileBtn = box.addButton(QString::fromUtf8("Как файл"), QMessageBox::AcceptRole); QPushButton *videoBtn = box.addButton(QString::fromUtf8("Как видео"), QMessageBox::DestructiveRole); box.exec(); asVideo = (box.clickedButton() == videoBtn); if (!box.clickedButton() || (!asVideo && box.clickedButton() != fileBtn)) { return; /* закрыт без выбора */ } GUI_INFO("InputBar::onAttachClicked: video detected, mode=%s", asVideo ? "video" : "file"); } QString destPath; if (asVideo) { destPath = QString("%1/%2_%3_%4.mp4") .arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0')); } else { 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(); m_pendingAttachAsVideo = asVideo; m_pendingAttachAsImage = asImage; 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 video=%d image=%d", qPrintable(srcPath), (long long)fi.size(), asVideo ? 1 : 0, asImage ? 1 : 0); } void InputBar::clearPendingAttachment() { m_pendingAttachSrc.clear(); m_pendingAttachDest.clear(); m_pendingAttachName.clear(); m_pendingAttachSize = 0; m_pendingAttachAsVideo = false; m_pendingAttachAsImage = false; m_videoProgressTimer->stop(); m_attachPreview->hide(); } void InputBar::onVideoProgressTick() { int p = video_transcode_get_progress(); if (p < 0) { m_videoProgressTimer->stop(); m_attachPreview->hide(); return; } m_attachPreviewLabel->setText(QString::fromUtf8("\xF0\x9F\x8E\xAC Перекодирование… %1%").arg(p / 10)); } 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)); } }