Compact Android stream controls into one icon toolbar

This commit is contained in:
evgeny committed 2026-10-19 11:51:33 +02:00
1 parent ec545a8665
commit 1084cd10ec
3 files changed
+172 -89

No files matched your search

@@ -14,7 +14,7 @@ import android.view.TextureView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.horizontalScroll
@@ -22,7 +22,13 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -30,9 +36,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
@@ -40,6 +47,7 @@ import androidx.compose.ui.platform.LocalContext
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
@@ -55,7 +63,6 @@ import com.utun.chat.data.StreamPreview
import com.utun.chat.data.StreamPreviewFormat
import com.utun.chat.data.StreamPreviewTarget
import com.utun.chat.data.StreamSource
import com.utun.chat.data.StreamCaptureMode
import com.utun.chat.data.StreamCaptureConfig
import com.utun.chat.data.StreamCaptureSettings
import com.utun.chat.data.StreamOrientation
@@ -65,6 +72,7 @@ import com.utun.chat.data.LensType
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.NumberFormat
/** Выход закрывает reader либо прямой preview output; foreground publisher останавливается явно. */
@OptIn(ExperimentalMaterial3Api::class)
@@ -117,8 +125,12 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
local?.config?.let { config = it; audio = local.audioAvailable }
}
LaunchedEffect(local?.phase, local?.pendingConfig, selected?.id) {
LogManager.addLog("DEBUG", "stream", "screen group=$group phase=${local?.phase} preview=${selected?.id} pending=${local?.pendingConfig}")
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) {
LogManager.addLog("DEBUG", "stream", "screen group=$group phase=${local?.phase} preview=${selected?.id} " +
"config=$shownConfig audio=$shownAudio pending=${local?.pendingConfig}")
}
Scaffold(topBar = {
@@ -127,19 +139,22 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
})
}) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(channelName, style = MaterialTheme.typography.titleMedium)
if (local?.ongoing == true && (selected == null || selected.self)) {
Row {
Checkbox(checked = local.audioEnabled, onCheckedChange = StreamController::setAudioEnabled,
enabled = local.phase == StreamPhase.LIVE && local.audioAvailable)
Text(stringResource(R.string.stream_audio), Modifier.padding(top = 12.dp))
}
}
StreamCaptureToolbar(channelName, 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,
onAudioChange = {
LogManager.addLog("INFO", "stream", "screen audio group=$group enabled=$it live=${ongoing != null}")
if (ongoing != null) StreamController.setAudioEnabled(it)
else { audio = it; StreamController.rememberSettings(audio = it) }
}, onChange = {
LogManager.addLog("INFO", "stream", "screen capture selection group=$group live=${ongoing != null} config=$it")
if (ongoing != null) StreamController.change(it)
else { config = it; StreamController.rememberSettings(config = it) }
})
if (selected != null) {
Text(selected.displayName, style = MaterialTheme.typography.titleSmall)
if (selected.self && local?.ongoing == true) {
CaptureOptions(cameras, local.pendingConfig ?: local.config, local.phase == StreamPhase.LIVE && local.pendingConfig == null, displayDegrees,
StreamController::change)
CaptureModeStatus(local.config, local.pendingConfig, local.notice)
StreamTxPanel(local.id)
}
@@ -154,8 +169,6 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) }
} else {
if (local?.ongoing == true) {
CaptureOptions(cameras, local.pendingConfig ?: local.config, local.phase == StreamPhase.LIVE && local.pendingConfig == null, displayDegrees,
StreamController::change)
CaptureModeStatus(local.config, local.pendingConfig, local.notice)
StreamTxPanel(local.id)
Text(stringResource(when (local.phase) {
@@ -165,16 +178,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
}))
Button(onClick = StreamController::stop, enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) }
} else {
CaptureOptions(cameras, config, !loadingModes, displayDegrees) {
config = it; StreamController.rememberSettings(config = it)
}
if (!loadingModes && cameras.isEmpty()) Text(stringResource(R.string.stream_no_modes), color = MaterialTheme.colorScheme.error)
Row {
Checkbox(checked = audio, enabled = !loadingModes, onCheckedChange = {
audio = it; StreamController.rememberSettings(audio = it)
})
Text(stringResource(R.string.microphone), Modifier.padding(top = 12.dp))
}
Button(onClick = {
if (!cameraGranted || (audio && !micGranted)) permissions.launch(
listOf(Manifest.permission.CAMERA).plus(if (audio) listOf(Manifest.permission.RECORD_AUDIO) else emptyList()).toTypedArray())
@@ -197,8 +201,6 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
}
@Composable
private fun CaptureModeStatus(config: StreamCaptureConfig?, pending: StreamCaptureConfig?, notice: Int?) {
config?.let { Text("${it.mode.label} · ${it.mode.videoCodec.label} · " + stringResource(R.string.stream_bitrate_value, it.bitrate / 1_000_000.0),
style = MaterialTheme.typography.bodySmall) }
if (pending != null) Text(stringResource(if (config == null) R.string.stream_connecting else R.string.stream_switching))
notice?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) }
}
@@ -215,89 +217,154 @@ private fun StreamTxPanel(id: Long) {
}
}
private enum class StreamOption { CHANNEL, CAMERA, CODEC, BITRATE, RESOLUTION, ORIENTATION }
/** Одна строка управления: значки отражают применённый режим, меню сохраняют все варианты выбора. */
@Composable
private fun CaptureOptions(cameras: List<StreamCamera>, config: StreamCaptureConfig?, enabled: Boolean, display: Int,
onChange: (StreamCaptureConfig) -> Unit) {
private fun StreamCaptureToolbar(channelName: String, cameras: List<StreamCamera>, config: StreamCaptureConfig?, display: Int,
enabled: Boolean, audio: Boolean, audioEnabled: Boolean, onAudioChange: (Boolean) -> Unit,
onChange: (StreamCaptureConfig) -> Unit) {
val mode = config?.mode
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
cameras.forEach { camera ->
FilterChip(selected = camera.id == mode?.camera, enabled = enabled, onClick = {
StreamCameraModes.choose(camera.modes, mode)?.let { onChange(config?.withMode(it) ?: StreamCaptureConfig(it)) }
}, label = { Text("${camera.lens.label} ${camera.lens.mm}".trim()) })
}
}
val camera = cameras.firstOrNull { it.id == mode?.camera }
val allModes = camera?.modes.orEmpty()
val modes = allModes.filter { it.videoCodec == mode?.videoCodec }
var codecsExpanded by remember { mutableStateOf(false) }
var bitrateExpanded by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
OutlinedButton(onClick = { codecsExpanded = true }, enabled = enabled && config != null, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.stream_video_codec, mode?.videoCodec?.label.orEmpty()))
var menu by remember { mutableStateOf<StreamOption?>(null) }
LaunchedEffect(enabled) { if (!enabled && menu != StreamOption.CHANNEL) menu = null }
val locale = LocalConfiguration.current.locales[0]
val shortBitrate = config?.let {
val mega = it.bitrate >= 1_000_000
val value = NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = if (mega && it.bitrate < 10_000_000) 1 else 0
isGroupingUsed = false
}.format(it.bitrate.toDouble() / if (mega) 1_000_000 else 1_000)
stringResource(if (mega) R.string.stream_bitrate_short_m else R.string.stream_bitrate_short_k, value)
} ?: "—"
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 = codecsExpanded && enabled, onDismissRequest = { codecsExpanded = false }) {
allModes.map { it.videoCodec }.distinct().sortedBy { it.id }.forEach { codec ->
DropdownMenuItem(text = { Text(codec.label) }, onClick = {
codecsExpanded = false
StreamCameraModes.choose(allModes.filter { it.videoCodec == codec }, mode)?.let { next ->
config?.let { onChange(it.withMode(next)) }
}
DropdownMenu(expanded = menu == StreamOption.CHANNEL, onDismissRequest = { menu = null }) {
DropdownMenuItem(text = { Text(channelName) }, onClick = { menu = null })
}
}
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)
}
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) }
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 = {
menu = null
val next = StreamCameraModes.choose(option.modes, mode)
if (next != null) onChange(config?.withMode(next) ?: StreamCaptureConfig(next))
else LogManager.addLog("ERROR", "video", "camera selection has no compatible mode camera=${option.id}")
})
}
}
}
Box(Modifier.weight(1f)) {
OutlinedButton(onClick = { bitrateExpanded = true }, enabled = enabled && config != null, modifier = Modifier.fillMaxWidth()) {
Text(if (config?.videoBitrate == null) stringResource(R.string.stream_bitrate_auto)
else stringResource(R.string.stream_bitrate_value, config.bitrate / 1_000_000.0))
Box {
StreamControlButton(stringResource(R.string.stream_video_codec, mode?.videoCodec?.label ?: "—"),
enabled && config != null, onClick = { menu = StreamOption.CODEC }) {
StreamValueIcon(mode?.videoCodec?.name ?: "—")
}
DropdownMenu(expanded = bitrateExpanded && enabled, onDismissRequest = { bitrateExpanded = false }) {
DropdownMenuItem(text = { Text(stringResource(R.string.stream_bitrate_auto)) }, onClick = {
bitrateExpanded = false; config?.let { onChange(it.copy(videoBitrate = null)) }
DropdownMenu(expanded = menu == StreamOption.CODEC && enabled, onDismissRequest = { menu = null }) {
allModes.map { it.videoCodec }.distinct().sortedBy { it.id }.forEach { codec ->
StreamOptionItem(codec.label, codec == mode?.videoCodec, onClick = {
menu = null
val next = StreamCameraModes.choose(allModes.filter { it.videoCodec == codec }, mode)
if (next != null) config?.let { onChange(it.withMode(next)) }
else LogManager.addLog("ERROR", "video", "codec selection has no compatible mode camera=${camera?.id} codec=$codec")
})
}
}
}
Box {
StreamControlButton(bitrateDescription, enabled && config != null, onClick = { menu = StreamOption.BITRATE }) {
StreamValueIcon(shortBitrate, automatic = config != null && config.videoBitrate == null)
}
DropdownMenu(expanded = menu == StreamOption.BITRATE && enabled, onDismissRequest = { menu = null }) {
StreamOptionItem(stringResource(R.string.stream_bitrate_auto), config?.videoBitrate == null, onClick = {
menu = null; config?.let { onChange(it.copy(videoBitrate = null)) }
})
mode?.let { m ->
listOf(256000, 512000, 1000000, 2000000, 4000000, 6000000, 8000000, 12000000, 16000000, 24000000, 32000000, 48000000, 64000000)
.map { it.coerceIn(m.minimumBitrate, m.maximumBitrate) }.distinct().forEach { value ->
DropdownMenuItem(text = { Text(stringResource(R.string.stream_bitrate_value, value / 1_000_000.0)) }, onClick = {
bitrateExpanded = false; config?.let { onChange(it.copy(videoBitrate = value)) }
})
StreamOptionItem(stringResource(R.string.stream_bitrate_value, value / 1_000_000.0),
config?.videoBitrate == value, onClick = {
menu = null; config?.let { onChange(it.copy(videoBitrate = value)) }
})
}
}
}
}
}
var expanded by remember { mutableStateOf(false) }
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
Box {
OutlinedButton(onClick = { expanded = true }, enabled = enabled && modes.isNotEmpty()) { Text(mode?.label.orEmpty()) }
DropdownMenu(expanded = expanded && enabled, onDismissRequest = { expanded = false }) {
modes.forEach { option -> DropdownMenuItem(text = { Text(option.label) }, onClick = {
expanded = false; config?.let { onChange(it.withMode(option)) }
StreamControlButton(stringResource(R.string.stream_resolution, mode?.label ?: "—"), enabled && modes.isNotEmpty(),
onClick = { menu = StreamOption.RESOLUTION }) { Icon(Icons.Filled.AspectRatio, contentDescription = null) }
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)) }
}) }
}
}
for (option in listOf(StreamOrientation.UPRIGHT, StreamOrientation.RIGHT, StreamOrientation.LEFT)) {
val active = config?.orientation == option
val label = stringResource(R.string.stream_orientation, option.degrees)
val top = if (camera?.lens?.type == LensType.SELFIE) (display - option.degrees + 360) % 360
else (option.degrees - display + 360) % 360
OutlinedButton(onClick = { config?.let { onChange(it.copy(orientation = option)) } }, enabled = enabled && config != null,
modifier = Modifier.size(44.dp).semantics { selected = active; contentDescription = label },
shape = RoundedCornerShape(6.dp), contentPadding = PaddingValues(2.dp),
border = BorderStroke(if (active) 2.dp else 1.dp,
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (active) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
StreamRotationMark(top)
Text("${option.degrees}°", fontSize = 10.sp, lineHeight = 10.sp)
Box {
val degrees = config?.orientation?.degrees ?: 0
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) }
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) })
}
}
}
}
}
/** Единый размер касания, подсказки и семантика для обычных кнопок и переключателя звука. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StreamControlButton(description: String, enabled: Boolean = true, checked: Boolean? = null,
onClick: () -> Unit, icon: @Composable () -> Unit) {
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) {
Box(Modifier.clearAndSetSemantics {}) { icon() }
} else IconToggleButton(checked = checked, onCheckedChange = { onClick() }, enabled = enabled, modifier = modifier) {
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)
}
}
/** Текущее значение отмечено галочкой и доступно средствам чтения экрана. */
@Composable
private fun StreamOptionItem(label: String, active: Boolean, onClick: () -> Unit, icon: @Composable (() -> Unit)? = null) {
DropdownMenuItem(text = { Text(label) }, onClick = onClick, leadingIcon = icon,
trailingIcon = { if (active) Icon(Icons.Filled.Check, contentDescription = null) }, modifier = Modifier.semantics { selected = active })
}
@Composable
private fun StreamRotationMark(top: Int) {
val frame = LocalContentColor.current
@@ -306,14 +373,10 @@ private fun StreamRotationMark(top: Int) {
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
drawRoundRect(frame, Offset(left, upper), Size(right - left, lower - upper), CornerRadius(2.dp.toPx()), style = Stroke(1.dp.toPx()))
val edge = when (top) {
90 -> Offset(right, upper) to Offset(right, lower)
180 -> Offset(left, lower) to Offset(right, lower)
270 -> Offset(left, upper) to Offset(left, lower)
else -> Offset(left, upper) to Offset(right, upper)
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)
}
drawLine(marker, edge.first, edge.second, 3.dp.toPx(), StrokeCap.Round)
}
Text(stringResource(R.string.stream_top), fontSize = 7.sp, lineHeight = 7.sp)
}
@@ -27,6 +27,16 @@
<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_resolution">Разрешение: %1$s</string>
<string name="stream_bitrate">Битрейт видео: %1$s</string>
<string name="stream_bitrate_auto_value">Битрейт видео: Авто (%1$s)</string>
<string name="stream_bitrate_short_k">%1$sК</string>
<string name="stream_bitrate_short_m">%1$sМ</string>
<string name="stream_bitrate_auto_mark">А</string>
<string name="stream_live">Прямая трансляция</string>
<string name="stream_connecting">Подключение…</string>
<string name="stream_stopping">Завершение трансляции…</string>
@@ -27,6 +27,16 @@
<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_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>
<string name="stream_bitrate_short_k">%1$sK</string>
<string name="stream_bitrate_short_m">%1$sM</string>
<string name="stream_bitrate_auto_mark">A</string>
<string name="stream_live">Streaming live</string>
<string name="stream_connecting">Connecting…</string>
<string name="stream_stopping">Finishing stream…</string>