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
@@ -2,6 +2,7 @@ package pw.binom.viewmate.glasses
import android.content.Context
import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Handler
import android.os.Looper
import com.google.android.exoplayer2.MediaItem
@@ -31,6 +32,31 @@ internal data class ContentId(
val audioIndex: Int = 0,
)
/** Порог «убегания» позиции вперёд: больше этого — откат к последней реальной. */
internal const val POSITION_DRIFT_THRESHOLD_MS = 5_000L
/**
* Чистая функция: если [currentMs] ушёл вперёд от [lastGoodMs] больше чем на
* порог (гашение экрана ExoPlayer набегает позицию по системным часам) — цель
* отката [lastGoodMs], иначе null (позиция реальная — оставляем).
*/
internal fun rollbackTargetMs(currentMs: Long, lastGoodMs: Long): Long? =
if (lastGoodMs > 0 && currentMs - lastGoodMs > POSITION_DRIFT_THRESHOLD_MS) lastGoodMs else null
/** Порог скачка позиции вперёд за один 5с-цикл: больше — мусор ExoPlayer (игнор). */
internal const val POSITION_JUMP_THRESHOLD_MS = 30_000L
/**
* Чистая функция: защита markPosition от мусорных скачков. При гашении экрана
* ExoPlayer «врёт» позицию (могла прыгнуть за 5с на 42 мин). Если новая
* позиция ушла вперёд от последней достоверной больше чем на порог — игнор
* (возвращает [lastGoodMs]), иначе позиция достоверна ([positionMs]).
* [trusted] — позиция задана пользователем (seek): достоверна всегда,
* защита от мусорных скачков отключается.
*/
internal fun markPositionTargetMs(positionMs: Long, lastGoodMs: Long, trusted: Boolean = false): Long =
if (!trusted && lastGoodMs > 0 && positionMs - lastGoodMs > POSITION_JUMP_THRESHOLD_MS) lastGoodMs else positionMs
/** Чистая функция: PlayVideo запоминает itemId/audioIndex. */
internal fun contentIdOnPlayVideo(msg: PlayVideo): ContentId = ContentId(msg.itemId, msg.audioIndex)
@@ -65,6 +91,36 @@ class MovieController(private val context: Context) {
private val mainHandler = Handler(Looper.getMainLooper())
/** Колбэк диагностических событий (markPosition/rollback/resume/WifiLock) — пишется в events.log извне. */
var eventLogger: ((String) -> Unit)? = null
/**
* Последняя РЕАЛЬНАЯ позиция (из 5с-цикла отправки, когда играем). При
* resume откатываем плеер, если текущая позиция «убежала» далеко вперёд
* (гашение экрана ExoPlayer набегает позицию по системным часам).
* @Volatile — пишется из IO-цикла HostConnection, читается на главном.
*/
@Volatile
private var lastGoodPositionMs: Long = 0
/**
* WifiLock удерживает WiFi-радио очков в активном состоянии, пока идёт
* просмотр. Без него при гашении экрана радио уходит в сон, и пауза/
* resume-пакеты лежат в стеке ~15с, пока радио не проснётся.
*/
private var wifiLock: WifiManager.WifiLock? = null
private fun releaseWifiLock() {
wifiLock?.let {
if (it.isHeld) {
it.release()
eventLogger?.invoke("WifiLock released")
log("glasses", "WifiLock released")
}
}
wifiLock = null
}
fun playVideo(msg: PlayVideo) {
mainHandler.post {
// Стриминг отключён полностью: играем ТОЛЬКО локальный файл file://, который есть на диске.
@@ -89,6 +145,30 @@ class MovieController(private val context: Context) {
player.seekTo(msg.startPositionMs)
player.playWhenReady = true
_movieActive.value = true
ensureWifiLock()
}
}
/**
* Гарантировать WifiLock: создать, если ещё нет, либо повторно захватить,
* если система его сбросила (isHeld=false). Вызывается при старте просмотра
* и в точках паузы при гашении экрана (пауза/resume должны уходить без
* задержек). Lock создаётся только при активном просмотре.
*/
fun ensureWifiLock() {
if (!_movieActive.value) return
if (wifiLock == null) {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate-playback").also {
it.setReferenceCounted(false)
it.acquire()
}
eventLogger?.invoke("WifiLock acquired")
log("glasses", "WifiLock acquired (просмотр активен)")
} else if (!wifiLock!!.isHeld) {
wifiLock!!.acquire()
eventLogger?.invoke("WifiLock re-acquired")
log("glasses", "WifiLock re-acquired (не был удержан)")
}
}
@@ -110,16 +190,19 @@ class MovieController(private val context: Context) {
MediaCommand.SEEK_FORWARD -> {
val target = player.currentPosition + seekMs
player.seekTo(target)
markPosition(player.currentPosition, trusted = true)
}
MediaCommand.SEEK_BACKWARD -> {
val target = (player.currentPosition - seekMs).coerceAtLeast(0L)
player.seekTo(target)
markPosition(player.currentPosition, trusted = true)
}
MediaCommand.SEEK -> {
val target = seekTargetMs(seekMs, player.duration)
player.seekTo(target)
markPosition(player.currentPosition, trusted = true)
}
// Звук — на телефоне, переключение аудио-дорожек очкам не нужно.
@@ -142,6 +225,7 @@ class MovieController(private val context: Context) {
player.stop()
player.release()
}
releaseWifiLock()
_movieActive.value = false
contentId = contentIdOnClose()
_title.value = null
@@ -168,6 +252,30 @@ class MovieController(private val context: Context) {
log("glasses", "resume проигнорирован: плеер не активен")
return
}
val current = player.currentPosition
rollbackTargetMs(current, lastGoodPositionMs)?.let { target ->
log("glasses", "позиция убежала: $current → откат к $target")
eventLogger?.invoke("rollback: current=$current → lastGood=$lastGoodPositionMs")
player.seekTo(target)
} ?: eventLogger?.invoke("resume ok: pos=$current lastGood=$lastGoodPositionMs")
player.play()
}
/**
* Запомнить последнюю реальную позицию — вызывается из 5с-цикла отправки
* PlaybackPosition, ТОЛЬКО когда играем. Потокобезопасно (@Volatile).
* Скачок вперёд >30с за один цикл — мусор ExoPlayer при гашении экрана:
* игнорируется, lastGood остаётся последней достоверной.
* [trusted] — позиция задана пользователем (seek через трекбар): считается
* достоверной сразу, чтобы rollback при resume откатил к ней, а не к началу.
*/
fun markPosition(positionMs: Long, trusted: Boolean = false) {
val target = markPositionTargetMs(positionMs, lastGoodPositionMs, trusted)
if (target != positionMs) {
eventLogger?.invoke("markPosition: скачок $lastGoodPositionMs → $positionMs (игнор)")
return
}
lastGoodPositionMs = target
eventLogger?.invoke("markPosition=$positionMs")
}
}