Add an automatically updated desktop grid of active streams
This commit is contained in:
1 parent
05deb15c91
commit
bad9ee3a0c
3 files changed
+187
-1
No files matched your search
@@ -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})
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "streamgrid_window.h"
|
||||
#include "streamplayer_window.h"
|
||||
#include "stream_sources.h"
|
||||
#include "../transport/stream_bridge.h"
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QCloseEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
std::atomic<uint64_t> 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<int>(std::ceil(std::sqrt(static_cast<double>(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<int>(i) / m_side, static_cast<int>(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);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Окно всех активных трансляций одного канала. Собственный catalogue owner не зависит от выбранного чата.
|
||||
* Плитка сохраняет player/decoder/часы при перекладке; новая эпоха заменяет только соответствующий player.
|
||||
* Пропавшему источнику даётся дочитать FIN, затем плитка удаляется. Пустое окно ждёт новые публикации. */
|
||||
#pragma once
|
||||
#include <QWidget>
|
||||
#include <QTimer>
|
||||
#include <QElapsedTimer>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
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<int>(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<Tile> 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();
|
||||
};
|
||||
Reference in new issue
Block a user