feat: бинокулярный видеорендер в app-glasses + иммерсивный режим + тест-план
- render/: Grafika-связка (EglCore/WindowSurface/FullFrameRect/Texture2dProgram/GlUtil/Drawable2d/EglSurfaceBase) — перенос из pair-view - ui/BinocularVideoPlayer: один SimpleExoPlayer → SurfaceTexture → EGL-блит в оба глаза (EglVideoRenderer шарится между LEFT/RIGHT) - ui/ViewUtils: Binocular/BinocularScreenSide/BinocularScreenSize (Compose, из pair-view) - ui/GlassesMovieScreen: «нос к носу», calculateVideoSizeFull, FLAG_KEEP_SCREEN_ON - MovieController: PlayVideo/MediaCommand (PLAY/PAUSE/TOGGLE/SEEK/CLOSE), доступ к ExoPlayer только с main-потока - HostConnection: PlayVideo→плеер, реальная позиция в PlaybackPosition (withContext Main) - MainActivity: иммерсивный режим (setDecorFitsSystemWindows(false) + hide systemBars) - TESTING.md: 10 тест-кейсов human-style; QA-прогон (opencode, 88.19) — 10/10 ПРОЙДЕН
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.GLES20
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import pw.binom.viewmate.glasses.render.EglCore
|
||||
import pw.binom.viewmate.glasses.render.FullFrameRect
|
||||
import pw.binom.viewmate.glasses.render.Texture2dProgram
|
||||
import pw.binom.viewmate.glasses.render.WindowSurface
|
||||
|
||||
/**
|
||||
* Бинокулярный видеорендер: один [SimpleExoPlayer] → SurfaceTexture → EGL-блит
|
||||
* в несколько выводов. Внутри [Binocular] content вызывается ДВАЖДЫ (левый/правый
|
||||
* глаз), поэтому [EglVideoRenderer] создаётся ОДИН на экран и шарится между
|
||||
* двумя экземплярами [BinocularVideoPlayer] — иначе второй setVideoSurface
|
||||
* перезаписал бы первый.
|
||||
*/
|
||||
@Composable
|
||||
fun BinocularVideoPlayer(
|
||||
player: SimpleExoPlayer,
|
||||
renderer: EglVideoRenderer,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
// Два SurfaceView на экземпляр (левый/правый глаз). Все они
|
||||
// регистрируются в общем рендерере и получают один и тот же кадр.
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
SurfaceView(context).also { sv ->
|
||||
sv.holder.addCallback(renderer.createCallback())
|
||||
}
|
||||
}
|
||||
)
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
SurfaceView(context).also { sv ->
|
||||
sv.holder.addCallback(renderer.createCallback())
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Общее GL-состояние: один EglCore + FullFrameRect + SurfaceTexture для плеера,
|
||||
* кадр дублируется во все готовые выводы (окна поверхностей).
|
||||
*
|
||||
* Всё создаётся на главном потоке (SurfaceHolder.Callback и OnFrameAvailableListener),
|
||||
* поэтому защита от гонок — null-проверки: кадр может прийти между созданиями выводов.
|
||||
*/
|
||||
class EglVideoRenderer(private val player: SimpleExoPlayer) {
|
||||
|
||||
private var eglCore: EglCore? = null
|
||||
private var fullFrameBlit: FullFrameRect? = null
|
||||
private var textureId = 0
|
||||
private var videoSurfaceTexture: SurfaceTexture? = null
|
||||
private val transformMatrix = FloatArray(16)
|
||||
private var playerSurface: Surface? = null
|
||||
|
||||
private val outputs = mutableListOf<Output>()
|
||||
|
||||
private class Output(
|
||||
val holder: SurfaceHolder,
|
||||
val surface: WindowSurface,
|
||||
) {
|
||||
var width = 0
|
||||
var height = 0
|
||||
}
|
||||
|
||||
private var released = false
|
||||
|
||||
private val frameAvailableListener = SurfaceTexture.OnFrameAvailableListener { _ ->
|
||||
if (released) return@OnFrameAvailableListener
|
||||
val st = videoSurfaceTexture
|
||||
val blit = fullFrameBlit
|
||||
if (st == null || blit == null) return@OnFrameAvailableListener
|
||||
|
||||
st.updateTexImage()
|
||||
st.getTransformMatrix(transformMatrix)
|
||||
|
||||
outputs.forEach { output ->
|
||||
if (output.width > 0 && output.height > 0) {
|
||||
drawFrame(blit, output.surface, output.width, output.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Колбэк для очередного SurfaceView (каждая половина экрана регистрирует свой вывод). */
|
||||
fun createCallback(): SurfaceHolder.Callback = object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) = attach(holder)
|
||||
|
||||
override fun surfaceChanged(
|
||||
holder: SurfaceHolder, format: Int, width: Int, height: Int
|
||||
) {
|
||||
outputs.firstOrNull { it.holder === holder }?.let {
|
||||
it.width = width
|
||||
it.height = height
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
val index = outputs.indexOfFirst { it.holder === holder }
|
||||
if (index >= 0) {
|
||||
val output = outputs.removeAt(index)
|
||||
output.surface.release()
|
||||
log("glasses", "EglVideoRenderer: вывод удалён")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attach(holder: SurfaceHolder) {
|
||||
if (released) return
|
||||
val egl = eglCore ?: EglCore().also { eglCore = it }
|
||||
val ws = WindowSurface(egl, holder.surface, false)
|
||||
outputs.add(Output(holder, ws))
|
||||
|
||||
if (fullFrameBlit == null) {
|
||||
// Первый вывод: создаём общий GL-стэйт, текущий контекст — его.
|
||||
ws.makeCurrent()
|
||||
fullFrameBlit = FullFrameRect(Texture2dProgram(Texture2dProgram.ProgramType.TEXTURE_EXT))
|
||||
textureId = fullFrameBlit!!.createTextureObject()
|
||||
videoSurfaceTexture = SurfaceTexture(textureId).also {
|
||||
it.setOnFrameAvailableListener(frameAvailableListener)
|
||||
}
|
||||
playerSurface = Surface(videoSurfaceTexture).also {
|
||||
player.setVideoSurface(it)
|
||||
}
|
||||
log("glasses", "EglVideoRenderer: GL-стэйт создан, плеер привязан к SurfaceTexture")
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawFrame(blit: FullFrameRect, windowSurface: WindowSurface, viewWidth: Int, viewHeight: Int) {
|
||||
windowSurface.makeCurrent()
|
||||
GLES20.glViewport(0, 0, viewWidth, viewHeight)
|
||||
blit.drawFrame(textureId, transformMatrix)
|
||||
windowSurface.swapBuffers()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (released) return
|
||||
released = true
|
||||
log("glasses", "EglVideoRenderer: release")
|
||||
|
||||
// Отвязать плеер (плеер мог быть уже освобождён — не падаем).
|
||||
val ps = playerSurface
|
||||
if (ps != null) {
|
||||
try {
|
||||
player.setVideoSurface(null)
|
||||
} catch (e: Throwable) {
|
||||
log("glasses", "setVideoSurface(null) пропущен: ${e.message}")
|
||||
}
|
||||
ps.release()
|
||||
playerSurface = null
|
||||
}
|
||||
|
||||
videoSurfaceTexture?.release()
|
||||
videoSurfaceTexture = null
|
||||
|
||||
outputs.forEach { it.surface.release() }
|
||||
outputs.clear()
|
||||
|
||||
fullFrameBlit?.release(false)
|
||||
fullFrameBlit = null
|
||||
|
||||
eglCore?.release()
|
||||
eglCore = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import com.google.android.exoplayer2.video.VideoSize
|
||||
import pw.binom.viewmate.glasses.log
|
||||
|
||||
/**
|
||||
* Экран кино: один плеер, вывод — дважды (левый/правый глаз) через [Binocular].
|
||||
* «Дубль» на эмуляторе 1280×480 — ожидаемое поведение.
|
||||
*/
|
||||
@Composable
|
||||
fun GlassesMovieScreen(
|
||||
player: SimpleExoPlayer,
|
||||
title: String?,
|
||||
) {
|
||||
log("glasses", "GlassesMovieScreen: ${title ?: "без названия"}")
|
||||
|
||||
val context = LocalContext.current
|
||||
val window = (context as Activity).window
|
||||
DisposableEffect(window) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
onDispose {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
var videoWidth by remember { mutableIntStateOf(640) }
|
||||
var videoHeight by remember { mutableIntStateOf(480) }
|
||||
|
||||
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
|
||||
// глаз из Binocular) регистрируют в нём свои SurfaceView.
|
||||
val renderer = remember { EglVideoRenderer(player) }
|
||||
|
||||
DisposableEffect(player) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (videoSize.width > 0 && videoSize.height > 0) {
|
||||
log("glasses", "videoSize: ${videoSize.width}x${videoSize.height}")
|
||||
videoWidth = videoSize.width
|
||||
videoHeight = videoSize.height
|
||||
}
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
onDispose {
|
||||
player.removeListener(listener)
|
||||
renderer.release()
|
||||
}
|
||||
}
|
||||
|
||||
Binocular(scale = true) {
|
||||
val screenWidthPx = BinocularScreenSize.current.width
|
||||
val screenHeightPx = BinocularScreenSize.current.height
|
||||
|
||||
val (playerWidth, playerHeight) = calculateVideoSizeFull(
|
||||
videoWidth = videoWidth,
|
||||
videoHeight = videoHeight,
|
||||
maxWidth = screenWidthPx, // padding = 0
|
||||
maxHeight = screenHeightPx - 25,
|
||||
)
|
||||
|
||||
// «Нос к носу»: окно каждого глаза прижато к центру экрана.
|
||||
val horizontalArrangement = when (BinocularScreenSide.current) {
|
||||
Side.LEFT -> Arrangement.End
|
||||
Side.RIGHT -> Arrangement.Start
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(Color.Black)
|
||||
.width((screenWidthPx).px),
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
) {
|
||||
BinocularVideoPlayer(
|
||||
player = player,
|
||||
renderer = renderer,
|
||||
modifier = Modifier.size(playerWidth.px, playerHeight.px),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fit с сохранением пропорций: вариант по ширине/по высоте, берём помещающийся. */
|
||||
fun calculateVideoSizeFull(
|
||||
videoWidth: Int, videoHeight: Int,
|
||||
maxWidth: Int, maxHeight: Int,
|
||||
paddingStart: Int = 0, paddingEnd: Int = 0,
|
||||
paddingTop: Int = 0, paddingBottom: Int = 0
|
||||
): Pair<Int, Int> {
|
||||
val availableWidth = maxWidth - (paddingStart + paddingEnd)
|
||||
val availableHeight = maxHeight - (paddingTop + paddingBottom)
|
||||
val aspectRatio = videoWidth.toFloat() / videoHeight.toFloat()
|
||||
|
||||
// Вариант: ширина по максимальной ширине
|
||||
val widthByWidth = availableWidth
|
||||
val heightByWidth = (widthByWidth / aspectRatio).toInt()
|
||||
// Вариант: высота по максимальной высоте
|
||||
val heightByHeight = availableHeight
|
||||
val widthByHeight = (heightByHeight * aspectRatio).toInt()
|
||||
|
||||
// Выбираем вариант, который помещается целиком
|
||||
return if (heightByWidth <= availableHeight) {
|
||||
Pair(widthByWidth, heightByWidth)
|
||||
} else {
|
||||
Pair(widthByHeight, heightByHeight)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/** Перевод пикселей (например, из расчёта размера видео) в Dp текущей плотности. */
|
||||
@get:Composable
|
||||
val Int.px: Dp
|
||||
get() = with(LocalDensity.current) { this@px.toDp() }
|
||||
@@ -0,0 +1,161 @@
|
||||
package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
enum class Side {
|
||||
LEFT, RIGHT,
|
||||
}
|
||||
|
||||
val BinocularScreenSide = staticCompositionLocalOf<Side> {
|
||||
error("Rayneo Layout not active")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> ProvidableCompositionLocal<T>.getValue(thisRef: Any?, property: KProperty<*>): T = current
|
||||
|
||||
data class ScreenSize(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
val BinocularScreenSize = staticCompositionLocalOf<ScreenSize> {
|
||||
error("Rayneo Layout not active")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BinocularSize(scale: Boolean, func: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val screenWidthPx =
|
||||
remember { (context as Activity).windowManager.currentWindowMetrics.bounds.width() }
|
||||
val screenHeightPx =
|
||||
remember { (context as Activity).windowManager.currentWindowMetrics.bounds.height() }
|
||||
if (scale) {
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSize provides ScreenSize(
|
||||
width = screenWidthPx,
|
||||
height = screenHeightPx,
|
||||
)
|
||||
) {
|
||||
func()
|
||||
}
|
||||
} else {
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSize provides ScreenSize(
|
||||
width = screenWidthPx / 2,
|
||||
height = screenHeightPx,
|
||||
)
|
||||
) {
|
||||
func()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("UnusedBoxWithConstraintsScope")
|
||||
fun Binocular(
|
||||
modifier: Modifier = Modifier,
|
||||
eachModifier: Modifier = Modifier,
|
||||
leftModifier: Modifier = Modifier,
|
||||
rightModifier: Modifier = Modifier,
|
||||
leftRotation: Float = 0f,
|
||||
rightRotation: Float = 0f,
|
||||
scale: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BinocularSize(scale) {
|
||||
if (scale) {
|
||||
BoxWithConstraints {
|
||||
val parentWidth = constraints.maxWidth
|
||||
PositionedBox(
|
||||
x = 0, y = 0, modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = 0.5f
|
||||
transformOrigin = TransformOrigin(0f, 0.5f)
|
||||
}
|
||||
.graphicsLayer {
|
||||
transformOrigin = TransformOrigin.Center
|
||||
rotationZ = leftRotation
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSide provides Side.LEFT
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
PositionedBox(
|
||||
x = (parentWidth),
|
||||
y = 0,
|
||||
modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = 0.5f
|
||||
transformOrigin = TransformOrigin(0f, 0.5f)
|
||||
}
|
||||
.graphicsLayer {
|
||||
transformOrigin = TransformOrigin.Center
|
||||
rotationZ = rightRotation
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSide provides Side.RIGHT
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(modifier = modifier.focusable()) {
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSide provides Side.LEFT
|
||||
) {
|
||||
Column(
|
||||
modifier = eachModifier
|
||||
.weight(1f, true)
|
||||
.then(leftModifier)
|
||||
) { content() }
|
||||
}
|
||||
// Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(color= Color.Green)) { Text("") }
|
||||
CompositionLocalProvider(
|
||||
BinocularScreenSide provides Side.RIGHT
|
||||
) {
|
||||
Column(
|
||||
modifier = eachModifier
|
||||
.weight(1f, true)
|
||||
.then(rightModifier)
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PositionedBox(
|
||||
x: Int,
|
||||
y: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Layout(content = content, modifier = modifier) { measurables, constraints ->
|
||||
val placeable = measurables.first().measure(constraints)
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
placeable.placeRelative(x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user