diff --git a/tools/chatgui/src/callwindow.cpp b/tools/chatgui/src/callwindow.cpp index 3054f327..19ef3eec 100644 --- a/tools/chatgui/src/callwindow.cpp +++ b/tools/chatgui/src/callwindow.cpp @@ -1,5 +1,6 @@ #include #include "callwindow.h" +#include "capture_controls.h" #include "../transport/gui_bridge.h" #include "call_audio_engine.h" #include "call_video_engine.h" @@ -11,8 +12,9 @@ #include #include #include -#include #include +#include +#include #include #include #include @@ -32,6 +34,7 @@ extern "C" { enum class Glyph { Phone, PhoneDown, Mic, MicOff, Camera, CameraOff, Settings }; static QString callText(const char* text) { return QCoreApplication::translate("ChatGui", text); } +using namespace CaptureControls; // Отдельное окно показа. Закрытие скрывает только изображение, не завершает звонок. class CallVideoWindow : public QWidget { @@ -317,11 +320,6 @@ CallWindow::CallWindow(QWidget* parent) m_muteBtn = makeRoundBtn(makeRoundIcon(QColor("#7f8c8d"), Glyph::Mic, 96), QCoreApplication::translate("ChatGui", "Mute"), this); m_muteBtn->setCheckable(true); - m_deviceCombo = new QComboBox(this); - m_deviceCombo->setStyleSheet( - "QComboBox { border: 1px solid palette(mid); border-radius: 4px;" - " padding: 6px 10px; font-size: 13px; background: palette(base); }"); - auto* root = new QVBoxLayout(this); root->setContentsMargins(12, 12, 12, 12); root->setSizeConstraint(QLayout::SetMinimumSize); @@ -360,11 +358,28 @@ CallWindow::CallWindow(QWidget* parent) m_videoStatus = new QLabel(this); 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_showVideoBtn = new QPushButton(callText("Show video"), this); actions->addWidget(m_showVideoBtn); lay->addLayout(actions); @@ -378,54 +393,32 @@ CallWindow::CallWindow(QWidget* parent) m_videoWindow->raise(); m_videoWindow->activateWindow(); DEBUG_INFO(DEBUG_CATEGORY_CALL, "call video window reopened"); }); - auto* modes = new QHBoxLayout(); - m_camera = new QComboBox(this); - m_resolution = new QComboBox(this); - auto refreshModes = [this] { - QSize wanted = m_resolution->currentData().toSize(); - const int previousFps = m_resolution->currentData(Qt::UserRole + 1).toInt(); - const int wantedFps = previousFps ? previousFps : 30; - if (wanted.isEmpty()) wanted = QSize(640, 480); - m_resolution->clear(); - for (const auto& value : m_camera->currentData(Qt::UserRole + 1).toList()) { - const auto mode = value.toList(); const QSize size = mode[0].toSize(); const int fps = mode[1].toInt(); - m_resolution->addItem(QStringLiteral("%1×%2 · %3 fps").arg(size.width()).arg(size.height()).arg(fps), size); - m_resolution->setItemData(m_resolution->count() - 1, fps, Qt::UserRole + 1); - } - int index = -1; - for (int i = 0; i < m_resolution->count(); ++i) - if (m_resolution->itemData(i).toSize() == wanted && m_resolution->itemData(i, Qt::UserRole + 1).toInt() == wantedFps) index = i; - if (index < 0) index = m_resolution->findData(QSize(640, 480)); - if (index < 0) index = m_resolution->count() - 1; - m_resolution->setCurrentIndex(index); m_resolution->setEnabled(index >= 0); - DEBUG_INFO(DEBUG_CATEGORY_CALL, "call camera resolutions device=%s count=%d requested=%dx%d selected=%dx%d fps=%d", - m_camera->currentData().toByteArray().constData(), m_resolution->count(), wanted.width(), wanted.height(), - m_resolution->currentData().toSize().width(), m_resolution->currentData().toSize().height(), - m_resolution->currentData(Qt::UserRole + 1).toInt()); - }; - connect(m_video, &CallVideoEngine::camerasChanged, this, [this, refreshModes](const CallCameraDevices& cameras) { - const auto selected = m_camera->currentData(); - m_camera->clear(); + 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) { - QVariantList sizes; - for (const auto& mode : camera.modes) sizes.append(QVariant(QVariantList{mode.size, mode.fps})); - m_camera->addItem(camera.name, camera.id); - m_camera->setItemData(m_camera->count() - 1, sizes, Qt::UserRole + 1); + auto* action = addOption(m_camera, camera.name, camera.id); action->setEnabled(!camera.modes.isEmpty()); } - int index = m_camera->findData(selected); - if (index < 0) for (int i = 0; i < m_camera->count(); ++i) { - if (!m_camera->itemData(i, Qt::UserRole + 1).toList().isEmpty()) { index = i; break; } + 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; } } - if (index >= 0) m_camera->setCurrentIndex(index); - refreshModes(); + refreshVideoModes(); if (m_videoEnabled->isChecked()) applyVideo(); }); - m_codec = new QComboBox(this); m_codec->addItem(callText("Auto (H.265 → H.264)"), 0); m_codec->addItem("H.265", 2); m_codec->addItem("H.264", 1); - modes->addWidget(m_camera, 1); modes->addWidget(m_resolution); modes->addWidget(m_codec); videoLayout->addLayout(modes); - auto* rates = new QHBoxLayout(); rates->addWidget(new QLabel(callText("Bitrate range, kbit/s:"), this)); - m_videoMin = new QSpinBox(this); m_videoMax = new QSpinBox(this); - for (auto* spin : {m_videoMin, m_videoMax}) { spin->setRange(300, 4000); spin->setSingleStep(100); rates->addWidget(spin); } - m_videoMin->setValue(300); m_videoMax->setValue(4000); videoLayout->addLayout(rates); lay->addWidget(m_videoControls); + 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"); @@ -433,13 +426,28 @@ CallWindow::CallWindow(QWidget* parent) DEBUG_INFO(DEBUG_CATEGORY_CALL, "desktop video button id=%016llx enabled=%d", (unsigned long long)m_callId, checked); applyVideo(); }); - connect(m_camera, QOverload::of(&QComboBox::activated), this, [this, refreshModes] { refreshModes(); applyVideo(); }); - for (auto* combo : {m_resolution, m_codec}) - connect(combo, QOverload::of(&QComboBox::activated), this, [this] { 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); + 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::of(&QSpinBox::valueChanged), this, [this](int minimum) { + if (minimum > m_videoMax->value()) m_videoMax->setValue(minimum); updateVideoMenuButtons(); + }); + connect(m_videoMax, QOverload::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) { @@ -492,19 +500,18 @@ CallWindow::CallWindow(QWidget* parent) btn->addWidget(m_settingsCell); btn->addStretch(1); lay->addLayout(btn); - videoLayout->addWidget(m_deviceCombo); 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_deviceCombo, QOverload::of(&QComboBox::activated), this, &CallWindow::onDeviceChanged); + 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); - setState(Outgoing); + updateVideoMenuButtons(); setState(Outgoing); } CallWindow::~CallWindow() { @@ -556,7 +563,7 @@ void CallWindow::setState(State s) { m_durationLabel->show(); m_startMs = QDateTime::currentMSecsSinceEpoch(); m_durationTimer->start(); - populateDeviceCombo(); + populateOutputDevices(); m_muteBtn->setChecked(false); break; case Ended: @@ -569,21 +576,19 @@ void CallWindow::setState(State s) { updateButtons(); } -void CallWindow::populateDeviceCombo() { - m_deviceCombo->blockSignals(true); - m_deviceCombo->clear(); - m_deviceCombo->addItem(QCoreApplication::translate("ChatGui", "Default (auto)"), QString()); +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)"); - m_deviceCombo->addItem(label, devs[i].id); + addOption(m_output, label, devs[i].id); } QString cur = CallAudioEngine::instance()->playbackDevice(); - for (int i = 0; i < m_deviceCombo->count(); i++) { - if (m_deviceCombo->itemData(i).toString() == cur) { m_deviceCombo->setCurrentIndex(i); break; } - } - m_deviceCombo->blockSignals(false); + 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() { @@ -616,16 +621,59 @@ void CallWindow::updateVideoPresentation() { } void CallWindow::applyVideo() { + updateVideoMenuButtons(); if (!m_videoCall) return; - QSize size = m_resolution->currentData().toSize(); - const bool enabled = m_videoEnabled->isChecked() && !size.isEmpty(); + 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", m_camera->currentData().toByteArray().constData()); + 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 = m_resolution->currentData(Qt::UserRole + 1).toInt(); - m_video->setVideo(enabled, m_camera->currentData().toByteArray(), size.width(), size.height(), m_codec->currentData().toInt(), fps ? fps : 30); + 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() { @@ -654,9 +702,11 @@ void CallWindow::onMuteToggled(bool checked) { m_muteCaption->setText(checked ? QCoreApplication::translate("ChatGui", "Unmute") : QCoreApplication::translate("ChatGui", "Mute")); } -void CallWindow::onDeviceChanged(int index) { - QString id = m_deviceCombo->itemData(index).toString(); +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 исключает повторную команду. */ diff --git a/tools/chatgui/src/callwindow.h b/tools/chatgui/src/callwindow.h index ef6b0fc6..41c6e265 100644 --- a/tools/chatgui/src/callwindow.h +++ b/tools/chatgui/src/callwindow.h @@ -6,10 +6,12 @@ #include #include #include +#include "call_camera_capture.h" class QLabel; class QPushButton; -class QComboBox; +class QMenu; +class QMenuBar; class QTimer; class QSpinBox; class CallVideoEngine; @@ -53,16 +55,21 @@ private slots: void onHangup(); // Ставит Hangup; при отказе сохраняет окно и показывает ошибку. void updateDuration(); // Только Active: прошедшее wall-clock время в минуты:секунды. void onMuteToggled(bool checked); // Меняет mute общего аудиодвижка, иконку и подпись. - void onDeviceChanged(int index); // Применяет backend ID выбранного выхода m_deviceCombo. + void onDeviceChanged(); // Применяет backend ID выбранного аудиовыхода m_output. private: void updateButtons(); // Видимость действий по State, затем общая раскладка видео. - void populateDeviceCombo(); // Выходы SoundManager: пустой ID — default, текущий выбор из CallAudioEngine. + void populateOutputDevices(); // Выходы SoundManager: пустой ID — default, текущий выбор из CallAudioEngine. + void refreshVideoModes(); // Уникальные размеры выбранной камеры, затем доступные FPS; сохраняет текущий допустимый режим. + void updateVideoMenuButtons(); // Текущие camera/codec/resolution/FPS/bitrate/output внутри кнопок, полные названия в подсказках. void applyVideo(); // Применяет выбранную камеру/режим; без доступного режима отправку видео отключает. void updateVideoPresentation(); // Отдельное remote-видео, локальный preview, настройки и диагностические подписи. CallVideoEngine* m_video; // Дочерний TX/RX/показ; start по таймеру 100мс только при Active и совпадающем активном аудиозвонке. QPushButton* m_videoEnabled; // Круглая checkable-кнопка отправки видео; наличие camera mode проверяется отдельно. - QComboBox *m_camera, *m_resolution, *m_codec; // ID камеры, размер/FPS и codec: 0=Auto, 2=H.265, 1=H.264. + QMenuBar* m_videoMenuBar; // Общий стиль трансляции: кнопки 48x48, значки 40x36, переключение раскрытых меню одним кликом. + QMenu *m_camera, *m_resolution, *m_fps, *m_codec, *m_bitrate, *m_output; // checked QAction/data хранит выбранный вариант. + CallCameraDevices m_cameras; // Последний полный снимок worker; режимы не переносятся между разными ID камер. + QList m_modes; // Допустимые пары resolution/FPS текущей камеры. QSpinBox *m_videoMin, *m_videoMax; // Диапазон 300..4000 кбит/с; native port получает бит/с. QLabel *m_localPreview, *m_videoStatus; // Малый локальный preview и статус/ошибка видеодвижка. QWidget *m_videoControls, *m_panel; // Сворачиваемые настройки и центральная панель шириной до 640. @@ -89,7 +96,6 @@ private: QPushButton* m_declineBtn; // Decline для Incoming; результат закрывает окно через MainWindow. QPushButton* m_hangupBtn; // Завершение остальных живых состояний. QPushButton* m_muteBtn; // Checkable mute общего CallAudioEngine, виден при Active. - QComboBox* m_deviceCombo; // Выбор общего выходного устройства звонка; находится внутри настроек. QTimer* m_durationTimer; // GUI 1с; запускается в Active, останавливается в Ended/close. QWidget* m_acceptCell; // Кнопка Accept и её подпись, показываются вместе.