feat: автоподхват звука после рестарта телефона (очки играют — звук молчит)

Сценарий (железо): телефон перезапущен, очки продолжают играть — звук
не создавался (плеер живёт только по 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), подтверждено пользователем
This commit is contained in:
Hermes Agent
2026-08-19 21:45:23 +03:00
parent 8072d0c95a
commit e29bc5032d
7 changed files with 178 additions and 14 deletions
@@ -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
@@ -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<PlaybackPosition>(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<PlaybackPosition>(decoded)
assertEquals(7000, decoded.positionMs)
assertTrue(!decoded.playing)
assertNull(decoded.itemId)
assertEquals(0, decoded.audioIndex)
}
@Test
fun glassesOffRoundTripWithDefault() {
val msg: GlassesToHost = GlassesOff()