phone: third «Пульт» tab with on-glasses cursor; atomic click injection

- shared: VmDisplay (guest 640x480 coordinate space), CursorMove and
  CursorClick messages (CursorClick replaces the DOWN/UP pair — see below).
- glasses: cursor state + Canvas overlay drawn in both eyes, clickCursor
  injects one atomic tap at the cursor, sendKey for Back/Play/Pause.
- phone: RemoteScreen — touchpad (relative drag moves the cursor, tap
  clicks), «Назад»/«Плей»/«Пауза» buttons, «Клавиатура» stub (TODO).
- The old CursorButton(DOWN)/CursorButton(UP) pair was removed: the two
  messages were injected concurrently and could arrive out of order,
  breaking the guest's touch stream so clicks worked only once.
- tests: CursorMove/CursorClick roundtrips.
This commit is contained in:
2026-09-25 18:20:56 +03:00
parent 03f1d7dcc7
commit 3fce3eb3ca
9 changed files with 338 additions and 8 deletions
@@ -18,8 +18,11 @@ import pw.binom.rayneovm.shared.AppCrashedEvent
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.VmMessage
@@ -159,6 +162,18 @@ class VmPhoneController(
}
}
/** Позиция курсора мыши в координатах VirtualDisplay — очки рисуют его в обоих глазах. */
suspend fun moveCursor(x: Float, y: Float) = send(CursorMove(x, y))
/** Клик мыши в текущей позиции курсора на очках. */
suspend fun clickCursor() = send(CursorClick)
/** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */
suspend fun sendKey(keyCode: Int) {
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN))
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_UP))
}
/**
* Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE.
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Mouse
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -25,10 +26,11 @@ import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.protocol.VmPhoneController
/**
* Главный экран приложения: NavigationBar с двумя табами.
* Главный экран приложения: NavigationBar с тремя табами.
*
* - «Очки» — статус связи и состояние GlassesApp
* - «Приложения» — список приложений для запуска
* - «Пульт» — тачпад-мышь и медиа-кнопки для гостя
*/
@Composable
fun HomeScreen(
@@ -63,6 +65,7 @@ fun HomeScreen(
onRequestMercuryPermissions = onRequestMercuryPermissions,
)
Tab.APPS -> AppsListScreen(controller = controller)
Tab.REMOTE -> RemoteScreen(controller = controller)
}
}
}
@@ -72,4 +75,5 @@ fun HomeScreen(
private enum class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
GLASSES("Очки", Icons.Default.Settings),
APPS("Приложения", Icons.Default.Apps),
REMOTE("Пульт", Icons.Default.Mouse),
}
@@ -0,0 +1,149 @@
package pw.binom.rayneovm.phone.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.VmDisplay
// android.view.KeyEvent-коды. Здесь обычные числа, чтобы не тянуть android-зависимости в UI-тесты.
private const val KEYCODE_BACK = 4
private const val KEYCODE_MEDIA_PLAY = 126
private const val KEYCODE_MEDIA_PAUSE = 127
/**
* Пульт управления гостевым приложением на очках:
* - тачпад-мышь: движение пальца → курсор (отыгрывается на очках в обоих глазах),
* тап по тачпаду → клик в текущей позиции курсора;
* - кнопки «Назад», «Плей», «Пауза»;
* - «Клавиатура» — заглушка (сделаем позже).
*/
@Composable
fun RemoteScreen(
controller: VmPhoneController,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
// Позиция курсора в координатах VirtualDisplay гостя.
var cursor by remember { mutableStateOf(Offset(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)) }
fun click() {
scope.launch { controller.clickCursor() }
}
Column(
modifier = modifier.fillMaxSize().background(Color.Black).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFF141414))
.pointerInput(Unit) {
detectTapGestures(onTap = { click() })
}
.pointerInput(Unit) {
detectDragGestures { change, drag ->
// Относительное движение: полный проход по тачпаду = весь экран гостя.
val gainX = VmDisplay.WIDTH / size.width.toFloat()
val gainY = VmDisplay.HEIGHT / size.height.toFloat()
cursor = Offset(
(cursor.x + drag.x * gainX).coerceIn(0f, VmDisplay.WIDTH - 1f),
(cursor.y + drag.y * gainY).coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
change.consume()
scope.launch { controller.moveCursor(cursor.x, cursor.y) }
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = "Тачпад: ведите пальцем — курсор на очках.\nТап — клик по курсору.",
color = Color(0xFF666666),
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
// Мини-индикатор положения курсора (для обратной связи на телефоне).
Canvas(modifier = Modifier.fillMaxSize()) {
val cx = cursor.x / VmDisplay.WIDTH * size.width
val cy = cursor.y / VmDisplay.HEIGHT * size.height
drawCircle(color = Color(0xFFFFEB3B), radius = 8f, center = Offset(cx, cy))
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RemoteButton(Icons.AutoMirrored.Filled.ArrowBack, "Назад", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_BACK) }
}
RemoteButton(Icons.Default.PlayArrow, "Плей", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_MEDIA_PLAY) }
}
RemoteButton(Icons.Default.Pause, "Пауза", Modifier.weight(1f)) {
scope.launch { controller.sendKey(KEYCODE_MEDIA_PAUSE) }
}
// TODO: клавиатура — отдельная задача.
RemoteButton(Icons.Default.Keyboard, "Клавиатура", Modifier.weight(1f), enabled = false) {}
}
}
}
@Composable
private fun RemoteButton(
icon: ImageVector,
label: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier,
contentPadding = PaddingValues(vertical = 12.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, contentDescription = label)
Text(label, fontSize = 11.sp)
}
}
}