From 9f1c1cf9e4cff2f6c1fa5422a80396c94ff5334e Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 28 Oct 2026 10:19:21 +0100 Subject: [PATCH] Add titled one-tap menus to stream capture and audio controls --- .../ui/components/MediaAudioDeviceButton.kt | 29 ++++++-- .../com/utun/chat/ui/screens/StreamScreen.kt | 68 ++++++++++++------- .../app/src/main/res/values-ru/strings.xml | 2 + .../app/src/main/res/values/strings.xml | 2 + 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt index 55ace16d..6d1a1d3e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MediaAudioDeviceButton.kt @@ -5,43 +5,58 @@ import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.HeadsetMic import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties import androidx.core.content.ContextCompat import com.utun.chat.R import com.utun.chat.data.MediaAudioController import com.utun.chat.data.MediaAudioProfile import com.utun.chat.data.LogManager -/** Один выбор для панели рации, публикации и всех экранов просмотра. */ +/** Один выбор для рации и трансляций; при заданном expanded состоянием и закрытием управляет владелец. */ @Composable -fun MediaAudioDeviceButton(tint: Color = LocalContentColor.current, onExpandedChange: (Boolean) -> Unit = {}) { +fun MediaAudioDeviceButton(tint: Color = LocalContentColor.current, expanded: Boolean? = null, + properties: PopupProperties = PopupProperties(focusable = true), onExpandedChange: (Boolean) -> Unit = {}) { val state by MediaAudioController.state.collectAsState() val context = LocalContext.current - var expanded by remember { mutableStateOf(false) } + var localExpanded by remember { mutableStateOf(false) } + val menuExpanded = expanded ?: localExpanded val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) MediaAudioController.select(MediaAudioProfile.BLUETOOTH) else LogManager.addLog("WARN", "media_audio", "Bluetooth selection cancelled: BLUETOOTH_CONNECT denied") } val bluetoothPermission = Build.VERSION.SDK_INT < 31 || ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED - fun expand(value: Boolean) { expanded = value; onExpandedChange(value) } + fun expand(value: Boolean) { + if (expanded == null) localExpanded = value + onExpandedChange(value) + LogManager.addLog("DEBUG", "media_audio", "Device menu expanded=$value preferred=${state.preferred} active=${state.active}") + } val labels = mapOf(MediaAudioProfile.AUTO to R.string.media_audio_auto, MediaAudioProfile.PHONE to R.string.media_audio_phone, MediaAudioProfile.WIRED to R.string.media_audio_wired, MediaAudioProfile.BLUETOOTH to R.string.media_audio_bluetooth) Box { - IconButton(onClick = { expand(true) }) { + IconButton(onClick = { expand(!menuExpanded) }) { Icon(if (state.active == MediaAudioProfile.PHONE) Icons.AutoMirrored.Filled.VolumeUp else Icons.Filled.HeadsetMic, stringResource(R.string.media_audio_devices, stringResource(labels.getValue(state.preferred))), tint = if (state.notice != null) MaterialTheme.colorScheme.error else tint) } - DropdownMenu(expanded, onDismissRequest = { expand(false) }) { + DropdownMenu(menuExpanded, properties = properties, onDismissRequest = { expand(false) }) { + Text(stringResource(R.string.media_audio_title), + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).semantics { heading() }, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + HorizontalDivider() for ((profile, label) in labels) { val available = when (profile) { MediaAudioProfile.WIRED -> state.wiredAvailable 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 20241c3a..bd1c4224 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 @@ -18,6 +18,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState @@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalLifecycleOwner @@ -49,6 +51,7 @@ 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 +import androidx.compose.ui.window.PopupProperties import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -121,6 +124,7 @@ private fun StreamPublishScreen(group: Long, channelName: String, onBack: () -> var config by remember { mutableStateOf(null) } var cameras by remember { mutableStateOf>(emptyList()) } var loadingModes by remember { mutableStateOf(true) } + var menu by remember(group) { mutableStateOf(null) } LaunchedEffect(Unit) { val saved = try { ChatApplication.instance.configProvider.getStreamCaptureSettings() } catch (e: CancellationException) { throw e } @@ -157,7 +161,7 @@ private fun StreamPublishScreen(group: Long, channelName: String, onBack: () -> } val title = stringResource(R.string.stream_channel_title, channelName) - Scaffold(topBar = { + Scaffold(modifier = Modifier.pointerInput(menu) { if (menu != null) detectTapGestures { menu = null } }, topBar = { TopAppBar(title = { TooltipBox(positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), tooltip = { PlainTooltip { Text(title) } }, state = rememberTooltipState()) { @@ -174,6 +178,7 @@ private fun StreamPublishScreen(group: Long, channelName: String, onBack: () -> enabled = if (ongoing != null) ongoing.phase != StreamPhase.STOPPING else !loadingModes && config != null && group != 0L && state?.ongoing != true, containerColor = if (ongoing != null) Color(0xFFB32632) else Color(0xFF24633D), onClick = { + menu = null if (ongoing != null) { LogManager.addLog("INFO", "stream", "screen stop group=$group id=${ongoing.id} phase=${ongoing.phase}") StreamController.stop() @@ -194,6 +199,7 @@ private fun StreamPublishScreen(group: Long, channelName: String, onBack: () -> }) { padding -> Column(Modifier.fillMaxSize().padding(padding).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { StreamCaptureToolbar(cameras, shownConfig, displayDegrees, Modifier.fillMaxWidth(), + menu = menu, onMenuChange = { menu = it }, 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, @@ -241,19 +247,21 @@ private fun StreamTxPanel(id: Long) { } } -private enum class StreamOption { CAMERA, CODEC, BITRATE, RESOLUTION, ORIENTATION } +private enum class StreamOption { AUDIO_DEVICE, CAMERA, CODEC, BITRATE, RESOLUTION, ORIENTATION } /** Одна строка управления: значки отражают применённый режим, меню сохраняют все варианты выбора. */ @Composable private fun StreamCaptureToolbar(cameras: List, config: StreamCaptureConfig?, display: Int, modifier: Modifier, + menu: StreamOption?, onMenuChange: (StreamOption?) -> Unit, enabled: Boolean, audio: Boolean, audioEnabled: Boolean, onAudioChange: (Boolean) -> Unit, onChange: (StreamCaptureConfig) -> Unit) { val mode = config?.mode val camera = cameras.firstOrNull { it.id == mode?.camera } val allModes = camera?.modes.orEmpty() val modes = allModes.filter { it.videoCodec == mode?.videoCodec } - var menu by remember { mutableStateOf(null) } - LaunchedEffect(enabled) { if (!enabled) menu = null } + val menuProperties = remember { PopupProperties(focusable = false, dismissOnClickOutside = false) } + BackHandler(menu != null) { onMenuChange(null) } + LaunchedEffect(enabled) { if (!enabled) onMenuChange(null) } LaunchedEffect(menu) { LogManager.addLog("DEBUG", "stream", "toolbar menu=$menu enabled=$enabled manualBitrateRange=${mode?.manualBitrateRange}") } val shortBitrate = config?.let { captureBitrateLabel(it.bitrate) } ?: "—" val bitrateValue = config?.let { stringResource(R.string.stream_bitrate_value, it.bitrate / 1_000_000.0) } ?: "—" @@ -261,25 +269,29 @@ private fun StreamCaptureToolbar(cameras: List, config: StreamCapt stringResource(R.string.stream_bitrate_auto_value, bitrateValue) else stringResource(R.string.stream_bitrate, bitrateValue) Row(modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { - MediaAudioDeviceButton() + MediaAudioDeviceButton(expanded = menu == StreamOption.AUDIO_DEVICE, properties = menuProperties, onExpandedChange = { + if (it) onMenuChange(StreamOption.AUDIO_DEVICE) else if (menu == StreamOption.AUDIO_DEVICE) onMenuChange(null) + }) CaptureControlButton(stringResource(if (audio) R.string.stream_audio_on else R.string.stream_audio_off), - enabled = audioEnabled, checked = audio, onClick = { onAudioChange(!audio) }) { + enabled = audioEnabled, checked = audio, onClick = { onMenuChange(null); onAudioChange(!audio) }) { 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() } ?: "—" CaptureControlButton(stringResource(R.string.stream_camera, name), enabled && cameras.isNotEmpty(), - onClick = { menu = StreamOption.CAMERA }) { + onClick = { onMenuChange(if (menu == StreamOption.CAMERA) null else StreamOption.CAMERA) }) { val focal = camera?.lens?.eqFocal?.takeIf { it > 0 }?.let { stringResource(R.string.stream_camera_focal_short, it.roundToInt()) } ?: "—" CaptureValueIcon(CaptureIconShape.CAMERA, focal, front = camera?.lens?.type == LensType.SELFIE) } - DropdownMenu(expanded = menu == StreamOption.CAMERA && enabled, onDismissRequest = { menu = null }) { + DropdownMenu(expanded = menu == StreamOption.CAMERA && enabled, properties = menuProperties, + onDismissRequest = { if (menu == StreamOption.CAMERA) onMenuChange(null) }) { + CaptureMenuHeader(stringResource(R.string.camera)) cameras.forEach { option -> CaptureOptionItem("${option.lens.label} ${option.lens.mm}".trim(), option.id == mode?.camera, onClick = { - menu = null + onMenuChange(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}") @@ -289,13 +301,15 @@ private fun StreamCaptureToolbar(cameras: List, config: StreamCapt } Box { CaptureControlButton(stringResource(R.string.stream_video_codec, mode?.videoCodec?.label ?: "—"), - enabled && config != null, onClick = { menu = StreamOption.CODEC }) { + enabled && config != null, onClick = { onMenuChange(if (menu == StreamOption.CODEC) null else StreamOption.CODEC) }) { CaptureValueIcon(CaptureIconShape.CHIP, mode?.videoCodec?.name ?: "—") } - DropdownMenu(expanded = menu == StreamOption.CODEC && enabled, onDismissRequest = { menu = null }) { + DropdownMenu(expanded = menu == StreamOption.CODEC && enabled, properties = menuProperties, + onDismissRequest = { if (menu == StreamOption.CODEC) onMenuChange(null) }) { + CaptureMenuHeader(stringResource(R.string.call_video_codec)) allModes.map { it.videoCodec }.distinct().sortedBy { it.id }.forEach { codec -> CaptureOptionItem(codec.label, codec == mode?.videoCodec, onClick = { - menu = null + onMenuChange(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") @@ -304,20 +318,23 @@ private fun StreamCaptureToolbar(cameras: List, config: StreamCapt } } Box { - CaptureControlButton(bitrateDescription, enabled && config != null, onClick = { menu = StreamOption.BITRATE }) { + CaptureControlButton(bitrateDescription, enabled && config != null, + onClick = { onMenuChange(if (menu == StreamOption.BITRATE) null else StreamOption.BITRATE) }) { CaptureValueIcon(CaptureIconShape.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 }) { + DropdownMenu(expanded = menu == StreamOption.BITRATE && enabled, properties = menuProperties, + onDismissRequest = { if (menu == StreamOption.BITRATE) onMenuChange(null) }) { + CaptureMenuHeader(stringResource(R.string.capture_bitrate_title)) CaptureOptionItem(stringResource(R.string.stream_bitrate_auto), config?.videoBitrate == null, onClick = { - menu = null; config?.let { onChange(it.copy(videoBitrate = null)) } + onMenuChange(null); config?.let { onChange(it.copy(videoBitrate = null)) } }) mode?.takeUnless { it.manualBitrateRange.isEmpty() }?.let { m -> listOf(256000, 512000, 1000000, 2000000, 4000000, 6000000, 8000000, 12000000) .map { it.coerceIn(m.manualBitrateRange) }.distinct().forEach { value -> CaptureOptionItem(stringResource(R.string.stream_bitrate_value, value / 1_000_000.0), config?.videoBitrate == value, onClick = { - menu = null; config?.let { onChange(it.copy(videoBitrate = value)) } + onMenuChange(null); config?.let { onChange(it.copy(videoBitrate = value)) } }) } } @@ -325,13 +342,15 @@ private fun StreamCaptureToolbar(cameras: List, config: StreamCapt } Box { CaptureControlButton(stringResource(R.string.stream_resolution, mode?.label ?: "—"), enabled && modes.isNotEmpty(), - onClick = { menu = StreamOption.RESOLUTION }) { + onClick = { onMenuChange(if (menu == StreamOption.RESOLUTION) null else StreamOption.RESOLUTION) }) { CaptureValueIcon(CaptureIconShape.SCREEN, mode?.let { "${minOf(it.width, it.height)}p" } ?: "—", detail = mode?.let { "${it.fps}fps" }) } - DropdownMenu(expanded = menu == StreamOption.RESOLUTION && enabled, onDismissRequest = { menu = null }) { + DropdownMenu(expanded = menu == StreamOption.RESOLUTION && enabled, properties = menuProperties, + onDismissRequest = { if (menu == StreamOption.RESOLUTION) onMenuChange(null) }) { + CaptureMenuHeader(stringResource(R.string.call_video_resolution)) modes.forEach { option -> CaptureOptionItem(option.label, option == mode, onClick = { - menu = null; config?.let { onChange(it.withMode(option)) } + onMenuChange(null); config?.let { onChange(it.withMode(option)) } }) } } } @@ -341,18 +360,21 @@ private fun StreamCaptureToolbar(cameras: List, config: StreamCapt val front = camera?.lens?.type == LensType.SELFIE val top = if (front) (display - degrees + 360) % 360 else (degrees - display + 360) % 360 val label = if (auto) stringResource(R.string.stream_orientation_auto) else stringResource(R.string.stream_orientation, degrees) - CaptureControlButton(label, enabled && config != null, onClick = { menu = StreamOption.ORIENTATION }) { + CaptureControlButton(label, enabled && config != null, + onClick = { onMenuChange(if (menu == StreamOption.ORIENTATION) null else StreamOption.ORIENTATION) }) { if (auto) Icon(Icons.Filled.ScreenRotation, contentDescription = null, modifier = Modifier.size(32.dp)) else StreamRotationMark(top, degrees) } - DropdownMenu(expanded = menu == StreamOption.ORIENTATION && enabled, onDismissRequest = { menu = null }) { + DropdownMenu(expanded = menu == StreamOption.ORIENTATION && enabled, properties = menuProperties, + onDismissRequest = { if (menu == StreamOption.ORIENTATION) onMenuChange(null) }) { + CaptureMenuHeader(stringResource(R.string.stream_orientation_title)) CaptureOptionItem(stringResource(R.string.stream_orientation_auto), auto, - onClick = { menu = null; config?.let { onChange(it.copy(orientation = StreamOrientation.AUTO)) } }, + onClick = { onMenuChange(null); config?.let { onChange(it.copy(orientation = StreamOrientation.AUTO)) } }, icon = { Icon(Icons.Filled.ScreenRotation, contentDescription = null, modifier = Modifier.size(32.dp)) }) 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 CaptureOptionItem(stringResource(R.string.stream_orientation, option.degrees), config?.orientation == option, - onClick = { menu = null; config?.let { onChange(it.copy(orientation = option)) } }, + onClick = { onMenuChange(null); config?.let { onChange(it.copy(orientation = option)) } }, icon = { StreamRotationMark(optionTop, option.degrees) }) } } 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 7620948b..8be612c9 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 @@ -551,6 +551,7 @@ Переключение камеры, режима или ориентации… Нет совместимой камеры или видеокодировщика Поворот: %1$d° + Поворот Поворот: Авто Трансляция Всего субблоков @@ -596,6 +597,7 @@ Отклонять все вызовы по рации Отклонять приглашения; включённая рация продолжит работать Аудиоустройство: %1$s + Аудиоустройство Автоматически Телефон Проводное / USB 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 e5659a82..b422889f 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -550,6 +550,7 @@ Switching camera, video mode or orientation… No compatible camera or video encoder found Rotation: %1$d° + Rotation Rotation: Auto Broadcast Total subblocks @@ -595,6 +596,7 @@ Reject all radio calls Reject radio invitations; the channel radio keeps working Audio device: %1$s + Audio device Automatic Phone Wired / USB