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:
@@ -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)
|
||||
}
|
||||
|
||||
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user