Expand Android multiview tiles on tap without restarting playback
This commit is contained in:
1 parent
d7d018d591
commit
b46f13920c
2 files changed
+92
-43
No files matched your search
+57
-20
@@ -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<StreamSource>, unavailable: Boolean, onClose: () -> Unit) {
|
||||
val tiles = remember(group) { mutableStateListOf<GridStream>() }
|
||||
var expanded by remember(group) { mutableStateOf<GridStream?>(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<StreamSo
|
||||
}
|
||||
}
|
||||
Scaffold(topBar = {
|
||||
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)) } })
|
||||
}) { padding ->
|
||||
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<StreamSo
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamGridTile(group: Long, tile: GridStream, visible: Boolean) {
|
||||
private fun StreamGridTile(group: Long, tile: GridStream, visible: Boolean, expanded: Boolean, covered: Boolean, onClick: () -> 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))
|
||||
}
|
||||
}
|
||||
+35
-23
@@ -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<Activity>().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<Activity>().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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user