From bad9ee3a0c7b016b8f9f00db0c968aa8e17b83ed Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 20 Oct 2026 16:39:43 +0200 Subject: [PATCH] Add an automatically updated desktop grid of active streams --- tools/chatgui/CMakeLists.txt | 3 +- tools/chatgui/src/streamgrid_window.cpp | 136 ++++++++++++++++++++++++ tools/chatgui/src/streamgrid_window.h | 49 +++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 tools/chatgui/src/streamgrid_window.cpp create mode 100644 tools/chatgui/src/streamgrid_window.h diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 9dbab5a7..88189c55 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -312,6 +312,7 @@ add_executable(vibechat WIN32 src/videoplayer_engine.cpp src/videoplayer_window.cpp src/streamplayer_window.cpp + src/streamgrid_window.cpp src/imageviewer_window.cpp src/member_files/browser.cpp src/member_files/batch.cpp @@ -669,7 +670,7 @@ else() endif() add_executable(test_ptt_key_chord tests/test_ptt_key_chord.cpp) -add_executable(test_stream_view tests/test_stream_view.cpp src/streamplayer_window.cpp transport/stream_bridge.cpp +add_executable(test_stream_view tests/test_stream_view.cpp src/streamplayer_window.cpp src/streamgrid_window.cpp transport/stream_bridge.cpp src/videoplayer_window.cpp src/videoplayer_engine.cpp src/sound_manager.cpp src/voice_audio_io.cpp src/audio_device.cpp transport/miniaudio_impl.c transport/audio_diagnostics.cpp) target_include_directories(test_stream_view PRIVATE ${UTUN_INCLUDE_DIRS} ${FFMPEG_INCLUDE_DIRS}) diff --git a/tools/chatgui/src/streamgrid_window.cpp b/tools/chatgui/src/streamgrid_window.cpp new file mode 100644 index 00000000..3e542e02 --- /dev/null +++ b/tools/chatgui/src/streamgrid_window.cpp @@ -0,0 +1,136 @@ +#include "streamgrid_window.h" +#include "streamplayer_window.h" +#include "stream_sources.h" +#include "../transport/stream_bridge.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +std::atomic nextOwner{0}; +QString text(const char* value) { return QCoreApplication::translate("ChatGui", value); } +} + +StreamGridWindow::StreamGridWindow(uint64_t group, QWidget* parent) + : QWidget(parent), m_group(group), m_owner(++nextOwner) { + setWindowTitle(text("All active streams")); resize(1100, 750); m_clock.start(); + 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); + connect(fullscreen, &QPushButton::clicked, this, [this] { if (isFullScreen()) showNormal(); else showFullScreen(); }); + connect(close, &QPushButton::clicked, this, &QWidget::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); + m_watching = gui_stream_watch(m_group, m_owner); + connect(&m_timer, &QTimer::timeout, this, &StreamGridWindow::tick); m_timer.start(250); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop multiview opened group=%llx owner=%llu", + (unsigned long long)m_group, (unsigned long long)m_owner); +} + +StreamGridWindow::~StreamGridWindow() { stop(); } + +/* Сверяем идентичности, не трогая остальные players при добавлении, удалении или новой эпохе. */ +void StreamGridWindow::onSources(const uint8_t* data, int length) { + if (m_closed) return; + StreamSourceSnapshot snapshot; + if (!parseStreamSources(data, length, snapshot) || snapshot.group != m_group) return; + if (!snapshot.valid) { + m_status->setText(text("Stream list temporarily unavailable")); + DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop multiview preserves tiles: catalogue unavailable group=%llx", + (unsigned long long)m_group); return; + } + bool changed = false; + for (auto& tile : m_tiles) { + 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(); + 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); + 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); + m_tiles.push_back(std::move(tile)); changed = true; + } + m_status->setText(text("Active streams: %1").arg(snapshot.sources.size())); + if (changed) arrange(); + tick(); +} + +/* Меняется только геометрия QWidget: decoder, media handle и audio clock остаются прежними. */ +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())))); + 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(); + } + 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); +} + +/* Сбой одного decoder повторяется с backoff, окончание и исчезновение не сбрасывают соседние плитки. */ +void StreamGridWindow::tick() { + if (m_closed) return; + if (!m_watching) m_watching = gui_stream_watch(m_group, m_owner); + bool changed = false; + 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()) + 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; + } + } else if (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); + it->retryAt = 0; changed = true; + } + } + ++it; + } + if (changed) arrange(); +} + +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; + m_tiles.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 new file mode 100644 index 00000000..cc8bb5dd --- /dev/null +++ b/tools/chatgui/src/streamgrid_window.h @@ -0,0 +1,49 @@ +/* Окно всех активных трансляций одного канала. Собственный catalogue owner не зависит от выбранного чата. + * Плитка сохраняет player/decoder/часы при перекладке; новая эпоха заменяет только соответствующий player. + * Пропавшему источнику даётся дочитать FIN, затем плитка удаляется. Пустое окно ждёт новые публикации. */ +#pragma once +#include +#include +#include +#include +#include + +class StreamPlayerWindow; +class QGridLayout; +class QLabel; +class QCloseEvent; +class QKeyEvent; + +class StreamGridWindow : public QWidget { + Q_OBJECT +public: + explicit StreamGridWindow(uint64_t group, QWidget* parent = nullptr); + ~StreamGridWindow() override; + uint64_t group() const { return m_group; } + void onSources(const uint8_t* data, int length); + int tileCount() const { return static_cast(m_tiles.size()); } + int gridSide() const { return m_side; } +protected: + void closeEvent(QCloseEvent* event) override; + void keyPressEvent(QKeyEvent* event) override; +private: + struct Tile { + uint64_t source, epoch; + QString name; + StreamPlayerWindow* player = nullptr; + qint64 removeAt = -1, retryAt = 0; + unsigned failures = 0; + }; + uint64_t m_group, m_owner; + std::vector m_tiles; + QGridLayout* m_grid = nullptr; + QLabel* m_status = nullptr; + QLabel* m_empty = nullptr; + QTimer m_timer; + QElapsedTimer m_clock; + int m_side = 0; + bool m_watching = false, m_closed = false; + void arrange(); + void tick(); + void stop(); +};