glasses: hold Mercury kill switch on hidden VirtualDisplay; phone: brightness/WiFi panel, two-finger touchpad scroll
This commit is contained in:
@@ -12,12 +12,15 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.mercury.DiscoveredGlasses
|
||||
import pw.binom.mercury.RayNeoMercury
|
||||
import pw.binom.mercury.model.GeneralStatus
|
||||
import pw.binom.mercury.model.MyWifiInfo
|
||||
|
||||
/**
|
||||
* Контроллер второго канала связи с очками — RayNeo Mercury BLE.
|
||||
@@ -40,6 +43,21 @@ class MercuryController(private val context: Context) {
|
||||
|
||||
private var lastScanResults: List<DiscoveredGlasses> = emptyList()
|
||||
|
||||
private val _generalStatus = MutableStateFlow<GeneralStatus?>(null)
|
||||
|
||||
/** Последний известный статус очков: яркость, WiFi, экран, батарея. */
|
||||
val generalStatus: StateFlow<GeneralStatus?> = _generalStatus.asStateFlow()
|
||||
|
||||
private val _wifiNetworks = MutableStateFlow<List<MyWifiInfo>>(emptyList())
|
||||
|
||||
/** Сети, найденные последним сканом WiFi на очках. */
|
||||
val wifiNetworks: StateFlow<List<MyWifiInfo>> = _wifiNetworks.asStateFlow()
|
||||
|
||||
private val _wifiScanning = MutableStateFlow(false)
|
||||
|
||||
/** Идёт ли сейчас скан WiFi-сетей очков. */
|
||||
val wifiScanning: StateFlow<Boolean> = _wifiScanning.asStateFlow()
|
||||
|
||||
/** Разрешения, которые нужно запросить для работы с BLE на этом API. */
|
||||
val requiredPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
@@ -156,6 +174,7 @@ class MercuryController(private val context: Context) {
|
||||
|
||||
fun disconnect() {
|
||||
scope.launch { mercury.disconnect() }
|
||||
clearGlassesData()
|
||||
_state.value = MercuryState.Disconnected()
|
||||
}
|
||||
|
||||
@@ -171,6 +190,81 @@ class MercuryController(private val context: Context) {
|
||||
/** Доступ к raw event-flow библиотеки (debug/диагностика). */
|
||||
val events get() = mercury.events
|
||||
|
||||
// ------------------------------------------------------------------ экран / яркость / wifi
|
||||
|
||||
/** Яркость экрана очков, 0..100. */
|
||||
fun setBrightness(value: Int) = launchMercury("setBrightness") {
|
||||
mercury.setBrightness(value)
|
||||
}
|
||||
|
||||
/** Авто-яркость экрана очков. */
|
||||
fun setAutoBrightness(enable: Boolean) = launchMercury("setAutoBrightness") {
|
||||
mercury.setAutoBrightness(enable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Погасить (on=false, deepSuspend) или разбудить (on=true) экран очков.
|
||||
*
|
||||
* Перед засыпанием включаем heartbeat: без него очки роняют BLE-связь
|
||||
* примерно через 3с после выключения экрана (см. RayNeoMercury.enableHeartbeat).
|
||||
*/
|
||||
fun setScreenOn(on: Boolean) = launchMercury("setScreenOn($on)") {
|
||||
if (!on) mercury.enableHeartbeat()
|
||||
mercury.setDeepSuspend(!on)
|
||||
}
|
||||
|
||||
/** Включить/выключить WiFi-радио очков. */
|
||||
fun setWifiEnabled(enable: Boolean) = launchMercury("setWifiEnabled($enable)") {
|
||||
mercury.wifiToggle(enable)
|
||||
}
|
||||
|
||||
/** Просканировать WiFi-сети очками; результат придёт в [wifiNetworks]. */
|
||||
fun scanWifi() {
|
||||
_wifiNetworks.value = emptyList()
|
||||
_wifiScanning.value = true
|
||||
scope.launch {
|
||||
val ok = try {
|
||||
mercury.wifiScan()
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w(TAG, "wifiScan failed: ${t.message}")
|
||||
false
|
||||
}
|
||||
if (!ok) _wifiScanning.value = false
|
||||
}
|
||||
// Очки не всегда присылают ответ — снимаем «сканируем…» по таймауту.
|
||||
scope.launch {
|
||||
delay(WIFI_SCAN_TIMEOUT_MS)
|
||||
_wifiScanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Подключить очки к WiFi-сети ([password] = null для открытой). */
|
||||
fun connectWifi(ssid: String, password: String?) = launchMercury("connectWifi($ssid)") {
|
||||
mercury.wifiConnect(ssid, password)
|
||||
}
|
||||
|
||||
/** Забыть сохранённую сеть на очках. */
|
||||
fun forgetWifi(ssid: String) = launchMercury("forgetWifi($ssid)") {
|
||||
mercury.wifiRemove(ssid)
|
||||
}
|
||||
|
||||
private fun launchMercury(action: String, block: suspend () -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
block()
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w(TAG, "$action failed: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Сбрасывает данные, которые живут только внутри BLE-сессии. */
|
||||
private fun clearGlassesData() {
|
||||
_generalStatus.value = null
|
||||
_wifiNetworks.value = emptyList()
|
||||
_wifiScanning.value = false
|
||||
}
|
||||
|
||||
private fun refreshPermissionState() {
|
||||
_state.value = when {
|
||||
!hasPermissions() -> MercuryState.PermissionsMissing
|
||||
@@ -205,12 +299,14 @@ class MercuryController(private val context: Context) {
|
||||
is RayNeoMercury.Event.Disconnected -> {
|
||||
val cur = _state.value
|
||||
if (cur is MercuryState.Connected) {
|
||||
clearGlassesData()
|
||||
_state.value = MercuryState.Disconnected(
|
||||
message = "Связь потеряна: ${event.cause}",
|
||||
)
|
||||
}
|
||||
}
|
||||
is RayNeoMercury.Event.GeneralStatusUpdate -> {
|
||||
_generalStatus.value = event.status
|
||||
val cur = _state.value
|
||||
if (cur is MercuryState.Connected) {
|
||||
_state.value = cur.copy(
|
||||
@@ -218,6 +314,10 @@ class MercuryController(private val context: Context) {
|
||||
)
|
||||
}
|
||||
}
|
||||
is RayNeoMercury.Event.WifiScanResult -> {
|
||||
_wifiNetworks.value = event.networks
|
||||
_wifiScanning.value = false
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -230,6 +330,11 @@ class MercuryController(private val context: Context) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MercuryController"
|
||||
|
||||
/** Сколько ждать ответ скана WiFi, прежде чем снять статус «сканируем…». */
|
||||
private const val WIFI_SCAN_TIMEOUT_MS = 15_000L
|
||||
|
||||
val Local = staticCompositionLocalOf<MercuryController> {
|
||||
error("MercuryController not provided")
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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.CursorScroll
|
||||
import pw.binom.rayneovm.shared.GetAppList
|
||||
import pw.binom.rayneovm.shared.GlassesStatus
|
||||
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
|
||||
@@ -75,8 +76,9 @@ class VmPhoneController(
|
||||
_state.value = ConnectionState.Searching
|
||||
val scanner = pw.binom.mercury.transport.MdnsScanner(context, "_rayneo-vm._tcp")
|
||||
// mDNS пытается 5с; если не вышло — TCP-фоллбек по подсети ещё ~5с.
|
||||
// Порт 8080 (см. docs/SPEC.md:4752 и VmService.WS_PORT).
|
||||
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080)
|
||||
// Порт 8080 и путь /vm (см. VmService.WS_PORT/WS_PATH). Путь нужен фоллбеку,
|
||||
// чтобы отличить наш сервер от чужого на том же порту.
|
||||
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080, fallbackPath = "/vm")
|
||||
_state.value = ConnectionState.Connecting(service.host)
|
||||
val url = "ws://${service.host}:${service.port}/vm"
|
||||
val transport = Transports.wifi(url, autoReconnect = false)
|
||||
@@ -168,6 +170,9 @@ class VmPhoneController(
|
||||
/** Клик мыши в текущей позиции курсора на очках. */
|
||||
suspend fun clickCursor() = send(CursorClick)
|
||||
|
||||
/** Скролл в текущей позиции курсора: dx/dy — смещение пальцев в пикселях гостя. */
|
||||
suspend fun scrollCursor(dx: Float, dy: Float) = send(CursorScroll(dx, dy))
|
||||
|
||||
/** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */
|
||||
suspend fun sendKey(keyCode: Int) {
|
||||
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN))
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -19,19 +20,29 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.mercury.DiscoveredGlasses
|
||||
import pw.binom.mercury.model.GeneralStatus
|
||||
import pw.binom.mercury.model.MyWifiInfo
|
||||
import pw.binom.rayneovm.phone.mercury.MercuryController
|
||||
import pw.binom.rayneovm.phone.mercury.MercuryState
|
||||
import pw.binom.rayneovm.phone.protocol.ConnectionState
|
||||
@@ -58,6 +69,9 @@ fun GlassesStatusScreen(
|
||||
val connection by controller.state.collectAsState()
|
||||
val glasses by controller.glassesStatus.collectAsState()
|
||||
val mercury by mercuryController.state.collectAsState()
|
||||
val generalStatus by mercuryController.generalStatus.collectAsState()
|
||||
val wifiNetworks by mercuryController.wifiNetworks.collectAsState()
|
||||
val wifiScanning by mercuryController.wifiScanning.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val mercuryConnected = mercury is MercuryState.Connected
|
||||
@@ -89,6 +103,24 @@ fun GlassesStatusScreen(
|
||||
onDisconnect = { mercuryController.disconnect() },
|
||||
)
|
||||
|
||||
ScreenAndBrightnessSection(
|
||||
status = generalStatus,
|
||||
enabled = mercuryConnected,
|
||||
onBrightness = { mercuryController.setBrightness(it) },
|
||||
onAutoBrightness = { mercuryController.setAutoBrightness(it) },
|
||||
onScreenOn = { mercuryController.setScreenOn(it) },
|
||||
)
|
||||
|
||||
GlassesWifiSection(
|
||||
status = generalStatus,
|
||||
networks = wifiNetworks,
|
||||
scanning = wifiScanning,
|
||||
enabled = mercuryConnected,
|
||||
onToggle = { mercuryController.setWifiEnabled(it) },
|
||||
onScan = { mercuryController.scanWifi() },
|
||||
onConnect = { ssid, password -> mercuryController.connectWifi(ssid, password) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LaunchGlassesAppButton(
|
||||
@@ -185,6 +217,163 @@ private fun Channel2MercurySection(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenAndBrightnessSection(
|
||||
status: GeneralStatus?,
|
||||
enabled: Boolean,
|
||||
onBrightness: (Int) -> Unit,
|
||||
onAutoBrightness: (Boolean) -> Unit,
|
||||
onScreenOn: (Boolean) -> Unit,
|
||||
) {
|
||||
SectionCard(title = "Экран и яркость") {
|
||||
if (!enabled) {
|
||||
Hint("Нужен Канал 2 (Mercury BLE).")
|
||||
} else {
|
||||
// Пока палец на слайдере — показываем локальное значение,
|
||||
// а в очки отправляем один раз, на отпускании (BLE не любит спам).
|
||||
var drag by remember { mutableStateOf<Float?>(null) }
|
||||
val brightness = drag ?: (status?.brightness ?: 0).toFloat()
|
||||
StatusRow("Яркость", "${brightness.toInt()}%")
|
||||
Slider(
|
||||
value = brightness,
|
||||
onValueChange = { drag = it },
|
||||
onValueChangeFinished = {
|
||||
drag?.let { onBrightness(it.toInt()) }
|
||||
drag = null
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
)
|
||||
ToggleRow(
|
||||
label = "Авто-яркость",
|
||||
checked = status?.automaticBrightness == true,
|
||||
onCheckedChange = onAutoBrightness,
|
||||
)
|
||||
val sleeping = (status?.deepSuspend ?: 0) == 1
|
||||
OutlinedButton(onClick = { onScreenOn(sleeping) }) {
|
||||
Text(if (sleeping) "Разбудить экран" else "Погасить экран")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlassesWifiSection(
|
||||
status: GeneralStatus?,
|
||||
networks: List<MyWifiInfo>,
|
||||
scanning: Boolean,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onScan: () -> Unit,
|
||||
onConnect: (String, String?) -> Unit,
|
||||
) {
|
||||
var pendingNetwork by remember { mutableStateOf<MyWifiInfo?>(null) }
|
||||
|
||||
SectionCard(title = "WiFi очков") {
|
||||
if (!enabled) {
|
||||
Hint("Нужен Канал 2 (Mercury BLE).")
|
||||
} else {
|
||||
ToggleRow(
|
||||
label = "WiFi включён",
|
||||
checked = status?.wifiEnabled == true,
|
||||
onCheckedChange = onToggle,
|
||||
)
|
||||
StatusRow("Сеть", status?.wifiInfo?.ssid ?: "не подключено")
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedButton(onClick = onScan, enabled = !scanning) {
|
||||
Text(if (scanning) "Сканируем…" else "Найти сети")
|
||||
}
|
||||
if (networks.isNotEmpty()) {
|
||||
Text("Найденные сети:", color = Color(0xFFB0B0B0), fontSize = 12.sp)
|
||||
networks.forEach { network ->
|
||||
NetworkRow(
|
||||
network = network,
|
||||
onClick = {
|
||||
if (network.needsPassword()) {
|
||||
pendingNetwork = network
|
||||
} else {
|
||||
onConnect(network.ssid, null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingNetwork?.let { network ->
|
||||
WifiPasswordDialog(
|
||||
ssid = network.ssid,
|
||||
onDismiss = { pendingNetwork = null },
|
||||
onConfirm = { password ->
|
||||
onConnect(network.ssid, password)
|
||||
pendingNetwork = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetworkRow(network: MyWifiInfo, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (network.saved) "${network.ssid} (сохранена)" else network.ssid,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
if (network.needsPassword()) {
|
||||
Text("нужен пароль", color = Color(0xFFB0B0B0), fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
Button(onClick = onClick) { Text("Подключить") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WifiPasswordDialog(ssid: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Пароль WiFi") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(ssid, color = Color(0xFFB0B0B0), fontSize = 13.sp)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
singleLine = true,
|
||||
label = { Text("Пароль") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(password) }) { Text("Подключить") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Отмена") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(text = label, color = Color(0xFFB0B0B0), fontSize = 14.sp)
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaunchGlassesAppButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
Button(
|
||||
|
||||
@@ -2,8 +2,8 @@ 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.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -48,8 +48,9 @@ private const val KEYCODE_MEDIA_PAUSE = 127
|
||||
|
||||
/**
|
||||
* Пульт управления гостевым приложением на очках:
|
||||
* - тачпад-мышь: движение пальца → курсор (отыгрывается на очках в обоих глазах),
|
||||
* - тачпад-мышь: движение одного пальца → курсор (отыгрывается на очках в обоих глазах),
|
||||
* тап по тачпаду → клик в текущей позиции курсора;
|
||||
* - два пальца по тачпаду → скролл (вверх/вниз/вправо/влево) под курсором;
|
||||
* - кнопки «Назад», «Плей», «Пауза»;
|
||||
* - «Клавиатура» — заглушка (сделаем позже).
|
||||
*/
|
||||
@@ -77,25 +78,61 @@ fun RemoteScreen(
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color(0xFF141414))
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onTap = { click() })
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures { change, drag ->
|
||||
// Относительное движение: полный проход по тачпаду = весь экран гостя.
|
||||
// Один палец — курсор; два пальца — скролл (как на тачпаде ноутбука).
|
||||
awaitEachGesture {
|
||||
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) }
|
||||
var mode = PadMode.UNDECIDED
|
||||
var lastCentroid = Offset.Zero
|
||||
var travel = 0f
|
||||
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val pressed = event.changes.filter { it.pressed }
|
||||
if (pressed.isEmpty()) break
|
||||
|
||||
val centroid = pressed.fold(Offset.Zero) { acc, c -> acc + c.position } /
|
||||
pressed.size.toFloat()
|
||||
// Второй палец появился (или жест начался сразу с двух) — скроллим.
|
||||
if (mode == PadMode.UNDECIDED || (mode == PadMode.CURSOR && pressed.size >= 2)) {
|
||||
mode = if (pressed.size >= 2) PadMode.SCROLL else PadMode.CURSOR
|
||||
lastCentroid = centroid
|
||||
continue
|
||||
}
|
||||
|
||||
val delta = centroid - lastCentroid
|
||||
lastCentroid = centroid
|
||||
if (delta.getDistance() <= 0f) continue
|
||||
travel += delta.getDistance()
|
||||
|
||||
when (mode) {
|
||||
PadMode.CURSOR -> {
|
||||
// Относительное движение: полный проход по тачпаду = весь экран гостя.
|
||||
cursor = Offset(
|
||||
(cursor.x + delta.x * gainX).coerceIn(0f, VmDisplay.WIDTH - 1f),
|
||||
(cursor.y + delta.y * gainY).coerceIn(0f, VmDisplay.HEIGHT - 1f),
|
||||
)
|
||||
scope.launch { controller.moveCursor(cursor.x, cursor.y) }
|
||||
}
|
||||
PadMode.SCROLL -> {
|
||||
// Смещение пальцев в пикселях гостя; очки инжектят колесо.
|
||||
scope.launch {
|
||||
controller.scrollCursor(delta.x * gainX, delta.y * gainY)
|
||||
}
|
||||
}
|
||||
PadMode.UNDECIDED -> Unit
|
||||
}
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
// Отпустили, не сдвинув палец, — это тап (клик по курсору).
|
||||
if (mode != PadMode.SCROLL && travel < viewConfiguration.touchSlop) click()
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Тачпад: ведите пальцем — курсор на очках.\nТап — клик по курсору.",
|
||||
text = "Тачпад: один палец — курсор на очках, тап — клик.\nДва пальца — скролл.",
|
||||
color = Color(0xFF666666),
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -147,3 +184,6 @@ private fun RemoteButton(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Режим жеста на тачпаде — определяется по количеству пальцев. */
|
||||
private enum class PadMode { UNDECIDED, CURSOR, SCROLL }
|
||||
|
||||
Reference in New Issue
Block a user