Очки: жесты и режимы общения с ассистентом (по 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:
@@ -0,0 +1,151 @@
|
||||
package pw.binom.viewmate.glasses.gestures
|
||||
|
||||
import com.rayneo.arsdk.android.touch.TempleAction
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
|
||||
/**
|
||||
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
|
||||
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
|
||||
*
|
||||
* @NONE — кино: ассистент не вызван.
|
||||
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true).
|
||||
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText).
|
||||
* @ANSWER — ответ виден: listening=false и assistantText непуст.
|
||||
*/
|
||||
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, }
|
||||
|
||||
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
|
||||
data class AssistantState(
|
||||
/** Микрофон открыт (TripleClick → слушаю). */
|
||||
val listening: Boolean = false,
|
||||
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */
|
||||
val thinking: Boolean = false,
|
||||
/** Накопленные фразы (SttPhrase, full). */
|
||||
val sttText: String = "",
|
||||
/** Ответ ассистента (ShowText). */
|
||||
val assistantText: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
|
||||
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт —
|
||||
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
|
||||
*/
|
||||
fun assistantMode(state: AssistantState): AssistantMode = when {
|
||||
state.listening -> AssistantMode.LISTENING
|
||||
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
|
||||
state.thinking -> AssistantMode.THINKING
|
||||
else -> AssistantMode.NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Решение по жесту: что сделать. Чистое (без побочных эффектов) — исполняет MainActivity.
|
||||
* Матрица (docs/gestures-and-modes.md):
|
||||
*
|
||||
* | Режим | Клик | Двойной | Тройной | Свайпы |
|
||||
* |------------------|--------------------|-----------|---------|----------------------|
|
||||
* | Кино | плей/пауза | игнор | вызов | ±30с |
|
||||
* | Слушаю | отправить фразу | отмена | игнор | игнор |
|
||||
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
|
||||
*/
|
||||
sealed interface GestureDecision {
|
||||
/** Жест в данном режиме не назначен (напр. DoubleClick в кино, свайпы в «слушаю»). */
|
||||
data object Ignore : GestureDecision
|
||||
|
||||
/** TripleClick (кино): пауза фильма + открыть микрофон. */
|
||||
data object CallAssistant : GestureDecision
|
||||
|
||||
/** Клик в кино: плей/пауза (как сегодня, MediaCommand.TOGGLE). */
|
||||
data object TogglePlayback : GestureDecision
|
||||
|
||||
/** Клик в «слушаю»: закрыть микрофон, отправить стоп-STT (фраза на телефон). */
|
||||
data object SendPhrase : GestureDecision
|
||||
|
||||
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
|
||||
data object NewPhrase : GestureDecision
|
||||
|
||||
/** Двойной в «слушаю»: отмена распознавания, остаёмся в разговоре (StopStt(cancel=true)). */
|
||||
data object CancelDictation : GestureDecision
|
||||
|
||||
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
|
||||
data object ExitMode : GestureDecision
|
||||
|
||||
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
|
||||
data class Seek(val command: MediaCommand, val seekMs: Long) : GestureDecision
|
||||
|
||||
/** Свайпы при «ответе виден»: дельта скролла ответа, px. Положительная — к концу текста. */
|
||||
data class ScrollAnswer(val delta: Float) : GestureDecision
|
||||
}
|
||||
|
||||
/**
|
||||
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
|
||||
*
|
||||
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
|
||||
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
|
||||
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
|
||||
* меняем знак здесь, в одной точке).
|
||||
*/
|
||||
fun gestureDecision(
|
||||
action: TempleAction,
|
||||
state: AssistantState,
|
||||
seekStepMs: Long = 30_000,
|
||||
pageDeltaPx: Float = 200f,
|
||||
): GestureDecision = when (val mode = assistantMode(state)) {
|
||||
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
|
||||
AssistantMode.NONE -> when (action) {
|
||||
is TempleAction.Click -> GestureDecision.TogglePlayback
|
||||
is TempleAction.TripleClick -> GestureDecision.CallAssistant
|
||||
else ->
|
||||
templeActionToCommand(action, seekStepMs)?.let { GestureDecision.Seek(it.command, it.seekMs) }
|
||||
?: GestureDecision.Ignore
|
||||
}
|
||||
|
||||
AssistantMode.LISTENING -> when (action) {
|
||||
is TempleAction.Click -> GestureDecision.SendPhrase
|
||||
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
|
||||
else -> GestureDecision.Ignore
|
||||
}
|
||||
|
||||
AssistantMode.THINKING -> when (action) {
|
||||
is TempleAction.Click -> GestureDecision.NewPhrase
|
||||
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
||||
else -> GestureDecision.Ignore
|
||||
}
|
||||
|
||||
AssistantMode.ANSWER -> when (action) {
|
||||
is TempleAction.Click -> GestureDecision.NewPhrase
|
||||
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
||||
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
|
||||
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
|
||||
is TempleAction.SlideContinuous ->
|
||||
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
|
||||
// два строка выше.
|
||||
GestureDecision.ScrollAnswer(action.delta)
|
||||
else -> GestureDecision.Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
|
||||
fun overlayVisible(state: AssistantState): Boolean =
|
||||
state.listening || state.thinking || state.assistantText.isNotBlank()
|
||||
|
||||
/**
|
||||
* Заголовок оверлея (над ответом). null — только ответ.
|
||||
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
|
||||
* listening ещё true — микрофон уже закрыт);
|
||||
* - «слушаю...»/фраза — микрофон открыт;
|
||||
* - при «ответе виден» — последняя фраза как контекст (или null).
|
||||
*/
|
||||
fun overlayHeader(state: AssistantState): String? = when {
|
||||
state.thinking && state.assistantText.isBlank() -> "думаю..."
|
||||
state.listening -> state.sttText.ifBlank { "слушаю..." }
|
||||
else -> state.sttText.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
|
||||
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue].
|
||||
* Чистая функция — unit-тесты без Android.
|
||||
*/
|
||||
fun scrollOffset(currentOffset: Int, delta: Float, maxScroll: Int): Int =
|
||||
(currentOffset + delta).toInt().coerceIn(0, maxScroll)
|
||||
Reference in New Issue
Block a user