Add adaptive Android grid with automatic source reconciliation

This commit is contained in:
evgeny committed 2026-10-20 17:07:19 +02:00
1 parent 14b152bb5a
commit 9e5b2ad435
3 files changed
+173 -1

No files matched your search

@@ -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<StreamSource>, unavailable: Boolean, onClose: () -> Unit) {
val tiles = remember(group) { mutableStateListOf<GridStream>() }
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)
}
}
}
@@ -74,9 +74,14 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSource>, 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<StreamSou
})
}) { padding ->
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,
@@ -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))
}
}