feat: кнопка «⏹ Закрыть» в пульте + автоматика обрыва связи (README «Автоматика»)

- GlassesScreen: кнопка ⏹ Закрыть → sendCommand(CLOSE) (обработчики были: телефон audio.stop, очки MovieController.close)
- app-glasses: MovieController.pauseIfPlaying()/resume(); HostConnection — при обрыве WS пауза + флаг, при восстановлении — continue (лог «связь потеряна — пауза» / «связь восстановлена — продолжаю»); флаг сбрасывается на PlayVideo/CLOSE
- app-phone: ConnectionAutomatics (connectionEvent, авто-resume по флагу «играл ли») + коллектор на hub.connected
- Тесты: app-phone 67/0, app-glasses 15/0
- OFF7 3/3: A5 стоп + обрыв-пауза-продолжение подтверждены живым прогоном
This commit is contained in:
Hermes Agent
2026-08-19 19:20:04 +03:00
parent 535f4e2050
commit 35f462b34a
10 changed files with 226 additions and 1 deletions
@@ -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<List<String>>(emptyList())
val messages: StateFlow<List<String>> = _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)
}
@@ -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()
}
}
@@ -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
}
}
@@ -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(), "последний обрыв был на паузе")
}
}