Очки: единый скролл диалога на оба глаза + мгновенная пауза звука телефона при вызове ассистента
This commit is contained in:
@@ -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.
|
||||||
|
- В конце — краткий отчёт: файлы, что изменено, результат сборки/тестов.
|
||||||
@@ -258,6 +258,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
if (app.movieController.pauseIfPlaying()) {
|
if (app.movieController.pauseIfPlaying()) {
|
||||||
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
||||||
}
|
}
|
||||||
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||||
app.listening.value = true
|
app.listening.value = true
|
||||||
app.thinking.value = false
|
app.thinking.value = false
|
||||||
app.sttText.value = ""
|
app.sttText.value = ""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -49,12 +50,43 @@ fun AssistantOverlay() {
|
|||||||
val chatHistory by app.chatHistory.collectAsState()
|
val chatHistory by app.chatHistory.collectAsState()
|
||||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||||
if (!overlayVisible(state)) return
|
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-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun overlayBox(app: GlassesApp, state: AssistantState, chatHistory: List<ChatEntry>) {
|
private fun overlayBox(
|
||||||
|
app: GlassesApp,
|
||||||
|
state: AssistantState,
|
||||||
|
chatHistory: List<ChatEntry>,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
) {
|
||||||
Binocular(scale = false) {
|
Binocular(scale = false) {
|
||||||
val scale by app.scale.collectAsState()
|
val scale by app.scale.collectAsState()
|
||||||
val screenWidthPx = BinocularScreenSize.current.width
|
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)
|
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
||||||
.background(Color.Black.copy(alpha = 0.72f)),
|
.background(Color.Black.copy(alpha = 0.72f)),
|
||||||
) {
|
) {
|
||||||
AssistantOverlayContent(state, chatHistory)
|
AssistantOverlayContent(state, chatHistory, scrollState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AssistantOverlayContent(state: AssistantState, chatHistory: List<ChatEntry>) {
|
private fun AssistantOverlayContent(
|
||||||
val app = GlassesApp.instance
|
state: AssistantState,
|
||||||
|
chatHistory: List<ChatEntry>,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
) {
|
||||||
val hasHistory = chatHistory.isNotEmpty()
|
val hasHistory = chatHistory.isNotEmpty()
|
||||||
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
|
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
|
||||||
val header = overlayHeader(state)
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
Reference in New Issue
Block a user