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:
@@ -3,6 +3,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_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.NEARBY_WIFI_DEVICES"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user