Add adaptive Android grid with automatic source reconciliation
This commit is contained in:
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user