From d0bbe8444e252a5a67e1cdc22c12608ef12886d7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 19 Oct 2026 12:08:57 +0200 Subject: [PATCH] Refine Android stream icons and show channel in title --- .../com/utun/chat/ui/screens/StreamScreen.kt | 174 +++++++++++++----- .../app/src/main/res/values-ru/strings.xml | 4 +- .../app/src/main/res/values/strings.xml | 4 +- 3 files changed, 135 insertions(+), 47 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt index 40384db7..ab3116aa 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt @@ -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 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, config: StreamCaptureConfig?, display: Int, +private fun StreamCaptureToolbar(cameras: List, 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(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 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 @@ -288,7 +295,8 @@ private fun StreamCaptureToolbar(channelName: String, cameras: List 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 { + 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) } } diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 29019336..4a61b21a 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -18,6 +18,7 @@ Очередь видео: %1$.1f КиБ · Неподтверждено: %2$.1f КиБ Буфер отправки: %1$.1f КиБ · ACK / 1 с: %2$.1f КиБ (%3$.2f Мбит/с) Трансляции + Трансляция: %1$s Трансляции: %1$d Выберите трансляцию Не удалось получить список трансляций @@ -27,10 +28,10 @@ Начать трансляцию Остановить трансляцию Звук трансляции - Канал: %1$s Звук трансляции включён Звук трансляции выключен Камера: %1$s + %1$dмм Разрешение: %1$s Битрейт видео: %1$s Битрейт видео: Авто (%1$s) @@ -501,7 +502,6 @@ Переключение камеры, режима или ориентации… Нет совместимой камеры или видеокодировщика Поворот: %1$d° - Верх Трансляция Всего субблоков Избыточных субблоков RS diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 90c9a7dd..71d58845 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ Video queue: %1$.1f KiB · Unacknowledged: %2$.1f KiB Send buffer: %1$.1f KiB · ACK / 1 s: %2$.1f KiB (%3$.2f Mbps) Live streams + Stream: %1$s Live streams: %1$d Choose a stream Could not load live streams @@ -27,10 +28,10 @@ Start stream Stop stream Stream audio - Channel: %1$s Stream audio on Stream audio off Camera: %1$s + %1$dmm Resolution: %1$s Video bitrate: %1$s Video bitrate: Auto (%1$s) @@ -500,7 +501,6 @@ Switching camera, video mode or orientation… No compatible camera or video encoder found Rotation: %1$d° - Top Broadcast Total subblocks Redundant RS subblocks