Очки: жесты и режимы общения с ассистентом (по docs/gestures-and-modes.md)

- AssistantMode: NONE/LISTENING/THINKING/ANSWER + чистая gestureDecision (матрица жестов)
- Клик в «ответ виден» = новая фраза; DoubleClick = выход (кино на паузе); свайпы = скролл
- Плавный скролл ответа: SlideContinuous delta → answerScrollDelta → scrollOffset (зажим 0..max)
- Индикатор «думаю...» (thinking, сброс на ShowText)
- AssistantOverlay: затемнение 72%, по глазам, скролл-аккумулятор
- Тесты: 22 (AssistantGesturesTest) — glasses 91 зелёных
This commit is contained in:
2026-08-23 01:55:24 +03:00
parent f36d04adc1
commit f75e672819
6 changed files with 664 additions and 90 deletions
@@ -19,7 +19,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -37,7 +36,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -52,10 +50,14 @@ import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.glasses.gestures.AssistantState
import pw.binom.viewmate.glasses.gestures.GestureDecision
import pw.binom.viewmate.glasses.gestures.actionName
import pw.binom.viewmate.glasses.gestures.templeActionToCommand
import pw.binom.viewmate.glasses.gestures.assistantMode
import pw.binom.viewmate.glasses.gestures.gestureDecision
import pw.binom.viewmate.glasses.status.StatusPanelCompose
import pw.binom.viewmate.glasses.stt.SttMicStream
import pw.binom.viewmate.glasses.ui.AssistantOverlay
import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
@@ -80,9 +82,9 @@ class MainActivity : BaseEventActivity() {
private val debugReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.getStringExtra("type")) {
"triple_click" -> handleTripleClick()
"click" -> handleClick()
"double_click" -> handleDoubleClick()
"triple_click" -> onTempleAction(TempleAction.TripleClick())
"click" -> onTempleAction(TempleAction.Click())
"double_click" -> onTempleAction(TempleAction.DoubleClick())
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
}
}
@@ -137,6 +139,9 @@ class MainActivity : BaseEventActivity() {
} else {
ConnectionScreen()
}
// Оверлей режима общения: поверх любого экрана (затемняет кино),
// виден в «слушаю»/«думаю»/«ответ виден».
AssistantOverlay()
}
}
}
@@ -190,11 +195,6 @@ class MainActivity : BaseEventActivity() {
}, null)
}
/**
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
* голосовой ввод (безусловный вызов), Click во время прослушивания —
* завершить распознавание, DoubleClick — отмена.
*/
private fun requestBtPermissions() {
if (!GlassesConfig.USE_BLUETOOTH) return
val granted = BtGlassesTransport.runtimePermissions().all {
@@ -205,73 +205,127 @@ class MainActivity : BaseEventActivity() {
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
}
/** События трекпада (SDK) → [onTempleAction] — единственная точка входа жестов. */
private fun collectTempleActions() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
templeActionViewModel.state.collect { action ->
when (action) {
is TempleAction.TripleClick -> handleTripleClick()
is TempleAction.Click -> handleClick()
is TempleAction.DoubleClick -> handleDoubleClick()
is TempleAction.SlideForward, is TempleAction.SlideBackward -> {
val cmd = templeActionToCommand(action)
log("gesture", "жест: ${actionName(action)} → ${cmd?.command}")
if (cmd != null) {
GlassesApp.instance.movieController.applyMediaCommand(cmd) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
}
else -> log("gesture", "жест (резерв): ${actionName(action)}")
}
onTempleAction(action)
}
}
}
}
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */
private fun handleTripleClick() {
if (GlassesApp.instance.listening.value) {
/**
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
*/
private fun onTempleAction(action: TempleAction) {
val app = GlassesApp.instance
val state = AssistantState(
listening = app.listening.value,
thinking = app.thinking.value,
sttText = app.sttText.value,
assistantText = app.assistantText.value,
)
when (val decision = gestureDecision(action, state)) {
GestureDecision.Ignore ->
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
GestureDecision.CallAssistant -> startAssistantMode()
GestureDecision.TogglePlayback -> togglePlayback()
GestureDecision.SendPhrase -> sendPhrase()
GestureDecision.NewPhrase -> startNewPhrase()
GestureDecision.CancelDictation -> cancelDictation()
GestureDecision.ExitMode -> exitAssistantMode()
is GestureDecision.Seek -> seekBy(decision)
is GestureDecision.ScrollAnswer -> {
val acc = app.answerScrollDelta.value + decision.delta
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
app.answerScrollDelta.value = acc
}
}
}
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
private fun startAssistantMode() {
val app = GlassesApp.instance
if (app.listening.value) {
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
return
}
log("stt", "ассистент: слушаю")
if (GlassesApp.instance.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick)")
if (app.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
GlassesApp.instance.listening.value = true
GlassesApp.instance.sttText.value = ""
GlassesApp.instance.assistantText.value = ""
sttMic.start(filesDir) { GlassesApp.instance.listening.value }
app.listening.value = true
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
sttMic.start(filesDir) { app.listening.value }
}
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */
private fun handleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "Click → завершить распознавание")
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
return
}
/** Click (кино): плей/пауза — как сегодня. */
private fun togglePlayback() {
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */
private fun handleDoubleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "DoubleClick → отмена распознавания")
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
}
return
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
private fun sendPhrase() {
log("gesture", "Click → отправить фразу")
sttMic.stop()
GlassesApp.instance.thinking.value = true
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
}
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
private fun startNewPhrase() {
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
val app = GlassesApp.instance
app.assistantText.value = ""
app.sttText.value = ""
app.thinking.value = false
app.answerScrollDelta.value = 0f
app.listening.value = true
sttMic.start(filesDir) { app.listening.value }
}
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
private fun cancelDictation() {
log("gesture", "DoubleClick → отмена распознавания")
sttMic.stop()
GlassesApp.instance.thinking.value = false
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
}
}
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
private fun exitAssistantMode() {
log("gesture", "DoubleClick → выход из режима (кино остаётся на паузе)")
sttMic.stop()
val app = GlassesApp.instance
app.listening.value = false
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
}
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
private fun seekBy(seek: GestureDecision.Seek) {
log("gesture", "жест: свайп → ${seek.command}")
GlassesApp.instance.movieController.applyMediaCommand(
MediaCommandMsg(seek.command, seek.seekMs)
) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
log("gesture", "жест (резерв): DoubleClick")
}
/**
@@ -411,41 +465,7 @@ fun ConnectionScreen() {
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
scale = scale,
)
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
// Остаётся и после завершения распознавания, пока не стёрт ответ ассистента
// (assistantText) — ответ читается до новой сессии.
val listening by GlassesApp.instance.listening.collectAsState()
val sttText by GlassesApp.instance.sttText.collectAsState()
val assistantText by GlassesApp.instance.assistantText.collectAsState()
if (listening || assistantText.isNotBlank()) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.72f)),
) {
Column(
modifier = Modifier.align(Alignment.Center).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
if (sttText.isBlank()) "слушаю..." else sttText,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
)
if (assistantText.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Text(
assistantText,
color = Color(0xFFB0BEC5),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
}
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
}
}
}