feat(app-glasses): жесты RayNeo SDK (TempleAction) + отключение звуков трекпада

Перенос из 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 не пропадает ✓ (подтверждено пользователем)
This commit is contained in:
Hermes Agent
2026-08-19 23:38:45 +03:00
parent 654a5f9e26
commit 345e7b74fa
7 changed files with 196 additions and 9 deletions
+7
View File
@@ -36,6 +36,13 @@ android {
compose = true compose = true
} }
testOptions {
// Unit-тесты строят TempleAction из aar, чьи конструкторы дёргают
// SystemClock.uptimeMillis() — unmocked методы android.jar возвращают
// значения по умолчанию вместо "not mocked".
unitTests.isReturnDefaultValues = true
}
} }
dependencies { dependencies {
@@ -1,6 +1,7 @@
package pw.binom.viewmate.glasses package pw.binom.viewmate.glasses
import android.app.Application import android.app.Application
import com.rayneo.arsdk.android.MercurySDK
import java.io.File import java.io.File
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */ /** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
@@ -29,6 +30,7 @@ class GlassesApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
MercurySDK.init(this)
movieController = MovieController(applicationContext) movieController = MovieController(applicationContext)
movieController.eventLogger = { msg -> logToFile("mc: $msg") } movieController.eventLogger = { msg -> logToFile("mc: $msg") }
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) } downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
@@ -9,12 +9,10 @@ import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.Display import android.view.Display
import android.view.KeyEvent
import android.view.MotionEvent
import java.io.File import java.io.File
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -29,9 +27,19 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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 import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
class MainActivity : ComponentActivity() { class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false private var resumeAfterScreenOn = false
private val screenReceiver = object : BroadcastReceiver() { private val screenReceiver = object : BroadcastReceiver() {
@@ -62,6 +70,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
collectTempleActions()
disableTrackpadSounds()
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
MaterialTheme { MaterialTheme {
@@ -110,6 +120,47 @@ class MainActivity : ComponentActivity() {
}, null) }, 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() { override fun onPause() {
super.onPause() super.onPause()
log("ui", "onPause") log("ui", "onPause")
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo import pw.binom.viewmate.core.protocol.PlayVideo
import java.io.File 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 { mainHandler.post {
val player = _player ?: run { val player = _player ?: run {
log("glasses", "MediaCommand $command проигнорирован: плеер не активен") log("glasses", "MediaCommand $command проигнорирован: плеер не активен")
onDone?.invoke()
return@post return@post
} }
when (command) { when (command) {
@@ -212,6 +218,7 @@ class MovieController(private val context: Context) {
MediaCommand.CLOSE -> close() MediaCommand.CLOSE -> close()
} }
onDone?.invoke()
} }
} }
@@ -235,13 +242,13 @@ class MovieController(private val context: Context) {
/** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */ /** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */
internal fun playbackPosition(): PlaybackSnapshot { internal fun playbackPosition(): PlaybackSnapshot {
val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex) 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 { fun pauseIfPlaying(): Boolean {
val player = _player ?: return false val player = _player ?: return false
val wasPlaying = player.isPlaying val wasPlaying = player.playWhenReady
if (wasPlaying) player.pause() if (wasPlaying) player.pause()
return wasPlaying return wasPlaying
} }
@@ -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"
}
@@ -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
}
}
@@ -115,6 +115,11 @@ class AudioSyncPlayer(private val context: Context) {
private var player: ExoPlayer? = null private var player: ExoPlayer? = null
private var ticker: Job? = null private var ticker: Job? = null
/** Позиция очков из последнего sync (для догонки после неточного seekTo). */
private var lastVideoPositionMs = 0L
/** IsPlaying очков из последнего sync. */
private var lastVideoPlaying = false
private val _positionMs = MutableStateFlow(0L) private val _positionMs = MutableStateFlow(0L)
/** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */ /** Текущая позиция аудио (для индикатора «🔊 звук: N мс»). Обновляется каждые 500 мс. */
val positionMs: StateFlow<Long> = _positionMs.asStateFlow() val positionMs: StateFlow<Long> = _positionMs.asStateFlow()
@@ -151,9 +156,24 @@ class AudioSyncPlayer(private val context: Context) {
fun sync(positionMs: Long, isPlaying: Boolean) { fun sync(positionMs: Long, isPlaying: Boolean) {
main.post { main.post {
val p = player ?: return@post val p = player ?: return@post
lastVideoPositionMs = positionMs
lastVideoPlaying = isPlaying
val audioPos = p.currentPosition val audioPos = p.currentPosition
val decision = decideSync(audioPos, positionMs, p.isPlaying, isPlaying) 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() } decision.playing?.let { if (it) p.play() else p.pause() }
log("audio sync:", "video=$positionMs мс audio=$audioPos мс diff=${abs(audioPos - positionMs)}") log("audio sync:", "video=$positionMs мс audio=$audioPos мс diff=${abs(audioPos - positionMs)}")
} }