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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user