From 05deb15c912d02399b8d4218876956ea414004b3 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 20 Oct 2026 16:39:34 +0200 Subject: [PATCH] Embed stream players and verify grid and concurrent playback --- tools/chatgui/src/streamplayer_window.cpp | 25 +++++++++-- tools/chatgui/src/streamplayer_window.h | 6 ++- tools/chatgui/tests/test_stream_view.cpp | 51 +++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/tools/chatgui/src/streamplayer_window.cpp b/tools/chatgui/src/streamplayer_window.cpp index f654f808..6273718e 100644 --- a/tools/chatgui/src/streamplayer_window.cpp +++ b/tools/chatgui/src/streamplayer_window.cpp @@ -50,6 +50,7 @@ struct StreamPlayerWindow::Playback { } audioGap; uint64_t id = ++nextId, group = 0, source = 0, epoch = 0; std::atomic running{true}, audioAvailable{false}; + std::atomic complete{false}, failed{false}; std::atomic volume{1.0f}; float outputVolume = 0.0f; uint64_t audioGeneration = 0; @@ -86,11 +87,12 @@ struct StreamPlayerWindow::Playback { } }; -StreamPlayerWindow::StreamPlayerWindow(uint64_t group, uint64_t source, uint64_t epoch, const QString& name, QWidget* parent) +StreamPlayerWindow::StreamPlayerWindow(uint64_t group, uint64_t source, uint64_t epoch, const QString& name, QWidget* parent, bool embedded) : QWidget(parent), m_playback(std::make_unique()) { m_playback->group = group; m_playback->source = source; m_playback->epoch = epoch; setWindowTitle(text("Live stream: %1").arg(name)); resize(800, 500); - auto* layout = new QVBoxLayout(this); layout->addWidget(new QLabel(name, this)); + setObjectName(QStringLiteral("streamPlayer_%1").arg(source)); + auto* layout = new QVBoxLayout(this); auto* title = new QLabel(name, this); layout->addWidget(title); m_status = new QLabel(text("Connecting…"), this); layout->addWidget(m_status); m_render = new VideoRenderWidget(this, true); layout->addWidget(m_render, 1); auto* controls = new QHBoxLayout; @@ -98,6 +100,11 @@ StreamPlayerWindow::StreamPlayerWindow(uint64_t group, uint64_t source, uint64_t volume->setAccessibleName(text("Stream volume")); controls->addWidget(new QLabel(text("Volume"), this)); controls->addWidget(volume); auto* fullscreen = new QPushButton(text("Fullscreen"), this); controls->addWidget(fullscreen); auto* close = new QPushButton(text("Close player"), this); controls->addWidget(close); layout->addLayout(controls); + if (embedded) { + fullscreen->hide(); close->hide(); layout->setContentsMargins(3, 3, 3, 3); layout->setSpacing(2); + title->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); title->setToolTip(name); + m_status->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); setMinimumSize(0, 0); + } connect(volume, &QSlider::valueChanged, this, [this](int value) { m_playback->volume = value / 100.0f; }); connect(fullscreen, &QPushButton::clicked, this, [this] { if (isFullScreen()) showNormal(); else showFullScreen(); }); connect(close, &QPushButton::clicked, this, &QWidget::close); @@ -109,6 +116,13 @@ StreamPlayerWindow::StreamPlayerWindow(uint64_t group, uint64_t source, uint64_t StreamPlayerWindow::~StreamPlayerWindow() { stop(); } +bool StreamPlayerWindow::finished() const { return m_playback->complete; } +bool StreamPlayerWindow::failed() const { return m_playback->failed; } +int StreamPlayerWindow::drainTimeoutMs() const { + auto& p = *m_playback; std::lock_guard guard(p.lock); + return p.complete ? 0 : static_cast(std::clamp((p.ready - p.clock(nowUs())) / 1000 + 2000, 2000, 30000)); +} + /* Hardware callback заполняет только ближайшую порцию; source time тишины остаётся неподвижным. */ void StreamPlayerWindow::attachAudio() { if (!m_playback->running || m_audioAttached) return; @@ -173,8 +187,9 @@ void StreamPlayerWindow::tick() { (unsigned long long)p.id, image.width(), image.height(), (long long)image.bytesPerLine()); emit framePresented(); } - if (finished) { - m_status->setText(text("Stream ended")); + if (finished || p.failed) { + if (finished) m_status->setText(text("Stream ended")); + p.complete = true; SoundManager::instance()->detachStream(p.id); m_audioAttached = false; m_timer.stop(); } } @@ -200,6 +215,7 @@ void StreamPlayerWindow::decode() { auto& p = *m_playback; #ifndef HAVE_FFMPEG DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop live decoder unavailable: FFmpeg disabled"); + p.failed = p.complete = true; emit statusChanged(text("Live video playback requires FFmpeg")); #else AVCodecContext* decoder = nullptr; AVFrame* frame = av_frame_alloc(); AVPacket* packet = av_packet_alloc(); @@ -227,6 +243,7 @@ void StreamPlayerWindow::decode() { deadline = nowUs() + 10000000; emit statusChanged(text("Waiting for a key frame…")); }; auto fail = [&](const QString& reason) { + p.failed = p.complete = true; DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop viewer failed id=%llu: %s", (unsigned long long)p.id, qPrintable(reason)); emit statusChanged(reason); }; diff --git a/tools/chatgui/src/streamplayer_window.h b/tools/chatgui/src/streamplayer_window.h index 9efcd794..0e0cd689 100644 --- a/tools/chatgui/src/streamplayer_window.h +++ b/tools/chatgui/src/streamplayer_window.h @@ -16,9 +16,13 @@ class QKeyEvent; class StreamPlayerWindow : public QWidget { Q_OBJECT public: - explicit StreamPlayerWindow(uint64_t group, uint64_t source, uint64_t epoch, const QString& name, QWidget* parent = nullptr); + explicit StreamPlayerWindow(uint64_t group, uint64_t source, uint64_t epoch, const QString& name, + QWidget* parent = nullptr, bool embedded = false); ~StreamPlayerWindow() override; void stop(); + bool finished() const; + bool failed() const; + int drainTimeoutMs() const; // Остаток готового media + 2s для FIN; максимум 30s при исчезновении источника. signals: void statusChanged(const QString& status); void framePresented(); diff --git a/tools/chatgui/tests/test_stream_view.cpp b/tools/chatgui/tests/test_stream_view.cpp index 319d0b7a..ed099787 100644 --- a/tools/chatgui/tests/test_stream_view.cpp +++ b/tools/chatgui/tests/test_stream_view.cpp @@ -1,11 +1,14 @@ /* Настоящее ядро/actor, подписанный медиаблок и H.264 decoder: каталог до подписки, эпоха, показ и teardown. */ #include "../src/streamplayer_window.h" +#include "../src/streamgrid_window.h" #include "../src/videoplayer_window.h" #include "../src/sound_manager.h" #include "../transport/stream_bridge.h" #include #include #include +#include +#include #include #include #include @@ -386,6 +389,44 @@ private slots: QVERIFY(gui_stream_watch(0, 77)); QVERIFY(gui_stream_watch(group)); QTest::qWait(20); } + void gridKeepsPlayersAndWaitsForSources() { +#if defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG) + QVERIFY(SoundManager::instance()->init()); + StreamGridWindow grid(group); grid.show(); + auto snapshot = [&](int count, bool valid = true) { + QJsonArray sources; + for (int i = 0; i < count; ++i) sources.append(QJsonObject{ + {"id", QString::number(inst->node_id ^ static_cast(i))}, + {"epoch", QString::number(epoch, 16)}, {"name", QStringLiteral("Source %1").arg(i)}, {"self", i == 0 ? 1 : 0}}); + QByteArray bytes(9, '\0'); stream_write_be(reinterpret_cast(bytes.data()), group, 8); bytes[8] = valid; + bytes += QJsonDocument(sources).toJson(QJsonDocument::Compact); + grid.onSources(reinterpret_cast(bytes.constData()), bytes.size()); + }; + snapshot(1); + QPointer first = grid.findChild(QStringLiteral("streamPlayer_%1").arg(inst->node_id)); + QVERIFY(first); + for (int count : {2, 4, 5, 9, 10, 16}) { + snapshot(count); QCOMPARE(grid.tileCount(), count); + QCOMPARE(grid.gridSide(), static_cast(std::ceil(std::sqrt(count)))); + QVERIFY(first); QCOMPARE(grid.findChild(first->objectName()), first.data()); + } + snapshot(0, false); QCOMPARE(grid.tileCount(), 16); // Недоступность каталога не снимает readers. + snapshot(1); QTRY_COMPARE_WITH_TIMEOUT(grid.tileCount(), 1, 3000); + QCOMPARE(grid.gridSide(), 1); QVERIFY(first); + QCOMPARE(stream_publish_finish(publisher), STREAM_OK); stream_close(publisher); publisher = nullptr; + QCOMPARE(stream_publish_open(inst, group, nullptr, &publisher), STREAM_OK); + stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); epoch = info.descriptor.source_epoch; + snapshot(1); QVERIFY(first.isNull()); QCOMPARE(grid.tileCount(), 1); + snapshot(0); QTRY_COMPARE_WITH_TIMEOUT(grid.tileCount(), 0, 3000); + QVERIFY(grid.isVisible()); QCOMPARE(grid.gridSide(), 0); + snapshot(1); QCOMPARE(grid.tileCount(), 1); + grid.close(); + QTRY_VERIFY(stream_get_info(publisher, &info) == STREAM_OK && info.owners == 1); +#else + QSKIP("Grid lifetime fixture requires null audio and FFmpeg"); +#endif + } + void realH264RenderingAndClose() { #ifdef HAVE_FFMPEG /* Фикстура протокола выше содержит фиктивный H264; реальный decoder получает отдельную эпоху. */ @@ -459,6 +500,16 @@ private slots: #ifdef TEST_NULL_AUDIO QCOMPARE(silenceGaps.load(), gapsBefore); #endif + { + StreamPlayerWindow second(group, inst->node_id, epoch, "Second reader"); second.show(); + QSignalSpy secondShown(&second, &StreamPlayerWindow::framePresented); + int beforeSecond = shown.count(); + QTRY_VERIFY_WITH_TIMEOUT(secondShown.count() > 3 && shown.count() > beforeSecond + 3, 5000); + stream_info together{}; QCOMPARE(stream_get_info(publisher, &together), STREAM_OK); QCOMPARE(together.owners, 3u); + second.close(); + QTRY_VERIFY(stream_get_info(publisher, &together) == STREAM_OK && together.owners == 2); + beforeSecond = shown.count(); QTRY_VERIFY(shown.count() > beforeSecond + 3); + } int beforeSwitch = shown.count(); revision++; rotation = 270; QTRY_VERIFY_WITH_TIMEOUT(shown.count() > beforeSwitch + 2, 5000); stream_info switched{}; QCOMPARE(stream_get_info(publisher, &switched), STREAM_OK);