Очки: жесты и режимы общения с ассистентом (по 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:
@@ -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 (корень, поверх любого экрана).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user