Регулятор «Высота» экрана очков (0..1, с учётом масштаба): SetVertical по протоколу, offset Y в кино/коннект/оверлее, слайдер на пульте

This commit is contained in:
2026-08-25 02:11:01 +03:00
parent b17b5bf424
commit 6d22f56bd8
10 changed files with 120 additions and 3 deletions
@@ -52,6 +52,9 @@ class GlassesApp : Application() {
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
val scale = MutableStateFlow(1f)
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
val vertical = MutableStateFlow(1f)
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */
val listening = MutableStateFlow(false)
@@ -92,10 +95,18 @@ class GlassesApp : Application() {
prefs.edit().putFloat("scale", v).apply()
}
/** Применить и сохранить вертикаль (0..1). */
fun applyVertical(value: Float) {
val v = value.coerceIn(0f, 1f)
vertical.value = v
prefs.edit().putFloat("vertical", v).apply()
}
override fun onCreate() {
super.onCreate()
instance = this
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
MercurySDK.init(this)
movieController = MovieController(applicationContext)
movieController.eventLogger = { msg -> logToFile("mc: $msg") }
@@ -36,6 +36,7 @@ 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.SetVertical
import pw.binom.viewmate.core.protocol.Shutdown
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel
@@ -309,6 +310,11 @@ class HostConnection(
log("app", "scale → ${msg.scale}")
}
is SetVertical -> {
GlassesApp.instance.applyVertical(msg.vertical)
log("app", "vertical → ${msg.vertical}")
}
is PlayVideo -> {
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
reconnectResume.reset()
@@ -65,6 +65,7 @@ import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.px
import pw.binom.viewmate.glasses.ui.scaleOffsetX
import pw.binom.viewmate.glasses.ui.verticalOffsetY
class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false
@@ -458,6 +459,7 @@ fun ConnectionScreen() {
val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState()
val scale by GlassesApp.instance.scale.collectAsState()
val vertical by GlassesApp.instance.vertical.collectAsState()
// Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой
// половины (явная ширина половины, иначе fillMaxSize растянет на 1280).
@@ -475,7 +477,10 @@ fun ConnectionScreen() {
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
.offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
)
.background(Color.Black),
) {
Column(
@@ -91,6 +91,7 @@ private fun overlayBox(
) {
Binocular(scale = false) {
val scale by app.scale.collectAsState()
val vertical by app.vertical.collectAsState()
val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height
val side = BinocularScreenSide.current
@@ -100,7 +101,10 @@ private fun overlayBox(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
.offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
)
.background(Color.Black.copy(alpha = 0.72f)),
) {
AssistantOverlayContent(state, chatHistory, scrollState)
@@ -57,6 +57,7 @@ fun GlassesMovieScreen(
var videoHeight by remember { mutableIntStateOf(480) }
val scale by GlassesApp.instance.scale.collectAsState()
val vertical by GlassesApp.instance.vertical.collectAsState()
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
// глаз из Binocular) регистрируют в нём свои SurfaceView.
@@ -107,7 +108,10 @@ fun GlassesMovieScreen(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px),
.offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
),
) {
Row(
modifier = Modifier.fillMaxWidth().background(Color.Black),
@@ -143,6 +147,14 @@ fun GlassesMovieScreen(
fun scaleOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
if (side == Side.LEFT) (screenWidthPx * (1f - scale)).toInt() else 0
/**
* Вертикальное смещение контейнера глаза (с учётом масштаба):
* vertical = 1.0 — у верха (как всегда было); 0.0 — нижняя кромка контейнера
* прижата к низу экрана. Свободный ход по вертикали = (1 - scale) высоты.
*/
fun verticalOffsetY(screenHeightPx: Int, scale: Float, vertical: Float): Int =
((1f - vertical) * screenHeightPx * (1f - scale)).toInt()
/** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */
private const val SCALE_DEBUG_SQUARE = false
@@ -4,6 +4,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.scaleOffsetX
import pw.binom.viewmate.glasses.ui.verticalOffsetY
/**
* scaleOffsetX — смещение контейнера глаза к центру (к носу):
@@ -34,4 +35,31 @@ class ScaleOffsetTest {
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.5f))
assertEquals(0, scaleOffsetX(Side.RIGHT, 480, 0.5f))
}
/*
* verticalOffsetY — вертикальное положение контейнера с учётом масштаба:
* 1.0 — у верха (offset 0), 0.0 — нижняя кромка у низа ((1 - scale) высоты).
*/
@Test
fun verticalTop_isZero() {
assertEquals(0, verticalOffsetY(480, 1f, 1f))
assertEquals(0, verticalOffsetY(480, 0.7f, 1f))
assertEquals(0, verticalOffsetY(480, 0.5f, 1f))
}
@Test
fun verticalBottom_pushesToBottomEdge() {
// 0.7 scale: свободный ход = 480 * 0.3 = 144
assertEquals(144, verticalOffsetY(480, 0.7f, 0f))
// 1.0 scale: хода нет — экран и так на весь дисплей
assertEquals(0, verticalOffsetY(480, 1f, 0f))
}
@Test
fun verticalMiddle_halfOfFreeRun() {
// 0.7 scale, vertical 0.5: (1-0.5)*480*0.3 = 72
assertEquals(72, verticalOffsetY(480, 0.7f, 0.5f))
assertEquals(0, verticalOffsetY(480, 1f, 0.5f))
}
}
@@ -71,6 +71,9 @@ fun GlassesScreen(
// масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании
var scale by remember { mutableStateOf(app.state.scale) }
// высота экрана очков (0..1): 1 — у верха, 0 — нижняя кромка у низа
var vertical by remember { mutableStateOf(app.state.vertical) }
val actions = app.actions
// активное скачивание на очки: первое с незавершённым файлом
@@ -183,6 +186,31 @@ fun GlassesScreen(
)
}
// высота экрана очков: 1.0 — у верха (как сейчас), 0.0 — нижняя кромка у низа (с учётом масштаба)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp),
) {
Text(
text = "Высота: ${"%.2f".format(vertical)}",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(start = 8.dp),
)
Slider(
value = vertical,
onValueChange = { vertical = it },
onValueChangeFinished = {
app.state.vertical = vertical
val a = actions
if (a != null) scope.launch { a.applyVertical(vertical) }
},
valueRange = 0f..1f,
steps = 9, // шаг 0.1
)
}
// активная загрузка контента на очки
if (activeGlassesItem != null) {
Text(
@@ -17,6 +17,7 @@ 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.SetVertical
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.Shutdown
@@ -275,6 +276,13 @@ class PhoneActions(
return "масштаб → $clamped"
}
/** Вертикаль экрана очков: SetVertical (0..1; 1 — верх, 0 — низ). */
suspend fun applyVertical(vertical: Float): String {
val clamped = vertical.coerceIn(0f, 1f)
sender.send(SetVertical(clamped))
return "высота → $clamped"
}
/** Закрыть приложение на очках (телефон остаётся открытым). */
suspend fun shutdownGlasses(): String {
sender.send(Shutdown())
@@ -39,6 +39,16 @@ class PhoneState {
log("state", "масштаб → $value")
}
private val _vertical = MutableStateFlow(1f)
val verticalFlow: StateFlow<Float> = _vertical.asStateFlow()
/** Вертикаль экрана очков (0..1): 1.0 — у верха, 0.0 — нижняя кромка у низа. */
var vertical: Float
get() = _vertical.value
set(value) {
_vertical.value = value.coerceIn(0f, 1f)
log("state", "высота → $value")
}
private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
var currentItem: JellyfinItem?
@@ -81,6 +81,11 @@ data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlas
@SerialName("set_scale")
data class SetScale(val scale: Float) : HostToGlasses
/** Вертикальное положение экрана очков (0..1); 1.0 — у верха, 0.0 — нижняя кромка у низа. */
@Serializable
@SerialName("set_vertical")
data class SetVertical(val vertical: Float) : HostToGlasses
/** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */
@Serializable
@SerialName("stt_phrase")