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 ac402a89..6cf93921 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 @@ -9,25 +9,34 @@ 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.Canvas import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment -import androidx.compose.ui.draw.rotate +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.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics 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.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle @@ -166,10 +175,13 @@ private fun CaptureOptions(cameras: List, config: StreamCaptureCon }, label = { Text("${camera.lens.label} ${camera.lens.mm}".trim()) }) } } - val modes = cameras.firstOrNull { it.id == mode?.camera }?.modes.orEmpty() + val camera = cameras.firstOrNull { it.id == mode?.camera } + val modes = camera?.modes.orEmpty() + val context = LocalContext.current + val configuration = LocalConfiguration.current + val display = remember(configuration) { ((context as? Activity)?.windowManager?.defaultDisplay?.rotation ?: 0) * 90 } var expanded by remember { mutableStateOf(false) } - var orientationsExpanded by remember { mutableStateOf(false) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + 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 }) { @@ -178,16 +190,45 @@ private fun CaptureOptions(cameras: List, config: StreamCaptureCon }) } } } - Box { - OutlinedButton(onClick = { orientationsExpanded = true }, enabled = enabled && config != null) { - Text(stringResource(R.string.stream_orientation, config?.orientation?.degrees ?: 0)) + 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) + } } - DropdownMenu(expanded = orientationsExpanded && enabled, onDismissRequest = { orientationsExpanded = false }) { - StreamOrientation.values().forEach { option -> DropdownMenuItem(text = { Text("${option.degrees}°") }, onClick = { - orientationsExpanded = false; config?.let { onChange(it.copy(orientation = option)) } - }) } + } + } +} + +@Composable +private fun StreamRotationMark(top: Int) { + val frame = LocalContentColor.current + val marker = MaterialTheme.colorScheme.error + Box(Modifier.size(24.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 + 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) } + drawLine(marker, edge.first, edge.second, 3.dp.toPx(), StrokeCap.Round) } + Text(stringResource(R.string.stream_top), fontSize = 7.sp, lineHeight = 7.sp) } } @@ -215,7 +256,6 @@ private fun LiveStreamView(group: Long, source: Long, epoch: Long, localPreview: private fun LiveStreamSurface(group: Long, source: Long, epoch: Long, localPreview: StreamPreview?, displayDegrees: Int) { val engine = remember(group, source, epoch, localPreview) { StreamPlayerEngine(group, source, epoch, localPreview, displayDegrees) } val aspect by engine.aspect.collectAsState() - val top by engine.top.collectAsState() val status by engine.status.collectAsState() var surface by remember { mutableStateOf(null) } DisposableEffect(engine) { onDispose { engine.stop() } } @@ -240,25 +280,8 @@ private fun LiveStreamSurface(group: Long, source: Long, epoch: Long, localPrevi }, modifier = Modifier.matchParentSize(), onRelease = { view -> engine.stop(); view.surfaceTextureListener = null; surface?.release(); surface = null }) - top?.let { StreamTopMarker(it) } } } } } -@Composable -private fun BoxScope.StreamTopMarker(direction: Int) { - val alignment = when (direction) { - 90 -> Alignment.CenterEnd - 180 -> Alignment.BottomCenter - 270 -> Alignment.CenterStart - else -> Alignment.TopCenter - } - Surface(modifier = Modifier.align(alignment).padding(8.dp), shape = RoundedCornerShape(6.dp), color = Color.Black.copy(alpha = 0.65f)) { - Row(Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Filled.ArrowUpward, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp).rotate(direction.toFloat())) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.stream_top), color = Color.White, style = MaterialTheme.typography.bodySmall) - } - } -}