345e7b74fa
Перенос из 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 не пропадает ✓ (подтверждено пользователем)
227 lines
9.4 KiB
Kotlin
227 lines
9.4 KiB
Kotlin
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)
|
||
}
|
||
}
|
||
}
|
||
}
|