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
+1 -1
View File
@@ -13,7 +13,7 @@
| Детали: выбор озвучки, статус конвертации, заказ зеркала | ✅ | | Детали: выбор озвучки, статус конвертации, заказ зеркала | ✅ |
| Реальное скачивание с S3 (SigV4, прогресс, докачка с места, персистентный статус) | ✅ | | Реальное скачивание с S3 (SigV4, прогресс, докачка с места, персистентный статус) | ✅ |
| Экран «Очки»: связь, батарея/память, пульт | ✅ | | Экран «Очки»: связь, батарея/память, пульт | ✅ |
| **Синхронизированный звук на телефоне** (очки без динамиков) | 🔧 в работе | | **Синхронизированный звук на телефоне** (очки без динамиков) | ✅ 19.08 (diff < 1000 мс, авто-ресинк) |
| Чат с ассистентом (режим CHAT, сессии Hermes, голосовой ассистент) | ❌ заглушка | | Чат с ассистентом (режим CHAT, сессии Hermes, голосовой ассистент) | ❌ заглушка |
| Жесты RayNeo (пауза/перемотка жестом) | ❌ в планах | | Жесты RayNeo (пауза/перемотка жестом) | ❌ в планах |
| NSD-дискавери телефона (реальные очки находят телефон по сети) | ❌ в планах (сейчас адрес зашит для эмулятора) | | NSD-дискавери телефона (реальные очки находят телефон по сети) | ❌ в планах (сейчас адрес зашит для эмулятора) |
+2
View File
@@ -301,5 +301,7 @@ APK `pw.binom.viewmate.glasses` установлен.
- ⚠️ Дефект A: звук при watch не запускается — ✅ ИСПРАВЛЕН и подтверждён (OFF5: audio sync diff 21–316 мс, «звук: N мс» растёт) - ⚠️ Дефект A: звук при watch не запускается — ✅ ИСПРАВЛЕН и подтверждён (OFF5: audio sync diff 21–316 мс, «звук: N мс» растёт)
- ⚠️ Дефект B: самовозобновление качки после «Удалить скачанное» — ✅ ИСПРАВЛЕН (поколения; OFF5: 60с после удаления — качки нет) - ⚠️ Дефект B: самовозобновление качки после «Удалить скачанное» — ✅ ИСПРАВЛЕН (поколения; OFF5: 60с после удаления — качки нет)
- ✅ OFF5 5/5: качка на очки 0→2.6 ГБ (S3 ожил после IO-шторма Server3), звук синхронен, пауза/плей/+30с, удаление без воскрешения - ✅ 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 держит остаточные чипы до перерисовки - Косметика (не блокер): после удаления UI держит остаточные чипы до перерисовки
- ⚠️ Примечание: лог очков в logcat забивается EGL-спамом (System.out вытесняется за секунды) — надёжный канал телеметрии — logcat ТЕЛЕФОНА - ⚠️ Примечание: лог очков в logcat забивается EGL-спамом (System.out вытесняется за секунды) — надёжный канал телеметрии — logcat ТЕЛЕФОНА
@@ -15,6 +15,7 @@ import kotlinx.coroutines.withContext
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 pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.net.GlassesWsClient import pw.binom.viewmate.core.net.GlassesWsClient
import pw.binom.viewmate.core.protocol.AssistantStateMsg import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg import pw.binom.viewmate.core.protocol.ChatSessionMsg
@@ -56,6 +57,9 @@ class HostConnection(
private val _messages = MutableStateFlow<List<String>>(emptyList()) private val _messages = MutableStateFlow<List<String>>(emptyList())
val messages: StateFlow<List<String>> = _messages.asStateFlow() val messages: StateFlow<List<String>> = _messages.asStateFlow()
/** Флаг «плеер играл при обрыве» — продолжим по восстановлении связи. */
private val reconnectResume = ReconnectResumeFlag()
fun start() { fun start() {
scope.launch { scope.launch {
client.connect( client.connect(
@@ -65,10 +69,17 @@ class HostConnection(
_status.value = "подключено: $url" _status.value = "подключено: $url"
addMessage("соединение установлено") addMessage("соединение установлено")
client.send(Hello(appVersion = APP_VERSION)) client.send(Hello(appVersion = APP_VERSION))
if (reconnectResume.consume()) {
withContext(Dispatchers.Main) { movieController.resume() }
addMessage("связь восстановлена — продолжаю")
}
}, },
onDisconnected = { onDisconnected = {
_connected.value = false _connected.value = false
_status.value = "нет соединения (реконнект через 3с)" _status.value = "нет соединения (реконнект через 3с)"
val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() }
reconnectResume.onConnectionLost(wasPlaying)
if (wasPlaying) addMessage("связь потеряна — пауза")
}, },
) )
} }
@@ -124,11 +135,13 @@ class HostConnection(
is PlayVideo -> { is PlayVideo -> {
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}") addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
reconnectResume.reset()
movieController.playVideo(msg) movieController.playVideo(msg)
} }
is MediaCommandMsg -> { is MediaCommandMsg -> {
addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})") addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})")
if (msg.command == MediaCommand.CLOSE) reconnectResume.reset()
movieController.applyMediaCommand(msg.command, msg.seekMs) movieController.applyMediaCommand(msg.command, msg.seekMs)
} }
@@ -111,4 +111,17 @@ class MovieController(private val context: Context) {
val player = _player ?: return 0L to false val player = _player ?: return 0L to false
return player.currentPosition to player.isPlaying 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(), "последний обрыв был на паузе")
}
}
@@ -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
}
}
@@ -7,6 +7,7 @@ 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.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinClient import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.media.MirrorClient
@@ -38,6 +39,9 @@ class PhoneApp : Application() {
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */ /** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) } val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
/** Автоматика паузы/продолжения звука при обрыве связи. */
private val connectionAutoResume = ConnectionAutoResume()
var actions: PhoneActions? = null var actions: PhoneActions? = null
private set private set
@@ -53,6 +57,33 @@ class PhoneApp : Application() {
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера // каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
server.hub.onPlaybackPosition = { positionMs, playing -> audioSync.sync(positionMs, playing) } 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 { scope.launch {
val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull() val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull()
?: "" ?: ""
@@ -130,6 +130,7 @@ fun GlassesScreen(
RemoteButton("⏪ −30с") { sendCommand(MediaCommand.SEEK_BACKWARD, 30_000) } RemoteButton("⏪ −30с") { sendCommand(MediaCommand.SEEK_BACKWARD, 30_000) }
RemoteButton("▶ Плей") { sendCommand(MediaCommand.PLAY) } RemoteButton("▶ Плей") { sendCommand(MediaCommand.PLAY) }
RemoteButton("🔀 Режим: Кино") { applyMode(GlassesMode.MOVIE) } RemoteButton("🔀 Режим: Кино") { applyMode(GlassesMode.MOVIE) }
RemoteButton("⏹ Закрыть") { sendCommand(MediaCommand.CLOSE) }
} }
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton("⏩ +30с") { sendCommand(MediaCommand.SEEK_FORWARD, 30_000) } RemoteButton("⏩ +30с") { sendCommand(MediaCommand.SEEK_FORWARD, 30_000) }
@@ -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())
}
}