аудио UDP+Opus (очки→телефон), жесты дужки → гость, «убить всё лишнее» на очках

- shared/audio: вендорнут Concentus (libs/concentus.jar), OpusCodec/AudioDatagram/AudioConfig;
  захват на очках 48кГц стерео → Opus → UDP-датаграммы вместо сырого PCM по WebSocket
- glass.audio: AudioRecord 48кГц с минимальным буфером; UDP-сервер запоминает адрес пира из датаграммы
- glasses: жесты тач-сенсора дужки (cyttsp5_mt) через dispatchTouchEvent → DPAD/BACK гостю (TempleGesture*)
- glasses: forceStopAll — force-stop сторонних + xr.runtime, kill scrcpy-сирот через Shizuku UserService
- phone: AudioPlaybackManager на UDP+Opus+low-latency AudioTrack; кнопка «Убить всё лишнее» на вкладке очков
- deps: mercury transport 0.1.2-SNAPSHOT, shizuku-vd 0.1.1-SNAPSHOT
This commit is contained in:
2026-10-01 05:54:54 +03:00
parent 8cfd9192b3
commit d4e1b66924
16 changed files with 744 additions and 90 deletions
@@ -7,6 +7,8 @@ import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import android.view.Display
import android.view.InputDevice
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -29,6 +31,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import pw.binom.rayneovm.glasses.audio.AudioBridge
import pw.binom.rayneovm.glasses.input.TempleGestureDetector
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.ui.GlassesScreen
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -38,6 +41,26 @@ class MainActivity : ComponentActivity() {
/** Последний обработанный tick запроса проекции (см. onCreate). */
private var handledTick = 0L
/** Контроллер очков — нужен и Compose, и распознавателю жестов дужки. */
private lateinit var controller: VmGlassesController
/**
* Распознаватель жестов тач-сенсора дужки. Дужка на X3 Pro — отдельное input-устройство
* `cyttsp5_mt` без ассоциированного дисплея, поэтому события не доходят до Compose и
* перехватываются в [dispatchTouchEvent]. Жесты уходят гостю через
* [VmGlassesController.sendTempleGesture].
*/
private val templeGestureDetector by lazy {
TempleGestureDetector { gesture -> controller.sendTempleGesture(gesture) }
}
/** id input-устройства дужки (`cyttsp5_mt`); -1 — не найдено. */
private val templeDeviceId: Int by lazy {
InputDevice.getDeviceIds().firstOrNull { id ->
InputDevice.getDevice(id)?.name?.contains(TEMPLE_DEVICE_HINT) == true
} ?: -1
}
/**
* Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow`
* он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2).
@@ -71,6 +94,13 @@ class MainActivity : ComponentActivity() {
}
enableEdgeToEdge()
ShizukuBootstrap.attach(this)
// Диагностика: печатаем input-устройства и id дужки, чтобы в логе было видно,
// куда приходят жесты (см. dispatchTouchEvent).
InputDevice.getDeviceIds().forEach { id ->
val d = InputDevice.getDevice(id)
Log.i(TAG, "input device id=$id name=${d?.name} sources=0x${d?.sources?.toString(16)}")
}
Log.i(TAG, "temple device id=$templeDeviceId (hint='$TEMPLE_DEVICE_HINT')")
// Запросы MediaProjection приходят из VmService через AudioBridge (задача #2).
// Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
@@ -89,6 +119,7 @@ class MainActivity : ComponentActivity() {
}
}
val controller = VmGlassesController.fromContext(this)
this.controller = controller
setContent {
CompositionLocalProvider(VmGlassesController.Local provides controller) {
@@ -116,6 +147,29 @@ class MainActivity : ComponentActivity() {
ShizukuBootstrap.refresh()
}
/**
* Перехватываем тач-события окна ДО Compose/View-иерархии. События с устройства дужки
* (`cyttsp6_mt`) не ассоциированы с дисплеем и иначе теряются: `onTouchEvent` Activity
* при этом может не вызываться вовсе. Всё, что пришло с дужки, отдаём распознавателю и
* потребляем, чтобы не утекло в UI.
*/
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
// Диагностика: на ACTION_DOWN печатаем устройство — видно, доходят ли касания дужки
// (deviceId тача дужки = 4, `cyttsp5_mt`) до окна Activity вообще.
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
Log.d(
TAG,
"touch down: deviceId=${event.deviceId} source=0x${event.source.toString(16)} " +
"x=${event.x} y=${event.y}",
)
}
val isTemple = event.deviceId == templeDeviceId && templeDeviceId != -1
if (!isTemple) return super.dispatchTouchEvent(event)
Log.d(TAG, "temple: action=${event.actionMasked} x=${event.x} y=${event.y}")
templeGestureDetector.onTouchEvent(event)
return true
}
override fun onPause() {
AudioBridge.activityResumed = false
super.onPause()
@@ -142,6 +196,13 @@ class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
/**
* Имя input-устройства тач-сенсора дужки RayNeo X3 Pro (проверено через `getevent`:
* все касания дужки приходят на `/dev/input/event2`, устройство `cyttsp5_mt`).
* Второй контроллер `cyttsp6_mt` не генерирует событий.
*/
private const val TEMPLE_DEVICE_HINT = "cyttsp5_mt"
}
}