diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt index 9a46a13f..b4fb2757 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt @@ -1,7 +1,10 @@ package com.utun.chat.ui.screens import android.os.SystemClock +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -11,9 +14,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp @@ -42,6 +48,19 @@ private class GridStream(val source: StreamSource) { @Composable internal fun StreamGridScreen(group: Long, title: String, sources: List, unavailable: Boolean, onClose: () -> Unit) { val tiles = remember(group) { mutableStateListOf() } + var expanded by remember(group) { mutableStateOf(null) } + val focused = expanded?.takeIf { it in tiles } + BackHandler(enabled = focused != null) { expanded = null } + if (focused != null) StreamFullscreenWindow(focused.source.id, focused.source.epoch) + LaunchedEffect(expanded, tiles.toList()) { + if (expanded != null && expanded !in tiles) { + LogManager.addLog("INFO", "stream", "multiview expanded source removed group=$group source=${expanded?.source?.id}") + expanded = null + } + } + LaunchedEffect(focused) { + LogManager.addLog("INFO", "stream", "multiview focus group=$group source=${focused?.source?.id} epoch=${focused?.source?.epoch}") + } val owner = LocalLifecycleOwner.current var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } DisposableEffect(owner) { @@ -84,29 +103,41 @@ internal fun StreamGridScreen(group: Long, title: String, sources: List + if (focused == null) + TopAppBar(title = { Text(stringResource(R.string.stream_all_title, title), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { IconButton(onClick = onClose) { Icon(Icons.Filled.Close, stringResource(R.string.stream_close_view)) } }) + }, contentWindowInsets = if (focused == null) ScaffoldDefaults.contentWindowInsets else WindowInsets(0, 0, 0, 0)) { padding -> Column(Modifier.fillMaxSize().padding(padding).background(Color.Black)) { - if (unavailable) Text(stringResource(R.string.stream_list_unavailable), color = Color.White, modifier = Modifier.padding(8.dp)) + if (unavailable && focused == null) + Text(stringResource(R.string.stream_list_unavailable), color = Color.White, modifier = Modifier.padding(8.dp)) if (tiles.isEmpty()) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.stream_waiting_sources), color = Color.White) } else BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { val shape = streamGridShape(tiles.size, maxWidth.value.toDouble(), maxHeight.value.toDouble()) - LaunchedEffect(shape, tiles.size) { - LogManager.addLog("INFO", "stream", "multiview layout group=$group tiles=${tiles.size} columns=${shape.columns} rows=${shape.rows}") + LaunchedEffect(shape, tiles.size, focused, maxWidth, maxHeight) { + LogManager.addLog("INFO", "stream", "multiview layout group=$group tiles=${tiles.size} " + + "columns=${shape.columns} rows=${shape.rows} focused=${focused?.source?.id} size=${maxWidth}x$maxHeight") } Layout(content = { - for (tile in tiles) key(tile.source.id, tile.source.epoch) { StreamGridTile(group, tile, visible) } + for (tile in tiles) key(tile.source.id, tile.source.epoch) { + StreamGridTile(group, tile, visible, tile === focused, focused != null && tile !== focused) { + expanded = if (tile === focused) null else tile + } + } }, modifier = Modifier.fillMaxSize()) { children, constraints -> val width = constraints.maxWidth val height = constraints.maxHeight val cellWidth = width / shape.columns val cellHeight = height / shape.rows - val measured = children.map { it.measure(Constraints.fixed(cellWidth, cellHeight)) } + val selected = tiles.indexOf(focused) + val measured = children.mapIndexed { index, child -> + child.measure(if (index == selected) Constraints.fixed(width, height) else Constraints.fixed(cellWidth, cellHeight)) + } layout(width, height) { + // Все TextureView остаются размещёнными; выбранная плитка закрывает остальные непрозрачным фоном. measured.forEachIndexed { index, child -> - child.place((index % shape.columns) * cellWidth, (index / shape.columns) * cellHeight) + if (index == selected) child.place(0, 0, zIndex = 1f) + else child.place((index % shape.columns) * cellWidth, (index / shape.columns) * cellHeight) } } } @@ -116,11 +147,12 @@ internal fun StreamGridScreen(group: Long, title: String, sources: List Unit) { var aspect by remember(tile.source.id, tile.source.epoch) { mutableStateOf(16f / 9) } - BoxWithConstraints(Modifier.fillMaxSize().padding(2.dp).clipToBounds().background(Color(0xff151515))) { - val width = minOf(maxWidth.value, maxHeight.value * aspect) - val height = width / aspect + BoxWithConstraints(Modifier.fillMaxSize().padding(if (expanded) 0.dp else 2.dp).clipToBounds() + .background(if (expanded) Color.Black else Color(0xff151515)) + .then(if (covered) Modifier.clearAndSetSemantics {} else Modifier)) { + val fit = fitVideoFrame(maxWidth.value, maxHeight.value, aspect, expanded) if (visible) key(tile.attempt) { StreamViewerPlayback(group, tile.source.id, tile.source.epoch, onAspect = { if (it > 0) aspect = it }, @@ -128,15 +160,20 @@ private fun StreamGridTile(group: Long, tile: GridStream, visible: Boolean) { tile.status = it if (it == R.string.stream_live) { tile.failures = 0; tile.retryAt = null } }, - modifier = Modifier.requiredSize(width.dp, height.dp).align(Alignment.Center), + modifier = Modifier.requiredSize(fit.width.dp, fit.height.dp).align(Alignment.Center) + .graphicsLayer { rotationZ = fit.rotation }, onEngine = { tile.engine = it }) } - Column(Modifier.align(Alignment.TopStart).fillMaxWidth().background(Color.Black.copy(alpha = 0.5f)).padding(4.dp)) { - Text(tile.source.displayName, color = Color.White, style = MaterialTheme.typography.labelSmall, - maxLines = 1, overflow = TextOverflow.Ellipsis) - if (tile.status != R.string.stream_live) - Text(stringResource(tile.status), color = Color.White, style = MaterialTheme.typography.labelSmall, - maxLines = 2, overflow = TextOverflow.Ellipsis) + if (!expanded || tile.status != R.string.stream_live) { + Column(Modifier.align(Alignment.TopStart).fillMaxWidth().background(Color.Black.copy(alpha = 0.5f)).padding(4.dp)) { + Text(tile.source.displayName, color = Color.White, style = MaterialTheme.typography.labelSmall, + maxLines = 1, overflow = TextOverflow.Ellipsis) + if (tile.status != R.string.stream_live) + Text(stringResource(tile.status), color = Color.White, style = MaterialTheme.typography.labelSmall, + maxLines = 2, overflow = TextOverflow.Ellipsis) + } } + Box(Modifier.matchParentSize().clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, + role = Role.Button, onClickLabel = stringResource(if (expanded) R.string.collapse else R.string.expand), onClick = onClick)) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt index 3197e86c..1691995c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt @@ -44,10 +44,7 @@ import kotlinx.coroutines.delay /** Полноэкранный viewer: видео разворачивается вдоль окна, ориентацию экрана не меняем. */ @Composable internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: String, name: String, onClose: () -> Unit) { - val context = LocalContext.current - val activity = remember(context) { - generateSequence(context) { (it as? ContextWrapper)?.baseContext }.filterIsInstance().firstOrNull() - } + StreamFullscreenWindow(source, epoch) val owner = LocalLifecycleOwner.current var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } var aspect by remember { mutableStateOf(0f) } @@ -64,24 +61,6 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S owner.lifecycle.addObserver(observer) onDispose { owner.lifecycle.removeObserver(observer) } } - DisposableEffect(activity) { - val window = activity?.window - val controller = window?.let { WindowCompat.getInsetsController(it, it.decorView) } - val insets = window?.let { ViewCompat.getRootWindowInsets(it.decorView) } - val statusVisible = insets?.isVisible(WindowInsetsCompat.Type.statusBars()) ?: true - val navigationVisible = insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) ?: true - val behavior = controller?.systemBarsBehavior - controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - controller?.hide(WindowInsetsCompat.Type.systemBars()) - LogManager.addLog("INFO", "stream", "viewer fullscreen source=$source epoch=$epoch " + - "screenOrientation=${activity?.requestedOrientation}") - onDispose { - if (behavior != null) controller?.systemBarsBehavior = behavior - if (statusVisible) controller?.show(WindowInsetsCompat.Type.statusBars()) else controller?.hide(WindowInsetsCompat.Type.statusBars()) - if (navigationVisible) controller?.show(WindowInsetsCompat.Type.navigationBars()) else controller?.hide(WindowInsetsCompat.Type.navigationBars()) - LogManager.addLog("INFO", "stream", "viewer window restored source=$source") - } - } LaunchedEffect(status) { controls = true if (status == R.string.stream_ended) { @@ -143,6 +122,33 @@ internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: S } } +/** Общий immersive-режим одиночного viewer и развёрнутой плитки; при выходе восстанавливает панели окна. */ +@Composable +internal fun StreamFullscreenWindow(source: Long, epoch: Long) { + val context = LocalContext.current + val activity = remember(context) { + generateSequence(context) { (it as? ContextWrapper)?.baseContext }.filterIsInstance().firstOrNull() + } + DisposableEffect(activity, source, epoch) { + val window = activity?.window + val controller = window?.let { WindowCompat.getInsetsController(it, it.decorView) } + val insets = window?.let { ViewCompat.getRootWindowInsets(it.decorView) } + val statusVisible = insets?.isVisible(WindowInsetsCompat.Type.statusBars()) ?: true + val navigationVisible = insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) ?: true + val behavior = controller?.systemBarsBehavior + controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller?.hide(WindowInsetsCompat.Type.systemBars()) + LogManager.addLog("INFO", "stream", "viewer fullscreen source=$source epoch=$epoch " + + "screenOrientation=${activity?.requestedOrientation}") + onDispose { + if (behavior != null) controller?.systemBarsBehavior = behavior + if (statusVisible) controller?.show(WindowInsetsCompat.Type.statusBars()) else controller?.hide(WindowInsetsCompat.Type.statusBars()) + if (navigationVisible) controller?.show(WindowInsetsCompat.Type.navigationBars()) else controller?.hide(WindowInsetsCompat.Type.navigationBars()) + LogManager.addLog("INFO", "stream", "viewer window restored source=$source") + } + } +} + /** Смена размеров окна и скрытие controls сохраняют TextureView, decoder и сетевой reader. */ @Composable internal fun StreamViewerPlayback(group: Long, source: Long, epoch: Long, onAspect: (Float) -> Unit, onStatus: (Int) -> Unit, @@ -158,6 +164,7 @@ internal fun StreamViewerPlayback(group: Long, source: Long, epoch: Long, onAspe resources.surface = null; resources.texture = null val currentEngine = resources.engine resources.engine = null + LogManager.addLog("INFO", "stream", "viewer surface release source=$source epoch=$epoch texture=${System.identityHashCode(currentTexture)}") if (currentEngine != null) currentEngine.stop { surface.release(); currentTexture.release() } else { surface.release(); currentTexture.release() } } @@ -173,9 +180,14 @@ internal fun StreamViewerPlayback(group: Long, source: Long, epoch: Long, onAspe val player = StreamPlayerEngine(group, source, epoch) val surface = Surface(texture) resources.texture = texture; resources.surface = surface; resources.engine = player + LogManager.addLog("INFO", "stream", "viewer surface available source=$source epoch=$epoch " + + "texture=${System.identityHashCode(texture)} size=${width}x$height") engine = player; onEngine(player); player.start(surface) } - override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) {} + override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { + LogManager.addLog("DEBUG", "stream", "viewer surface resized source=$source " + + "texture=${System.identityHashCode(texture)} size=${width}x$height") + } override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { releaseSurface(texture); return false }