feat(app-phone): синхронизированный звук на телефоне при просмотре на очках + README-статус

- AudioSyncPlayer (ExoPlayer media3): играет дорожку (локальный файл или presigned S3-URL), коррекция по PlaybackPosition очков (каждые 5с, порог 1000мс), мгновенная реакция на пульт, индикатор «🔊 звук: N мс»
- Вещдок-лог: audio sync: video=N audio=M diff=K (на стенде diff ~150мс)
- Смена озвучки во время просмотра — перезапуск с позиции очков
- README: секция «Статус реализации»
- Тесты 24/0 (8 новых AudioSyncPlayerTest)
This commit is contained in:
Hermes Agent
2026-08-19 14:41:40 +03:00
parent 724f90a385
commit fafdddc121
9 changed files with 307 additions and 1 deletions
@@ -0,0 +1,62 @@
package pw.binom.viewmate.phone
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class AudioSyncPlayerTest {
@Test
fun noSeekWhenWithinThreshold() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_800, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=800мс ≤ 1000мс — seek не нужен")
assertNull(d.playing, "play/pause совпадают — не трогаем")
}
@Test
fun thresholdBoundaryDiff1000DoesNotSeek() {
val d = decideSync(audioPosition = 10_000, videoPosition = 11_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=1000мс не больше порога — не дёргаем")
}
@Test
fun seeksToVideoPositionWhenDiffAboveThreshold() {
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
assertEquals(12_000L, d.seekToMs, "diff=2000мс > 1000мс — перематываем на позицию очков")
assertNull(d.playing)
}
@Test
fun seeksWhenAudioAheadOfVideo() {
val d = decideSync(audioPosition = 25_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true)
assertEquals(20_000L, d.seekToMs)
}
@Test
fun playWhenVideoPlayingButAudioPaused() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = false, videoPlaying = true)
assertNull(d.seekToMs)
assertEquals(true, d.playing, "очки играют — аудио должно играть")
}
@Test
fun pauseWhenVideoPausedButAudioPlaying() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false)
assertNull(d.seekToMs)
assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться")
}
@Test
fun seekAndPlayApplyTogether() {
val d = decideSync(audioPosition = 5_000, videoPosition = 8_000, audioPlaying = false, videoPlaying = true)
assertEquals(8_000L, d.seekToMs)
assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync")
}
@Test
fun noCorrectionWhenFullyAligned() {
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs)
assertNull(d.playing)
}
}