Show own Android multiview source through silent camera preview

This commit is contained in:
evgeny committed 2026-10-20 19:17:59 +02:00
1 parent c4b2052b27
commit 5c1619420a
2 files changed
+63 -33

No files matched your search

@@ -29,6 +29,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.utun.chat.R
import com.utun.chat.data.LogManager
import com.utun.chat.data.StreamController
import com.utun.chat.data.StreamPhase
import com.utun.chat.data.StreamPlayerEngine
import com.utun.chat.data.StreamSource
import com.utun.chat.data.streamGridShape
@@ -80,7 +82,7 @@ internal fun StreamGridScreen(group: Long, title: String, sources: List<StreamSo
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)
tile.removeAt = now + if (tile.source.self) 0 else (tile.engine?.drainTimeoutMs ?: 2000)
LogManager.addLog("INFO", "stream", "multiview drains source=${tile.source.id} deadline=${tile.removeAt}")
}
}
@@ -92,7 +94,7 @@ internal fun StreamGridScreen(group: Long, title: String, sources: List<StreamSo
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.source.self || 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
@@ -155,16 +157,17 @@ private fun StreamGridTile(group: Long, tile: GridStream, visible: Boolean, expa
.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 },
onStatus = {
tile.status = it
if (it == R.string.stream_live) { tile.failures = 0; tile.retryAt = null }
},
modifier = Modifier.requiredSize(fit.width.dp, fit.height.dp).align(Alignment.Center)
.graphicsLayer { rotationZ = fit.rotation },
onEngine = { tile.engine = it })
if (visible) {
val video = Modifier.requiredSize(fit.width.dp, fit.height.dp).align(Alignment.Center).graphicsLayer { rotationZ = fit.rotation }
if (tile.source.self) StreamGridPreview(group, tile, onAspect = { aspect = it }, modifier = video)
else 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 = video, onEngine = { tile.engine = it })
}
}
if (!expanded || tile.status != R.string.stream_live) {
Column(Modifier.align(Alignment.TopStart).fillMaxWidth().background(Color.Black.copy(alpha = 0.5f)).padding(4.dp)) {
@@ -180,3 +183,23 @@ private fun StreamGridTile(group: Long, tile: GridStream, visible: Boolean, expa
.semantics { contentDescription = tile.source.displayName })
}
}
/** Собственный источник никогда не открывает reader: звук остаётся только у удалённых плиток. */
@Composable
private fun StreamGridPreview(group: Long, tile: GridStream, onAspect: (Float) -> Unit, modifier: Modifier) {
val state by StreamController.state.collectAsState()
val local = state?.takeIf { it.group == group }
val active = local?.takeIf { it.ongoing && it.phase != StreamPhase.STOPPING }
LaunchedEffect(local?.id, local?.phase) {
if (active == null) tile.status = when (local?.phase) {
StreamPhase.STOPPING -> R.string.stream_stopping
StreamPhase.FAILED -> R.string.stream_failed
else -> R.string.stream_ended
}
LogManager.addLog(if (local == null) "WARN" else "INFO", "stream",
"multiview local preview group=$group source=${tile.source.id} epoch=${tile.source.epoch} " +
"publisher=${local?.id} phase=${local?.phase} active=${active != null} decoder=false audio=false")
}
if (active != null) StreamPreviewPlayback(active.preview, StreamDisplayDegrees(), onAspect = onAspect,
onStatus = { tile.status = it }, modifier = modifier)
}
@@ -383,6 +383,30 @@ private fun StreamRotationMark(top: Int, degrees: Int) {
@Composable
private fun LiveStreamView(localPreview: StreamPreview, displayDegrees: Int) {
var aspect by remember(localPreview) { mutableStateOf(9f / 16f) }
var status by remember(localPreview) { mutableStateOf(R.string.stream_connecting) }
BoxWithConstraints(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
val fit = fitVideoFrame(maxWidth.value, maxHeight.value, aspect, false)
StreamPreviewPlayback(localPreview, displayDegrees, onAspect = { aspect = it }, onStatus = { status = it },
modifier = Modifier.requiredSize(fit.width.dp, fit.height.dp))
if (status != R.string.stream_live) {
Surface(Modifier.align(Alignment.TopCenter).padding(8.dp), shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) {
Text(stringResource(status), Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
color = if (status == R.string.stream_preview_failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
}
}
}
}
/** Общий прямой preview для экрана публикации и сетки: только Camera2 output, без reader/decoder/audio. */
@Composable
internal fun StreamPreviewPlayback(localPreview: StreamPreview, displayDegrees: Int, onAspect: (Float) -> Unit,
onStatus: (Int) -> Unit, modifier: Modifier = Modifier) {
val state by localPreview.state.collectAsState()
val aspect = state.format?.aspect(displayDegrees) ?: 9f / 16f
val status = if (state.failed) R.string.stream_preview_failed else if (state.active) R.string.stream_live else R.string.stream_connecting
LaunchedEffect(aspect, status) { onAspect(aspect); onStatus(status) }
val owner = LocalLifecycleOwner.current
var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
DisposableEffect(owner, localPreview) {
@@ -394,7 +418,10 @@ private fun LiveStreamView(localPreview: StreamPreview, displayDegrees: Int) {
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer); localPreview.setVisible(false) }
}
if (visible) CameraPreviewSurface(localPreview, displayDegrees)
if (visible) key(localPreview) {
AndroidView(factory = { CameraPreviewView(it, localPreview) }, modifier = modifier,
update = { it.update(state.format, displayDegrees) }, onRelease = { it.close() })
}
}
/** DisplayListener замечает также 180°, когда Configuration и размеры окна не меняются. */
@@ -420,26 +447,6 @@ internal fun StreamDisplayDegrees(): Int {
return degrees
}
@Composable
private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) {
val state by preview.state.collectAsState()
val aspect = state.format?.aspect(displayDegrees) ?: 9f / 16f
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(Modifier.aspectRatio(aspect, matchHeightConstraintsFirst = true)) {
AndroidView(factory = { CameraPreviewView(it, preview) }, modifier = Modifier.matchParentSize(),
update = { it.update(state.format, displayDegrees) }, onRelease = { it.close() })
}
if (state.failed || !state.active) {
Surface(Modifier.align(Alignment.TopCenter).padding(8.dp), shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) {
Text(stringResource(if (state.failed) R.string.stream_preview_failed else R.string.stream_connecting),
Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
color = if (state.failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
}
}
}
}
/** TextureView отдаёт texture владельцам Camera2; destroy не освобождает используемый камерой output. */
internal class CameraPreviewView(context: Context, private val preview: StreamPreview, keepAwake: Boolean = true) : TextureView(context) {
private var target: StreamPreviewTarget? = null