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