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
@@ -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,
),
)
}
}
}
@@ -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<Long, Boolean> {
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)
}
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */