From 896185244b8538bedf00f60a24d9fcc292a33a9d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 20 Aug 2026 00:52:21 +0300 Subject: [PATCH] =?UTF-8?q?fix(app-glasses):=20scale=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=83=D0=B7=D0=B5=20=E2=80=94=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D1=83=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D1=80=D0=B8=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BA=D0=B0=D0=B4=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На паузе ExoPlayer не шлёт кадры — поверхность меняла размер/позицию, но кадр оставался старым (часть видео не видно). requestRedraw(): updateTexImage + drawFrame для всех outputs — вызывается при смене scale (LaunchedEffect) и в surfaceChanged. Подтверждено на железе. --- .../pw/binom/viewmate/glasses/GlassesApp.kt | 4 + .../binom/viewmate/glasses/HostConnection.kt | 6 ++ .../glasses/status/StatusPanelCompose.kt | 12 +-- .../glasses/ui/BinocularVideoPlayer.kt | 33 +++++++++ .../viewmate/glasses/ui/GlassesMovieScreen.kt | 73 +++++++++++++------ .../binom/viewmate/glasses/ScaleOffsetTest.kt | 37 ++++++++++ .../binom/viewmate/phone/ui/GlassesScreen.kt | 28 +++++++ .../binom/viewmate/core/phone/PhoneActions.kt | 10 +++ .../binom/viewmate/core/phone/PhoneState.kt | 10 +++ .../viewmate/core/protocol/HostToGlasses.kt | 5 ++ .../core/protocol/HostToGlassesTest.kt | 10 +++ 11 files changed, 201 insertions(+), 27 deletions(-) create mode 100644 app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/ScaleOffsetTest.kt diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index 9be1fdc..04821f5 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -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 diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index 4458215..947f5f6 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -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() diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt index f9792f0..e05786a 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt @@ -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(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) } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt index d85430e..4c43480 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt @@ -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() // Размер видео (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) diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt index e359204..317d2f0 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt @@ -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, diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/ScaleOffsetTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/ScaleOffsetTest.kt new file mode 100644 index 0000000..79c189f --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/ScaleOffsetTest.kt @@ -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)) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt index 8bd91ae..9197c65 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt @@ -68,6 +68,9 @@ fun GlassesScreen( // трекбар: пока тянем — слайдер следует за пальцем; отпустили — абсолютный SEEK var sliderValue by remember { mutableStateOf(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( diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt index 1d1d817..eeba888 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt @@ -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 } diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt index 7460231..c51f6af 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneState.kt @@ -29,6 +29,16 @@ class PhoneState { log("state", "режим → $value") } + private val _scale = MutableStateFlow(1f) + val scaleFlow: StateFlow = _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(null) val currentItemFlow: StateFlow = _currentItem.asStateFlow() var currentItem: JellyfinItem? diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt index 936b297..36e96d8 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt @@ -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 diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt index ce463ea..5f7c132 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt @@ -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(decoded) + assertEquals(0.7f, decoded.scale) + } + @Test fun mediaCommandRoundTrip() { val msg: HostToGlasses = MediaCommandMsg(command = MediaCommand.SEEK_FORWARD, seekMs = 30_000)