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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user