Fix Android stream layout and show measured publisher bitrate
This commit is contained in:
1 parent
e246357f92
commit
b8bc9b409a
7 files changed
+46
-24
No files matched your search
+15
-22
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }, [] {},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user