From 345e7b74fa1a132626c6c1ef182972f18ad84323 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 19 Aug 2026 23:38:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(app-glasses):=20=D0=B6=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20RayNeo=20SDK=20(TempleAction)=20+=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B2=D1=83?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D1=82=D1=80=D0=B5=D0=BA=D0=BF=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенос из 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 не пропадает ✓ (подтверждено пользователем) --- app-glasses/build.gradle.kts | 7 ++ .../pw/binom/viewmate/glasses/GlassesApp.kt | 2 + .../pw/binom/viewmate/glasses/MainActivity.kt | 61 +++++++++++++++-- .../binom/viewmate/glasses/MovieController.kt | 13 +++- .../glasses/gestures/GestureCommands.kt | 33 +++++++++ .../glasses/gestures/GestureCommandsTest.kt | 67 +++++++++++++++++++ .../binom/viewmate/phone/AudioSyncPlayer.kt | 22 +++++- 7 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/GestureCommands.kt create mode 100644 app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/GestureCommandsTest.kt diff --git a/app-glasses/build.gradle.kts b/app-glasses/build.gradle.kts index 66fc642..437fe99 100644 --- a/app-glasses/build.gradle.kts +++ b/app-glasses/build.gradle.kts @@ -36,6 +36,13 @@ android { compose = true } + testOptions { + // Unit-тесты строят TempleAction из aar, чьи конструкторы дёргают + // SystemClock.uptimeMillis() — unmocked методы android.jar возвращают + // значения по умолчанию вместо "not mocked". + unitTests.isReturnDefaultValues = true + } + } dependencies { diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index 3412055..9be1fdc 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -1,6 +1,7 @@ package pw.binom.viewmate.glasses import android.app.Application +import com.rayneo.arsdk.android.MercurySDK import java.io.File /** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */ @@ -29,6 +30,7 @@ class GlassesApp : Application() { override fun onCreate() { super.onCreate() instance = this + MercurySDK.init(this) movieController = MovieController(applicationContext) movieController.eventLogger = { msg -> logToFile("mc: $msg") } downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index 7344062..afa39a7 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -9,12 +9,10 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.Display +import android.view.KeyEvent +import android.view.MotionEvent import java.io.File -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -29,9 +27,19 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.rayneo.arsdk.android.ui.activity.BaseEventActivity +import kotlinx.coroutines.launch +import pw.binom.viewmate.glasses.gestures.actionName +import pw.binom.viewmate.glasses.gestures.templeActionToCommand import pw.binom.viewmate.glasses.ui.GlassesMovieScreen -class MainActivity : ComponentActivity() { +class MainActivity : BaseEventActivity() { private var resumeAfterScreenOn = false private val screenReceiver = object : BroadcastReceiver() { @@ -62,6 +70,8 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + collectTempleActions() + disableTrackpadSounds() WindowCompat.setDecorFitsSystemWindows(window, false) setContent { MaterialTheme { @@ -110,6 +120,47 @@ class MainActivity : ComponentActivity() { }, null) } + /** Жесты трекпада → команды просмотра (Click=пауза/плей, свайпы=±30с). */ + private fun collectTempleActions() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + templeActionViewModel.state.collect { action -> + val cmd = templeActionToCommand(action) + if (cmd != null) { + log("gesture", "жест: ${actionName(action)} → ${cmd.command}") + GlassesApp.instance.movieController.applyMediaCommand(cmd) { + GlassesApp.instance.hostConnection.sendPlaybackPositionNow() + } + } else { + log("gesture", "жест (резерв): ${actionName(action)}") + } + } + } + } + } + + /** + * Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool: + * mercury_system_single_tap/fling/double_click/long_press) — это и есть + * системные щелчки очков. Отключаем все четыре типа (индексы 0..3), + * чтобы клики/свайпы не щёлкали. Распознавание жестов не затрагивается. + */ + private fun disableTrackpadSounds() { + for (soundType in 0..3) toggleSoundEffect(soundType, false) + log("ui", "звуки трекпада SDK отключены (types 0..3)") + } + + /** + * Жесты SDK детектятся ТОЛЬКО в onTouchEvent (единственный канал в aar: + * BaseTouchActivity.onTouchEvent → TouchDispatcher.onMotionEvent). Поэтому + * событие НЕ съедаем — прогоняем через onTouchEvent: жест распознаётся, + * а в систему/UI событие не уходит (на очках нет таповых элементов). + * dispatchKeyEvent глушит клавиши трекпада целиком. + */ + override fun dispatchTouchEvent(event: MotionEvent): Boolean = onTouchEvent(event) + + override fun dispatchKeyEvent(event: KeyEvent): Boolean = true + override fun onPause() { super.onPause() log("ui", "onPause") diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt index 6e00f9d..7a7fd49 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import pw.binom.viewmate.core.MediaCommand +import pw.binom.viewmate.core.protocol.MediaCommandMsg import pw.binom.viewmate.core.protocol.PlayVideo import java.io.File @@ -172,10 +173,15 @@ class MovieController(private val context: Context) { } } - fun applyMediaCommand(command: MediaCommand, seekMs: Long) { + /** Команда от жеста очков: MediaCommandMsg из протокола (как из телефона). */ + fun applyMediaCommand(msg: MediaCommandMsg, onDone: (() -> Unit)? = null) = + applyMediaCommand(msg.command, msg.seekMs, onDone) + + fun applyMediaCommand(command: MediaCommand, seekMs: Long, onDone: (() -> Unit)? = null) { mainHandler.post { val player = _player ?: run { log("glasses", "MediaCommand $command проигнорирован: плеер не активен") + onDone?.invoke() return@post } when (command) { @@ -212,6 +218,7 @@ class MovieController(private val context: Context) { MediaCommand.CLOSE -> close() } + onDone?.invoke() } } @@ -235,13 +242,13 @@ class MovieController(private val context: Context) { /** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */ internal fun playbackPosition(): PlaybackSnapshot { val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex) - return PlaybackSnapshot(player.currentPosition, player.isPlaying, contentId.itemId, contentId.audioIndex) + return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex) } /** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */ fun pauseIfPlaying(): Boolean { val player = _player ?: return false - val wasPlaying = player.isPlaying + val wasPlaying = player.playWhenReady if (wasPlaying) player.pause() return wasPlaying } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/GestureCommands.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/GestureCommands.kt new file mode 100644 index 0000000..74e7e45 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/GestureCommands.kt @@ -0,0 +1,33 @@ +package pw.binom.viewmate.glasses.gestures + +import com.rayneo.arsdk.android.touch.TempleAction +import pw.binom.viewmate.core.MediaCommand +import pw.binom.viewmate.core.protocol.MediaCommandMsg + +/** + * Маппинг жестов SDK на команды просмотра (как пульт телефона). + * DoubleClick/LongClick/TripleClick/MoveUp/ActionUp/Idle — резерв (null). + */ +fun templeActionToCommand( + action: TempleAction, + seekStepMs: Long = 30_000, +): MediaCommandMsg? = when (action) { + is TempleAction.Click -> MediaCommandMsg(MediaCommand.TOGGLE) + is TempleAction.SlideForward -> MediaCommandMsg(MediaCommand.SEEK_FORWARD, seekStepMs) + is TempleAction.SlideBackward -> MediaCommandMsg(MediaCommand.SEEK_BACKWARD, seekStepMs) + else -> null +} + +/** Человекочитаемое имя жеста для логов (SDK actionName() — только для MotionEvent). */ +fun actionName(action: TempleAction): String = when (action) { + is TempleAction.Click -> "Click" + is TempleAction.DoubleClick -> "DoubleClick" + is TempleAction.LongClick -> "LongClick" + is TempleAction.TripleClick -> "TripleClick" + is TempleAction.SlideForward -> "SlideForward" + is TempleAction.SlideBackward -> "SlideBackward" + is TempleAction.SlideContinuous -> "SlideContinuous" + is TempleAction.MoveUp -> "MoveUp" + is TempleAction.ActionUp -> "ActionUp" + TempleAction.Idle -> "Idle" +} diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/GestureCommandsTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/GestureCommandsTest.kt new file mode 100644 index 0000000..911f40c --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/GestureCommandsTest.kt @@ -0,0 +1,67 @@ +package pw.binom.viewmate.glasses.gestures + +import android.view.MotionEvent +import com.rayneo.arsdk.android.touch.FlingArgs +import com.rayneo.arsdk.android.touch.TempleAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import pw.binom.viewmate.core.MediaCommand + +/** + * Маппинг жестов SDK → команды просмотра (чистая логика, без Android). + * FlingArgs требует не-null MotionEvent; в JVM-тесте реальный экземпляр + * MotionEvent получаем через sun.misc.Unsafe.allocateInstance (без вызова + * конструктора и методов фреймворка — в unit-тестах они "not mocked"). + * Поля args в templeActionToCommand не читаются — важен только тип жеста. + */ +class GestureCommandsTest { + + private val flingArgs = FlingArgs(fakeMotionEvent(), fakeMotionEvent(), 0f, 0f) + + @Test + fun clickMapsToToggle() { + val cmd = templeActionToCommand(TempleAction.Click()) + assertEquals(MediaCommand.TOGGLE, cmd?.command) + assertEquals(0L, cmd?.seekMs) + } + + @Test + fun slideForwardMapsToSeekForward() { + val cmd = templeActionToCommand(TempleAction.SlideForward(flingArgs)) + assertEquals(MediaCommand.SEEK_FORWARD, cmd?.command) + assertEquals(30_000L, cmd?.seekMs) + } + + @Test + fun slideBackwardMapsToSeekBackward() { + val cmd = templeActionToCommand(TempleAction.SlideBackward(flingArgs)) + assertEquals(MediaCommand.SEEK_BACKWARD, cmd?.command) + assertEquals(30_000L, cmd?.seekMs) + } + + @Test + fun customSeekStepIsUsed() { + val cmd = templeActionToCommand(TempleAction.SlideForward(flingArgs), seekStepMs = 60_000) + assertEquals(60_000L, cmd?.seekMs) + } + + @Test + fun reservedGesturesMapToNull() { + assertNull(templeActionToCommand(TempleAction.DoubleClick())) + assertNull(templeActionToCommand(TempleAction.LongClick())) + assertNull(templeActionToCommand(TempleAction.TripleClick())) + assertNull(templeActionToCommand(TempleAction.MoveUp(false))) + assertNull(templeActionToCommand(TempleAction.ActionUp())) + assertNull(templeActionToCommand(TempleAction.Idle)) + } + + private fun fakeMotionEvent(): MotionEvent { + val unsafeClass = Class.forName("sun.misc.Unsafe") + val theUnsafe = unsafeClass.getDeclaredField("theUnsafe") + theUnsafe.isAccessible = true + val unsafe = theUnsafe.get(null) + val allocate = unsafeClass.getMethod("allocateInstance", Class::class.java) + return allocate.invoke(unsafe, MotionEvent::class.java) as MotionEvent + } +} 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 index 43e5e9f..cb08754 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt @@ -115,6 +115,11 @@ class AudioSyncPlayer(private val context: Context) { 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 = _positionMs.asStateFlow() @@ -151,9 +156,24 @@ class AudioSyncPlayer(private val context: Context) { 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) } + 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)}") }