Show Android live streams fullscreen with automatic orientation and timed controls
This commit is contained in:
1 parent
d912d53979
commit
931631a4a9
3 files changed
+166
-48
No files matched your search
@@ -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<Float> = mutableAspect
|
||||
private val mutableStatus = MutableStateFlow(R.string.stream_connecting)
|
||||
val status: StateFlow<Int> = mutableStatus
|
||||
|
||||
@@ -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<StreamSource>, 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<Long?>(null) }
|
||||
@@ -129,10 +134,10 @@ fun StreamScreen(channelId: String, channelName: String, sources: List<StreamSou
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
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, 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,
|
||||
enabled = local.phase != StreamPhase.STOPPING) { Text(stringResource(R.string.stream_stop)) }
|
||||
} else {
|
||||
@@ -296,22 +301,19 @@ private fun StreamRotationMark(top: Int) {
|
||||
}
|
||||
|
||||
@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
|
||||
var visible by remember(owner) { mutableStateOf(owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
|
||||
DisposableEffect(owner, localPreview) {
|
||||
localPreview?.setVisible(visible)
|
||||
localPreview.setVisible(visible)
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
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<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
|
||||
private fun CameraPreviewSurface(preview: StreamPreview, displayDegrees: Int) {
|
||||
val state by preview.state.collectAsState()
|
||||
|
||||
+149
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user