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 android.app.Application
import com.rayneo.arsdk.android.MercurySDK import com.rayneo.arsdk.android.MercurySDK
import java.io.File import java.io.File
import kotlinx.coroutines.flow.MutableStateFlow
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */ /** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
object GlassesConfig { object GlassesConfig {
@@ -27,6 +28,9 @@ class GlassesApp : Application() {
lateinit var downloadManager: GlassesDownloadManager lateinit var downloadManager: GlassesDownloadManager
private set private set
/** Масштаб экрана очков (0.5..1.0); задаётся пультом телефона. */
val scale = MutableStateFlow(1f)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this 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.PlayVideo
import pw.binom.viewmate.core.protocol.PlaybackPosition import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode 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.ShowText
import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.Welcome
@@ -221,6 +222,11 @@ class HostConnection(
is SetMode -> addMessage("SetMode: ${msg.mode}") 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 -> { is PlayVideo -> {
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}") addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
reconnectResume.reset() reconnectResume.reset()
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
* дублируется и бинокулярно сливается в одну. * дублируется и бинокулярно сливается в одну.
*/ */
@Composable @Composable
fun StatusPanelCompose(modifier: Modifier = Modifier) { fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
var data by remember { mutableStateOf<StatusPanelData?>(null) } var data by remember { mutableStateOf<StatusPanelData?>(null) }
val context = LocalContext.current val context = LocalContext.current
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -34,15 +34,17 @@ fun StatusPanelCompose(modifier: Modifier = Modifier) {
onDispose { provider.stop() } onDispose { provider.stop() }
} }
val d = data ?: return val d = data ?: return
val baseStyle = MaterialTheme.typography.bodyMedium
val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale)
Row( Row(
modifier = modifier modifier = modifier
.background(Color(0x99000000)) // полупрозрачный чёрный .background(Color(0x99000000)) // полупрозрачный чёрный
.padding(horizontal = 10.dp, vertical = 4.dp), .padding(horizontal = 10.dp * scale, vertical = 4.dp * scale),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(d.time, color = Color.White, style = MaterialTheme.typography.bodyMedium) Text(d.time, color = Color.White, style = style)
Spacer(Modifier.width(6.dp)) 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.graphics.SurfaceTexture
import android.opengl.GLES20 import android.opengl.GLES20
import android.os.Looper
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
@@ -73,6 +74,9 @@ class EglVideoRenderer(
private val transformMatrix = FloatArray(16) private val transformMatrix = FloatArray(16)
private var playerSurface: Surface? = null private var playerSurface: Surface? = null
// Поток, где создан GL-контекст/привязка EGL (attach из surfaceCreated — main).
private var renderThread: Thread? = null
private val outputs = mutableListOf<Output>() private val outputs = mutableListOf<Output>()
// Размер видео (0,0 пока неизвестен) — нужен для letterbox-вьюпорта. // Размер видео (0,0 пока неизвестен) — нужен для letterbox-вьюпорта.
@@ -127,6 +131,7 @@ class EglVideoRenderer(
it.width = width it.width = width
it.height = height it.height = height
} }
requestRedraw()
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun surfaceDestroyed(holder: SurfaceHolder) {
@@ -141,6 +146,7 @@ class EglVideoRenderer(
private fun attach(holder: SurfaceHolder) { private fun attach(holder: SurfaceHolder) {
if (released) return if (released) return
renderThread = Thread.currentThread()
val egl = eglCore ?: EglCore().also { eglCore = it } val egl = eglCore ?: EglCore().also { eglCore = it }
val ws = WindowSurface(egl, holder.surface, false) val ws = WindowSurface(egl, holder.surface, false)
outputs.add(Output(holder, ws)) 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) { private fun drawFrame(blit: FullFrameRect, windowSurface: WindowSurface, viewWidth: Int, viewHeight: Int) {
windowSurface.makeCurrent() windowSurface.makeCurrent()
val rect = letterboxRect(videoWidth, videoHeight, viewWidth, viewHeight) 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember 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.Player
import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.video.VideoSize import com.google.android.exoplayer2.video.VideoSize
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.log import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.status.StatusPanelCompose import pw.binom.viewmate.glasses.status.StatusPanelCompose
@@ -51,12 +56,19 @@ fun GlassesMovieScreen(
var videoWidth by remember { mutableIntStateOf(640) } var videoWidth by remember { mutableIntStateOf(640) }
var videoHeight by remember { mutableIntStateOf(480) } var videoHeight by remember { mutableIntStateOf(480) }
val scale by GlassesApp.instance.scale.collectAsState()
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый // Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
// глаз из Binocular) регистрируют в нём свои SurfaceView. // глаз из Binocular) регистрируют в нём свои SurfaceView.
val renderer = remember { val renderer = remember {
EglVideoRenderer(player) EglVideoRenderer(player)
} }
// Смена масштаба на паузе — принудительно перерисовать последний кадр.
LaunchedEffect(scale) {
renderer.requestRedraw()
}
DisposableEffect(player) { DisposableEffect(player) {
val listener = object : Player.Listener { val listener = object : Player.Listener {
override fun onVideoSizeChanged(videoSize: VideoSize) { override fun onVideoSizeChanged(videoSize: VideoSize) {
@@ -77,46 +89,63 @@ fun GlassesMovieScreen(
Binocular(scale = false) { Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height 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( val (playerWidth, playerHeight) = calculateVideoSizeFull(
videoWidth = videoWidth, videoWidth = videoWidth,
videoHeight = videoHeight, videoHeight = videoHeight,
maxWidth = screenWidthPx, // padding = 0 maxWidth = containerW,
maxHeight = screenHeightPx - 25, maxHeight = containerH - 25,
) )
// «Нос к носу»: окно каждого глаза прижато к центру экрана. Box(
val horizontalArrangement = when (BinocularScreenSide.current) {
Side.LEFT -> Arrangement.End
Side.RIGHT -> Arrangement.Start
}
// ВАЖНО: явная ширина половины (как у Row ниже) — иначе fillMaxSize
// растянет контент на весь 1280, и панель окажется по центру экрана
// (половина в каждом глазу) вместо центра каждой половины.
Box(modifier = Modifier.width((screenWidthPx).px).fillMaxHeight()) {
Row(
modifier = Modifier modifier = Modifier
.background(Color.Black) .width(containerW.px)
.width((screenWidthPx).px), .height(containerH.px)
horizontalArrangement = horizontalArrangement, .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( BinocularVideoPlayer(
player = player, player = player,
renderer = renderer, renderer = renderer,
modifier = Modifier.size(playerWidth.px, playerHeight.px), modifier = Modifier.size(playerWidth.px, playerHeight.px),
) )
} }
}
// Панель по центру верхней letterbox-полосы: в каждом глазу — в центре // Панель по центру верхней letterbox-полосы: в каждом глазу — в центре
// своей половины, бинокулярно сливается в одну по центру. // своей половины, бинокулярно сливается в одну по центру.
StatusPanelCompose( StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), 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 с сохранением пропорций: вариант по ширине/по высоте, берём помещающийся. */ /** Fit с сохранением пропорций: вариант по ширине/по высоте, берём помещающийся. */
fun calculateVideoSizeFull( fun calculateVideoSizeFull(
videoWidth: Int, videoHeight: Int, 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 // трекбар: пока тянем — слайдер следует за пальцем; отпустили — абсолютный SEEK
var sliderValue by remember { mutableStateOf<Float?>(null) } var sliderValue by remember { mutableStateOf<Float?>(null) }
// масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании
var scale by remember { mutableStateOf(app.state.scale) }
val actions = app.actions 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) { if (activeGlassesItem != null) {
Text( 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.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode 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.ShowText
/** /**
@@ -102,6 +103,8 @@ class PhoneActions(
val title = item.Name ?: itemId val title = item.Name ?: itemId
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex)) 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.mode = GlassesMode.MOVIE
state.currentItem = item state.currentItem = item
state.currentAudioIndex = audioIndex state.currentAudioIndex = audioIndex
@@ -253,5 +256,12 @@ class PhoneActions(
return "команда медиа: $cmd${if (seekMs != 0L) " ($seekMs мс)" else ""}" 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 private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60
} }
@@ -29,6 +29,16 @@ class PhoneState {
log("state", "режим → $value") 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) private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow() val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
var currentItem: JellyfinItem? var currentItem: JellyfinItem?
@@ -75,3 +75,8 @@ data class ShowText(val text: String, val sessionId: String? = null) : HostToGla
@Serializable @Serializable
@SerialName("chat_session") @SerialName("chat_session")
data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlasses 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) 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 @Test
fun mediaCommandRoundTrip() { fun mediaCommandRoundTrip() {
val msg: HostToGlasses = MediaCommandMsg(command = MediaCommand.SEEK_FORWARD, seekMs = 30_000) val msg: HostToGlasses = MediaCommandMsg(command = MediaCommand.SEEK_FORWARD, seekMs = 30_000)