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
@@ -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()) {
Row(
Box(
modifier = Modifier
.background(Color.Black)
.width((screenWidthPx).px),
horizontalArrangement = horizontalArrangement,
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px),
) {
Row(
modifier = Modifier.fillMaxWidth().background(Color.Black),
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
) {
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,
@@ -0,0 +1,37 @@
package pw.binom.viewmate.glasses
import kotlin.test.Test
import kotlin.test.assertEquals
import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.scaleOffsetX
/**
* scaleOffsetX — смещение контейнера глаза к центру (к носу):
* левый прижат правым краем к центру, правый — offset 0 (левый край уже у центра).
* Чистая функция — тестируется без Android.
*/
class ScaleOffsetTest {
@Test
fun leftFullScreenNoOffset() {
assertEquals(0, scaleOffsetX(Side.LEFT, 640, 1f))
}
@Test
fun leftScale07Offset() {
assertEquals(192, scaleOffsetX(Side.LEFT, 640, 0.7f))
}
@Test
fun leftScale05Offset() {
assertEquals(320, scaleOffsetX(Side.LEFT, 640, 0.5f))
}
@Test
fun rightNeverOffset() {
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 1f))
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.7f))
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.5f))
assertEquals(0, scaleOffsetX(Side.RIGHT, 480, 0.5f))
}
}
@@ -68,6 +68,9 @@ fun GlassesScreen(
// трекбар: пока тянем — слайдер следует за пальцем; отпустили — абсолютный SEEK
var sliderValue by remember { mutableStateOf<Float?>(null) }
// масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании
var scale by remember { mutableStateOf(app.state.scale) }
val actions = app.actions
// активное скачивание на очки: первое с незавершённым файлом
@@ -155,6 +158,31 @@ fun GlassesScreen(
}
}
// масштаб экрана очков: весь экран уменьшается (0.5..1), картина прижимается к носу
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp),
) {
Text(
text = "Масштаб: ${"%.2f".format(scale)}",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(start = 8.dp),
)
Slider(
value = scale,
onValueChange = { scale = it },
onValueChangeFinished = {
app.state.scale = scale
val a = actions
if (a != null) scope.launch { a.applyScale(scale) }
},
valueRange = 0.5f..1f,
steps = 9, // шаг 0.05
)
}
// активная загрузка контента на очки
if (activeGlassesItem != null) {
Text(
@@ -16,6 +16,7 @@ import pw.binom.viewmate.core.protocol.DownloadFiles
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.ShowText
/**
@@ -102,6 +103,8 @@ class PhoneActions(
val title = item.Name ?: itemId
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex))
// очки не помнят scale между запусками — при старте просмотра шлём текущий
if (state.scale != 1f) sender.send(SetScale(state.scale))
state.mode = GlassesMode.MOVIE
state.currentItem = item
state.currentAudioIndex = audioIndex
@@ -253,5 +256,12 @@ class PhoneActions(
return "команда медиа: $cmd${if (seekMs != 0L) " ($seekMs мс)" else ""}"
}
/** Масштаб экрана очков: SetScale (0.5..1.0). */
suspend fun applyScale(scale: Float): String {
val clamped = scale.coerceIn(0.5f, 1f)
sender.send(SetScale(clamped))
return "масштаб → $clamped"
}
private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60
}
@@ -29,6 +29,16 @@ class PhoneState {
log("state", "режим → $value")
}
private val _scale = MutableStateFlow(1f)
val scaleFlow: StateFlow<Float> = _scale.asStateFlow()
/** Масштаб экрана очков (0.5..1.0); слайдер пульта пишет сюда же. */
var scale: Float
get() = _scale.value
set(value) {
_scale.value = value.coerceIn(0.5f, 1f)
log("state", "масштаб → $value")
}
private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
var currentItem: JellyfinItem?
@@ -75,3 +75,8 @@ data class ShowText(val text: String, val sessionId: String? = null) : HostToGla
@Serializable
@SerialName("chat_session")
data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlasses
/** Масштаб экрана очков (0.5..1.0); 1.0 — на весь экран. */
@Serializable
@SerialName("set_scale")
data class SetScale(val scale: Float) : HostToGlasses
@@ -45,6 +45,16 @@ class HostToGlassesTest {
assertEquals(GlassesMode.MOVIE, decoded.mode)
}
@Test
fun setScaleRoundTrip() {
val msg: HostToGlasses = SetScale(scale = 0.7f)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"set_scale","scale":0.7}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<SetScale>(decoded)
assertEquals(0.7f, decoded.scale)
}
@Test
fun mediaCommandRoundTrip() {
val msg: HostToGlasses = MediaCommandMsg(command = MediaCommand.SEEK_FORWARD, seekMs = 30_000)