From 6dab5db56ab31e719ee4cab33614ad7215c730c0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 20 Oct 2026 20:32:52 +0200 Subject: [PATCH] Show silent local preview and focus remote desktop stream tiles --- tools/chatgui/src/streamgrid_window.cpp | 164 ++++++++++++++++++++---- tools/chatgui/src/streamgrid_window.h | 27 +++- 2 files changed, 164 insertions(+), 27 deletions(-) diff --git a/tools/chatgui/src/streamgrid_window.cpp b/tools/chatgui/src/streamgrid_window.cpp index 3e542e02..1c503860 100644 --- a/tools/chatgui/src/streamgrid_window.cpp +++ b/tools/chatgui/src/streamgrid_window.cpp @@ -1,5 +1,6 @@ #include "streamgrid_window.h" #include "streamplayer_window.h" +#include "videoplayer_window.h" #include "stream_sources.h" #include "../transport/stream_bridge.h" #include @@ -8,7 +9,14 @@ #include #include #include -#include +#include +#include +#include +#ifdef HAVE_STREAM_CAPTURE +#include +#include +#include +#endif #include #include #include @@ -24,9 +32,15 @@ StreamGridWindow::StreamGridWindow(uint64_t group, QWidget* parent) auto* layout = new QVBoxLayout(this); auto* controls = new QHBoxLayout; m_status = new QLabel(text("Connecting…"), this); controls->addWidget(m_status, 1); auto* fullscreen = new QPushButton(text("Fullscreen"), this); controls->addWidget(fullscreen); - auto* close = new QPushButton(text("Close player"), this); controls->addWidget(close); layout->addLayout(controls); + m_close = new QPushButton(text("Close player"), this); controls->addWidget(m_close); layout->addLayout(controls); connect(fullscreen, &QPushButton::clicked, this, [this] { if (isFullScreen()) showNormal(); else showFullScreen(); }); - connect(close, &QPushButton::clicked, this, &QWidget::close); + connect(m_close, &QPushButton::clicked, this, [this] { if (m_focused) restoreGrid(); else close(); }); + auto* escape = new QShortcut(QKeySequence(Qt::Key_Escape), this); escape->setContext(Qt::WidgetWithChildrenShortcut); + connect(escape, &QShortcut::activated, this, [this] { + if (m_focused) restoreGrid(); + else if (isFullScreen()) showNormal(); + else close(); + }); m_empty = new QLabel(text("Waiting for active streams…"), this); m_empty->setAlignment(Qt::AlignCenter); layout->addWidget(m_empty); m_grid = new QGridLayout; m_grid->setSpacing(4); layout->addLayout(m_grid, 1); @@ -38,6 +52,103 @@ StreamGridWindow::StreamGridWindow(uint64_t group, QWidget* parent) StreamGridWindow::~StreamGridWindow() { stop(); } +/* Self получает прямую поверхность издателя: ни worker, ни reader, ни вход микшера не создаются. */ +void StreamGridWindow::createTile(Tile& tile) { + if (tile.self) { + tile.widget = new QWidget(this); + tile.widget->setObjectName(QStringLiteral("streamPreview_%1").arg(tile.source)); + auto* layout = new QVBoxLayout(tile.widget); layout->setContentsMargins(3, 3, 3, 3); layout->setSpacing(2); + auto* name = new QLabel(tile.name, tile.widget); name->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + name->setToolTip(tile.name); layout->addWidget(name); + tile.status = new QLabel(text("Connecting…"), tile.widget); layout->addWidget(tile.status); +#ifdef HAVE_STREAM_CAPTURE + auto* preview = new QVideoWidget(tile.widget); preview->setAspectRatioMode(Qt::KeepAspectRatio); + tile.video = preview; +#else + tile.video = new QLabel(text("Video capture requires Qt Multimedia and FFmpeg"), tile.widget); +#endif + tile.video->setToolTip(text("Manage stream")); layout->addWidget(tile.video, 1); + updatePreview(tile); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview local preview group=%llx source=%llx epoch=%llx decoder=0 audio=0", + (unsigned long long)m_group, (unsigned long long)tile.source, (unsigned long long)tile.epoch); + } else { + tile.player = new StreamPlayerWindow(m_group, tile.source, tile.epoch, tile.name, this, true); + tile.widget = tile.player; tile.video = tile.player->findChild(); + } + if (!tile.video) { + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop multiview tile has no video source=%llx", (unsigned long long)tile.source); return; + } + tile.video->installEventFilter(this); + for (auto* child : tile.video->findChildren()) child->installEventFilter(this); +} + +/* QVariant хранит shared QVideoFrame; оба окна читают последний захваченный кадр без перекодирования. */ +void StreamGridWindow::setLocalPreview(uint64_t group, const QVariant& frame) { + if (m_closed || group != m_group) return; + m_preview = frame; + for (auto& tile : m_tiles) if (tile.self) updatePreview(tile); +} + +void StreamGridWindow::updatePreview(Tile& tile) { +#ifdef HAVE_STREAM_CAPTURE + auto frame = m_preview.value(); + static_cast(tile.video)->videoSink()->setVideoFrame(frame); + tile.status->setText(text(frame.isValid() ? "Live" : "Connecting…")); + if (frame.isValid() && !tile.previewPresented) + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview first local frame source=%llx size=%dx%d decoder=0 audio=0", + (unsigned long long)tile.source, frame.width(), frame.height()); + tile.previewPresented = frame.isValid(); +#else + Q_UNUSED(tile); +#endif +} + +/* Изменяется лишь видимость/геометрия: все удалённые players и их аудиочасы продолжают работать. */ +void StreamGridWindow::activateTile(uint64_t source) { + auto tile = std::find_if(m_tiles.begin(), m_tiles.end(), [source](const auto& tile) { return tile.source == source; }); + if (m_closed || tile == m_tiles.end()) { + DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop multiview click on removed source=%llx", (unsigned long long)source); return; + } + if (tile->self) { + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview opens publisher group=%llx source=%llx", + (unsigned long long)m_group, (unsigned long long)source); + emit publishRequested(m_group); + } else if (m_focused == source) restoreGrid(); + else { + if (!m_focused) m_gridWindowState = windowState(); + m_focused = source; arrange(); showFullScreen(); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview focus source=%llx players_preserved=%zu", + (unsigned long long)source, m_tiles.size()); + } +} + +void StreamGridWindow::restoreGrid() { + if (!m_focused) return; + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview returns to grid source=%llx players_preserved=%zu", + (unsigned long long)m_focused, m_tiles.size()); + m_focused = 0; setWindowState(m_gridWindowState); arrange(); +} + +/* Не перехватываем drag/колесо zoom. Активация после release даёт VideoRenderWidget завершить drag. */ +bool StreamGridWindow::eventFilter(QObject* watched, QEvent* event) { + if (event->type() == QEvent::MouseButtonPress) { + auto* mouse = static_cast(event); + if (mouse->button() == Qt::LeftButton) { m_pressed = watched; m_pressPosition = mouse->globalPos(); m_dragged = false; } + } else if (event->type() == QEvent::MouseMove && m_pressed == watched) { + auto* mouse = static_cast(event); + if ((mouse->globalPos() - m_pressPosition).manhattanLength() >= QApplication::startDragDistance()) m_dragged = true; + } else if (event->type() == QEvent::MouseButtonRelease && m_pressed == watched) { + auto* mouse = static_cast(event); m_pressed = nullptr; + if (mouse->button() == Qt::LeftButton && !m_dragged && + (mouse->globalPos() - m_pressPosition).manhattanLength() < QApplication::startDragDistance()) { + for (const auto& tile : m_tiles) if (tile.video == watched || tile.video->isAncestorOf(qobject_cast(watched))) { + QMetaObject::invokeMethod(this, [this, source = tile.source] { activateTile(source); }, Qt::QueuedConnection); break; + } + } + } + return QWidget::eventFilter(watched, event); +} + /* Сверяем идентичности, не трогая остальные players при добавлении, удалении или новой эпохе. */ void StreamGridWindow::onSources(const uint8_t* data, int length) { if (m_closed) return; @@ -53,23 +164,22 @@ void StreamGridWindow::onSources(const uint8_t* data, int length) { auto source = std::find_if(snapshot.sources.begin(), snapshot.sources.end(), [&](const auto& s) { return s.id == tile.source; }); if (source == snapshot.sources.end()) { if (tile.removeAt < 0) { - tile.removeAt = m_clock.elapsed() + tile.player->drainTimeoutMs(); + tile.removeAt = m_clock.elapsed() + (tile.self ? 0 : tile.player->drainTimeoutMs()); DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview drains source=%llx deadline_ms=%lld", (unsigned long long)tile.source, (long long)tile.removeAt); } continue; } tile.removeAt = -1; - if (tile.epoch == source->epoch) continue; - delete tile.player; - tile.epoch = source->epoch; tile.name = source->name; tile.failures = 0; tile.retryAt = 0; - tile.player = new StreamPlayerWindow(m_group, tile.source, tile.epoch, tile.name, this, true); + if (tile.epoch == source->epoch && tile.self == source->self) continue; + if (m_focused == tile.source) restoreGrid(); + delete tile.widget; + tile = Tile{source->id, source->epoch, source->name, source->self}; createTile(tile); changed = true; } for (const auto& source : snapshot.sources) { if (std::any_of(m_tiles.begin(), m_tiles.end(), [&](const auto& tile) { return tile.source == source.id; })) continue; - Tile tile{source.id, source.epoch, source.name}; - tile.player = new StreamPlayerWindow(m_group, tile.source, tile.epoch, tile.name, this, true); + Tile tile{source.id, source.epoch, source.name, source.self}; createTile(tile); m_tiles.push_back(std::move(tile)); changed = true; } m_status->setText(text("Active streams: %1").arg(snapshot.sources.size())); @@ -82,14 +192,22 @@ void StreamGridWindow::arrange() { while (auto* item = m_grid->takeAt(0)) delete item; for (int i = 0; i < m_side; ++i) { m_grid->setRowStretch(i, 0); m_grid->setColumnStretch(i, 0); } m_side = m_tiles.empty() ? 0 : static_cast(std::ceil(std::sqrt(static_cast(m_tiles.size())))); + if (m_focused && std::none_of(m_tiles.begin(), m_tiles.end(), [this](const auto& tile) { return tile.source == m_focused; })) { + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview focused source removed source=%llx", (unsigned long long)m_focused); + m_focused = 0; setWindowState(m_gridWindowState); + } for (int i = 0; i < m_side; ++i) { m_grid->setRowStretch(i, 1); m_grid->setColumnStretch(i, 1); } for (size_t i = 0; i < m_tiles.size(); ++i) { - m_grid->addWidget(m_tiles[i].player, static_cast(i) / m_side, static_cast(i) % m_side); - m_tiles[i].player->show(); + auto& tile = m_tiles[i]; + if (m_focused && m_focused != tile.source) { tile.widget->hide(); continue; } + if (m_focused) m_grid->addWidget(tile.widget, 0, 0, m_side, m_side); + else m_grid->addWidget(tile.widget, static_cast(i) / m_side, static_cast(i) % m_side); + tile.widget->show(); } m_empty->setVisible(m_tiles.empty()); - DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview layout group=%llx tiles=%zu grid=%dx%d", - (unsigned long long)m_group, m_tiles.size(), m_side, m_side); + m_close->setText(text(m_focused ? "All active streams" : "Close player")); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview layout group=%llx tiles=%zu grid=%dx%d focused=%llx", + (unsigned long long)m_group, m_tiles.size(), m_side, m_side, (unsigned long long)m_focused); } /* Сбой одного decoder повторяется с backoff, окончание и исчезновение не сбрасывают соседние плитки. */ @@ -100,20 +218,19 @@ void StreamGridWindow::tick() { qint64 now = m_clock.elapsed(); for (auto it = m_tiles.begin(); it != m_tiles.end();) { if (it->removeAt >= 0) { - if (it->player->finished() || now >= it->removeAt) { - if (!it->player->finished()) + if (it->self || it->player->finished() || now >= it->removeAt) { + if (!it->self && !it->player->finished()) DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop multiview source disappeared before drain source=%llx", (unsigned long long)it->source); - delete it->player; it = m_tiles.erase(it); changed = true; continue; + delete it->widget; it = m_tiles.erase(it); changed = true; continue; } - } else if (it->player->failed()) { + } else if (!it->self && it->player->failed()) { if (!it->retryAt) { it->retryAt = now + std::min(10000u, 500u << std::min(it->failures++, 5u)); DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop multiview retry source=%llx attempt=%u delay_ms=%lld", (unsigned long long)it->source, it->failures, (long long)(it->retryAt - now)); } else if (now >= it->retryAt) { - delete it->player; - it->player = new StreamPlayerWindow(m_group, it->source, it->epoch, it->name, this, true); + delete it->widget; createTile(*it); it->retryAt = 0; changed = true; } } @@ -125,12 +242,9 @@ void StreamGridWindow::tick() { void StreamGridWindow::stop() { if (m_closed) return; m_closed = true; m_timer.stop(); gui_stream_watch(0, m_owner); - for (auto& tile : m_tiles) delete tile.player; + for (auto& tile : m_tiles) delete tile.widget; m_tiles.clear(); + m_preview.clear(); DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview closed group=%llx", (unsigned long long)m_group); } void StreamGridWindow::closeEvent(QCloseEvent* event) { stop(); QWidget::closeEvent(event); } -void StreamGridWindow::keyPressEvent(QKeyEvent* event) { - if (event->key() == Qt::Key_Escape) { if (isFullScreen()) showNormal(); else close(); } - else QWidget::keyPressEvent(event); -} diff --git a/tools/chatgui/src/streamgrid_window.h b/tools/chatgui/src/streamgrid_window.h index cc8bb5dd..a879e549 100644 --- a/tools/chatgui/src/streamgrid_window.h +++ b/tools/chatgui/src/streamgrid_window.h @@ -1,18 +1,22 @@ /* Окно всех активных трансляций одного канала. Собственный catalogue owner не зависит от выбранного чата. * Плитка сохраняет player/decoder/часы при перекладке; новая эпоха заменяет только соответствующий player. + * Своя плитка получает setLocalPreview до кодирования, без reader/audio; клик запрашивает управление. + * Чужая плитка разворачивается без пересоздания players; Esc/повторный клик возвращает сетку. * Пропавшему источнику даётся дочитать FIN, затем плитка удаляется. Пустое окно ждёт новые публикации. */ #pragma once #include #include #include +#include +#include #include #include class StreamPlayerWindow; class QGridLayout; class QLabel; +class QPushButton; class QCloseEvent; -class QKeyEvent; class StreamGridWindow : public QWidget { Q_OBJECT @@ -21,16 +25,24 @@ public: ~StreamGridWindow() override; uint64_t group() const { return m_group; } void onSources(const uint8_t* data, int length); + void setLocalPreview(uint64_t group, const QVariant& frame); // Последний QVideoFrame локального publisher, GUI-поток. int tileCount() const { return static_cast(m_tiles.size()); } int gridSide() const { return m_side; } + uint64_t focusedSource() const { return m_focused; } +signals: + void publishRequested(quint64 group); // Открыть управление своей публикацией, сохранив это окно. protected: void closeEvent(QCloseEvent* event) override; - void keyPressEvent(QKeyEvent* event) override; + bool eventFilter(QObject* watched, QEvent* event) override; private: struct Tile { uint64_t source, epoch; QString name; + bool self = false, previewPresented = false; + QWidget* widget = nullptr; // Владеет player либо прямым preview, удаляется целиком. + QWidget* video = nullptr; // Поверхность изображения; сюда направлены клики. StreamPlayerWindow* player = nullptr; + QLabel* status = nullptr; // Статус прямого preview; у remote им владеет player. qint64 removeAt = -1, retryAt = 0; unsigned failures = 0; }; @@ -39,10 +51,21 @@ private: QGridLayout* m_grid = nullptr; QLabel* m_status = nullptr; QLabel* m_empty = nullptr; + QPushButton* m_close = nullptr; + QVariant m_preview; // Только последний кадр издателя; сеть/декодер для self не открываются. QTimer m_timer; QElapsedTimer m_clock; int m_side = 0; bool m_watching = false, m_closed = false; + uint64_t m_focused = 0; // Source развёрнутой чужой плитки; 0 — сетка. + Qt::WindowStates m_gridWindowState; + QPointer m_pressed; + QPoint m_pressPosition; + bool m_dragged = false; + void createTile(Tile& tile); + void updatePreview(Tile& tile); + void activateTile(uint64_t source); + void restoreGrid(); void arrange(); void tick(); void stop();