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
View File
@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission <uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES" android:name="android.permission.NEARBY_WIFI_DEVICES"
@@ -1,6 +1,7 @@
package pw.binom.viewmate.glasses package pw.binom.viewmate.glasses
import android.app.Application import android.app.Application
import java.io.File
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */ /** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
object GlassesConfig { object GlassesConfig {
@@ -29,6 +30,7 @@ class GlassesApp : Application() {
super.onCreate() super.onCreate()
instance = this instance = this
movieController = MovieController(applicationContext) movieController = MovieController(applicationContext)
movieController.eventLogger = { msg -> logToFile("mc: $msg") }
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) } downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
hostConnection = HostConnection( hostConnection = HostConnection(
fallbackUrl = GlassesConfig.HOST_URL, fallbackUrl = GlassesConfig.HOST_URL,
@@ -43,5 +45,13 @@ class GlassesApp : Application() {
companion object { companion object {
lateinit var instance: GlassesApp lateinit var instance: GlassesApp
private set 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) delay(5_000)
client?.send(buildStatus()) client?.send(buildStatus())
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() } val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
client?.send( client?.send(
PlaybackPosition( PlaybackPosition(
positionMs = snapshot.positionMs, positionMs = snapshot.positionMs,
@@ -157,6 +158,16 @@ class HostConnection(
client?.send(msg) 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 отсутствует, долгий поиск бессмыслен — * Эмулятор (AVD/Genymotion): mDNS отсутствует, долгий поиск бессмыслен —
* таймаут сокращается до [MdnsClient.EMULATOR_TIMEOUT_MS] и быстрее * таймаут сокращается до [MdnsClient.EMULATOR_TIMEOUT_MS] и быстрее
@@ -1,6 +1,15 @@
package pw.binom.viewmate.glasses 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.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Display
import java.io.File
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@@ -25,6 +34,32 @@ import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var resumeAfterScreenOn = false 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -43,25 +78,94 @@ class MainActivity : ComponentActivity() {
} }
hideSystemBars() hideSystemBars()
log("ui", "иммерсивный режим включён") 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() { override fun onPause() {
super.onPause() super.onPause()
log("ui", "onPause")
val was = GlassesApp.instance.movieController.pauseIfPlaying() 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", "экран погас — пауза") if (was) log("ui", "экран погас — пауза")
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
log("ui", "onResume")
hideSystemBars() hideSystemBars()
logEvent("onResume: resume flag=$resumeAfterScreenOn")
if (resumeAfterScreenOn) { if (resumeAfterScreenOn) {
resumeAfterScreenOn = false resumeAfterScreenOn = false
GlassesApp.instance.movieController.resume() GlassesApp.instance.movieController.resume()
sendPositionWithRetries()
log("ui", "экран включён — продолжаю") 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() { private fun hideSystemBars() {
window.decorView.post { window.decorView.post {
WindowInsetsControllerCompat(window, window.decorView).apply { WindowInsetsControllerCompat(window, window.decorView).apply {
@@ -2,6 +2,7 @@ package pw.binom.viewmate.glasses
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
@@ -31,6 +32,31 @@ internal data class ContentId(
val audioIndex: Int = 0, 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. */ /** Чистая функция: PlayVideo запоминает itemId/audioIndex. */
internal fun contentIdOnPlayVideo(msg: PlayVideo): ContentId = ContentId(msg.itemId, msg.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()) 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) { fun playVideo(msg: PlayVideo) {
mainHandler.post { mainHandler.post {
// Стриминг отключён полностью: играем ТОЛЬКО локальный файл file://, который есть на диске. // Стриминг отключён полностью: играем ТОЛЬКО локальный файл file://, который есть на диске.
@@ -89,6 +145,30 @@ class MovieController(private val context: Context) {
player.seekTo(msg.startPositionMs) player.seekTo(msg.startPositionMs)
player.playWhenReady = true player.playWhenReady = true
_movieActive.value = 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 -> { MediaCommand.SEEK_FORWARD -> {
val target = player.currentPosition + seekMs val target = player.currentPosition + seekMs
player.seekTo(target) player.seekTo(target)
markPosition(player.currentPosition, trusted = true)
} }
MediaCommand.SEEK_BACKWARD -> { MediaCommand.SEEK_BACKWARD -> {
val target = (player.currentPosition - seekMs).coerceAtLeast(0L) val target = (player.currentPosition - seekMs).coerceAtLeast(0L)
player.seekTo(target) player.seekTo(target)
markPosition(player.currentPosition, trusted = true)
} }
MediaCommand.SEEK -> { MediaCommand.SEEK -> {
val target = seekTargetMs(seekMs, player.duration) val target = seekTargetMs(seekMs, player.duration)
player.seekTo(target) player.seekTo(target)
markPosition(player.currentPosition, trusted = true)
} }
// Звук — на телефоне, переключение аудио-дорожек очкам не нужно. // Звук — на телефоне, переключение аудио-дорожек очкам не нужно.
@@ -142,6 +225,7 @@ class MovieController(private val context: Context) {
player.stop() player.stop()
player.release() player.release()
} }
releaseWifiLock()
_movieActive.value = false _movieActive.value = false
contentId = contentIdOnClose() contentId = contentIdOnClose()
_title.value = null _title.value = null
@@ -168,6 +252,30 @@ class MovieController(private val context: Context) {
log("glasses", "resume проигнорирован: плеер не активен") log("glasses", "resume проигнорирован: плеер не активен")
return 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() 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")
}
} }
@@ -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))
}
}
@@ -1,12 +1,15 @@
package pw.binom.viewmate.phone package pw.binom.viewmate.phone
import android.app.Application import android.app.Application
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.core.GlassesMode import pw.binom.viewmate.core.GlassesMode
@@ -88,13 +91,31 @@ class PhoneApp : Application() {
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
nsd.publish() nsd.publish()
val lastPositionAt = AtomicLong(0)
val lastWasPlaying = AtomicBoolean(false)
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера // каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
// + автоподхват звука (очки играют после рестарта телефона, audioSync не активен) // + автоподхват звука (очки играют после рестарта телефона, audioSync не активен)
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex -> server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex ->
lastPositionAt.set(System.currentTimeMillis())
lastWasPlaying.set(playing)
audioSync.sync(positionMs, playing) audioSync.sync(positionMs, playing)
scope.launch { autostartAudio(itemId, audioIndex, 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) { scope.launch(Dispatchers.Main) {
var prevConnected = server.hub.connected.value > 0 var prevConnected = server.hub.connected.value > 0
@@ -12,6 +12,12 @@ object PhoneConfig {
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG" const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080 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 // S3 SeaweedFS (bucket media, префикс mirror/): read-only identity mirror-client
const val S3_ACCESS_KEY = "MirrorClient01QwertyKey" const val S3_ACCESS_KEY = "MirrorClient01QwertyKey"
const val S3_SECRET_KEY = "MirrorClient01SecretKeyValueForGlassesOnly" const val S3_SECRET_KEY = "MirrorClient01SecretKeyValueForGlassesOnly"