33 changed files with 1374 additions and 152 deletions
@ -0,0 +1,21 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import org.json.JSONArray |
||||
|
||||
/** Источник идентифицируется участником и эпохой; IDs сохраняют все 64 бита. */ |
||||
data class StreamSource(val id: Long, val name: String, val epoch: Long, val self: Boolean) { |
||||
val displayName: String get() = name.ifEmpty { id.toULong().toString() } |
||||
|
||||
companion object { |
||||
fun parse(json: String): List<StreamSource> { |
||||
val array = JSONArray(json) |
||||
return (0 until array.length()).map { i -> |
||||
val item = array.getJSONObject(i) |
||||
val id = item.getString("id").toULong().toLong() |
||||
val epoch = item.getString("epoch").toULong(16).toLong() |
||||
require(id != 0L && epoch != 0L) { "Zero stream identity" } |
||||
StreamSource(id, item.getString("name"), epoch, item.getInt("self") == 1) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,305 @@
|
||||
#include "streamplayer_window.h" |
||||
#include "videoplayer_window.h" |
||||
#include "sound_manager.h" |
||||
#include "../transport/stream_bridge.h" |
||||
#include <QVBoxLayout> |
||||
#include <QHBoxLayout> |
||||
#include <QLabel> |
||||
#include <QPushButton> |
||||
#include <QSlider> |
||||
#include <QCloseEvent> |
||||
#include <QKeyEvent> |
||||
#include <QTransform> |
||||
#include <atomic> |
||||
#include <thread> |
||||
#include <mutex> |
||||
#include <deque> |
||||
#include <array> |
||||
#include <chrono> |
||||
#include <algorithm> |
||||
#include <cstring> |
||||
|
||||
extern "C" { |
||||
#include "../../../lib/debug_config.h" |
||||
#include "../../../lib/opus_codec.h" |
||||
#include "../../../src/stream/stream.h" |
||||
#ifdef HAVE_FFMPEG |
||||
#include <libavcodec/avcodec.h> |
||||
#include <libswscale/swscale.h> |
||||
#endif |
||||
} |
||||
|
||||
namespace { |
||||
int64_t nowUs() { |
||||
return std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()).count(); |
||||
} |
||||
std::atomic<uint64_t> nextId{0}; |
||||
QString text(const char* value) { return QCoreApplication::translate("ChatGui", value); } |
||||
} |
||||
|
||||
struct StreamPlayerWindow::Playback { |
||||
struct Video { QImage image; int64_t pts; }; |
||||
struct Audio { std::array<int16_t, 960> pcm; int64_t pts; unsigned offset = 0; }; |
||||
uint64_t id = ++nextId, group = 0, source = 0, epoch = 0; |
||||
std::atomic<bool> running{true}, audioAvailable{false}; |
||||
std::atomic<float> volume{1.0f}; |
||||
std::thread worker; |
||||
std::mutex lock; |
||||
std::deque<Video> videos; |
||||
std::deque<Audio> audios; |
||||
int64_t prefillUntil = 0, videoBase = -1, videoAt = 0, audioPts = -1, audioAt = 0; |
||||
uint64_t shown = 0, underruns = 0; |
||||
bool ended = false; |
||||
|
||||
int64_t clock(int64_t now) const { |
||||
if (audioAvailable && audioPts >= 0 && now - audioAt < 200000) return audioPts + now - audioAt; |
||||
return videoBase >= 0 ? videoBase + now - videoAt : 0; |
||||
} |
||||
}; |
||||
|
||||
StreamPlayerWindow::StreamPlayerWindow(uint64_t group, uint64_t source, uint64_t epoch, const QString& name, QWidget* parent) |
||||
: QWidget(parent), m_playback(std::make_unique<Playback>()) { |
||||
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)); |
||||
m_status = new QLabel(text("Connecting…"), this); layout->addWidget(m_status); |
||||
m_render = new VideoRenderWidget(this); layout->addWidget(m_render, 1); |
||||
auto* controls = new QHBoxLayout; |
||||
auto* volume = new QSlider(Qt::Horizontal, this); volume->setRange(0, 100); volume->setValue(100); |
||||
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); |
||||
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); |
||||
connect(this, &StreamPlayerWindow::statusChanged, m_status, &QLabel::setText, Qt::QueuedConnection); |
||||
connect(SoundManager::instance(), &SoundManager::contextAboutToReset, this, [this] { |
||||
m_audio.close(); m_playback->audioAvailable = false; |
||||
std::lock_guard<std::mutex> guard(m_playback->lock); |
||||
m_playback->audios.clear(); m_playback->audioPts = -1; |
||||
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop stream audio context reset id=%llu", (unsigned long long)m_playback->id); |
||||
}); |
||||
connect(SoundManager::instance(), &SoundManager::contextRestored, this, &StreamPlayerWindow::openAudio); |
||||
connect(SoundManager::instance(), &SoundManager::devicesChanged, this, [this] { m_audio.close(); openAudio(); }); |
||||
openAudio(); |
||||
connect(&m_timer, &QTimer::timeout, this, &StreamPlayerWindow::tick); m_timer.start(15); |
||||
m_playback->worker = std::thread(&StreamPlayerWindow::decode, this); |
||||
} |
||||
|
||||
StreamPlayerWindow::~StreamPlayerWindow() { stop(); } |
||||
|
||||
/* Output принадлежит GUI и закрывается до shutdown общего miniaudio-контекста. */ |
||||
void StreamPlayerWindow::openAudio() { |
||||
if (!m_playback->running || m_audio.initialized()) return; |
||||
ma_context* context = SoundManager::instance()->context(); |
||||
if (!context) { DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop stream audio unavailable: no context"); return; } |
||||
auto config = ma_device_config_init(ma_device_type_playback); |
||||
config.sampleRate = 48000; config.playback.channels = 1; config.playback.format = ma_format_s16; |
||||
ma_device_id device; |
||||
if (SoundManager::deviceId(SoundManager::instance()->playbackDevice(), &device)) config.playback.pDeviceID = &device; |
||||
bool initialized = m_audio.init(context, config, "stream", m_playback->id, [this](const void*, void* output, unsigned frames) { |
||||
auto* pcm = static_cast<int16_t*>(output); std::fill(pcm, pcm + frames, 0); |
||||
auto& p = *m_playback; std::lock_guard<std::mutex> guard(p.lock); |
||||
int64_t now = nowUs(); |
||||
if (!p.prefillUntil || now < p.prefillUntil) return; |
||||
unsigned written = 0; float volume = p.volume.load(); int64_t firstPts = -1; |
||||
while (written < frames && !p.audios.empty()) { |
||||
auto& packet = p.audios.front(); |
||||
if (firstPts < 0) firstPts = packet.pts + packet.offset * 1000000LL / 48000 - written * 1000000LL / 48000; |
||||
unsigned count = std::min(frames - written, 960 - packet.offset); |
||||
for (unsigned i = 0; i < count; i++) pcm[written + i] = static_cast<int16_t>(packet.pcm[packet.offset + i] * volume); |
||||
written += count; packet.offset += count; |
||||
if (packet.offset == 960) p.audios.pop_front(); |
||||
} |
||||
if (firstPts >= 0) { p.audioPts = firstPts; p.audioAt = m_audio.callbackTimeUs(); } |
||||
else if (p.audioPts >= 0) { |
||||
p.audioPts += m_audio.callbackTimeUs() - p.audioAt; p.audioAt = m_audio.callbackTimeUs(); |
||||
} |
||||
if (written < frames && p.audioPts >= 0 && !p.ended) p.underruns++; |
||||
}); |
||||
if (!initialized || !m_audio.start()) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop stream audio device failed id=%llu", (unsigned long long)m_playback->id); |
||||
m_audio.close(); m_playback->audioAvailable = false; return; |
||||
} |
||||
m_playback->audioAvailable = true; |
||||
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop stream audio output ready id=%llu", (unsigned long long)m_playback->id); |
||||
} |
||||
|
||||
/* GUI забирает последний готовый кадр по аппаратным часам, не накапливая Qt-сигналы с QImage. */ |
||||
void StreamPlayerWindow::tick() { |
||||
auto& p = *m_playback; |
||||
if (m_audio.needsRecovery()) { m_audio.close(); p.audioAvailable = false; openAudio(); } |
||||
QImage image; bool finished = false; |
||||
{ |
||||
std::lock_guard<std::mutex> guard(p.lock); |
||||
int64_t now = nowUs(), clock = p.clock(now); |
||||
if (now >= p.prefillUntil) while (!p.videos.empty() && p.videos.front().pts <= clock) { |
||||
image = std::move(p.videos.front().image); p.videos.pop_front(); |
||||
} |
||||
finished = p.ended && p.videos.empty() && (p.audios.empty() || !p.audioAvailable); |
||||
} |
||||
if (!image.isNull()) { |
||||
m_render->setFrame(image); |
||||
m_status->setText(text("Live")); |
||||
if (!p.shown++) { |
||||
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop first frame id=%llu %dx%d", (unsigned long long)p.id, image.width(), image.height()); |
||||
} |
||||
emit framePresented(); |
||||
} |
||||
if (finished) { m_status->setText(text("Stream ended")); m_audio.close(); m_timer.stop(); } |
||||
} |
||||
|
||||
/* Закрытие может происходить до выполнения open actor; session_id отменяет именно эту попытку. */ |
||||
void StreamPlayerWindow::stop() { |
||||
if (!m_playback->running.exchange(false) && !m_playback->worker.joinable()) return; |
||||
m_timer.stop(); gui_stream_stop(m_playback->id); |
||||
if (m_playback->worker.joinable()) m_playback->worker.join(); |
||||
m_audio.close(); m_playback->audioAvailable = false; |
||||
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop stream player stopped id=%llu shown=%llu underruns=%llu", |
||||
(unsigned long long)m_playback->id, (unsigned long long)m_playback->shown, (unsigned long long)m_playback->underruns); |
||||
} |
||||
|
||||
void StreamPlayerWindow::closeEvent(QCloseEvent* event) { stop(); QWidget::closeEvent(event); } |
||||
void StreamPlayerWindow::keyPressEvent(QKeyEvent* event) { |
||||
if (event->key() == Qt::Key_Escape) { if (isFullScreen()) showNormal(); else close(); } |
||||
else QWidget::keyPressEvent(event); |
||||
} |
||||
|
||||
/* Worker владеет codec contexts. GAP/pressure сбрасывает refs и часы до следующего ключевого кадра. */ |
||||
void StreamPlayerWindow::decode() { |
||||
auto& p = *m_playback; |
||||
#ifndef HAVE_FFMPEG |
||||
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop live decoder unavailable: FFmpeg disabled"); |
||||
emit statusChanged(text("Live video playback requires FFmpeg")); |
||||
#else |
||||
AVCodecContext* decoder = nullptr; AVFrame* frame = av_frame_alloc(); AVPacket* packet = av_packet_alloc(); |
||||
SwsContext* converter = nullptr; |
||||
opus_codec_decoder_t* opus = nullptr; |
||||
bool waitKey = true, opened = false; |
||||
int64_t deadline = nowUs() + 10000000, reportAt = nowUs(), expectedAudio = -1; |
||||
uint64_t decoded = 0, discarded = 0; |
||||
auto reset = [&] { |
||||
avcodec_free_context(&decoder); |
||||
opus_codec_decoder_destroy(opus); opus = nullptr; expectedAudio = -1; waitKey = true; |
||||
std::lock_guard<std::mutex> guard(p.lock); |
||||
p.videos.clear(); p.audios.clear(); p.prefillUntil = 0; p.videoBase = p.audioPts = -1; |
||||
emit statusChanged(text("Waiting for a key frame…")); |
||||
}; |
||||
auto fail = [&](const QString& reason) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop viewer failed id=%llu: %s", (unsigned long long)p.id, qPrintable(reason)); |
||||
emit statusChanged(reason); |
||||
}; |
||||
if (!frame || !packet) fail(text("Stream decoder allocation failed")); |
||||
else if (!gui_stream_open(p.id, p.group, p.source, p.epoch)) fail(text("Could not connect to the stream")); |
||||
else while (p.running) { |
||||
StreamRecord record; |
||||
if (!gui_stream_pull(p.id, record)) { |
||||
if (!gui_stream_ready()) { fail(text("Chat service is stopped")); break; } |
||||
if ((!opened || waitKey) && nowUs() > deadline) { fail(text("Stream connection timed out")); break; } |
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5)); continue; |
||||
} |
||||
if (record.kind == StreamRecordKind::Opened) { opened = true; continue; } |
||||
if (record.kind == StreamRecordKind::Gap) { reset(); deadline = nowUs() + 10000000; continue; } |
||||
if (record.kind == StreamRecordKind::Closed) { |
||||
const char* reason = record.reason == STREAM_CLOSE_SOURCE_CHANGED ? "The stream restarted. Select a source again." |
||||
: record.reason == STREAM_CLOSE_MEMBER ? "The source is no longer a channel member" |
||||
: record.reason == STREAM_CLOSE_SERVICE ? "Chat service is stopped" |
||||
: record.reason == STREAM_CLOSE_GROUP ? "The channel is closed" : "Could not continue the stream"; |
||||
fail(text(reason)); break; |
||||
} |
||||
if (record.kind == StreamRecordKind::End) { std::lock_guard<std::mutex> guard(p.lock); p.ended = true; break; } |
||||
if (record.pts > INT64_MAX) { fail(text("Invalid stream timestamp")); break; } |
||||
if (record.kind == StreamRecordKind::Audio) { |
||||
if (!p.audioAvailable) continue; |
||||
if (!opus) opus = opus_codec_decoder_create(48000, 1); |
||||
if (!opus) { fail(text("Stream audio decoder failed")); break; } |
||||
if (expectedAudio >= 0 && (static_cast<int64_t>(record.pts) < expectedAudio - 10000 |
||||
|| static_cast<int64_t>(record.pts) > expectedAudio + 100000)) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop audio discontinuity id=%llu expected=%lld pts=%llu", |
||||
(unsigned long long)p.id, (long long)expectedAudio, (unsigned long long)record.pts); |
||||
reset(); deadline = nowUs() + 10000000; continue; |
||||
} |
||||
int missing = expectedAudio < 0 ? 0 : static_cast<int>((static_cast<int64_t>(record.pts) - expectedAudio + 10000) / 20000); |
||||
bool failed = false, pressure = false; |
||||
for (int i = 0; i <= missing; i++) { |
||||
Playback::Audio audio{}; audio.pts = i == missing ? static_cast<int64_t>(record.pts) : expectedAudio + i * 20000; |
||||
int count = opus_codec_decode(opus, i == missing ? reinterpret_cast<const uint8_t*>(record.data.constData()) : nullptr, |
||||
i == missing ? record.data.size() : 0, audio.pcm.data(), 960); |
||||
if (count != 960) { fail(text("Stream audio decoder failed")); failed = true; break; } |
||||
std::lock_guard<std::mutex> guard(p.lock); |
||||
if (!p.prefillUntil) p.prefillUntil = nowUs() + 150000; |
||||
if (p.audios.size() >= 25) { pressure = true; break; } |
||||
p.audios.push_back(std::move(audio)); |
||||
} |
||||
if (failed) break; |
||||
if (pressure) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop decoded audio pressure id=%llu; wait KEY", (unsigned long long)p.id); |
||||
reset(); deadline = nowUs() + 10000000; |
||||
} else expectedAudio = static_cast<int64_t>(record.pts) + 20000; |
||||
continue; |
||||
} |
||||
if (waitKey && !(record.flags & STREAM_MEDIA_KEY)) continue; |
||||
if (!decoder) { |
||||
const AVCodec* codec = avcodec_find_decoder(AV_CODEC_ID_H264); |
||||
decoder = codec ? avcodec_alloc_context3(codec) : nullptr; |
||||
if (decoder) { decoder->thread_count = 1; decoder->flags |= AV_CODEC_FLAG_LOW_DELAY; } |
||||
if (!decoder || avcodec_open2(decoder, codec, nullptr) < 0) { fail(text("Stream video decoder failed")); break; } |
||||
decoder->pkt_timebase = AVRational{1, 1000000}; |
||||
waitKey = false; |
||||
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop H264 decoder started id=%llu", (unsigned long long)p.id); |
||||
} |
||||
av_packet_unref(packet); |
||||
if (av_new_packet(packet, record.data.size()) < 0) { fail(text("Stream decoder allocation failed")); break; } |
||||
std::memcpy(packet->data, record.data.constData(), static_cast<size_t>(record.data.size())); |
||||
packet->pts = packet->dts = static_cast<int64_t>(record.pts); |
||||
int sent = avcodec_send_packet(decoder, packet); |
||||
if (sent < 0) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop H264 packet rejected result=%d id=%llu", sent, (unsigned long long)p.id); |
||||
reset(); deadline = nowUs() + 10000000; continue; |
||||
} |
||||
int received; |
||||
while ((received = avcodec_receive_frame(decoder, frame)) == 0) { |
||||
decoded++; |
||||
if (frame->width <= 0 || frame->height <= 0 || frame->width > 4096 || frame->height > 4096) { |
||||
fail(text("Invalid stream video dimensions")); p.running = false; break; |
||||
} |
||||
int64_t pts = frame->best_effort_timestamp == AV_NOPTS_VALUE ? static_cast<int64_t>(record.pts) : frame->best_effort_timestamp; |
||||
{ |
||||
std::lock_guard<std::mutex> guard(p.lock); |
||||
if (!p.prefillUntil) p.prefillUntil = nowUs() + 150000; |
||||
if (p.videoBase < 0) { p.videoBase = pts; p.videoAt = p.prefillUntil; } |
||||
if (pts < p.clock(nowUs()) - 100000) { discarded++; continue; } |
||||
} |
||||
converter = sws_getCachedContext(converter, frame->width, frame->height, static_cast<AVPixelFormat>(frame->format), |
||||
frame->width, frame->height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); |
||||
QImage image(frame->width, frame->height, QImage::Format_RGB888); |
||||
if (!converter || image.isNull()) { fail(text("Stream decoder allocation failed")); p.running = false; break; } |
||||
uint8_t* destination[] = {image.bits(), nullptr, nullptr, nullptr}; |
||||
int strides[] = {static_cast<int>(image.bytesPerLine()), 0, 0, 0}; |
||||
if (sws_scale(converter, frame->data, frame->linesize, 0, frame->height, destination, strides) != frame->height) { |
||||
fail(text("Stream video conversion failed")); p.running = false; break; |
||||
} |
||||
if (record.format.rotation) image = image.transformed(QTransform().rotate(record.format.rotation)); |
||||
std::lock_guard<std::mutex> guard(p.lock); |
||||
if (p.videos.size() == 6) { p.videos.pop_front(); discarded++; } |
||||
p.videos.push_back({std::move(image), pts}); |
||||
} |
||||
if (received != AVERROR(EAGAIN) && received != AVERROR_EOF && p.running) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop H264 decode rejected result=%d id=%llu", received, (unsigned long long)p.id); |
||||
reset(); deadline = nowUs() + 10000000; |
||||
} |
||||
if (nowUs() - reportAt >= 1000000) { |
||||
reportAt = nowUs(); std::lock_guard<std::mutex> guard(p.lock); |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, "desktop viewer id=%llu decoded=%llu discarded=%llu videoQ=%zu audioQ=%zu underruns=%llu", |
||||
(unsigned long long)p.id, (unsigned long long)decoded, (unsigned long long)discarded, |
||||
p.videos.size(), p.audios.size(), (unsigned long long)p.underruns); |
||||
} |
||||
} |
||||
avcodec_free_context(&decoder); av_frame_free(&frame); av_packet_free(&packet); |
||||
if (converter) sws_freeContext(converter); |
||||
opus_codec_decoder_destroy(opus); |
||||
#endif |
||||
gui_stream_stop(p.id); |
||||
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop decoder released id=%llu", (unsigned long long)p.id); |
||||
} |
||||
@ -0,0 +1,39 @@
|
||||
/* Просмотр одной live-трансляции: FFmpeg/Opus в worker, ограниченные очереди PCM/кадров.
|
||||
* GUI владеет output device и окном; audio callback публикует часы физического воспроизведения. |
||||
* Закрытие останавливает worker и снимает reader. Публикация и общий транспорт не закрываются. */ |
||||
#pragma once |
||||
|
||||
#include <QWidget> |
||||
#include <QTimer> |
||||
#include <memory> |
||||
#include <cstdint> |
||||
#include "audio_device.h" |
||||
|
||||
class VideoRenderWidget; |
||||
class QLabel; |
||||
class QCloseEvent; |
||||
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); |
||||
~StreamPlayerWindow() override; |
||||
void stop(); |
||||
signals: |
||||
void statusChanged(const QString& status); |
||||
void framePresented(); |
||||
protected: |
||||
void closeEvent(QCloseEvent* event) override; |
||||
void keyPressEvent(QKeyEvent* event) override; |
||||
private: |
||||
struct Playback; |
||||
std::unique_ptr<Playback> m_playback; |
||||
AudioDevice m_audio; |
||||
QTimer m_timer; |
||||
VideoRenderWidget* m_render = nullptr; |
||||
QLabel* m_status = nullptr; |
||||
void openAudio(); |
||||
void tick(); |
||||
void decode(); |
||||
}; |
||||
@ -0,0 +1,215 @@
|
||||
/* Настоящее ядро/actor, подписанный медиаблок и H.264 decoder: каталог до подписки, эпоха, показ и teardown. */ |
||||
#include "../src/streamplayer_window.h" |
||||
#include "../src/sound_manager.h" |
||||
#include "../transport/stream_bridge.h" |
||||
#include <QtTest> |
||||
#include <QJsonDocument> |
||||
#include <QJsonArray> |
||||
#include <QLabel> |
||||
#include <QSlider> |
||||
#include <atomic> |
||||
#include <cmath> |
||||
|
||||
extern "C" { |
||||
#include "utun_instance.h" |
||||
#include "chat/chat_core.h" |
||||
#include "chat/chat_event.h" |
||||
#include "stream/stream.h" |
||||
#include "stream/stream_member.h" |
||||
#include "debug_config.h" |
||||
#include "mem.h" |
||||
#include "sqlite3.h" |
||||
#include "opus_codec.h" |
||||
#ifdef HAVE_FFMPEG |
||||
#include <libavcodec/avcodec.h> |
||||
#endif |
||||
} |
||||
|
||||
static QList<QByteArray> snapshots; |
||||
#ifdef TEST_NULL_AUDIO |
||||
static std::atomic<ma_device_data_proc> originalAudioCallback{nullptr}; |
||||
static std::atomic<uint64_t> nonzeroSamples{0}; |
||||
extern "C" ma_result __real_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context*); |
||||
extern "C" ma_result __real_ma_device_init(ma_context*, const ma_device_config*, ma_device*); |
||||
extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context* context) { |
||||
ma_backend backend = ma_backend_null; return __real_ma_context_init(&backend, 1, nullptr, context); |
||||
} |
||||
static void observeAudio(ma_device* device, void* out, const void* in, ma_uint32 frames) { |
||||
originalAudioCallback.load()(device, out, in, frames); |
||||
const auto* pcm = static_cast<const int16_t*>(out); |
||||
for (unsigned i = 0; i < frames; i++) if (pcm[i]) nonzeroSamples.fetch_add(1); |
||||
} |
||||
extern "C" ma_result __wrap_ma_device_init(ma_context* context, const ma_device_config* config, ma_device* device) { |
||||
ma_device_config copy = *config; |
||||
if (copy.deviceType == ma_device_type_playback && copy.playback.format == ma_format_s16 && copy.playback.channels == 1) { |
||||
originalAudioCallback = copy.dataCallback; copy.dataCallback = observeAudio; |
||||
} |
||||
return __real_ma_device_init(context, ©, device); |
||||
} |
||||
#endif |
||||
static void sourceEvent(UTUN_INSTANCE*, int type, const uint8_t* data, int size) { |
||||
if (type == CHAT_EVT_STREAM_SOURCES) snapshots.push_back(QByteArray(reinterpret_cast<const char*>(data), size)); |
||||
} |
||||
|
||||
class StreamViewTest : public QObject { |
||||
Q_OBJECT |
||||
UASYNC* ua = nullptr; |
||||
UTUN_INSTANCE* inst = nullptr; |
||||
STREAM_HANDLE* publisher = nullptr; |
||||
uint64_t group = 0, epoch = 0, frameId = 0; |
||||
QTimer poll; |
||||
size_t allocations = 0; |
||||
|
||||
bool receive(uint64_t id, StreamRecord& record) { |
||||
QElapsedTimer elapsed; elapsed.start(); |
||||
while (elapsed.elapsed() < 1000) { |
||||
if (gui_stream_pull(id, record)) return true; |
||||
QTest::qWait(1); |
||||
} |
||||
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop test missing record id=%llu", (unsigned long long)id); return false; |
||||
} |
||||
|
||||
void publish(const QByteArray& video, const QByteArray& audio = {}) { |
||||
stream_media_block media{}; media.format = {160, 90, 90, STREAM_MEDIA_H264, STREAM_MEDIA_OPUS}; |
||||
media.first_frame_offset = 0; media.video_count = 1; |
||||
media.video[0] = {++frameId, frameId * 100000, static_cast<uint32_t>(video.size()), 0, |
||||
static_cast<uint32_t>(video.size()), STREAM_MEDIA_KEY, reinterpret_cast<const uint8_t*>(video.constData())}; |
||||
if (!audio.isEmpty()) { |
||||
media.audio_count = 1; |
||||
media.audio[0] = {frameId * 100000, static_cast<uint32_t>(audio.size()), 960, |
||||
reinterpret_cast<const uint8_t*>(audio.constData())}; |
||||
} |
||||
uint8_t wire[7080]; size_t length = 0; |
||||
QCOMPARE(stream_media_encode(&media, wire, sizeof(wire), &length), STREAM_OK); |
||||
QCOMPARE(stream_publish_block(publisher, wire, length), STREAM_OK); |
||||
} |
||||
|
||||
private slots: |
||||
void initTestCase() { |
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_INFO); |
||||
debug_set_category_level(DEBUG_CATEGORY_SYS, DEBUG_LEVEL_ERROR); |
||||
allocations = u_get_allocated_count(); ua = uasync_create(); QVERIFY(ua); |
||||
inst = utun_instance_create_from_str(ua, |
||||
"[global]\nauto_sockets=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n"); |
||||
QVERIFY(inst); QCOMPARE(utun_core_start(inst), 0); QCOMPARE(chat_service_start(inst), 0); |
||||
chat_core_create_channel_auto(inst, "Stream test"); |
||||
sqlite3_stmt* statement = nullptr; |
||||
QCOMPARE(sqlite3_prepare_v2(chat_core_get_db(inst), "SELECT channel_id FROM channels LIMIT 1", -1, &statement, nullptr), SQLITE_OK); |
||||
QCOMPARE(sqlite3_step(statement), SQLITE_ROW); |
||||
group = QString::fromUtf8(reinterpret_cast<const char*>(sqlite3_column_text(statement, 0))).toULongLong(); |
||||
sqlite3_finalize(statement); QVERIFY(group); |
||||
chat_event_set_handler(inst, sourceEvent); QVERIFY(gui_stream_watch(group)); |
||||
QVERIFY(gui_stream_attach(inst)); uasync_mark_running(ua); |
||||
connect(&poll, &QTimer::timeout, this, [this] { uasync_poll(ua, 0); }); poll.start(1); |
||||
} |
||||
|
||||
void catalogueWithoutViewer() { |
||||
QTRY_COMPARE(snapshots.size(), 1); |
||||
QCOMPARE(snapshots.last().mid(9), QByteArray("[]")); QVERIFY(!stream_service_needs_activity(inst)); |
||||
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; |
||||
QTRY_COMPARE(snapshots.size(), 2); |
||||
auto sources = QJsonDocument::fromJson(snapshots.last().mid(9)).array(); QCOMPARE(sources.size(), 1); |
||||
QCOMPARE(stream_read_be(reinterpret_cast<const uint8_t*>(snapshots.last().constData()), 8), group); |
||||
QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); |
||||
} |
||||
|
||||
void staleSelectionAndCancellation() { |
||||
QVERIFY(gui_stream_open(100, group, inst->node_id, epoch ^ 1)); |
||||
StreamRecord event; |
||||
QVERIFY(receive(100, event)); QCOMPARE(event.kind, StreamRecordKind::Closed); |
||||
QCOMPARE(event.reason, STREAM_CLOSE_SOURCE_CHANGED); |
||||
QVERIFY(gui_stream_open(101, group, inst->node_id, epoch)); gui_stream_stop(101); QTest::qWait(20); |
||||
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); |
||||
} |
||||
|
||||
void mediaAndPressure() { |
||||
QVERIFY(gui_stream_open(102, group, inst->node_id, epoch)); StreamRecord event; |
||||
QVERIFY(receive(102, event)); QCOMPARE(event.kind, StreamRecordKind::Opened); |
||||
const QByteArray frame(1500, 'k'); const QByteArray audio("opus"); publish(frame, audio); |
||||
QVERIFY(receive(102, event)); QCOMPARE(event.kind, StreamRecordKind::Video); QCOMPARE(event.data, frame); |
||||
QCOMPARE(event.format.rotation, uint16_t(90)); |
||||
QVERIFY(receive(102, event)); QCOMPARE(event.kind, StreamRecordKind::Audio); QCOMPARE(event.data, audio); |
||||
for (int i = 0; i < 270; i++) { publish(frame); uasync_poll(ua, 0); } |
||||
bool gap = false; unsigned records = 0; |
||||
while (gui_stream_pull(102, event)) { gap |= event.kind == StreamRecordKind::Gap; records++; } |
||||
QVERIFY(gap); QVERIFY(records <= 256); |
||||
publish(frame); QVERIFY(receive(102, event)); QCOMPARE(event.kind, StreamRecordKind::Video); |
||||
gui_stream_stop(102); QTest::qWait(20); |
||||
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); |
||||
} |
||||
|
||||
void realH264RenderingAndClose() { |
||||
#ifdef HAVE_FFMPEG |
||||
const AVCodec* encoder = avcodec_find_encoder(AV_CODEC_ID_H264); |
||||
if (!encoder) QSKIP("H264 encoder is unavailable for the decoder fixture"); |
||||
AVCodecContext* context = avcodec_alloc_context3(encoder); QVERIFY(context); |
||||
context->width = 160; context->height = 90; context->pix_fmt = AV_PIX_FMT_YUV420P; |
||||
context->time_base = {1, 10}; context->framerate = {10, 1}; context->max_b_frames = 0; |
||||
AVDictionary* options = nullptr; av_dict_set(&options, "preset", "ultrafast", 0); av_dict_set(&options, "tune", "zerolatency", 0); |
||||
QCOMPARE(avcodec_open2(context, encoder, &options), 0); av_dict_free(&options); |
||||
AVFrame* frame = av_frame_alloc(); QVERIFY(frame); frame->width = 160; frame->height = 90; frame->format = context->pix_fmt; |
||||
QCOMPARE(av_frame_get_buffer(frame, 32), 0); |
||||
for (int plane = 0; plane < 3; plane++) for (int y = 0; y < (plane ? 45 : 90); y++) |
||||
memset(frame->data[plane] + y * frame->linesize[plane], plane ? 128 : 80, plane ? 80 : 160); |
||||
frame->pts = 0; QCOMPARE(avcodec_send_frame(context, frame), 0); |
||||
AVPacket* packet = av_packet_alloc(); QVERIFY(packet); QCOMPARE(avcodec_receive_packet(context, packet), 0); |
||||
QByteArray video(reinterpret_cast<const char*>(packet->data), packet->size); |
||||
av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&context); |
||||
QByteArray audio; |
||||
#ifdef TEST_NULL_AUDIO |
||||
QVERIFY(SoundManager::instance()->init()); |
||||
auto* opus = opus_codec_encoder_create(48000, 1); QVERIFY(opus); |
||||
int16_t pcm[960]; for (unsigned i = 0; i < 960; i++) pcm[i] = static_cast<int16_t>(6000 * std::sin(i * 0.05)); |
||||
uint8_t encoded[1275]; int length = opus_codec_encode(opus, pcm, 960, encoded, sizeof(encoded)); QVERIFY(length > 0); |
||||
audio = QByteArray(reinterpret_cast<const char*>(encoded), length); opus_codec_encoder_destroy(opus); |
||||
nonzeroSamples = 0; |
||||
#endif |
||||
StreamPlayerWindow window(group, inst->node_id, epoch, "Source"); |
||||
QSignalSpy shown(&window, &StreamPlayerWindow::framePresented); window.show(); |
||||
QTimer sender; connect(&sender, &QTimer::timeout, this, [this, video, audio] { publish(video, audio); }); sender.start(100); |
||||
QTRY_VERIFY_WITH_TIMEOUT(shown.count() > 0, 5000); |
||||
#ifdef TEST_NULL_AUDIO |
||||
QTRY_VERIFY_WITH_TIMEOUT(nonzeroSamples.load() > 0, 5000); |
||||
auto* volume = window.findChild<QSlider*>(); QVERIFY(volume); volume->setValue(0); |
||||
QTest::qWait(50); uint64_t played = nonzeroSamples.load(); QTest::qWait(100); QCOMPARE(nonzeroSamples.load(), played); |
||||
#endif |
||||
sender.stop(); window.close(); QTest::qWait(20); |
||||
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); |
||||
#else |
||||
QSKIP("FFmpeg is disabled"); |
||||
#endif |
||||
} |
||||
|
||||
void finishAndDetachPendingOwner() { |
||||
QCOMPARE(stream_publish_finish(publisher), STREAM_OK); QTRY_COMPARE(snapshots.last().mid(9), QByteArray("[]")); |
||||
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); QVERIFY(info.descriptor.source_epoch != epoch); |
||||
QVERIFY(gui_stream_open(103, group, inst->node_id, epoch)); StreamRecord event; |
||||
QVERIFY(receive(103, event)); QCOMPARE(event.reason, STREAM_CLOSE_SOURCE_CHANGED); |
||||
QVERIFY(gui_stream_open(104, group, inst->node_id, info.descriptor.source_epoch)); |
||||
gui_stream_detach(inst); QVERIFY(!gui_stream_ready()); QTest::qWait(20); |
||||
QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); QCOMPARE(info.owners, 1u); |
||||
} |
||||
|
||||
void channelClosureNotifiesViewer() { |
||||
QVERIFY(gui_stream_attach(inst)); QVERIFY(gui_stream_watch(group)); |
||||
stream_info info{}; QCOMPARE(stream_get_info(publisher, &info), STREAM_OK); |
||||
QVERIFY(gui_stream_open(105, group, inst->node_id, info.descriptor.source_epoch)); |
||||
StreamRecord event; QVERIFY(receive(105, event)); QCOMPARE(event.kind, StreamRecordKind::Opened); |
||||
QByteArray channel = QByteArray::number(group); chat_core_delete_channel(inst, channel.constData()); |
||||
QVERIFY(receive(105, event)); QCOMPARE(event.kind, StreamRecordKind::Closed); QCOMPARE(event.reason, STREAM_CLOSE_GROUP); |
||||
QTRY_COMPARE(snapshots.last().mid(9), QByteArray("[]")); |
||||
QVERIFY(!stream_handle_active(publisher)); QCOMPARE(stream_handle_reason(publisher), STREAM_CLOSE_GROUP); |
||||
} |
||||
|
||||
void cleanupTestCase() { |
||||
poll.stop(); gui_stream_detach(inst); stream_close(publisher); uasync_mark_stopped(ua); |
||||
SoundManager::instance()->shutdown(); |
||||
utun_instance_destroy(inst); uasync_destroy(ua, 0); |
||||
QCOMPARE(u_get_allocated_count(), allocations); |
||||
} |
||||
}; |
||||
QTEST_MAIN(StreamViewTest) |
||||
#include "test_stream_view.moc" |
||||
@ -0,0 +1,210 @@
|
||||
#include "stream_bridge.h" |
||||
#include <deque> |
||||
#include <mutex> |
||||
#include <algorithm> |
||||
|
||||
extern "C" { |
||||
#include "../../../lib/debug_config.h" |
||||
#include "../../../lib/mem.h" |
||||
#include "../../../lib/u_async.h" |
||||
#include "../../../src/utun_instance.h" |
||||
#include "../../../src/stream/stream.h" |
||||
#include "../../../src/stream/stream_member.h" |
||||
} |
||||
|
||||
namespace { |
||||
struct Viewer { uint64_t id = 0, group = 0, source = 0, epoch = 0; }; |
||||
struct Bridge { |
||||
std::mutex lock; |
||||
UTUN_INSTANCE* inst = nullptr; |
||||
posted_task* pending = nullptr; |
||||
uint64_t watch = 0; |
||||
bool watchPending = false, opening = false, stopping = false, overflow = false; |
||||
Viewer viewer, requested; |
||||
STREAM_HANDLE* handle = nullptr; |
||||
stream_media_receiver* receiver = nullptr; |
||||
std::deque<StreamRecord> output; |
||||
size_t bytes = 0; |
||||
} bridge; |
||||
|
||||
void process(void*); |
||||
|
||||
/* Вызывается под lock; резервирование делает отказ команды наблюдаемым до изменения состояния. */ |
||||
bool schedule() { |
||||
if (!bridge.inst) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop stream command rejected: core unavailable"); return false; |
||||
} |
||||
if (bridge.pending) return true; |
||||
auto* task = static_cast<posted_task*>(u_calloc(1, sizeof(posted_task))); |
||||
if (!task) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop stream task allocation failed"); return false; } |
||||
task->callback = process; bridge.pending = task; |
||||
uasync_post_reserved(bridge.inst->ua, task); |
||||
return true; |
||||
} |
||||
|
||||
/* Управляющие записи не теряются при media pressure; старые readers не накапливают очередь. */ |
||||
void output(StreamRecord record) { |
||||
if (bridge.output.size() >= 256 || bridge.bytes + static_cast<size_t>(record.data.size()) > 4u * 1024u * 1024u) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop stream RX pressure id=%llu records=%zu bytes=%zu; wait KEY", |
||||
(unsigned long long)record.id, bridge.output.size(), bridge.bytes); |
||||
bridge.output.clear(); bridge.bytes = 0; bridge.overflow = true; |
||||
bridge.output.push_back({record.id, 0, StreamRecordKind::Gap}); |
||||
if (record.kind == StreamRecordKind::Video || record.kind == StreamRecordKind::Audio) return; |
||||
} |
||||
bridge.bytes += static_cast<size_t>(record.data.size()); bridge.output.push_back(std::move(record)); |
||||
} |
||||
|
||||
/* Только uasync под lock; снимает собственное владение подпиской, не транспорт других владельцев. */ |
||||
void closeViewer(StreamRecordKind kind, int reason) { |
||||
if (bridge.viewer.id) output({bridge.viewer.id, 0, kind, {}, 0, reason}); |
||||
if (bridge.handle) stream_close(bridge.handle); |
||||
stream_media_receiver_destroy(bridge.receiver); |
||||
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop viewer released id=%llu reason=%d", |
||||
(unsigned long long)bridge.viewer.id, reason); |
||||
bridge.handle = nullptr; bridge.receiver = nullptr; bridge.viewer = {}; bridge.overflow = false; |
||||
} |
||||
|
||||
void mediaFrame(void*, const stream_media_format* format, const stream_media_video* video, const stream_media_audio* audio) { |
||||
if (bridge.overflow) return; |
||||
StreamRecord record; record.id = bridge.viewer.id; record.format = *format; |
||||
if (video) { |
||||
record.kind = StreamRecordKind::Video; record.pts = video->pts_us; record.flags = video->flags; |
||||
record.data = QByteArray(reinterpret_cast<const char*>(video->data), static_cast<int>(video->length)); |
||||
} else { |
||||
record.kind = StreamRecordKind::Audio; record.pts = audio->pts_us; |
||||
record.data = QByteArray(reinterpret_cast<const char*>(audio->data), static_cast<int>(audio->length)); |
||||
} |
||||
output(std::move(record)); |
||||
} |
||||
|
||||
/* Scratch receiver/stream не покидает callback; decoder получает собственные копии. */ |
||||
void mediaEvent(UTUN_INSTANCE* inst, const stream_event* event, void*) { |
||||
std::lock_guard<std::mutex> guard(bridge.lock); |
||||
if (inst != bridge.inst || !bridge.handle || event->descriptor.group_id != bridge.viewer.group |
||||
|| event->descriptor.source_node_id != bridge.viewer.source || event->descriptor.source_epoch != bridge.viewer.epoch) return; |
||||
if (event->type == STREAM_EVENT_DATA) { |
||||
int result = stream_media_receiver_feed(bridge.receiver, event->data, event->length); |
||||
if (result != STREAM_OK) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop viewer media rejected result=%d", result); |
||||
closeViewer(StreamRecordKind::Closed, STREAM_CLOSE_FAILED); |
||||
} else if (bridge.overflow) { |
||||
stream_media_receiver_gap(bridge.receiver); bridge.overflow = false; |
||||
} |
||||
} else if (event->type == STREAM_EVENT_GAP) { |
||||
stream_media_receiver_gap(bridge.receiver); output({bridge.viewer.id, 0, StreamRecordKind::Gap}); |
||||
} else if (event->type == STREAM_EVENT_END || !stream_handle_active(bridge.handle)) { |
||||
closeViewer(event->type == STREAM_EVENT_END ? StreamRecordKind::End : StreamRecordKind::Closed, event->reason); |
||||
} |
||||
} |
||||
|
||||
/* Сериализованный actor: старый owner закрывается перед открытием нового выбранного источника. */ |
||||
void process(void*) { |
||||
std::lock_guard<std::mutex> guard(bridge.lock); |
||||
bridge.pending = nullptr; |
||||
if (!bridge.inst) return; |
||||
if (bridge.watchPending) { |
||||
bridge.watchPending = false; |
||||
if (stream_watch_sources(bridge.inst, bridge.watch) != STREAM_OK) |
||||
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop stream source watch failed group=%llx", (unsigned long long)bridge.watch); |
||||
} |
||||
if (bridge.stopping || bridge.opening) { |
||||
if (bridge.viewer.id) closeViewer(StreamRecordKind::Closed, STREAM_CLOSE_STOPPED); |
||||
bridge.stopping = false; |
||||
} |
||||
if (!bridge.opening) return; |
||||
bridge.opening = false; bridge.viewer = bridge.requested; bridge.requested = {}; |
||||
stream_member_state member{}; |
||||
int reason = STREAM_CLOSE_FAILED; |
||||
int result = stream_member_load(bridge.inst, bridge.viewer.group, bridge.viewer.source, &member, nullptr); |
||||
if (result == STREAM_OK && (!member.published || member.source.source_epoch != bridge.viewer.epoch)) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop selected source changed source=%llx expected=%llx actual=%llx", |
||||
(unsigned long long)bridge.viewer.source, (unsigned long long)bridge.viewer.epoch, |
||||
(unsigned long long)(member.published ? member.source.source_epoch : 0)); |
||||
result = STREAM_ERR_CONFLICT; reason = STREAM_CLOSE_SOURCE_CHANGED; |
||||
} |
||||
if (result == STREAM_OK) result = stream_subscribe_open(bridge.inst, bridge.viewer.group, bridge.viewer.source, &bridge.handle); |
||||
stream_info info{}; |
||||
if (result == STREAM_OK) result = stream_get_info(bridge.handle, &info); |
||||
if (result == STREAM_OK && info.descriptor.source_epoch != bridge.viewer.epoch) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_STREAM, "desktop reader encountered stale local epoch"); |
||||
result = STREAM_ERR_CONFLICT; reason = STREAM_CLOSE_SOURCE_CHANGED; |
||||
} |
||||
if (result == STREAM_OK) result = stream_media_receiver_create(&bridge.receiver, mediaFrame, nullptr); |
||||
if (result != STREAM_OK) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop viewer open failed id=%llu group=%llx source=%llx result=%d", |
||||
(unsigned long long)bridge.viewer.id, (unsigned long long)bridge.viewer.group, |
||||
(unsigned long long)bridge.viewer.source, result); |
||||
closeViewer(StreamRecordKind::Closed, reason); return; |
||||
} |
||||
output({bridge.viewer.id, 0, StreamRecordKind::Opened}); |
||||
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop viewer opened id=%llu group=%llx source=%llx epoch=%llx", |
||||
(unsigned long long)bridge.viewer.id, (unsigned long long)bridge.viewer.group, |
||||
(unsigned long long)bridge.viewer.source, (unsigned long long)bridge.viewer.epoch); |
||||
} |
||||
} |
||||
|
||||
bool gui_stream_attach(UTUN_INSTANCE* inst) { |
||||
std::lock_guard<std::mutex> guard(bridge.lock); |
||||
if (!inst || !inst->stream || bridge.inst) { |
||||
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop stream attach rejected"); return false; |
||||
} |
||||
bridge.inst = inst; bridge.output.clear(); bridge.bytes = 0; |
||||
stream_set_event_callback(inst, mediaEvent, nullptr); |
||||
if (bridge.watchPending) { |
||||
bridge.watchPending = false; |
||||
if (stream_watch_sources(inst, bridge.watch) != STREAM_OK) |
||||
DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop initial source watch failed group=%llx", (unsigned long long)bridge.watch); |
||||
} |
||||
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop stream bridge attached"); return true; |
||||
} |
||||
|
||||
void gui_stream_detach(UTUN_INSTANCE* inst) { |
||||
std::lock_guard<std::mutex> guard(bridge.lock); |
||||
if (!inst || bridge.inst != inst) return; |
||||
if (bridge.pending) uasync_cancel_post(inst->ua, bridge.pending); |
||||
stream_watch_sources(inst, 0); |
||||
bridge.pending = nullptr; stream_set_event_callback(inst, nullptr, nullptr); |
||||
if (bridge.viewer.id) closeViewer(StreamRecordKind::Closed, STREAM_CLOSE_SERVICE); |
||||
if (bridge.opening) output({bridge.requested.id, 0, StreamRecordKind::Closed, {}, 0, STREAM_CLOSE_SERVICE}); |
||||
bridge.inst = nullptr; bridge.requested = {}; bridge.opening = bridge.stopping = bridge.watchPending = false; |
||||
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop stream bridge detached"); |
||||
} |
||||
|
||||
bool gui_stream_watch(uint64_t group) { |
||||
std::lock_guard<std::mutex> guard(bridge.lock); |
||||
if (!bridge.inst) { |
||||
bridge.watch = group; bridge.watchPending = true; |
||||
DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, "desktop source watch queued until attach group=%llx", (unsigned long long)group); return true; |
||||
} |
||||
if (!schedule()) return false; |
||||
bridge.watch = group; bridge.watchPending = true; return true; |
||||
} |
||||
|
||||
bool gui_stream_open(uint64_t id, uint64_t group, uint64_t source, uint64_t epoch) { |
||||
std::lock_guard<std::mutex> guard(bridge.lock); |
||||
if (!id || !group || !source || !epoch) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop viewer invalid identity"); return false; } |
||||
if (!schedule()) return false; |
||||
if (bridge.opening) output({bridge.requested.id, 0, StreamRecordKind::Closed, {}, 0, STREAM_CLOSE_STOPPED}); |
||||
bridge.requested = {id, group, source, epoch}; bridge.opening = true; return true; |
||||
} |
||||
|
||||
void gui_stream_stop(uint64_t id) { |
||||
std::lock_guard<std::mutex> guard(bridge.lock); |
||||
if (bridge.opening && bridge.requested.id == id) { |
||||
bridge.opening = false; bridge.requested = {}; output({id, 0, StreamRecordKind::Closed, {}, 0, STREAM_CLOSE_STOPPED}); |
||||
} |
||||
if (bridge.viewer.id == id && schedule()) bridge.stopping = true; |
||||
for (auto i = bridge.output.begin(); i != bridge.output.end();) { |
||||
if (i->id == id) { bridge.bytes -= static_cast<size_t>(i->data.size()); i = bridge.output.erase(i); } |
||||
else ++i; |
||||
} |
||||
} |
||||
|
||||
bool gui_stream_pull(uint64_t id, StreamRecord& record) { |
||||
std::lock_guard<std::mutex> guard(bridge.lock); |
||||
auto i = std::find_if(bridge.output.begin(), bridge.output.end(), [id](const StreamRecord& r) { return r.id == id; }); |
||||
if (i == bridge.output.end()) return false; |
||||
bridge.bytes -= static_cast<size_t>(i->data.size()); record = std::move(*i); bridge.output.erase(i); return true; |
||||
} |
||||
|
||||
bool gui_stream_ready() { std::lock_guard<std::mutex> guard(bridge.lock); return bridge.inst != nullptr; } |
||||
@ -0,0 +1,27 @@
|
||||
/* Desktop live viewer: attach/detach в uasync до destroy ядра; остальные вызовы потокобезопасны.
|
||||
* Один viewer, команды содержат IDs; RX ограничен 4MB/256 записей, borrowed данные копируются. |
||||
* Watch до attach запоминает выбранную группу. Каталог идёт через CHAT_EVT_STREAM_SOURCES, |
||||
* медиаданные — через отдельную очередь pull. */ |
||||
#pragma once |
||||
|
||||
#include <QByteArray> |
||||
#include <cstdint> |
||||
#include "../../../src/stream/stream_media.h" |
||||
|
||||
struct UTUN_INSTANCE; |
||||
enum class StreamRecordKind { Video, Audio, Gap, Opened, End, Closed }; |
||||
struct StreamRecord { |
||||
uint64_t id = 0, pts = 0; |
||||
StreamRecordKind kind = StreamRecordKind::Gap; |
||||
stream_media_format format{}; |
||||
int flags = 0, reason = 0; |
||||
QByteArray data; |
||||
}; |
||||
|
||||
bool gui_stream_attach(UTUN_INSTANCE* inst); |
||||
void gui_stream_detach(UTUN_INSTANCE* inst); |
||||
bool gui_stream_watch(uint64_t group); |
||||
bool gui_stream_open(uint64_t id, uint64_t group, uint64_t source, uint64_t epoch); |
||||
void gui_stream_stop(uint64_t id); |
||||
bool gui_stream_pull(uint64_t id, StreamRecord& record); |
||||
bool gui_stream_ready(); |
||||
Loading…
Reference in new issue