Browse Source

Fix Android stream layout and show measured publisher bitrate

stream-multiview
evgeny 6 days ago
parent
commit
b8bc9b409a
  1. 37
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt
  2. 18
      tools/chatgui/src/stream_publish_engine.cpp
  3. 1
      tools/chatgui/src/stream_publish_engine.h
  4. 6
      tools/chatgui/src/stream_publish_window.cpp
  5. 6
      tools/chatgui/tests/test_stream_publish.cpp
  6. 1
      tools/chatgui/translations/chatgui_en.ts
  7. 1
      tools/chatgui/translations/chatgui_ru.ts

37
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt

@ -2,7 +2,6 @@ package com.utun.chat.ui.screens
import android.app.Activity
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import android.graphics.SurfaceTexture
import android.view.Surface
import android.view.TextureView
@ -42,14 +41,13 @@ import com.utun.chat.data.LogManager
import com.utun.chat.data.StreamPlayerEngine
import kotlinx.coroutines.delay
/** Полноэкранный viewer: геометрия видео не зависит от видимости управления. */
/** Полноэкранный viewer: видео разворачивается вдоль окна, ориентацию экрана не меняем. */
@Composable
internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: String, name: String, onClose: () -> Unit) {
val context = LocalContext.current
val activity = remember(context) {
generateSequence(context) { (it as? ContextWrapper)?.baseContext }.filterIsInstance<Activity>().firstOrNull()
}
val previousOrientation = remember(activity) { activity?.requestedOrientation }
val owner = LocalLifecycleOwner.current
var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
var aspect by remember { mutableStateOf(0f) }
@ -75,25 +73,13 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S
val behavior = controller?.systemBarsBehavior
controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller?.hide(WindowInsetsCompat.Type.systemBars())
LogManager.addLog("INFO", "stream", "viewer fullscreen source=$source epoch=$epoch orientation=$previousOrientation")
LogManager.addLog("INFO", "stream", "viewer fullscreen source=$source epoch=$epoch " +
"screenOrientation=${activity?.requestedOrientation}")
onDispose {
if (previousOrientation != null) activity?.requestedOrientation = previousOrientation
if (behavior != null) controller?.systemBarsBehavior = behavior
if (statusVisible) controller?.show(WindowInsetsCompat.Type.statusBars()) else controller?.hide(WindowInsetsCompat.Type.statusBars())
if (navigationVisible) controller?.show(WindowInsetsCompat.Type.navigationBars()) else controller?.hide(WindowInsetsCompat.Type.navigationBars())
LogManager.addLog("INFO", "stream", "viewer window restored source=$source orientation=$previousOrientation")
}
}
LaunchedEffect(activity, aspect) {
// Формат уже учитывает rotation источника. До первого KEY ориентацию не угадываем.
val orientation = when {
aspect > 1f -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
aspect > 0f && aspect < 1f -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
else -> null
}
if (activity != null && orientation != null && activity.requestedOrientation != orientation) {
activity.requestedOrientation = orientation
LogManager.addLog("INFO", "stream", "viewer orientation source=$source aspect=$aspect requested=$orientation")
LogManager.addLog("INFO", "stream", "viewer window restored source=$source")
}
}
LaunchedEffect(status) {
@ -114,14 +100,21 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S
BoxWithConstraints(Modifier.fillMaxSize().background(Color.Black).clipToBounds()) {
val ratio = aspect.takeIf { it > 0f } ?: 16f / 9f
val width = minOf(maxWidth, maxHeight * ratio)
val turn = aspect > 0f && minOf(maxHeight, maxWidth * ratio) > minOf(maxWidth, maxHeight * ratio)
val width = if (turn) minOf(maxHeight, maxWidth * ratio) else minOf(maxWidth, maxHeight * ratio)
val height = width / ratio
val density = LocalDensity.current
val videoSize = with(density) { Size(width.toPx(), height.toPx()) }
// Жесты работают в координатах окна, поэтому учитывают размеры после поворота видео.
val videoSize = with(density) { if (turn) Size(height.toPx(), width.toPx()) else Size(width.toPx(), height.toPx()) }
val viewport = with(density) { Size(maxWidth.toPx(), maxHeight.toPx()) }
LaunchedEffect(aspect, turn, videoSize, viewport) {
LogManager.addLog("INFO", "stream", "viewer fit source=$source aspect=$aspect viewport=$viewport " +
"video=$videoSize videoRotation=${if (turn) 90 else 0}")
}
LaunchedEffect(videoSize, viewport) { zoom.constrain(videoSize, viewport) }
if (visible) StreamViewerPlayback(group, source, epoch, { aspect = it }, { status = it },
Modifier.size(width, height).align(Alignment.Center).graphicsLayer {
Modifier.requiredSize(width, height).align(Alignment.Center).graphicsLayer {
rotationZ = if (turn) 90f else 0f
scaleX = zoom.scale; scaleY = zoom.scale
translationX = zoom.offset.x; translationY = zoom.offset.y
})
@ -136,7 +129,7 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S
}
})
if (controls) {
Row(Modifier.fillMaxWidth().align(Alignment.TopCenter).background(Color.Black.copy(alpha = 0.65f))
Row(Modifier.fillMaxWidth().align(Alignment.TopCenter).background(Color.Black.copy(alpha = 0.325f))
.windowInsetsPadding(WindowInsets.safeDrawing).padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {

18
tools/chatgui/src/stream_publish_engine.cpp

@ -110,6 +110,9 @@ struct StreamPublishEngine::Impl {
std::atomic<int64_t> imageStageAt{monotonicUs()};
std::atomic<uint64_t> imageFrames{0};
std::atomic<uint64_t> convertedFrames{0}, encodedFrames{0}, deliveredFrames{0}, repeatedFrames{0};
std::atomic<uint64_t> encodedBytes{0}, lastKeyBytes{0}; // Видео до сетевой отправки, без повторов транспорта.
uint64_t bitrateBytes = 0, videoBitrate = 0;
int64_t bitrateAt = 0;
void videoProgress(const char* stage) {
videoStageAt.store(monotonicUs()); videoStage.store(stage);
}
@ -236,6 +239,7 @@ bool StreamPublishEngine::active() const { return m->id != 0; }
bool StreamPublishEngine::running() const { return m->running.load(); }
uint64_t StreamPublishEngine::group() const { return m->group; }
StreamPublishConfig StreamPublishEngine::config() const { std::lock_guard<std::mutex> guard(m->lock); return m->wanted; }
uint64_t StreamPublishEngine::videoBitrate() const { return active() ? m->videoBitrate : 0; }
QVariant StreamPublishEngine::previewFrame() const {
#ifdef HAVE_STREAM_CAPTURE
std::lock_guard<std::mutex> guard(m->lock); return QVariant::fromValue(m->previewFrame);
@ -585,6 +589,7 @@ bool StreamPublishEngine::start(uint64_t group, const StreamPublishConfig& confi
m->videoReady = false; m->softwareRequest = 0; m->softwareCodec = 0; m->lastAudioAt = 0;
m->origin = m->started = monotonicUs(); m->stats = {}; }
m->convertedFrames = m->encodedFrames = m->deliveredFrames = m->repeatedFrames = 0;
m->encodedBytes = m->lastKeyBytes = 0; m->bitrateBytes = m->videoBitrate = 0; m->bitrateAt = m->origin;
m->videoProgress("starting"); m->captureReport = 0;
m->captureReportRaw = m->captureReportConverted = m->captureReportEncoded = m->captureReportDelivered = 0;
m->id = QRandomGenerator::global()->generate64(); if (!m->id) m->id = 1;
@ -966,6 +971,8 @@ void StreamPublishEngine::videoLoop(StreamPublishConfig initial) {
stream_media_format format{(uint16_t)config.video.width, (uint16_t)config.video.height, (uint16_t)config.video.rotation,
(uint8_t)config.video.codec, (uint8_t)(config.audio ? STREAM_MEDIA_OPUS_STEREO : 0), revision};
for (const auto& encoded : output) {
m->encodedBytes += encoded.data.size();
if (encoded.key) m->lastKeyBytes = encoded.data.size();
if (gui_stream_publish_feed(m->id, true, encoded.pts, encoded.key, encoded.data, &format) == 1) {
lastPts = encoded.pts; if (encoded.key) lastKey = encoded.pts; count++;
m->deliveredFrames++;
@ -1014,6 +1021,17 @@ void StreamPublishEngine::tick() {
m_microphone.release(); m->id = m->group = 0; emit stateChanged(text("Chat service is stopped")); return;
}
{ std::lock_guard<std::mutex> guard(m->lock); m->stats = status; }
const int64_t bitrateNow = monotonicUs(), bitrateElapsed = bitrateNow - m->bitrateAt;
if (bitrateElapsed >= 1000000) {
const uint64_t encodedBytes = m->encodedBytes.load();
m->videoBitrate = (encodedBytes - m->bitrateBytes) * 8000000 / bitrateElapsed;
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "desktop video bitrate id=%llu actual_bps=%llu target_bps=%d bytes=%llu interval_us=%lld "
"last_key_bytes=%llu queue_bytes=%llu need_key=%d",
(unsigned long long)m->id, (unsigned long long)m->videoBitrate, config().video.bitrate,
(unsigned long long)(encodedBytes - m->bitrateBytes), (long long)bitrateElapsed,
(unsigned long long)m->lastKeyBytes.load(), (unsigned long long)status.video_bytes, status.need_key);
m->bitrateBytes = encodedBytes; m->bitrateAt = bitrateNow;
}
emit telemetryChanged(status);
if (status.state == STREAM_PUBLISHER_ENDED || status.state == STREAM_PUBLISHER_FAILED) {
stop();

1
tools/chatgui/src/stream_publish_engine.h

@ -49,6 +49,7 @@ public:
bool running() const;
uint64_t group() const;
StreamPublishConfig config() const;
uint64_t videoBitrate() const; // Фактический выход video encoder за последний интервал около секунды, бит/с; читать в GUI-потоке.
QVariant previewFrame() const;
QList<int> codecs() const;
static QList<StreamVideoSource> sources();

6
tools/chatgui/src/stream_publish_window.cpp

@ -88,7 +88,8 @@ StreamPublishWindow::StreamPublishWindow(StreamPublishEngine* engine, uint64_t g
m_mute = new QCheckBox(text("Mute stream audio"), settings); form->addRow(m_mute);
connect(m_mute, &QCheckBox::toggled, engine, &StreamPublishEngine::setMuted);
m_status = new QLabel(this); m_status->setWordWrap(true); layout->addWidget(m_status);
m_stats = new QLabel(this); m_stats->setWordWrap(true); m_stats->setMinimumHeight(46); layout->addWidget(m_stats);
m_stats = new QLabel(this); m_stats->setObjectName(QStringLiteral("streamPublishStats"));
m_stats->setWordWrap(true); m_stats->setMinimumHeight(66); layout->addWidget(m_stats);
m_start = new QPushButton(text("Start stream"), this); layout->addWidget(m_start);
connect(m_start, &QPushButton::clicked, this, [this] {
if (!m_engine->active()) m_status->setProperty("streamError", QString());
@ -141,7 +142,8 @@ StreamPublishWindow::StreamPublishWindow(StreamPublishEngine* engine, uint64_t g
});
connect(engine, &StreamPublishEngine::audioChannelsChanged, this, [this](int channels) { m_inputChannels->setText(channels == 2 ? text("Stereo") : text("Mono")); });
connect(engine, &StreamPublishEngine::telemetryChanged, this, [this](const stream_publisher_status& stats) {
m_stats->setText(text("Video queue: %1 · Inflight: %2 · Send buffer: %3\nACK, all connections, last second: %4")
m_stats->setText(text("Current video bitrate: %1 Mbit/s").arg(m_engine->videoBitrate() / 1000000.0, 0, 'f', 2) + '\n' +
text("Video queue: %1 · Inflight: %2 · Send buffer: %3\nACK, all connections, last second: %4")
.arg(bytes(stats.video_bytes), bytes(stats.inflight_bytes), bytes(stats.total_bytes), bytes(stats.acked_1s)));
});
refreshSources(); refreshCodecs(); restoreSettings(); synchronize();

6
tools/chatgui/tests/test_stream_publish.cpp

@ -799,6 +799,12 @@ private slots:
QTest::qWait(1);
}
QVERIFY(video >= 10); QVERIFY(audio >= 40); QVERIFY(latest > first + 1000000); QVERIFY(left && right > left * 3);
QTRY_VERIFY_WITH_TIMEOUT(engine.videoBitrate() > 0, 3000);
auto* stats = controls.findChild<QLabel*>("streamPublishStats"); QVERIFY(stats);
QVERIFY(stats->text().contains(QStringLiteral("Current video bitrate: ")));
QVERIFY(stats->text().contains(QStringLiteral("%1 Mbit/s").arg(engine.videoBitrate() / 1000000.0, 0, 'f', 2)));
DEBUG_INFO(DEBUG_CATEGORY_STREAM, "[PASS] publisher displays measured video bitrate bps=%llu",
(unsigned long long)engine.videoBitrate());
AudioCapture radio;
std::atomic<uint64_t> radioFrames{0};
QVERIFY(radio.open({}, AudioCapture::Role::Radio, 1, 303, [](int, int) { return true; }, [] {},

1
tools/chatgui/translations/chatgui_en.ts

@ -121,6 +121,7 @@ Use CPU encoding instead? This may cause high CPU usage and slow down the comput
<message><source>Stream publication failed</source><translation>Stream publication failed</translation></message>
<message><source>Timed out waiting for the first video frame</source><translation>Timed out waiting for the first video frame</translation></message>
<message><source>Video capture requires Qt Multimedia and FFmpeg</source><translation>Video capture requires Qt Multimedia and FFmpeg</translation></message>
<message><source>Current video bitrate: %1 Mbit/s</source><translation>Current video bitrate: %1 Mbit/s</translation></message>
<message><source>Video queue: %1 · Inflight: %2 · Send buffer: %3
ACK, all connections, last second: %4</source><translation>Video queue: %1 · Inflight: %2 · Send buffer: %3
ACK, all connections, last second: %4</translation></message>

1
tools/chatgui/translations/chatgui_ru.ts

@ -124,6 +124,7 @@ Use CPU encoding instead? This may cause high CPU usage and slow down the comput
<message><source>Stream publication failed</source><translation>Ошибка отправки трансляции</translation></message>
<message><source>Timed out waiting for the first video frame</source><translation>Истекло время ожидания первого видеокадра</translation></message>
<message><source>Video capture requires Qt Multimedia and FFmpeg</source><translation>Для захвата видео нужны Qt Multimedia и FFmpeg</translation></message>
<message><source>Current video bitrate: %1 Mbit/s</source><translation>Текущий битрейт видео: %1 Мбит/с</translation></message>
<message><source>Video queue: %1 · Inflight: %2 · Send buffer: %3
ACK, all connections, last second: %4</source><translation>Очередь видео: %1 · В полёте: %2 · Буфер отправки: %3
ACK всех подключений за секунду: %4</translation></message>

Loading…
Cancel
Save