diff --git a/app-glasses/src/main/AndroidManifest.xml b/app-glasses/src/main/AndroidManifest.xml index 3164e3f..f14276a 100644 --- a/app-glasses/src/main/AndroidManifest.xml +++ b/app-glasses/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + 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") + } + } } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index 947f364..4458215 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -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] и быстрее diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index e748df6..7344062 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -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 { diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt index 732e355..6e00f9d 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt @@ -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") + } } diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerRollbackTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerRollbackTest.kt new file mode 100644 index 0000000..474fff6 --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerRollbackTest.kt @@ -0,0 +1,84 @@ +package pw.binom.viewmate.glasses + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Откат позиции при resume: если текущая позиция «убежала» вперёд больше чем + * на 5с от последней реальной (гашение экрана ExoPlayer набегает позицию по + * системным часам) — плеер откатывается к последней реальной. + * Чистая логика тестируется без Android-плеера. + */ +class MovieControllerRollbackTest { + + @Test + fun noRollbackWhenPositionsAgree() { + assertNull(rollbackTargetMs(currentMs = 92_000, lastGoodMs = 92_000)) + } + + @Test + fun noRollbackWithinThreshold() { + assertNull(rollbackTargetMs(currentMs = 97_000, lastGoodMs = 92_000)) + } + + @Test + fun rollbackWhenPositionRanAhead() { + assertEquals(92_000L, rollbackTargetMs(currentMs = 2_280_000, lastGoodMs = 92_000)) + } + + @Test + fun noRollbackWhenLastGoodUnknown() { + assertNull(rollbackTargetMs(currentMs = 38_000, lastGoodMs = 0)) + } + + @Test + fun noRollbackWhenRunningBackward() { + // Позиция позади последней реальной (перемотка назад) — не трогаем. + assertNull(rollbackTargetMs(currentMs = 1_000, lastGoodMs = 92_000)) + } + + @Test + fun markPositionAcceptsRealProgress() { + assertEquals(97_000L, markPositionTargetMs(positionMs = 97_000, lastGoodMs = 92_000)) + } + + @Test + fun markPositionAcceptsWithinJumpThreshold() { + assertEquals(122_000L, markPositionTargetMs(positionMs = 122_000, lastGoodMs = 92_000)) + } + + @Test + fun markPositionIgnoresGarbageJump() { + // Мусор ExoPlayer при гашении: 77s → 42min за один 5с-цикл. + assertEquals(77_182L, markPositionTargetMs(positionMs = 2_527_153, lastGoodMs = 77_182)) + } + + @Test + fun markPositionTrustedAcceptsSeekForward() { + // Seek через трекбар (trusted) — позиция достоверна, даже если скачок > порога. + assertEquals(2_527_153L, markPositionTargetMs(positionMs = 2_527_153, lastGoodMs = 77_182, trusted = true)) + } + + @Test + fun markPositionTrustedAcceptsWhenWithinJumpThreshold() { + assertEquals(122_000L, markPositionTargetMs(positionMs = 122_000, lastGoodMs = 92_000, trusted = true)) + } + + @Test + fun markPositionUntrustedStillIgnoresGarbageJump() { + // Из 5с-цикла (trusted=false) мусор ExoPlayer по-прежнему игнорируется. + assertEquals(77_182L, markPositionTargetMs(positionMs = 2_527_153, lastGoodMs = 77_182, trusted = false)) + } + + @Test + fun markPositionAcceptsBackwardSeek() { + // Перемотка назад — не скачок вперёд, принимаем. + assertEquals(1_000L, markPositionTargetMs(positionMs = 1_000, lastGoodMs = 92_000)) + } + + @Test + fun markPositionAcceptsWhenLastGoodUnknown() { + assertEquals(38_000L, markPositionTargetMs(positionMs = 38_000, lastGoodMs = 0)) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index 14a1e1d..ce21765 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -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 diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt index c0add91..b62019f 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt @@ -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"