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 0f232e6f..40384db7 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,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 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, config: StreamCaptureConfig?, enabled: Boolean, display: Int, - onChange: (StreamCaptureConfig) -> Unit) { +private fun StreamCaptureToolbar(channelName: String, cameras: List, 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(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) } 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 81621649..29019336 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 @@ -27,6 +27,16 @@ Начать трансляцию Остановить трансляцию Звук трансляции + Канал: %1$s + Звук трансляции включён + Звук трансляции выключен + Камера: %1$s + Разрешение: %1$s + Битрейт видео: %1$s + Битрейт видео: Авто (%1$s) + %1$sК + %1$sМ + А Прямая трансляция Подключение… Завершение трансляции… 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 fed0c5ed..90c9a7dd 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -27,6 +27,16 @@ Start stream Stop stream Stream audio + Channel: %1$s + Stream audio on + Stream audio off + Camera: %1$s + Resolution: %1$s + Video bitrate: %1$s + Video bitrate: Auto (%1$s) + %1$sK + %1$sM + A Streaming live Connecting… Finishing stream…