From e29bc5032dbc32891d5ed465417337dbad64ceee Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 19 Aug 2026 21:45:23 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=85=D0=B2=D0=B0=D1=82=20=D0=B7=D0=B2=D1=83=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=B0=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20(=D0=BE=D1=87=D0=BA=D0=B8=20=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=20=E2=80=94=20=D0=B7=D0=B2=D1=83=D0=BA=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=BB=D1=87=D0=B8=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сценарий (железо): телефон перезапущен, очки продолжают играть — звук не создавался (плеер живёт только по watch). Решение: - PlaybackPosition теперь несёт itemId+audioIndex (дефолты — совместимость) - очки: MovieController хранит itemId/audioIndex (playVideo/close), шлёт в позиции - телефон: onPlaybackPosition → autostartAudio: если itemId есть, аудио не активно и не было неудачи — mirrorByItem → resolveAudioSource → play(source, positionMs); lastFailed защита от спама каждые 5с - Тесты: 13 lib-core (сериализация + старый формат), 4 glasses (contentId/close), 70/0 phone; железо: рестарт телефона → «audio sync» через ~5с (diff 483), подтверждено пользователем --- .../binom/viewmate/glasses/HostConnection.kt | 11 +++- .../binom/viewmate/glasses/MovieController.kt | 37 ++++++++++-- .../glasses/MovieControllerContentTest.kt | 57 +++++++++++++++++++ .../pw/binom/viewmate/phone/GlassesServer.kt | 14 +++-- .../pw/binom/viewmate/phone/PhoneApp.kt | 28 ++++++++- .../binom/viewmate/core/protocol/Messages.kt | 14 ++++- .../core/protocol/GlassesToHostTest.kt | 31 ++++++++++ 7 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerContentTest.kt 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 5098c8b..947f364 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 @@ -77,8 +77,15 @@ class HostConnection( while (isActive) { delay(5_000) client?.send(buildStatus()) - val (positionMs, playing) = withContext(Dispatchers.Main) { movieController.playbackPosition() } - client?.send(PlaybackPosition(positionMs = positionMs, playing = playing)) + val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() } + client?.send( + PlaybackPosition( + positionMs = snapshot.positionMs, + playing = snapshot.playing, + itemId = snapshot.itemId, + audioIndex = snapshot.audioIndex, + ), + ) } } } 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 7c450ec..450be76 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 @@ -21,6 +21,30 @@ import java.io.File internal fun seekTargetMs(seekMs: Long, durationMs: Long): Long = seekMs.coerceIn(0L, durationMs.coerceAtLeast(0L)) +/** + * Идентичность контента, играющего на очках (itemId + audioIndex). Хранится + * отдельно от плеера: телефону она нужна для автоподхвата звука по + * PlaybackPosition (очки играют — телефон создаёт аудио-плеер). + */ +internal data class ContentId( + val itemId: String? = null, + val audioIndex: Int = 0, +) + +/** Чистая функция: PlayVideo запоминает itemId/audioIndex. */ +internal fun contentIdOnPlayVideo(msg: PlayVideo): ContentId = ContentId(msg.itemId, msg.audioIndex) + +/** Чистая функция: CLOSE сбрасывает контент — телефон не должен подхватывать звук. */ +internal fun contentIdOnClose(): ContentId = ContentId() + +/** Снимок позиции плеера для PlaybackPosition (очки → телефон, каждые 5с). */ +internal data class PlaybackSnapshot( + val positionMs: Long, + val playing: Boolean, + val itemId: String?, + val audioIndex: Int, +) + /** * Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer, * выполняет команды из WS-протокола. Сам плеер создаётся только по PlayVideo. @@ -36,6 +60,9 @@ class MovieController(private val context: Context) { private var _player: SimpleExoPlayer? = null val player: SimpleExoPlayer? get() = _player + /** Текущий контент (itemId/audioIndex) — для PlaybackPosition, без обращения к плееру. */ + private var contentId = ContentId() + private val mainHandler = Handler(Looper.getMainLooper()) fun playVideo(msg: PlayVideo) { @@ -54,6 +81,7 @@ class MovieController(private val context: Context) { _player = it log("glasses", "SimpleExoPlayer создан") } + contentId = contentIdOnPlayVideo(msg) _title.value = msg.title log("glasses", "PlayVideo: «${msg.title}» — ${msg.videoUrl}") player.setMediaItem(MediaItem.fromUri(msg.videoUrl)) @@ -115,14 +143,15 @@ class MovieController(private val context: Context) { player.release() } _movieActive.value = false + contentId = contentIdOnClose() _title.value = null } } - /** Читать ТОЛЬКО с главного потока. */ - fun playbackPosition(): Pair { - val player = _player ?: return 0L to false - return player.currentPosition to player.isPlaying + /** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */ + internal fun playbackPosition(): PlaybackSnapshot { + val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex) + return PlaybackSnapshot(player.currentPosition, player.isPlaying, contentId.itemId, contentId.audioIndex) } /** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */ diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerContentTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerContentTest.kt new file mode 100644 index 0000000..f0095e1 --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerContentTest.kt @@ -0,0 +1,57 @@ +package pw.binom.viewmate.glasses + +import pw.binom.viewmate.core.protocol.PlayVideo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Запоминание контента в MovieController (itemId/audioIndex) для PlaybackPosition: + * phone автоподхватывает звук, если очки играют после рестарта телефона. + * Чистые функции (как MovieControllerSeekTest) — без Android-плеера. + */ +class MovieControllerContentTest { + + @Test + fun playVideoRemembersItemIdAndAudioIndex() { + val content = contentIdOnPlayVideo( + PlayVideo( + itemId = "movie-1", + title = "Акира", + videoUrl = "file:///data/viewmate/movie-1/video.mkv", + audioIndex = 2, + ), + ) + assertEquals("movie-1", content.itemId) + assertEquals(2, content.audioIndex) + } + + @Test + fun closeClearsItemId() { + val content = contentIdOnClose() + assertNull(content.itemId) + assertEquals(0, content.audioIndex) + } + + @Test + fun playbackSnapshotCarriesContentIdentity() { + val snapshot = PlaybackSnapshot( + positionMs = 65_432, + playing = true, + itemId = "movie-1", + audioIndex = 1, + ) + assertEquals(65_432, snapshot.positionMs) + assertTrue(snapshot.playing) + assertEquals("movie-1", snapshot.itemId) + assertEquals(1, snapshot.audioIndex) + } + + @Test + fun contentIdDefaultsToNoContent() { + val content = ContentId() + assertNull(content.itemId) + assertEquals(0, content.audioIndex) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index 3baaec6..cb9d160 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -86,8 +86,12 @@ class GlassesHub( private val _positionMs = MutableStateFlow(null) val positionMs: StateFlow = _positionMs.asStateFlow() - /** Хук на позицию очков: вызывается на каждый PlaybackPosition (и GlassesOff с playing=false). */ - var onPlaybackPosition: ((positionMs: Long, playing: Boolean) -> Unit)? = null + /** + * Хук на позицию очков: вызывается на каждый PlaybackPosition (и GlassesOff + * с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после + * рестарта телефона (см. PhoneApp.autostartAudio). + */ + var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null fun add(session: DefaultWebSocketServerSession) { sessions[session] = Unit @@ -155,14 +159,14 @@ class GlassesHub( _positionMs.value = msg.positionMs this.state.playing = msg.playing val state = if (msg.playing) "играет" else "пауза" - log("glasses", "позиция: ${msg.positionMs} мс, $state") - onPlaybackPosition?.invoke(msg.positionMs, msg.playing) + log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}") + onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex) } is GlassesOff -> { log("glasses", "очки выключились → пауза (${msg.reason})") this.state.playing = false - onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false) + onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0) } } } 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 cdbc219..bc81d0d 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 @@ -50,6 +50,28 @@ class PhoneApp : Application() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** itemId, у которого автоподхват звука уже не удался — не дёргать повторно каждые 5с. */ + private var lastFailedItemId: String? = null + + /** + * Автоподхват звука после рестарта телефона: очки продолжают играть и шлют + * PlaybackPosition с itemId+audioIndex, а audioSync не активен (телефон «не в курсе»). + * Создаём аудио-плеер с позиции очков — как в DetailsScreen.onWatch. + */ + private suspend fun autostartAudio(itemId: String?, audioIndex: Int, positionMs: Long) { + if (itemId == null || audioSync.isActive || itemId == lastFailedItemId) return + val mirrorFiles = runCatching { mirror.mirrorByItem(itemId) }.getOrNull()?.files + val source = resolveAudioSource(downloadManager, itemId, mirrorFiles, audioIndex) + if (source != null) { + lastFailedItemId = null + audioSync.play(source, positionMs) + log("audio", "автоподхват звука: itemId=$itemId idx=$audioIndex pos=$positionMs") + } else { + log("audio", "автоподхват звука не удался: itemId=$itemId idx=$audioIndex (нет источника)") + lastFailedItemId = itemId + } + } + override fun onCreate() { super.onCreate() instance = this @@ -59,7 +81,11 @@ class PhoneApp : Application() { nsd.publish() // каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера - server.hub.onPlaybackPosition = { positionMs, playing -> audioSync.sync(positionMs, playing) } + // + автоподхват звука (очки играют после рестарта телефона, audioSync не активен) + server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex -> + audioSync.sync(positionMs, playing) + scope.launch { autostartAudio(itemId, audioIndex, positionMs) } + } // автоматика обрыва связи: потеря → пауза, восстановление → продолжить с того же места scope.launch(Dispatchers.Main) { diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt index 965d052..ac27bed 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt @@ -57,10 +57,20 @@ data class DownloadProgress( val phase: DownloadPhase, ) : GlassesToHost -/** Позиция воспроизведения (очки — мастер таймкода, шлют регулярно). */ +/** + * Позиция воспроизведения (очки — мастер таймкода, шлют регулярно). + * [itemId]+[audioIndex] позволяют телефону автоподхватить звук после рестарта: + * очки продолжают играть, а телефон создаёт аудио-плеер по этим полям. + * Дефолты — обратная совместимость со старыми очками. + */ @Serializable @SerialName("playback_position") -data class PlaybackPosition(val positionMs: Long, val playing: Boolean) : GlassesToHost +data class PlaybackPosition( + val positionMs: Long, + val playing: Boolean, + val itemId: String? = null, + val audioIndex: Int = 0, +) : GlassesToHost /** Очки выключились / экран погас. */ @Serializable diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt index 0d87195..f452d40 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt @@ -118,6 +118,37 @@ class GlassesToHostTest { assertTrue(decoded.playing) } + @Test + fun playbackPositionWithContentRoundTrip() { + val msg: GlassesToHost = PlaybackPosition( + positionMs = 120_500, + playing = true, + itemId = "movie-1", + audioIndex = 2, + ) + val text = json.encodeToString(GlassesToHost.serializer(), msg) + assertTrue("\"itemId\":\"movie-1\"" in text) + assertTrue("\"audioIndex\":2" in text) + val decoded = json.decodeFromString(GlassesToHost.serializer(), text) + assertIs(decoded) + assertEquals(120_500, decoded.positionMs) + assertTrue(decoded.playing) + assertEquals("movie-1", decoded.itemId) + assertEquals(2, decoded.audioIndex) + } + + @Test + fun playbackPositionOldFormatGetsDefaults() { + // старое сообщение без itemId/audioIndex должно декодироваться с дефолтами + val oldText = """{"type":"playback_position","positionMs":7000,"playing":false}""" + val decoded = json.decodeFromString(GlassesToHost.serializer(), oldText) + assertIs(decoded) + assertEquals(7000, decoded.positionMs) + assertTrue(!decoded.playing) + assertNull(decoded.itemId) + assertEquals(0, decoded.audioIndex) + } + @Test fun glassesOffRoundTripWithDefault() { val msg: GlassesToHost = GlassesOff()