diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt index 713d208e..6f41fced 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt @@ -23,7 +23,7 @@ class StreamPlayerEngine(private val group: Long, private val source: Long, priv private data class Frame(override val pts: Long, val key: Boolean, val width: Int, val height: Int, val rotation: Int, val revision: Long, val codec: StreamVideoCodec, val config: ByteArray, val data: ByteArray) : StreamDecodePump.Frame - private val mutableAspect = MutableStateFlow(16f / 9f) + private val mutableAspect = MutableStateFlow(0f) // Ориентация неизвестна до первого KEY с форматом. val aspect: StateFlow = mutableAspect private val mutableStatus = MutableStateFlow(R.string.stream_connecting) val status: StateFlow = mutableStatus 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 53003fc2..d7ca69c3 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 @@ -10,7 +10,6 @@ import android.hardware.display.DisplayManager import android.os.Handler import android.os.Looper import android.os.SystemClock -import android.view.Surface import android.view.TextureView import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -23,6 +22,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -51,7 +51,6 @@ 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.StreamPreview import com.utun.chat.data.StreamPreviewFormat import com.utun.chat.data.StreamPreviewTarget @@ -72,8 +71,14 @@ import kotlinx.coroutines.withContext fun StreamScreen(channelId: String, channelName: String, sources: List, selected: StreamSource?, sourcesFailed: Boolean, onSelect: (StreamSource?) -> Unit, onBack: () -> Unit) { val context = LocalContext.current - val displayDegrees = StreamDisplayDegrees() val group = remember(channelId) { channelId.toULongOrNull()?.toLong() ?: 0L } + if (selected != null && !selected.self) { + key(group, selected.id, selected.epoch) { + StreamViewerScreen(group, selected.id, selected.epoch, channelName, selected.displayName) { onSelect(null); onBack() } + } + return + } + val displayDegrees = StreamDisplayDegrees() val state by StreamController.state.collectAsState() val local = state?.takeIf { it.group == group } var previewedSession by remember(group) { mutableStateOf(null) } @@ -129,10 +134,10 @@ fun StreamScreen(channelId: String, channelName: String, sources: List - if (event == Lifecycle.Event.ON_START) { localPreview?.setVisible(true); visible = true } - if (event == Lifecycle.Event.ON_STOP) { localPreview?.setVisible(false); visible = false } + if (event == Lifecycle.Event.ON_START) { localPreview.setVisible(true); visible = true } + if (event == Lifecycle.Event.ON_STOP) { localPreview.setVisible(false); visible = false } } owner.lifecycle.addObserver(observer) - onDispose { owner.lifecycle.removeObserver(observer); localPreview?.setVisible(false) } - } - if (visible) { - if (localPreview != null) CameraPreviewSurface(localPreview, displayDegrees) - else LiveStreamSurface(group, source, epoch) + onDispose { owner.lifecycle.removeObserver(observer); localPreview.setVisible(false) } } + if (visible) CameraPreviewSurface(localPreview, displayDegrees) } /** DisplayListener замечает также 180°, когда Configuration и размеры окна не меняются. */ @@ -337,39 +339,6 @@ private fun StreamDisplayDegrees(): Int { return degrees } -@Composable -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(null) } - DisposableEffect(engine) { onDispose { engine.stop() } } - Column(Modifier.fillMaxSize()) { - Text(stringResource(status)) - Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { - Box(Modifier.aspectRatio(aspect, matchHeightConstraintsFirst = true)) { - AndroidView(factory = { context -> - TextureView(context).apply { - keepScreenOn = true - surfaceTextureListener = object : TextureView.SurfaceTextureListener { - override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { - surface = Surface(texture); engine.start(surface!!) - } - override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) {} - override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { - engine.stop(); surface?.release(); surface = null; return true - } - override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {} - } - } - }, modifier = Modifier.matchParentSize(), onRelease = { view -> - engine.stop(); view.surfaceTextureListener = null; surface?.release(); surface = null - }) - } - } - } -} - @Composable private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) { val state by preview.state.collectAsState() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt new file mode 100644 index 00000000..5eed1e29 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt @@ -0,0 +1,149 @@ +package com.utun.chat.ui.screens + +import android.app.Activity +import android.content.ContextWrapper +import android.content.pm.ActivityInfo +import android.graphics.SurfaceTexture +import android.view.Surface +import android.view.TextureView +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +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 kotlinx.coroutines.delay + +/** Полноэкранный viewer: геометрия видео не зависит от видимости управления. */ +@Composable +internal fun StreamViewerScreen(group: Long, source: Long, epoch: Long, title: String, name: String, onClose: () -> Unit) { + val context = LocalContext.current + val activity = remember(context) { + generateSequence(context) { (it as? ContextWrapper)?.baseContext }.filterIsInstance().firstOrNull() + } + val previousOrientation = remember(activity) { activity?.requestedOrientation } + val owner = LocalLifecycleOwner.current + var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } + var aspect by remember { mutableStateOf(0f) } + var status by remember { mutableStateOf(R.string.stream_connecting) } + var controls by remember { mutableStateOf(true) } + + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) { visible = true; controls = true } + if (event == Lifecycle.Event.ON_STOP) visible = false + } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + DisposableEffect(activity) { + val window = activity?.window + val controller = window?.let { WindowCompat.getInsetsController(it, it.decorView) } + val insets = window?.let { ViewCompat.getRootWindowInsets(it.decorView) } + val statusVisible = insets?.isVisible(WindowInsetsCompat.Type.statusBars()) ?: true + val navigationVisible = insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) ?: true + val behavior = controller?.systemBarsBehavior + controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller?.hide(WindowInsetsCompat.Type.systemBars()) + LogManager.addLog("INFO", "stream", "viewer fullscreen source=$source epoch=$epoch orientation=$previousOrientation") + onDispose { + if (previousOrientation != null) activity?.requestedOrientation = previousOrientation + if (behavior != null) controller?.systemBarsBehavior = behavior + if (statusVisible) controller?.show(WindowInsetsCompat.Type.statusBars()) else controller?.hide(WindowInsetsCompat.Type.statusBars()) + if (navigationVisible) controller?.show(WindowInsetsCompat.Type.navigationBars()) else controller?.hide(WindowInsetsCompat.Type.navigationBars()) + LogManager.addLog("INFO", "stream", "viewer window restored source=$source orientation=$previousOrientation") + } + } + LaunchedEffect(activity, aspect) { + // Формат уже учитывает rotation источника. До первого KEY ориентацию не угадываем. + val orientation = when { + aspect > 1f -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + aspect > 0f && aspect < 1f -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + else -> null + } + if (activity != null && orientation != null && activity.requestedOrientation != orientation) { + activity.requestedOrientation = orientation + LogManager.addLog("INFO", "stream", "viewer orientation source=$source aspect=$aspect requested=$orientation") + } + } + LaunchedEffect(status) { controls = true } + LaunchedEffect(controls, status, visible) { + if (controls && status == R.string.stream_live && visible) { delay(3000); controls = false } + } + LaunchedEffect(controls) { LogManager.addLog("DEBUG", "stream", "viewer controls source=$source visible=$controls") } + + Box(Modifier.fillMaxSize().background(Color.Black)) { + if (visible) StreamViewerPlayback(group, source, epoch, { aspect = it }, { status = it }) + Box(Modifier.matchParentSize().pointerInput(status) { + detectTapGestures { if (status == R.string.stream_live) controls = !controls else controls = true } + }) + if (controls) { + Row(Modifier.fillMaxWidth().align(Alignment.TopCenter).background(Color.Black.copy(alpha = 0.65f)) + .windowInsetsPadding(WindowInsets.safeDrawing).padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, color = Color.White, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(name, color = Color.White, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(stringResource(status), color = Color.White, style = MaterialTheme.typography.bodySmall) + } + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, stringResource(R.string.stream_close_view), tint = Color.White) } + } + } + } +} + +/** Смена размеров окна и скрытие controls сохраняют TextureView, decoder и сетевой reader. */ +@Composable +private fun StreamViewerPlayback(group: Long, source: Long, epoch: Long, onAspect: (Float) -> Unit, onStatus: (Int) -> Unit) { + 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(null) } + LaunchedEffect(aspect, status) { onAspect(aspect); onStatus(status) } + DisposableEffect(engine) { onDispose { engine.stop() } } + BoxWithConstraints(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val ratio = aspect.takeIf { it > 0f } ?: 16f / 9f + val width = minOf(maxWidth, maxHeight * ratio) + val height = width / ratio + AndroidView(factory = { context -> + TextureView(context).apply { + keepScreenOn = true + surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { + surface = Surface(texture); engine.start(surface!!) + } + override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) {} + override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { + engine.stop(); surface?.release(); surface = null; return true + } + override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {} + } + } + }, modifier = Modifier.size(width, height), onRelease = { view -> + engine.stop(); view.surfaceTextureListener = null; surface?.release(); surface = null + }) + } +}