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 851233a..bbf0891 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 @@ -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") } 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 3af7cc0..5db6702 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 @@ -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() diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index 139e39b..5fc395c 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -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( diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt index 7a1a026..1c69e66 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt @@ -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) 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 317d2f0..0cd7a03 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 @@ -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 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 index 79c189f..292a1d5 100644 --- a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/ScaleOffsetTest.kt +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/ScaleOffsetTest.kt @@ -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)) + } } 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 10ac5ea..0a0f190 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 @@ -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( 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 3b8b8e8..cf434e1 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 @@ -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()) 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 c51f6af..f443317 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 @@ -39,6 +39,16 @@ class PhoneState { log("state", "масштаб → $value") } + private val _vertical = MutableStateFlow(1f) + val verticalFlow: StateFlow = _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(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 3ef44c1..1daa459 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 @@ -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")