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"