Add titled one-tap menus to stream capture and audio controls

This commit is contained in:
evgeny committed 2026-10-28 12:08:07 +01:00
1 parent 211f8f4c5d
commit 9f1c1cf9e4
4 files changed
+71 -30

No files matched your search

@@ -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
@@ -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<StreamCaptureConfig?>(null) }
var cameras by remember { mutableStateOf<List<StreamCamera>>(emptyList()) }
var loadingModes by remember { mutableStateOf(true) }
var menu by remember(group) { mutableStateOf<StreamOption?>(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<StreamCamera>, 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<StreamOption?>(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<StreamCamera>, 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<StreamCamera>, 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<StreamCamera>, 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<StreamCamera>, 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<StreamCamera>, 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) })
}
}
@@ -551,6 +551,7 @@
<string name="stream_switching">Переключение камеры, режима или ориентации…</string>
<string name="stream_no_modes">Нет совместимой камеры или видеокодировщика</string>
<string name="stream_orientation">Поворот: %1$d°</string>
<string name="stream_orientation_title">Поворот</string>
<string name="stream_orientation_auto">Поворот: Авто</string>
<string name="broadcast">Трансляция</string>
<string name="stream_subblocks">Всего субблоков</string>
@@ -596,6 +597,7 @@
<string name="block_incoming_radio">Отклонять все вызовы по рации</string>
<string name="block_incoming_radio_description">Отклонять приглашения; включённая рация продолжит работать</string>
<string name="media_audio_devices">Аудиоустройство: %1$s</string>
<string name="media_audio_title">Аудиоустройство</string>
<string name="media_audio_auto">Автоматически</string>
<string name="media_audio_phone">Телефон</string>
<string name="media_audio_wired">Проводное / USB</string>
@@ -550,6 +550,7 @@
<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_orientation_title">Rotation</string>
<string name="stream_orientation_auto">Rotation: Auto</string>
<string name="broadcast">Broadcast</string>
<string name="stream_subblocks">Total subblocks</string>
@@ -595,6 +596,7 @@
<string name="block_incoming_radio">Reject all radio calls</string>
<string name="block_incoming_radio_description">Reject radio invitations; the channel radio keeps working</string>
<string name="media_audio_devices">Audio device: %1$s</string>
<string name="media_audio_title">Audio device</string>
<string name="media_audio_auto">Automatic</string>
<string name="media_audio_phone">Phone</string>
<string name="media_audio_wired">Wired / USB</string>