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,177 @@
package pw.binom.viewmate.phone
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.MirrorFiles
import java.io.File
import kotlin.math.abs
/** Решение одного sync-цикла: seekToMs не null → перемотать; playing не null → play/pause. */
internal data class SyncDecision(
val seekToMs: Long? = null,
val playing: Boolean? = null,
)
/** Порог рассинхрона: пока diff ≤ 1000 мс — не дёргаем плеер. */
internal const val SYNC_THRESHOLD_MS = 1000L
/**
* Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
* - |audio - video| > [thresholdMs] → перемотать на позицию очков;
* - isPlaying очков отличается от состояния аудио → play/pause.
*/
internal fun decideSync(
audioPosition: Long,
videoPosition: Long,
audioPlaying: Boolean,
videoPlaying: Boolean,
thresholdMs: Long = SYNC_THRESHOLD_MS,
): SyncDecision {
val seekToMs = if (abs(audioPosition - videoPosition) > thresholdMs) videoPosition else null
val playing = when {
videoPlaying == audioPlaying -> null
videoPlaying -> true
else -> false
}
return SyncDecision(seekToMs = seekToMs, playing = playing)
}
/**
* Источник аудио для просмотра на очках: локальный скачанный `audio-N.ogg`
* (если есть — быстрее, без сети), иначе presigned URL дорожки [audioIndex]
* (стриминг, без скачивания). null — дорожки нет.
*/
fun resolveAudioSource(
downloadManager: DownloadManager,
itemId: String,
mirrorFiles: MirrorFiles?,
audioIndex: Int,
): String? {
val files = mirrorFiles ?: return null
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull() ?: return null
val local = downloadManager.audioFile(itemId, audio.index)
return if (local.exists()) {
local.absolutePath
} else {
S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
}
}
/**
* Аудио-плеер телефона, синхронизированный с мастер-таймкодом очков.
* ExoPlayer (media3): стримит URL (S3 presigned) или играет локальный файл.
* Звук играет ТОЛЬКО пока активен просмотр на очках: вызовы без активного
* плеера — no-op, [sync] на каждый PlaybackPosition от очков (5с).
* Все операции ExoPlayer постятся на главный поток (требование media3).
*/
class AudioSyncPlayer(private val context: Context) {
private val main = Handler(Looper.getMainLooper())
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var player: ExoPlayer? = null
private var ticker: Job? = null
private val _positionMs = MutableStateFlow(0L)
/** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */
val positionMs: StateFlow<Long> = _positionMs.asStateFlow()
/** Есть ли активный аудио-плеер (просмотр на очках). */
val isActive: Boolean
get() = player != null
/**
* Запустить аудио с [urlOrFile] (URL или локальный путь) с позиции [startPositionMs].
* Повторный вызов перезагружает ту же дорожку; для смены дорожки — [stop] затем [play].
*/
fun play(urlOrFile: String, startPositionMs: Long) {
main.post {
val uri = if (urlOrFile.startsWith("http://") || urlOrFile.startsWith("https://")) {
Uri.parse(urlOrFile)
} else {
Uri.fromFile(File(urlOrFile))
}
val p = player ?: ExoPlayer.Builder(context).build().also { player = it }
p.setMediaItem(MediaItem.fromUri(uri))
p.prepare()
p.seekTo(startPositionMs.coerceAtLeast(0L))
p.playWhenReady = true
log("audio", "играю $urlOrFile с ${startPositionMs} мс")
startTicker()
}
}
/**
* Коррекция по позиции очков: diff > 1000 мс → seek, play/pause по isPlaying.
* Пишет ключевой лог для QA при каждом вызове.
*/
fun sync(positionMs: Long, isPlaying: Boolean) {
main.post {
val p = player ?: return@post
val audioPos = p.currentPosition
val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying)
decision.seekToMs?.let { p.seekTo(it) }
decision.playing?.let { if (it) p.play() else p.pause() }
log("audio sync:", "video=$positionMs мс audio=$audioPos мс diff=${abs(audioPos - positionMs)}")
}
}
fun play() {
main.post { player?.play() }
}
fun pause() {
main.post { player?.pause() }
}
fun seekTo(ms: Long) {
main.post { player?.seekTo(ms.coerceAtLeast(0L)) }
}
/** Остановить и освободить плеер. */
fun stop() {
main.post {
ticker?.cancel()
ticker = null
val p = player
player = null
_positionMs.value = 0L
if (p != null) {
p.playWhenReady = false
p.stop()
p.release()
log("audio", "плеер освобождён")
}
}
}
/** Текущая позиция аудио (0, если плеер не активен). Только с главного потока. */
fun currentPositionMs(): Long {
val p = player ?: return 0L
return p.currentPosition
}
private fun startTicker() {
if (ticker?.isActive == true) return
ticker = scope.launch {
while (isActive) {
_positionMs.value = player?.currentPosition ?: 0L
delay(500)
}
}
}
}