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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user