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) {
|
while (isActive) {
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
client?.send(buildStatus())
|
client?.send(buildStatus())
|
||||||
val (positionMs, playing) = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||||
client?.send(PlaybackPosition(positionMs = positionMs, playing = playing))
|
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 =
|
internal fun seekTargetMs(seekMs: Long, durationMs: Long): Long =
|
||||||
seekMs.coerceIn(0L, durationMs.coerceAtLeast(0L))
|
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,
|
* Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer,
|
||||||
* выполняет команды из WS-протокола. Сам плеер создаётся только по PlayVideo.
|
* выполняет команды из WS-протокола. Сам плеер создаётся только по PlayVideo.
|
||||||
@@ -36,6 +60,9 @@ class MovieController(private val context: Context) {
|
|||||||
private var _player: SimpleExoPlayer? = null
|
private var _player: SimpleExoPlayer? = null
|
||||||
val player: SimpleExoPlayer? get() = _player
|
val player: SimpleExoPlayer? get() = _player
|
||||||
|
|
||||||
|
/** Текущий контент (itemId/audioIndex) — для PlaybackPosition, без обращения к плееру. */
|
||||||
|
private var contentId = ContentId()
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
fun playVideo(msg: PlayVideo) {
|
fun playVideo(msg: PlayVideo) {
|
||||||
@@ -54,6 +81,7 @@ class MovieController(private val context: Context) {
|
|||||||
_player = it
|
_player = it
|
||||||
log("glasses", "SimpleExoPlayer создан")
|
log("glasses", "SimpleExoPlayer создан")
|
||||||
}
|
}
|
||||||
|
contentId = contentIdOnPlayVideo(msg)
|
||||||
_title.value = msg.title
|
_title.value = msg.title
|
||||||
log("glasses", "PlayVideo: «${msg.title}» — ${msg.videoUrl}")
|
log("glasses", "PlayVideo: «${msg.title}» — ${msg.videoUrl}")
|
||||||
player.setMediaItem(MediaItem.fromUri(msg.videoUrl))
|
player.setMediaItem(MediaItem.fromUri(msg.videoUrl))
|
||||||
@@ -115,14 +143,15 @@ class MovieController(private val context: Context) {
|
|||||||
player.release()
|
player.release()
|
||||||
}
|
}
|
||||||
_movieActive.value = false
|
_movieActive.value = false
|
||||||
|
contentId = contentIdOnClose()
|
||||||
_title.value = null
|
_title.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Читать ТОЛЬКО с главного потока. */
|
/** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */
|
||||||
fun playbackPosition(): Pair<Long, Boolean> {
|
internal fun playbackPosition(): PlaybackSnapshot {
|
||||||
val player = _player ?: return 0L to false
|
val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex)
|
||||||
return player.currentPosition to player.isPlaying
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,8 +86,12 @@ class GlassesHub(
|
|||||||
private val _positionMs = MutableStateFlow<Long?>(null)
|
private val _positionMs = MutableStateFlow<Long?>(null)
|
||||||
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
|
val positionMs: StateFlow<Long?> = _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) {
|
fun add(session: DefaultWebSocketServerSession) {
|
||||||
sessions[session] = Unit
|
sessions[session] = Unit
|
||||||
@@ -155,14 +159,14 @@ class GlassesHub(
|
|||||||
_positionMs.value = msg.positionMs
|
_positionMs.value = msg.positionMs
|
||||||
this.state.playing = msg.playing
|
this.state.playing = msg.playing
|
||||||
val state = if (msg.playing) "играет" else "пауза"
|
val state = if (msg.playing) "играет" else "пауза"
|
||||||
log("glasses", "позиция: ${msg.positionMs} мс, $state")
|
log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}")
|
||||||
onPlaybackPosition?.invoke(msg.positionMs, msg.playing)
|
onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
is GlassesOff -> {
|
is GlassesOff -> {
|
||||||
log("glasses", "очки выключились → пауза (${msg.reason})")
|
log("glasses", "очки выключились → пауза (${msg.reason})")
|
||||||
this.state.playing = false
|
this.state.playing = false
|
||||||
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false)
|
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,28 @@ class PhoneApp : Application() {
|
|||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
@@ -59,7 +81,11 @@ class PhoneApp : Application() {
|
|||||||
nsd.publish()
|
nsd.publish()
|
||||||
|
|
||||||
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
|
// каждый 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) {
|
scope.launch(Dispatchers.Main) {
|
||||||
|
|||||||
@@ -57,10 +57,20 @@ data class DownloadProgress(
|
|||||||
val phase: DownloadPhase,
|
val phase: DownloadPhase,
|
||||||
) : GlassesToHost
|
) : GlassesToHost
|
||||||
|
|
||||||
/** Позиция воспроизведения (очки — мастер таймкода, шлют регулярно). */
|
/**
|
||||||
|
* Позиция воспроизведения (очки — мастер таймкода, шлют регулярно).
|
||||||
|
* [itemId]+[audioIndex] позволяют телефону автоподхватить звук после рестарта:
|
||||||
|
* очки продолжают играть, а телефон создаёт аудио-плеер по этим полям.
|
||||||
|
* Дефолты — обратная совместимость со старыми очками.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("playback_position")
|
@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
|
@Serializable
|
||||||
|
|||||||
@@ -118,6 +118,37 @@ class GlassesToHostTest {
|
|||||||
assertTrue(decoded.playing)
|
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
|
@Test
|
||||||
fun glassesOffRoundTripWithDefault() {
|
fun glassesOffRoundTripWithDefault() {
|
||||||
val msg: GlassesToHost = GlassesOff()
|
val msg: GlassesToHost = GlassesOff()
|
||||||
|
|||||||
Reference in New Issue
Block a user