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,6 +1,7 @@
package pw.binom.viewmate.glasses
import android.app.Application
import java.io.File
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
object GlassesConfig {
@@ -29,6 +30,7 @@ class GlassesApp : Application() {
super.onCreate()
instance = this
movieController = MovieController(applicationContext)
movieController.eventLogger = { msg -> logToFile("mc: $msg") }
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
hostConnection = HostConnection(
fallbackUrl = GlassesConfig.HOST_URL,
@@ -43,5 +45,13 @@ class GlassesApp : Application() {
companion object {
lateinit var instance: GlassesApp
private set
/** Диагностические события в filesDir/events.log (доступно из любой точки, в т.ч. MovieController). */
fun logToFile(msg: String) {
runCatching {
val f = File(instance.filesDir, "events.log")
f.appendText("${System.currentTimeMillis()} $msg\n")
}
}
}
}
@@ -78,6 +78,7 @@ class HostConnection(
delay(5_000)
client?.send(buildStatus())
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
client?.send(
PlaybackPosition(
positionMs = snapshot.positionMs,
@@ -157,6 +158,16 @@ class HostConnection(
client?.send(msg)
}
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
fun sendPlaybackPositionNow() {
val c = client ?: return
scope.launch {
val (positionMs, playing, itemId, audioIndex) =
withContext(Dispatchers.Main) { movieController.playbackPosition() }
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex))
}
}
/**
* Эмулятор (AVD/Genymotion): mDNS отсутствует, долгий поиск бессмыслен —
* таймаут сокращается до [MdnsClient.EMULATOR_TIMEOUT_MS] и быстрее
@@ -1,6 +1,15 @@
package pw.binom.viewmate.glasses
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.display.DisplayManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Display
import java.io.File
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
@@ -25,6 +34,32 @@ import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
class MainActivity : ComponentActivity() {
private var resumeAfterScreenOn = false
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
log("ui", "SCREEN_OFF receiver")
val was = GlassesApp.instance.movieController.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("SCREEN_OFF: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас (receiver) — пауза")
}
Intent.ACTION_SCREEN_ON -> {
log("ui", "SCREEN_ON receiver")
logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) {
resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume()
sendPositionWithRetries()
log("ui", "экран включён (receiver) — продолжаю")
}
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -43,25 +78,94 @@ class MainActivity : ComponentActivity() {
}
hideSystemBars()
log("ui", "иммерсивный режим включён")
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
}
registerReceiver(screenReceiver, filter)
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
dm.registerDisplayListener(object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {}
override fun onDisplayChanged(displayId: Int) {
if (displayId != Display.DEFAULT_DISPLAY) return
val state = dm.getDisplay(displayId).state
logEvent("display state=$state")
if (state == Display.STATE_OFF) {
val was = GlassesApp.instance.movieController.pauseIfPlaying()
resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("display OFF: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
} else if (state == Display.STATE_ON) {
logEvent("display ON: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) {
resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume()
sendPositionWithRetries()
}
}
}
}, null)
}
override fun onPause() {
super.onPause()
log("ui", "onPause")
val was = GlassesApp.instance.movieController.pauseIfPlaying()
resumeAfterScreenOn = was
resumeAfterScreenOn = resumeAfterScreenOn || was
logEvent("onPause: pause wasPlaying=$was")
GlassesApp.instance.movieController.ensureWifiLock()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
if (was) log("ui", "экран погас — пауза")
}
override fun onResume() {
super.onResume()
log("ui", "onResume")
hideSystemBars()
logEvent("onResume: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) {
resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume()
sendPositionWithRetries()
log("ui", "экран включён — продолжаю")
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
logEvent("focus=$hasFocus")
log("ui", "focus=$hasFocus")
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(screenReceiver)
}
/**
* Немедленная отправка позиции + повторы (0с/2с/5с): радио RayNeo просыпается
* ~5-7с после включения экрана, первый PlaybackPosition уходит только когда радио
* ожило — свежая позиция (playing=true) доедет сразу после пробуждения.
* Повторы безвредны: телефон идемпотентен.
*/
private fun sendPositionWithRetries() {
val conn = GlassesApp.instance.hostConnection
val handler = Handler(Looper.getMainLooper())
conn.sendPlaybackPositionNow()
handler.postDelayed({ conn.sendPlaybackPositionNow() }, 2_000)
handler.postDelayed({ conn.sendPlaybackPositionNow() }, 5_000)
}
private fun logEvent(msg: String) {
runCatching {
val f = File(filesDir, "events.log")
f.appendText("${System.currentTimeMillis()} $msg\n")
}
}
private fun hideSystemBars() {
window.decorView.post {
WindowInsetsControllerCompat(window, window.decorView).apply {
@@ -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")
}
}