Browse Source

Show Android live streams fullscreen with automatic orientation and timed controls

master
evgeny 5 days ago
parent
commit
931631a4a9
  1. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamPlayerEngine.kt
  2. 63
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamScreen.kt
  3. 149
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/StreamViewerScreen.kt

2
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, 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 rotation: Int, val revision: Long, val codec: StreamVideoCodec,
val config: ByteArray, val data: ByteArray) : StreamDecodePump.Frame val config: ByteArray, val data: ByteArray) : StreamDecodePump.Frame
private val mutableAspect = MutableStateFlow(16f / 9f) private val mutableAspect = MutableStateFlow(0f) // Ориентация неизвестна до первого KEY с форматом.
val aspect: StateFlow<Float> = mutableAspect val aspect: StateFlow<Float> = mutableAspect
private val mutableStatus = MutableStateFlow(R.string.stream_connecting) private val mutableStatus = MutableStateFlow(R.string.stream_connecting)
val status: StateFlow<Int> = mutableStatus val status: StateFlow<Int> = mutableStatus

63
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.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock import android.os.SystemClock
import android.view.Surface
import android.view.TextureView import android.view.TextureView
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -23,6 +22,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable 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.LogManager
import com.utun.chat.data.StreamController import com.utun.chat.data.StreamController
import com.utun.chat.data.StreamPhase import com.utun.chat.data.StreamPhase
import com.utun.chat.data.StreamPlayerEngine
import com.utun.chat.data.StreamPreview import com.utun.chat.data.StreamPreview
import com.utun.chat.data.StreamPreviewFormat import com.utun.chat.data.StreamPreviewFormat
import com.utun.chat.data.StreamPreviewTarget import com.utun.chat.data.StreamPreviewTarget
@ -72,8 +71,14 @@ import kotlinx.coroutines.withContext
fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSource>, selected: StreamSource?, fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSource>, selected: StreamSource?,
sourcesFailed: Boolean, onSelect: (StreamSource?) -> Unit, onBack: () -> Unit) { sourcesFailed: Boolean, onSelect: (StreamSource?) -> Unit, onBack: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val displayDegrees = StreamDisplayDegrees()
val group = remember(channelId) { channelId.toULongOrNull()?.toLong() ?: 0L } 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 state by StreamController.state.collectAsState()
val local = state?.takeIf { it.group == group } val local = state?.takeIf { it.group == group }
var previewedSession by remember(group) { mutableStateOf<Long?>(null) } var previewedSession by remember(group) { mutableStateOf<Long?>(null) }
@ -129,10 +134,10 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
if (selected.self && local?.ongoing != true) Text(stringResource(R.string.stream_ended)) if (selected.self && local?.ongoing != true) Text(stringResource(R.string.stream_ended))
else key(group, selected.id, selected.epoch) { else key(group, selected.id, selected.epoch) {
LiveStreamView(group, selected.id, selected.epoch, if (selected.self) local?.preview else null, displayDegrees) local?.preview?.let { LiveStreamView(it, displayDegrees) }
} }
} }
TextButton(onClick = { onSelect(null); onBack() }) { Text(stringResource(R.string.stream_close_view)) } IconButton(onClick = { onSelect(null); onBack() }) { Icon(Icons.Filled.Close, stringResource(R.string.stream_close_view)) }
if (selected.self && local?.ongoing == true) Button(onClick = StreamController::stop, if (selected.self && local?.ongoing == true) Button(onClick = StreamController::stop,
enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) } enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) }
} else { } else {
@ -296,22 +301,19 @@ private fun StreamRotationMark(top: Int) {
} }
@Composable @Composable
private fun LiveStreamView(group: Long, source: Long, epoch: Long, localPreview: StreamPreview?, displayDegrees: Int) { private fun LiveStreamView(localPreview: StreamPreview, displayDegrees: Int) {
val owner = LocalLifecycleOwner.current val owner = LocalLifecycleOwner.current
var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
DisposableEffect(owner, localPreview) { DisposableEffect(owner, localPreview) {
localPreview?.setVisible(visible) localPreview.setVisible(visible)
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) { localPreview?.setVisible(true); visible = true } 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_STOP) { localPreview.setVisible(false); visible = false }
} }
owner.lifecycle.addObserver(observer) owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer); localPreview?.setVisible(false) } onDispose { owner.lifecycle.removeObserver(observer); localPreview.setVisible(false) }
}
if (visible) {
if (localPreview != null) CameraPreviewSurface(localPreview, displayDegrees)
else LiveStreamSurface(group, source, epoch)
} }
if (visible) CameraPreviewSurface(localPreview, displayDegrees)
} }
/** DisplayListener замечает также 180°, когда Configuration и размеры окна не меняются. */ /** DisplayListener замечает также 180°, когда Configuration и размеры окна не меняются. */
@ -337,39 +339,6 @@ private fun StreamDisplayDegrees(): Int {
return degrees 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<Surface?>(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 @Composable
private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) { private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) {
val state by preview.state.collectAsState() val state by preview.state.collectAsState()

149
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<Activity>().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<Surface?>(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
})
}
}
Loading…
Cancel
Save