Сохраняем прогресс просмотра и возобновляем с позиции

This commit is contained in:
2026-09-07 01:09:44 +03:00
parent 79a500150d
commit 1b817aa9a6
24 changed files with 1564 additions and 481 deletions
@@ -0,0 +1,182 @@
package pw.binom.viewmate.glasses
import android.view.MotionEvent
import androidx.compose.runtime.Composable
import com.rayneo.arsdk.android.touch.FlingArgs
import com.rayneo.arsdk.android.touch.TempleAction
/**
* Дискретный жест трекпада. Плавного (continuous) жеста нет: [TempleAction.SlideContinuous]
* по знаку дельты сворачивается в дискретный свайп ([toGesture]) — плавной прокрутки диалога
* ассистента больше не нужно.
*/
enum class Gesture { Click, DoubleClick, TripleClick, SwipeForward, SwipeBackward }
/**
* SDK-событие трекпада → дискретный [Gesture]. Возвращает null, если жест игнорируется
* (MoveUp/ActionUp/Idle/LongClick). [TempleAction.SlideContinuous] → дискретный свайп по знаку
* дельты (>=0 вперёд).
*/
fun TempleAction.toGesture(): Gesture? = when (this) {
is TempleAction.Click -> Gesture.Click
is TempleAction.DoubleClick -> Gesture.DoubleClick
is TempleAction.TripleClick -> Gesture.TripleClick
is TempleAction.SlideForward -> Gesture.SwipeForward
is TempleAction.SlideBackward -> Gesture.SwipeBackward
is TempleAction.SlideContinuous -> if (this.delta >= 0f) Gesture.SwipeForward else Gesture.SwipeBackward
else -> null
}
/**
* Обратный [toGesture] — [Gesture] → SDK-событие, чтобы маршрутизацию layout-а прогнать через
* единственную матрицу [pw.binom.viewmate.glasses.gestures.gestureDecision] (она ждёт
* [TempleAction]). Плавного жеста нет — дискретные свайпы.
*/
fun Gesture.toAction(): TempleAction = when (this) {
Gesture.Click -> TempleAction.Click()
Gesture.DoubleClick -> TempleAction.DoubleClick()
Gesture.TripleClick -> TempleAction.TripleClick()
Gesture.SwipeForward -> TempleAction.SlideForward(DUMMY_FLING)
Gesture.SwipeBackward -> TempleAction.SlideBackward(DUMMY_FLING)
}
/**
* Плейсхолдер [FlingArgs] для свайпов в [toAction]: матрица [gestureDecision] смотрит только
* тип жеста, args свайпа ей не нужны, поэтому одна переиспользуемая заглушка (один
* [MotionEvent] на всё время — pool-утечки нет).
*/
private val DUMMY_FLING: FlingArgs = run {
val e = MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0)
FlingArgs(e, e, 0f, 0f)
}
/**
* Экран/слой очков. Рисуется композитом в стек [LayoutManager]: снизу вверх, между слоями
* затемнение. [gesture] вызывается ТОЛЬКО вершиной стека — [LayoutManager.dispatch] маршрутизирует
* каждый жест в [Layout.gesture] активного (верхнего) layout-а, остальные слои жест не получают.
*/
interface Layout {
/** Основной экран слоя (видео/чат и т.п.). */
@Composable
fun drawStage()
/** Панель состояния: вызывается первой, потом стандартные время/заряд. */
@Composable
fun drawStatusPanel()
/** Обработка жестов данным слоем. Вызывается менеджером только для верхнего слоя. */
fun gesture(g: Gesture)
/**
* Слой открыт (встал вершиной стека) — менеджер вызывает сразу после [LayoutContext.open].
* Под-слой использует для своего «старта»: напр. [pw.binom.viewmate.glasses.ui.layers.ListeningLayout]
* здесь открывает микрофон. Корневой слой не получает (он не «открывается», а создаётся менеджером).
*/
fun onEnter() {}
/** Слой закрыт (POP-нут из стека) — менеджер вызывает в [LayoutContext.close]. Симметрично [onEnter]. */
fun onExit() {}
}
/**
* Контекст, доступный layout-у: открыть под-слой ([open]), закрыть себя ([close]), управлять
* глобальным индикатором записи ([setRecordingIndicator]). Индикатор горит красным, пока активен
* хотя бы один слой, включивший его; выключается, когда этот слой перестал быть активным (POP)
* или переключил его в false. Экземпляр создаёт [LayoutManager] — каждый layout получает один
* общий контекст (самого менеджера).
*/
interface LayoutContext {
fun setRecordingIndicator(on: Boolean)
/** Закрыть текущий (верхний) слой. Корневой слой закрыться не может. */
fun close()
/** Выйти до корня (корневой слой остаётся). */
fun closeToRoot()
/**
* Открыть новый слой поверх: менеджер сам создаст layout через [factory] и передаст ему
* контекст. Инвариант — только активный (верхний) слой открывает под-слой.
*/
fun open(factory: (LayoutContext) -> Layout)
}
/**
* ЛIFO-стек layout-ов очков (то, что пользователь называет «стек»): корень (двойным кликом не
* вытолкнуть) → сверху под-слои. Владеет стеком и контекстом одновременно — один объект, без
* «расщеплённого» context/manager. Корень создаётся [rootFactory], которому менеджер отдаёт сам
* себя как [LayoutContext] (корень открывает под-слои через [LayoutContext.open]).
*
* Композитинг — [compositing]: рисуются ВСЕ слои снизу вверх, между ними затемнение
* (например `фильм + затемнение + ассистент`). Жесты — [dispatch] в вершину.
*/
class LayoutManager(
rootFactory: (LayoutContext) -> Layout,
) : LayoutContext {
private val stack = ArrayDeque<Layout>()
private val recordingOn = linkedSetOf<Layout>()
/** Колбэк на каждую mутацию стека (open/close/closeToRoot) — для внешних проекций (напр. navStack). */
var onChange: (List<Layout>) -> Unit = {}
init {
stack.addLast(rootFactory(this))
onChange(stack.toList())
}
/** Верхний (активный) слой. */
val current: Layout get() = stack.last()
/** Корневой слой (не выталкивается; владелец медиастейта, напр. [MoveLayout]). */
val root: Layout get() = stack.first()
/** Глубина стека (1 = только корень). */
val depth: Int get() = stack.size
/** Стек снизу вверх. */
fun layersBottomUp(): List<Layout> = stack.toList()
/** Композит: каждый слой + флаг затемнения (затемнять каждый слой, кроме верхнего). */
data class Layer(val layout: Layout, val dim: Boolean)
/** Композит снизу вверх: film → dim → assistant. Верхний (последний) — без затемнения. */
fun compositing(): List<Layer> =
stack.mapIndexed { i, l -> Layer(l, i < stack.lastIndex) }
/** Маршрутизация жеста в верхний слой — единственная точка входа. */
fun dispatch(g: Gesture) {
current.gesture(g)
}
// LayoutContext:
override fun open(factory: (LayoutContext) -> Layout) {
val child = factory(this)
stack.addLast(child)
child.onEnter()
onChange(stack.toList())
}
override fun close() {
// Корневой слой не закрывается.
if (stack.size > 1) {
val popped = stack.removeLast()
popped.onExit()
recordingOn.remove(popped)
onChange(stack.toList())
}
}
/** Выйти до корня (корень остаётся). */
override fun closeToRoot() {
while (stack.size > 1) close()
}
override fun setRecordingIndicator(on: Boolean) {
val top = current
if (on) recordingOn.add(top) else recordingOn.remove(top)
}
/** Индикатор записи: горит, пока активен хотя бы один слой с включённым индикатором. */
fun recordingIndicator(): Boolean = recordingOn.isNotEmpty()
}
@@ -11,9 +11,14 @@ import kotlinx.coroutines.CoroutineScope
import pw.binom.viewmate.core.log.BatchingLogCollector import pw.binom.viewmate.core.log.BatchingLogCollector
import pw.binom.viewmate.core.log.FileLogSpool import pw.binom.viewmate.core.log.FileLogSpool
import pw.binom.viewmate.core.log.SystemClock import pw.binom.viewmate.core.log.SystemClock
import pw.binom.viewmate.glasses.gestures.NavFrame
import pw.binom.viewmate.glasses.stt.GlassesAsr import pw.binom.viewmate.glasses.stt.GlassesAsr
import pw.binom.viewmate.glasses.stt.GlassesSttSession import pw.binom.viewmate.glasses.stt.GlassesSttSession
import pw.binom.viewmate.glasses.stt.LocalGlassesRecognizer
import pw.binom.viewmate.glasses.stt.SttModelDownloader import pw.binom.viewmate.glasses.stt.SttModelDownloader
import pw.binom.viewmate.glasses.ui.layers.AssistantEngine
import pw.binom.viewmate.glasses.ui.layers.MoveLayout
import pw.binom.viewmate.glasses.ui.layers.toFrame
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */ /** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
data class ChatEntry(val role: String, val text: String) data class ChatEntry(val role: String, val text: String)
@@ -47,9 +52,6 @@ class GlassesApp : Application() {
lateinit var hostConnection: HostConnection lateinit var hostConnection: HostConnection
private set private set
lateinit var movieController: MovieController
private set
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */ /** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
lateinit var downloadManager: GlassesDownloadManager lateinit var downloadManager: GlassesDownloadManager
private set private set
@@ -66,6 +68,26 @@ class GlassesApp : Application() {
lateinit var sttSession: GlassesSttSession lateinit var sttSession: GlassesSttSession
private set private set
/**
* Персистентный менеджер стека layout-ов (корень [MoveLayout] получает [LayoutContext] и
* контекст приложения для своего медиастейта). Единственный владелец стека: слои
* открывают/закрывают кадры через [LayoutContext.open]/[LayoutContext.close];
* [navStack] — проекция этого стека для оверлея.
*/
val layoutManager = LayoutManager { ctx -> MoveLayout(ctx, this) }
/** Корневой слой (владелец медиастейта): протокол/lifecycle/эффекты дотягиваются до
* [MovieController] через него ([rootLayer.controller]). */
val rootLayer: MoveLayout get() = layoutManager.root as MoveLayout
/**
* Движок ассистента: внятный стейт диалога (фазы/тексты/история) + его жестовые эффекты.
* Владелец стейта, живёт всё время приложения; слои [AssistantLayout]/[ListeningLayout]
* и оверлей дотягиваются до него, протокол/lifecycle пишут через [assistantEngine].
* Зависимости от [GlassesApp] (sttSession/sttModel/hostConnection/rootLayer) — лениво, в методах.
*/
val assistantEngine = AssistantEngine(this)
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */ /** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
lateinit var logCollector: BatchingLogCollector lateinit var logCollector: BatchingLogCollector
@@ -84,39 +106,18 @@ class GlassesApp : Application() {
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */ /** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
val vertical = MutableStateFlow(1f) val vertical = MutableStateFlow(1f)
/** // Стейт ассистента (listening/sttText/assistantText/chatHistory/thinking/sttStartBlocked
* Идёт распознавание речи (TripleClick стартовал; завершает фразу Click, // + sttNoSpeech/answerScrollDelta ниже) вынесен в [assistantEngine] — владелец стейта.
* отмена записи DoubleClick НЕ завершает сессию — сразу возвращает в
* «слушаю» снова; 30с тишины/выход из режима закрывают сессию).
*/
val listening = MutableStateFlow(false)
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
val sttText = MutableStateFlow("")
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/** /**
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом * Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона, * вытолкнуть) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон, чат на фоне).
* новый ответ (ShowText) — последнее сообщение истории, не стирает её. * Тройной клик в кино кладёт «ассистент» + «слушаю»; двойной клик POP-ит верхний кадр:
* из «слушаю» → в кадр ассистента (idle, микрофон закрыт, чат не прерван), из idle/
* «думаю»/«ответ» → в «фильм». Держится до явного выхода (двойной клик в кадре
* ассистента, 30с тишины, потеря фокуса) и сбрасывается в [AssistantEngine.exitMode].
*/ */
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList()) val navStack = MutableStateFlow(listOf(NavFrame.Film))
/**
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
*/
val thinking = MutableStateFlow(false)
/**
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
*/
val answerScrollDelta = MutableStateFlow(0f)
/** /**
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс * Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
@@ -148,12 +149,12 @@ class GlassesApp : Application() {
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f) scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f) vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
MercurySDK.init(this) MercurySDK.init(this)
movieController = MovieController(applicationContext) // Медиадвижок живёт в корневом слое (rootLayer.controller), не в приложении.
movieController.eventLogger = { msg -> logToFile("mc: $msg") } rootLayer.controller.eventLogger = { msg -> logToFile("mc: $msg") }
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) } downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
hostConnection = HostConnection( hostConnection = HostConnection(
fallbackUrl = GlassesConfig.HOST_URL, fallbackUrl = GlassesConfig.HOST_URL,
movieController = movieController, movieController = rootLayer.controller,
downloadManager = downloadManager, downloadManager = downloadManager,
context = applicationContext, context = applicationContext,
) )
@@ -184,10 +185,15 @@ class GlassesApp : Application() {
sttSession = GlassesSttSession( sttSession = GlassesSttSession(
filesDir = filesDir, filesDir = filesDir,
scope = bgScope, scope = bgScope,
recognizer = { asr.recognize(it) }, recognizer = LocalGlassesRecognizer(asr, sttModel),
modelReady = { sttModel.status.value.ready },
onResult = { text, auto -> mainActivity?.onSttResult(text, auto) }, onResult = { text, auto -> mainActivity?.onSttResult(text, auto) },
) )
// Проекция стека layout-ов в [navStack] (для оверлея): стек правды — layoutManager,
// navStack — лишь его зеркало. Начальная проекция (корень = Film).
layoutManager.onChange = { layouts -> navStack.value = layouts.map { it.toFrame() } }
navStack.value = layoutManager.layersBottomUp().map { it.toFrame() }
log("app", "layout-менеджер: корень=${layoutManager.current.toFrame()}, navStack=[${navStack.value}]")
} }
companion object { companion object {
@@ -354,39 +354,38 @@ class HostConnection(
is ShowText -> { is ShowText -> {
addMessage("ShowText: ${msg.text}") addMessage("ShowText: ${msg.text}")
GlassesApp.instance.thinking.value = false GlassesApp.instance.assistantEngine.onShowText(msg.text)
GlassesApp.instance.answerScrollDelta.value = 0f
GlassesApp.instance.assistantText.value = msg.text
} }
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}") is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
is ChatHistoryMsg -> { is ChatHistoryMsg -> {
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.") log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
GlassesApp.instance.chatHistory.value = msg.messages GlassesApp.instance.assistantEngine.onChatHistory(
msg.messages
.filter { it.role == "user" || it.role == "assistant" } .filter { it.role == "user" || it.role == "assistant" }
.map { ChatEntry(it.role, it.content) } .map { ChatEntry(it.role, it.content) },
)
} }
is SttPhrase -> { is SttPhrase -> {
GlassesApp.instance.sttText.value = msg.full GlassesApp.instance.assistantEngine.onSttPhrase(msg.full)
log("stt", "фраза: ${msg.phrase}") log("stt", "фраза: ${msg.phrase}")
} }
is SttDone -> { is SttDone -> {
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}") log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}")
GlassesApp.instance.listening.value = false GlassesApp.instance.assistantEngine.onStopListening()
} }
is SttCancel -> { is SttCancel -> {
when (msg.reason) { when (msg.reason) {
SttCancel.REASON_CANCEL -> { SttCancel.REASON_CANCEL -> {
// Явная отмена диктовки двойным тапом: очки САМИ уже вернулись // Явная отмена диктовки двойным тапом: очки САМИ уже сделали стоп
// в «слушаю» по жесту (TZ-ui-four п.1: отмена не закрывает чат, // (POP кадра «слушаю» с ОСТАВАНИЕМ в ассистенте — микрофон закрыт,
// оверлей остаётся, микрофон готов к следующей записи). Это всего // чат не прерван, оверлей на месте). Это всего подтверждение, что
// подтверждение, что телефон отменил STT-сессию (stt.reset()) — // телефон отменил STT-сессию (stt.reset()) — состояния не трогаем.
// состояния (listening/thinking/оверлей) НЕ трогаем. log("stt", "отмена диктовки (двойной тап) — стоп, остался в ассистенте, оверлей на месте")
log("stt", "отмена диктовки (двойной тап) — остался в «слушаю», оверлей на месте")
} }
SttCancel.REASON_TIMEOUT -> { SttCancel.REASON_TIMEOUT -> {
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из // Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
@@ -397,11 +396,10 @@ class HostConnection(
} }
else -> { else -> {
// reason="user" — пустой клик без голоса: не выходим из режима, // reason="user" — пустой клик без голоса: не выходим из режима,
// возвращаемся в «слушаю» (микрофон снова открыт). // стоп-записи с остаованием в ассистенте (idle), «не расслышал».
log("stt", "отменено — возврат в «слушаю»") log("stt", "отменено — стоп, остаюсь в ассистенте (не расслышал)")
GlassesApp.instance.thinking.value = false GlassesApp.instance.mainActivity?.stopSttListening()
GlassesApp.instance.mainActivity?.resumeSttListening() ?: log("stt", "mainActivity ещё нет — не могу сделать стоп")
?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон")
} }
} }
} }
@@ -49,17 +49,14 @@ import kotlinx.coroutines.launch
import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.SttText import pw.binom.viewmate.core.protocol.SttText
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.actionName
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.status.StatusPanelCompose
import pw.binom.viewmate.glasses.ui.AssistantOverlay import pw.binom.viewmate.glasses.ui.AssistantOverlay
import pw.binom.viewmate.glasses.ui.Binocular import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSide import pw.binom.viewmate.glasses.ui.BinocularScreenSide
import pw.binom.viewmate.glasses.ui.BinocularScreenSize import pw.binom.viewmate.glasses.ui.BinocularScreenSize
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.LayoutStackContent
import pw.binom.viewmate.glasses.ui.Side import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.px import pw.binom.viewmate.glasses.ui.px
import pw.binom.viewmate.glasses.ui.scaleOffsetX import pw.binom.viewmate.glasses.ui.scaleOffsetX
@@ -89,10 +86,10 @@ class MainActivity : BaseEventActivity() {
when (intent?.action) { when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> { Intent.ACTION_SCREEN_OFF -> {
log("ui", "SCREEN_OFF receiver") log("ui", "SCREEN_OFF receiver")
val was = GlassesApp.instance.movieController.pauseIfPlaying() val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("SCREEN_OFF: pause wasPlaying=$was") logEvent("SCREEN_OFF: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock() GlassesApp.instance.rootLayer.controller.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас (receiver) — пауза") if (was) log("ui", "экран погас (receiver) — пауза")
} }
@@ -101,7 +98,7 @@ class MainActivity : BaseEventActivity() {
logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn") logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) { if (resumeAfterScreenOn) {
resumeAfterScreenOn = false resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume() GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries() sendPositionWithRetries()
log("ui", "экран включён (receiver) — продолжаю") log("ui", "экран включён (receiver) — продолжаю")
} }
@@ -119,24 +116,10 @@ class MainActivity : BaseEventActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
MaterialTheme(colorScheme = darkColorScheme()) { MaterialTheme(colorScheme = darkColorScheme()) {
Box( // Layout-стек (LIFO): персистентный менеджер (GlassesApp.layoutManager) композитит
modifier = Modifier // все слои снизу вверх (кино → затемнение → чат → слушаю). Триггер рекомпозиции —
.fillMaxSize() // стек менеджера (зеркало navStack, обновляется через ctx.open/close).
.background(Color.Black) LayoutStackContent(manager = GlassesApp.instance.layoutManager)
) {
val controller = GlassesApp.instance.movieController
val movieActive by controller.movieActive.collectAsState()
val title by controller.title.collectAsState()
val player = controller.player
if (movieActive && player != null) {
GlassesMovieScreen(player = player, title = title)
} else {
ConnectionScreen()
}
// Оверлей режима общения: поверх любого экрана (затемняет кино),
// виден в «слушаю»/«думаю»/«ответ виден».
AssistantOverlay()
}
} }
} }
hideSystemBars() hideSystemBars()
@@ -172,16 +155,16 @@ class MainActivity : BaseEventActivity() {
val state = dm.getDisplay(displayId).state val state = dm.getDisplay(displayId).state
logEvent("display state=$state") logEvent("display state=$state")
if (state == Display.STATE_OFF) { if (state == Display.STATE_OFF) {
val was = GlassesApp.instance.movieController.pauseIfPlaying() val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("display OFF: pause wasPlaying=$was") logEvent("display OFF: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock() GlassesApp.instance.rootLayer.controller.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
} else if (state == Display.STATE_ON) { } else if (state == Display.STATE_ON) {
logEvent("display ON: resume flag=$resumeAfterScreenOn") logEvent("display ON: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) { if (resumeAfterScreenOn) {
resumeAfterScreenOn = false resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume() GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries() sendPositionWithRetries()
} }
} }
@@ -212,171 +195,48 @@ class MainActivity : BaseEventActivity() {
/** /**
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast * Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку * (debugReceiver, эмулятор). Жест деспачится в верхний слой стека через персистентный
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже. * [pw.binom.viewmate.glasses.LayoutManager] — каждый слой сам решает, что со своим жестом
* делать (MoveLayout / AssistantLayout / ListeningLayout).
*/ */
private fun onTempleAction(action: TempleAction) { private fun onTempleAction(action: TempleAction) {
val app = GlassesApp.instance val g = action.toGesture() ?: run {
val state = AssistantState( log("gesture", "жест ${actionName(action)} вне дискретной матрицы — игнор")
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 return
} }
log("stt", "ассистент: слушаю") log("gesture", "жест: ${g}")
if (app.movieController.pauseIfPlaying()) { GlassesApp.instance.layoutManager.dispatch(g)
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
app.listening.value = true
app.thinking.value = false
app.sttText.value = ""
app.assistantText.value = ""
app.answerScrollDelta.value = 0f
app.sttSession.start()
} }
/** Click (кино): плей/пауза — как сегодня. */ // Мозг ассистента (стейт/эффекты) — в движке общения [GlassesApp.assistantEngine]:
private fun togglePlayback() { // слои AssistantLayout / ListeningLayout упираются в него, кино — в MoveLayout.
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */
private fun sendPhrase() {
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
val app = GlassesApp.instance
app.listening.value = false
app.thinking.value = true
app.sttSession.send()
}
/** 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
app.sttSession.start()
}
/** /**
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без * SttCancel(reason="user") с телефона — пустой клик без голоса (старый путь StopStt).
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается, * Стоп-записи с ОСТАВАНИЕМ в ассистенте (idle, «не расслышал»). Реализация — в движке
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2). * общения ([GlassesApp.assistantEngine]).
*/ */
fun resumeSttListening() { fun stopSttListening() = GlassesApp.instance.assistantEngine.requestSttStop()
val app = GlassesApp.instance
app.thinking.value = false
app.listening.value = true
app.sttSession.start()
log("stt", "возврат в «слушаю» (SttCancel)")
}
/** /**
* DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (TZ-ui-four п.1): возвращаемся * Результат локального распознавания ([GlassesApp] → [onSttResult], на главном потоке).
* в «слушаю» — микрофон сразу готов к следующей записи, оверлей ОСТАЁТСЯ на * Не-пустой — POP «слушаю», ждём ответ, шлём [SttText] на телефон; пустой по клику — стоп с
* экране (даже если истории диалога ещё нет — отмена не закрывает чат). * оставанием в ассистенте; пустой после 30с-лимыта — выход из режима. Состояние/навигация —
* Закрытие ассистента — двойной в «думаю»/«ответ виден» ([exitAssistantMode]). * в движке ([GlassesApp.assistantEngine]); отправка [SttText] — здесь (нужен [lifecycleScope]).
*/
private fun cancelDictation() {
log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
val app = GlassesApp.instance
app.sttSession.cancel()
app.sttText.value = ""
app.thinking.value = false
app.listening.value = true
app.sttSession.start()
}
/**
* Результат локального распознавания ([GlassesSttSession.onResult], на главном потоке).
* [text] — распознанный на очках текст (пустой — тишина/модель не готова), [auto] —
* сработал 30с-лимит. Текст уходит на телефон [SttText]; пустой по клику — остаёмся
* слушать, пустой после лимита — выход из режима.
*/ */
fun onSttResult(text: String, auto: Boolean) { fun onSttResult(text: String, auto: Boolean) {
val toSend = GlassesApp.instance.assistantEngine.processSttResult(text, auto)
if (toSend != null) {
val app = GlassesApp.instance val app = GlassesApp.instance
app.sttText.value = text lifecycleScope.launch { app.hostConnection.sendToHost(SttText(toSend)) }
when {
text.isNotBlank() -> {
app.listening.value = false
app.thinking.value = true
log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → SttText на телефон")
lifecycleScope.launch {
app.hostConnection.sendToHost(SttText(text))
} }
} }
auto -> finishAssistantMode("тишина 30с (очки) — выход из режима")
else -> {
log("stt", "клик без голоса — остаюсь в «слушаю»")
app.thinking.value = false
app.listening.value = true
app.sttSession.start()
}
}
}
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)")
/** /**
* Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл — * Таймаут тишины 30с с телефона (SttCancel reason="timeout"): выход из режима общения.
* выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection. * Реализация — в движке общения ([GlassesApp.assistantEngine]).
*/ */
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима") fun onSttTimeout() = GlassesApp.instance.assistantEngine.handleSttTimeout()
/** Общая логика выхода из режима общения: стоп сессии + сброс состояний. */
private fun finishAssistantMode(reason: String) {
log("gesture", reason)
GlassesApp.instance.sttSession.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()
}
}
/** /**
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool: * Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
@@ -405,15 +265,15 @@ class MainActivity : BaseEventActivity() {
log("ui", "onPause") log("ui", "onPause")
val app = GlassesApp.instance val app = GlassesApp.instance
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл // Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
// в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым. // в лаунчер) режим общения гарантированно закрывается — стек POP-ится до фильма.
if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) { if (app.navStack.value.size > 1) {
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)") log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
finishAssistantMode("onPause — потеря фокуса, выход из режима общения") app.assistantEngine.exitMode("onPause — потеря фокуса, выход из режима общения")
} }
val was = app.movieController.pauseIfPlaying() val was = app.rootLayer.controller.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("onPause: pause wasPlaying=$was") logEvent("onPause: pause wasPlaying=$was")
app.movieController.ensureWifiLock() app.rootLayer.controller.ensureWifiLock()
app.hostConnection.sendPlaybackPositionNow() app.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас — пауза") if (was) log("ui", "экран погас — пауза")
} }
@@ -425,7 +285,7 @@ class MainActivity : BaseEventActivity() {
logEvent("onResume: resume flag=$resumeAfterScreenOn") logEvent("onResume: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) { if (resumeAfterScreenOn) {
resumeAfterScreenOn = false resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume() GlassesApp.instance.rootLayer.controller.resume()
sendPositionWithRetries() sendPositionWithRetries()
log("ui", "экран включён — продолжаю") log("ui", "экран включён — продолжаю")
} }
@@ -8,34 +8,60 @@ import pw.binom.viewmate.glasses.stt.SttModelStatus
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md): * Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза». * «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
* *
* @NONE — кино: ассистент не вызван. * Навигация — ЛIFO-стек [NavFrame]: корень [NavFrame.Film] (двойным кликом не вытолкнуть),
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true). * сверху [NavFrame.Assistant] (чат), сверху [NavFrame.Listening] (микрофон, чат на фоне).
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText). * Двойной клик — POP верхнего кадра. Режим выводится из вершины стека + контента кадра
* @ANSWER — ответ виден: listening=false и assistantText непуст. * ассистента:
*
* @NONE — кино (вершина Film): ассистент не вызван.
* @LISTENING — вершина Listening: микрофон открыт, чат виден на фоне.
* @THINKING — вершина Assistant, фраза отправлена, ждём ответ (ShowText).
* @ANSWER — вершина Assistant, ответ виден (assistantText непуст).
* @IDLE — вершина Assistant без «думаю»/ответа: микрофон закрыт, чат на месте.
*/ */
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, } enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, IDLE, }
/** Кадр стека навигации очков. Снизу вверх: [Film] (корень) → [Assistant] → [Listening]. */
enum class NavFrame { Film, Assistant, Listening }
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */ /** Снимок состояния общения для карты жеста (чистые данные, без Android). */
data class AssistantState( data class AssistantState(
/** Микрофон открыт (TripleClick → слушаю). */ /** Стек навигации, снизу вверх. Внизу всегда [NavFrame.Film] (корень). */
val listening: Boolean = false, val stack: List<NavFrame> = listOf(NavFrame.Film),
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */ /** «Думаю»: фраза отправлена, ждём ответ (ShowText). Контент кадра ассистента. */
val thinking: Boolean = false, val thinking: Boolean = false,
/** Накопленные фразы (SttPhrase, full). */ /** Накопленные фразы (SttPhrase, full). Контент кадра «слушаю». */
val sttText: String = "", val sttText: String = "",
/** Ответ ассистента (ShowText). */ /** Ответ ассистента (ShowText). Контент кадра ассистента. */
val assistantText: String = "", val assistantText: String = "",
) /** Попытка начать диктовку отклонена: модель не скачалась (микрофон не открыт). */
val sttStartBlocked: Boolean = false,
/** Последний стоп/отправка не поймала голоса — показать «не расслышал». */
val sttNoSpeech: Boolean = false,
) {
/** Вершина стека — текущий кадр. */
val top: NavFrame get() = stack.last()
/** Микрофон открыт: вершина — [NavFrame.Listening]. */
val listening: Boolean get() = top == NavFrame.Listening
/** В кадре ассистента (idle/думаю/ответ/слушаю): вершина Assistant или Listening. */
val inAssistant: Boolean get() = top == NavFrame.Assistant || top == NavFrame.Listening
}
/** /**
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст), * Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт — * он приоритетнее «думаю»; вершина [NavFrame.Listening] всегда побеждает (микрофон открыт —
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит). * жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
*
* Стек: фильм (корень) → ассистент → слушаю. «Ассистент» без «слушаю»/ответа/«думаю»
* (вершина [NavFrame.Assistant]) — idle-кадр [IDLE]: микрофон закрыт, чат не прерван.
*/ */
fun assistantMode(state: AssistantState): AssistantMode = when { fun assistantMode(state: AssistantState): AssistantMode = when {
state.listening -> AssistantMode.LISTENING state.top == NavFrame.Listening -> AssistantMode.LISTENING
state.top == NavFrame.Assistant -> when {
state.assistantText.isNotBlank() -> AssistantMode.ANSWER state.assistantText.isNotBlank() -> AssistantMode.ANSWER
state.thinking -> AssistantMode.THINKING state.thinking -> AssistantMode.THINKING
else -> AssistantMode.IDLE
}
else -> AssistantMode.NONE else -> AssistantMode.NONE
} }
@@ -45,8 +71,9 @@ fun assistantMode(state: AssistantState): AssistantMode = when {
* *
* | Режим | Клик | Двойной | Тройной | Свайпы | * | Режим | Клик | Двойной | Тройной | Свайпы |
* |------------------|--------------------|-----------|---------|----------------------| * |------------------|--------------------|-----------|---------|----------------------|
* | Кино | плей/пауза | игнор | вызов | ±30с | * | Кино (корень) | плей/пауза | игнор | вызов | ±30с |
* | Слушаю | отправить фразу | отмена | игнор | игнор | * | Слушаю | отправить фразу | стоп → idle | игнор | игнор |
* | Ассистент (idle) | открыть запись | выход | запись | игнор |
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл | * | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
*/ */
sealed interface GestureDecision { sealed interface GestureDecision {
@@ -66,14 +93,17 @@ sealed interface GestureDecision {
data object NewPhrase : GestureDecision data object NewPhrase : GestureDecision
/** /**
* Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю» * Двойной в «слушаю» (стоп, POP верхнего кадра): закрыть микрофон, ОСТАТЬСЯ в ассистенте
* ([afterCancelDictation]): микрофон снова готов к следующей записи, * (стек: фильм → ассистент, без «слушаю» сверху) — см. [AssistantState.stopListeningFrame].
* оверлей остаётся на экране даже при пустой истории диалога. * Выхода нет: клик открывает запись снова, двойной — выход из чата. Сторона очков:
* Сторона очков: StopStt(cancel=true) на телефон. * StopStt(cancel=true).
*/ */
data object CancelDictation : GestureDecision data object StopListening : GestureDecision
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */ /** Клик/тройной в idle-кадре ассистента: снова открыть микрофон (вниз по стеку — «слушаю»). */
data object BeginListening : GestureDecision
/** Двойной в «думаю»/«ответ виден»/idle: выход из режима — закрыть оверлей, фильм на паузе. */
data object ExitMode : GestureDecision data object ExitMode : GestureDecision
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */ /** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
@@ -87,15 +117,11 @@ sealed interface GestureDecision {
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android. * Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
* *
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]). * @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
* меняем знак здесь, в одной точке).
*/ */
fun gestureDecision( fun gestureDecision(
action: TempleAction, action: TempleAction,
state: AssistantState, state: AssistantState,
seekStepMs: Long = 30_000, seekStepMs: Long = 30_000,
pageDeltaPx: Float = 200f,
): GestureDecision = when (val mode = assistantMode(state)) { ): GestureDecision = when (val mode = assistantMode(state)) {
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице. // Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
AssistantMode.NONE -> when (action) { AssistantMode.NONE -> when (action) {
@@ -106,9 +132,30 @@ fun gestureDecision(
?: GestureDecision.Ignore ?: GestureDecision.Ignore
} }
AssistantMode.LISTENING -> when (action) { // «Слушаю» (вершина — Listening). Модель не скачалась (стtStartBlocked): микрофон не открыт,
// кадр на месте — Click = повторить попытку, DoubleClick = выход. Иначе: Click — отправить,
// DoubleClick — стоп (POP «слушаю» → idle).
AssistantMode.LISTENING -> when {
state.sttStartBlocked -> when (action) {
is TempleAction.Click -> GestureDecision.BeginListening
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore
}
else -> when (action) {
is TempleAction.Click -> GestureDecision.SendPhrase is TempleAction.Click -> GestureDecision.SendPhrase
is TempleAction.DoubleClick -> GestureDecision.CancelDictation // Стоп: POP «слушаю» → кадр ассистента (idle), чат не прерван.
is TempleAction.DoubleClick -> GestureDecision.StopListening
else -> GestureDecision.Ignore
}
}
// Idle-кадр ассистента (стек: фильм → ассистент, без «слушаю»): микрофон закрыт.
AssistantMode.IDLE -> when (action) {
is TempleAction.Click -> GestureDecision.BeginListening
is TempleAction.TripleClick -> GestureDecision.BeginListening
// Выход из чата: POP «ассистент» → корень (кино).
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore else -> GestureDecision.Ignore
} }
@@ -119,44 +166,68 @@ fun gestureDecision(
} }
AssistantMode.ANSWER -> when (action) { AssistantMode.ANSWER -> when (action) {
// Плавной и дискретной прокрутки ответа больше нет (решение пользователя): свайпы
// игнорируем — из-под ответа не ищем по (приостановленному) фильму и не скроллим текст.
is TempleAction.Click -> GestureDecision.NewPhrase is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode 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 else -> GestureDecision.Ignore
} }
} }
/** /**
* Состояние после отмены записи ([GestureDecision.CancelDictation], двойной в «слушаю»): * Тройной клик в фильме: на стек кладём кадр ассистента (чат) и сразу кадр «слушаю»
* возврат в «слушаю» — микрофон снова открыт и готов к следующей записи, отменённый * (микрофон) — стек [Film, Assistant, Listening]. Чат виден на фоне. Чистая функция.
* текст ([AssistantState.sttText]) сбросен. Диалог НЕ прерывается: оверлей остаётся
* на экране даже когда истории диалога ещё нет (empty [AssistantState] → [overlayVisible]=true,
* т.к. listening=true). Закрытие ассистента — отдельное явное действие:
* двойной в «думаю»/«ответ виден» ([GestureDecision.ExitMode]).
* Чистая функция — unit-тесты без Android.
*/ */
fun afterCancelDictation(state: AssistantState): AssistantState = fun AssistantState.enterAssistant(): AssistantState =
state.copy(listening = true, thinking = false, sttText = "") copy(
stack = stack + NavFrame.Assistant + NavFrame.Listening,
thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false,
)
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */ /** Клик в idle/«думаю»/«ответ»: кладём кадр «слушаю» поверх кадра ассистента. Чистая функция. */
fun AssistantState.beginListeningFrame(): AssistantState =
copy(stack = stack + NavFrame.Listening, thinking = false, sttText = "", sttNoSpeech = false)
/**
* Стоп-записи ([GestureDecision.StopListening], двойной в «слушаю» / пустой клик): POP кадра
* «слушаю» — остаёмся в кадре ассистента (idle), чат НЕ прерван, микрофон закрыт. [noSpeech] —
* показать «не расслышал». Возобновить запись — Click/Triple ([GestureDecision.BeginListening]);
* выйти — DoubleClick ([GestureDecision.ExitMode]). Чистая функция — unit-тесты без Android.
*/
fun AssistantState.stopListeningFrame(noSpeech: Boolean): AssistantState =
copy(stack = stack.dropLast(1), thinking = false, sttText = "", sttNoSpeech = noSpeech)
/**
* Выход из чата ([GestureDecision.ExitMode], двойной в кадре ассистента / таймаут тишины):
* POP кадра ассистента до корня [NavFrame.Film] (фильм). Сброс контента. Чистая функция.
*/
fun AssistantState.exitToFilm(): AssistantState =
copy(
stack = listOf(NavFrame.Film),
thinking = false, sttText = "", assistantText = "", sttNoSpeech = false, sttStartBlocked = false,
)
/** Оверлей общения виден, пока в кадре ассистента (idle/думаю/ответ/слушаю) или блок модели. */
fun overlayVisible(state: AssistantState): Boolean = fun overlayVisible(state: AssistantState): Boolean =
state.listening || state.thinking || state.assistantText.isNotBlank() state.inAssistant || state.sttStartBlocked
/** /**
* Заголовок оверлея (над ответом). null — только ответ. * Заголовок оверлея (над ответом). null — только ответ.
* - «модель не скачалась» — попытка начать диктовку отклонена (модель не READY),
* микрофон не открыт; ниже живая строка прогресса ([modelStatusLine]);
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если * - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
* listening ещё true — микрофон уже закрыт); * listening ещё true — микрофон уже закрыт);
* - «слушаю...»/фраза — микрофон открыт; * - «слушаю...»/фраза — микрофон открыт;
* - при «ответе виден» — последняя фраза как контекст (или null). * - «не расслышал» — стоп/отправка не поймала голоса (пустой клик);
* - кадр ассистента (idle/думаю без ответа/ответ виден) — подсказка жестов
* (клик — говорить, двойной — выход); чат прокручивается под заголовком.
*/ */
fun overlayHeader(state: AssistantState): String? = when { fun overlayHeader(state: AssistantState): String? = when {
state.sttStartBlocked -> "модель не скачалась"
state.thinking && state.assistantText.isBlank() -> "думаю..." state.thinking && state.assistantText.isBlank() -> "думаю..."
state.listening -> state.sttText.ifBlank { "слушаю..." } state.top == NavFrame.Listening -> state.sttText.ifBlank { "слушаю..." }
state.sttNoSpeech -> "не расслышал — клик, чтобы повторить"
state.top == NavFrame.Assistant -> "клик — говорить · двойной — выход"
else -> state.sttText.takeIf { it.isNotBlank() } else -> state.sttText.takeIf { it.isNotBlank() }
} }
@@ -41,7 +41,7 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive // Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
// + свежее значение от телефона); без видео — ничего не выводить. // + свежее значение от телефона); без видео — ничего не выводить.
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState() val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
val movieActive = GlassesApp.instance.movieController.movieActive.collectAsState() val movieActive = GlassesApp.instance.rootLayer.controller.movieActive.collectAsState()
Row( Row(
modifier = modifier modifier = modifier
.background(Color(0x99000000)) // полупрозрачный чёрный .background(Color(0x99000000)) // полупрозрачный чёрный
@@ -37,5 +37,7 @@ class GlassesAsr(
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */ /** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String = fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
recognizer.recognize(samples, sampleRate) runCatching { recognizer.recognize(samples, sampleRate) }
.onFailure { log("stt", "Qwen3 recognizer упал: ${it.javaClass.simpleName}: ${it.message}") }
.getOrDefault("")
} }
@@ -0,0 +1,31 @@
package pw.binom.viewmate.glasses.stt
/**
* Абстракция механизма распознавания очков: готов ли механизм + распознать фразу.
*
* [GlassesSttSession] работает с этим интерфейсом, а не с конкретным движком — так
* дальше за ним можно подключить другой механизм (например, удалённое распознавание
* на телефоне) без изменения сессии.
*/
interface GlassesRecognizer {
/** Механизм готов (модель докачана / нативка загрузится). false — не открывать микрофон. */
fun isReady(): Boolean
/** Распознать фразу (FloatArray, 16 кГц mono). Пустая строка — тишина / не готово. */
fun recognize(phrase: FloatArray): String
}
/**
* Единственная реализация: локальный механизм распознавания очков — Qwen3-ASR
* ([GlassesAsr]); готовность берёт из статуса скачанной модели ([SttModelDownloader]).
* Телефон в распознавании не участвует.
*/
class LocalGlassesRecognizer(
private val asr: GlassesAsr,
private val model: SttModelDownloader,
) : GlassesRecognizer {
override fun isReady(): Boolean = model.status.value.ready
override fun recognize(phrase: FloatArray): String = asr.recognize(phrase)
}
@@ -3,6 +3,7 @@ package pw.binom.viewmate.glasses.stt
import java.io.File import java.io.File
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import kotlin.math.sqrt
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -22,7 +23,7 @@ interface GlassesMic {
/** /**
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём * Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
* локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше * локально (через [recognizer]) и возвращаем готовый текст — телефон больше
* не участвует в распознавании. UX остаётся «говори → клик → текст». * не участвует в распознавании. UX остаётся «говори → клик → текст».
* *
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон, * Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
@@ -37,8 +38,7 @@ interface GlassesMic {
class GlassesSttSession( class GlassesSttSession(
private val filesDir: File, private val filesDir: File,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val recognizer: (FloatArray) -> String, private val recognizer: GlassesRecognizer,
private val modelReady: () -> Boolean,
private val onResult: (text: String, auto: Boolean) -> Unit, private val onResult: (text: String, auto: Boolean) -> Unit,
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) }, private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
) { ) {
@@ -59,9 +59,18 @@ class GlassesSttSession(
val isRunning: Boolean get() = running val isRunning: Boolean get() = running
/** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */ /**
* Начать слушать: чистый буфер + микрофон + таймер автозавершения.
* Гейт в момент ПОПЫТКИ начать сбор: если механизм ещё не готов ([GlassesRecognizer.isReady]=false),
* микрофон НЕ открываем и PCM не накапливаем — сбор не начинается, распознавание
* гарантированно не будет пустым «потом». Ошибку показывает UI (sttStartBlocked).
*/
fun start() { fun start() {
if (running) return if (running) return
if (!recognizer.isReady()) {
log("stt", "STT-сессия очков: механизм не готов — микрофон не открываю, сбор не начинаю")
return
}
running = true running = true
pcmLock.withLock { pcm = ByteArray(0) } pcmLock.withLock { pcm = ByteArray(0) }
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } } val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
@@ -101,10 +110,33 @@ class GlassesSttSession(
watchdog?.cancel() watchdog?.cancel()
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } } val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val text = if (audio.isNotEmpty() && modelReady()) { val text = if (audio.isNotEmpty() && recognizer.isReady()) {
val phrases = splitPhrases(trimEdges(s16leToFloat(audio))) val floats = s16leToFloat(audio)
phrases.joinToString(" ") { recognizer(it) } var peak = 0f
for (f in floats) { val a = if (f < 0f) -f else f; if (a > peak) peak = a }
val win = 320
var w005 = 0; var w001 = 0
var i = 0
while (i + win <= floats.size) {
var sum = 0.0
for (k in i until i + win) sum += floats[k] * floats[k].toDouble()
val rms = sqrt(sum / win).toFloat()
if (rms >= 0.005f) w005++
if (rms >= 0.01f) w001++
i += win
}
val trimmed = trimEdges(floats)
val phrases = splitPhrases(trimmed)
log("stt", "diag pcm=${audio.size / 32}мс peak=${peak} окно[≥.005]=${w005} [≥.01]=${w001} trim=${trimmed.size / 32000}с фраз=${phrases.size}")
val joined = phrases.mapIndexed { n, p ->
val r = recognizer.recognize(p)
log("stt", "diag фраза${n + 1} ${p.size * 1000 / 16000}мс → '${r.take(40)}'")
r
}.joinToString(" ")
if (joined.isEmpty()) log("stt", "diag: результат пустой")
joined
} else { } else {
log("stt", "diag: pcm=${audio.size}б isReady=${recognizer.isReady()} — распознавание пропущено")
"" ""
} }
withContext(Dispatchers.Main) { onResult(text, auto) } withContext(Dispatchers.Main) { onResult(text, auto) }
@@ -186,7 +186,7 @@ class SttModelDownloader(
val parent = File(modelsDir, f.relPath).parentFile val parent = File(modelsDir, f.relPath).parentFile
parent?.mkdirs() parent?.mkdirs()
val final = File(modelsDir, f.relPath) val final = File(modelsDir, f.relPath)
val part = File(modelsDir, "$f.relPath.part") val part = File(modelsDir, "${f.relPath}.part")
val existing = if (part.exists()) part.length() else 0L val existing = if (part.exists()) part.length() else 0L
val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath) val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath)
@@ -35,6 +35,7 @@ import pw.binom.viewmate.glasses.gestures.overlayHeader
import pw.binom.viewmate.glasses.gestures.modelStatusLine import pw.binom.viewmate.glasses.gestures.modelStatusLine
import pw.binom.viewmate.glasses.gestures.overlayVisible import pw.binom.viewmate.glasses.gestures.overlayVisible
import pw.binom.viewmate.glasses.gestures.scrollOffset import pw.binom.viewmate.glasses.gestures.scrollOffset
import pw.binom.viewmate.glasses.ui.layers.AssistantEngine
/** /**
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт * Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
@@ -47,19 +48,30 @@ import pw.binom.viewmate.glasses.gestures.scrollOffset
* [GlassesApp.answerScrollDelta]). * [GlassesApp.answerScrollDelta]).
*/ */
@Composable @Composable
fun AssistantOverlay() { fun AssistantOverlay(engine: AssistantEngine) {
val app = GlassesApp.instance val app = GlassesApp.instance
val listening by app.listening.collectAsState() // Навигационное зеркало (стек) + модель-догрузчик — в приложении; стейт ассистента — в [engine].
val thinking by app.thinking.collectAsState() val navStack by app.navStack.collectAsState()
val sttText by app.sttText.collectAsState() val thinking by engine.thinking.collectAsState()
val assistantText by app.assistantText.collectAsState() val sttText by engine.sttText.collectAsState()
val chatHistory by app.chatHistory.collectAsState() val assistantText by engine.assistantText.collectAsState()
val state = AssistantState(listening, thinking, sttText, assistantText) val sttStartBlocked by engine.sttStartBlocked.collectAsState()
val sttNoSpeech by engine.sttNoSpeech.collectAsState()
val chatHistory by engine.chatHistory.collectAsState()
val state = AssistantState(
stack = navStack,
thinking = thinking,
sttText = sttText,
assistantText = assistantText,
sttStartBlocked = sttStartBlocked,
sttNoSpeech = sttNoSpeech,
)
if (!overlayVisible(state)) return if (!overlayVisible(state)) return
val modelStatus by app.sttModel.status.collectAsState() val modelStatus by app.sttModel.status.collectAsState()
// Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание // Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ старт отклонён
// локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему. // (модель не скачалась, стtStartBlocked): распознавание локально, поэтому модель не
val modelLine = if (state.listening) modelStatusLine(modelStatus) else null // READY = распознавание не готово — объясняем что происходит (качается / не скачалась).
val modelLine = if (state.listening || state.sttStartBlocked) modelStatusLine(modelStatus) else null
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды // Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать // (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
// в единственном экземпляре — иначе глаза скроллят по-разному. // в единственном экземпляре — иначе глаза скроллят по-разному.
@@ -74,12 +86,12 @@ fun AssistantOverlay() {
} }
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза; // Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset]. // аккумулятор (answerScrollDelta) общий в [engine]. Цель — чистый [scrollOffset].
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
app.answerScrollDelta.collect { pending -> engine.answerScrollDelta.collect { pending ->
if (pending == 0f) return@collect if (pending == 0f) return@collect
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue) val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
app.answerScrollDelta.value = 0f engine.answerScrollDelta.value = 0f
if (target != scrollState.value) { if (target != scrollState.value) {
scrollState.animateScrollTo(target, tween(150)) scrollState.animateScrollTo(target, tween(150))
} }
@@ -114,7 +126,9 @@ private fun overlayBox(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black), // Полупрозрачный: затемнённый фильм (DimScrim снизу) просвечивает позади чата —
// «чат виден на фоне». Чат сам по себе тёмный, читаемость не падает.
.background(Color.Black.copy(alpha = 0.88f)),
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -127,7 +141,7 @@ private fun overlayBox(
) { ) {
AssistantOverlayContent(state, chatHistory, scrollState, modelLine) AssistantOverlayContent(state, chatHistory, scrollState, modelLine)
if (state.listening) { if (state.listening && !state.sttStartBlocked) {
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
@@ -0,0 +1,42 @@
package pw.binom.viewmate.glasses.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.LayoutManager
import pw.binom.viewmate.glasses.ui.layers.MoveLayout
/**
* Ленточный (LIFO) layout-стек очков: композитинг снизу вверх — [LayoutManager.compositing]
* рендерит ВСЕ слои (кино → затемнение → чат → слушаю), панель статуса у верхнего.
* Стек правды — персистентный [GlassesApp.layoutManager]; [navStack] — лишь его зеркало
* (для триггера рекомпозиции).
*/
@Composable
fun LayoutStackContent(manager: LayoutManager) {
// Триггер рекомпозиции: стек менеджера меняется через ctx.open/close, а LayoutManager.onChange
// зеркалирует его в [GlassesApp.navStack] (StateFlow). compositing() читает сам стек менеджера
// (deque); изменение navStack запускает рекомпозицию.
val nav by GlassesApp.instance.navStack.collectAsState()
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
manager.compositing().forEach { layer ->
layer.layout.drawStage()
// Затемняем фильм, только когда поверх него что-то есть (чтение nav — и триггер).
if (layer.layout is MoveLayout && nav.size > 1) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
)
}
}
manager.current.drawStatusPanel()
}
}
@@ -0,0 +1,263 @@
package pw.binom.viewmate.glasses.ui.layers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import pw.binom.viewmate.glasses.ChatEntry
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.gestures.AssistantMode
import pw.binom.viewmate.glasses.gestures.NavFrame
/**
* Внятный стейт режима ассистента + его эффекты — единый когерентный объект, а не размазанные
* flows в [GlassesApp]. Слои [AssistantLayout]/[ListeningLayout] получают движок и *сами*
* владеют таблицами жестов, вызывая эффекты здесь; чат-оверлей читает стейт тоже отсюда.
*
* Зависимости (STT-сессия, модель, протокол, медиа-контроллер, менеджер стека) берёт лениво из
* [GlassesApp] в методах — сам движок строится без побочных эффектов (его стейт — лишь flows),
* поэтому порядок инициализации полей приложения не критичен.
*/
class AssistantEngine(private val app: GlassesApp) {
// ── Стейт ассистента (были размазанными полями в GlassesApp) ──────────────────────
/** Фаза «думаю»: фраза отправлена, ждём ответ (ShowText). */
val thinking = MutableStateFlow(false)
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
val sttText = MutableStateFlow("")
/** Текст ответа ассистента (ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/** Попытка начать диктовку отклонена: модель Qwen3 не скачалась. */
val sttStartBlocked = MutableStateFlow(false)
/** Последний стоп/отправка не поймал голоса — оверлей показывает «не расслышал». */
val sttNoSpeech = MutableStateFlow(false)
/** Идёт распознавание (TripleClick открыл, завершает Click / 30s-лимит). */
val listening = MutableStateFlow(false)
/** История диалога сессии (роль wire: "user"/"assistant"); приходит целиком с телефона. */
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
/** Непоглощённая дельта скролла ответа (px); записывается эффектами, читается оверлеем. */
val answerScrollDelta = MutableStateFlow(0f)
/**
* Кэлбэки ВЫЗЫВШИГО (слоя ассистента): [listeningSuccess] — куда слой записи возвращает
* распознанный текст, [listeningCancel] — факт отмены ввода. Привязываются
* [ListeningLayout.onEnter] через [bindListening], отцепляются в [ListeningLayout.onExit];
* null — записи нет / POP-нута.
*/
private var listeningSuccess: ((String) -> Unit)? = null
private var listeningCancel: (() -> Unit)? = null
/** Скоуп корутин, которые запускают вызывающие слои (явное ожидание результата записи). */
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// ── Под-состояние кадра ассистента (top == Assistant) ─────────────────────────────
/** idle / thinking / answer — по стейту (top кадра ассистента предполагается активным). */
fun mode(): AssistantMode = when {
assistantText.value.isNotBlank() -> AssistantMode.ANSWER
thinking.value -> AssistantMode.THINKING
else -> AssistantMode.IDLE
}
// ── Эффекты (перенесены из GlassesGestureEffects) ────────────────────────────────
/**
* TripleClick в кино: пауза фильма + открыть кадр «ассистент» ([AssistantLayout]) и
* сразу «слушание» ([ListeningLayout]). Фильм остаётся на паузе, аудиосинхр улетает на
* телефон (продолжит догнанку, не перемотает).
*/
fun enterAssistant() {
if (app.layoutManager.depth > 1) {
log("gesture", "TripleClick проигнорирован: уже в ассистенте")
return
}
log("stt", "ассистент: слушаю (стек: фильм → ассистент → слушаю)")
if (app.rootLayer.controller.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
}
app.hostConnection.sendPlaybackPositionNow()
sttNoSpeech.value = false
assistantText.value = ""
answerScrollDelta.value = 0f
// Кадр ассистента сам открывает слой записи (в своём onEnter), передавая кэлбэки onSuccess/onCancel.
app.layoutManager.open { ctx -> AssistantLayout(ctx, this) }
}
/**
* Слой записи открыт микрофон ([ListeningLayout.onEnter]): открыть микрофон только если модель
* готова. Модель Qwen3 ещё не скачалась — микрофон не открываем: показываем «модель не
* скачалась» ([sttStartBlocked]), распознавание не готово. Здесь стек НЕ трогаем — слой уже
* открыт (его открыл [enterAssistant] / кадр ассистента).
*/
fun beginMic() {
sttNoSpeech.value = false
sttText.value = ""
if (!app.sttModel.status.value.ready) {
listening.value = false
thinking.value = true
sttStartBlocked.value = true
log("stt", "старт диктовки отклонён: модель Qwen3 не скачалась — микрофон не открываю, показываю ошибку")
return
}
sttStartBlocked.value = false
thinking.value = false
listening.value = true
app.sttSession.start()
}
/** Слой записи закрыт микрофон ([ListeningLayout.onExit]): закрыть микрофон, гасить «слушаю». */
fun endMic() {
app.sttSession.stop()
listening.value = false
sttText.value = ""
}
/** Логика «думает ассистент или нет»: сотереть показанный ответ и дельту скролла перед новой фразой. */
fun clearAnswer() {
assistantText.value = ""
answerScrollDelta.value = 0f
}
/**
* Слой записи открыт ([ListeningLayout.onEnter]): привязать кэлбэки вызвавшего, чтобы сюда
* доставлять распознанный текст и факт отмены ввода.
*/
fun bindListening(onSuccess: (String) -> Unit, onCancel: () -> Unit) {
listeningSuccess = onSuccess
listeningCancel = onCancel
}
/** Слой записи закрыт ([ListeningLayout.onExit]): отцепить кэлбэки (дальше результатов нет). */
fun unbindListening() {
listeningSuccess = null
listeningCancel = null
}
/**
* Доставить распознанный текст вызвавшему ассистенту ([ListeningLayout.onSuccess]). Если слой
* записи уже POP-нут (кэлбэк отцеплен) — эффект выполняю сам, без ассистента.
*/
private fun deliverSuccess(text: String) {
val cb = listeningSuccess
if (cb != null) cb.invoke(text) else continueTurn(text)
}
/** Доставить факт отмены вызвавшему ассистенту. Если слой записи уже POP-нут — [stopToIdle] выполняю сам. */
private fun deliverCancel() {
val cb = listeningCancel
if (cb != null) cb.invoke() else stopToIdle("отмена ввода → стоп, остаюсь в ассистенте", noSpeech = true)
}
/** Ассистент получил фразу: жду ответ ([thinking]), POP записи (её onExit закроет микрофон). */
fun continueTurn(text: String) {
log("stt", "ассистент: жду ответ на фразу '${text.take(40)}'")
if (app.layoutManager.current is ListeningLayout) app.layoutManager.close() // onExit → endMic
thinking.value = true
}
/** Клик в «слушаю»: распознать скопленный микрофон локально Qwen3-ом и отправить на телефон. */
fun sendPhrase() {
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
thinking.value = true
app.sttSession.send()
}
/**
* Стоп-записи с ОСТАВАНИЕМ в кадре ассистента: POP [ListeningLayout] (idle) — его onExit
* закрывает микрофон ([endMic]). Микрофон закрывается ПОД-слоем, а не здесь.
*/
fun stopToIdle(reason: String, noSpeech: Boolean) {
log("stt", reason)
if (app.layoutManager.current is ListeningLayout) app.layoutManager.close() // onExit → endMic
thinking.value = false
sttNoSpeech.value = noSpeech
}
/** Выход из режима в корень: closeToRoot (POP [ListeningLayout] → onExit → [endMic]), чистим стейт. */
fun exitMode(reason: String) {
log("stt", reason)
app.layoutManager.closeToRoot() // POP Listening → onExit → endMic (стоп микрофона)
thinking.value = false
sttStartBlocked.value = false
sttNoSpeech.value = false
sttText.value = ""
assistantText.value = ""
answerScrollDelta.value = 0f
}
/**
* Результат локального распознавания (на очках): не пусто → отправить на телефон и ждать
* ответ; пустой авто-результат (тишина 30с) → выход из режима; пустой клик без голоса →
* стоп с остаованием в ассистенте. Возвращает текст для SttText (null — не отправлять).
*/
fun processSttResult(text: String, auto: Boolean): String? {
sttText.value = text
return when {
text.isNotBlank() -> {
log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → ассистенту")
deliverSuccess(text) // → ассистент: continueTurn (жду ответ)
text
}
auto -> {
exitMode("тишина 30с (очки) — выход из режима")
null
}
else -> {
deliverCancel() // → ассистент: stopToIdle (не расслышал)
null
}
}
}
/** Host-driven: явная отмена диктовки (user) — стоп, остаёмся в ассистенте (не расслышал). */
fun requestSttStop() {
log("stt", "SttCancel(user) → стоп, остаюсь в ассистенте (не расслышал)")
deliverCancel() // → ассистент: stopToIdle
}
/** Host-driven: авто-отмена по тишине 30s — выход из режима. */
fun handleSttTimeout() = exitMode("СттCancel(timeout) — тишина 30с, выход из режима")
// ── Host-driven записи стейта (вместо прямого писания flows в GlassesApp) ──────────
/** ShowText: ответ ассистента виден, «думаю» сброшено. */
fun onShowText(text: String) {
thinking.value = false
answerScrollDelta.value = 0f
assistantText.value = text
}
/** ChatHistory: история сессии с телефона (фильтрация wire-типов — в HostConnection). */
fun onChatHistory(messages: List<ChatEntry>) {
chatHistory.value = messages
}
/** SttPhrase: накопленный текст распознавания (для оверлея). */
fun onSttPhrase(full: String) {
sttText.value = full
}
/** SttDone: распознавание завершено, индикатор записи гаснет. */
fun onStopListening() {
listening.value = false
}
}
/** Провязка [Layout] → [NavFrame] для [GlassesApp.navStack] (зеркало стека в [NavFrame]-терминах). */
fun Layout.toFrame(): NavFrame = when (this) {
is MoveLayout -> NavFrame.Film
is AssistantLayout -> NavFrame.Assistant
is ListeningLayout -> NavFrame.Listening
else -> NavFrame.Film
}
@@ -0,0 +1,90 @@
package pw.binom.viewmate.glasses.ui.layers
import androidx.compose.runtime.Composable
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.gestures.AssistantMode
import pw.binom.viewmate.glasses.ui.AssistantOverlay
import kotlin.coroutines.resume
/**
* Кадр ассистента (чат). Полупрозрачный [AssistantOverlay] — затемнённый фильм просвечивает
* на фоне (фильм остаётся ниже в композите). В под-состоянии «слушаю» оверлей сам показывает
* индикатор записи и текущую фразу — отдельного экрана «слушаю» нет, чат остаётся на фоне.
*
* В кадре остаётся только *его* логика: скролы (свайпы), «думает ассистент или нет» и реакция на
* результат записи. Записью микрофона кадр НЕ занимается: клик *вызывает* слой записи
* ([ListeningLayout]) suspend-функцией [listen] и явно ждёт её окончания (текст / отмена);
* сам микрофон запись открывает/закрывает в своём onEnter/onExit.
*/
class AssistantLayout(
private val ctx: LayoutContext,
private val engine: AssistantEngine,
) : Layout {
@Composable
override fun drawStage() {
AssistantOverlay(engine)
}
@Composable
override fun drawStatusPanel() = Unit
/**
* Таблица жестов кадра (top == Assistant): клик *вызывает* слой записи (передавая кэлбэк);
* логика здесь — только свайпы (в диалоге не скроллим) и «думает» (стёрнуть ответ).
*/
override fun gesture(g: Gesture) {
when (g) {
Gesture.DoubleClick -> engine.exitMode("DoubleClick → выход из чата (кино остаётся на паузе)")
Gesture.Click -> when (engine.mode()) {
AssistantMode.IDLE -> openListening()
AssistantMode.THINKING, AssistantMode.ANSWER -> {
engine.clearAnswer()
openListening()
}
else -> Unit // NONE/LISTENING при top==Assistant невозможны (тогда топ был бы другим)
}
Gesture.TripleClick -> if (engine.mode() == AssistantMode.IDLE) openListening()
Gesture.SwipeForward, Gesture.SwipeBackward -> Unit // логика скролов: в диалоге не скроллим
}
}
/**
* Suspend-функция вызывающего места: открыть слой записи и ЯВНО дождаться его окончания.
* Возвращает распознанный текст ([onSuccess]) или null ([onCancel], отмена) — слой записи
* завершает ожидание этими кэлбэками (текст доставляет движок, отмену слой вызовал сам).
*/
private suspend fun listen() = suspendCancellableCoroutine { context ->
ctx.open { c ->
context.invokeOnCancellation {
c.close()
}
ListeningLayout(c, engine, onSuccess = { text ->
context.resume(text)
}, onCancel = {
context.resume(null)
})
}
}
/** Вызвать запись: ланчить suspend-функцию [listen] в [engine.scope] и по её результату продолжить ход. */
private fun openListening() {
engine.scope.launch {
val text = listen()
if (text != null) {
engine.continueTurn(text) // распознали фразу → жду ответ (thinking, POP записи)
} else {
engine.stopToIdle(
"отмена ввода → остаюсь в ассистенте",
noSpeech = true
) // отмена → idle
}
}
}
}
@@ -0,0 +1,71 @@
package pw.binom.viewmate.glasses.ui.layers
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
/**
* Под-слой «слушаю» — слой ЗАПИСИ: он *занимается* микрофоном. Пустой, прозрачный поверх кадра
* ассистента (визуал индикатора/фразы рисует [AssistantLayout] под ним, чат остаётся на фоне).
*
* Микрофон открывается/закрывается САМИМ этим слоем: [onEnter] → [AssistantEngine.beginMic]
* (открыть только если модель готова), [onExit] → [AssistantEngine.endMic] (закрыть).
*
* Слой не «знает», что делать с результатом — он возвращает его ВЫЗЫВШИМУ (слою ассистента) двумя
* кэлбэками, которые тот передал при вызове: [onSuccess] (сразу текст распознанной фразы) и
* [onCancel] (просто отмена ввода). Распознанный текст движок доставляет сюда через
* [AssistantEngine.bindListening] (привязан в [onEnter], отцепляется в [onExit]); кэки-жесты слой
* вызывает [onCancel] сам. Если модель не скачалась (sttStartBlocked) — клик пере-начинает попытку,
* двойной клик выходит из режима.
*/
class ListeningLayout(
private val ctx: LayoutContext,
private val engine: AssistantEngine,
private val onSuccess: (String) -> Unit,
private val onCancel: () -> Unit,
) : Layout {
@Composable
override fun drawStage() {
Box(modifier = Modifier.fillMaxSize())
}
@Composable
override fun drawStatusPanel() = Unit
/** Слой записи открыт: привязать кэлбэки вызвавшего к движку + открыть микрофон (если модель готова). */
override fun onEnter() {
engine.bindListening(onSuccess, onCancel)
engine.beginMic()
}
/**
* Слой записи закрыт: отцепить кэлбэки + закрыть микрофон. [onCancel] — страховочный вызов:
* закрытие без успешного текста (выход из режима, стоп) сообщает вызывающему об отмене. Для
* вызывающего [onCancel] идемпотентен (deferred уже завершён — повтор игнорируется).
*/
override fun onExit() {
engine.endMic()
engine.unbindListening()
onCancel()
}
/** Таблица жестов под-состояния (top == Listening): этот слой *занимается* микрофоном. */
override fun gesture(g: Gesture) {
when (g) {
Gesture.Click ->
if (engine.sttStartBlocked.value) engine.beginMic() else engine.sendPhrase()
Gesture.DoubleClick ->
if (engine.sttStartBlocked.value) {
engine.exitMode("DoubleClick → выход (модель не скачалась)") // onExit → onCancel
} else {
onCancel() // → вызывающий: stopToIdle (останемся в ассистенте)
}
Gesture.TripleClick, Gesture.SwipeForward, Gesture.SwipeBackward -> Unit
}
}
}
@@ -0,0 +1,73 @@
package pw.binom.viewmate.glasses.ui.layers
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.glasses.ConnectionScreen
import pw.binom.viewmate.glasses.Gesture
import pw.binom.viewmate.glasses.GlassesApp
import pw.binom.viewmate.glasses.Layout
import pw.binom.viewmate.glasses.LayoutContext
import pw.binom.viewmate.glasses.MovieController
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
/**
* Кино — корень стека. Рисуется всегда (нижний слой композита): активный фильм —
* [GlassesMovieScreen], иначе экран подключения [ConnectionScreen]. Поверх него LayoutManager
* кладёт затемнение + под-слои (ассистент/слушание), поэтому фильм виден на фоне чата.
*
* Слой *владеет* своим медиастейтом ([controller]: плеер/позиция/wifi-lock) — он живёт со
* стопом корня, а не с жизненным циклом Activity. Протокол/lifecycle/эффекты дотягиваются до
* движка через слой ([controller]). Слой *сам* отвечает за свои жесты: тройной клик — пауза +
* открыть ассистента, клик — плей/пауза, свайпы — seek. Открытие под-слоёв — через [ctx].
*/
class MoveLayout(
private val ctx: LayoutContext,
context: Context,
) : Layout {
/** Медиадвижок слоя: [MoveLayout] — владелец (корень живёт до конца сессии очков). */
val controller = MovieController(context)
@Composable
override fun drawStage() {
val movieActive by controller.movieActive.collectAsState()
val title by controller.title.collectAsState()
val player = controller.player
if (movieActive && player != null) GlassesMovieScreen(player = player, title = title)
else ConnectionScreen()
}
@Composable
override fun drawStatusPanel() = Unit
/** Жесты кино (слой активен, когда он вершина стека): каждый жест ведёт себя сам. */
override fun gesture(g: Gesture) {
when (g) {
Gesture.Click -> togglePlayback()
Gesture.DoubleClick -> Unit // игнор в кино (матрица: DoubleClick в кино = Ignore)
Gesture.TripleClick -> GlassesApp.instance.assistantEngine.enterAssistant()
Gesture.SwipeForward -> seekBy(MediaCommand.SEEK_FORWARD, 30_000)
Gesture.SwipeBackward -> seekBy(MediaCommand.SEEK_BACKWARD, 30_000)
}
}
/** Click (кино): плей/пауза + отправить позицию на телефон. */
private fun togglePlayback() {
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
controller.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** Свайп (кино): seek на ±[ms]. */
private fun seekBy(cmd: MediaCommand, ms: Long) {
log("gesture", "жест: свайп → $cmd ${ms}ms")
controller.applyMediaCommand(MediaCommandMsg(cmd, ms)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
}
@@ -0,0 +1,168 @@
package pw.binom.viewmate.glasses
import androidx.compose.runtime.Composable
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* [LayoutManager] — ЛIFO-стек layout-ов очков: корень (двойным кликом не вытолкнуть) → под-слои.
* Проверяем: open/close/closeToRoot, вершина, композитинг (затемнение между слоями), индикатор
* записи, маршрутизацию жеста в верхний слой (и только в него).
*/
class LayoutManagerTest {
/** Тестируемый layout: пустые @Composable тела (в юнит-тестах не компонируются), лог жеста. */
private class FakeLayout(
val id: String,
private val onGesture: (Gesture) -> Unit = {},
) : Layout {
val gestures = mutableListOf<Gesture>()
@Composable
override fun drawStage() {}
@Composable
override fun drawStatusPanel() {}
override fun gesture(g: Gesture) {
gestures += g
onGesture(g)
}
}
private val root = FakeLayout("film")
private val assistant = FakeLayout("assistant")
@Test
fun `root is the initial current`() {
val m = LayoutManager { _ -> root }
assertEquals(1, m.depth)
assertIs<FakeLayout>(m.current)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `open pushes a child on top`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
assertEquals(2, m.depth)
assertEquals("assistant", (m.current as FakeLayout).id)
assertEquals(listOf("film", "assistant"), m.layersBottomUp().map { (it as FakeLayout).id })
}
@Test
fun `close pops to root`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.close()
assertEquals(1, m.depth)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `close on root is a no-op`() {
val m = LayoutManager { _ -> root }
m.close()
assertEquals(1, m.depth)
assertEquals("film", (m.current as FakeLayout).id)
}
@Test
fun `closeToRoot drops every non-root layer`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
m.open { FakeLayout("listening") }
assertEquals(3, m.depth)
m.closeToRoot()
assertEquals(1, m.depth)
assertEquals(listOf("film"), m.layersBottomUp().map { (it as FakeLayout).id })
}
@Test
fun `compositing dims every layer but the top`() {
val m = LayoutManager { _ -> root }
m.open { FakeLayout("assistant") }
m.open { FakeLayout("listening") }
val layers = m.compositing()
assertEquals(3, layers.size)
// film → dim, assistant → dim, listening → без затемнения.
assertEquals(listOf("film", "assistant", "listening"), layers.map { (it.layout as FakeLayout).id })
assertEquals(listOf(true, true, false), layers.map { it.dim })
}
@Test
fun `single layer composites without dim`() {
val m = LayoutManager { _ -> root }
val layers = m.compositing()
assertEquals(listOf(false), layers.map { it.dim })
}
@Test
fun `dispatch routes only to the top layer`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.dispatch(Gesture.Click)
// Вершина (assistant) получила жест, корень — нет.
assertEquals(listOf(Gesture.Click), assistant.gestures)
assertEquals(0, root.gestures.size)
}
@Test
fun `dispatch after close routes back to root`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.close()
m.dispatch(Gesture.DoubleClick)
assertEquals(0, assistant.gestures.size)
assertEquals(listOf(Gesture.DoubleClick), root.gestures)
}
@Test
fun `recording indicator reflects an active layer`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
assertFalse(m.recordingIndicator())
m.setRecordingIndicator(true)
assertTrue(m.recordingIndicator())
m.setRecordingIndicator(false)
assertFalse(m.recordingIndicator())
}
@Test
fun `popping a recording layer turns the indicator off`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.setRecordingIndicator(true)
assertTrue(m.recordingIndicator())
m.close()
// assistant (POP) выключил индикатор — корень его не включал.
assertFalse(m.recordingIndicator())
}
@Test
fun `a re-opened layer starts with the indicator off`() {
val m = LayoutManager { _ -> root }
m.open { assistant }
m.setRecordingIndicator(true)
m.close()
m.open { FakeLayout("assistant2") }
// Новый экземпляр не наследует включение — индикатор выключен, пока сам не включит.
assertFalse(m.recordingIndicator())
}
@Test
fun `child receives the shared context`() {
var received: LayoutContext? = null
val m = LayoutManager { _ -> root }
m.open { ctx -> received = ctx; assistant }
assertSame(m, received)
assertTrue(m.recordingIndicator() == false)
}
private fun assertSame(a: Any?, b: Any?) {
if (a !== b) throw AssertionError("ожидал тот же экземпляр контекста")
}
}
@@ -18,34 +18,43 @@ import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.glasses.stt.SttModelStatus import pw.binom.viewmate.glasses.stt.SttModelStatus
/** /**
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино, * Карта жеста (docs/gestures-and-modes.md) — ЛIFO-стек кадров:
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»). * [NavFrame.Film] (корень) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон).
* Двойной клик POP-ает верхний кадр; корень не вытолкнуть. Плюс свайпы → ±30с в кино,
* скролл ответа (дельта/зажим/усечение), заголовок оверлея.
*/ */
class AssistantGesturesTest { class AssistantGesturesTest {
private val movie = AssistantState() // Стек-состояния (вершина — текущий кадр). Корень всегда [NavFrame.Film].
private val listening = AssistantState(listening = true) private val movie = AssistantState() // [Film]
private val thinking = AssistantState(thinking = true) private val idle = AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant))
private val answer = AssistantState(assistantText = "ответ") private val listening =
AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening))
private val thinking = idle.copy(thinking = true)
private val answer = idle.copy(assistantText = "ответ")
private val blocked = listening.copy(sttStartBlocked = true)
// ————— режим по состоянию ————— // ————— режим по вершине стека —————
@Test @Test
fun movieStateIsNone() { fun movieStateIsNone() {
assertEquals(AssistantMode.NONE, assistantMode(movie)) assertEquals(AssistantMode.NONE, assistantMode(movie))
} }
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */ /** Вершина Listening всё побеждает — жесты идут по матрице «слушаю». */
@Test @Test
fun listeningWinsOverEverything() { fun listeningTopWins() {
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а") val s = listening.copy(thinking = true, sttText = "ф", assistantText = "а")
assertEquals(AssistantMode.LISTENING, assistantMode(s)) assertEquals(AssistantMode.LISTENING, assistantMode(s))
} }
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */ /** «Ответ виден»: вершина Assistant, assistantText непуст — даже при thinking. */
@Test @Test
fun answerWinsOverThinking() { fun answerWinsOverThinking() {
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а"))) assertEquals(
AssistantMode.ANSWER,
assistantMode(idle.copy(thinking = true, assistantText = "а")),
)
} }
@Test @Test
@@ -53,7 +62,12 @@ class AssistantGesturesTest {
assertEquals(AssistantMode.THINKING, assistantMode(thinking)) assertEquals(AssistantMode.THINKING, assistantMode(thinking))
} }
// ————— кино ( NONE) ————— @Test
fun idleWhenAssistantTopNoContent() {
assertEquals(AssistantMode.IDLE, assistantMode(idle))
}
// ————— кино (вершина Film) —————
@Test @Test
fun movieMatrix() { fun movieMatrix() {
@@ -78,12 +92,12 @@ class AssistantGesturesTest {
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d) assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
} }
// ————— слушаю ————— // ————— слушаю (вершина Listening) —————
@Test @Test
fun listeningMatrix() { fun listeningMatrix() {
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening)) assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening)) assertEquals(GestureDecision.StopListening, gestureDecision(DoubleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
@@ -91,7 +105,16 @@ class AssistantGesturesTest {
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening)) assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
} }
// ————— «думаю» (фраза отправлена, ответа ещё нет) ————— /** Модель не скачалась: кадр Listening на месте, микрофон закрыт — клик повторить, двойной выход. */
@Test
fun listeningBlockedMatrix() {
assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), blocked))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), blocked))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), blocked))
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), blocked))
}
// ————— «думаю» / «ответ» (вершина Assistant) —————
@Test @Test
fun thinkingMatrix() { fun thinkingMatrix() {
@@ -102,27 +125,87 @@ class AssistantGesturesTest {
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking)) assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
} }
// ————— «ответ виден» —————
@Test @Test
fun answerMatrix() { fun answerMatrix() {
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer)) assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer)) assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer)) assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer)) // Скролла ответа больше нет (решение пользователя: в диалоге ассистента не скроллим) —
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer)) // все свайпы (дискретные и плавный шлейф) игнорируются.
// Плавный: накопленная дельта шлейфа, знак — как пришёл. assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), answer))
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer)) assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), answer))
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer)) assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(12.5f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(-7f), answer))
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer)) assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
} }
// ————— стек-переходы —————
/** Тройной клик в кино: кладём «ассистент» и сразу «слушаю» — [Film, Assistant, Listening]. */
@Test @Test
fun answerPageDeltaIsParameter() { fun enterAssistantPushesBothFrames() {
assertEquals( val s = movie.enterAssistant()
GestureDecision.ScrollAnswer(80f), assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), s.stack)
gestureDecision(slideForward(), answer, pageDeltaPx = 80f), assertEquals(AssistantMode.LISTENING, assistantMode(s))
) }
/** Click в idle/«думаю»/«ответ»: кладём «слушаю» поверх кадра ассистента. */
@Test
fun beginListeningFramePushesListening() {
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), idle.beginListeningFrame().stack)
assertEquals(AssistantMode.LISTENING, assistantMode(answer.beginListeningFrame()))
}
/** DoubleClick в «слушаю» — стоп: POP кадра, остаёмся в ассистенте (idle), чат не прерван. */
@Test
fun stopListeningStaysInAssistant() {
val decision = gestureDecision(DoubleClick(), listening)
assertEquals(GestureDecision.StopListening, decision)
// POP кадра «слушаю» — кадр ассистента (idle), оверлей остаётся, выхода нет.
val after = listening.stopListeningFrame(noSpeech = false)
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant), after.stack)
assertEquals(AssistantMode.IDLE, assistantMode(after))
assertTrue(overlayVisible(after))
assertFalse(after.listening)
// Idle: Click/Triple — возобновить запись; DoubleClick — POP ассистента (выход к фильму).
assertEquals(GestureDecision.BeginListening, gestureDecision(Click(), after))
assertEquals(GestureDecision.BeginListening, gestureDecision(TripleClick(), after))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after))
}
/** Стоп первой записи (пустая история): оверлей НЕ закрывается — остаёмся в idle-кадре. */
@Test
fun stopFirstRecordingKeepsOverlay() {
val stopped = listening.copy(sttNoSpeech = true).stopListeningFrame(noSpeech = true)
assertTrue(stopped.assistantText.isBlank())
assertFalse(stopped.listening)
// Оверлей виден (idle-кадр), хотя ни фразы, ни ответа нет.
assertTrue(overlayVisible(stopped))
// И только явный выход (POP до фильма) прячет оверлей.
val exited = stopped.exitToFilm()
assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
}
/** Стоп сбрасывает накопленный текст текущей (останавливаемой) записи. */
@Test
fun stopListeningClearsCancelledText() {
val after = listening.copy(sttText = "слова до отмены").stopListeningFrame(noSpeech = false)
assertEquals("", after.sttText)
assertFalse(after.listening)
}
/** Выход: POP всё до корня (фильм), сброс контента. */
@Test
fun exitToFilmPopsToRoot() {
val exited =
listening.copy(sttText = "ф", thinking = true, assistantText = "а", sttNoSpeech = true)
.exitToFilm()
assertEquals(listOf(NavFrame.Film), exited.stack)
assertFalse(overlayVisible(exited))
assertTrue(exited.assistantText.isBlank())
assertFalse(exited.thinking)
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
} }
// ————— сессия: кино → слушаю → «думаю» → ответ → выход ————— // ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
@@ -131,9 +214,9 @@ class AssistantGesturesTest {
fun sessionTransitions() { fun sessionTransitions() {
var s = movie var s = movie
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s)) assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
s = s.copy(listening = true) s = s.enterAssistant()
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s)) assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет s = s.stopListeningFrame(noSpeech = false).copy(thinking = true) // фраза отправлена, ответ ещё нет
assertEquals(AssistantMode.THINKING, assistantMode(s)) assertEquals(AssistantMode.THINKING, assistantMode(s))
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s)) assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s)) assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
@@ -141,77 +224,42 @@ class AssistantGesturesTest {
assertEquals(AssistantMode.ANSWER, assistantMode(s)) assertEquals(AssistantMode.ANSWER, assistantMode(s))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s)) assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
// Выход: оверлей невидим, кино снова управляется кликом. // Выход: оверлей невидим, кино снова управляется кликом.
val exited = s.copy(assistantText = "") val exited = s.exitToFilm()
assertFalse(overlayVisible(exited)) assertFalse(overlayVisible(exited))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited)) assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
} }
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */ /** Корень «фильм»: двойным кликом попу нельзя; вход в ассистент — Triple. */
@Test @Test
fun cancelStaysInConversation() { fun filmRootCannotBePopped() {
val decision = gestureDecision(DoubleClick(), listening) assertEquals(AssistantMode.NONE, assistantMode(movie))
assertEquals(GestureDecision.CancelDictation, decision) assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
// После отмены (в т.ч. С первой записи, когда истории диалога ещё нет) assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
// возврат в «слушаю»: оверлей остаётся на экране, выхода нет.
val after = afterCancelDictation(listening)
assertEquals(AssistantMode.LISTENING, assistantMode(after))
assertTrue(overlayVisible(after))
// Двойной тап в «слушаю» после отмены — снова просто отмена новой записи.
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), after))
// Отмена посреди диалога: пользователь говорит новую фразу → ответ виден
// (listening уже закрыт отправкой) — выход (как и раньше) DoubleClick.
val afterWithAnswer = after.copy(listening = false, thinking = false, assistantText = "прошлый ответ")
assertEquals(AssistantMode.ANSWER, assistantMode(afterWithAnswer))
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), afterWithAnswer))
}
/** Отмена первой записи (пустая история): оверлей НЕ закрывается (TZ-ui-four п.1). */
@Test
fun cancelFirstRecordingKeepsOverlay() {
// Чистый «кино»-снимок → «слушаю» (вызов ассистента) → отмена записи.
val listeningNow = movie.copy(listening = true)
val cancelled = afterCancelDictation(listeningNow)
assertTrue(cancelled.assistantText.isBlank())
assertTrue(cancelled.listening)
// Оверлей виден, хотя ни фразы, ни ответа нет — «слушай готов».
assertTrue(overlayVisible(cancelled))
// И только явный выход (после диалога) прячет оверлей.
val exited = cancelled.copy(listening = false, thinking = false, assistantText = "ответ")
val closed = exited.copy(listening = false, thinking = false, assistantText = "")
assertFalse(overlayVisible(closed))
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), closed))
}
/** Отмена сбрасывает накопленный текст текущей (отменяемой) записи. */
@Test
fun cancelDictationClearsCancelledText() {
val after = afterCancelDictation(listening.copy(sttText = "слова до отмены"))
assertEquals("", after.sttText)
assertTrue(after.listening)
} }
// ————— оверлей ————— // ————— оверлей —————
@Test @Test
fun overlayInvisibleInMovie() { fun overlayInvisibleInMovie() {
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает). // Настоящее кино-состояние: стек [Film], оверлей невидим.
assertFalse(overlayVisible(movie)) assertFalse(overlayVisible(movie))
} }
@Test @Test
fun overlayVisibleInListeningThinkingAndAnswer() { fun overlayVisibleInAssistantFrames() {
assertTrue(overlayVisible(listening)) assertTrue(overlayVisible(listening))
assertTrue(overlayVisible(thinking)) assertTrue(overlayVisible(thinking))
assertTrue(overlayVisible(answer)) assertTrue(overlayVisible(answer))
assertTrue(overlayVisible(idle))
} }
@Test @Test
fun thinkingHeaderIsDotDotDot() { fun thinkingHeaderIsDotDotDot() {
assertEquals("думаю...", overlayHeader(thinking)) assertEquals("думаю...", overlayHeader(thinking))
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...». // После отправки фразы: вершина ещё Listening + thinking — тоже «думаю...».
assertEquals( assertEquals(
"думаю...", "думаю...",
overlayHeader(AssistantState(listening = true, thinking = true)), overlayHeader(listening.copy(thinking = true)),
) )
} }
@@ -222,10 +270,37 @@ class AssistantGesturesTest {
} }
@Test @Test
fun answerHeaderIsLastPhraseOrNull() { fun idleHeaderHintsGestures() {
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет"))) assertEquals("клик — говорить · двойной — выход", overlayHeader(idle))
assertNull(overlayHeader(answer)) // В ответе подсказка та же — чат прокручивается, жесты те же.
assertNull(overlayHeader(movie)) assertEquals("клик — говорить · двойной — выход", overlayHeader(answer))
}
/** Пустой клик без голоса — idle-кадр с «не расслышал». */
@Test
fun noSpeechIdleShowsHint() {
val noSpeech = idle.copy(sttNoSpeech = true)
assertEquals(AssistantMode.IDLE, assistantMode(noSpeech))
assertEquals("не расслышал — клик, чтобы повторить", overlayHeader(noSpeech))
assertTrue(overlayVisible(noSpeech))
}
// ————— старт отклонён: модель не скачалась —————
@Test
fun overlayVisibleWhenStartBlocked() {
assertTrue(overlayVisible(blocked))
assertFalse(overlayVisible(movie))
}
@Test
fun startBlockedHeaderIsModelNotDownloaded() {
// Блок бьёт и обычный «думаю...» — заголовок всегда «модель не скачалась».
assertEquals("модель не скачалась", overlayHeader(blocked))
assertEquals(
"модель не скачалась",
overlayHeader(blocked.copy(assistantText = "ответ")),
)
} }
// ————— строка статуса модели ————— // ————— строка статуса модели —————
@@ -32,6 +32,9 @@ object MercuryBridge {
private const val TAG = "MercuryBridge" private const val TAG = "MercuryBridge"
/** Пакет нашего очкового приложения (applicationId из app-glasses). */
const val VIEWMATE_GLASSES_PKG = "pw.binom.viewmate.glasses"
private var mercury: RayNeoMercury? = null private var mercury: RayNeoMercury? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -234,6 +237,16 @@ object MercuryBridge {
return ok return ok
} }
/**
* Запустить приложение на очках по пакету (протокол «app-market», [RayNeoMercury.appMarketLaunch]).
* Очки сами открывают приложение по [pkg]; [extras] — опциональные аргументы (как Intent).
*/
suspend fun launchApp(pkg: String, extras: Map<String, String>? = null): Boolean {
val ok = mercury?.appMarketLaunch(pkg, extras) ?: false
setStatus(if (ok) "запуск приложения $pkg на очках" else "ошибка отправки (нет связи?)")
return ok
}
/** Автоблокировка экрана через N секунд; -1 = никогда. */ /** Автоблокировка экрана через N секунд; -1 = никогда. */
suspend fun autoLockTime(time: Int): Boolean { suspend fun autoLockTime(time: Int): Boolean {
val ok = mercury?.setAutoLockTime(time) ?: false val ok = mercury?.setAutoLockTime(time) ?: false
@@ -167,6 +167,9 @@ class PhoneApp : Application() {
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() } val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
private var localLlmDownloadJob: Job? = null private var localLlmDownloadJob: Job? = null
/** Пока true — движок автоповтора: после ошибки повторит скачивание через 60с. */
private var localLlmAutoRetry = false
private fun initialLocalLlmState(): LocalLlmModelState = private fun initialLocalLlmState(): LocalLlmModelState =
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
@@ -186,11 +189,15 @@ class PhoneApp : Application() {
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)), * Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256. * прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
* Повторный вызов во время скачивания — no-op. * Повторный вызов во время скачивания — no-op.
* Авто-повтор: при ошибке (если [localLlmAutoRetry] не сброшен кнопкой «стоп»)
* ждёт 60с и снова пытается, пока файла нет.
*/ */
fun downloadLocalLlmModel() { fun downloadLocalLlmModel() {
val current = localLlmDownloadJob val current = localLlmDownloadJob
if (current != null && current.isActive) return if (current != null && current.isActive) return
localLlmAutoRetry = true
localLlmDownloadJob = scope.launch { localLlmDownloadJob = scope.launch {
while (localLlmAutoRetry && !localModelFile().isFile) {
val target = localModelFile() val target = localModelFile()
_localLlmState.value = LocalLlmModelState.Downloading(0) _localLlmState.value = LocalLlmModelState.Downloading(0)
try { try {
@@ -199,17 +206,24 @@ class PhoneApp : Application() {
} }
_localLlmState.value = LocalLlmModelState.Ready _localLlmState.value = LocalLlmModelState.Ready
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт") log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
return@launch
} catch (e: CancellationException) { } catch (e: CancellationException) {
_localLlmState.value = LocalLlmModelState.Idle
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка") val msg = e.message ?: e.toString()
log("llm", "не удалось скачать модель: ${e.message}") _localLlmState.value = LocalLlmModelState.Error(msg)
log("llm", "не удалось скачать модель: $msg — авто-повтор через 60с")
delay(60_000)
} }
} }
// отменено пользователем («стоп»): [localLlmAutoRetry] сброшен, [localLlmState]=Idle
}
} }
/** Остановить скачивание локальной модели (частичный файл удаляется). */ /** Остановить скачивание локальной модели (частичный файл остаётся, автоповтор гаснет). */
fun cancelLocalLlmDownload() { fun cancelLocalLlmDownload() {
localLlmAutoRetry = false
localLlmDownloadJob?.cancel() localLlmDownloadJob?.cancel()
} }
@@ -313,16 +327,25 @@ class PhoneApp : Application() {
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки. * Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем. * Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
*/ */
private val NO_LLM_ANSWER =
"Серверный LLM не настроен (нет API-ключа в local.properties). " +
"Переключитесь на локальный LLM (Gemma 4 E2B) — кнопкой в чате или в настройках, " +
"либо задайте llm.apiKey и пересоберите."
private suspend fun assistantLoop() { private suspend fun assistantLoop() {
for (phrase in assistantChannel) { for (phrase in assistantChannel) {
log("assistant", "получена фраза: $phrase") log("assistant", "получена фраза: $phrase")
val assistant = ensureAssistant() val assistant = ensureAssistant()
val answer: String
if (assistant == null) { if (assistant == null) {
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена") // Серверный LLM без API-ключа: не «пропущена в никуда», а явная
continue // подсказка + сохранение фразы в БД (чтобы сообщение не исчезало).
} log("assistant", "серверный LLM без ключа (LLM_API_KEY) — не настроен, подсказка пользователю")
answer = NO_LLM_ANSWER
persistNoLlm(phrase)
} else {
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING)) server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
val answer = runCatching { answer = runCatching {
val active = _activeSessionId.value val active = _activeSessionId.value
if (active != null) { if (active != null) {
assistant.processInSession(active, phrase) assistant.processInSession(active, phrase)
@@ -332,6 +355,7 @@ class PhoneApp : Application() {
} }
.onFailure { log("assistant", "ошибка: ${it.message}") } .onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" } .getOrElse { "Ассистент недоступен: ${it.message}" }
}
server.hub.broadcast(ShowText(answer)) server.hub.broadcast(ShowText(answer))
// Очки показывают историю всей сессии (свайпами); ответ уже в ней. // Очки показывают историю всей сессии (свайпами); ответ уже в ней.
server.hub.broadcast(chatHistoryMsg()) server.hub.broadcast(chatHistoryMsg())
@@ -339,6 +363,16 @@ class PhoneApp : Application() {
} }
} }
/**
* Нет работающего ассистента (сервер без ключа): сохраняем и user-фразу, и
* короткую подсказку, чтобы в чате/на очках не было «провала» в истории.
*/
private fun persistNoLlm(phrase: String) {
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: chatDao.createSession("нет LLM")
chatDao.addMessage(id, role = ChatRole.USER, content = phrase)
chatDao.addMessage(id, role = ChatRole.ASSISTANT, content = NO_LLM_ANSWER)
}
/** /**
* История активной (или последней) сессии для очков: только роли user/assistant, * История активной (или последней) сессии для очков: только роли user/assistant,
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1). * tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
@@ -499,6 +533,13 @@ class PhoneApp : Application() {
scope.launch { assistantLoop() } scope.launch { assistantLoop() }
// Автодокачка локальной LLM-модели при старте, если файла нет.
// Каскадный автоповтор (60с) до успеха или «стоп» в настройках.
if (!localModelFile().isFile) {
log("llm", "локальной модели нет — запускаю авто-скачивание (Gemma 4 E2B, ${Gemma4E2B.FILE_NAME})")
downloadLocalLlmModel()
}
val lastPositionAt = AtomicLong(0) val lastPositionAt = AtomicLong(0)
val lastWasPlaying = AtomicBoolean(false) val lastWasPlaying = AtomicBoolean(false)
@@ -157,7 +157,7 @@ fun ChatScreen() {
Box { Box {
TextButton(onClick = { sessionMenu = true }) { TextButton(onClick = { sessionMenu = true }) {
Text( Text(
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…", text = sessions.firstOrNull { it.id == activeId }?.title?.takeIf { it.isNotBlank() } ?: "нет",
color = PhoneAccent, color = PhoneAccent,
fontSize = 12.sp, fontSize = 12.sp,
) )
@@ -165,7 +165,7 @@ fun ChatScreen() {
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) { DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
sessions.forEach { s -> sessions.forEach { s ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(s.title, fontSize = 12.sp) }, text = { Text(s.title.ifBlank { "нет" }, fontSize = 12.sp) },
onClick = { onClick = {
activeId = s.id activeId = s.id
app.selectChatSession(s.id) app.selectChatSession(s.id)
@@ -198,12 +198,29 @@ fun ChatScreen() {
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»). // Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
val llmChoice by app.llmChoice.collectAsState() val llmChoice by app.llmChoice.collectAsState()
Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw", text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B"
else "Серверный LLM · llm.binom.pw",
color = PhoneMuted, color = PhoneMuted,
fontSize = 10.sp, fontSize = 10.sp,
) )
Spacer(Modifier.width(6.dp))
// Переключить бэкенд прямо в чате (без захода в настройки).
Text(
text = if (llmChoice == LlmModelChoice.LOCAL) "→ серверный" else "→ локальный",
color = PhoneAccent,
fontSize = 10.sp,
modifier = Modifier
.clickable {
app.setLlmModelChoice(
if (llmChoice == LlmModelChoice.LOCAL) LlmModelChoice.REMOTE
else LlmModelChoice.LOCAL,
)
}
.padding(horizontal = 4.dp, vertical = 2.dp),
)
}
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
// Лента сообщений // Лента сообщений
@@ -201,11 +201,12 @@ private fun LlmModelSection() {
) )
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (choice == LlmModelChoice.LOCAL) { // Индикатор локальной модели — независимо от выбора бэкенда: пока модель
// скачивается (в т.ч. авто) или упало, видно всегда, а не только в ветке LOCAL.
when (val s = llmState) { when (val s = llmState) {
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) { LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})", text = "Локальная модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
color = PhoneMuted, color = PhoneMuted,
fontSize = 11.sp, fontSize = 11.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -225,14 +226,14 @@ private fun LlmModelSection() {
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
text = "Скачивание модели… ${s.percent}%", text = "Скачивание локальной модели… ${s.percent}%",
color = PhoneMuted, color = PhoneMuted,
fontSize = 10.sp, fontSize = 10.sp,
) )
} }
LocalLlmModelState.Ready -> Text( LocalLlmModelState.Ready -> Text(
text = "Модель готова (Gemma 4 E2B, локально)", text = "Локальная модель готова (Gemma 4 E2B)",
color = PhoneGreen, color = PhoneGreen,
fontSize = 11.sp, fontSize = 11.sp,
) )
@@ -247,7 +248,13 @@ private fun LlmModelSection() {
LlmSmallAction("повторить") { app.downloadLocalLlmModel() } LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
} }
} }
} else {
// Подпись серверного бэкенда — когда выбран сервер и локальная модель не в пути/ошибке.
if (
choice == LlmModelChoice.REMOTE &&
llmState !is LocalLlmModelState.Downloading &&
llmState !is LocalLlmModelState.Error
) {
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp) Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
} }
} }
@@ -41,6 +41,7 @@ import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.DownloadPhase import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.PhoneApp import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.agent.LocalLlmModelState
import pw.binom.viewmate.phone.glassesLivePercent import pw.binom.viewmate.phone.glassesLivePercent
/** Экран 3 — Статус очков + пульт. */ /** Экран 3 — Статус очков + пульт. */
@@ -61,6 +62,7 @@ fun GlassesScreen(
val playing by app.state.playingFlow.collectAsState() val playing by app.state.playingFlow.collectAsState()
val mode by app.state.modeFlow.collectAsState() val mode by app.state.modeFlow.collectAsState()
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState() val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
val llmState by app.localLlmState.collectAsState()
val durationMs = currentItem?.RunTimeTicks?.div(10_000) val durationMs = currentItem?.RunTimeTicks?.div(10_000)
var showDownloads by remember { mutableStateOf(false) } var showDownloads by remember { mutableStateOf(false) }
@@ -222,6 +224,18 @@ fun GlassesScreen(
) )
} }
// авто-скачивание локальной LLM-модели (Gemma 4 E2B)
val llmModelState = llmState
if (llmModelState is LocalLlmModelState.Downloading) {
Text(
text = "скачивание LLM-модели: ${llmModelState.percent}%",
color = PhoneYellow,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
}
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -197,6 +197,19 @@ fun MercuryPanel() {
} }
} }
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { scope.launch { MercuryBridge.launchApp(MercuryBridge.VIEWMATE_GLASSES_PKG) } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
),
) {
Text("Запустить приложение", fontSize = 12.sp)
}
}
// ---- Яркость ---- // ---- Яркость ----
Text( Text(
text = "Яркость: $brightness%", text = "Яркость: $brightness%",