Сохраняем прогресс просмотра и возобновляем с позиции
This commit is contained in:
@@ -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.FileLogSpool
|
||||
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.GlassesSttSession
|
||||
import pw.binom.viewmate.glasses.stt.LocalGlassesRecognizer
|
||||
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"). */
|
||||
data class ChatEntry(val role: String, val text: String)
|
||||
@@ -47,9 +52,6 @@ class GlassesApp : Application() {
|
||||
lateinit var hostConnection: HostConnection
|
||||
private set
|
||||
|
||||
lateinit var movieController: MovieController
|
||||
private set
|
||||
|
||||
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
|
||||
lateinit var downloadManager: GlassesDownloadManager
|
||||
private set
|
||||
@@ -66,6 +68,26 @@ class GlassesApp : Application() {
|
||||
lateinit var sttSession: GlassesSttSession
|
||||
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. */
|
||||
lateinit var logCollector: BatchingLogCollector
|
||||
@@ -84,39 +106,18 @@ class GlassesApp : Application() {
|
||||
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
|
||||
val vertical = MutableStateFlow(1f)
|
||||
|
||||
/**
|
||||
* Идёт распознавание речи (TripleClick стартовал; завершает фразу Click,
|
||||
* отмена записи DoubleClick НЕ завершает сессию — сразу возвращает в
|
||||
* «слушаю» снова; 30с тишины/выход из режима закрывают сессию).
|
||||
*/
|
||||
val listening = MutableStateFlow(false)
|
||||
|
||||
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
|
||||
val sttText = MutableStateFlow("")
|
||||
|
||||
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
||||
val assistantText = MutableStateFlow("")
|
||||
// Стейт ассистента (listening/sttText/assistantText/chatHistory/thinking/sttStartBlocked
|
||||
// + sttNoSpeech/answerScrollDelta ниже) вынесен в [assistantEngine] — владелец стейта.
|
||||
|
||||
/**
|
||||
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом
|
||||
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона,
|
||||
* новый ответ (ShowText) — последнее сообщение истории, не стирает её.
|
||||
* Стек навигации очков (ЛIFO), снизу вверх: корень [NavFrame.Film] (двойным кликом не
|
||||
* вытолкнуть) → [NavFrame.Assistant] (чат) → [NavFrame.Listening] (микрофон, чат на фоне).
|
||||
* Тройной клик в кино кладёт «ассистент» + «слушаю»; двойной клик POP-ит верхний кадр:
|
||||
* из «слушаю» → в кадр ассистента (idle, микрофон закрыт, чат не прерван), из idle/
|
||||
* «думаю»/«ответ» → в «фильм». Держится до явного выхода (двойной клик в кадре
|
||||
* ассистента, 30с тишины, потеря фокуса) и сбрасывается в [AssistantEngine.exitMode].
|
||||
*/
|
||||
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
|
||||
|
||||
/**
|
||||
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
||||
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
||||
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
|
||||
*/
|
||||
val thinking = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
|
||||
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
|
||||
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
|
||||
*/
|
||||
val answerScrollDelta = MutableStateFlow(0f)
|
||||
val navStack = MutableStateFlow(listOf(NavFrame.Film))
|
||||
|
||||
/**
|
||||
* Индикатор аудио-синхронизации для статус-панели: |звук − видео| в мс
|
||||
@@ -148,12 +149,12 @@ class GlassesApp : Application() {
|
||||
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
|
||||
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
|
||||
MercurySDK.init(this)
|
||||
movieController = MovieController(applicationContext)
|
||||
movieController.eventLogger = { msg -> logToFile("mc: $msg") }
|
||||
// Медиадвижок живёт в корневом слое (rootLayer.controller), не в приложении.
|
||||
rootLayer.controller.eventLogger = { msg -> logToFile("mc: $msg") }
|
||||
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
|
||||
hostConnection = HostConnection(
|
||||
fallbackUrl = GlassesConfig.HOST_URL,
|
||||
movieController = movieController,
|
||||
movieController = rootLayer.controller,
|
||||
downloadManager = downloadManager,
|
||||
context = applicationContext,
|
||||
)
|
||||
@@ -184,10 +185,15 @@ class GlassesApp : Application() {
|
||||
sttSession = GlassesSttSession(
|
||||
filesDir = filesDir,
|
||||
scope = bgScope,
|
||||
recognizer = { asr.recognize(it) },
|
||||
modelReady = { sttModel.status.value.ready },
|
||||
recognizer = LocalGlassesRecognizer(asr, sttModel),
|
||||
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 {
|
||||
|
||||
@@ -354,39 +354,38 @@ class HostConnection(
|
||||
|
||||
is ShowText -> {
|
||||
addMessage("ShowText: ${msg.text}")
|
||||
GlassesApp.instance.thinking.value = false
|
||||
GlassesApp.instance.answerScrollDelta.value = 0f
|
||||
GlassesApp.instance.assistantText.value = msg.text
|
||||
GlassesApp.instance.assistantEngine.onShowText(msg.text)
|
||||
}
|
||||
|
||||
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
|
||||
|
||||
is ChatHistoryMsg -> {
|
||||
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
|
||||
GlassesApp.instance.chatHistory.value = msg.messages
|
||||
.filter { it.role == "user" || it.role == "assistant" }
|
||||
.map { ChatEntry(it.role, it.content) }
|
||||
GlassesApp.instance.assistantEngine.onChatHistory(
|
||||
msg.messages
|
||||
.filter { it.role == "user" || it.role == "assistant" }
|
||||
.map { ChatEntry(it.role, it.content) },
|
||||
)
|
||||
}
|
||||
|
||||
is SttPhrase -> {
|
||||
GlassesApp.instance.sttText.value = msg.full
|
||||
GlassesApp.instance.assistantEngine.onSttPhrase(msg.full)
|
||||
log("stt", "фраза: ${msg.phrase}")
|
||||
}
|
||||
|
||||
is SttDone -> {
|
||||
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}")
|
||||
GlassesApp.instance.listening.value = false
|
||||
GlassesApp.instance.assistantEngine.onStopListening()
|
||||
}
|
||||
|
||||
is SttCancel -> {
|
||||
when (msg.reason) {
|
||||
SttCancel.REASON_CANCEL -> {
|
||||
// Явная отмена диктовки двойным тапом: очки САМИ уже вернулись
|
||||
// в «слушаю» по жесту (TZ-ui-four п.1: отмена не закрывает чат,
|
||||
// оверлей остаётся, микрофон готов к следующей записи). Это всего
|
||||
// подтверждение, что телефон отменил STT-сессию (stt.reset()) —
|
||||
// состояния (listening/thinking/оверлей) НЕ трогаем.
|
||||
log("stt", "отмена диктовки (двойной тап) — остался в «слушаю», оверлей на месте")
|
||||
// Явная отмена диктовки двойным тапом: очки САМИ уже сделали стоп
|
||||
// (POP кадра «слушаю» с ОСТАВАНИЕМ в ассистенте — микрофон закрыт,
|
||||
// чат не прерван, оверлей на месте). Это всего подтверждение, что
|
||||
// телефон отменил STT-сессию (stt.reset()) — состояния не трогаем.
|
||||
log("stt", "отмена диктовки (двойной тап) — стоп, остался в ассистенте, оверлей на месте")
|
||||
}
|
||||
SttCancel.REASON_TIMEOUT -> {
|
||||
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
|
||||
@@ -397,11 +396,10 @@ class HostConnection(
|
||||
}
|
||||
else -> {
|
||||
// reason="user" — пустой клик без голоса: не выходим из режима,
|
||||
// возвращаемся в «слушаю» (микрофон снова открыт).
|
||||
log("stt", "отменено — возврат в «слушаю»")
|
||||
GlassesApp.instance.thinking.value = false
|
||||
GlassesApp.instance.mainActivity?.resumeSttListening()
|
||||
?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон")
|
||||
// стоп-записи с остаованием в ассистенте (idle), «не расслышал».
|
||||
log("stt", "отменено — стоп, остаюсь в ассистенте (не расслышал)")
|
||||
GlassesApp.instance.mainActivity?.stopSttListening()
|
||||
?: log("stt", "mainActivity ещё нет — не могу сделать стоп")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,17 +49,14 @@ import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
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.assistantMode
|
||||
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
||||
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
||||
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
||||
import pw.binom.viewmate.glasses.ui.Binocular
|
||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
||||
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.px
|
||||
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
||||
@@ -89,10 +86,10 @@ class MainActivity : BaseEventActivity() {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
log("ui", "SCREEN_OFF receiver")
|
||||
val was = GlassesApp.instance.movieController.pauseIfPlaying()
|
||||
val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
|
||||
resumeAfterScreenOn = resumeAfterScreenOn || was
|
||||
logEvent("SCREEN_OFF: pause wasPlaying=$was")
|
||||
GlassesApp.instance.movieController.ensureWifiLock()
|
||||
GlassesApp.instance.rootLayer.controller.ensureWifiLock()
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
if (was) log("ui", "экран погас (receiver) — пауза")
|
||||
}
|
||||
@@ -101,7 +98,7 @@ class MainActivity : BaseEventActivity() {
|
||||
logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn")
|
||||
if (resumeAfterScreenOn) {
|
||||
resumeAfterScreenOn = false
|
||||
GlassesApp.instance.movieController.resume()
|
||||
GlassesApp.instance.rootLayer.controller.resume()
|
||||
sendPositionWithRetries()
|
||||
log("ui", "экран включён (receiver) — продолжаю")
|
||||
}
|
||||
@@ -119,24 +116,10 @@ class MainActivity : BaseEventActivity() {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
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()
|
||||
}
|
||||
// Layout-стек (LIFO): персистентный менеджер (GlassesApp.layoutManager) композитит
|
||||
// все слои снизу вверх (кино → затемнение → чат → слушаю). Триггер рекомпозиции —
|
||||
// стек менеджера (зеркало navStack, обновляется через ctx.open/close).
|
||||
LayoutStackContent(manager = GlassesApp.instance.layoutManager)
|
||||
}
|
||||
}
|
||||
hideSystemBars()
|
||||
@@ -172,16 +155,16 @@ class MainActivity : BaseEventActivity() {
|
||||
val state = dm.getDisplay(displayId).state
|
||||
logEvent("display state=$state")
|
||||
if (state == Display.STATE_OFF) {
|
||||
val was = GlassesApp.instance.movieController.pauseIfPlaying()
|
||||
val was = GlassesApp.instance.rootLayer.controller.pauseIfPlaying()
|
||||
resumeAfterScreenOn = resumeAfterScreenOn || was
|
||||
logEvent("display OFF: pause wasPlaying=$was")
|
||||
GlassesApp.instance.movieController.ensureWifiLock()
|
||||
GlassesApp.instance.rootLayer.controller.ensureWifiLock()
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
} else if (state == Display.STATE_ON) {
|
||||
logEvent("display ON: resume flag=$resumeAfterScreenOn")
|
||||
if (resumeAfterScreenOn) {
|
||||
resumeAfterScreenOn = false
|
||||
GlassesApp.instance.movieController.resume()
|
||||
GlassesApp.instance.rootLayer.controller.resume()
|
||||
sendPositionWithRetries()
|
||||
}
|
||||
}
|
||||
@@ -212,171 +195,48 @@ class MainActivity : BaseEventActivity() {
|
||||
|
||||
/**
|
||||
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
|
||||
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
|
||||
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
|
||||
* (debugReceiver, эмулятор). Жест деспачится в верхний слой стека через персистентный
|
||||
* [pw.binom.viewmate.glasses.LayoutManager] — каждый слой сам решает, что со своим жестом
|
||||
* делать (MoveLayout / AssistantLayout / ListeningLayout).
|
||||
*/
|
||||
private fun onTempleAction(action: TempleAction) {
|
||||
val app = GlassesApp.instance
|
||||
val state = AssistantState(
|
||||
listening = app.listening.value,
|
||||
thinking = app.thinking.value,
|
||||
sttText = app.sttText.value,
|
||||
assistantText = app.assistantText.value,
|
||||
)
|
||||
when (val decision = gestureDecision(action, state)) {
|
||||
GestureDecision.Ignore ->
|
||||
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
|
||||
GestureDecision.CallAssistant -> startAssistantMode()
|
||||
GestureDecision.TogglePlayback -> togglePlayback()
|
||||
GestureDecision.SendPhrase -> sendPhrase()
|
||||
GestureDecision.NewPhrase -> startNewPhrase()
|
||||
GestureDecision.CancelDictation -> cancelDictation()
|
||||
GestureDecision.ExitMode -> exitAssistantMode()
|
||||
is GestureDecision.Seek -> seekBy(decision)
|
||||
is GestureDecision.ScrollAnswer -> {
|
||||
val acc = app.answerScrollDelta.value + decision.delta
|
||||
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
|
||||
app.answerScrollDelta.value = acc
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun onTempleAction(action: TempleAction) {
|
||||
val g = action.toGesture() ?: run {
|
||||
log("gesture", "жест ${actionName(action)} вне дискретной матрицы — игнор")
|
||||
return
|
||||
}
|
||||
log("gesture", "жест: ${g}")
|
||||
GlassesApp.instance.layoutManager.dispatch(g)
|
||||
}
|
||||
|
||||
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
|
||||
private fun startAssistantMode() {
|
||||
val app = GlassesApp.instance
|
||||
if (app.listening.value) {
|
||||
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
|
||||
return
|
||||
}
|
||||
log("stt", "ассистент: слушаю")
|
||||
if (app.movieController.pauseIfPlaying()) {
|
||||
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 (кино): плей/пауза — как сегодня. */
|
||||
private fun togglePlayback() {
|
||||
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()
|
||||
}
|
||||
// Мозг ассистента (стейт/эффекты) — в движке общения [GlassesApp.assistantEngine]:
|
||||
// слои AssistantLayout / ListeningLayout упираются в него, кино — в MoveLayout.
|
||||
|
||||
/**
|
||||
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
|
||||
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
|
||||
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
|
||||
*/
|
||||
fun resumeSttListening() {
|
||||
val app = GlassesApp.instance
|
||||
app.thinking.value = false
|
||||
app.listening.value = true
|
||||
app.sttSession.start()
|
||||
log("stt", "возврат в «слушаю» (SttCancel)")
|
||||
}
|
||||
* SttCancel(reason="user") с телефона — пустой клик без голоса (старый путь StopStt).
|
||||
* Стоп-записи с ОСТАВАНИЕМ в ассистенте (idle, «не расслышал»). Реализация — в движке
|
||||
* общения ([GlassesApp.assistantEngine]).
|
||||
*/
|
||||
fun stopSttListening() = GlassesApp.instance.assistantEngine.requestSttStop()
|
||||
|
||||
/**
|
||||
* DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (TZ-ui-four п.1): возвращаемся
|
||||
* в «слушаю» — микрофон сразу готов к следующей записи, оверлей ОСТАЁТСЯ на
|
||||
* экране (даже если истории диалога ещё нет — отмена не закрывает чат).
|
||||
* Закрытие ассистента — двойной в «думаю»/«ответ виден» ([exitAssistantMode]).
|
||||
*/
|
||||
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()
|
||||
}
|
||||
/**
|
||||
* Результат локального распознавания ([GlassesApp] → [onSttResult], на главном потоке).
|
||||
* Не-пустой — POP «слушаю», ждём ответ, шлём [SttText] на телефон; пустой по клику — стоп с
|
||||
* оставанием в ассистенте; пустой после 30с-лимыта — выход из режима. Состояние/навигация —
|
||||
* в движке ([GlassesApp.assistantEngine]); отправка [SttText] — здесь (нужен [lifecycleScope]).
|
||||
*/
|
||||
fun onSttResult(text: String, auto: Boolean) {
|
||||
val toSend = GlassesApp.instance.assistantEngine.processSttResult(text, auto)
|
||||
if (toSend != null) {
|
||||
val app = GlassesApp.instance
|
||||
lifecycleScope.launch { app.hostConnection.sendToHost(SttText(toSend)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат локального распознавания ([GlassesSttSession.onResult], на главном потоке).
|
||||
* [text] — распознанный на очках текст (пустой — тишина/модель не готова), [auto] —
|
||||
* сработал 30с-лимит. Текст уходит на телефон [SttText]; пустой по клику — остаёмся
|
||||
* слушать, пустой после лимита — выход из режима.
|
||||
*/
|
||||
fun onSttResult(text: String, auto: Boolean) {
|
||||
val app = GlassesApp.instance
|
||||
app.sttText.value = text
|
||||
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"): пользователь молчит/ушёл —
|
||||
* выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection.
|
||||
*/
|
||||
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
||||
|
||||
/** Общая логика выхода из режима общения: стоп сессии + сброс состояний. */
|
||||
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()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Таймаут тишины 30с с телефона (SttCancel reason="timeout"): выход из режима общения.
|
||||
* Реализация — в движке общения ([GlassesApp.assistantEngine]).
|
||||
*/
|
||||
fun onSttTimeout() = GlassesApp.instance.assistantEngine.handleSttTimeout()
|
||||
|
||||
/**
|
||||
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
|
||||
@@ -405,15 +265,15 @@ class MainActivity : BaseEventActivity() {
|
||||
log("ui", "onPause")
|
||||
val app = GlassesApp.instance
|
||||
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
|
||||
// в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым.
|
||||
if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) {
|
||||
// в лаунчер) режим общения гарантированно закрывается — стек POP-ится до фильма.
|
||||
if (app.navStack.value.size > 1) {
|
||||
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
|
||||
finishAssistantMode("onPause — потеря фокуса, выход из режима общения")
|
||||
app.assistantEngine.exitMode("onPause — потеря фокуса, выход из режима общения")
|
||||
}
|
||||
val was = app.movieController.pauseIfPlaying()
|
||||
val was = app.rootLayer.controller.pauseIfPlaying()
|
||||
resumeAfterScreenOn = resumeAfterScreenOn || was
|
||||
logEvent("onPause: pause wasPlaying=$was")
|
||||
app.movieController.ensureWifiLock()
|
||||
app.rootLayer.controller.ensureWifiLock()
|
||||
app.hostConnection.sendPlaybackPositionNow()
|
||||
if (was) log("ui", "экран погас — пауза")
|
||||
}
|
||||
@@ -425,7 +285,7 @@ class MainActivity : BaseEventActivity() {
|
||||
logEvent("onResume: resume flag=$resumeAfterScreenOn")
|
||||
if (resumeAfterScreenOn) {
|
||||
resumeAfterScreenOn = false
|
||||
GlassesApp.instance.movieController.resume()
|
||||
GlassesApp.instance.rootLayer.controller.resume()
|
||||
sendPositionWithRetries()
|
||||
log("ui", "экран включён — продолжаю")
|
||||
}
|
||||
|
||||
+120
-49
@@ -8,34 +8,60 @@ import pw.binom.viewmate.glasses.stt.SttModelStatus
|
||||
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
|
||||
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
|
||||
*
|
||||
* @NONE — кино: ассистент не вызван.
|
||||
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true).
|
||||
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText).
|
||||
* @ANSWER — ответ виден: listening=false и assistantText непуст.
|
||||
* Навигация — ЛIFO-стек [NavFrame]: корень [NavFrame.Film] (двойным кликом не вытолкнуть),
|
||||
* сверху [NavFrame.Assistant] (чат), сверху [NavFrame.Listening] (микрофон, чат на фоне).
|
||||
* Двойной клик — POP верхнего кадра. Режим выводится из вершины стека + контента кадра
|
||||
* ассистента:
|
||||
*
|
||||
* @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). */
|
||||
data class AssistantState(
|
||||
/** Микрофон открыт (TripleClick → слушаю). */
|
||||
val listening: Boolean = false,
|
||||
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */
|
||||
/** Стек навигации, снизу вверх. Внизу всегда [NavFrame.Film] (корень). */
|
||||
val stack: List<NavFrame> = listOf(NavFrame.Film),
|
||||
/** «Думаю»: фраза отправлена, ждём ответ (ShowText). Контент кадра ассистента. */
|
||||
val thinking: Boolean = false,
|
||||
/** Накопленные фразы (SttPhrase, full). */
|
||||
/** Накопленные фразы (SttPhrase, full). Контент кадра «слушаю». */
|
||||
val sttText: String = "",
|
||||
/** Ответ ассистента (ShowText). */
|
||||
/** Ответ ассистента (ShowText). Контент кадра ассистента. */
|
||||
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=true всегда побеждает (микروفон открыт —
|
||||
* он приоритетнее «думаю»; вершина [NavFrame.Listening] всегда побеждает (микрофон открыт —
|
||||
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
|
||||
*
|
||||
* Стек: фильм (корень) → ассистент → слушаю. «Ассистент» без «слушаю»/ответа/«думаю»
|
||||
* (вершина [NavFrame.Assistant]) — idle-кадр [IDLE]: микрофон закрыт, чат не прерван.
|
||||
*/
|
||||
fun assistantMode(state: AssistantState): AssistantMode = when {
|
||||
state.listening -> AssistantMode.LISTENING
|
||||
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
|
||||
state.thinking -> AssistantMode.THINKING
|
||||
state.top == NavFrame.Listening -> AssistantMode.LISTENING
|
||||
state.top == NavFrame.Assistant -> when {
|
||||
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
|
||||
state.thinking -> AssistantMode.THINKING
|
||||
else -> AssistantMode.IDLE
|
||||
}
|
||||
else -> AssistantMode.NONE
|
||||
}
|
||||
|
||||
@@ -45,8 +71,9 @@ fun assistantMode(state: AssistantState): AssistantMode = when {
|
||||
*
|
||||
* | Режим | Клик | Двойной | Тройной | Свайпы |
|
||||
* |------------------|--------------------|-----------|---------|----------------------|
|
||||
* | Кино | плей/пауза | игнор | вызов | ±30с |
|
||||
* | Слушаю | отправить фразу | отмена | игнор | игнор |
|
||||
* | Кино (корень) | плей/пауза | игнор | вызов | ±30с |
|
||||
* | Слушаю | отправить фразу | стоп → idle | игнор | игнор |
|
||||
* | Ассистент (idle) | открыть запись | выход | запись | игнор |
|
||||
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
|
||||
*/
|
||||
sealed interface GestureDecision {
|
||||
@@ -66,14 +93,17 @@ sealed interface GestureDecision {
|
||||
data object NewPhrase : GestureDecision
|
||||
|
||||
/**
|
||||
* Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю»
|
||||
* ([afterCancelDictation]): микрофон снова готов к следующей записи,
|
||||
* оверлей остаётся на экране даже при пустой истории диалога.
|
||||
* Сторона очков: StopStt(cancel=true) на телефон.
|
||||
* Двойной в «слушаю» (стоп, POP верхнего кадра): закрыть микрофон, ОСТАТЬСЯ в ассистенте
|
||||
* (стек: фильм → ассистент, без «слушаю» сверху) — см. [AssistantState.stopListeningFrame].
|
||||
* Выхода нет: клик открывает запись снова, двойной — выход из чата. Сторона очков:
|
||||
* StopStt(cancel=true).
|
||||
*/
|
||||
data object CancelDictation : GestureDecision
|
||||
data object StopListening : GestureDecision
|
||||
|
||||
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
|
||||
/** Клик/тройной в idle-кадре ассистента: снова открыть микрофон (вниз по стеку — «слушаю»). */
|
||||
data object BeginListening : GestureDecision
|
||||
|
||||
/** Двойной в «думаю»/«ответ виден»/idle: выход из режима — закрыть оверлей, фильм на паузе. */
|
||||
data object ExitMode : GestureDecision
|
||||
|
||||
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
|
||||
@@ -87,15 +117,11 @@ sealed interface GestureDecision {
|
||||
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
|
||||
*
|
||||
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
|
||||
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
|
||||
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
|
||||
* меняем знак здесь, в одной точке).
|
||||
*/
|
||||
fun gestureDecision(
|
||||
action: TempleAction,
|
||||
state: AssistantState,
|
||||
seekStepMs: Long = 30_000,
|
||||
pageDeltaPx: Float = 200f,
|
||||
): GestureDecision = when (val mode = assistantMode(state)) {
|
||||
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
|
||||
AssistantMode.NONE -> when (action) {
|
||||
@@ -106,9 +132,30 @@ fun gestureDecision(
|
||||
?: GestureDecision.Ignore
|
||||
}
|
||||
|
||||
AssistantMode.LISTENING -> when (action) {
|
||||
is TempleAction.Click -> GestureDecision.SendPhrase
|
||||
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
|
||||
// «Слушаю» (вершина — 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
|
||||
// Стоп: 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
|
||||
}
|
||||
|
||||
@@ -119,44 +166,68 @@ fun gestureDecision(
|
||||
}
|
||||
|
||||
AssistantMode.ANSWER -> when (action) {
|
||||
// Плавной и дискретной прокрутки ответа больше нет (решение пользователя): свайпы
|
||||
// игнорируем — из-под ответа не ищем по (приостановленному) фильму и не скроллим текст.
|
||||
is TempleAction.Click -> GestureDecision.NewPhrase
|
||||
is TempleAction.DoubleClick -> GestureDecision.ExitMode
|
||||
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
|
||||
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
|
||||
is TempleAction.SlideContinuous ->
|
||||
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
|
||||
// два строка выше.
|
||||
GestureDecision.ScrollAnswer(action.delta)
|
||||
else -> GestureDecision.Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние после отмены записи ([GestureDecision.CancelDictation], двойной в «слушаю»):
|
||||
* возврат в «слушаю» — микрофон снова открыт и готов к следующей записи, отменённый
|
||||
* текст ([AssistantState.sttText]) сбросен. Диалог НЕ прерывается: оверлей остаётся
|
||||
* на экране даже когда истории диалога ещё нет (empty [AssistantState] → [overlayVisible]=true,
|
||||
* т.к. listening=true). Закрытие ассистента — отдельное явное действие:
|
||||
* двойной в «думаю»/«ответ виден» ([GestureDecision.ExitMode]).
|
||||
* Чистая функция — unit-тесты без Android.
|
||||
* Тройной клик в фильме: на стек кладём кадр ассистента (чат) и сразу кадр «слушаю»
|
||||
* (микрофон) — стек [Film, Assistant, Listening]. Чат виден на фоне. Чистая функция.
|
||||
*/
|
||||
fun afterCancelDictation(state: AssistantState): AssistantState =
|
||||
state.copy(listening = true, thinking = false, sttText = "")
|
||||
fun AssistantState.enterAssistant(): AssistantState =
|
||||
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 =
|
||||
state.listening || state.thinking || state.assistantText.isNotBlank()
|
||||
state.inAssistant || state.sttStartBlocked
|
||||
|
||||
/**
|
||||
* Заголовок оверлея (над ответом). null — только ответ.
|
||||
* - «модель не скачалась» — попытка начать диктовку отклонена (модель не READY),
|
||||
* микрофон не открыт; ниже живая строка прогресса ([modelStatusLine]);
|
||||
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
|
||||
* listening ещё true — микрофон уже закрыт);
|
||||
* - «слушаю...»/фраза — микрофон открыт;
|
||||
* - при «ответе виден» — последняя фраза как контекст (или null).
|
||||
* - «не расслышал» — стоп/отправка не поймала голоса (пустой клик);
|
||||
* - кадр ассистента (idle/думаю без ответа/ответ виден) — подсказка жестов
|
||||
* (клик — говорить, двойной — выход); чат прокручивается под заголовком.
|
||||
*/
|
||||
fun overlayHeader(state: AssistantState): String? = when {
|
||||
state.sttStartBlocked -> "модель не скачалась"
|
||||
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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
|
||||
// Индикатор |звук − видео| (мс): только когда видео играет на очках (MovieController.movieActive
|
||||
// + свежее значение от телефона); без видео — ничего не выводить.
|
||||
val diffMs = GlassesApp.instance.audioSyncDiffMs.collectAsState()
|
||||
val movieActive = GlassesApp.instance.movieController.movieActive.collectAsState()
|
||||
val movieActive = GlassesApp.instance.rootLayer.controller.movieActive.collectAsState()
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(Color(0x99000000)) // полупрозрачный чёрный
|
||||
|
||||
@@ -37,5 +37,7 @@ class GlassesAsr(
|
||||
|
||||
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
|
||||
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.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.sqrt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -22,7 +23,7 @@ interface GlassesMic {
|
||||
|
||||
/**
|
||||
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
|
||||
* локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше
|
||||
* локально (через [recognizer]) и возвращаем готовый текст — телефон больше
|
||||
* не участвует в распознавании. UX остаётся «говори → клик → текст».
|
||||
*
|
||||
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
|
||||
@@ -37,8 +38,7 @@ interface GlassesMic {
|
||||
class GlassesSttSession(
|
||||
private val filesDir: File,
|
||||
private val scope: CoroutineScope,
|
||||
private val recognizer: (FloatArray) -> String,
|
||||
private val modelReady: () -> Boolean,
|
||||
private val recognizer: GlassesRecognizer,
|
||||
private val onResult: (text: String, auto: Boolean) -> Unit,
|
||||
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
|
||||
|
||||
/** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */
|
||||
/**
|
||||
* Начать слушать: чистый буфер + микрофон + таймер автозавершения.
|
||||
* Гейт в момент ПОПЫТКИ начать сбор: если механизм ещё не готов ([GlassesRecognizer.isReady]=false),
|
||||
* микрофон НЕ открываем и PCM не накапливаем — сбор не начинается, распознавание
|
||||
* гарантированно не будет пустым «потом». Ошибку показывает UI (sttStartBlocked).
|
||||
*/
|
||||
fun start() {
|
||||
if (running) return
|
||||
if (!recognizer.isReady()) {
|
||||
log("stt", "STT-сессия очков: механизм не готов — микрофон не открываю, сбор не начинаю")
|
||||
return
|
||||
}
|
||||
running = true
|
||||
pcmLock.withLock { pcm = ByteArray(0) }
|
||||
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
|
||||
@@ -101,10 +110,33 @@ class GlassesSttSession(
|
||||
watchdog?.cancel()
|
||||
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val text = if (audio.isNotEmpty() && modelReady()) {
|
||||
val phrases = splitPhrases(trimEdges(s16leToFloat(audio)))
|
||||
phrases.joinToString(" ") { recognizer(it) }
|
||||
val text = if (audio.isNotEmpty() && recognizer.isReady()) {
|
||||
val floats = s16leToFloat(audio)
|
||||
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 {
|
||||
log("stt", "diag: pcm=${audio.size}б isReady=${recognizer.isReady()} — распознавание пропущено")
|
||||
""
|
||||
}
|
||||
withContext(Dispatchers.Main) { onResult(text, auto) }
|
||||
|
||||
@@ -186,7 +186,7 @@ class SttModelDownloader(
|
||||
val parent = File(modelsDir, f.relPath).parentFile
|
||||
parent?.mkdirs()
|
||||
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 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.overlayVisible
|
||||
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]).
|
||||
*/
|
||||
@Composable
|
||||
fun AssistantOverlay() {
|
||||
fun AssistantOverlay(engine: AssistantEngine) {
|
||||
val app = GlassesApp.instance
|
||||
val listening by app.listening.collectAsState()
|
||||
val thinking by app.thinking.collectAsState()
|
||||
val sttText by app.sttText.collectAsState()
|
||||
val assistantText by app.assistantText.collectAsState()
|
||||
val chatHistory by app.chatHistory.collectAsState()
|
||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||
// Навигационное зеркало (стек) + модель-догрузчик — в приложении; стейт ассистента — в [engine].
|
||||
val navStack by app.navStack.collectAsState()
|
||||
val thinking by engine.thinking.collectAsState()
|
||||
val sttText by engine.sttText.collectAsState()
|
||||
val assistantText by engine.assistantText.collectAsState()
|
||||
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
|
||||
val modelStatus by app.sttModel.status.collectAsState()
|
||||
// Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание
|
||||
// локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему.
|
||||
val modelLine = if (state.listening) modelStatusLine(modelStatus) else null
|
||||
// Строка статуса модели — когда микрофон открыт («слушаю») ИЛИ старт отклонён
|
||||
// (модель не скачалась, стtStartBlocked): распознавание локально, поэтому модель не
|
||||
// READY = распознавание не готово — объясняем что происходит (качается / не скачалась).
|
||||
val modelLine = if (state.listening || state.sttStartBlocked) modelStatusLine(modelStatus) else null
|
||||
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
||||
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
||||
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
||||
@@ -74,12 +86,12 @@ fun AssistantOverlay() {
|
||||
}
|
||||
|
||||
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
|
||||
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
|
||||
// аккумулятор (answerScrollDelta) общий в [engine]. Цель — чистый [scrollOffset].
|
||||
LaunchedEffect(Unit) {
|
||||
app.answerScrollDelta.collect { pending ->
|
||||
engine.answerScrollDelta.collect { pending ->
|
||||
if (pending == 0f) return@collect
|
||||
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
|
||||
app.answerScrollDelta.value = 0f
|
||||
engine.answerScrollDelta.value = 0f
|
||||
if (target != scrollState.value) {
|
||||
scrollState.animateScrollTo(target, tween(150))
|
||||
}
|
||||
@@ -114,7 +126,9 @@ private fun overlayBox(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
// Полупрозрачный: затемнённый фильм (DimScrim снизу) просвечивает позади чата —
|
||||
// «чат виден на фоне». Чат сам по себе тёмный, читаемость не падает.
|
||||
.background(Color.Black.copy(alpha = 0.88f)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -127,7 +141,7 @@ private fun overlayBox(
|
||||
) {
|
||||
AssistantOverlayContent(state, chatHistory, scrollState, modelLine)
|
||||
|
||||
if (state.listening) {
|
||||
if (state.listening && !state.sttStartBlocked) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.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("ожидал тот же экземпляр контекста")
|
||||
}
|
||||
}
|
||||
+155
-80
@@ -18,34 +18,43 @@ import pw.binom.viewmate.core.MediaCommand
|
||||
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 {
|
||||
|
||||
private val movie = AssistantState()
|
||||
private val listening = AssistantState(listening = true)
|
||||
private val thinking = AssistantState(thinking = true)
|
||||
private val answer = AssistantState(assistantText = "ответ")
|
||||
// Стек-состояния (вершина — текущий кадр). Корень всегда [NavFrame.Film].
|
||||
private val movie = AssistantState() // [Film]
|
||||
private val idle = AssistantState(stack = listOf(NavFrame.Film, NavFrame.Assistant))
|
||||
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
|
||||
fun movieStateIsNone() {
|
||||
assertEquals(AssistantMode.NONE, assistantMode(movie))
|
||||
}
|
||||
|
||||
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */
|
||||
/** Вершина Listening всё побеждает — жесты идут по матрице «слушаю». */
|
||||
@Test
|
||||
fun listeningWinsOverEverything() {
|
||||
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а")
|
||||
fun listeningTopWins() {
|
||||
val s = listening.copy(thinking = true, sttText = "ф", assistantText = "а")
|
||||
assertEquals(AssistantMode.LISTENING, assistantMode(s))
|
||||
}
|
||||
|
||||
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */
|
||||
/** «Ответ виден»: вершина Assistant, assistantText непуст — даже при thinking. */
|
||||
@Test
|
||||
fun answerWinsOverThinking() {
|
||||
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а")))
|
||||
assertEquals(
|
||||
AssistantMode.ANSWER,
|
||||
assistantMode(idle.copy(thinking = true, assistantText = "а")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -53,7 +62,12 @@ class AssistantGesturesTest {
|
||||
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
|
||||
}
|
||||
|
||||
// ————— кино ( NONE) —————
|
||||
@Test
|
||||
fun idleWhenAssistantTopNoContent() {
|
||||
assertEquals(AssistantMode.IDLE, assistantMode(idle))
|
||||
}
|
||||
|
||||
// ————— кино (вершина Film) —————
|
||||
|
||||
@Test
|
||||
fun movieMatrix() {
|
||||
@@ -78,12 +92,12 @@ class AssistantGesturesTest {
|
||||
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
|
||||
}
|
||||
|
||||
// ————— слушаю —————
|
||||
// ————— слушаю (вершина Listening) —————
|
||||
|
||||
@Test
|
||||
fun listeningMatrix() {
|
||||
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(slideForward(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
|
||||
@@ -91,7 +105,16 @@ class AssistantGesturesTest {
|
||||
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
|
||||
fun thinkingMatrix() {
|
||||
@@ -102,27 +125,87 @@ class AssistantGesturesTest {
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
|
||||
}
|
||||
|
||||
// ————— «ответ виден» —————
|
||||
|
||||
@Test
|
||||
fun answerMatrix() {
|
||||
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
|
||||
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer))
|
||||
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer))
|
||||
// Плавный: накопленная дельта шлейфа, знак — как пришёл.
|
||||
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer))
|
||||
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer))
|
||||
// Скролла ответа больше нет (решение пользователя: в диалоге ассистента не скроллим) —
|
||||
// все свайпы (дискретные и плавный шлейф) игнорируются.
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), answer))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), answer))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(12.5f), answer))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(-7f), answer))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
|
||||
}
|
||||
|
||||
// ————— стек-переходы —————
|
||||
|
||||
/** Тройной клик в кино: кладём «ассистент» и сразу «слушаю» — [Film, Assistant, Listening]. */
|
||||
@Test
|
||||
fun answerPageDeltaIsParameter() {
|
||||
assertEquals(
|
||||
GestureDecision.ScrollAnswer(80f),
|
||||
gestureDecision(slideForward(), answer, pageDeltaPx = 80f),
|
||||
)
|
||||
fun enterAssistantPushesBothFrames() {
|
||||
val s = movie.enterAssistant()
|
||||
assertEquals(listOf(NavFrame.Film, NavFrame.Assistant, NavFrame.Listening), s.stack)
|
||||
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() {
|
||||
var s = movie
|
||||
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
|
||||
s = s.copy(listening = true)
|
||||
s = s.enterAssistant()
|
||||
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(GestureDecision.NewPhrase, gestureDecision(Click(), s))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
||||
@@ -141,77 +224,42 @@ class AssistantGesturesTest {
|
||||
assertEquals(AssistantMode.ANSWER, assistantMode(s))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
||||
// Выход: оверлей невидим, кино снова управляется кликом.
|
||||
val exited = s.copy(assistantText = "")
|
||||
val exited = s.exitToFilm()
|
||||
assertFalse(overlayVisible(exited))
|
||||
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
|
||||
}
|
||||
|
||||
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */
|
||||
/** Корень «фильм»: двойным кликом попу нельзя; вход в ассистент — Triple. */
|
||||
@Test
|
||||
fun cancelStaysInConversation() {
|
||||
val decision = gestureDecision(DoubleClick(), listening)
|
||||
assertEquals(GestureDecision.CancelDictation, decision)
|
||||
// После отмены (в т.ч. С первой записи, когда истории диалога ещё нет)
|
||||
// возврат в «слушаю»: оверлей остаётся на экране, выхода нет.
|
||||
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)
|
||||
fun filmRootCannotBePopped() {
|
||||
assertEquals(AssistantMode.NONE, assistantMode(movie))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
|
||||
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
|
||||
}
|
||||
|
||||
// ————— оверлей —————
|
||||
|
||||
@Test
|
||||
fun overlayInvisibleInMovie() {
|
||||
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает).
|
||||
// Настоящее кино-состояние: стек [Film], оверлей невидим.
|
||||
assertFalse(overlayVisible(movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlayVisibleInListeningThinkingAndAnswer() {
|
||||
fun overlayVisibleInAssistantFrames() {
|
||||
assertTrue(overlayVisible(listening))
|
||||
assertTrue(overlayVisible(thinking))
|
||||
assertTrue(overlayVisible(answer))
|
||||
assertTrue(overlayVisible(idle))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thinkingHeaderIsDotDotDot() {
|
||||
assertEquals("думаю...", overlayHeader(thinking))
|
||||
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...».
|
||||
// После отправки фразы: вершина ещё Listening + thinking — тоже «думаю...».
|
||||
assertEquals(
|
||||
"думаю...",
|
||||
overlayHeader(AssistantState(listening = true, thinking = true)),
|
||||
overlayHeader(listening.copy(thinking = true)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -222,10 +270,37 @@ class AssistantGesturesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun answerHeaderIsLastPhraseOrNull() {
|
||||
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет")))
|
||||
assertNull(overlayHeader(answer))
|
||||
assertNull(overlayHeader(movie))
|
||||
fun idleHeaderHintsGestures() {
|
||||
assertEquals("клик — говорить · двойной — выход", overlayHeader(idle))
|
||||
// В ответе подсказка та же — чат прокручивается, жесты те же.
|
||||
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"
|
||||
|
||||
/** Пакет нашего очкового приложения (applicationId из app-glasses). */
|
||||
const val VIEWMATE_GLASSES_PKG = "pw.binom.viewmate.glasses"
|
||||
|
||||
private var mercury: RayNeoMercury? = null
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@@ -234,6 +237,16 @@ object MercuryBridge {
|
||||
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 = никогда. */
|
||||
suspend fun autoLockTime(time: Int): Boolean {
|
||||
val ok = mercury?.setAutoLockTime(time) ?: false
|
||||
|
||||
@@ -167,6 +167,9 @@ class PhoneApp : Application() {
|
||||
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
|
||||
|
||||
private var localLlmDownloadJob: Job? = null
|
||||
/** Пока true — движок автоповтора: после ошибки повторит скачивание через 60с. */
|
||||
private var localLlmAutoRetry = false
|
||||
|
||||
private fun initialLocalLlmState(): LocalLlmModelState =
|
||||
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
|
||||
|
||||
@@ -186,30 +189,41 @@ class PhoneApp : Application() {
|
||||
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
|
||||
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
|
||||
* Повторный вызов во время скачивания — no-op.
|
||||
* Авто-повтор: при ошибке (если [localLlmAutoRetry] не сброшен кнопкой «стоп»)
|
||||
* ждёт 60с и снова пытается, пока файла нет.
|
||||
*/
|
||||
fun downloadLocalLlmModel() {
|
||||
val current = localLlmDownloadJob
|
||||
if (current != null && current.isActive) return
|
||||
localLlmAutoRetry = true
|
||||
localLlmDownloadJob = scope.launch {
|
||||
val target = localModelFile()
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
||||
try {
|
||||
GemmaModelDownloader(target).download { percent ->
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
||||
while (localLlmAutoRetry && !localModelFile().isFile) {
|
||||
val target = localModelFile()
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
||||
try {
|
||||
GemmaModelDownloader(target).download { percent ->
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
||||
}
|
||||
_localLlmState.value = LocalLlmModelState.Ready
|
||||
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
||||
return@launch
|
||||
} catch (e: CancellationException) {
|
||||
_localLlmState.value = LocalLlmModelState.Idle
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val msg = e.message ?: e.toString()
|
||||
_localLlmState.value = LocalLlmModelState.Error(msg)
|
||||
log("llm", "не удалось скачать модель: $msg — авто-повтор через 60с")
|
||||
delay(60_000)
|
||||
}
|
||||
_localLlmState.value = LocalLlmModelState.Ready
|
||||
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
|
||||
log("llm", "не удалось скачать модель: ${e.message}")
|
||||
}
|
||||
// отменено пользователем («стоп»): [localLlmAutoRetry] сброшен, [localLlmState]=Idle
|
||||
}
|
||||
}
|
||||
|
||||
/** Остановить скачивание локальной модели (частичный файл удаляется). */
|
||||
/** Остановить скачивание локальной модели (частичный файл остаётся, автоповтор гаснет). */
|
||||
fun cancelLocalLlmDownload() {
|
||||
localLlmAutoRetry = false
|
||||
localLlmDownloadJob?.cancel()
|
||||
}
|
||||
|
||||
@@ -313,25 +327,35 @@ class PhoneApp : Application() {
|
||||
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
|
||||
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
|
||||
*/
|
||||
private val NO_LLM_ANSWER =
|
||||
"Серверный LLM не настроен (нет API-ключа в local.properties). " +
|
||||
"Переключитесь на локальный LLM (Gemma 4 E2B) — кнопкой в чате или в настройках, " +
|
||||
"либо задайте llm.apiKey и пересоберите."
|
||||
|
||||
private suspend fun assistantLoop() {
|
||||
for (phrase in assistantChannel) {
|
||||
log("assistant", "получена фраза: $phrase")
|
||||
val assistant = ensureAssistant()
|
||||
val answer: String
|
||||
if (assistant == null) {
|
||||
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
|
||||
continue
|
||||
}
|
||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||
val answer = runCatching {
|
||||
val active = _activeSessionId.value
|
||||
if (active != null) {
|
||||
assistant.processInSession(active, phrase)
|
||||
} else {
|
||||
assistant.process(phrase)
|
||||
// Серверный LLM без API-ключа: не «пропущена в никуда», а явная
|
||||
// подсказка + сохранение фразы в БД (чтобы сообщение не исчезало).
|
||||
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — не настроен, подсказка пользователю")
|
||||
answer = NO_LLM_ANSWER
|
||||
persistNoLlm(phrase)
|
||||
} else {
|
||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||
answer = runCatching {
|
||||
val active = _activeSessionId.value
|
||||
if (active != null) {
|
||||
assistant.processInSession(active, phrase)
|
||||
} else {
|
||||
assistant.process(phrase)
|
||||
}
|
||||
}
|
||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||
}
|
||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||
server.hub.broadcast(ShowText(answer))
|
||||
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
|
||||
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,
|
||||
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
|
||||
@@ -499,6 +533,13 @@ class PhoneApp : Application() {
|
||||
|
||||
scope.launch { assistantLoop() }
|
||||
|
||||
// Автодокачка локальной LLM-модели при старте, если файла нет.
|
||||
// Каскадный автоповтор (60с) до успеха или «стоп» в настройках.
|
||||
if (!localModelFile().isFile) {
|
||||
log("llm", "локальной модели нет — запускаю авто-скачивание (Gemma 4 E2B, ${Gemma4E2B.FILE_NAME})")
|
||||
downloadLocalLlmModel()
|
||||
}
|
||||
|
||||
val lastPositionAt = AtomicLong(0)
|
||||
val lastWasPlaying = AtomicBoolean(false)
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ fun ChatScreen() {
|
||||
Box {
|
||||
TextButton(onClick = { sessionMenu = true }) {
|
||||
Text(
|
||||
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…",
|
||||
text = sessions.firstOrNull { it.id == activeId }?.title?.takeIf { it.isNotBlank() } ?: "нет",
|
||||
color = PhoneAccent,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
@@ -165,7 +165,7 @@ fun ChatScreen() {
|
||||
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
|
||||
sessions.forEach { s ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(s.title, fontSize = 12.sp) },
|
||||
text = { Text(s.title.ifBlank { "нет" }, fontSize = 12.sp) },
|
||||
onClick = {
|
||||
activeId = s.id
|
||||
app.selectChatSession(s.id)
|
||||
@@ -198,12 +198,29 @@ fun ChatScreen() {
|
||||
|
||||
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
|
||||
val llmChoice by app.llmChoice.collectAsState()
|
||||
Text(
|
||||
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B"
|
||||
else "Серверный LLM · llm.binom.pw",
|
||||
color = PhoneMuted,
|
||||
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))
|
||||
|
||||
// Лента сообщений
|
||||
|
||||
@@ -201,53 +201,60 @@ private fun LlmModelSection() {
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (choice == LlmModelChoice.LOCAL) {
|
||||
when (val s = llmState) {
|
||||
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
|
||||
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProgressBar(
|
||||
percent = s.percent,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Скачивание модели… ${s.percent}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
LocalLlmModelState.Ready -> Text(
|
||||
text = "Модель готова (Gemma 4 E2B, локально)",
|
||||
color = PhoneGreen,
|
||||
// Индикатор локальной модели — независимо от выбора бэкенда: пока модель
|
||||
// скачивается (в т.ч. авто) или упало, видно всегда, а не только в ветке LOCAL.
|
||||
when (val s = llmState) {
|
||||
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Локальная модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Ошибка скачивания: ${s.message}",
|
||||
color = PhoneRed,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
} else {
|
||||
|
||||
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProgressBar(
|
||||
percent = s.percent,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Скачивание локальной модели… ${s.percent}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
LocalLlmModelState.Ready -> Text(
|
||||
text = "Локальная модель готова (Gemma 4 E2B)",
|
||||
color = PhoneGreen,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
|
||||
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Ошибка скачивания: ${s.message}",
|
||||
color = PhoneRed,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
}
|
||||
|
||||
// Подпись серверного бэкенда — когда выбран сервер и локальная модель не в пути/ошибке.
|
||||
if (
|
||||
choice == LlmModelChoice.REMOTE &&
|
||||
llmState !is LocalLlmModelState.Downloading &&
|
||||
llmState !is LocalLlmModelState.Error
|
||||
) {
|
||||
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.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmModelState
|
||||
import pw.binom.viewmate.phone.glassesLivePercent
|
||||
|
||||
/** Экран 3 — Статус очков + пульт. */
|
||||
@@ -61,6 +62,7 @@ fun GlassesScreen(
|
||||
val playing by app.state.playingFlow.collectAsState()
|
||||
val mode by app.state.modeFlow.collectAsState()
|
||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||
val llmState by app.localLlmState.collectAsState()
|
||||
val durationMs = currentItem?.RunTimeTicks?.div(10_000)
|
||||
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.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 = "Яркость: $brightness%",
|
||||
|
||||
Reference in New Issue
Block a user