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:
Hermes Agent
2026-08-19 14:41:40 +03:00
parent 724f90a385
commit fafdddc121
9 changed files with 307 additions and 1 deletions
+1
View File
@@ -49,6 +49,7 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation(libs.exoplayer)
implementation(libs.media3.exoplayer)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
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)
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
/** Хук на позицию очков: вызывается на каждый PlaybackPosition (и GlassesOff с playing=false). */
var onPlaybackPosition: ((positionMs: Long, playing: Boolean) -> Unit)? = null
fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit
_connected.value = sessions.size
@@ -111,11 +114,13 @@ class GlassesHub(
this.state.playing = msg.playing
val state = if (msg.playing) "играет" else "пауза"
log("glasses", "позиция: ${msg.positionMs} мс, $state")
onPlaybackPosition?.invoke(msg.positionMs, msg.playing)
}
is GlassesOff -> {
log("glasses", "очки выключились → пауза (${msg.reason})")
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 audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
var actions: PhoneActions? = null
private set
@@ -47,6 +50,9 @@ class PhoneApp : Application() {
server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
server.hub.onPlaybackPosition = { positionMs, playing -> audioSync.sync(positionMs, playing) }
scope.launch {
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.PhoneConfig
import pw.binom.viewmate.phone.S3Presigner
import pw.binom.viewmate.phone.resolveAudioSource
/** Экран 2 — Детали элемента: озвучка, статус конвертации, скачивание, кнопки. */
@Composable
@@ -102,6 +103,14 @@ fun DetailsScreen(
fun onSelectAudio(index: Int) {
audioIndex = 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() {
@@ -118,7 +127,14 @@ fun DetailsScreen(
val result = actions.watch(item.Id, effectiveAudioIndex) {
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 storageTotal by app.server.hub.storageTotalGb.collectAsState()
val positionMs by app.server.hub.positionMs.collectAsState()
val audioPos by app.audioSync.positionMs.collectAsState()
val currentItem by app.state.currentItemFlow.collectAsState()
val playing by app.state.playingFlow.collectAsState()
val mode by app.state.modeFlow.collectAsState()
@@ -63,6 +64,16 @@ fun GlassesScreen() {
val a = actions ?: return
if (connected == 0) return
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) {
@@ -84,6 +95,7 @@ fun GlassesScreen() {
storageTotal = storageTotal,
currentItemName = currentItem?.Name,
positionMs = positionMs,
audioPositionMs = audioPos,
durationMs = currentItem?.RunTimeTicks?.div(10_000),
playing = playing,
)
@@ -137,6 +149,7 @@ private fun GlassesStatusCard(
storageTotal: Double?,
currentItemName: String?,
positionMs: Long?,
audioPositionMs: Long,
durationMs: Long?,
playing: Boolean,
) {
@@ -228,6 +241,12 @@ private fun GlassesStatusCard(
val dur = durationMs?.let { msToClock(it) } ?: "--:--"
val name = currentItemName ?: "—"
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(
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)
}
}