Show own Android multiview source through silent camera preview
This commit is contained in:
1 parent
c4b2052b27
commit
5c1619420a
2 files changed
+63
-33
No files matched your search
+35
-12
@@ -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
|
||||
|
||||
Reference in new issue
Block a user