Files
utun2/tools/chatgui/src/callwindow.cpp
T

809 lines
43 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <QCoreApplication>
#include "callwindow.h"
#include "capture_controls.h"
#include "../transport/gui_bridge.h"
#include "call_audio_engine.h"
#include "call_video_engine.h"
#include "../../../src/call/call_video_port.h"
#include "../../../src/call/call_video_playout.h"
#include "sound_manager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QSpinBox>
#include <QFormLayout>
#include <QWidgetAction>
#include <QTimer>
#include <QDateTime>
#include <QCloseEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QScreen>
#include <QPainter>
#include <QPainterPath>
#include <QRegularExpression>
#include <cstring>
extern "C" {
#include "../../lib/debug_config.h"
}
/* ── рисование иконок звонка (QPainter, без внешних ассетов) ── */
enum class Glyph { Phone, PhoneDown, Mic, MicOff, Camera, CameraOff, Settings, Diagnostics };
static QString callText(const char* text) { return QCoreApplication::translate("ChatGui", text); }
using namespace CaptureControls;
/* Рисует исходный shared-кадр в свободной области; layout задаёт масштаб, кадр не ограничивает минимум окна. */
class CallLocalPreview : public QWidget {
public:
explicit CallLocalPreview(QWidget* parent) : QWidget(parent) {
setObjectName("callLocalPreview"); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
}
void setImage(const QImage& image) { m_image = image; update(); }
protected:
void paintEvent(QPaintEvent*) override {
QPainter painter(this); painter.fillRect(rect(), palette().window());
if (m_image.isNull()) return;
painter.setRenderHint(QPainter::SmoothPixmapTransform);
QSize fitted = m_image.size().scaled(size(), Qt::KeepAspectRatio);
painter.drawImage(QRect(QPoint((width() - fitted.width()) / 2, (height() - fitted.height()) / 2), fitted), m_image);
}
void resizeEvent(QResizeEvent* event) override {
QWidget::resizeEvent(event);
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call local preview resized area=%dx%d image=%dx%d", width(), height(), m_image.width(), m_image.height());
}
private:
QImage m_image; // Shared-память исходного CPU-кадра; paint не создаёт уменьшенные промежуточные pixmap.
};
// Отдельное окно показа. Закрытие скрывает только изображение, не завершает звонок.
class CallVideoWindow : public QWidget {
public:
explicit CallVideoWindow(QWidget* parent) : QWidget(parent, Qt::Window) {
setObjectName("callVideoWindow"); setMinimumSize(320, 240); setAttribute(Qt::WA_QuitOnClose, false);
QSize available = parent->screen() ? parent->screen()->availableGeometry().size() : QSize(1280, 900);
resize(qMin(1100, available.width() * 9 / 10), qMin(800, available.height() * 9 / 10));
}
QImage remote, local;
CallVideoEngine* engine = nullptr;
quint64 serial = 0;
quint64 callId = 0;
int64_t requestedAt = 0; // GUI-получение кадра -> начало paintEvent, отдельно от самой работы painter.
protected:
void moveEvent(QMoveEvent* event) override { ++m_moves; QWidget::moveEvent(event); }
void resizeEvent(QResizeEvent* event) override { ++m_resizes; QWidget::resizeEvent(event); }
void hideEvent(QHideEvent* event) override { presented(); QWidget::hideEvent(event); }
void changeEvent(QEvent* event) override {
QWidget::changeEvent(event);
if (isMinimized()) presented();
}
void closeEvent(QCloseEvent* event) override {
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call video window closed; call continues"); event->accept();
}
void keyPressEvent(QKeyEvent* event) override {
if (event->key() == Qt::Key_F11 || (event->key() == Qt::Key_Escape && isFullScreen())) {
toggleFullScreen(); event->accept(); return;
}
QWidget::keyPressEvent(event);
}
void mouseDoubleClickEvent(QMouseEvent* event) override {
if (event->button() == Qt::LeftButton) { toggleFullScreen(); event->accept(); return; }
QWidget::mouseDoubleClickEvent(event);
}
void paintEvent(QPaintEvent*) override {
const int64_t started = call_video_playout_now_us(), requestWait = serial && requestedAt ? started - requestedAt : 0;
const bool selected = serial != 0;
QPainter painter(this); painter.fillRect(rect(), Qt::black); painter.setRenderHint(QPainter::SmoothPixmapTransform);
if (remote.isNull()) return;
QSize fitted = remote.size().scaled(size(), Qt::KeepAspectRatio);
QRect videoRect(QPoint((width() - fitted.width()) / 2, (height() - fitted.height()) / 2), fitted);
const int64_t prepared = call_video_playout_now_us();
painter.drawImage(videoRect, remote);
const int64_t drawn = call_video_playout_now_us();
if (!local.isNull()) {
QSize preview = local.size().scaled(qMin(200, fitted.width() / 4), qMin(150, fitted.height() / 4), Qt::KeepAspectRatio);
QRect target(QPoint(videoRect.right() - preview.width() - 12, videoRect.top() + 12), preview);
painter.setPen(QPen(Qt::white, 2)); painter.drawRect(target.adjusted(-1, -1, 1, 1)); painter.drawImage(target, local);
}
const int64_t overlay = call_video_playout_now_us();
painter.end();
const int64_t finished = call_video_playout_now_us();
presented();
++m_paints; if (selected) ++m_selectedPaints;
m_requestMax = std::max(m_requestMax, requestWait); m_paintMax = std::max(m_paintMax, finished - started);
m_prepareMax = std::max(m_prepareMax, prepared - started); m_drawMax = std::max(m_drawMax, drawn - prepared);
m_overlayMax = std::max(m_overlayMax, overlay - drawn); m_finishMax = std::max(m_finishMax, finished - overlay);
if (!m_slowReported && (requestWait > 50000 || finished - started > 20000)) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "video GUI paint delayed call=%llu request_wait_us=%lld paint_us=%lld "
"prepare_us=%lld draw_us=%lld overlay_us=%lld finish_us=%lld window=%dx%d image=%dx%d dpr_x1000=%d mono_us=%lld",
(unsigned long long)callId, (long long)requestWait, (long long)(finished - started),
(long long)(prepared - started), (long long)(drawn - prepared), (long long)(overlay - drawn),
(long long)(finished - overlay), width(), height(), remote.width(), remote.height(),
int(devicePixelRatioF() * 1000), (long long)finished);
m_slowReported = true;
}
if (!m_reportAt) m_reportAt = started;
if (finished - m_reportAt >= 1000000) {
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "video GUI paint call=%llu interval_us=%lld paints=%u selected=%u moves=%u resizes=%u "
"request_wait_max_us=%lld paint_max_us=%lld prepare_max_us=%lld draw_max_us=%lld overlay_max_us=%lld finish_max_us=%lld",
(unsigned long long)callId, (long long)(finished - m_reportAt), m_paints, m_selectedPaints, m_moves, m_resizes,
(long long)m_requestMax, (long long)m_paintMax, (long long)m_prepareMax, (long long)m_drawMax,
(long long)m_overlayMax, (long long)m_finishMax);
m_reportAt = finished; m_paints = m_selectedPaints = m_moves = m_resizes = 0;
m_requestMax = m_paintMax = m_prepareMax = m_drawMax = m_overlayMax = m_finishMax = 0; m_slowReported = false;
}
}
private:
void presented() {
if (engine && serial) { engine->imagePresented(serial); serial = 0; }
}
void toggleFullScreen() {
if (isFullScreen()) { showNormal(); if (!m_geometry.isEmpty()) restoreGeometry(m_geometry); }
else { m_geometry = saveGeometry(); showFullScreen(); }
}
QByteArray m_geometry;
int64_t m_reportAt = 0, m_requestMax = 0, m_paintMax = 0, m_prepareMax = 0, m_drawMax = 0, m_overlayMax = 0, m_finishMax = 0;
unsigned m_paints = 0, m_selectedPaints = 0, m_moves = 0, m_resizes = 0;
bool m_slowReported = false;
};
static QColor colorForName(const QString& name) {
static const QColor palette[] = {
QColor("#5B9BD5"), QColor("#70AD47"), QColor("#ED7D31"),
QColor("#9B59B6"), QColor("#F4B400"), QColor("#E67E22"),
QColor("#16A085"), QColor("#D35400"),
};
quint32 h = 0;
for (const QChar c : name) h = h * 31u + c.unicode();
return palette[h % 8];
}
static QPixmap makeAvatar(const QString& name, int size) {
QPixmap pm(size, size);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing);
p.setBrush(colorForName(name));
p.setPen(Qt::NoPen);
p.drawEllipse(0, 0, size, size);
QString initial = name.trimmed().isEmpty() ? QStringLiteral("?") : name.trimmed().left(1).toUpper();
p.setPen(Qt::white);
QFont f = p.font();
f.setBold(true);
f.setPixelSize(size / 2);
p.setFont(f);
p.drawText(QRect(0, 0, size, size), Qt::AlignCenter, initial);
p.end();
return pm;
}
static void drawPhoneGlyph(QPainter* p, const QRectF& r, bool down) {
const qreal s = r.width();
p->save();
p->translate(r.center());
p->rotate(down ? 135.0 : -45.0);
const qreal w = s * 0.36, h = s * 0.64;
QPainterPath path;
path.addRoundedRect(QRectF(-w / 2, -h / 2, w, h), w * 0.5, w * 0.5);
p->setPen(Qt::NoPen);
p->setBrush(Qt::white);
p->drawPath(path);
p->restore();
}
static void drawMicGlyph(QPainter* p, const QRectF& r, bool off) {
const qreal s = r.width();
const QPointF c = r.center();
p->save();
QPen pen(Qt::white, s * 0.09, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin);
p->setPen(pen);
p->setBrush(Qt::NoBrush);
const qreal cw = s * 0.30;
p->drawRoundedRect(QRectF(c.x() - cw / 2, r.top() + s * 0.10, cw, s * 0.40), cw / 2, cw / 2);
p->drawArc(QRectF(c.x() - s * 0.20, r.top() + s * 0.42, s * 0.40, s * 0.26), 180 * 16, 180 * 16);
p->drawLine(QPointF(c.x(), r.top() + s * 0.60), QPointF(c.x(), r.top() + s * 0.68));
p->drawLine(QPointF(c.x() - s * 0.17, r.top() + s * 0.68), QPointF(c.x() + s * 0.17, r.top() + s * 0.68));
if (off) {
p->setPen(QPen(Qt::white, s * 0.09, Qt::SolidLine, Qt::RoundCap));
p->drawLine(QPointF(c.x() - s * 0.26, c.y() - s * 0.26), QPointF(c.x() + s * 0.26, c.y() + s * 0.26));
}
p->restore();
}
static void drawCameraGlyph(QPainter* p, const QRectF& r, bool off) {
const qreal s = r.width();
p->save();
p->translate(r.topLeft());
p->setPen(QPen(Qt::white, s * 0.07, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
p->setBrush(Qt::NoBrush);
p->drawRoundedRect(QRectF(s * 0.06, s * 0.24, s * 0.56, s * 0.52), s * 0.07, s * 0.07);
QPainterPath lens;
lens.moveTo(s * 0.62, s * 0.40); lens.lineTo(s * 0.90, s * 0.24);
lens.lineTo(s * 0.90, s * 0.76); lens.lineTo(s * 0.62, s * 0.60); lens.closeSubpath();
p->drawPath(lens);
if (off) p->drawLine(QPointF(s * 0.06, s * 0.06), QPointF(s * 0.94, s * 0.94));
p->restore();
}
static void drawSettingsGlyph(QPainter* p, const QRectF& r) {
const qreal s = r.width();
p->save(); p->translate(r.center());
p->setPen(Qt::NoPen); p->setBrush(Qt::white);
for (int tooth = 0; tooth < 8; ++tooth) {
p->save(); p->rotate(tooth * 45.0);
p->drawRoundedRect(QRectF(-s * 0.10, -s * 0.47, s * 0.20, s * 0.22), s * 0.03, s * 0.03);
p->restore();
}
QPainterPath ring;
ring.setFillRule(Qt::OddEvenFill);
ring.addEllipse(QRectF(-s * 0.34, -s * 0.34, s * 0.68, s * 0.68));
ring.addEllipse(QRectF(-s * 0.14, -s * 0.14, s * 0.28, s * 0.28));
p->drawPath(ring);
p->restore();
}
static QIcon makeRoundIcon(const QColor& bg, Glyph glyph, int size) {
QPixmap pm(size, size);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing);
p.setPen(Qt::NoPen);
p.setBrush(bg);
p.drawEllipse(0, 0, size, size);
const QRectF glyphRect(size * 0.24, size * 0.24, size * 0.52, size * 0.52);
switch (glyph) {
case Glyph::Phone: drawPhoneGlyph(&p, glyphRect, false); break;
case Glyph::PhoneDown: drawPhoneGlyph(&p, glyphRect, true); break;
case Glyph::Mic: drawMicGlyph(&p, glyphRect, false); break;
case Glyph::MicOff: drawMicGlyph(&p, glyphRect, true); break;
case Glyph::Camera: drawCameraGlyph(&p, glyphRect, false); break;
case Glyph::CameraOff: drawCameraGlyph(&p, glyphRect, true); break;
case Glyph::Settings: drawSettingsGlyph(&p, glyphRect); break;
case Glyph::Diagnostics: {
p.setPen(QPen(Qt::white, glyphRect.width() * 0.07, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
p.drawLine(glyphRect.bottomLeft(), glyphRect.topLeft()); p.drawLine(glyphRect.bottomLeft(), glyphRect.bottomRight());
QPainterPath graph; graph.moveTo(glyphRect.left() + glyphRect.width() * 0.1, glyphRect.center().y());
graph.lineTo(glyphRect.left() + glyphRect.width() * 0.35, glyphRect.center().y());
graph.lineTo(glyphRect.center().x(), glyphRect.top() + glyphRect.height() * 0.2);
graph.lineTo(glyphRect.left() + glyphRect.width() * 0.65, glyphRect.top() + glyphRect.height() * 0.8);
graph.lineTo(glyphRect.right() - glyphRect.width() * 0.1, glyphRect.top() + glyphRect.height() * 0.3);
p.setBrush(Qt::NoBrush); p.drawPath(graph); break;
}
}
p.end();
return QIcon(pm);
}
static QPushButton* makeRoundBtn(const QIcon& icon, const QString& tooltip, QWidget* parent) {
auto* btn = new QPushButton(parent);
btn->setIcon(icon);
btn->setIconSize(QSize(52, 52));
btn->setFixedSize(64, 64);
btn->setCursor(Qt::PointingHandCursor);
btn->setToolTip(tooltip);
btn->setStyleSheet(
"QPushButton { border: none; border-radius: 32px; background: transparent; }"
"QPushButton:pressed { background: rgba(255,255,255,0.15); }");
return btn;
}
static QWidget* wrapWithCaption(QWidget* btn, const QString& caption, QLabel** captionOut = nullptr) {
auto* w = new QWidget(btn->parentWidget());
auto* l = new QVBoxLayout(w);
l->setContentsMargins(0, 0, 0, 0);
l->setSpacing(6);
l->addWidget(btn, 0, Qt::AlignHCenter);
auto* cap = new QLabel(caption, w);
cap->setAlignment(Qt::AlignCenter);
cap->setStyleSheet("color: palette(text); font-size: 11px; background: transparent;");
l->addWidget(cap, 0, Qt::AlignHCenter);
if (captionOut) *captionOut = cap;
return w;
}
CallWindow::CallWindow(QWidget* parent)
: QWidget(parent, Qt::Window) {
setWindowTitle(QCoreApplication::translate("ChatGui", "Call"));
resize(520, 520);
setAttribute(Qt::WA_DeleteOnClose, false);
m_avatarLabel = new QLabel(this);
m_avatarLabel->setObjectName("callAvatar");
m_avatarLabel->setFixedSize(80, 80);
m_avatarLabel->setAlignment(Qt::AlignCenter);
m_peerLabel = new QLabel(this);
m_peerLabel->setAlignment(Qt::AlignCenter);
m_peerLabel->setStyleSheet("font-size: 17px; font-weight: bold; color: palette(text);");
m_statusLabel = new QLabel(this);
m_statusLabel->setAlignment(Qt::AlignCenter);
m_statusLabel->setStyleSheet("color: gray; font-size: 13px;");
m_statusLabel->setWordWrap(true);
connect(CallAudioEngine::instance(), &CallAudioEngine::captureErrorChanged, this, [this] {
auto* audio = CallAudioEngine::instance();
if (m_state == Active && audio->callId() == m_callId)
m_statusLabel->setText(audio->captureError().isEmpty() ? callText("In call") : audio->captureError());
});
m_durationLabel = new QLabel(QStringLiteral("00:00"), this);
m_durationLabel->setAlignment(Qt::AlignCenter);
m_durationLabel->setStyleSheet("font-size: 15px; color: palette(text); font-family: monospace;");
m_bgpPathLabel = new QLabel(this);
m_bgpPathLabel->setAlignment(Qt::AlignCenter);
m_bgpPathLabel->setWordWrap(true);
m_bgpPathLabel->setStyleSheet("color: gray; font-size: 11px;");
m_bgpPathLabel->hide();
m_connectionLabel = new QLabel(this);
m_connectionLabel->setAlignment(Qt::AlignCenter);
m_connectionLabel->setStyleSheet("color: gray; font-size: 13px;");
setConnectionType(0, 0);
m_statsLabel = new QLabel(this);
m_statsLabel->setObjectName("callAudioStats");
m_statsLabel->setAlignment(Qt::AlignCenter);
m_statsLabel->setWordWrap(true);
m_statsLabel->setStyleSheet("color: gray; font-size: 11px;");
m_statsLabel->setTextFormat(Qt::RichText);
m_statsLabel->hide();
m_acceptBtn = makeRoundBtn(makeRoundIcon(QColor("#27ae60"), Glyph::Phone, 96), QCoreApplication::translate("ChatGui", "Accept"), this);
m_declineBtn = makeRoundBtn(makeRoundIcon(QColor("#e74c3c"), Glyph::PhoneDown, 96), QCoreApplication::translate("ChatGui", "Decline"), this);
m_hangupBtn = makeRoundBtn(makeRoundIcon(QColor("#e74c3c"), Glyph::PhoneDown, 96), QCoreApplication::translate("ChatGui", "End call"), this);
m_muteBtn = makeRoundBtn(makeRoundIcon(QColor("#7f8c8d"), Glyph::Mic, 96), QCoreApplication::translate("ChatGui", "Mute"), this);
m_muteBtn->setCheckable(true);
auto* root = new QVBoxLayout(this);
root->setContentsMargins(12, 12, 12, 12);
root->setSizeConstraint(QLayout::SetMinimumSize);
m_videoWindow = new CallVideoWindow(this);
m_panel = new QWidget(this); m_panel->setObjectName("callPanel"); m_panel->setMaximumWidth(640);
m_panel->setStyleSheet("QWidget#callPanel { background: palette(window); border-radius: 12px; }");
// Содержимое сверху, отдельная строка действий всегда у нижнего края окна.
root->addWidget(m_panel, 0, Qt::AlignHCenter);
QVBoxLayout* lay = new QVBoxLayout(m_panel);
lay->setContentsMargins(16, 20, 16, 16);
lay->setSpacing(8);
lay->addWidget(m_avatarLabel, 0, Qt::AlignHCenter);
lay->addWidget(m_peerLabel);
lay->addWidget(m_statusLabel);
m_continueBtn = new QPushButton(callText("Call anyway"), this);
lay->addWidget(m_continueBtn);
connect(m_continueBtn, &QPushButton::clicked, this, [this] {
if (m_state != WaitDecision) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "desktop caller overrides phone busy id=%016llx", (unsigned long long)m_callId);
if (gui_bridge_call_action(GUI_CALL_CONTINUE, m_callId) < 0) {
m_statusLabel->setText(callText("Could not send the call command")); return;
}
setState(Outgoing);
SoundManager::instance()->playLoop("call_connecting");
});
lay->addWidget(m_durationLabel);
m_diagnostics = new QWidget(m_panel); m_diagnostics->setObjectName("callDiagnostics");
auto* diagnosticLayout = new QVBoxLayout(m_diagnostics); diagnosticLayout->setContentsMargins(0, 0, 0, 0);
diagnosticLayout->addWidget(m_connectionLabel); diagnosticLayout->addWidget(m_bgpPathLabel); diagnosticLayout->addWidget(m_statsLabel);
lay->addWidget(m_diagnostics);
m_video = new CallVideoEngine(this);
m_video->setPresentationDriven(true); m_videoWindow->engine = m_video;
auto* previewArea = new QWidget(this); previewArea->setObjectName("callPreviewArea");
auto* previewLayout = new QVBoxLayout(previewArea); previewLayout->setContentsMargins(0, 0, 0, 0);
m_localPreview = new CallLocalPreview(previewArea); m_localPreview->hide(); previewLayout->addWidget(m_localPreview);
root->addWidget(previewArea, 1);
m_videoStatus = new QLabel(this); m_videoStatus->setObjectName("callVideoStatus"); m_videoStatus->setWordWrap(true); lay->addWidget(m_videoStatus);
m_videoControls = new QWidget(this); auto* videoLayout = new QVBoxLayout(m_videoControls);
videoLayout->setContentsMargins(0, 0, 0, 0);
m_videoMenuBar = new QMenuBar(m_videoControls); m_videoMenuBar->setNativeMenuBar(false); m_videoMenuBar->setMouseTracking(true);
m_videoMenuBar->setObjectName("callVideoControls");
auto* menuStyle = new MenuStyle; menuStyle->setParent(this); m_videoMenuBar->setStyle(menuStyle);
videoLayout->addWidget(m_videoMenuBar, 0, Qt::AlignHCenter); lay->insertWidget(0, m_videoControls);
auto makeMenu = [this](const char* heading, const char* name) {
auto* menu = m_videoMenuBar->addMenu(QString()); menu->setObjectName(QString::fromLatin1(name));
new QActionGroup(menu); menu->addSection(callText(heading)); return menu;
};
m_camera = makeMenu("Camera", "callVideoCamera");
m_codec = makeMenu("Video codec", "callVideoCodec");
m_bitrate = makeMenu("Video bitrate", "callVideoBitrate");
m_resolution = makeMenu("Video resolution", "callVideoResolution");
m_fps = makeMenu("Frame rate", "callVideoFps");
m_output = makeMenu("Audio output", "callAudioOutput");
addOption(m_output, callText("Default (auto)"), QString());
auto* actions = new QHBoxLayout();
m_videoEnabled = makeRoundBtn(makeRoundIcon(QColor("#7f8c8d"), Glyph::CameraOff, 96), callText("Turn video on"), this);
m_videoEnabled->setObjectName("callVideoEnabled");
m_videoEnabled->setCheckable(true); m_videoEnabled->setAccessibleName(callText("Turn video on"));
actions->addStretch(1);
m_settingsBtn = makeRoundBtn(makeRoundIcon(QColor("#7f8c8d"), Glyph::Settings, 96), callText("Video settings"), this);
m_settingsBtn->setObjectName("callVideoSettings");
m_settingsBtn->setCheckable(true); m_settingsBtn->setAccessibleName(callText("Video settings"));
m_diagnosticsBtn = makeRoundBtn(makeRoundIcon(QColor("#7f8c8d"), Glyph::Diagnostics, 96), callText("Diagnostics"), this);
m_diagnosticsBtn->setObjectName("callDiagnosticsButton"); m_diagnosticsBtn->setCheckable(true);
m_diagnosticsBtn->setAccessibleName(callText("Diagnostics"));
m_showVideoBtn = new QPushButton(callText("Show video"), this); actions->addWidget(m_showVideoBtn);
lay->addLayout(actions);
connect(m_settingsBtn, &QPushButton::toggled, this, [this](bool checked) {
m_settingsBtn->setIcon(makeRoundIcon(checked ? QColor("#2980b9") : QColor("#7f8c8d"), Glyph::Settings, 96));
DEBUG_INFO(DEBUG_CATEGORY_CALL, "desktop video settings visible=%d", checked);
updateVideoPresentation();
});
connect(m_diagnosticsBtn, &QPushButton::toggled, this, [this](bool checked) {
m_diagnosticsBtn->setIcon(makeRoundIcon(checked ? QColor("#2980b9") : QColor("#7f8c8d"), Glyph::Diagnostics, 96));
DEBUG_INFO(DEBUG_CATEGORY_CALL, "desktop call diagnostics visible=%d id=%llu", checked, (unsigned long long)m_callId);
updateVideoPresentation();
});
connect(m_showVideoBtn, &QPushButton::clicked, this, [this] {
if (m_videoWindow->isMinimized()) m_videoWindow->showNormal(); else m_videoWindow->show();
m_videoWindow->raise(); m_videoWindow->activateWindow();
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call video window reopened");
});
connect(m_video, &CallVideoEngine::camerasChanged, this, [this](const CallCameraDevices& cameras) {
const auto previous = value(m_camera); m_cameras = cameras;
clearOptions(m_camera, "Camera");
for (const auto& camera : cameras) {
auto* action = addOption(m_camera, camera.name, camera.id); action->setEnabled(!camera.modes.isEmpty());
}
auto* previousAction = option(m_camera, previous);
if (previousAction && previousAction->isEnabled()) previousAction->setChecked(true);
else for (auto* action : m_camera->actions()) {
if (action->isCheckable() && action->isEnabled()) { action->setChecked(true); break; }
}
refreshVideoModes();
if (m_videoEnabled->isChecked()) applyVideo();
});
addOption(m_codec, callText("Auto (H.265 → H.264)"), 0); addOption(m_codec, "H.265", 2); addOption(m_codec, "H.264", 1);
auto* ranges = new QWidget(m_bitrate); auto* rates = new QFormLayout(ranges);
auto* rangeAction = new QWidgetAction(m_bitrate); rangeAction->setDefaultWidget(ranges); m_bitrate->addAction(rangeAction);
m_videoMin = new QSpinBox(ranges); m_videoMax = new QSpinBox(ranges);
m_videoMin->setObjectName("callVideoMinimumBitrate"); m_videoMax->setObjectName("callVideoMaximumBitrate");
for (auto* spin : {m_videoMin, m_videoMax}) {
spin->setRange(300, 4000); spin->setSingleStep(100); spin->setSuffix(callText(" kbit/s"));
}
rates->addRow(callText("Call video: minimum bitrate"), m_videoMin);
rates->addRow(callText("Call video: maximum bitrate"), m_videoMax);
m_videoMin->setValue(300); m_videoMax->setValue(4000);
ranges->setToolTip(callText("Video bitrate adapts to the connection within this range. Default: 300–4000 kbit/s."));
connect(m_videoEnabled, &QPushButton::toggled, this, [this](bool checked) {
m_videoEnabled->setIcon(makeRoundIcon(checked ? QColor("#2980b9") : QColor("#7f8c8d"), checked ? Glyph::Camera : Glyph::CameraOff, 96));
const QString action = callText(checked ? "Turn video off" : "Turn video on");
m_videoEnabled->setToolTip(action); m_videoEnabled->setAccessibleName(action);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "desktop video button id=%016llx enabled=%d", (unsigned long long)m_callId, checked);
applyVideo();
});
connect(m_camera, &QMenu::triggered, this, [this] { refreshVideoModes(); applyVideo(); });
connect(m_resolution, &QMenu::triggered, this, [this] {
populateFrameRates(m_fps, m_modes, value(m_resolution).toSize(), value(m_fps).toInt()); applyVideo();
});
for (auto* menu : {m_fps, m_codec}) connect(menu, &QMenu::triggered, this, &CallWindow::applyVideo);
auto updateRange = [this] {
if (m_videoMin->value() > m_videoMax->value()) m_videoMax->setValue(m_videoMin->value());
if (m_videoCall && call_video_port_range(m_videoCall, m_videoMin->value() * 1000, m_videoMax->value() * 1000) < 0) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call bitrate range refused id=%llu min_kbps=%d max_kbps=%d",
(unsigned long long)m_videoCall, m_videoMin->value(), m_videoMax->value());
m_videoStatus->setText(callText("Could not save the call video bitrate range.")); m_videoStatus->show();
}
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call bitrate range selected id=%llu min_kbps=%d max_kbps=%d",
(unsigned long long)m_videoCall, m_videoMin->value(), m_videoMax->value());
updateVideoMenuButtons();
};
connect(m_videoMin, QOverload<int>::of(&QSpinBox::valueChanged), this, [this](int minimum) {
if (minimum > m_videoMax->value()) m_videoMax->setValue(minimum); updateVideoMenuButtons();
});
connect(m_videoMax, QOverload<int>::of(&QSpinBox::valueChanged), this, [this](int maximum) {
if (maximum < m_videoMin->value()) m_videoMin->setValue(maximum); updateVideoMenuButtons();
});
connect(m_videoMin, &QSpinBox::editingFinished, this, updateRange);
connect(m_videoMax, &QSpinBox::editingFinished, this, updateRange);
connect(m_video, &CallVideoEngine::statusChanged, this, [this](const QString& text) {
m_videoStatus->setText(text); m_videoStatus->setVisible(m_state == Active && !text.isEmpty());
});
connect(m_video, &CallVideoEngine::imageChanged, this, [this](const QImage& image, quint64 serial) {
bool changed = m_remoteImage.isNull() != image.isNull(); m_remoteImage = image;
m_videoWindow->remote = image; m_videoWindow->serial = serial; m_videoWindow->callId = m_videoCall;
m_videoWindow->requestedAt = call_video_playout_now_us(); m_videoWindow->update();
if (changed) updateVideoPresentation();
// Скрытое окно продолжает принимать поток, не ждёт невозможного paintEvent.
if (!m_videoWindow->isVisible() || m_videoWindow->isMinimized()) m_video->imagePresented(serial);
update();
});
connect(m_video, &CallVideoEngine::localImageChanged, this, [this](const QImage& image) {
bool changed = m_localImage.isNull() != image.isNull(); m_localImage = image;
m_videoWindow->local = image; m_videoWindow->update();
m_localPreview->setImage(image);
if (changed) updateVideoPresentation();
update();
});
auto* videoTimer = new QTimer(this); videoTimer->setInterval(100);
connect(videoTimer, &QTimer::timeout, this, [this] {
if (m_state != Active || !CallAudioEngine::instance()->isActive() || CallAudioEngine::instance()->callId() != m_callId) return;
if (m_videoCall != m_callId) { m_videoCall = m_callId; m_videoRangeReady = false; m_video->start(m_callId); applyVideo(); }
struct call_video_port_status status{};
if (!m_videoRangeReady && call_video_port_status(m_callId, &status) == 0 && status.minimum_bps) {
m_videoMin->setValue(status.minimum_bps / 1000); m_videoMax->setValue(status.maximum_bps / 1000); m_videoRangeReady = true;
}
}); videoTimer->start();
m_acceptCell = wrapWithCaption(m_acceptBtn, QCoreApplication::translate("ChatGui", "Accept"));
m_declineCell = wrapWithCaption(m_declineBtn, QCoreApplication::translate("ChatGui", "Decline"));
m_hangupCell = wrapWithCaption(m_hangupBtn, QCoreApplication::translate("ChatGui", "End call"));
m_muteCell = wrapWithCaption(m_muteBtn, QCoreApplication::translate("ChatGui", "Mute"), &m_muteCaption);
m_videoCell = wrapWithCaption(m_videoEnabled, callText("Camera"));
m_settingsCell = wrapWithCaption(m_settingsBtn, callText("Video settings"));
m_diagnosticsCell = wrapWithCaption(m_diagnosticsBtn, callText("Diagnostics"));
auto* footer = new QWidget(this); footer->setObjectName("callActions");
QHBoxLayout* btn = new QHBoxLayout(footer); btn->setContentsMargins(0, 0, 0, 0);
btn->setSpacing(10);
btn->addStretch(1);
btn->addWidget(m_acceptCell);
btn->addWidget(m_declineCell);
btn->addWidget(m_hangupCell);
btn->addWidget(m_muteCell);
btn->addWidget(m_videoCell);
btn->addWidget(m_settingsCell);
btn->addWidget(m_diagnosticsCell);
btn->addStretch(1);
root->addWidget(footer, 0, Qt::AlignHCenter);
connect(m_acceptBtn, &QPushButton::clicked, this, &CallWindow::onAccept);
connect(m_declineBtn, &QPushButton::clicked, this, &CallWindow::onDecline);
connect(m_hangupBtn, &QPushButton::clicked, this, &CallWindow::onHangup);
connect(m_muteBtn, &QPushButton::toggled, this, &CallWindow::onMuteToggled);
connect(m_output, &QMenu::triggered, this, &CallWindow::onDeviceChanged);
m_durationTimer = new QTimer(this);
m_durationTimer->setInterval(1000);
connect(m_durationTimer, &QTimer::timeout, this, &CallWindow::updateDuration);
updateVideoMenuButtons(); setState(Outgoing);
}
CallWindow::~CallWindow() {
// Остановить callbacks до удаления дочерних виджетов окна.
m_video->disconnect(this); m_video->stop();
}
void CallWindow::setPeerName(const QString& name) {
m_peerLabel->setText(name);
m_avatarLabel->setPixmap(makeAvatar(name, 80));
m_videoWindow->setWindowTitle(callText("Video — %1").arg(name));
}
void CallWindow::setBgpPath(const QString& path) {
m_bgpPathLabel->setText(path);
m_bgpPathLabel->setVisible(m_state == Active && !path.isEmpty());
}
void CallWindow::setConnectionType(uint8_t type, quint64 nextHop) {
const QString names[] = { QCoreApplication::translate("ChatGui", "not connected"), QCoreApplication::translate("ChatGui", "direct"),
QCoreApplication::translate("ChatGui", "reverse"), QCoreApplication::translate("ChatGui", "indirect") };
m_connectionLabel->setText(QCoreApplication::translate("ChatGui", "Connection: %1").arg(names[type <= 3 ? type : 0]));
m_connectionLabel->setToolTip(type == 3 ? QCoreApplication::translate("ChatGui", "Relay: 0x%1").arg(nextHop, 16, 16, QChar('0')) : QString());
}
void CallWindow::setState(State s) {
m_state = s;
switch (s) {
case WaitDecision:
m_statusLabel->setText(callText("Subscriber is on a phone call"));
m_durationLabel->hide();
break;
case Outgoing:
m_statusLabel->setText(QCoreApplication::translate("ChatGui", "Connecting…"));
m_durationLabel->hide();
break;
case Ringing:
m_statusLabel->setText(QCoreApplication::translate("ChatGui", "Calling…"));
m_durationLabel->hide();
break;
case Incoming:
m_statusLabel->setText(QCoreApplication::translate("ChatGui", "Incoming call…"));
m_durationLabel->hide();
break;
case Active:
m_statusLabel->setText(CallAudioEngine::instance()->captureError().isEmpty()
? callText("In call") : CallAudioEngine::instance()->captureError());
m_durationLabel->setText(QStringLiteral("00:00"));
m_durationLabel->show();
m_startMs = QDateTime::currentMSecsSinceEpoch();
m_durationTimer->start();
populateOutputDevices();
m_muteBtn->setChecked(false);
break;
case Ended:
m_video->stop(); m_videoCall = 0;
setConnectionType(0, 0);
m_statusLabel->setText(QCoreApplication::translate("ChatGui", "Ended"));
m_durationTimer->stop();
break;
}
updateButtons();
}
void CallWindow::populateOutputDevices() {
clearOptions(m_output, "Audio output");
addOption(m_output, callText("Default (auto)"), QString());
const auto devs = SoundManager::enumeratePlaybackDevices();
for (int i = 0; i < devs.size(); i++) {
QString label = devs[i].name;
if (devs[i].isDefault) label += QCoreApplication::translate("ChatGui", " (default)");
addOption(m_output, label, devs[i].id);
}
QString cur = CallAudioEngine::instance()->playbackDevice();
if (!select(m_output, cur))
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call audio output unavailable device=%s; using default selection", qPrintable(cur));
updateVideoMenuButtons();
}
void CallWindow::updateButtons() {
m_continueBtn->setVisible(m_state == WaitDecision);
m_acceptCell->setVisible(m_state == Incoming);
m_declineCell->setVisible(m_state == Incoming);
m_hangupCell->setVisible(m_state == Outgoing || m_state == Ringing || m_state == Active || m_state == WaitDecision);
m_muteCell->setVisible(m_state == Active);
updateVideoPresentation();
}
void CallWindow::updateVideoPresentation() {
bool receiving = m_state == Active && !m_remoteImage.isNull();
if (receiving != m_receivingVideo) {
m_receivingVideo = receiving;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call video separate window remote=%d local=%d", receiving, !m_localImage.isNull());
if (receiving) m_videoWindow->show(); else m_videoWindow->hide();
}
bool active = m_state == Active, settings = active && m_settingsBtn->isChecked();
m_avatarLabel->setVisible(!active || (!settings && !receiving && m_localImage.isNull()));
m_localPreview->setVisible(active && !receiving && !m_localImage.isNull());
m_connectionLabel->setVisible(active); m_bgpPathLabel->setVisible(active && !m_bgpPathLabel->text().isEmpty());
m_statsLabel->setVisible(active && !m_statsLabel->text().isEmpty());
m_diagnostics->setVisible(active && m_diagnosticsBtn->isChecked()); m_diagnosticsCell->setVisible(active);
m_videoCell->setVisible(active); m_settingsCell->setVisible(active);
m_videoControls->setVisible(settings); m_showVideoBtn->setVisible(receiving);
m_videoStatus->setVisible(active && !m_videoStatus->text().isEmpty());
QSize panelMinimum = m_panel->minimumSizeHint();
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call layout active=%d preview=%d settings=%d avatar=%d diagnostics=%d window=%dx%d panel_min=%dx%d",
active, m_localPreview->isVisible(), settings, !m_avatarLabel->isHidden(), !m_diagnostics->isHidden(),
width(), height(), panelMinimum.width(), panelMinimum.height());
update();
}
void CallWindow::applyVideo() {
updateVideoMenuButtons();
if (!m_videoCall) return;
QSize size = value(m_resolution).toSize();
const bool enabled = m_videoEnabled->isChecked() && !size.isEmpty() && selected(m_fps);
if (m_videoEnabled->isChecked() && !enabled) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call camera has no selected mode device=%s", value(m_camera).toByteArray().constData());
m_videoStatus->setText(callText("No supported video modes")); m_videoStatus->show();
}
if (size.isEmpty()) size = QSize(640, 480);
const int fps = value(m_fps).toInt();
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call video selection id=%llu enabled=%d device=%s mode=%dx%d/%d codec=%d",
(unsigned long long)m_videoCall, enabled, value(m_camera).toByteArray().constData(), size.width(), size.height(), fps, value(m_codec).toInt());
m_video->setVideo(enabled, value(m_camera).toByteArray(), size.width(), size.height(), value(m_codec).toInt(), fps ? fps : 30);
}
void CallWindow::refreshVideoModes() {
QSize wanted = value(m_resolution).toSize(); if (wanted.isEmpty()) wanted = QSize(640, 480);
const int previousFps = selected(m_fps) ? value(m_fps).toInt() : 30;
clearOptions(m_resolution, "Video resolution"); m_modes.clear();
for (const auto& camera : m_cameras) if (camera.id == value(m_camera).toByteArray()) { m_modes = camera.modes; break; }
for (const auto& mode : m_modes) if (!option(m_resolution, mode.size))
addOption(m_resolution, QStringLiteral("%1 × %2").arg(mode.size.width()).arg(mode.size.height()), mode.size);
if (!select(m_resolution, wanted) && !select(m_resolution, QSize(640, 480)) && !m_modes.isEmpty())
select(m_resolution, m_modes.back().size);
populateFrameRates(m_fps, m_modes, value(m_resolution).toSize(), previousFps);
if (m_modes.isEmpty()) DEBUG_WARN(DEBUG_CATEGORY_CALL, "call camera has no supported modes device=%s", value(m_camera).toByteArray().constData());
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call camera modes device=%s count=%lld requested=%dx%d selected=%dx%d/%d",
value(m_camera).toByteArray().constData(), (long long)m_modes.size(), wanted.width(), wanted.height(),
value(m_resolution).toSize().width(), value(m_resolution).toSize().height(), value(m_fps).toInt());
updateVideoMenuButtons();
}
void CallWindow::updateVideoMenuButtons() {
auto update = [this](QMenu* menu, const char* heading, const QString& shape, const QString& label, const QString& detail = {}) {
menu->menuAction()->setIcon(controlIcon(palette(), shape, label, detail));
QString description = callText(heading);
if (auto* action = selected(menu)) description += QStringLiteral(": ") + action->text();
menu->menuAction()->setToolTip(description); menu->setAccessibleName(description);
};
auto* camera = selected(m_camera);
update(m_camera, "Camera", "camera", camera ? camera->text() : "—");
int codec = value(m_codec).toInt();
update(m_codec, "Video codec", "chip", codec ? QStringLiteral("H%1").arg(codec == 2 ? 265 : 264) : "Auto");
auto shortRate = [](int kbps) { return kbps >= 1000 ? QString::number(kbps / 1000.0, 'g', 2) + 'M' : QString::number(kbps) + 'k'; };
update(m_bitrate, "Video bitrate", "gauge", shortRate(m_videoMax->value()), shortRate(m_videoMin->value()));
QString range = callText("Bitrate range, kbit/s:") + QStringLiteral(" %1–%2").arg(m_videoMin->value()).arg(m_videoMax->value());
m_bitrate->menuAction()->setToolTip(range); m_bitrate->setAccessibleName(range);
QSize size = value(m_resolution).toSize();
update(m_resolution, "Video resolution", "screen", size.isEmpty() ? "—" : QString::number(std::min(size.width(), size.height())) + 'p');
update(m_fps, "Frame rate", "fps", selected(m_fps) ? value(m_fps).toString() : "—", "FPS");
auto* output = selected(m_output); update(m_output, "Audio output", "output", output ? output->text() : "—");
m_resolution->menuAction()->setEnabled(!m_modes.isEmpty()); m_fps->menuAction()->setEnabled(!m_modes.isEmpty());
m_videoMenuBar->setFixedWidth(m_videoMenuBar->sizeHint().width());
}
void CallWindow::onAccept() {
if (gui_bridge_call_action(GUI_CALL_ACCEPT, m_callId) < 0) {
m_statusLabel->setText(callText("Could not send the call command")); return;
}
SoundManager::instance()->stopLoop();
setState(Active);
}
void CallWindow::onDecline() {
if (gui_bridge_call_action(GUI_CALL_DECLINE, m_callId) < 0)
m_statusLabel->setText(callText("Could not send the call command"));
}
void CallWindow::onHangup() {
if (gui_bridge_call_action(GUI_CALL_HANGUP, m_callId) < 0)
m_statusLabel->setText(callText("Could not send the call command"));
}
void CallWindow::onMuteToggled(bool checked) {
CallAudioEngine::instance()->setMuted(checked);
m_muteBtn->setIcon(makeRoundIcon(checked ? QColor("#e74c3c") : QColor("#7f8c8d"),
checked ? Glyph::MicOff : Glyph::Mic, 96));
if (m_muteCaption)
m_muteCaption->setText(checked ? QCoreApplication::translate("ChatGui", "Unmute") : QCoreApplication::translate("ChatGui", "Mute"));
}
void CallWindow::onDeviceChanged() {
QString id = value(m_output).toString();
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call audio output selected device=%s", qPrintable(id));
CallAudioEngine::instance()->setPlaybackDevice(id);
updateVideoMenuButtons();
}
/* Закрытие непринятого вызова равнозначно Decline; локальный Ended исключает повторную команду. */
void CallWindow::closeEvent(QCloseEvent* event) {
const State previous = m_state;
if (previous != Ended) {
setState(Ended);
const auto action = previous == Incoming ? GUI_CALL_DECLINE : GUI_CALL_HANGUP;
const char* name = action == GUI_CALL_DECLINE ? "decline" : "hangup";
if (gui_bridge_call_action(action, m_callId) < 0)
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call window closed without queued %s id=%llu previous_state=%d",
name, (unsigned long long)m_callId, int(previous));
else DEBUG_INFO(DEBUG_CATEGORY_CALL, "call window closed: queued %s id=%llu previous_state=%d",
name, (unsigned long long)m_callId, int(previous));
}
emit closed(m_callId);
event->accept();
}
void CallWindow::updateDuration() {
if (m_state != Active) return;
qint64 ms = QDateTime::currentMSecsSinceEpoch() - m_startMs;
qint64 s = ms / 1000;
m_durationLabel->setText(QStringLiteral("%1:%2")
.arg(s / 60, 2, 10, QChar('0'))
.arg(s % 60, 2, 10, QChar('0')));
}
void CallWindow::setAudioStats(int rtt, int buffer, int tempo, int dropped, int underruns,
int minimum, int maximum, int reserve) {
QString text = QCoreApplication::translate("ChatGui",
"Buffer: %1 ms · Reserve: %2 ms\nOver 10 s: minimum %3 · maximum %4 ms\nSpeed: %5× · RTT: %6 "
"ms\nBuffer drops: %7 · Underruns: %8")
.arg(buffer).arg(reserve).arg(minimum).arg(maximum)
.arg(tempo / 100.0, 0, 'f', 2).arg(rtt).arg(dropped).arg(underruns);
auto* audio = CallAudioEngine::instance();
if (audio->callId() == m_callId) {
auto output = audio->outputLatency();
auto value = [](int ms) { return ms >= 0 ? QString::number(ms) : QStringLiteral("—"); };
QString total = value(output.outputMs);
if (output.outputMs >= 0 && !output.measured) total.prepend(QStringLiteral("~"));
text += QLatin1Char('\n') + QCoreApplication::translate("ChatGui", "Audio output: %1 ms (includes app queue: %2 ms)")
.arg(total).arg(value(output.queueMs));
}
QString html = text.toHtmlEscaped();
static const QRegularExpression values(QStringLiteral("(~?(?:\\d+(?:[.,]\\d+)?|—)(?:\\h*(?:ms|мс|s|с|×))?)"));
html.replace(values, QStringLiteral("<b style=\"color: %1;\">\\1</b>").arg(palette().color(QPalette::WindowText).name()));
html.replace(QLatin1Char('\n'), QStringLiteral("<br>")); m_statsLabel->setText(html);
m_statsLabel->setVisible(m_state == Active);
}