Compact Android stream controls into one icon toolbar
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user