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