feat(#1,#3): binocular start screen via Binocular{}; cursor auto-hide after 3s idle

This commit is contained in:
2026-09-27 06:42:26 +03:00
parent b1efa3d462
commit 3ac76daca1
2 changed files with 67 additions and 23 deletions
@@ -74,6 +74,13 @@ class VmGlassesController {
/** Насос скролла: один на приложение. */
private var scrollPump: Job? = null
/**
* Watchdog автоскрытия курсора: после [CURSOR_HIDE_MS] бездействия курсор гасится. Перезапускается
* на каждом действии тачпада. Курсор появится вновь при следующем move/click/scroll — место
* появления не принципиально (новое — от последнего moveCursor).
*/
private var cursorHideJob: Job? = null
@Volatile private var appContext: Context? = null
/** Сериализует создание/освобождение VirtualDisplay — параллельный acquire оставил бы висеть второй UserService. */
@@ -159,6 +166,7 @@ class VmGlassesController {
x = x.coerceIn(0f, VmDisplay.WIDTH - 1f),
y = y.coerceIn(0f, VmDisplay.HEIGHT - 1f),
)
scheduleCursorHide()
}
/**
@@ -172,6 +180,7 @@ class VmGlassesController {
val pos = _cursor.value ?: return
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
scheduleCursorHide()
testScope.launch {
val ok = ShizukuVdBinder.tap(binder, displayId, pos.x, pos.y)
if (!ok) Log.w(TAG, "clickCursor: tap failed (displayId=$displayId)")
@@ -192,10 +201,30 @@ class VmGlassesController {
*/
fun scrollCursor(dx: Float, dy: Float) {
if (dx == 0f && dy == 0f) return
scheduleCursorHide()
scrollChannel.trySend(floatArrayOf(dx, dy))
ensureScrollPump()
}
/**
* Перезапускает watchdog автоскрытия курсора. Если гость работает и через [CURSOR_HIDE_MS] не
* будет новых действий с тачпада — прячем курсор.
*/
private fun scheduleCursorHide() {
if (activeSource == null) return
cursorHideJob?.cancel()
cursorHideJob = testScope.launch {
delay(CURSOR_HIDE_MS)
_cursor.value = null
}
}
/** Останавливает watchdog и очищает курсор (вызов из [stopGuest]). */
private fun cancelCursorHide() {
cursorHideJob?.cancel()
cursorHideJob = null
}
/** Один насос на приложение: копит дельты и инжектит пачкой. */
private fun ensureScrollPump() {
if (scrollPump != null) return
@@ -353,6 +382,7 @@ class VmGlassesController {
acquireMutex.withLock {
killActiveGuest()
releaseFrameSource()
cancelCursorHide()
_cursor.value = null
setState(GlassesScreenState.NoAppSelected)
}
@@ -551,6 +581,9 @@ class VmGlassesController {
/** Скролл-драг: как часто сливаем накопленные дельты, порог и сброс «хвоста». */
private const val SCROLL_TICK_MS = 90L
/** Через сколько мс простоя тачпада прятать курсор. */
private const val CURSOR_HIDE_MS = 3_000L
/** Минимальная длина драга: короче гость трактует как клик по элементу (проверено на Settings ~50-60 px). */
private const val SCROLL_MIN_PX = 96f
@@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.mercury.binocular.Binocular
import pw.binom.mercury.binocular.BinocularRendererView
import pw.binom.mercury.binocular.NoOpFrameSource
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
@@ -55,34 +56,44 @@ fun GlassesScreen(
modifier = Modifier.fillMaxSize(),
)
// Compose overlay поверх GL
// Compose overlay поверх GL. Простые статусы рисуем бинокулярно (по тексту на каждый глаз)
// через [Binocular]; GL-слой и курсор мыши внутри Binocular НЕ оборачиваем — они сами
// справляются (рендер и точки в обеих половинах).
when (val s = state) {
is GlassesScreenState.Starting -> StatusOverlay("Подготавливаем очки...")
is GlassesScreenState.ShizukuMissing -> StatusOverlay(
title = "Shizuku не активен",
subtitle = "Без Shizuku приложение не может создать виртуальный экран.\n" +
"Тапните здесь, чтобы дать разрешение.",
onClick = onRequestShizukuPermission,
)
is GlassesScreenState.NoConnection -> StatusOverlay(
title = "Нет связи с телефоном",
subtitle = "Убедитесь, что телефон и очки в одной WiFi-сети."
)
is GlassesScreenState.NoAppSelected -> StatusOverlay(
title = "Выберите приложение на телефоне",
subtitle = "Откройте приложение «RayNeo VM» и выберите приложение для запуска.\n\n[DEBUG] Тап-тест: запустить Zona сейчас",
onClick = {
controller.startTestLaunch("free.zona")
}
)
is GlassesScreenState.Starting -> Binocular { StatusOverlay("Подготавливаем очки...") }
is GlassesScreenState.ShizukuMissing -> Binocular {
StatusOverlay(
title = "Shizuku не активен",
subtitle = "Без Shizuku приложение не может создать виртуальный экран.\n" +
"Тапните здесь, чтобы дать разрешение.",
onClick = onRequestShizukuPermission,
)
}
is GlassesScreenState.NoConnection -> Binocular {
StatusOverlay(
title = "Нет связи с телефоном",
subtitle = "Убедитесь, что телефон и очки в одной WiFi-сети."
)
}
is GlassesScreenState.NoAppSelected -> Binocular {
StatusOverlay(
title = "Выберите приложение на телефоне",
subtitle = "Откройте приложение «RayNeo VM» и выберите приложение для запуска.\n\n[DEBUG] Тап-тест: запустить Zona сейчас",
onClick = {
controller.startTestLaunch("free.zona")
}
)
}
is GlassesScreenState.Running -> {
// Ничего поверх GL, кроме курсора мыши: FrameSource уже установлен контроллером.
cursor?.let { CursorOverlay(it.x, it.y) }
}
is GlassesScreenState.Error -> StatusOverlay(
title = "Ошибка",
subtitle = s.message,
)
is GlassesScreenState.Error -> Binocular {
StatusOverlay(
title = "Ошибка",
subtitle = s.message,
)
}
}
}
}