fix(app-glasses): scale на паузе — принудительная перерисовка кадра

На паузе ExoPlayer не шлёт кадры — поверхность меняла размер/позицию,
но кадр оставался старым (часть видео не видно). requestRedraw():
updateTexImage + drawFrame для всех outputs — вызывается при смене
scale (LaunchedEffect) и в surfaceChanged. Подтверждено на железе.
This commit is contained in:
Hermes Agent
2026-08-20 00:52:21 +03:00
parent 9f20093bbf
commit 896185244b
11 changed files with 201 additions and 27 deletions
@@ -2,6 +2,7 @@ package pw.binom.viewmate.glasses.ui
import android.graphics.SurfaceTexture
import android.opengl.GLES20
import android.os.Looper
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
@@ -73,6 +74,9 @@ class EglVideoRenderer(
private val transformMatrix = FloatArray(16)
private var playerSurface: Surface? = null
// Поток, где создан GL-контекст/привязка EGL (attach из surfaceCreated — main).
private var renderThread: Thread? = null
private val outputs = mutableListOf<Output>()
// Размер видео (0,0 пока неизвестен) — нужен для letterbox-вьюпорта.
@@ -127,6 +131,7 @@ class EglVideoRenderer(
it.width = width
it.height = height
}
requestRedraw()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
@@ -141,6 +146,7 @@ class EglVideoRenderer(
private fun attach(holder: SurfaceHolder) {
if (released) return
renderThread = Thread.currentThread()
val egl = eglCore ?: EglCore().also { eglCore = it }
val ws = WindowSurface(egl, holder.surface, false)
outputs.add(Output(holder, ws))
@@ -160,6 +166,33 @@ class EglVideoRenderer(
}
}
/** Перерисовать последний кадр (для смены масштаба на паузе). */
fun requestRedraw() {
val t = renderThread ?: return
if (Thread.currentThread() == t) redrawNow() else {
// Колбэки SurfaceTexture приходят на поток создания — постим туда.
// attach всегда на main — используем main looper.
android.os.Handler(Looper.getMainLooper()).post { redrawNow() }
}
}
private fun redrawNow() {
if (released) return
val st = videoSurfaceTexture ?: return
val blit = fullFrameBlit ?: return
try {
st.updateTexImage()
st.getTransformMatrix(transformMatrix)
outputs.forEach { output ->
if (output.width > 0 && output.height > 0) {
drawFrame(blit, output.surface, output.width, output.height)
}
}
} catch (_: Exception) {
// GL-контекст может быть не готов — следующий кадр перерисует.
}
}
private fun drawFrame(blit: FullFrameRect, windowSurface: WindowSurface, viewWidth: Int, viewHeight: Int) {
windowSurface.makeCurrent()
val rect = letterboxRect(videoWidth, videoHeight, viewWidth, viewHeight)
@@ -6,13 +6,17 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
@@ -25,6 +29,7 @@ import androidx.compose.ui.unit.dp
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.video.VideoSize
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.status.StatusPanelCompose
@@ -51,12 +56,19 @@ fun GlassesMovieScreen(
var videoWidth by remember { mutableIntStateOf(640) }
var videoHeight by remember { mutableIntStateOf(480) }
val scale by GlassesApp.instance.scale.collectAsState()
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
// глаз из Binocular) регистрируют в нём свои SurfaceView.
val renderer = remember {
EglVideoRenderer(player)
}
// Смена масштаба на паузе — принудительно перерисовать последний кадр.
LaunchedEffect(scale) {
renderer.requestRedraw()
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onVideoSizeChanged(videoSize: VideoSize) {
@@ -77,46 +89,63 @@ fun GlassesMovieScreen(
Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height
val side = BinocularScreenSide.current
// Масштаб через layout: контейнер каждого глаза — scale от половины экрана,
// сдвинут к центру (к носу) на (1 - scale) своей ширины.
val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt()
val (playerWidth, playerHeight) = calculateVideoSizeFull(
videoWidth = videoWidth,
videoHeight = videoHeight,
maxWidth = screenWidthPx, // padding = 0
maxHeight = screenHeightPx - 25,
maxWidth = containerW,
maxHeight = containerH - 25,
)
// «Нос к носу»: окно каждого глаза прижато к центру экрана.
val horizontalArrangement = when (BinocularScreenSide.current) {
Side.LEFT -> Arrangement.End
Side.RIGHT -> Arrangement.Start
}
// ВАЖНО: явная ширина половины (как у Row ниже) — иначе fillMaxSize
// растянет контент на весь 1280, и панель окажется по центру экрана
// (половина в каждом глазу) вместо центра каждой половины.
Box(modifier = Modifier.width((screenWidthPx).px).fillMaxHeight()) {
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px),
) {
Row(
modifier = Modifier
.background(Color.Black)
.width((screenWidthPx).px),
horizontalArrangement = horizontalArrangement,
modifier = Modifier.fillMaxWidth().background(Color.Black),
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
) {
BinocularVideoPlayer(
player = player,
renderer = renderer,
modifier = Modifier.size(playerWidth.px, playerHeight.px),
)
if (SCALE_DEBUG_SQUARE) {
// Тестовый режим: большой красный квадрат вместо видео — быстрая
// проверка масштаба/позиций скрином без запуска видео.
Box(Modifier.fillMaxSize().background(Color.Red))
} else {
BinocularVideoPlayer(
player = player,
renderer = renderer,
modifier = Modifier.size(playerWidth.px, playerHeight.px),
)
}
}
// Панель по центру верхней letterbox-полосы: в каждом глазу — в центре
// своей половины, бинокулярно сливается в одну по центру.
StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
scale = scale,
)
}
}
}
/**
* Смещение контейнера к центру: левый глаз прижат правым краем к центру,
* правый — от центра (offset 0: его левый край уже у центра).
*/
fun scaleOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
if (side == Side.LEFT) (screenWidthPx * (1f - scale)).toInt() else 0
/** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */
private const val SCALE_DEBUG_SQUARE = false
/** Fit с сохранением пропорций: вариант по ширине/по высоте, берём помещающийся. */
fun calculateVideoSizeFull(
videoWidth: Int, videoHeight: Int,