diff --git a/README.md b/README.md index 45794e8..abd7e28 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ | Детали: выбор озвучки, статус конвертации, заказ зеркала | ✅ | | Реальное скачивание с S3 (SigV4, прогресс, докачка с места, персистентный статус) | ✅ | | Экран «Очки»: связь, батарея/память, пульт | ✅ | -| **Синхронизированный звук на телефоне** (очки без динамиков) | 🔧 в работе | +| **Синхронизированный звук на телефоне** (очки без динамиков) | ✅ 19.08 (diff < 1000 мс, авто-ресинк) | | Чат с ассистентом (режим CHAT, сессии Hermes, голосовой ассистент) | ❌ заглушка | | Жесты RayNeo (пауза/перемотка жестом) | ❌ в планах | | NSD-дискавери телефона (реальные очки находят телефон по сети) | ❌ в планах (сейчас адрес зашит для эмулятора) | diff --git a/TESTING.md b/TESTING.md index d54c640..e9b1a82 100644 --- a/TESTING.md +++ b/TESTING.md @@ -301,5 +301,7 @@ APK `pw.binom.viewmate.glasses` установлен. - ⚠️ Дефект A: звук при watch не запускается — ✅ ИСПРАВЛЕН и подтверждён (OFF5: audio sync diff 21–316 мс, «звук: N мс» растёт) - ⚠️ Дефект B: самовозобновление качки после «Удалить скачанное» — ✅ ИСПРАВЛЕН (поколения; OFF5: 60с после удаления — качки нет) - ✅ OFF5 5/5: качка на очки 0→2.6 ГБ (S3 ожил после IO-шторма Server3), звук синхронен, пауза/плей/+30с, удаление без воскрешения +- OFF6-3: resume качки из .part ✅ (2%→100%, не с 0%); A4 смена озвучки во время просмотра ✅ (state озвучка → #2, diff<1000); A5 CLOSE ❌ — кнопки «Закрыть» в пульте НЕТ (README обещает — реализуется); обрыв связи: WS рвётся (Ping timeout, reconnect, ресинк diff 210–228) ✅, но пауза при обрыве НЕ реализована (видео играет дальше — README «Автоматика» — реализуется) +- ✅ OFF7 3/3: кнопка «⏹ Закрыть» в пульте (стоп: плеер освобождён, sync стоп, кнопка снова активна); автоматика обрыва: разрыв → пауза видео+звук (позиция замерла), восстановление → «продолжаю» с того же места, ресинк diff 179–432 (<1000). Замечание: пауза звука — по факту таймаута (до ~25с), транзиентный рассинхрон до 23с при восстановлении — само лечится за ~5с - Косметика (не блокер): после удаления UI держит остаточные чипы до перерисовки - ⚠️ Примечание: лог очков в logcat забивается EGL-спамом (System.out вытесняется за секунды) — надёжный канал телеметрии — logcat ТЕЛЕФОНА 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 c44e062..015d338 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 @@ -15,6 +15,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.net.GlassesWsClient import pw.binom.viewmate.core.protocol.AssistantStateMsg import pw.binom.viewmate.core.protocol.ChatSessionMsg @@ -56,6 +57,9 @@ class HostConnection( private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages.asStateFlow() + /** Флаг «плеер играл при обрыве» — продолжим по восстановлении связи. */ + private val reconnectResume = ReconnectResumeFlag() + fun start() { scope.launch { client.connect( @@ -65,10 +69,17 @@ class HostConnection( _status.value = "подключено: $url" addMessage("соединение установлено") client.send(Hello(appVersion = APP_VERSION)) + if (reconnectResume.consume()) { + withContext(Dispatchers.Main) { movieController.resume() } + addMessage("связь восстановлена — продолжаю") + } }, onDisconnected = { _connected.value = false _status.value = "нет соединения (реконнект через 3с)" + val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() } + reconnectResume.onConnectionLost(wasPlaying) + if (wasPlaying) addMessage("связь потеряна — пауза") }, ) } @@ -124,11 +135,13 @@ class HostConnection( is PlayVideo -> { addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}") + reconnectResume.reset() movieController.playVideo(msg) } is MediaCommandMsg -> { addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})") + if (msg.command == MediaCommand.CLOSE) reconnectResume.reset() movieController.applyMediaCommand(msg.command, msg.seekMs) } 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 54a2d9e..a111a78 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 @@ -111,4 +111,17 @@ class MovieController(private val context: Context) { val player = _player ?: return 0L to false return player.currentPosition to player.isPlaying } + + /** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */ + fun pauseIfPlaying(): Boolean { + val player = _player ?: return false + val wasPlaying = player.isPlaying + if (wasPlaying) player.pause() + return wasPlaying + } + + /** Продолжить с того же места (связь восстановилась). Только с главного потока. */ + fun resume() { + _player?.play() + } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ReconnectAutomatics.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ReconnectAutomatics.kt new file mode 100644 index 0000000..e4d2f2a --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ReconnectAutomatics.kt @@ -0,0 +1,28 @@ +package pw.binom.viewmate.glasses + +/** + * Автоматика очков при обрыве связи (чистая логика, без Android): + * запоминает, что плеер играл в момент разрыва, и по восстановлении + * соединения позволяет продолжить с того же места. + */ +internal class ReconnectResumeFlag { + + private var resume = false + + /** Обрыв связи: если плеер играл — продолжим после восстановления. */ + fun onConnectionLost(playerWasPlaying: Boolean) { + resume = playerWasPlaying + } + + /** Восстановление связи: продолжить ли (сбрасывает флаг). */ + fun consume(): Boolean { + val shouldResume = resume + resume = false + return shouldResume + } + + /** Новая видео-сессия или CLOSE: продолжать нечего. */ + fun reset() { + resume = false + } +} diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/ReconnectAutomaticsTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/ReconnectAutomaticsTest.kt new file mode 100644 index 0000000..0eb29c6 --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/ReconnectAutomaticsTest.kt @@ -0,0 +1,39 @@ +package pw.binom.viewmate.glasses + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReconnectAutomaticsTest { + + @Test + fun resumeRememberedWhenPlayingAtLoss() { + val flag = ReconnectResumeFlag() + flag.onConnectionLost(playerWasPlaying = true) + assertTrue(flag.consume(), "играл — после восстановления продолжаем") + assertFalse(flag.consume(), "consume сбрасывает флаг") + } + + @Test + fun noResumeWhenPausedAtLoss() { + val flag = ReconnectResumeFlag() + flag.onConnectionLost(playerWasPlaying = false) + assertFalse(flag.consume(), "на паузе — не продолжаем") + } + + @Test + fun resetClearsFlag() { + val flag = ReconnectResumeFlag() + flag.onConnectionLost(playerWasPlaying = true) + flag.reset() + assertFalse(flag.consume(), "CLOSE / новое видео — продолжать нечего") + } + + @Test + fun newLossOverwritesOldFlag() { + val flag = ReconnectResumeFlag() + flag.onConnectionLost(playerWasPlaying = true) + flag.onConnectionLost(playerWasPlaying = false) + assertFalse(flag.consume(), "последний обрыв был на паузе") + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ConnectionAutomatics.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ConnectionAutomatics.kt new file mode 100644 index 0000000..0a4836f --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ConnectionAutomatics.kt @@ -0,0 +1,48 @@ +package pw.binom.viewmate.phone + +/** Событие изменения связи очки↔телефон (для автоматики паузы/продолжения). */ +internal enum class ConnectionEvent { + /** Связь не менялась. */ + NONE, + + /** Связь потеряна (была → нет). */ + LOST, + + /** Связь восстановилась (не было → есть). */ + RESTORED, +} + +/** Переход «было подключено / стало подключено» → событие. Чистая функция. */ +internal fun connectionEvent(prevConnected: Boolean, nowConnected: Boolean): ConnectionEvent = when { + prevConnected && !nowConnected -> ConnectionEvent.LOST + !prevConnected && nowConnected -> ConnectionEvent.RESTORED + else -> ConnectionEvent.NONE +} + +/** + * Флаг «продолжить звук после восстановления связи»: при обрыве запоминает, + * играл ли звук; по восстановлении — отдаёт и сбрасывает. + * Чистая логика без Android — покрыта unit-тестами. + */ +internal class ConnectionAutoResume { + + var resumeAfterReconnect: Boolean = false + private set + + /** Обрыв связи: звук продолжится, только если плеер был активен и играл. */ + fun onLost(audioActive: Boolean, wasPlaying: Boolean) { + resumeAfterReconnect = audioActive && wasPlaying + } + + /** Восстановление связи: продолжить ли звук (сбрасывает флаг). */ + fun consumeResume(): Boolean { + val shouldResume = resumeAfterReconnect + resumeAfterReconnect = false + return shouldResume + } + + /** Сброс (например, после CLOSE). */ + fun reset() { + resumeAfterReconnect = false + } +} 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 29f5568..d0d688a 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 @@ -7,6 +7,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import pw.binom.viewmate.core.media.JellyfinClient import pw.binom.viewmate.core.media.MirrorClient @@ -38,6 +39,9 @@ class PhoneApp : Application() { /** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */ val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) } + /** Автоматика паузы/продолжения звука при обрыве связи. */ + private val connectionAutoResume = ConnectionAutoResume() + var actions: PhoneActions? = null private set @@ -53,6 +57,33 @@ class PhoneApp : Application() { // каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера server.hub.onPlaybackPosition = { positionMs, playing -> audioSync.sync(positionMs, playing) } + // автоматика обрыва связи: потеря → пауза, восстановление → продолжить с того же места + scope.launch(Dispatchers.Main) { + var prevConnected = server.hub.connected.value > 0 + server.hub.connected.collect { count -> + val nowConnected = count > 0 + when (connectionEvent(prevConnected, nowConnected)) { + ConnectionEvent.LOST -> { + connectionAutoResume.onLost(audioSync.isActive, state.playing) + if (audioSync.isActive) { + log("audio", "связь потеряна — пауза") + audioSync.pause() + } + } + + ConnectionEvent.RESTORED -> { + if (connectionAutoResume.consumeResume()) { + log("audio", "связь восстановлена — продолжаю") + audioSync.play() + } + } + + ConnectionEvent.NONE -> Unit + } + prevConnected = nowConnected + } + } + scope.launch { val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull() ?: "" diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt index 4a65a11..9ec0cd2 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt @@ -130,6 +130,7 @@ fun GlassesScreen( RemoteButton("⏪ −30с") { sendCommand(MediaCommand.SEEK_BACKWARD, 30_000) } RemoteButton("▶ Плей") { sendCommand(MediaCommand.PLAY) } RemoteButton("🔀 Режим: Кино") { applyMode(GlassesMode.MOVIE) } + RemoteButton("⏹ Закрыть") { sendCommand(MediaCommand.CLOSE) } } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { RemoteButton("⏩ +30с") { sendCommand(MediaCommand.SEEK_FORWARD, 30_000) } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/ConnectionAutomaticsTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/ConnectionAutomaticsTest.kt new file mode 100644 index 0000000..f07b791 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/ConnectionAutomaticsTest.kt @@ -0,0 +1,50 @@ +package pw.binom.viewmate.phone + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConnectionAutomaticsTest { + + @Test + fun connectionEventTransitions() { + assertEquals(ConnectionEvent.NONE, connectionEvent(prevConnected = false, nowConnected = false)) + assertEquals(ConnectionEvent.RESTORED, connectionEvent(prevConnected = false, nowConnected = true)) + assertEquals(ConnectionEvent.LOST, connectionEvent(prevConnected = true, nowConnected = false)) + assertEquals(ConnectionEvent.NONE, connectionEvent(prevConnected = true, nowConnected = true)) + } + + @Test + fun resumeRememberedOnlyWhenSoundWasActiveAndPlaying() { + val auto = ConnectionAutoResume() + auto.onLost(audioActive = true, wasPlaying = true) + assertTrue(auto.resumeAfterReconnect, "плеер был активен и играл — надо продолжить") + assertTrue(auto.consumeResume(), "consume отдаёт флаг") + assertFalse(auto.resumeAfterReconnect, "consume сбрасывает флаг") + assertFalse(auto.consumeResume(), "повторный consume — false") + } + + @Test + fun noResumeWhenSoundWasPaused() { + val auto = ConnectionAutoResume() + auto.onLost(audioActive = true, wasPlaying = false) + assertFalse(auto.resumeAfterReconnect, "звук был на паузе — не продолжаем") + } + + @Test + fun noResumeWhenNoAudioPlayer() { + val auto = ConnectionAutoResume() + auto.onLost(audioActive = false, wasPlaying = true) + assertFalse(auto.resumeAfterReconnect, "плеера нет (CLOSE) — нечего продолжать") + } + + @Test + fun resetClearsFlag() { + val auto = ConnectionAutoResume() + auto.onLost(audioActive = true, wasPlaying = true) + auto.reset() + assertFalse(auto.resumeAfterReconnect) + assertFalse(auto.consumeResume()) + } +}