From 5c1619420a7aaec08f7b9b77bf4cf08942fb35b5 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 20 Oct 2026 19:17:59 +0200 Subject: [PATCH] Show own Android multiview source through silent camera preview --- .../utun/chat/ui/screens/StreamGridScreen.kt | 47 +++++++++++++----- .../com/utun/chat/ui/screens/StreamScreen.kt | 49 +++++++++++-------- 2 files changed, 63 insertions(+), 33 deletions(-) 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 index 8cf5a3ff..1f69f989 100644 --- 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 @@ -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 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) +} 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 a6df0384..7d50309a 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 @@ -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