fix(app-glasses): scale на паузе — принудительная перерисовка кадра
На паузе ExoPlayer не шлёт кадры — поверхность меняла размер/позицию, но кадр оставался старым (часть видео не видно). requestRedraw(): updateTexImage + drawFrame для всех outputs — вызывается при смене scale (LaunchedEffect) и в surfaceChanged. Подтверждено на железе.
This commit is contained in:
@@ -3,6 +3,7 @@ package pw.binom.viewmate.glasses
|
||||
import android.app.Application
|
||||
import com.rayneo.arsdk.android.MercurySDK
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
||||
object GlassesConfig {
|
||||
@@ -27,6 +28,9 @@ class GlassesApp : Application() {
|
||||
lateinit var downloadManager: GlassesDownloadManager
|
||||
private set
|
||||
|
||||
/** Масштаб экрана очков (0.5..1.0); задаётся пультом телефона. */
|
||||
val scale = MutableStateFlow(1f)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
@@ -30,6 +30,7 @@ import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||||
import pw.binom.viewmate.core.protocol.SetMode
|
||||
import pw.binom.viewmate.core.protocol.SetScale
|
||||
import pw.binom.viewmate.core.protocol.ShowText
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
|
||||
@@ -221,6 +222,11 @@ class HostConnection(
|
||||
|
||||
is SetMode -> addMessage("SetMode: ${msg.mode}")
|
||||
|
||||
is SetScale -> {
|
||||
GlassesApp.instance.scale.value = msg.scale.coerceIn(0.5f, 1f)
|
||||
log("app", "scale → ${msg.scale}")
|
||||
}
|
||||
|
||||
is PlayVideo -> {
|
||||
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
|
||||
reconnectResume.reset()
|
||||
|
||||
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
* дублируется и бинокулярно сливается в одну.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusPanelCompose(modifier: Modifier = Modifier) {
|
||||
fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
|
||||
var data by remember { mutableStateOf<StatusPanelData?>(null) }
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
@@ -34,15 +34,17 @@ fun StatusPanelCompose(modifier: Modifier = Modifier) {
|
||||
onDispose { provider.stop() }
|
||||
}
|
||||
val d = data ?: return
|
||||
val baseStyle = MaterialTheme.typography.bodyMedium
|
||||
val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(Color(0x99000000)) // полупрозрачный чёрный
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
.padding(horizontal = 10.dp * scale, vertical = 4.dp * scale),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(d.time, color = Color.White, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(d.time, color = Color.White, style = style)
|
||||
Spacer(Modifier.width(6.dp * scale))
|
||||
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
|
||||
Text("${d.percent}%", color = Color(d.chargeColorArgb), style = MaterialTheme.typography.bodyMedium)
|
||||
Text("${d.percent}%", color = Color(d.chargeColorArgb), style = style)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user