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:
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
Очки RayNeo X2 + компаньон на телефон: кино и чат с ассистентом.
|
Очки RayNeo X2 + компаньон на телефон: кино и чат с ассистентом.
|
||||||
|
|
||||||
|
## Статус реализации (19.08.2026)
|
||||||
|
|
||||||
|
| Возможность | Статус |
|
||||||
|
|---|---|
|
||||||
|
| Бинокулярный видеорендер на очках (ExoPlayer → EGL-блит в оба глаза), fullscreen | ✅ этап 1 |
|
||||||
|
| Пульт: пауза/плей/±30с/закрыть (с телефона и из консоли) | ✅ |
|
||||||
|
| Связка очки↔телефон по WiFi (WS), два эмулятора + adb forward | ✅ |
|
||||||
|
| Каталог Jellyfin на телефоне (Фильмы/Сериалы/Папки, поиск, постеры, бейджи зеркала) | ✅ |
|
||||||
|
| Детали: выбор озвучки, статус конвертации, заказ зеркала | ✅ |
|
||||||
|
| Реальное скачивание с S3 (SigV4, прогресс, докачка с места, персистентный статус) | ✅ |
|
||||||
|
| Экран «Очки»: связь, батарея/память, пульт | ✅ |
|
||||||
|
| **Синхронизированный звук на телефоне** (очки без динамиков) | 🔧 в работе |
|
||||||
|
| Чат с ассистентом (режим CHAT, сессии Hermes, голосовой ассистент) | ❌ заглушка |
|
||||||
|
| Жесты RayNeo (пауза/перемотка жестом) | ❌ в планах |
|
||||||
|
| NSD-дискавери телефона (реальные очки находят телефон по сети) | ❌ в планах (сейчас адрес зашит для эмулятора) |
|
||||||
|
|
||||||
|
Подробный журнал решений и инцидентов: `/root/notes/2026-08-19.md` (внутреннее).
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ dependencies {
|
|||||||
implementation("io.coil-kt:coil-compose:2.6.0")
|
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
implementation(libs.exoplayer)
|
implementation(libs.exoplayer)
|
||||||
|
implementation(libs.media3.exoplayer)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(project(":lib-core"))
|
implementation(project(":lib-core"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ class GlassesHub(
|
|||||||
private val _positionMs = MutableStateFlow<Long?>(null)
|
private val _positionMs = MutableStateFlow<Long?>(null)
|
||||||
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
|
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
|
||||||
|
|
||||||
|
/** Хук на позицию очков: вызывается на каждый PlaybackPosition (и GlassesOff с playing=false). */
|
||||||
|
var onPlaybackPosition: ((positionMs: Long, playing: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
fun add(session: DefaultWebSocketServerSession) {
|
fun add(session: DefaultWebSocketServerSession) {
|
||||||
sessions[session] = Unit
|
sessions[session] = Unit
|
||||||
_connected.value = sessions.size
|
_connected.value = sessions.size
|
||||||
@@ -111,11 +114,13 @@ class GlassesHub(
|
|||||||
this.state.playing = msg.playing
|
this.state.playing = msg.playing
|
||||||
val state = if (msg.playing) "играет" else "пауза"
|
val state = if (msg.playing) "играет" else "пауза"
|
||||||
log("glasses", "позиция: ${msg.positionMs} мс, $state")
|
log("glasses", "позиция: ${msg.positionMs} мс, $state")
|
||||||
|
onPlaybackPosition?.invoke(msg.positionMs, msg.playing)
|
||||||
}
|
}
|
||||||
|
|
||||||
is GlassesOff -> {
|
is GlassesOff -> {
|
||||||
log("glasses", "очки выключились → пауза (${msg.reason})")
|
log("glasses", "очки выключились → пауза (${msg.reason})")
|
||||||
this.state.playing = false
|
this.state.playing = false
|
||||||
|
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ class PhoneApp : Application() {
|
|||||||
/** Реальное скачивание зеркал на телефон (с прогрессом). */
|
/** Реальное скачивание зеркал на телефон (с прогрессом). */
|
||||||
val downloadManager: DownloadManager by lazy { DownloadManager(this, mirror) }
|
val downloadManager: DownloadManager by lazy { DownloadManager(this, mirror) }
|
||||||
|
|
||||||
|
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
|
||||||
|
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
|
||||||
|
|
||||||
var actions: PhoneActions? = null
|
var actions: PhoneActions? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -47,6 +50,9 @@ class PhoneApp : Application() {
|
|||||||
server.start()
|
server.start()
|
||||||
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||||
|
|
||||||
|
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
|
||||||
|
server.hub.onPlaybackPosition = { positionMs, playing -> audioSync.sync(positionMs, playing) }
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull()
|
val userId = runCatching { jellyfin.users().firstOrNull()?.Id }.getOrNull()
|
||||||
?: ""
|
?: ""
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import pw.binom.viewmate.phone.DownloadPhase
|
|||||||
import pw.binom.viewmate.phone.PhoneApp
|
import pw.binom.viewmate.phone.PhoneApp
|
||||||
import pw.binom.viewmate.phone.PhoneConfig
|
import pw.binom.viewmate.phone.PhoneConfig
|
||||||
import pw.binom.viewmate.phone.S3Presigner
|
import pw.binom.viewmate.phone.S3Presigner
|
||||||
|
import pw.binom.viewmate.phone.resolveAudioSource
|
||||||
|
|
||||||
/** Экран 2 — Детали элемента: озвучка, статус конвертации, скачивание, кнопки. */
|
/** Экран 2 — Детали элемента: озвучка, статус конвертации, скачивание, кнопки. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -102,6 +103,14 @@ fun DetailsScreen(
|
|||||||
fun onSelectAudio(index: Int) {
|
fun onSelectAudio(index: Int) {
|
||||||
audioIndex = index
|
audioIndex = index
|
||||||
app.state.currentAudioIndex = index
|
app.state.currentAudioIndex = index
|
||||||
|
// смена озвучки во время просмотра: перезапуск звука с текущей позицией очков
|
||||||
|
if (app.audioSync.isActive) {
|
||||||
|
val positionMs = app.server.hub.positionMs.value ?: 0L
|
||||||
|
app.audioSync.stop()
|
||||||
|
resolveAudioSource(app.downloadManager, item.Id, files, index)?.let { source ->
|
||||||
|
app.audioSync.play(source, positionMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onWatch() {
|
fun onWatch() {
|
||||||
@@ -118,7 +127,14 @@ fun DetailsScreen(
|
|||||||
val result = actions.watch(item.Id, effectiveAudioIndex) {
|
val result = actions.watch(item.Id, effectiveAudioIndex) {
|
||||||
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
||||||
}
|
}
|
||||||
if (!result.contains("включил")) snackbar.showSnackbar(result)
|
if (result.contains("включил")) {
|
||||||
|
// звук — на телефоне: запускаем ту же дорожку (локально или стриминг)
|
||||||
|
resolveAudioSource(app.downloadManager, item.Id, files, effectiveAudioIndex)?.let { source ->
|
||||||
|
app.audioSync.play(source, 0L)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
snackbar.showSnackbar(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ fun GlassesScreen() {
|
|||||||
val storageUsed by app.server.hub.storageUsedGb.collectAsState()
|
val storageUsed by app.server.hub.storageUsedGb.collectAsState()
|
||||||
val storageTotal by app.server.hub.storageTotalGb.collectAsState()
|
val storageTotal by app.server.hub.storageTotalGb.collectAsState()
|
||||||
val positionMs by app.server.hub.positionMs.collectAsState()
|
val positionMs by app.server.hub.positionMs.collectAsState()
|
||||||
|
val audioPos by app.audioSync.positionMs.collectAsState()
|
||||||
val currentItem by app.state.currentItemFlow.collectAsState()
|
val currentItem by app.state.currentItemFlow.collectAsState()
|
||||||
val playing by app.state.playingFlow.collectAsState()
|
val playing by app.state.playingFlow.collectAsState()
|
||||||
val mode by app.state.modeFlow.collectAsState()
|
val mode by app.state.modeFlow.collectAsState()
|
||||||
@@ -63,6 +64,16 @@ fun GlassesScreen() {
|
|||||||
val a = actions ?: return
|
val a = actions ?: return
|
||||||
if (connected == 0) return
|
if (connected == 0) return
|
||||||
scope.launch { a.applyMediaCommand(cmd, seekMs) }
|
scope.launch { a.applyMediaCommand(cmd, seekMs) }
|
||||||
|
// те же команды — мгновенно на телефонный звук (очки остаются мастером)
|
||||||
|
val audio = app.audioSync
|
||||||
|
when (cmd) {
|
||||||
|
MediaCommand.PLAY -> audio.play()
|
||||||
|
MediaCommand.PAUSE -> audio.pause()
|
||||||
|
MediaCommand.SEEK_FORWARD -> audio.seekTo(audio.currentPositionMs() + seekMs)
|
||||||
|
MediaCommand.SEEK_BACKWARD -> audio.seekTo((audio.currentPositionMs() - seekMs).coerceAtLeast(0L))
|
||||||
|
MediaCommand.CLOSE -> audio.stop()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyMode(target: GlassesMode) {
|
fun applyMode(target: GlassesMode) {
|
||||||
@@ -84,6 +95,7 @@ fun GlassesScreen() {
|
|||||||
storageTotal = storageTotal,
|
storageTotal = storageTotal,
|
||||||
currentItemName = currentItem?.Name,
|
currentItemName = currentItem?.Name,
|
||||||
positionMs = positionMs,
|
positionMs = positionMs,
|
||||||
|
audioPositionMs = audioPos,
|
||||||
durationMs = currentItem?.RunTimeTicks?.div(10_000),
|
durationMs = currentItem?.RunTimeTicks?.div(10_000),
|
||||||
playing = playing,
|
playing = playing,
|
||||||
)
|
)
|
||||||
@@ -137,6 +149,7 @@ private fun GlassesStatusCard(
|
|||||||
storageTotal: Double?,
|
storageTotal: Double?,
|
||||||
currentItemName: String?,
|
currentItemName: String?,
|
||||||
positionMs: Long?,
|
positionMs: Long?,
|
||||||
|
audioPositionMs: Long,
|
||||||
durationMs: Long?,
|
durationMs: Long?,
|
||||||
playing: Boolean,
|
playing: Boolean,
|
||||||
) {
|
) {
|
||||||
@@ -228,6 +241,12 @@ private fun GlassesStatusCard(
|
|||||||
val dur = durationMs?.let { msToClock(it) } ?: "--:--"
|
val dur = durationMs?.let { msToClock(it) } ?: "--:--"
|
||||||
val name = currentItemName ?: "—"
|
val name = currentItemName ?: "—"
|
||||||
Text("$name · $pos / $dur", color = PhoneText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
Text("$name · $pos / $dur", color = PhoneText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(
|
||||||
|
text = "🔊 звук: $audioPositionMs мс",
|
||||||
|
color = if (audioPositionMs > 0) PhoneBlueText else PhoneMuted,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
StatusChip(
|
StatusChip(
|
||||||
text = if (playing) "плей" else "пауза",
|
text = if (playing) "плей" else "пауза",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class AudioSyncPlayerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noSeekWhenWithinThreshold() {
|
||||||
|
val d = decideSync(audioPosition = 10_000, videoPosition = 10_800, audioPlaying = true, videoPlaying = true)
|
||||||
|
assertNull(d.seekToMs, "diff=800мс ≤ 1000мс — seek не нужен")
|
||||||
|
assertNull(d.playing, "play/pause совпадают — не трогаем")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thresholdBoundaryDiff1000DoesNotSeek() {
|
||||||
|
val d = decideSync(audioPosition = 10_000, videoPosition = 11_000, audioPlaying = true, videoPlaying = true)
|
||||||
|
assertNull(d.seekToMs, "diff=1000мс не больше порога — не дёргаем")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seeksToVideoPositionWhenDiffAboveThreshold() {
|
||||||
|
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
|
||||||
|
assertEquals(12_000L, d.seekToMs, "diff=2000мс > 1000мс — перематываем на позицию очков")
|
||||||
|
assertNull(d.playing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seeksWhenAudioAheadOfVideo() {
|
||||||
|
val d = decideSync(audioPosition = 25_000, videoPosition = 20_000, audioPlaying = true, videoPlaying = true)
|
||||||
|
assertEquals(20_000L, d.seekToMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playWhenVideoPlayingButAudioPaused() {
|
||||||
|
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = false, videoPlaying = true)
|
||||||
|
assertNull(d.seekToMs)
|
||||||
|
assertEquals(true, d.playing, "очки играют — аудио должно играть")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pauseWhenVideoPausedButAudioPlaying() {
|
||||||
|
val d = decideSync(audioPosition = 10_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = false)
|
||||||
|
assertNull(d.seekToMs)
|
||||||
|
assertEquals(false, d.playing, "очки на паузе — аудио должно паузиться")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seekAndPlayApplyTogether() {
|
||||||
|
val d = decideSync(audioPosition = 5_000, videoPosition = 8_000, audioPlaying = false, videoPlaying = true)
|
||||||
|
assertEquals(8_000L, d.seekToMs)
|
||||||
|
assertEquals(true, d.playing, "и seek, и play срабатывают в одном sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noCorrectionWhenFullyAligned() {
|
||||||
|
val d = decideSync(audioPosition = 42_000, videoPosition = 42_000, audioPlaying = true, videoPlaying = true)
|
||||||
|
assertNull(d.seekToMs)
|
||||||
|
assertNull(d.playing)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ compose-plugin = "1.8.0"
|
|||||||
androidx-compose-bom = "2025.05.00"
|
androidx-compose-bom = "2025.05.00"
|
||||||
androidx-activity-compose = "1.10.1"
|
androidx-activity-compose = "1.10.1"
|
||||||
exoplayer = "2.17.1"
|
exoplayer = "2.17.1"
|
||||||
|
media3 = "1.6.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||||
@@ -19,6 +20,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
|
|||||||
android-material3 = { module = "androidx.compose.material3:material3" }
|
android-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
|
||||||
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
||||||
|
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||||
|
|
||||||
# Ktor client (HTTP)
|
# Ktor client (HTTP)
|
||||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
|
|||||||
Reference in New Issue
Block a user