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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user