diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt index 14f70d10..545f227b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt +++ b/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().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)) { diff --git a/tools/chatgui/src/stream_publish_engine.cpp b/tools/chatgui/src/stream_publish_engine.cpp index 87ebae0d..2b777ade 100644 --- a/tools/chatgui/src/stream_publish_engine.cpp +++ b/tools/chatgui/src/stream_publish_engine.cpp @@ -110,6 +110,9 @@ struct StreamPublishEngine::Impl { std::atomic imageStageAt{monotonicUs()}; std::atomic imageFrames{0}; std::atomic convertedFrames{0}, encodedFrames{0}, deliveredFrames{0}, repeatedFrames{0}; + std::atomic 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 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 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 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(); diff --git a/tools/chatgui/src/stream_publish_engine.h b/tools/chatgui/src/stream_publish_engine.h index dd026f23..264271ea 100644 --- a/tools/chatgui/src/stream_publish_engine.h +++ b/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 codecs() const; static QList sources(); diff --git a/tools/chatgui/src/stream_publish_window.cpp b/tools/chatgui/src/stream_publish_window.cpp index 573e9e8c..fa4e6b97 100644 --- a/tools/chatgui/src/stream_publish_window.cpp +++ b/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(); diff --git a/tools/chatgui/tests/test_stream_publish.cpp b/tools/chatgui/tests/test_stream_publish.cpp index f8adff0a..60150862 100644 --- a/tools/chatgui/tests/test_stream_publish.cpp +++ b/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("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 radioFrames{0}; QVERIFY(radio.open({}, AudioCapture::Role::Radio, 1, 303, [](int, int) { return true; }, [] {}, diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index e0c873fa..5d748c3f 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/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 Stream publication failedStream publication failed Timed out waiting for the first video frameTimed out waiting for the first video frame Video capture requires Qt Multimedia and FFmpegVideo capture requires Qt Multimedia and FFmpeg + Current video bitrate: %1 Mbit/sCurrent video bitrate: %1 Mbit/s Video queue: %1 · Inflight: %2 · Send buffer: %3 ACK, all connections, last second: %4Video queue: %1 · Inflight: %2 · Send buffer: %3 ACK, all connections, last second: %4 diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index 64bca8a1..57f659a2 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/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 Stream publication failedОшибка отправки трансляции Timed out waiting for the first video frameИстекло время ожидания первого видеокадра Video capture requires Qt Multimedia and FFmpegДля захвата видео нужны Qt Multimedia и FFmpeg + Current video bitrate: %1 Mbit/sТекущий битрейт видео: %1 Мбит/с Video queue: %1 · Inflight: %2 · Send buffer: %3 ACK, all connections, last second: %4Очередь видео: %1 · В полёте: %2 · Буфер отправки: %3 ACK всех подключений за секунду: %4