Files
view-mate/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt
T
Hermes Agent 345e7b74fa feat(app-glasses): жесты RayNeo SDK (TempleAction) + отключение звуков трекпада
Перенос из pair-view (проверено на железе): RayNeoARSDK V0.1.3 (aar в
libs/, MercurySDK.init в GlassesApp), MainActivity → BaseEventActivity,
templeActionViewModel.state → команды (Click=пауза/плей, SlideForward/
Backward=±30с), dispatchTouchEvent→onTouchEvent + toggleSoundEffect(0..3,
false) — системные щелчки трекпада отключены.

Фиксы по ходу (железо):
- applyMediaCommand(msg, onDone) — sendPlaybackPositionNow после seekTo
  (иначе телефон получал позицию ДО seek)
- догонка seekTo через 1с на телефоне (первый seekTo в ogg неточен)
- playbackPosition: isPlaying → playWhenReady — ложное playing=false при
  буферизации после seekTo паузило звук на телефоне (~5с «пропажи»);
  pauseIfPlaying тоже по playWhenReady

Тесты: glasses 59+5, phone 71. Железо: клик пауза/плей ✓, свайпы ±30с ✓,
звук при seek не пропадает ✓ (подтверждено пользователем)
2026-08-19 23:38:45 +03:00

227 lines
9.4 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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
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)
}
}
}
}