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) } /** * Результат выбора аудио-источника: [source] (локальный путь или presigned URL) * либо null с человекочитаемой [reason] — чтобы лог показал точную причину отсутствия звука. */ internal data class AudioSourceResult( val source: String?, val reason: String, ) /** * Источник аудио для просмотра на очках: локальный скачанный `audio-N.ogg` * (если есть — быстрее, без сети), иначе presigned URL дорожки [audioIndex] * (стриминг, без скачивания). null — дорожки нет, причина в [AudioSourceResult.reason]. * Чистая функция (без Android) — покрыта unit-тестами. */ internal fun resolveAudioSourceDetailed( mirrorFiles: MirrorFiles?, audioIndex: Int, localAudioFile: (index: Int) -> File, presign: (url: String) -> String = { S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION) }, ): AudioSourceResult { if (mirrorFiles == null) { return AudioSourceResult(null, "files=null (зеркало не получено)") } val audio = mirrorFiles.audios.firstOrNull { it.index == audioIndex } ?: mirrorFiles.audios.firstOrNull() if (audio == null) { return AudioSourceResult(null, "audios пусто (искал дорожку #$audioIndex)") } val local = localAudioFile(audio.index) return if (local.exists()) { AudioSourceResult(local.absolutePath, "локальный файл ${local.absolutePath}") } else { AudioSourceResult(presign(audio.url), "стриминг (локального audio-${audio.index}.ogg на телефоне нет)") } } /** * Public-обёртка для UI: источник аудио по [DownloadManager] (S3-presign по умолчанию). */ fun resolveAudioSource( downloadManager: DownloadManager, itemId: String, mirrorFiles: MirrorFiles?, audioIndex: Int, ): String? = resolveAudioSourceDetailed( mirrorFiles = mirrorFiles, audioIndex = audioIndex, localAudioFile = { downloadManager.audioFile(itemId, it) }, ).source /** * Аудио-плеер телефона, синхронизированный с мастер-таймкодом очков. * 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 /** Позиция очков из последнего sync (для догонки после неточного seekTo). */ private var lastVideoPositionMs = 0L /** IsPlaying очков из последнего sync. */ private var lastVideoPlaying = false private val _positionMs = MutableStateFlow(0L) /** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */ val positionMs: StateFlow = _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 lastVideoPositionMs = positionMs lastVideoPlaying = isPlaying val audioPos = p.currentPosition val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying) decision.seekToMs?.let { p.seekTo(it) lastVideoPositionMs = it // Догонка через 1с: первый seekTo в ogg приблизительный (недолёт ~2с), // второй seekTo точен — делаем его сразу, не дожидаясь следующего sync (5с). main.postDelayed({ val pp = player ?: return@postDelayed val target = lastVideoPositionMs + 1_000 // очки ушли вперёд за секунду if (abs(pp.currentPosition - target) > 500) { pp.seekTo(target) log("audio sync:", "догонка seekTo → $target (было ${pp.currentPosition})") } }, 1_000) } 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) } } } }