|
|
|
|
@ -3,6 +3,8 @@ package com.utun.chat.ui.screens
|
|
|
|
|
import android.Manifest |
|
|
|
|
import android.app.Activity |
|
|
|
|
import android.content.pm.PackageManager |
|
|
|
|
import android.content.Context |
|
|
|
|
import android.graphics.Matrix |
|
|
|
|
import android.graphics.SurfaceTexture |
|
|
|
|
import android.view.Surface |
|
|
|
|
import android.view.TextureView |
|
|
|
|
@ -47,6 +49,8 @@ import com.utun.chat.data.StreamController
|
|
|
|
|
import com.utun.chat.data.StreamPhase |
|
|
|
|
import com.utun.chat.data.StreamPlayerEngine |
|
|
|
|
import com.utun.chat.data.StreamPreview |
|
|
|
|
import com.utun.chat.data.StreamPreviewFormat |
|
|
|
|
import com.utun.chat.data.StreamPreviewTarget |
|
|
|
|
import com.utun.chat.data.StreamSource |
|
|
|
|
import com.utun.chat.data.StreamCaptureMode |
|
|
|
|
import com.utun.chat.data.StreamCaptureConfig |
|
|
|
|
@ -58,7 +62,7 @@ import kotlinx.coroutines.CancellationException
|
|
|
|
|
import kotlinx.coroutines.Dispatchers |
|
|
|
|
import kotlinx.coroutines.withContext |
|
|
|
|
|
|
|
|
|
/** Выход с экрана закрывает только reader; foreground publisher останавливает явная кнопка/notification. */ |
|
|
|
|
/** Выход закрывает reader либо прямой preview output; foreground publisher останавливается явно. */ |
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class) |
|
|
|
|
@Composable |
|
|
|
|
fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSource>, selected: StreamSource?, |
|
|
|
|
@ -118,7 +122,10 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
|
|
|
|
|
StreamTxPanel(local.id) |
|
|
|
|
} |
|
|
|
|
Box(Modifier.weight(1f).fillMaxWidth()) { |
|
|
|
|
key(group, selected.id, selected.epoch) { LiveStreamView(group, selected.id, selected.epoch, if (selected.self) local?.preview else null) } |
|
|
|
|
if (selected.self && local?.ongoing != true) Text(stringResource(R.string.stream_ended)) |
|
|
|
|
else key(group, selected.id, selected.epoch) { |
|
|
|
|
LiveStreamView(group, selected.id, selected.epoch, if (selected.self) local?.preview else null) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
TextButton(onClick = { onSelect(null); onBack() }) { Text(stringResource(R.string.stream_close_view)) } |
|
|
|
|
if (selected.self && local?.ongoing == true) Button(onClick = StreamController::stop, |
|
|
|
|
@ -303,12 +310,15 @@ private fun LiveStreamView(group: Long, source: Long, epoch: Long, localPreview:
|
|
|
|
|
val displayDegrees = if (localPreview != null) remember(configuration) { |
|
|
|
|
((context as? Activity)?.windowManager?.defaultDisplay?.rotation ?: Surface.ROTATION_0) * 90 |
|
|
|
|
} else 0 |
|
|
|
|
if (visible) key(localPreview, displayDegrees) { LiveStreamSurface(group, source, epoch, localPreview, displayDegrees) } |
|
|
|
|
if (visible) { |
|
|
|
|
if (localPreview != null) CameraPreviewSurface(localPreview, displayDegrees) |
|
|
|
|
else LiveStreamSurface(group, source, epoch) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@Composable |
|
|
|
|
private fun LiveStreamSurface(group: Long, source: Long, epoch: Long, localPreview: StreamPreview?, displayDegrees: Int) { |
|
|
|
|
val engine = remember(group, source, epoch, localPreview) { StreamPlayerEngine(group, source, epoch, localPreview, displayDegrees) } |
|
|
|
|
private fun LiveStreamSurface(group: Long, source: Long, epoch: Long) { |
|
|
|
|
val engine = remember(group, source, epoch) { StreamPlayerEngine(group, source, epoch) } |
|
|
|
|
val aspect by engine.aspect.collectAsState() |
|
|
|
|
val status by engine.status.collectAsState() |
|
|
|
|
var surface by remember { mutableStateOf<Surface?>(null) } |
|
|
|
|
@ -338,3 +348,80 @@ private fun LiveStreamSurface(group: Long, source: Long, epoch: Long, localPrevi
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@Composable |
|
|
|
|
private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) { |
|
|
|
|
val state by preview.state.collectAsState() |
|
|
|
|
val aspect = state.format?.aspect(displayDegrees) ?: 9f / 16f |
|
|
|
|
Column(Modifier.fillMaxSize()) { |
|
|
|
|
Text(stringResource(if (state.failed) R.string.stream_failed else if (state.active) R.string.stream_live else R.string.stream_connecting)) |
|
|
|
|
Box(Modifier.weight(1f).fillMaxWidth(), 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() }) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** TextureView отдаёт texture владельцам Camera2; destroy не освобождает используемый камерой output. */ |
|
|
|
|
private class CameraPreviewView(context: Context, private val preview: StreamPreview) : TextureView(context) { |
|
|
|
|
private var target: StreamPreviewTarget? = null |
|
|
|
|
private var format: StreamPreviewFormat? = null |
|
|
|
|
private var displayDegrees = 0 |
|
|
|
|
private var closed = false |
|
|
|
|
private var frames = 0L |
|
|
|
|
private var reportFrames = 0L |
|
|
|
|
private var reportNs = 0L |
|
|
|
|
|
|
|
|
|
init { |
|
|
|
|
keepScreenOn = true |
|
|
|
|
surfaceTextureListener = object : SurfaceTextureListener { |
|
|
|
|
override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { |
|
|
|
|
if (closed) return |
|
|
|
|
target = preview.attach(texture); frames = 0; reportFrames = 0; reportNs = System.nanoTime() |
|
|
|
|
transform() |
|
|
|
|
} |
|
|
|
|
override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { transform() } |
|
|
|
|
override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { |
|
|
|
|
val previous = target ?: return true |
|
|
|
|
preview.detach(previous); target = null; previous.release() |
|
|
|
|
return false // Camera session удерживает свою ссылку до onClosed/onConfigureFailed. |
|
|
|
|
} |
|
|
|
|
override fun onSurfaceTextureUpdated(texture: SurfaceTexture) { |
|
|
|
|
frames++ |
|
|
|
|
val now = System.nanoTime() |
|
|
|
|
if (frames == 1L || now - reportNs >= 1_000_000_000) { |
|
|
|
|
val fps = (frames - reportFrames) * 1_000_000_000.0 / (now - reportNs).coerceAtLeast(1) |
|
|
|
|
LogManager.addLog(if (frames == 1L) "INFO" else "DEBUG", "video", "direct preview frame " + |
|
|
|
|
"target=${target?.let(System::identityHashCode)} frames=$frames fps=${"%.1f".format(fps)} " + |
|
|
|
|
"ageUs=${(now - texture.timestamp) / 1000} timestampNs=${texture.timestamp} " + |
|
|
|
|
"rotation=${format?.camera?.rotation(displayDegrees)}") |
|
|
|
|
reportNs = now; reportFrames = frames |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fun update(next: StreamPreviewFormat?, display: Int) { |
|
|
|
|
if (next == format && display == displayDegrees) return |
|
|
|
|
format = next; displayDegrees = display; transform() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
private fun transform() { |
|
|
|
|
val next = format ?: return |
|
|
|
|
if (width == 0 || height == 0) return |
|
|
|
|
val destination = next.corners(displayDegrees) |
|
|
|
|
for (i in destination.indices) destination[i] *= if (i % 2 == 0) width.toFloat() else height.toFloat() |
|
|
|
|
val source = floatArrayOf(0f, 0f, width.toFloat(), 0f, width.toFloat(), height.toFloat(), 0f, height.toFloat()) |
|
|
|
|
val matrix = Matrix() |
|
|
|
|
if (!matrix.setPolyToPoly(source, 0, destination, 0, 4)) { |
|
|
|
|
LogManager.addLog("ERROR", "video", "direct preview transform failed view=${width}x$height format=$next"); return |
|
|
|
|
} |
|
|
|
|
setTransform(matrix) |
|
|
|
|
LogManager.addLog("DEBUG", "video", "direct preview transform view=${width}x$height buffer=${next.width}x${next.height} " + |
|
|
|
|
"rotation=${next.camera.rotation(displayDegrees)} front=${next.camera.front}") |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fun close() { closed = true; keepScreenOn = false; target?.let(preview::detach) } |
|
|
|
|
} |
|
|
|
|
|