Refine Android stream icons and show channel in title
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user