diff --git a/TASK-glasses-scroll-fix.md b/TASK-glasses-scroll-fix.md new file mode 100644 index 0000000..79dba59 --- /dev/null +++ b/TASK-glasses-scroll-fix.md @@ -0,0 +1,49 @@ +# TASK-glasses-scroll-fix.md — фикс скролла диалога на очках + пауза звука телефона + +## Контекст +После касяков 1–3 (коммит 706cc10) тест на железе выявил два новых дефекта (отчёт 23.08 21:02-21:03): + +**Дефект А (глаза скроллят по-разному + «всё зависло»):** +Пользователь: «текст будто бы по-разному скроллится... скролл по-разному работал для левого и правого глаза. мб ошибка связана с тем, что текст рисуется дважды (для каждого глаза)». +Корень (установлен расследованием): +- `Binocular()` (app-glasses/.../ui/ViewUtils.kt) вызывает content **ДВАЖДЫ**: левый глаз — `PositionedBox(x=0)` с `BinocularScreenSide=LEFT`, правый — `PositionedBox(x=parentWidth)` с `BinocularScreenSide=RIGHT`. +- `AssistantOverlayContent` (app-glasses/.../ui/AssistantOverlay.kt) внутри этого контента создаёт: + - `rememberScrollState()` — **два независимых экземпляра** (по одному на глаз); + - `LaunchedEffect(chatHistory.size, state.assistantText) { animateScrollTo(maxValue) }` — **два экземпляра**, анимируют каждый СВОЙ scrollState; + - `LaunchedEffect(Unit) { app.answerScrollDelta.collect { ... } }` — **два коллектора** на один общий `answerScrollDelta`: первый читает дельту и обнуляет аккумулятор, второй в это же время видит 0 или уже обработанную дельту → скролл расходится между глазами, автоскролл и свайпы дерутся → визуальная «каша» и ощущение зависания. + +**Дефект Б (звук на телефоне после паузы на очках):** +Пользователь: «я нажал три раза (вызвать ассистента). На очках фильм остановился, а на телефоне ещё какое-то время звук шёл». +Корень: `startAssistantMode()` (app-glasses/.../MainActivity.kt, ~строка 251) вызывает `movieController.pauseIfPlaying()` (пауза ExoPlayer очков), но **не вызывает `hostConnection.sendPlaybackPositionNow()`**. Телефон ставит аудио-плеер на паузу ТОЛЬКО по `PlaybackPosition` (`PhoneApp.onPlaybackPosition → audioSync.sync`), а статус шлётся раз в ~5 секунд. В `onPause`/`SCREEN_OFF`/`display OFF` очки такой вызов делают — в `startAssistantMode` забыли. + +## Правки (только app-glasses; всё остальное НЕ трогать) + +### 1. AssistantOverlay.kt — один scrollState и один коллектор на оба глаза +Сейчас: `AssistantOverlay()` → `overlayBox(app, state, chatHistory)` → `Binocular { ... AssistantOverlayContent(state, chatHistory) }`, а `AssistantOverlayContent` сама создаёт scrollState и LaunchedEffect'ы — поэтому они дублируются на каждый глаз. + +Требование: **поднять состояние и эффекты на уровень ВЫШЕ Binocular** (в `AssistantOverlay()`), чтобы они существовали в единственном экземпляре: +1. В `AssistantOverlay()` после проверки `overlayVisible`: + - `val scrollState = rememberScrollState()` — ОДИН экземпляр на оба глаза; + - ОДИН `LaunchedEffect(chatHistory.size, assistantText)` — автоскролл к концу: `if (hasHistory || assistantText.isNotBlank()) scrollState.animateScrollTo(scrollState.maxValue)`; + - ОДИН `LaunchedEffect(Unit)` — коллектор `app.answerScrollDelta`: логика как сейчас (`scrollOffset`, обнуление аккумулятора, `animateScrollTo(target, tween(150))`). +2. `AssistantOverlayContent` сделать **чистой отрисовкой**: параметры `(state, chatHistory, scrollState)`, ВНУТРИ НЕ создавать ни rememberScrollState, ни LaunchedEffect'ов, ни коллекторов. Только Column + header + список `chatHistory` (как сейчас: «я: ...» белым / «ассистент: ...» `0xFFB0BEC5`, verticalScroll(scrollState)) + fallback на `assistantText` при пустой истории. +3. `overlayBox` — передавать `scrollState` в `AssistantOverlayContent` (сигнатуры поправить). + +Проверка логики: в `Binocular` контент вызывается дважды, но scrollState/LaunchedEffect теперь живут вне него — оба глаза рисуют одно и то же состояние, гонки нет. + +### 2. MainActivity.kt — мгновенная отправка позиции при вызове ассистента +В `startAssistantMode()` после `app.movieController.pauseIfPlaying()` добавить: +```kotlin +GlassesApp.instance.hostConnection.sendPlaybackPositionNow() +``` +(по образцу `onPause`/`SCREEN_OFF`: пауза + немедленный статус, чтобы телефон тут же поставил звук на паузу). + +### 3. Сборка, тесты, коммит +- `./gradlew :lib-core:build :app-glasses:assembleDebug :app-phone:assembleDebug` — BUILD SUCCESSFUL, тесты проходят. +- `git add -A && git commit -m 'Очки: единый скролл диалога на оба глаза + мгновенная пауза звука телефона при вызове ассистента'` (БЕЗ push). + +## КРИТИЧЕСКИЕ ПРАВИЛА +- workspace = /root/WORK/view-mate. Пути ТОЛЬКО относительные от корня (app-glasses/...). НИКОГДА /root/WORK/app-glasses (без view-mate) — external_directory, permission-guard отклонит. +- НЕ трогать: app-phone, lib-core, LocalLlm.kt (GPU), скорость, клавиатуру, автоименование, жесты (AssistantGestures.kt), матрицу режимов. +- Коммит одного файла оверлея + одного метода MainActivity. +- В конце — краткий отчёт: файлы, что изменено, результат сборки/тестов. diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index cc6f49d..4030b34 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -258,6 +258,7 @@ class MainActivity : BaseEventActivity() { if (app.movieController.pauseIfPlaying()) { log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода") } + GlassesApp.instance.hostConnection.sendPlaybackPositionNow() app.listening.value = true app.thinking.value = false app.sttText.value = "" diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt index 4e4b13a..d53d634 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme @@ -49,12 +50,43 @@ fun AssistantOverlay() { val chatHistory by app.chatHistory.collectAsState() val state = AssistantState(listening, thinking, sttText, assistantText) if (!overlayVisible(state)) return - overlayBox(app, state, chatHistory) + // Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды + // (левый/правый глаз), поэтому scrollState и коллекторы должны существовать + // в единственном экземпляре — иначе глаза скроллят по-разному. + val scrollState = rememberScrollState() + val hasHistory = chatHistory.isNotEmpty() + + // Новый ответ (ShowText) или пополнившаяся история — к последнему сообщению. + LaunchedEffect(chatHistory.size, state.assistantText) { + if (hasHistory || state.assistantText.isNotBlank()) { + scrollState.animateScrollTo(scrollState.maxValue) + } + } + + // Накопленная дельта скролла от жестов. Коллектор один на оба глаза; + // аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset]. + LaunchedEffect(Unit) { + app.answerScrollDelta.collect { pending -> + if (pending == 0f) return@collect + val target = scrollOffset(scrollState.value, pending, scrollState.maxValue) + app.answerScrollDelta.value = 0f + if (target != scrollState.value) { + scrollState.animateScrollTo(target, tween(150)) + } + } + } + + overlayBox(app, state, chatHistory, scrollState) } /** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */ @Composable -private fun overlayBox(app: GlassesApp, state: AssistantState, chatHistory: List) { +private fun overlayBox( + app: GlassesApp, + state: AssistantState, + chatHistory: List, + scrollState: ScrollState, +) { Binocular(scale = false) { val scale by app.scale.collectAsState() val screenWidthPx = BinocularScreenSize.current.width @@ -69,38 +101,20 @@ private fun overlayBox(app: GlassesApp, state: AssistantState, chatHistory: List .offset(x = scaleOffsetX(side, screenWidthPx, scale).px) .background(Color.Black.copy(alpha = 0.72f)), ) { - AssistantOverlayContent(state, chatHistory) + AssistantOverlayContent(state, chatHistory, scrollState) } } } @Composable -private fun AssistantOverlayContent(state: AssistantState, chatHistory: List) { - val app = GlassesApp.instance +private fun AssistantOverlayContent( + state: AssistantState, + chatHistory: List, + scrollState: ScrollState, +) { val hasHistory = chatHistory.isNotEmpty() val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank() val header = overlayHeader(state) - val scrollState = rememberScrollState() - - // Новый ответ (ShowText) или пополнившаяся история — к последнему сообщению. - LaunchedEffect(chatHistory.size, state.assistantText) { - if (hasHistory || state.assistantText.isNotBlank()) { - scrollState.animateScrollTo(scrollState.maxValue) - } - } - - // Накопленная дельта скролла от жестов: каждый глаз — свой [LaunchedEffect], - // аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset]. - LaunchedEffect(Unit) { - app.answerScrollDelta.collect { pending -> - if (pending == 0f) return@collect - val target = scrollOffset(scrollState.value, pending, scrollState.maxValue) - app.answerScrollDelta.value = 0f - if (target != scrollState.value) { - scrollState.animateScrollTo(target, tween(150)) - } - } - } Column( modifier = Modifier