diff --git a/README.md b/README.md index 93c7063..45794e8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,24 @@ Очки 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` (внутреннее). + ## Структура ``` diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index 0122122..d6b68ba 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -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")) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt new file mode 100644 index 0000000..3c7b6c6 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt @@ -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 = _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) + } + } + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index 30cbf98..63d1ba0 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -61,6 +61,9 @@ class GlassesHub( private val _positionMs = MutableStateFlow(null) val positionMs: StateFlow = _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) } } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index 90e7183..29f5568 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -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() ?: "" diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt index 1f87a02..55c72b3 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt @@ -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) + } } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt index 10d84a5..c6fdf1a 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt @@ -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 "пауза", diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt new file mode 100644 index 0000000..caa8079 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt @@ -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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bc467c5..91312ae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ compose-plugin = "1.8.0" androidx-compose-bom = "2025.05.00" androidx-activity-compose = "1.10.1" exoplayer = "2.17.1" +media3 = "1.6.1" [libraries] 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" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } 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-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }