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 new file mode 100644 index 00000000..9a46a13f --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamGridScreen.kt @@ -0,0 +1,142 @@ +package com.utun.chat.ui.screens + +import android.os.SystemClock +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.layout.Layout +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.utun.chat.R +import com.utun.chat.data.LogManager +import com.utun.chat.data.StreamPlayerEngine +import com.utun.chat.data.StreamSource +import com.utun.chat.data.streamGridShape +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +private class GridStream(val source: StreamSource) { + var status by mutableStateOf(R.string.stream_connecting) + var attempt by mutableStateOf(0) + var removeAt: Long? = null + var retryAt: Long? = null + var failures = 0 + var engine: StreamPlayerEngine? = null +} + +/** Все плитки находятся в плоском keyed Layout: смена строк/колонок не пересоздаёт TextureView. + * Пустой каталог оставляет экран открытым; временный сбой каталога сохраняет действующие readers. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun StreamGridScreen(group: Long, title: String, sources: List, unavailable: Boolean, onClose: () -> Unit) { + val tiles = remember(group) { mutableStateListOf() } + val owner = LocalLifecycleOwner.current + var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, _ -> + visible = owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + LaunchedEffect(group, sources, unavailable) { + if (unavailable) return@LaunchedEffect + val now = SystemClock.elapsedRealtime() + val current = sources.associateBy { it.id } + tiles.removeAll { tile -> current[tile.source.id]?.let { it.epoch != tile.source.epoch } == true } + for (tile in tiles) { + if (tile.source.id in current) tile.removeAt = null + else if (tile.removeAt == null) { + tile.removeAt = now + (tile.engine?.drainTimeoutMs ?: 2000) + LogManager.addLog("INFO", "stream", "multiview drains source=${tile.source.id} deadline=${tile.removeAt}") + } + } + for (source in sources) if (tiles.none { it.source.id == source.id }) tiles.add(GridStream(source)) + LogManager.addLog("INFO", "stream", "multiview catalogue group=$group active=${sources.size} tiles=${tiles.size}") + } + LaunchedEffect(group, visible) { + while (isActive) { + val now = SystemClock.elapsedRealtime() + tiles.removeAll { it.removeAt?.let { until -> it.status == R.string.stream_ended || now >= until } == true } + if (visible) for (tile in tiles) { + if (tile.removeAt != null || tile.status != R.string.stream_failed) continue + if (tile.retryAt == null) { + val pause = minOf(10000, 500 shl minOf(tile.failures++, 5)) + tile.retryAt = now + pause + LogManager.addLog("WARN", "stream", "multiview retry source=${tile.source.id} delayMs=$pause") + } else if (now >= tile.retryAt!!) { + tile.retryAt = null; tile.status = R.string.stream_connecting; tile.attempt++ + } + } + delay(250) + } + } + 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 -> + 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 (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}") + } + Layout(content = { + for (tile in tiles) key(tile.source.id, tile.source.epoch) { StreamGridTile(group, tile, visible) } + }, 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)) } + layout(width, height) { + measured.forEachIndexed { index, child -> + child.place((index % shape.columns) * cellWidth, (index / shape.columns) * cellHeight) + } + } + } + } + } + } +} + +@Composable +private fun StreamGridTile(group: Long, tile: GridStream, visible: Boolean) { + 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 + if (visible) key(tile.attempt) { + StreamViewerPlayback(group, tile.source.id, tile.source.epoch, + onAspect = { if (it > 0) aspect = it }, + onStatus = { + 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), + 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) + } + } +} 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 1d2ddb5b..a6df0384 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 @@ -74,9 +74,14 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun StreamScreen(channelId: String, channelName: String, sources: List, selected: StreamSource?, - sourcesFailed: Boolean, onSelect: (StreamSource?) -> Unit, onBack: () -> Unit) { + sourcesFailed: Boolean, onSelect: (StreamSource?) -> Unit, viewAll: Boolean = false, + onViewAll: () -> Unit = {}, onBack: () -> Unit) { val context = LocalContext.current val group = remember(channelId) { channelId.toULongOrNull()?.toLong() ?: 0L } + if (viewAll) { + StreamGridScreen(group, channelName, sources, sourcesFailed) { onSelect(null); onBack() } + return + } if (selected != null && !selected.self) { key(group, selected.id, selected.epoch) { StreamViewerScreen(group, selected.id, selected.epoch, channelName, selected.displayName) { onSelect(null); onBack() } @@ -141,6 +146,7 @@ fun StreamScreen(channelId: String, channelName: String, sources: List Column(Modifier.fillMaxSize().padding(padding).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onViewAll, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.stream_watch_all)) } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { StreamCaptureToolbar(cameras, shownConfig, displayDegrees, Modifier.weight(1f), enabled = if (ongoing != null) ongoing.phase == StreamPhase.LIVE && ongoing.pendingConfig == null else !loadingModes, diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamGridLayoutTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamGridLayoutTest.kt new file mode 100644 index 00000000..1dd0a612 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamGridLayoutTest.kt @@ -0,0 +1,24 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +class StreamGridLayoutTest { + @Test fun portraitAndLandscapeUseDifferentShapes() { + assertEquals(StreamGridShape(1, 2), streamGridShape(2, 400.0, 800.0)) + assertEquals(StreamGridShape(2, 1), streamGridShape(2, 1600.0, 600.0)) + assertEquals(StreamGridShape(1, 4), streamGridShape(4, 400.0, 800.0)) + } + @Test fun everySourceHasACellAcrossCountsAndSizes() { + for (count in 1..40) for ((width, height) in listOf(400.0 to 800.0, 800.0 to 400.0, 900.0 to 900.0)) { + val shape = streamGridShape(count, width, height) + assertTrue(shape.columns > 0 && shape.rows > 0) + assertTrue(shape.columns * shape.rows >= count) + assertEquals((count + shape.columns - 1) / shape.columns, shape.rows) + } + } + @Test fun emptyAndZeroSizedLayoutIsSafe() { + assertEquals(StreamGridShape(1, 1), streamGridShape(0, 400.0, 800.0)) + assertEquals(StreamGridShape(1, 1), streamGridShape(4, 0.0, 800.0)) + } +}