Ассистент Порфирий: тула времени, сессии, чат-UI, голосовой цикл; BT-транспорт очки↔телефон
- BT (RFCOMM SPP 00001105) вместо WiFi: BtGlassesTransport/BtServerTransport, реконнект 3с - PlaybackService (foreground, WAKE_LOCK) против морозов HONOR - Ассистент: AgentTools (TimeTool ISO 8601), TOOL_CALL-протокол, сессии SQLite (UUID) - Чат-UI: создание/сброс/переключение сессий, автообновление ленты 2с - LlmClient на прямом OkHttp (Koog давал пустой content на длинных историях) - Голосовой цикл: тройной тап на очках → STT → активная сессия → ShowText - Тесты: AgentToolsTest (3), GlassesServerTest переписан (6) — зелёные
This commit is contained in:
@@ -1,106 +1,132 @@
|
||||
# Задача: масштаб на всех экранах очков + сохранение скролла каталога
|
||||
# Задача: телефон не должен засыпать во время просмотра (foreground service)
|
||||
|
||||
## Баг 1: ConnectionScreen игнорирует масштаб
|
||||
## Симптом (пользователь, 21.08)
|
||||
|
||||
**Симптом (пользователь):** «в режиме НЕ просмотра видео (пока ничего не включено)
|
||||
очки игнорируют выставленный масштаб». Плюс «масштаб будто бы не сохраняется —
|
||||
сразу после старта масштаб 1».
|
||||
«Если я телефон отправлю спать, то звук на телефоне прекращается. А когда на
|
||||
телефоне прекращается звук, очки тоже перестают играть.»
|
||||
|
||||
**Корень:** масштаб (`GlassesApp.instance.scale`, 0.5..1) применяется ТОЛЬКО в
|
||||
`GlassesMovieScreen.kt` (контейнер `width((screenWidthPx * scale).toInt().px)` +
|
||||
offset к центру). А `ConnectionScreen()` в
|
||||
`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt` (строка ~354)
|
||||
рисует контент на ПОЛНУЮ половину глаза (`Modifier.width(screenWidthPx.px)`) —
|
||||
scale не читает вообще. Поэтому и «не сохраняется»: сохранённый scale в prefs есть,
|
||||
но на не-видео экране он не виден.
|
||||
## Корень (диагноз подтверждён)
|
||||
|
||||
**Фикс:** в `ConnectionScreen()` применять scale так же, как в GlassesMovieScreen:
|
||||
Архитектура: звук играет ТОЛЬКО на телефоне (у очков нет динамиков), видео —
|
||||
на очках, синк по позиции (очки — мастер, 5с-цикл). На телефоне НЕТ
|
||||
foreground service и НЕТ wake/wifi lock — приложение живёт только в Activity
|
||||
(`app-phone/src/main/AndroidManifest.xml`: только MainActivity, пермишенов
|
||||
WAKE_LOCK нет). При выключении экрана HONOR (Android 16, агрессивная фоновая
|
||||
политика) замораживает/убивает процесс:
|
||||
|
||||
1. WS-сервер (`GlassesServer`, :8080) умирает → очки теряют связь (ping 5с) →
|
||||
автоматика обрыва на очках («связь потеряна — пауза») останавливает ВИДЕО.
|
||||
2. `AudioSyncPlayer` (ExoPlayer) заморожен → ЗВУК встаёт.
|
||||
|
||||
## Решение
|
||||
|
||||
Добавить в app-phone **foreground service** типа `mediaPlayback` + WAKE_LOCK +
|
||||
WifiLock, чтобы процесс (WS-сервер + аудио-плеер) жил при выключенном экране
|
||||
телефона. Очки тогда не теряют связь, видео не паузится, звук играет.
|
||||
|
||||
### 1. Манифест (`app-phone/src/main/AndroidManifest.xml`)
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
```
|
||||
Внутри `<application>` — сервис:
|
||||
```xml
|
||||
<service
|
||||
android:name=".PlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
```
|
||||
|
||||
### 2. Сервис `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PlaybackService.kt`
|
||||
|
||||
```kotlin
|
||||
fun ConnectionScreen() {
|
||||
val connection = GlassesApp.instance.hostConnection
|
||||
val connected by connection.connected.collectAsState()
|
||||
val status by connection.status.collectAsState()
|
||||
val messages by connection.messages.collectAsState()
|
||||
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
|
||||
|
||||
Binocular(scale = false) {
|
||||
val screenWidthPx = BinocularScreenSize.current.width
|
||||
val screenHeightPx = BinocularScreenSize.current.height
|
||||
// масштаб через layout — как в GlassesMovieScreen
|
||||
val containerW = (screenWidthPx * scale).toInt()
|
||||
val containerH = (screenHeightPx * scale).toInt()
|
||||
val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(x = offsetX.px)
|
||||
.background(Color.Black),
|
||||
) {
|
||||
... существующий контент ...
|
||||
class PlaybackService : Service() {
|
||||
companion object {
|
||||
const val CHANNEL_ID = "viewmate_playback"
|
||||
const val NOTIFICATION_ID = 1
|
||||
// аккуратный запуск: ContextCompat.startForegroundService(context, intent)
|
||||
fun start(context: Context) {
|
||||
ContextCompat.startForegroundService(
|
||||
context, Intent(context, PlaybackService::class.java)
|
||||
)
|
||||
}
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, PlaybackService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// канал уведомлений (Android 8+)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, "ViewMate playback",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { setShowBadge(false) }
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||
.setContentTitle("ViewMate")
|
||||
.setContentText("Звук на телефоне при просмотре на очках")
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
||||
// держим CPU и WiFi живыми при выключенном экране
|
||||
wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
|
||||
?.apply { acquire() }
|
||||
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
|
||||
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
|
||||
?.apply { acquire() }
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||
START_NOT_STICKY
|
||||
|
||||
override fun onDestroy() {
|
||||
wakeLock?.takeIf { it.isHeld }?.release()
|
||||
wifiLock?.takeIf { it.isHeld }?.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
}
|
||||
```
|
||||
Импорты: androidx.core.app.NotificationCompat, androidx.core.content.ContextCompat.
|
||||
Check: в gradle уже есть androidx.core (проверить libs.versions.toml — ktor/compose
|
||||
тянут core-ktx, но для чистоты добавить `androidx.core:core-ktx` если нет).
|
||||
|
||||
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose,
|
||||
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
|
||||
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
|
||||
### 3. Запуск/остановка сервиса в `PhoneApp.kt`
|
||||
|
||||
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
|
||||
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
|
||||
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
|
||||
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
|
||||
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
|
||||
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
|
||||
там есть `Side.LEFT/RIGHT` через CompositionLocal).
|
||||
- **Старт — при каждом старте аудио** (3 места):
|
||||
- `autostartAudio()` (строка ~173, перед `audioSync.play(source, positionMs)`)
|
||||
- `DetailsScreen.onWatch` — там где вызывается `app.audioSync.play(source, positionMs)`
|
||||
(строка ~127) и второй play (строка ~215)
|
||||
- ОБЩИЙ подход: раз звук — сервис. Проще всего: стартовать в
|
||||
`PhoneApp.onCreate()` сразу (сервис лёгкий, уведомление висит пока
|
||||
приложение живо) — НО только если пользователь не против постоянного
|
||||
уведомления. РЕШЕНИЕ: старт в onCreate, стоп в onTerminate — сервис
|
||||
живёт пока жив телефон-компаньон (WS-сервер тоже живёт всё время),
|
||||
уведомление — постоянный индикатор «ViewMate работает». Это проще и
|
||||
покрывает и watch, и autostartAudio, и STT-сессии.
|
||||
- **Стоп**: в `onTerminate()` — `PlaybackService.stop(this)`.
|
||||
|
||||
## Баг 2: скролл каталога теряется при навигации
|
||||
Решение принято: **старт в onCreate, стоп в onTerminate** (сервис = спутник
|
||||
WS-сервера, а не аудио-сессии; уведомление постоянно, но это норма для
|
||||
приложения-компаньона — так работает, например, в паре с очками).
|
||||
|
||||
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
|
||||
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
|
||||
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
|
||||
папку, потом ещё — и т.д.»
|
||||
### 4. Проверка
|
||||
|
||||
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`:
|
||||
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
|
||||
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
|
||||
|
||||
**Фикс:** добавить в `CatalogState`:
|
||||
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
|
||||
- `private val savedScroll = mutableMapOf<Path, Pair<Int, Int>>()` — сохранённые позиции по пути
|
||||
- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path
|
||||
- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)`
|
||||
- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair<Int,Int>` (текущий path)
|
||||
|
||||
В `CatalogScreen` composable:
|
||||
```kotlin
|
||||
val gridState = rememberLazyGridState()
|
||||
// восстанавливаем позицию при смене пути/загрузке
|
||||
LaunchedEffect(state.path, state.items) {
|
||||
val (idx, off) = state.savedScrollForCurrentPath()
|
||||
gridState.scrollToItem(idx, off)
|
||||
}
|
||||
// сохраняем позицию при скролле
|
||||
LaunchedEffect(gridState) {
|
||||
snapshotFlow {
|
||||
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
|
||||
}.collect { (idx, off) -> state.setScroll(idx, off) }
|
||||
}
|
||||
```
|
||||
и `LazyVerticalGrid(state = gridState, ...)`.
|
||||
|
||||
Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется
|
||||
на родителя — достаём сохранённое. Вложенные папки работают автоматически:
|
||||
каждый уровень сохраняет свою позицию.
|
||||
|
||||
Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`,
|
||||
`androidx.compose.runtime.snapshotFlow`.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех.
|
||||
2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные.
|
||||
3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу).
|
||||
4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
|
||||
1. Сборка: `./gradlew :app-phone:assembleDebug` — успех.
|
||||
2. Юнит-тесты: `./gradlew :app-phone:testDebugUnitTest` — зелёные.
|
||||
3. На железе (через 142): установить APK, запустить, начать просмотр,
|
||||
`adb -s <phone> shell input keyevent 26` (экран телефона в сон) →
|
||||
проверить: звук играет, видео на очках играет (позиция растёт в логах),
|
||||
сервис жив: `adb shell dumpsys activity services | grep -A3 PlaybackService`.
|
||||
|
||||
Reference in New Issue
Block a user