3 changed files with 166 additions and 48 deletions
@ -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…
Reference in new issue