diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt index 946fe1c..a143b65 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt @@ -6,6 +6,7 @@ import android.os.Handler import android.os.Looper import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,8 +41,13 @@ internal const val DRIFT_GAIN = 0.0002f /** Потолок изменения темпа (±10%): дальше слышно, что «не в свой темп». */ internal const val MAX_DRIFT_SPEED = 0.10f -/** Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo. */ -internal const val HARD_SEEK_THRESHOLD_MS = 2000L +/** + * Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo. + * Поднят с 2000 до 5000: обычная буферная пауза вывода (0.5–1.5 с) при локальном файле не должна + * форсировать жёсткий seek (он перебуферизирует ExoPlayer и даёт слышимую тишину + прыжок позиции); + * умеренный рассинхрон закрывается плавной коррекцией скоростью, бесшовно. + */ +internal const val HARD_SEEK_THRESHOLD_MS = 5000L /** * Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android): @@ -138,6 +144,16 @@ class AudioSyncPlayer(private val context: Context) { private var player: ExoPlayer? = null private var ticker: Job? = null + /** Слушатель буферизации ExoPlayer: логирует паузы вывода (причина «немого» звука). */ + private val bufferingListener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING -> log("audio", "буферизация: вывод приостановлен (возможен пропуск звука 0.5–1с)") + Player.STATE_READY -> log("audio", "буфер готов, вывод возобновлён") + } + } + } + /** Позиция очков из последнего sync. */ private var lastVideoPositionMs = 0L /** IsPlaying очков из последнего sync. */ @@ -162,12 +178,16 @@ class AudioSyncPlayer(private val context: Context) { } else { Uri.fromFile(File(urlOrFile)) } - val p = player ?: ExoPlayer.Builder(context).build().also { player = it } + val p = player ?: ExoPlayer.Builder(context).build().also { + it.addListener(bufferingListener) + player = it + } p.setMediaItem(MediaItem.fromUri(uri)) p.prepare() p.seekTo(startPositionMs.coerceAtLeast(0L)) p.playWhenReady = true - log("audio", "играю $urlOrFile с ${startPositionMs} мс") + val isStream = urlOrFile.startsWith("http://") || urlOrFile.startsWith("https://") + log("audio", "играю ${if (isStream) "стриминг S3" else "локальный файл"}: $urlOrFile с ${startPositionMs} мс") startTicker() } } @@ -189,7 +209,7 @@ class AudioSyncPlayer(private val context: Context) { decision.seekToMs != null -> { p.seekTo(decision.seekToMs) p.setPlaybackParameters(PlaybackParameters(1f)) - log("audio sync:", "seek → ${decision.seekToMs}") + log("audio sync:", "seek → ${decision.seekToMs} (diff=${abs(audioPos - positionMs)})") } else -> { p.setPlaybackParameters(PlaybackParameters(decision.speed))