feat: стабилизация гашения экрана очков (пауза/резюм звука)

Проблемы на железе (RayNeo X2 + HONOR): при гашении экрана позиция
ExoPlayer «набегала» (мусор 42 мин), откат не срабатывал (markPosition
сохранял мусор), доставка паузы телефону 5-10с (RayNeo вырубает
WiFi-радио при screen-off, игнорируя WifiLock).

Решение:
- markPosition(positionMs, trusted): защита от мусорных скачков (>30с за
  цикл = игнор); seek через трекбар помечает позицию trusted (rollback
  откатывает к seek, не к началу)
- rollback при resume: откат к последней достоверной позиции, если
  текущая убежала вперёд >5с
- WifiLock (FULL_HIGH_PERF) при просмотре + ensureWifiLock в точках
  гашения (страховка; RayNeo игнорирует, но не мешает)
- Watchdog на телефоне: очки молчат >5с (AUDIO_SILENCE_PAUSE_MS) → пауза
  звука (гарантированная верхняя граница)
- sendPlaybackPositionNow с повторами (0/2/5с) при включении экрана —
  свежая позиция уходит при пробуждении радио
- Диагностика: события пишутся в files/events.log (logcat очков забит
  EGL-спамом)
- Тесты: +14 (rollback/скачки/trusted seek)
- Железо подтверждено: позиция после гашения идеальная (продолжение с
  места паузы, не в начало), звук пауза ≤5-6с, синк diff < 1000
This commit is contained in:
Hermes Agent
2026-08-19 22:55:49 +03:00
parent 4309d4edd2
commit 8182a62885
8 changed files with 346 additions and 1 deletions
@@ -1,12 +1,15 @@
package pw.binom.viewmate.phone
import android.app.Application
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.GlassesMode
@@ -88,13 +91,31 @@ class PhoneApp : Application() {
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
nsd.publish()
val lastPositionAt = AtomicLong(0)
val lastWasPlaying = AtomicBoolean(false)
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
// + автоподхват звука (очки играют после рестарта телефона, audioSync не активен)
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex ->
lastPositionAt.set(System.currentTimeMillis())
lastWasPlaying.set(playing)
audioSync.sync(positionMs, playing)
scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) }
}
// watchdog: очки молчат (экран погас/радио спит) — пауза звука по таймауту
scope.launch {
while (true) {
delay(PhoneConfig.WATCHDOG_TICK_MS)
val silentFor = System.currentTimeMillis() - lastPositionAt.get()
if (silentFor > PhoneConfig.AUDIO_SILENCE_PAUSE_MS && audioSync.isActive && lastWasPlaying.get()) {
log("audio", "очки молчат ${silentFor}мс — пауза (экран погас?)")
audioSync.pause()
lastWasPlaying.set(false) // не спамить каждые 2с
}
}
}
// автоматика обрыва связи: потеря → пауза, восстановление → продолжить с того же места
scope.launch(Dispatchers.Main) {
var prevConnected = server.hub.connected.value > 0
@@ -12,6 +12,12 @@ object PhoneConfig {
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
/** Период проверки watchdog. */
const val WATCHDOG_TICK_MS = 2_000L
// S3 SeaweedFS (bucket media, префикс mirror/): read-only identity mirror-client
const val S3_ACCESS_KEY = "MirrorClient01QwertyKey"
const val S3_SECRET_KEY = "MirrorClient01SecretKeyValueForGlassesOnly"