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:
Hermes Agent
2026-08-19 04:15:55 +03:00
parent 298d8e0b69
commit 4458678698
16 changed files with 2148 additions and 7 deletions
@@ -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)
}
}
}