Refine Android stream icons and show channel in title

This commit is contained in:
evgeny committed 2026-10-19 12:08:57 +02:00
1 parent 1084cd10ec
commit d0bbe8444e
3 files changed
+135 -47

No files matched your search

@@ -14,7 +14,6 @@ import android.view.TextureView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.border
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.horizontalScroll
@@ -24,11 +23,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.AspectRatio
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -37,6 +34,7 @@ import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -48,6 +46,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
@@ -73,6 +72,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.NumberFormat
import kotlin.math.roundToInt
/** Выход закрывает reader либо прямой preview output; foreground publisher останавливается явно. */
@OptIn(ExperimentalMaterial3Api::class)
@@ -128,18 +128,24 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
val ongoing = local?.takeIf { it.ongoing }
val shownConfig = if (ongoing != null) ongoing.config ?: ongoing.pendingConfig else config
val shownAudio = ongoing?.audioEnabled ?: audio
LaunchedEffect(local?.phase, local?.pendingConfig, selected?.id, shownConfig, shownAudio) {
LaunchedEffect(local?.phase, local?.pendingConfig, selected?.id, shownConfig, shownAudio, displayDegrees) {
LogManager.addLog("DEBUG", "stream", "screen group=$group phase=${local?.phase} preview=${selected?.id} " +
"config=$shownConfig audio=$shownAudio pending=${local?.pendingConfig}")
"config=$shownConfig audio=$shownAudio display=$displayDegrees pending=${local?.pendingConfig}")
}
val title = stringResource(R.string.stream_channel_title, channelName)
Scaffold(topBar = {
TopAppBar(title = { Text(stringResource(R.string.stream_title)) }, navigationIcon = {
TopAppBar(title = {
TooltipBox(positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = { PlainTooltip { Text(title) } }, state = rememberTooltipState()) {
Text(title, style = MaterialTheme.typography.titleMedium, fontSize = 18.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}, navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) }
})
}) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
StreamCaptureToolbar(channelName, cameras, shownConfig, displayDegrees,
StreamCaptureToolbar(cameras, shownConfig, displayDegrees,
enabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.pendingConfig == null else !loadingModes,
audio = shownAudio,
audioEnabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.audioAvailable else !loadingModes,
@@ -217,11 +223,12 @@ private fun StreamTxPanel(id: Long) {
}
}
private enum class StreamOption { CHANNEL, CAMERA, CODEC, BITRATE, RESOLUTION, ORIENTATION }
private enum class StreamOption { CAMERA, CODEC, BITRATE, RESOLUTION, ORIENTATION }
private enum class StreamIconShape { CAMERA, CHIP, GAUGE, SCREEN }
/** Одна строка управления: значки отражают применённый режим, меню сохраняют все варианты выбора. */
@Composable
private fun StreamCaptureToolbar(channelName: String, cameras: List<StreamCamera>, config: StreamCaptureConfig?, display: Int,
private fun StreamCaptureToolbar(cameras: List<StreamCamera>, config: StreamCaptureConfig?, display: Int,
enabled: Boolean, audio: Boolean, audioEnabled: Boolean, onAudioChange: (Boolean) -> Unit,
onChange: (StreamCaptureConfig) -> Unit) {
val mode = config?.mode
@@ -229,7 +236,8 @@ private fun StreamCaptureToolbar(channelName: String, cameras: List<StreamCamera
val allModes = camera?.modes.orEmpty()
val modes = allModes.filter { it.videoCodec == mode?.videoCodec }
var menu by remember { mutableStateOf<StreamOption?>(null) }
LaunchedEffect(enabled) { if (!enabled && menu != StreamOption.CHANNEL) menu = null }
LaunchedEffect(enabled) { if (!enabled) menu = null }
LaunchedEffect(menu) { LogManager.addLog("DEBUG", "stream", "toolbar menu=$menu enabled=$enabled") }
val locale = LocalConfiguration.current.locales[0]
val shortBitrate = config?.let {
val mega = it.bitrate >= 1_000_000
@@ -242,23 +250,22 @@ private fun StreamCaptureToolbar(channelName: String, cameras: List<StreamCamera
val bitrateValue = config?.let { stringResource(R.string.stream_bitrate_value, it.bitrate / 1_000_000.0) } ?: "—"
val bitrateDescription = if (config != null && config.videoBitrate == null)
stringResource(R.string.stream_bitrate_auto_value, bitrateValue) else stringResource(R.string.stream_bitrate, bitrateValue)
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) {
Box {
StreamControlButton(stringResource(R.string.stream_channel, channelName), onClick = { menu = StreamOption.CHANNEL }) {
Icon(Icons.Filled.Tag, contentDescription = null)
}
DropdownMenu(expanded = menu == StreamOption.CHANNEL, onDismissRequest = { menu = null }) {
DropdownMenuItem(text = { Text(channelName) }, onClick = { menu = null })
}
}
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically) {
StreamControlButton(stringResource(if (audio) R.string.stream_audio_on else R.string.stream_audio_off),
enabled = audioEnabled, checked = audio, onClick = { onAudioChange(!audio) }) {
Icon(if (audio) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, contentDescription = null)
Icon(if (audio) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = null, modifier = Modifier.size(32.dp))
}
Box {
val name = camera?.let { "${it.lens.label} ${it.lens.mm}".trim() } ?: "—"
StreamControlButton(stringResource(R.string.stream_camera, name), enabled && cameras.isNotEmpty(),
onClick = { menu = StreamOption.CAMERA }) { Icon(Icons.Filled.CameraAlt, contentDescription = null) }
onClick = { menu = StreamOption.CAMERA }) {
val focal = camera?.lens?.eqFocal?.takeIf { it > 0 }?.let {
stringResource(R.string.stream_camera_focal_short, it.roundToInt())
} ?: "—"
StreamValueIcon(StreamIconShape.CAMERA, focal, front = camera?.lens?.type == LensType.SELFIE)
}
DropdownMenu(expanded = menu == StreamOption.CAMERA && enabled, onDismissRequest = { menu = null }) {
cameras.forEach { option ->
StreamOptionItem("${option.lens.label} ${option.lens.mm}".trim(), option.id == mode?.camera, onClick = {
@@ -273,7 +280,7 @@ private fun StreamCaptureToolbar(channelName: String, cameras: List<StreamCamera
Box {
StreamControlButton(stringResource(R.string.stream_video_codec, mode?.videoCodec?.label ?: "—"),
enabled && config != null, onClick = { menu = StreamOption.CODEC }) {
StreamValueIcon(mode?.videoCodec?.name ?: "—")
StreamValueIcon(StreamIconShape.CHIP, mode?.videoCodec?.name ?: "—")
}
DropdownMenu(expanded = menu == StreamOption.CODEC && enabled, onDismissRequest = { menu = null }) {
allModes.map { it.videoCodec }.distinct().sortedBy { it.id }.forEach { codec ->
@@ -288,7 +295,8 @@ private fun StreamCaptureToolbar(channelName: String, cameras: List<StreamCamera
}
Box {
StreamControlButton(bitrateDescription, enabled && config != null, onClick = { menu = StreamOption.BITRATE }) {
StreamValueIcon(shortBitrate, automatic = config != null && config.videoBitrate == null)
StreamValueIcon(StreamIconShape.GAUGE, shortBitrate,
detail = if (config != null && config.videoBitrate == null) stringResource(R.string.stream_bitrate_auto_mark) else null)
}
DropdownMenu(expanded = menu == StreamOption.BITRATE && enabled, onDismissRequest = { menu = null }) {
StreamOptionItem(stringResource(R.string.stream_bitrate_auto), config?.videoBitrate == null, onClick = {
@@ -307,7 +315,10 @@ private fun StreamCaptureToolbar(channelName: String, cameras: List<StreamCamera
}
Box {
StreamControlButton(stringResource(R.string.stream_resolution, mode?.label ?: "—"), enabled && modes.isNotEmpty(),
onClick = { menu = StreamOption.RESOLUTION }) { Icon(Icons.Filled.AspectRatio, contentDescription = null) }
onClick = { menu = StreamOption.RESOLUTION }) {
StreamValueIcon(StreamIconShape.SCREEN, mode?.let { "${minOf(it.width, it.height)}p" } ?: "—",
detail = mode?.let { "${it.fps}fps" })
}
DropdownMenu(expanded = menu == StreamOption.RESOLUTION && enabled, onDismissRequest = { menu = null }) {
modes.forEach { option -> StreamOptionItem(option.label, option == mode, onClick = {
menu = null; config?.let { onChange(it.withMode(option)) }
@@ -319,13 +330,13 @@ private fun StreamCaptureToolbar(channelName: String, cameras: List<StreamCamera
val front = camera?.lens?.type == LensType.SELFIE
val top = if (front) (display - degrees + 360) % 360 else (degrees - display + 360) % 360
StreamControlButton(stringResource(R.string.stream_orientation, degrees), enabled && config != null,
onClick = { menu = StreamOption.ORIENTATION }) { StreamRotationMark(top) }
onClick = { menu = StreamOption.ORIENTATION }) { StreamRotationMark(top, degrees) }
DropdownMenu(expanded = menu == StreamOption.ORIENTATION && enabled, onDismissRequest = { menu = null }) {
for (option in listOf(StreamOrientation.UPRIGHT, StreamOrientation.RIGHT, StreamOrientation.LEFT)) {
val optionTop = if (front) (display - option.degrees + 360) % 360 else (option.degrees - display + 360) % 360
StreamOptionItem(stringResource(R.string.stream_orientation, option.degrees), config?.orientation == option,
onClick = { menu = null; config?.let { onChange(it.copy(orientation = option)) } },
icon = { StreamRotationMark(optionTop) })
icon = { StreamRotationMark(optionTop, option.degrees) })
}
}
}
@@ -340,21 +351,88 @@ private fun StreamControlButton(description: String, enabled: Boolean = true, ch
TooltipBox(positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), tooltip = { PlainTooltip { Text(description) } },
state = rememberTooltipState()) {
val modifier = Modifier.size(48.dp).semantics { contentDescription = description }
if (checked == null) IconButton(onClick = onClick, enabled = enabled, modifier = modifier) {
val colors = MaterialTheme.colorScheme
val background = colors.surfaceVariant.copy(alpha = 0.55f)
val shape = RoundedCornerShape(12.dp)
if (checked == null) FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = modifier, shape = shape,
colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = background, contentColor = colors.onSurface,
disabledContainerColor = colors.surfaceVariant.copy(alpha = 0.2f))) {
Box(Modifier.clearAndSetSemantics {}) { icon() }
} else IconToggleButton(checked = checked, onCheckedChange = { onClick() }, enabled = enabled, modifier = modifier) {
} else FilledTonalIconToggleButton(checked = checked, onCheckedChange = { onClick() }, enabled = enabled,
modifier = modifier, shape = shape,
colors = IconButtonDefaults.filledTonalIconToggleButtonColors(containerColor = background, contentColor = colors.onSurfaceVariant,
disabledContainerColor = colors.surfaceVariant.copy(alpha = 0.2f), checkedContainerColor = colors.primaryContainer,
checkedContentColor = colors.onPrimaryContainer)) {
Box(Modifier.clearAndSetSemantics {}) { icon() }
}
}
}
/** Значение находится внутри рамки значка; автоматический битрейт помечен буквой A внутри той же рамки. */
/** Различимые силуэты камеры, микросхемы, шкалы скорости и экрана содержат текущие значения. */
@Composable
private fun StreamValueIcon(value: String, automatic: Boolean = false) {
Column(Modifier.size(34.dp, 28.dp).border(1.dp, LocalContentColor.current, RoundedCornerShape(3.dp)),
verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
if (automatic) Text(stringResource(R.string.stream_bitrate_auto_mark), fontSize = 7.sp, lineHeight = 7.sp, maxLines = 1)
Text(value, fontSize = 10.sp, lineHeight = 11.sp, fontWeight = FontWeight.Medium, maxLines = 1, softWrap = false)
private fun StreamValueIcon(shape: StreamIconShape, value: String, detail: String? = null, front: Boolean = false) {
val frame = LocalContentColor.current
val accent = MaterialTheme.colorScheme.primary.copy(alpha = frame.alpha)
Box(Modifier.size(40.dp, 36.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.matchParentSize()) {
val w = size.width; val h = size.height
val stroke = Stroke(1.5.dp.toPx(), cap = StrokeCap.Round)
when (shape) {
StreamIconShape.CAMERA -> {
val body = Path().apply {
moveTo(w * 0.12f, h * 0.24f); lineTo(w * 0.28f, h * 0.24f)
lineTo(w * 0.36f, h * 0.08f); lineTo(w * 0.64f, h * 0.08f); lineTo(w * 0.72f, h * 0.24f)
lineTo(w * 0.88f, h * 0.24f); quadraticBezierTo(w * 0.96f, h * 0.24f, w * 0.96f, h * 0.34f)
lineTo(w * 0.96f, h * 0.84f); quadraticBezierTo(w * 0.96f, h * 0.94f, w * 0.88f, h * 0.94f)
lineTo(w * 0.12f, h * 0.94f); quadraticBezierTo(w * 0.04f, h * 0.94f, w * 0.04f, h * 0.84f)
lineTo(w * 0.04f, h * 0.34f); quadraticBezierTo(w * 0.04f, h * 0.24f, w * 0.12f, h * 0.24f); close()
}
drawPath(body, frame, style = stroke)
drawLine(accent, Offset(w * 0.43f, h * 0.08f), Offset(w * 0.57f, h * 0.08f), stroke.width, StrokeCap.Round)
}
StreamIconShape.CHIP -> {
drawRoundRect(frame, Offset(w * 0.14f, h * 0.14f), Size(w * 0.72f, h * 0.72f),
CornerRadius(3.dp.toPx()), style = stroke)
for (position in listOf(0.28f, 0.5f, 0.72f)) {
drawLine(accent, Offset(w * position, h * 0.02f), Offset(w * position, h * 0.14f), stroke.width)
drawLine(accent, Offset(w * position, h * 0.86f), Offset(w * position, h * 0.98f), stroke.width)
drawLine(accent, Offset(w * 0.02f, h * position), Offset(w * 0.14f, h * position), stroke.width)
drawLine(accent, Offset(w * 0.86f, h * position), Offset(w * 0.98f, h * position), stroke.width)
}
}
StreamIconShape.GAUGE -> {
drawArc(accent, 180f, 180f, false, Offset(w * 0.05f, h * 0.06f), Size(w * 0.9f, h * 0.9f), style = stroke)
drawLine(frame, Offset(w * 0.05f, h * 0.51f), Offset(w * 0.15f, h * 0.51f), stroke.width)
drawLine(frame, Offset(w * 0.5f, h * 0.06f), Offset(w * 0.5f, h * 0.16f), stroke.width)
drawLine(frame, Offset(w * 0.85f, h * 0.51f), Offset(w * 0.95f, h * 0.51f), stroke.width)
drawLine(frame, Offset(w * 0.16f, h * 0.94f), Offset(w * 0.84f, h * 0.94f), stroke.width, StrokeCap.Round)
}
StreamIconShape.SCREEN -> {
drawRoundRect(frame, Offset(w * 0.03f, h * 0.05f), Size(w * 0.94f, h * 0.76f),
CornerRadius(3.dp.toPx()), style = stroke)
drawLine(accent, Offset(w * 0.5f, h * 0.81f), Offset(w * 0.5f, h * 0.96f), stroke.width)
drawLine(accent, Offset(w * 0.3f, h * 0.96f), Offset(w * 0.7f, h * 0.96f), stroke.width, StrokeCap.Round)
}
}
}
when (shape) {
StreamIconShape.CAMERA -> Box(Modifier.offset(y = 4.dp)) {
if (front) Icon(Icons.Outlined.Person, contentDescription = null, modifier = Modifier.size(24.dp))
else Text(value, fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1, softWrap = false)
}
StreamIconShape.CHIP -> Text(value, fontSize = 11.sp, lineHeight = 13.sp,
fontWeight = FontWeight.SemiBold, maxLines = 1, softWrap = false)
StreamIconShape.GAUGE -> {
if (detail != null) Text(detail, Modifier.align(Alignment.TopCenter).padding(top = 7.dp),
fontSize = 8.sp, lineHeight = 9.sp, fontWeight = FontWeight.Medium, color = accent, maxLines = 1)
Text(value, Modifier.offset(y = 7.dp), fontSize = 12.sp, lineHeight = 14.sp,
fontWeight = FontWeight.SemiBold, maxLines = 1, softWrap = false)
}
StreamIconShape.SCREEN -> Column(Modifier.padding(bottom = 5.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(value, fontSize = 11.sp, lineHeight = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, softWrap = false)
if (detail != null) Text(detail, fontSize = 8.sp, lineHeight = 9.sp, maxLines = 1, softWrap = false)
}
}
}
}
@@ -365,20 +443,30 @@ private fun StreamOptionItem(label: String, active: Boolean, onClick: () -> Unit
trailingIcon = { if (active) Icon(Icons.Filled.Check, contentDescription = null) }, modifier = Modifier.semantics { selected = active })
}
/** Активное ребро выделено сплошной полосой и треугольником; угол сдвинут от маркера внутрь кадра. */
@Composable
private fun StreamRotationMark(top: Int) {
private fun StreamRotationMark(top: Int, degrees: Int) {
val frame = LocalContentColor.current
val marker = MaterialTheme.colorScheme.error
Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) {
val marker = MaterialTheme.colorScheme.primary.copy(alpha = frame.alpha)
Box(Modifier.size(40.dp, 36.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.matchParentSize()) {
val left = size.width * 0.15f; val right = size.width * 0.85f
val upper = size.height * 0.08f; val lower = size.height * 0.92f
val left = size.width * 0.2f; val right = size.width * 0.8f
val upper = size.height * 0.04f; val lower = size.height * 0.96f
rotate(top.toFloat()) {
drawRoundRect(frame, Offset(left, upper), Size(right - left, lower - upper), CornerRadius(2.dp.toPx()), style = Stroke(1.dp.toPx()))
drawLine(marker, Offset(left, upper), Offset(right, upper), 3.dp.toPx(), StrokeCap.Round)
drawRoundRect(frame.copy(alpha = frame.alpha * 0.6f), Offset(left, upper), Size(right - left, lower - upper),
CornerRadius(4.dp.toPx()), style = Stroke(1.5.dp.toPx()))
drawRoundRect(marker, Offset(left, upper), Size(right - left, 4.dp.toPx()), CornerRadius(2.dp.toPx()))
val triangle = Path().apply {
moveTo(center.x - 5.dp.toPx(), upper + 3.dp.toPx()); lineTo(center.x + 5.dp.toPx(), upper + 3.dp.toPx())
lineTo(center.x, upper + 7.dp.toPx()); close()
}
drawPath(triangle, marker)
}
}
Text(stringResource(R.string.stream_top), fontSize = 7.sp, lineHeight = 7.sp)
val x = when (top) { 90 -> (-2).dp; 270 -> 2.dp; else -> 0.dp }
val y = when (top) { 0 -> 2.dp; 180 -> (-2).dp; else -> 0.dp }
Text("${degrees}°", Modifier.offset(x, y), fontSize = 10.sp, lineHeight = 12.sp,
fontWeight = FontWeight.Medium, maxLines = 1, softWrap = false)
}
}
@@ -18,6 +18,7 @@
<string name="stream_tx_queues">Очередь видео: %1$.1f КиБ · Неподтверждено: %2$.1f КиБ</string>
<string name="stream_tx_total">Буфер отправки: %1$.1f КиБ · ACK / 1 с: %2$.1f КиБ (%3$.2f Мбит/с)</string>
<string name="stream_title">Трансляции</string>
<string name="stream_channel_title">Трансляция: %1$s</string>
<string name="stream_available">Трансляции: %1$d</string>
<string name="stream_choose_source">Выберите трансляцию</string>
<string name="stream_list_failed">Не удалось получить список трансляций</string>
@@ -27,10 +28,10 @@
<string name="stream_start">Начать трансляцию</string>
<string name="stream_stop">Остановить трансляцию</string>
<string name="stream_audio">Звук трансляции</string>
<string name="stream_channel">Канал: %1$s</string>
<string name="stream_audio_on">Звук трансляции включён</string>
<string name="stream_audio_off">Звук трансляции выключен</string>
<string name="stream_camera">Камера: %1$s</string>
<string name="stream_camera_focal_short">%1$dмм</string>
<string name="stream_resolution">Разрешение: %1$s</string>
<string name="stream_bitrate">Битрейт видео: %1$s</string>
<string name="stream_bitrate_auto_value">Битрейт видео: Авто (%1$s)</string>
@@ -501,7 +502,6 @@
<string name="stream_switching">Переключение камеры, режима или ориентации…</string>
<string name="stream_no_modes">Нет совместимой камеры или видеокодировщика</string>
<string name="stream_orientation">Поворот: %1$d°</string>
<string name="stream_top">Верх</string>
<string name="broadcast">Трансляция</string>
<string name="stream_subblocks">Всего субблоков</string>
<string name="stream_rs_redundant">Избыточных субблоков RS</string>
@@ -18,6 +18,7 @@
<string name="stream_tx_queues">Video queue: %1$.1f KiB · Unacknowledged: %2$.1f KiB</string>
<string name="stream_tx_total">Send buffer: %1$.1f KiB · ACK / 1 s: %2$.1f KiB (%3$.2f Mbps)</string>
<string name="stream_title">Live streams</string>
<string name="stream_channel_title">Stream: %1$s</string>
<string name="stream_available">Live streams: %1$d</string>
<string name="stream_choose_source">Choose a stream</string>
<string name="stream_list_failed">Could not load live streams</string>
@@ -27,10 +28,10 @@
<string name="stream_start">Start stream</string>
<string name="stream_stop">Stop stream</string>
<string name="stream_audio">Stream audio</string>
<string name="stream_channel">Channel: %1$s</string>
<string name="stream_audio_on">Stream audio on</string>
<string name="stream_audio_off">Stream audio off</string>
<string name="stream_camera">Camera: %1$s</string>
<string name="stream_camera_focal_short">%1$dmm</string>
<string name="stream_resolution">Resolution: %1$s</string>
<string name="stream_bitrate">Video bitrate: %1$s</string>
<string name="stream_bitrate_auto_value">Video bitrate: Auto (%1$s)</string>
@@ -500,7 +501,6 @@
<string name="stream_switching">Switching camera, video mode or orientation…</string>
<string name="stream_no_modes">No compatible camera or video encoder found</string>
<string name="stream_orientation">Rotation: %1$d°</string>
<string name="stream_top">Top</string>
<string name="broadcast">Broadcast</string>
<string name="stream_subblocks">Total subblocks</string>
<string name="stream_rs_redundant">Redundant RS subblocks</string>