|
|
|
|
@ -13,6 +13,8 @@
|
|
|
|
|
#include <QPainter> |
|
|
|
|
#include <QScrollArea> |
|
|
|
|
#include <QSignalBlocker> |
|
|
|
|
#include <QJsonDocument> |
|
|
|
|
#include <QScopedValueRollback> |
|
|
|
|
#ifdef HAVE_STREAM_CAPTURE |
|
|
|
|
#include <QVideoWidget> |
|
|
|
|
#include <QVideoSink> |
|
|
|
|
@ -23,8 +25,14 @@ namespace {
|
|
|
|
|
QString text(const char* value) { return QCoreApplication::translate("ChatGui", value); } |
|
|
|
|
QString bytes(uint64_t value) { return QString::number(value / 1024.0, 'f', 1) + " KiB"; } |
|
|
|
|
} |
|
|
|
|
StreamPublishWindow::StreamPublishWindow(StreamPublishEngine* engine, uint64_t group, QWidget* parent) |
|
|
|
|
StreamPublishWindow::StreamPublishWindow(StreamPublishEngine* engine, uint64_t group, QWidget* parent, const QString& saved) |
|
|
|
|
: QWidget(parent, Qt::Window), m_engine(engine), m_group(group) { |
|
|
|
|
if (!saved.isEmpty()) { |
|
|
|
|
QJsonParseError error; auto document = QJsonDocument::fromJson(saved.toUtf8(), &error); |
|
|
|
|
if (error.error != QJsonParseError::NoError || !document.isObject()) |
|
|
|
|
DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop capture settings invalid: %s", qPrintable(error.errorString())); |
|
|
|
|
else m_settings = document.object(); |
|
|
|
|
} |
|
|
|
|
setWindowTitle(text("Broadcast stream")); resize(720, 720); setMinimumSize(460, 480); |
|
|
|
|
auto* layout = new QVBoxLayout(this); |
|
|
|
|
#ifdef HAVE_STREAM_CAPTURE |
|
|
|
|
@ -49,10 +57,8 @@ StreamPublishWindow::StreamPublishWindow(StreamPublishEngine* engine, uint64_t g
|
|
|
|
|
connect(refresh, &QPushButton::clicked, this, &StreamPublishWindow::refreshSources); form->addRow(text("Video source"), sourceRow); |
|
|
|
|
connect(m_source, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this] { m_source->setToolTip(m_source->currentText()); }); |
|
|
|
|
m_mode = new QComboBox(settings); |
|
|
|
|
for (auto mode : {QSize(640, 360), QSize(1280, 720), QSize(1920, 1080)}) |
|
|
|
|
for (int fps : {15, 30, 60}) m_mode->addItem(QStringLiteral("%1 × %2 · %3 fps").arg(mode.width()).arg(mode.height()).arg(fps), |
|
|
|
|
QVariantList{mode.width(), mode.height(), fps}); |
|
|
|
|
m_mode->setCurrentIndex(4); form->addRow(text("Video mode"), m_mode); |
|
|
|
|
m_mode->setObjectName(QStringLiteral("streamVideoMode")); m_source->setObjectName(QStringLiteral("streamVideoSource")); |
|
|
|
|
form->addRow(text("Video mode"), m_mode); |
|
|
|
|
m_codec = new QComboBox(settings); form->addRow(text("Video codec"), m_codec); |
|
|
|
|
m_bitrate = new QComboBox(settings); m_bitrate->addItem(text("Automatic"), 0); |
|
|
|
|
for (int bitrate : {250000, 500000, 1000000, 2000000, 4000000, 8000000, 16000000}) |
|
|
|
|
@ -84,14 +90,23 @@ StreamPublishWindow::StreamPublishWindow(StreamPublishEngine* engine, uint64_t g
|
|
|
|
|
if (!m_engine->active()) m_status->setProperty("streamError", QString()); |
|
|
|
|
if (m_engine->active()) m_engine->stop(); else m_engine->start(m_group, selection()); synchronize(); |
|
|
|
|
}); |
|
|
|
|
for (auto* box : {m_source, m_mode, m_codec, m_bitrate}) connect(box, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &StreamPublishWindow::apply); |
|
|
|
|
connect(m_source, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this] { |
|
|
|
|
if (m_updating) return; |
|
|
|
|
auto previous = selection(); |
|
|
|
|
refreshModes(previous, previous.source.kind == StreamVideoSource::Screen); apply(); |
|
|
|
|
if (!m_engine->active()) synchronize(); |
|
|
|
|
}); |
|
|
|
|
for (auto* box : {m_mode, m_codec, m_bitrate}) connect(box, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &StreamPublishWindow::apply); |
|
|
|
|
connect(m_microphone, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &StreamPublishWindow::apply); |
|
|
|
|
connect(m_audio, &QCheckBox::toggled, this, &StreamPublishWindow::apply); |
|
|
|
|
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) |
|
|
|
|
connect(m_rotation, &QButtonGroup::idClicked, this, &StreamPublishWindow::apply); |
|
|
|
|
#else |
|
|
|
|
connect(m_rotation, QOverload<int>::of(&QButtonGroup::buttonClicked), this, &StreamPublishWindow::apply); |
|
|
|
|
#endif |
|
|
|
|
connect(engine, &StreamPublishEngine::codecsChanged, this, &StreamPublishWindow::refreshCodecs); |
|
|
|
|
connect(engine, &StreamPublishEngine::configurationChanged, this, &StreamPublishWindow::synchronize); |
|
|
|
|
connect(engine, &StreamPublishEngine::sourcesChanged, this, &StreamPublishWindow::refreshSources); |
|
|
|
|
connect(engine, &StreamPublishEngine::configurationChanged, this, [this] { synchronize(); saveSettings(); }); |
|
|
|
|
connect(engine, &StreamPublishEngine::stateChanged, this, [this](const QString& value) { |
|
|
|
|
auto error = m_status->property("streamError").toString(); |
|
|
|
|
m_status->setText(error.isEmpty() ? value : value + "\n" + error); synchronize(); |
|
|
|
|
@ -104,53 +119,173 @@ StreamPublishWindow::StreamPublishWindow(StreamPublishEngine* engine, uint64_t g
|
|
|
|
|
m_stats->setText(text("Video queue: %1 · Inflight: %2 · Send buffer: %3\nACK, all connections, last second: %4") |
|
|
|
|
.arg(bytes(stats.video_bytes), bytes(stats.inflight_bytes), bytes(stats.total_bytes), bytes(stats.acked_1s))); |
|
|
|
|
}); |
|
|
|
|
refreshSources(); refreshCodecs(); synchronize(); |
|
|
|
|
refreshSources(); refreshCodecs(); restoreSettings(); synchronize(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* В БД храним пользовательский выбор, а не runtime handles источника и рассчитанный auto-битрейт. */ |
|
|
|
|
void StreamPublishWindow::restoreSettings() { |
|
|
|
|
if (m_settings.isEmpty() || m_engine->active()) return; |
|
|
|
|
m_updating = true; |
|
|
|
|
auto select = [](QComboBox* box, const QVariant& value, const char* field) { |
|
|
|
|
int index = box->findData(value); |
|
|
|
|
if (index >= 0) box->setCurrentIndex(index); |
|
|
|
|
else DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop capture setting unavailable field=%s value=%s", field, qPrintable(value.toString())); |
|
|
|
|
}; |
|
|
|
|
int sourceIndex = -1; |
|
|
|
|
for (int i = 0; i < m_sources.size(); i++) { |
|
|
|
|
const auto& source = m_sources[i]; |
|
|
|
|
if (source.kind != m_settings.value("source_kind").toInt()) continue; |
|
|
|
|
bool matches = source.kind == StreamVideoSource::Window ? source.name == m_settings.value("source_name").toString() |
|
|
|
|
: source.id == QByteArray::fromBase64(m_settings.value("source_id").toString().toLatin1()); |
|
|
|
|
if (matches) { sourceIndex = i; break; } |
|
|
|
|
} |
|
|
|
|
if (sourceIndex >= 0) m_source->setCurrentIndex(sourceIndex); |
|
|
|
|
else DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop saved capture source unavailable; using current source"); |
|
|
|
|
auto restored = selection(); |
|
|
|
|
restored.video.width = m_settings.value("width").toInt(1280); restored.video.height = m_settings.value("height").toInt(720); |
|
|
|
|
restored.video.fps = m_settings.value("fps").toInt(30); restored.nativeSize = m_settings.value("native_size").toBool(); |
|
|
|
|
refreshModes(restored, sourceIndex < 0 && restored.source.kind == StreamVideoSource::Screen); |
|
|
|
|
int bitrate = m_settings.value("bitrate").toInt(); |
|
|
|
|
if (bitrate > 0 && m_bitrate->findData(bitrate) < 0) |
|
|
|
|
m_bitrate->addItem(QStringLiteral("%1 Mbit/s").arg(bitrate / 1000000.0, 0, 'f', 2), bitrate); |
|
|
|
|
select(m_bitrate, bitrate, "bitrate"); |
|
|
|
|
if (auto* button = m_rotation->button(m_settings.value("rotation").toInt())) button->setChecked(true); |
|
|
|
|
else DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop saved capture rotation invalid"); |
|
|
|
|
m_audio->setChecked(m_settings.value("audio").toBool(true)); |
|
|
|
|
select(m_microphone, m_settings.value("microphone").toString(), "microphone"); |
|
|
|
|
m_updating = false; |
|
|
|
|
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop capture settings restored: %s", QJsonDocument(m_settings).toJson(QJsonDocument::Compact).constData()); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
void StreamPublishWindow::saveSettings() { |
|
|
|
|
if (m_updating) return; |
|
|
|
|
auto config = selection(); auto saved = m_settings; |
|
|
|
|
if (m_source->currentIndex() >= 0) { |
|
|
|
|
saved["source_kind"] = config.source.kind; |
|
|
|
|
saved["source_id"] = QString::fromLatin1(config.source.id.toBase64()); |
|
|
|
|
saved["source_name"] = config.source.kind == StreamVideoSource::Window ? config.source.name : QString(); |
|
|
|
|
} |
|
|
|
|
if (m_mode->currentIndex() >= 0) { |
|
|
|
|
saved["width"] = config.video.width; saved["height"] = config.video.height; saved["fps"] = config.video.fps; |
|
|
|
|
saved["native_size"] = config.nativeSize; |
|
|
|
|
} |
|
|
|
|
if (m_codec->currentIndex() >= 0) saved["codec"] = config.video.codec; |
|
|
|
|
saved["bitrate"] = m_bitrate->currentData().toInt(); saved["rotation"] = config.video.rotation; |
|
|
|
|
saved["audio"] = config.audio; saved["microphone"] = config.microphone; |
|
|
|
|
if (saved == m_settings) return; |
|
|
|
|
m_settings = saved; |
|
|
|
|
auto json = QJsonDocument(saved).toJson(QJsonDocument::Compact); |
|
|
|
|
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop capture settings selected: %s", json.constData()); |
|
|
|
|
emit settingsChanged(QString::fromUtf8(json)); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
void StreamPublishWindow::refreshSources() { |
|
|
|
|
m_updating = true; auto config = m_engine->active() ? m_engine->config() : selection(); |
|
|
|
|
QScopedValueRollback<bool> updating(m_updating, true); |
|
|
|
|
auto config = m_engine->active() ? m_engine->config() : selection(); |
|
|
|
|
m_sources = StreamPublishEngine::sources(); m_source->clear(); |
|
|
|
|
int selected = 0; |
|
|
|
|
int selected = -1; |
|
|
|
|
for (int i = 0; i < m_sources.size(); i++) { |
|
|
|
|
const auto& source = m_sources[i]; |
|
|
|
|
auto& source = m_sources[i]; |
|
|
|
|
if (source.kind == config.source.kind && source.id == config.source.id && source.window == config.source.window) { |
|
|
|
|
selected = i; |
|
|
|
|
if (m_engine->active() && !config.source.size.isEmpty()) source.size = config.source.size; |
|
|
|
|
} |
|
|
|
|
QString kind = source.kind == StreamVideoSource::Camera ? text("Camera") : source.kind == StreamVideoSource::Screen ? text("Screen") : text("Window"); |
|
|
|
|
m_source->addItem(kind + ": " + source.name); |
|
|
|
|
if (source.kind == config.source.kind && source.id == config.source.id && source.window == config.source.window) selected = i; |
|
|
|
|
QString label = kind + ": " + source.name; |
|
|
|
|
if (source.kind == StreamVideoSource::Screen && !source.size.isEmpty()) |
|
|
|
|
label += QStringLiteral(" (%1 × %2)").arg(source.size.width()).arg(source.size.height()); |
|
|
|
|
m_source->addItem(label); |
|
|
|
|
} |
|
|
|
|
m_source->setCurrentIndex(m_sources.isEmpty() ? -1 : selected); m_updating = false; |
|
|
|
|
bool changed = selected < 0; |
|
|
|
|
if (changed && !m_engine->active() && !m_sources.isEmpty()) selected = 0; |
|
|
|
|
m_source->setCurrentIndex(selected); |
|
|
|
|
refreshModes(config, changed && selected >= 0 && m_sources[selected].kind == StreamVideoSource::Screen); |
|
|
|
|
synchronize(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* Перестраиваем весь список под блокировкой, чтобы в engine не ушёл промежуточный режим другого источника. */ |
|
|
|
|
void StreamPublishWindow::refreshModes(const StreamPublishConfig& previous, bool maximum) { |
|
|
|
|
QScopedValueRollback<bool> updating(m_updating, true); |
|
|
|
|
m_mode->clear(); m_modes.clear(); |
|
|
|
|
int sourceIndex = m_source->currentIndex(); |
|
|
|
|
if (sourceIndex < 0 || sourceIndex >= m_sources.size()) return; |
|
|
|
|
const auto& source = m_sources[sourceIndex]; |
|
|
|
|
if (source.kind == StreamVideoSource::Camera) m_modes = source.modes; |
|
|
|
|
else if (source.kind == StreamVideoSource::Screen) m_modes = stream_video_screen_modes(source.size); |
|
|
|
|
else { |
|
|
|
|
for (QSize size : {QSize(1920, 1080), QSize(1280, 720), QSize(640, 360)}) |
|
|
|
|
for (int fps : {15, 30, 60}) m_modes.append({size, fps}); |
|
|
|
|
} |
|
|
|
|
for (const auto& mode : m_modes) { |
|
|
|
|
QString label = mode.size.isEmpty() ? text("Original resolution · %1 fps").arg(mode.fps) |
|
|
|
|
: QStringLiteral("%1 × %2 · %3 fps").arg(mode.size.width()).arg(mode.size.height()).arg(mode.fps); |
|
|
|
|
m_mode->addItem(label, QVariantList{mode.size.width(), mode.size.height(), mode.fps}); |
|
|
|
|
m_mode->setItemData(m_mode->count() - 1, source.kind == StreamVideoSource::Screen && mode.size == m_modes.front().size, Qt::UserRole + 1); |
|
|
|
|
} |
|
|
|
|
maximum |= source.kind == StreamVideoSource::Screen && previous.nativeSize; |
|
|
|
|
StreamVideoMode wanted{QSize(previous.video.width, previous.video.height), previous.video.fps}; |
|
|
|
|
int selected = stream_video_select_mode(m_modes, wanted, maximum); |
|
|
|
|
m_mode->setCurrentIndex(selected); |
|
|
|
|
if (selected < 0) { |
|
|
|
|
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "desktop source has no selectable modes source=%s", qPrintable(source.name)); |
|
|
|
|
m_mode->setToolTip(text("No supported video modes")); return; |
|
|
|
|
} |
|
|
|
|
const auto& mode = m_modes[selected]; |
|
|
|
|
if (!maximum && wanted != mode) DEBUG_WARN(DEBUG_CATEGORY_VIDEO, |
|
|
|
|
"desktop video mode adjusted source=%s requested=%dx%d/%d selected=%dx%d/%d", |
|
|
|
|
qPrintable(source.name), wanted.size.width(), wanted.size.height(), wanted.fps, mode.size.width(), mode.size.height(), mode.fps); |
|
|
|
|
m_mode->setToolTip(source.kind == StreamVideoSource::Camera ? text("Frame rate is limited by the camera and the selected stream mode") : QString()); |
|
|
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "desktop video modes source=%s count=%lld selected=%dx%d/%d native=%d", |
|
|
|
|
qPrintable(source.name), (long long)m_modes.size(), mode.size.width(), mode.size.height(), mode.fps, |
|
|
|
|
m_mode->currentData(Qt::UserRole + 1).toBool()); |
|
|
|
|
} |
|
|
|
|
void StreamPublishWindow::refreshCodecs() { |
|
|
|
|
m_updating = true; int previous = m_codec->currentData().toInt(); m_codec->clear(); |
|
|
|
|
m_updating = true; |
|
|
|
|
int previous = m_codec->currentIndex() >= 0 ? m_codec->currentData().toInt() : m_settings.value("codec").toInt(); |
|
|
|
|
m_codec->clear(); |
|
|
|
|
for (int codec : m_engine->codecs()) m_codec->addItem(LiveVideoEncoder::codecName(codec), codec); |
|
|
|
|
int index = m_codec->findData(previous); if (index >= 0) m_codec->setCurrentIndex(index); |
|
|
|
|
else if (previous && m_codec->count()) DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop saved codec=%d unavailable; using codec=%d", |
|
|
|
|
previous, m_codec->currentData().toInt()); |
|
|
|
|
m_updating = false; synchronize(); |
|
|
|
|
} |
|
|
|
|
StreamPublishConfig StreamPublishWindow::selection() const { |
|
|
|
|
StreamPublishConfig config; |
|
|
|
|
if (m_source->currentIndex() >= 0 && m_source->currentIndex() < m_sources.size()) config.source = m_sources[m_source->currentIndex()]; |
|
|
|
|
auto mode = m_mode->currentData().toList(); |
|
|
|
|
if (mode.size() == 3) { config.video.width = mode[0].toInt(); config.video.height = mode[1].toInt(); config.video.fps = mode[2].toInt(); } |
|
|
|
|
if (mode.size() == 3) { |
|
|
|
|
if (mode[0].toInt() > 0 && mode[1].toInt() > 0) { config.video.width = mode[0].toInt(); config.video.height = mode[1].toInt(); } |
|
|
|
|
config.video.fps = mode[2].toInt(); |
|
|
|
|
} |
|
|
|
|
config.nativeSize = config.source.kind == StreamVideoSource::Screen && m_mode->currentData(Qt::UserRole + 1).toBool(); |
|
|
|
|
config.video.codec = m_codec->currentData().toInt(); config.video.rotation = m_rotation->checkedId(); |
|
|
|
|
config.video.bitrate = m_bitrate->currentData().toInt(); |
|
|
|
|
if (!config.video.bitrate) config.video.bitrate = std::clamp(config.video.width * config.video.height * config.video.fps / 11, 250000, 16000000); |
|
|
|
|
config.automaticBitrate = !config.video.bitrate; |
|
|
|
|
if (config.automaticBitrate) config.video.bitrate = std::clamp(config.video.width * config.video.height * config.video.fps / 11, 250000, 16000000); |
|
|
|
|
config.microphone = m_microphone->currentData().toString(); config.audio = m_audio->isChecked(); return config; |
|
|
|
|
} |
|
|
|
|
void StreamPublishWindow::apply() { if (!m_updating && m_engine->active()) m_engine->reconfigure(selection()); } |
|
|
|
|
void StreamPublishWindow::apply() { |
|
|
|
|
if (m_updating || m_mode->currentIndex() < 0) return; |
|
|
|
|
if (m_engine->active()) m_engine->reconfigure(selection()); |
|
|
|
|
saveSettings(); |
|
|
|
|
} |
|
|
|
|
void StreamPublishWindow::synchronize() { |
|
|
|
|
m_updating = true; bool active = m_engine->active(); |
|
|
|
|
QScopedValueRollback<bool> updating(m_updating, true); bool active = m_engine->active(); |
|
|
|
|
m_start->setText(active ? text("Stop stream") : text("Start stream")); |
|
|
|
|
m_start->setEnabled((active && m_engine->running()) || (!active && StreamPublishEngine::supported() && m_source->count() && m_codec->count())); |
|
|
|
|
m_start->setEnabled((active && m_engine->running()) || (!active && StreamPublishEngine::supported() |
|
|
|
|
&& m_source->currentIndex() >= 0 && m_mode->currentIndex() >= 0 && m_codec->count())); |
|
|
|
|
for (auto* box : {m_source, m_mode, m_codec, m_bitrate}) box->setEnabled(!active || m_engine->running()); |
|
|
|
|
m_audio->setEnabled(!active); m_microphone->setEnabled(!active); m_mute->setEnabled(m_engine->running() && m_engine->config().audio); |
|
|
|
|
{ QSignalBlocker blocked(m_mute); m_mute->setChecked(m_engine->muted()); } |
|
|
|
|
if (active) { |
|
|
|
|
auto config = m_engine->config(); |
|
|
|
|
for (int i = 0; i < m_sources.size(); i++) if (m_sources[i].kind == config.source.kind |
|
|
|
|
&& m_sources[i].id == config.source.id && m_sources[i].window == config.source.window) m_source->setCurrentIndex(i); |
|
|
|
|
for (int i = 0; i < m_mode->count(); i++) if (m_mode->itemData(i).toList() == QVariantList{config.video.width, config.video.height, config.video.fps}) m_mode->setCurrentIndex(i); |
|
|
|
|
&& m_sources[i].id == config.source.id && m_sources[i].window == config.source.window) { |
|
|
|
|
m_source->setCurrentIndex(i); if (!config.source.size.isEmpty()) m_sources[i].size = config.source.size; |
|
|
|
|
} |
|
|
|
|
refreshModes(config); |
|
|
|
|
m_codec->setCurrentIndex(m_codec->findData(config.video.codec)); |
|
|
|
|
int automatic = std::clamp(config.video.width * config.video.height * config.video.fps / 11, 250000, 16000000); |
|
|
|
|
int bitrateIndex = config.video.bitrate == automatic && m_bitrate->currentData().toInt() == 0 ? 0 : m_bitrate->findData(config.video.bitrate); |
|
|
|
|
int bitrateIndex = config.automaticBitrate ? 0 : m_bitrate->findData(config.video.bitrate); |
|
|
|
|
if (bitrateIndex < 0) { |
|
|
|
|
m_bitrate->addItem(QStringLiteral("%1 Mbit/s").arg(config.video.bitrate / 1000000.0, 0, 'f', 2), config.video.bitrate); |
|
|
|
|
bitrateIndex = m_bitrate->count() - 1; |
|
|
|
|
@ -159,5 +294,4 @@ void StreamPublishWindow::synchronize() {
|
|
|
|
|
if (auto* button = m_rotation->button(config.video.rotation)) button->setChecked(true); |
|
|
|
|
m_audio->setChecked(config.audio); m_microphone->setCurrentIndex(m_microphone->findData(config.microphone)); |
|
|
|
|
} |
|
|
|
|
m_updating = false; |
|
|
|
|
} |
|
|
|
|
|